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, song = await _get_version_and_assert_band_membership(version_id, session, current_member) role = await BandRepository(session).get_member_role(song.band_id, current_member.id) # Debug logging for permission issues import logging log = logging.getLogger(__name__) log.info(f"User {current_member.id} accessing version {version_id}") log.info(f"Song band: {song.band_id}") log.info(f"User role in band: {role if role else 'NOT A 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)