312 lines
12 KiB
Python
Executable File
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)
|