Files
rehearshalhub/api/src/rehearsalhub/routers/songs.py
Mistral Vibe b2d6b4d113 Refactor storage to provider-agnostic band-scoped model
Replaces per-member Nextcloud credentials with a BandStorage model that
supports multiple providers. Credentials are Fernet-encrypted at rest;
worker receives audio via an internal streaming endpoint instead of
direct storage access.

- Add BandStorage DB model with partial unique index (one active per band)
- Add migrations 0007 (create band_storage) and 0008 (drop old nc columns)
- Add StorageFactory that builds the correct StorageClient from BandStorage
- Add storage router: connect/nextcloud, OAuth2 authorize/callback, list, disconnect
- Add Fernet encryption helpers in security/encryption.py
- Rewrite watcher for per-band polling via internal API config endpoint
- Update worker to stream audio from API instead of accessing storage directly
- Update frontend: new storage API in bands.ts, rewritten StorageSection,
  simplified band creation modal (no storage step)
- Add STORAGE_ENCRYPTION_KEY to all docker-compose files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 23:22:36 +02:00

323 lines
12 KiB
Python
Executable File

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)