146 lines
4.2 KiB
Python
146 lines
4.2 KiB
Python
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 = """
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Forbidden</title>
|
|
<style>
|
|
html, body {
|
|
height: 100%;
|
|
margin: 0;
|
|
background: #000;
|
|
color: #f5f5f5;
|
|
font-family: "Segoe UI", sans-serif;
|
|
}
|
|
body {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.skull {
|
|
font-size: 60vh;
|
|
line-height: 1;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="skull" aria-hidden="true">☠</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return HTMLResponse(content=html, status_code=403)
|