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] = [] band_folder = band.nc_folder_path or f"bands/{band.slug}/" log.info("Starting NC scan for band '%s' in folder '%s'", band.slug, band_folder) try: items = await nc.list_folder(band_folder) except Exception as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Nextcloud unreachable: {exc}") log.info("Found %d top-level entries in '%s'", len(items), band_folder) # Collect (nc_file_path, nc_folder, song_title, rehearsal_label) tuples. # nc_folder is the directory that groups versions of the same song. # For YYMMDD / dated rehearsal subfolders each file is its own song — # the song title comes from the filename stem, not the folder name. to_import: list[tuple[str, str, str, str | None]] = [] for item in items: rel = relative(item.path) if rel.endswith("/"): dir_name = Path(rel.rstrip("/")).name try: sub_items = await nc.list_folder(rel) except Exception as exc: log.warning("Could not list subfolder '%s': %s", rel, exc) continue audio_files = [s for s in sub_items if Path(relative(s.path)).suffix.lower() in AUDIO_EXTENSIONS] log.info("Subfolder '%s': %d audio files found", dir_name, len(audio_files)) for sub in audio_files: sub_rel = relative(sub.path) song_title = Path(sub_rel).stem # Each file in a rehearsal folder is its own song, # grouped under its own sub-subfolder path for version tracking. song_folder = str(Path(sub_rel).parent) + "/" rehearsal_label = dir_name # e.g. "231015" or "2023-10-15" to_import.append((sub_rel, song_folder, song_title, rehearsal_label)) else: if Path(rel).suffix.lower() in AUDIO_EXTENSIONS: folder = str(Path(rel).parent) + "/" title = Path(rel).stem to_import.append((rel, folder, title, None)) log.info("NC scan: %d audio files to evaluate for import", len(to_import)) song_repo = SongRepository(session) from rehearsalhub.schemas.audio_version import AudioVersionCreate # noqa: PLC0415 for nc_file_path, nc_folder, song_title, rehearsal_label in to_import: # Skip if this exact file version is already registered try: meta = await nc.get_file_metadata(nc_file_path) etag = meta.etag except Exception as exc: log.warning("Could not fetch metadata for '%s': %s — skipping", nc_file_path, exc) continue if etag and await version_repo.get_by_etag(etag): log.debug("Skipping '%s' — etag already registered", nc_file_path) continue # Find or create song record 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: log.info("Creating new song '%s' (folder: %s)", song_title, nc_folder) song = await song_repo.create( band_id=band_id, title=song_title, status="jam", notes=f"Rehearsal: {rehearsal_label}" if rehearsal_label else None, nc_folder_path=nc_folder, created_by=current_member.id, ) else: log.info("Found existing song '%s' (id: %s)", song.title, song.id) 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) label_info = f" [rehearsal: {rehearsal_label}]" if rehearsal_label else "" log.info("Imported '%s' as song '%s'%s", nc_file_path, song_title, label_info) log.info("NC scan complete: %d new versions imported", len(imported)) 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)