Files
rehearshalhub/api/src/rehearsalhub/routers/songs.py
2026-04-08 15:10:52 +02:00

312 lines
12 KiB
Python
Executable File

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)