Files
rehearshalhub/api/src/rehearsalhub/routers/versions.py
Mistral Vibe d9035acdff feat: app shell with sidebar + bug fixes
UI:
- Add persistent sidebar (210px) with band switcher dropdown, Library/Player/Settings nav, user avatar row, and sign-out button
- Align design system CSS vars to CLAUDE.md spec (#0f0f12 bg, #e8a22a amber accent, rgba borders/text)
- Remove light mode toggle (no light mode in v1)
- Homepage auto-redirects to first band; shows create-band form only when no bands exist
- Strip full-page wrappers from all pages (shell owns layout)
- Remove debug console.log statements from SongPage

Bug fixes:
- nginx: trailing slash on `location ^~ /api/v1/bands/` caused 301 redirect on POST, dropping the request body — removed trailing slash
- API: _member_from_request (used by nc-scan stream) only accepted Bearer token, not httpOnly cookie — add rh_token cookie fallback
- API: internal_secret config field now has a dev default so the service starts without INTERNAL_SECRET env var set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:43:47 +02:00

272 lines
9.7 KiB
Python

import uuid
import asyncio
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import Response
from fastapi.security.utils import get_authorization_scheme_param
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.member import MemberRepository
from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.audio_version import AudioVersionCreate, AudioVersionRead
from rehearsalhub.services.auth import decode_token
from rehearsalhub.services.band import BandService
from rehearsalhub.services.song import SongService
from rehearsalhub.storage.nextcloud import NextcloudClient
router = APIRouter(tags=["versions"])
_AUDIO_CONTENT_TYPES: dict[str, str] = {
".mp3": "audio/mpeg",
".wav": "audio/wav",
".flac": "audio/flac",
".ogg": "audio/ogg",
".m4a": "audio/mp4",
".aac": "audio/aac",
".opus": "audio/ogg",
".m3u8": "application/vnd.apple.mpegurl",
}
async def _download_with_retry(storage: NextcloudClient, file_path: str, max_retries: int = 3) -> bytes:
"""Download file from Nextcloud with retry logic for transient errors."""
last_error = None
for attempt in range(max_retries):
try:
data = await storage.download(file_path)
return data
except httpx.ConnectError as e:
last_error = e
if attempt < max_retries - 1:
# Exponential backoff: 1s, 2s, 4s
wait_time = 2 ** attempt
await asyncio.sleep(wait_time)
continue
except httpx.HTTPStatusError as e:
# Don't retry on 4xx errors (client errors)
if e.response.status_code >= 500:
last_error = e
if attempt < max_retries - 1:
wait_time = 2 ** attempt
await asyncio.sleep(wait_time)
continue
else:
raise
except Exception as e:
last_error = e
break
# If we exhausted retries, raise the last error
if last_error:
raise last_error
else:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to download file from storage"
)
async def _member_from_request(
request: Request,
token: str | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> Member:
"""Resolve member from Authorization header, ?token= query param, or httpOnly cookie.
The cookie fallback allows fetch()-based callers (which send credentials:include)
to use the same endpoint as EventSource callers (which must use ?token=).
"""
auth_header = request.headers.get("Authorization")
if auth_header:
scheme, param = get_authorization_scheme_param(auth_header)
if scheme.lower() == "bearer":
token = param
if not token:
token = request.cookies.get("rh_token")
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token required")
try:
payload = decode_token(token)
member_id = uuid.UUID(payload["sub"])
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
member = await MemberRepository(session).get_by_id(member_id)
if member is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Member not found")
return member
async def _get_version_and_assert_band_membership(
version_id: uuid.UUID,
session: AsyncSession,
current_member: Member,
) -> tuple:
version_repo = AudioVersionRepository(session)
version = await version_repo.get_by_id(version_id)
if version is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found")
song_repo = SongRepository(session)
song = await song_repo.get_by_id(version.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 version, song
@router.get("/songs/{song_id}/versions", response_model=list[AudioVersionRead])
async def list_versions(
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_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, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
version_repo = AudioVersionRepository(session)
return [AudioVersionRead.model_validate(v) for v in await version_repo.list_for_song(song_id)]
@router.post(
"/songs/{song_id}/versions",
response_model=AudioVersionRead,
status_code=status.HTTP_201_CREATED,
)
async def create_version(
song_id: uuid.UUID,
data: AudioVersionCreate,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
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, 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)
version = await song_svc.register_version(song_id, data, current_member.id)
return AudioVersionRead.model_validate(version)
@router.get("/versions/{version_id}/waveform")
async def get_waveform(
version_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
) -> Any:
version, _ = await _get_version_and_assert_band_membership(version_id, session, current_member)
if not version.waveform_url:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waveform not ready")
storage = NextcloudClient.for_member(current_member)
if storage is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No storage provider configured for this account"
)
try:
data = await _download_with_retry(storage, version.waveform_url)
except httpx.ConnectError:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Storage service unavailable."
)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Waveform file not found in storage."
)
else:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Storage returned an error."
)
except Exception:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to fetch waveform."
)
import json
return json.loads(data)
@router.get("/versions/{version_id}/stream")
async def stream_version(
version_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(_member_from_request),
):
version, _ = await _get_version_and_assert_band_membership(version_id, session, current_member)
# Prefer HLS playlist if transcoding finished, otherwise serve the raw file
if version.cdn_hls_base:
file_path = f"{version.cdn_hls_base}/playlist.m3u8"
elif version.nc_file_path:
file_path = version.nc_file_path
else:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No audio file")
storage = NextcloudClient.for_member(current_member)
if storage is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No storage provider configured for this account"
)
try:
data = await _download_with_retry(storage, file_path)
except httpx.ConnectError:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Storage service unavailable."
)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found in storage."
)
else:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Storage returned an error."
)
except Exception:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to stream file."
)
content_type = _AUDIO_CONTENT_TYPES.get(Path(file_path).suffix.lower(), "application/octet-stream")
return Response(content=data, media_type=content_type)