Initial commit: RehearsalHub POC
Full-stack self-hosted band rehearsal platform: Backend (FastAPI + SQLAlchemy 2.0 async): - Auth with JWT (register, login, /me, settings) - Band management with Nextcloud folder integration - Song management with audio version tracking - Nextcloud scan to auto-import audio files - Band membership with link-based invite system - Song comments - Audio analysis worker (BPM, key, loudness, waveform) - Nextcloud activity watcher for auto-import - WebSocket support for real-time annotation updates - Alembic migrations (0001–0003) - Repository pattern, Ruff + mypy configured Frontend (React 18 + Vite + TypeScript strict): - Login/register page with post-login redirect - Home page with band list and creation form - Band page with member panel, invite link, song list, NC scan - Song page with waveform player, annotations, comment thread - Settings page for per-user Nextcloud credentials - Invite acceptance page (/invite/:token) - ESLint v9 flat config + TypeScript strict mode Infrastructure: - Docker Compose: PostgreSQL, Redis, API, worker, watcher, nginx - nginx reverse proxy for static files + /api/ proxy - make check runs all linters before docker compose build Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
240
api/src/rehearsalhub/routers/songs.py
Normal file
240
api/src/rehearsalhub/routers/songs.py
Normal file
@@ -0,0 +1,240 @@
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from rehearsalhub.db.engine import get_session
|
||||
from rehearsalhub.db.models import Member
|
||||
from rehearsalhub.dependencies import get_current_member
|
||||
from rehearsalhub.repositories.audio_version import AudioVersionRepository
|
||||
from rehearsalhub.repositories.band import BandRepository
|
||||
from rehearsalhub.repositories.comment import CommentRepository
|
||||
from rehearsalhub.repositories.song import SongRepository
|
||||
from rehearsalhub.schemas.comment import SongCommentCreate, SongCommentRead
|
||||
from rehearsalhub.schemas.song import SongCreate, SongRead
|
||||
from rehearsalhub.services.band import BandService
|
||||
from rehearsalhub.services.song import SongService
|
||||
from rehearsalhub.storage.nextcloud import NextcloudClient
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["songs"])
|
||||
|
||||
AUDIO_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus"}
|
||||
|
||||
|
||||
@router.get("/bands/{band_id}/songs", response_model=list[SongRead])
|
||||
async def list_songs(
|
||||
band_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_member: Member = Depends(get_current_member),
|
||||
):
|
||||
band_svc = BandService(session)
|
||||
try:
|
||||
await band_svc.assert_membership(band_id, current_member.id)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
||||
song_svc = SongService(session)
|
||||
return await song_svc.list_songs(band_id)
|
||||
|
||||
|
||||
@router.post("/bands/{band_id}/songs", response_model=SongRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_song(
|
||||
band_id: uuid.UUID,
|
||||
data: SongCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_member: Member = Depends(get_current_member),
|
||||
):
|
||||
band_svc = BandService(session)
|
||||
try:
|
||||
await band_svc.assert_membership(band_id, current_member.id)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
||||
|
||||
band_repo = BandRepository(session)
|
||||
band = await band_repo.get_by_id(band_id)
|
||||
if band is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found")
|
||||
|
||||
song_svc = SongService(session)
|
||||
song = await song_svc.create_song(band_id, data, current_member.id, band.slug, creator=current_member)
|
||||
read = SongRead.model_validate(song)
|
||||
read.version_count = 0
|
||||
return read
|
||||
|
||||
|
||||
@router.post("/bands/{band_id}/nc-scan", response_model=list[SongRead])
|
||||
async def scan_nextcloud(
|
||||
band_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_member: Member = Depends(get_current_member),
|
||||
):
|
||||
"""
|
||||
Scan the band's Nextcloud folder for audio files and import any not yet
|
||||
registered as songs/versions. Idempotent — safe to call multiple times.
|
||||
"""
|
||||
band_svc = BandService(session)
|
||||
try:
|
||||
await band_svc.assert_membership(band_id, current_member.id)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
||||
|
||||
band_repo = BandRepository(session)
|
||||
band = await band_repo.get_by_id(band_id)
|
||||
if band is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found")
|
||||
|
||||
nc = NextcloudClient.for_member(current_member)
|
||||
version_repo = AudioVersionRepository(session)
|
||||
song_svc = SongService(session)
|
||||
|
||||
# dav_prefix to strip full WebDAV hrefs → user-relative paths
|
||||
dav_prefix = f"/remote.php/dav/files/{nc._auth[0]}/"
|
||||
|
||||
def relative(href: str) -> str:
|
||||
if href.startswith(dav_prefix):
|
||||
return href[len(dav_prefix):]
|
||||
return href.lstrip("/")
|
||||
|
||||
imported: list[SongRead] = []
|
||||
|
||||
try:
|
||||
items = await nc.list_folder(band.nc_folder_path or f"bands/{band.slug}/")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Nextcloud unreachable: {exc}")
|
||||
|
||||
# Collect (nc_file_path, song_folder_rel, song_title) tuples
|
||||
to_import: list[tuple[str, str, str]] = []
|
||||
|
||||
for item in items:
|
||||
rel = relative(item.path)
|
||||
if rel.endswith("/"):
|
||||
# It's a subdirectory — scan one level deeper
|
||||
try:
|
||||
sub_items = await nc.list_folder(rel)
|
||||
except Exception:
|
||||
continue
|
||||
dir_name = Path(rel.rstrip("/")).name
|
||||
for sub in sub_items:
|
||||
sub_rel = relative(sub.path)
|
||||
if Path(sub_rel).suffix.lower() in AUDIO_EXTENSIONS:
|
||||
to_import.append((sub_rel, rel, dir_name))
|
||||
else:
|
||||
if Path(rel).suffix.lower() in AUDIO_EXTENSIONS:
|
||||
folder = str(Path(rel).parent) + "/"
|
||||
title = Path(rel).stem
|
||||
to_import.append((rel, folder, title))
|
||||
|
||||
for nc_file_path, nc_folder, song_title in to_import:
|
||||
# Skip if version already registered by etag
|
||||
try:
|
||||
meta = await nc.get_file_metadata(nc_file_path)
|
||||
etag = meta.etag
|
||||
except Exception:
|
||||
etag = None
|
||||
|
||||
if etag and await version_repo.get_by_etag(etag):
|
||||
continue
|
||||
|
||||
# Find or create song
|
||||
song_repo = SongRepository(session)
|
||||
song = await song_repo.get_by_nc_folder_path(nc_folder)
|
||||
if song is None:
|
||||
song = await song_repo.get_by_title_and_band(band_id, song_title)
|
||||
if song is None:
|
||||
song = await song_repo.create(
|
||||
band_id=band_id,
|
||||
title=song_title,
|
||||
status="jam",
|
||||
notes=None,
|
||||
nc_folder_path=nc_folder,
|
||||
created_by=current_member.id,
|
||||
)
|
||||
|
||||
from rehearsalhub.schemas.audio_version import AudioVersionCreate # noqa: PLC0415
|
||||
await song_svc.register_version(
|
||||
song.id,
|
||||
AudioVersionCreate(
|
||||
nc_file_path=nc_file_path,
|
||||
nc_file_etag=etag,
|
||||
format=Path(nc_file_path).suffix.lstrip(".").lower(),
|
||||
file_size_bytes=meta.size if etag else None,
|
||||
),
|
||||
current_member.id,
|
||||
)
|
||||
|
||||
read = SongRead.model_validate(song)
|
||||
read.version_count = 1
|
||||
imported.append(read)
|
||||
log.info("Imported %s as song '%s'", nc_file_path, song_title)
|
||||
|
||||
return imported
|
||||
|
||||
|
||||
# ── Comments ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _assert_song_membership(
|
||||
song_id: uuid.UUID, member_id: uuid.UUID, session: AsyncSession
|
||||
) -> None:
|
||||
song_repo = SongRepository(session)
|
||||
song = await song_repo.get_by_id(song_id)
|
||||
if song is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Song not found")
|
||||
band_svc = BandService(session)
|
||||
try:
|
||||
await band_svc.assert_membership(song.band_id, member_id)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
||||
|
||||
|
||||
@router.get("/songs/{song_id}/comments", response_model=list[SongCommentRead])
|
||||
async def list_comments(
|
||||
song_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_member: Member = Depends(get_current_member),
|
||||
):
|
||||
await _assert_song_membership(song_id, current_member.id, session)
|
||||
repo = CommentRepository(session)
|
||||
comments = await repo.list_for_song(song_id)
|
||||
return [SongCommentRead.from_model(c) for c in comments]
|
||||
|
||||
|
||||
@router.post("/songs/{song_id}/comments", response_model=SongCommentRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_comment(
|
||||
song_id: uuid.UUID,
|
||||
data: SongCommentCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_member: Member = Depends(get_current_member),
|
||||
):
|
||||
await _assert_song_membership(song_id, current_member.id, session)
|
||||
repo = CommentRepository(session)
|
||||
comment = await repo.create(song_id=song_id, author_id=current_member.id, body=data.body)
|
||||
comment = await repo.get_with_author(comment.id)
|
||||
return SongCommentRead.from_model(comment)
|
||||
|
||||
|
||||
@router.delete("/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_comment(
|
||||
comment_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_member: Member = Depends(get_current_member),
|
||||
):
|
||||
repo = CommentRepository(session)
|
||||
comment = await repo.get_with_author(comment_id)
|
||||
if comment is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||
|
||||
# Allow author or band admin
|
||||
if comment.author_id != current_member.id:
|
||||
song_repo = SongRepository(session)
|
||||
song = await song_repo.get_by_id(comment.song_id)
|
||||
band_svc = BandService(session)
|
||||
try:
|
||||
await band_svc.assert_admin(song.band_id, current_member.id) # type: ignore[union-attr]
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
|
||||
|
||||
await repo.delete(comment)
|
||||
Reference in New Issue
Block a user