import json import logging import uuid from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.responses import StreamingResponse from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from rehearsalhub.db.engine import get_session, get_session_factory from rehearsalhub.db.models import Member from rehearsalhub.dependencies import get_current_member from rehearsalhub.routers.versions import _member_from_request 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, SongUpdate from rehearsalhub.services.band import BandService from rehearsalhub.services.nc_scan import scan_band_folder 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"} class NcScanResult(BaseModel): folder: str files_found: int imported: int skipped: int songs: list[SongRead] @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") storage = NextcloudClient.for_member(current_member) song_svc = SongService(session, storage=storage) return await song_svc.list_songs(band_id) @router.get("/bands/{band_id}/songs/search", response_model=list[SongRead]) async def search_songs( band_id: uuid.UUID, q: str | None = Query(None, description="Title substring search"), tags: list[str] = Query(default=[], description="Songs must have ALL these tags"), key: str | None = Query(None, description="Musical key, e.g. 'Am' or 'C'"), bpm_min: float | None = Query(None, ge=0), bpm_max: float | None = Query(None, ge=0), session_id: uuid.UUID | None = Query(None), unattributed: bool = Query(False, description="Only songs with no rehearsal session"), 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_repo = SongRepository(session) songs = await song_repo.search( band_id, q=q, tags=tags or None, key=key, bpm_min=bpm_min, bpm_max=bpm_max, session_id=session_id, unattributed=unattributed, ) return [ SongRead.model_validate(s).model_copy(update={"version_count": len(s.versions)}) for s in songs ] @router.get("/songs/{song_id}", response_model=SongRead) async def get_song( song_id: uuid.UUID, session: AsyncSession = Depends(get_session), current_member: Member = Depends(get_current_member), ): song_repo = SongRepository(session) song = await song_repo.get_with_versions(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, current_member.id) except PermissionError: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member") return SongRead.model_validate(song).model_copy(update={"version_count": len(song.versions)}) @router.patch("/songs/{song_id}", response_model=SongRead) async def update_song( song_id: uuid.UUID, data: SongUpdate, session: AsyncSession = Depends(get_session), current_member: Member = Depends(get_current_member), ): song_repo = SongRepository(session) song = await song_repo.get_with_versions(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, current_member.id) except PermissionError: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member") updates = {k: v for k, v in data.model_dump().items() if v is not None} if updates: song = await song_repo.update(song, **updates) return SongRead.model_validate(song).model_copy(update={"version_count": len(song.versions)}) @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") storage = NextcloudClient.for_member(current_member) song_svc = SongService(session, storage=storage) 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 async def _get_band_and_assert_member( band_id: uuid.UUID, current_member: Member, session: AsyncSession, ): 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") return band @router.get("/bands/{band_id}/nc-scan/stream") async def scan_nextcloud_stream( band_id: uuid.UUID, session: AsyncSession = Depends(get_session), current_member: Member = Depends(_member_from_request), ): """ SSE endpoint: streams scan progress as newline-delimited JSON events. Each event is a JSON object on its own line. Accepts ?token= for EventSource clients that can't set headers. """ band = await _get_band_and_assert_member(band_id, current_member, session) band_folder = band.nc_folder_path or f"bands/{band.slug}/" nc = NextcloudClient.for_member(current_member) member_id = current_member.id async def event_generator(): async with get_session_factory()() as db: try: async for event in scan_band_folder(db, nc, band_id, band_folder, member_id): yield json.dumps(event) + "\n" if event.get("type") in ("song", "session"): await db.commit() except Exception: log.exception("SSE scan error for band %s", band_id) yield json.dumps({"type": "error", "message": "Scan failed due to an internal error."}) + "\n" finally: await db.commit() return StreamingResponse( event_generator(), media_type="application/x-ndjson", ) @router.post("/bands/{band_id}/nc-scan", response_model=NcScanResult) async def scan_nextcloud( band_id: uuid.UUID, session: AsyncSession = Depends(get_session), current_member: Member = Depends(get_current_member), ): """ Blocking scan — collects all results then returns. Delegates to scan_band_folder. Prefer the SSE /nc-scan/stream endpoint for large folders. """ band = await _get_band_and_assert_member(band_id, current_member, session) band_folder = band.nc_folder_path or f"bands/{band.slug}/" nc = NextcloudClient.for_member(current_member) songs: list[SongRead] = [] stats = {"found": 0, "imported": 0, "skipped": 0} async for event in scan_band_folder(session, nc, band_id, band_folder, current_member.id): if event["type"] == "song": songs.append(SongRead(**event["song"])) elif event["type"] == "done": stats = event["stats"] elif event["type"] == "error": raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=event["message"]) return NcScanResult( folder=band_folder, files_found=stats["found"], imported=stats["imported"], skipped=stats["skipped"], songs=songs, ) # ── 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, timestamp=data.timestamp, tag=data.tag) 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)