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>
272 lines
9.7 KiB
Python
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)
|