from __future__ import annotations import secrets import shutil from pathlib import Path from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse from sqlalchemy import delete, func, select, update from sqlalchemy.orm import Session from ..auth import require_session from ..database import get_db from ..models import AudioAsset, AudioClip, GameAssignment, PlaybackSession, UserSession from ..schemas import ( AudioAssetImportCreate, AudioAssetResponse, AudioAssetUpdate, AudioClipCreate, AudioClipResponse, AudioClipUpdate, AudioClipReorder, ) from ..storage import storage router = APIRouter(prefix="/media", tags=["media"]) DEFAULT_CLIP_LENGTH_MS = 30_000 def clip_to_response(clip: AudioClip) -> AudioClipResponse: normalized_url = f"/media/files/{clip.normalized_path}" if clip.normalized_path else None waveform = storage.load_or_generate_waveform(clip.asset.storage_path) return AudioClipResponse( id=clip.id, asset_id=clip.asset_id, external_team_id=clip.asset.external_team_id, owner_external_player_id=clip.asset.owner_external_player_id, asset_title=clip.asset.title, label=clip.label, start_ms=clip.start_ms, end_ms=clip.end_ms, sort_order=clip.sort_order, normalization_status=clip.normalization_status, normalized_url=normalized_url, waveform_duration_ms=waveform["duration_ms"] if waveform else None, waveform_peaks=waveform["peaks"] if waveform else None, created_at=clip.created_at, ) def can_manage_asset(session: UserSession, asset: AudioAsset, owner_external_player_id: str | None = None) -> bool: if session.is_admin or asset.uploaded_by_session_id == session.id: return True return owner_external_player_id is not None and asset.owner_external_player_id == owner_external_player_id def next_clip_sort_order(db: Session, *, external_team_id: str, owner_external_player_id: str) -> int: highest_sort_order = db.scalar( select(func.max(AudioClip.sort_order)) .join(AudioClip.asset) .where( AudioAsset.external_team_id == external_team_id, AudioAsset.owner_external_player_id == owner_external_player_id, ) ) return highest_sort_order + 1 if highest_sort_order is not None else 0 def create_asset_with_default_clip( *, db: Session, session: UserSession, external_team_id: str, owner_external_player_id: str, title: str, original_filename: str, mime_type: str, size_bytes: int, storage_path: str, ) -> AudioAssetResponse: asset = AudioAsset( external_team_id=external_team_id, owner_external_player_id=owner_external_player_id, uploaded_by_session_id=session.id, title=title, original_filename=original_filename, mime_type=mime_type, size_bytes=size_bytes, storage_path=storage_path, ) db.add(asset) db.flush() clip = AudioClip( asset_id=asset.id, label=asset.title, start_ms=0, end_ms=DEFAULT_CLIP_LENGTH_MS, sort_order=next_clip_sort_order( db, external_team_id=external_team_id, owner_external_player_id=owner_external_player_id, ), normalization_status="processing", ) db.add(clip) db.flush() normalized_name = f"clip-{clip.id}-{secrets.token_hex(6)}{Path(asset.storage_path).suffix or '.bin'}" clip.normalized_path = storage.normalize_clip(asset.storage_path, normalized_name) clip.normalization_status = "ready" storage.generate_waveform(asset.storage_path) db.commit() db.refresh(asset) return AudioAssetResponse.model_validate(asset, from_attributes=True) def download_media_to_storage(url: str) -> tuple[str, int, str, str]: try: from yt_dlp import YoutubeDL from yt_dlp.utils import DownloadError except ImportError as exc: # pragma: no cover - guarded by dependency install raise HTTPException(status_code=500, detail="yt-dlp is not installed") from exc storage_name = f"{secrets.token_hex(16)}.%(ext)s" outtmpl = str(storage.uploads_dir / storage_name) options = { "format": "bestaudio/best", "noplaylist": True, "quiet": True, "no_warnings": True, "outtmpl": outtmpl, "restrictfilenames": True, "extractor_args": {"youtube": {"player_client": ["android"]}}, } node_path = shutil.which("node") if node_path: options["js_runtimes"] = {"node": {"path": node_path}} try: with YoutubeDL(options) as ydl: info = ydl.extract_info(url, download=True) downloaded_path = Path(ydl.prepare_filename(info)) except DownloadError as exc: # pragma: no cover - exercised via HTTP behavior message = str(exc).strip() or "Could not download media from that URL" raise HTTPException(status_code=422, detail=f"Could not download media from that URL: {message}") from exc except Exception as exc: # pragma: no cover - exercised via HTTP behavior message = str(exc).strip() or "Could not download media from that URL" raise HTTPException(status_code=422, detail=f"Could not download media from that URL: {message}") from exc if not downloaded_path.exists(): raise HTTPException(status_code=502, detail="Downloaded file was not created") size_bytes = downloaded_path.stat().st_size original_filename = downloaded_path.name source_title = str(info.get("title") or downloaded_path.stem) return str(downloaded_path.relative_to(storage.root)), size_bytes, original_filename, source_title @router.post("/uploads", response_model=AudioAssetResponse) async def upload_audio( external_team_id: str = Form(...), owner_external_player_id: str = Form(...), title: str = Form(...), file: UploadFile = File(...), session: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> AudioAssetResponse: extension = Path(file.filename or "upload.bin").suffix or ".bin" storage_name = f"{secrets.token_hex(16)}{extension}" relative_path, size = storage.save_upload(file, storage_name) return create_asset_with_default_clip( db=db, session=session, external_team_id=external_team_id, owner_external_player_id=owner_external_player_id, title=title, original_filename=file.filename or storage_name, mime_type=file.content_type or "application/octet-stream", size_bytes=size, storage_path=relative_path, ) @router.post("/imports", response_model=AudioAssetResponse) def import_audio( payload: AudioAssetImportCreate, session: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> AudioAssetResponse: relative_path, size_bytes, original_filename, source_title = download_media_to_storage(payload.url) title = payload.title.strip() if payload.title else "" if not title: title = source_title return create_asset_with_default_clip( db=db, session=session, external_team_id=payload.external_team_id, owner_external_player_id=payload.owner_external_player_id, title=title, original_filename=original_filename, mime_type="application/octet-stream", size_bytes=size_bytes, storage_path=relative_path, ) @router.get("/assets", response_model=list[AudioAssetResponse]) def list_assets( external_team_id: str, owner_external_player_id: str | None = None, _: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> list[AudioAssetResponse]: query = select(AudioAsset).where(AudioAsset.external_team_id == external_team_id) if owner_external_player_id: query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id) assets = db.scalars(query.order_by(AudioAsset.created_at.desc())).all() return [AudioAssetResponse.model_validate(asset, from_attributes=True) for asset in assets] @router.delete("/assets/{asset_id}", status_code=204) def delete_asset( asset_id: int, owner_external_player_id: str | None = None, session: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> None: asset = db.get(AudioAsset, asset_id) if asset is None: raise HTTPException(status_code=404, detail="Asset not found") if not can_manage_asset(session, asset, owner_external_player_id): raise HTTPException(status_code=403, detail="You can only delete your own uploads") clips = db.scalars(select(AudioClip).where(AudioClip.asset_id == asset.id)).all() clip_ids = [clip.id for clip in clips] if clip_ids: assignment_ids = db.scalars(select(GameAssignment.id).where(GameAssignment.clip_id.in_(clip_ids))).all() db.execute(delete(GameAssignment).where(GameAssignment.clip_id.in_(clip_ids))) if assignment_ids: db.execute( update(PlaybackSession) .where(PlaybackSession.current_assignment_id.in_(assignment_ids)) .values(current_assignment_id=None) ) for clip in clips: if clip.normalized_path: storage.delete_relative_path(clip.normalized_path) db.delete(clip) storage.delete_relative_path(asset.storage_path) db.delete(asset) db.commit() @router.patch("/assets/{asset_id}", response_model=AudioAssetResponse) def update_asset( asset_id: int, payload: AudioAssetUpdate, owner_external_player_id: str | None = None, session: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> AudioAssetResponse: asset = db.get(AudioAsset, asset_id) if asset is None: raise HTTPException(status_code=404, detail="Asset not found") if not can_manage_asset(session, asset, owner_external_player_id): raise HTTPException(status_code=403, detail="You can only update your own uploads") title = payload.title.strip() if not title: raise HTTPException(status_code=422, detail="File name cannot be blank") asset.title = title db.commit() db.refresh(asset) return AudioAssetResponse.model_validate(asset, from_attributes=True) @router.post("/clips", response_model=AudioClipResponse) def create_clip( payload: AudioClipCreate, _: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> AudioClipResponse: asset = db.get(AudioAsset, payload.asset_id) if asset is None: raise HTTPException(status_code=404, detail="Asset not found") if asset.external_team_id != payload.external_team_id: raise HTTPException(status_code=422, detail="Clip does not belong to this team") if asset.owner_external_player_id != payload.owner_external_player_id: raise HTTPException(status_code=403, detail="You can only create clips for that player") if payload.end_ms <= payload.start_ms: raise HTTPException(status_code=422, detail="Clip end must be greater than start") clip = AudioClip( asset_id=asset.id, label=payload.label, start_ms=payload.start_ms, end_ms=payload.end_ms, sort_order=next_clip_sort_order( db, external_team_id=payload.external_team_id, owner_external_player_id=payload.owner_external_player_id, ), normalization_status="processing", ) db.add(clip) db.flush() normalized_name = f"clip-{clip.id}-{secrets.token_hex(6)}{Path(asset.storage_path).suffix or '.bin'}" clip.normalized_path = storage.normalize_clip(asset.storage_path, normalized_name) clip.normalization_status = "ready" db.commit() db.refresh(clip) return clip_to_response(clip) @router.patch("/clips/{clip_id}", response_model=AudioClipResponse) def update_clip( clip_id: int, payload: AudioClipUpdate, owner_external_player_id: str | None = None, session: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> AudioClipResponse: clip = db.get(AudioClip, clip_id) if clip is None: raise HTTPException(status_code=404, detail="Clip not found") if not can_manage_asset(session, clip.asset, owner_external_player_id): raise HTTPException(status_code=403, detail="You can only update clips from your own uploads") if payload.end_ms <= payload.start_ms: raise HTTPException(status_code=422, detail="Clip end must be greater than start") clip.label = payload.label or clip.label clip.start_ms = payload.start_ms clip.end_ms = payload.end_ms if payload.sort_order is not None: clip.sort_order = payload.sort_order db.commit() db.refresh(clip) return clip_to_response(clip) @router.delete("/clips/{clip_id}", status_code=204) def delete_clip( clip_id: int, owner_external_player_id: str | None = None, session: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> None: clip = db.get(AudioClip, clip_id) if clip is None: raise HTTPException(status_code=404, detail="Clip not found") if not can_manage_asset(session, clip.asset, owner_external_player_id): raise HTTPException(status_code=403, detail="You can only delete clips from your own uploads") assignment_ids = db.scalars(select(GameAssignment.id).where(GameAssignment.clip_id == clip.id)).all() db.execute(delete(GameAssignment).where(GameAssignment.clip_id == clip.id)) if assignment_ids: db.execute( update(PlaybackSession) .where(PlaybackSession.current_assignment_id.in_(assignment_ids)) .values(current_assignment_id=None) ) if clip.normalized_path: storage.delete_relative_path(clip.normalized_path) db.delete(clip) db.commit() @router.get("/clips", response_model=list[AudioClipResponse]) def list_clips( external_team_id: str, owner_external_player_id: str | None = None, _: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> list[AudioClipResponse]: query = ( select(AudioClip) .join(AudioClip.asset) .where(AudioAsset.external_team_id == external_team_id) .order_by(AudioClip.sort_order.asc(), AudioClip.created_at.desc()) ) if owner_external_player_id: query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id) clips = db.scalars(query).all() return [clip_to_response(clip) for clip in clips] @router.post("/clips/reorder", status_code=204) def reorder_clips( payload: AudioClipReorder, session: UserSession = Depends(require_session), db: Session = Depends(get_db), ) -> None: if not session.is_admin and session.external_team_id != payload.external_team_id: raise HTTPException(status_code=403, detail="You can only reorder clips for your selected team") clips = db.scalars( select(AudioClip) .join(AudioClip.asset) .where( AudioAsset.external_team_id == payload.external_team_id, AudioAsset.owner_external_player_id == payload.owner_external_player_id, ) ).all() clips_by_id = {clip.id: clip for clip in clips} if len(clips_by_id) != len(clips) or set(clips_by_id) != set(payload.clip_ids): raise HTTPException(status_code=422, detail="Clip order must include every clip for that player") for sort_order, clip_id in enumerate(payload.clip_ids): clips_by_id[clip_id].sort_order = sort_order db.commit() @router.get("/files/{relative_path:path}") def media_file(relative_path: str) -> FileResponse: path = storage.absolute_path(relative_path) if not path.exists(): raise HTTPException(status_code=404, detail="File not found") return FileResponse(path)