from __future__ import annotations import os from pathlib import Path from typing import Iterable from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse try: from mutagen import File as MutagenFile # type: ignore except Exception as exc: # pragma: no cover raise RuntimeError("mutagen package is required to read audio metadata") from exc app = FastAPI(title="Halloween Sound API") SOUNDS_ROOT = Path(os.getenv("SOUNDS_ROOT", "/app/sounds")) app.mount("/media", StaticFiles(directory=SOUNDS_ROOT, check_dir=False), name="media") class SoundInfo(BaseModel): path: str duration_seconds: int duration_milliseconds: int duration_human: str def format_duration(duration_ms: int) -> str: seconds, milliseconds = divmod(duration_ms, 1000) minutes, seconds = divmod(seconds, 60) hours, minutes = divmod(minutes, 60) parts: list[str] = [] if hours: parts.append(f"{hours}h") if minutes: parts.append(f"{minutes}m") if seconds or not parts: if milliseconds: parts.append(f"{seconds}.{milliseconds:03d}s") else: parts.append(f"{seconds}s") elif milliseconds: parts.append(f"0.{milliseconds:03d}s") return " ".join(parts) def iter_sound_files(directory: Path) -> Iterable[Path]: """Yield files in a directory recursively (ignores missing directories).""" if not directory.exists(): return [] return (path for path in directory.rglob("*") if path.is_file()) def get_sound_info(path: Path, request: Request) -> SoundInfo | None: """Use mutagen to extract duration; skip unsupported files.""" metadata = MutagenFile(path) if not metadata or not getattr(metadata, "info", None): return None duration = getattr(metadata.info, "length", None) if duration is None: return None duration_ms = int(duration * 1000) duration_seconds = duration_ms // 1000 relative_path = path.relative_to(SOUNDS_ROOT) media_path = str(relative_path).replace("\\", "/") url = str(request.url_for("media", path=media_path)) return SoundInfo( path=url, duration_seconds=duration_seconds, duration_milliseconds=duration_ms, duration_human=format_duration(duration_ms), ) def collect_sounds(category: str, *, allow_missing: bool, request: Request) -> list[SoundInfo]: category_path = SOUNDS_ROOT / category if not category_path.exists(): if allow_missing: return [] raise HTTPException(status_code=404, detail=f"Category '{category}' not found") sounds: list[SoundInfo] = [] for file_path in iter_sound_files(category_path): info = get_sound_info(file_path, request) if info: sounds.append(info) return sounds @app.get("/sounds/{category}", response_model=list[SoundInfo]) def sounds_by_category(category: str, request: Request) -> list[SoundInfo]: return collect_sounds(category, allow_missing=False, request=request) @app.get("/sounds", response_model=dict[str, list[SoundInfo]]) def all_sounds(request: Request) -> dict[str, list[SoundInfo]]: return { "loop": collect_sounds("loop", allow_missing=True, request=request), "scare": collect_sounds("scare", allow_missing=True, request=request), } @app.get("/", status_code=403, response_class=HTMLResponse) def root() -> HTMLResponse: html = """