first commit
This commit is contained in:
145
app/main.py
Normal file
145
app/main.py
Normal file
@@ -0,0 +1,145 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user