first commit

This commit is contained in:
2025-11-11 10:24:08 +01:00
commit bd8f5bc125
5 changed files with 177 additions and 0 deletions

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
SOUNDS_ROOT=/app/sounds
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 80
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]

0
README.md Normal file
View File

145
app/main.py Normal file
View 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)

13
docker-compose.yml Normal file
View File

@@ -0,0 +1,13 @@
services:
halloween-sound-api:
build: .
ports:
- "8282:80"
volumes:
- /opt/docker/halloween/sounds:/app/sounds:ro
environment:
- SOUNDS_ROOT=/app/sounds
- WATCHTOWER_DISABLE=true
restart: unless-stopped
labels:
- com.centurylinklabs.watchtower.enable=false

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
fastapi==0.115.6
uvicorn[standard]==0.30.1
mutagen==1.47.0