import json import logging import uuid 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.config import get_settings from rehearsalhub.db.engine import get_session, get_session_factory from rehearsalhub.queue.redis_queue import flush_pending_pushes from rehearsalhub.db.models import Member from rehearsalhub.dependencies import get_current_member from rehearsalhub.repositories.band import BandRepository from rehearsalhub.repositories.band_storage import BandStorageRepository from rehearsalhub.repositories.comment import CommentRepository from rehearsalhub.repositories.song import SongRepository from rehearsalhub.routers.versions import _member_from_request 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.factory import StorageFactory 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") song_svc = SongService(session) 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") song_svc = SongService(session) song = await song_svc.create_song(band_id, data, current_member.id, band.slug) 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) bs = await BandStorageRepository(session).get_active_for_band(band_id) band_folder = (bs.root_path if bs and bs.root_path else None) or f"bands/{band.slug}/" member_id = current_member.id settings = get_settings() async def event_generator(): async with get_session_factory()() as db: try: storage = await StorageFactory.create(db, band_id, settings) async for event in scan_band_folder(db, storage, band_id, band_folder, member_id): yield json.dumps(event) + "\n" if event.get("type") in ("song", "session"): await db.commit() await flush_pending_pushes(db) except LookupError as exc: yield json.dumps({"type": "error", "message": str(exc)}) + "\n" 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() await flush_pending_pushes(db) 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) bs = await BandStorageRepository(session).get_active_for_band(band_id) band_folder = (bs.root_path if bs and bs.root_path else None) or f"bands/{band.slug}/" try: storage = await StorageFactory.create(session, band_id, get_settings()) except LookupError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) songs: list[SongRead] = [] stats = {"found": 0, "imported": 0, "skipped": 0} async for event in scan_band_folder(session, storage, 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)