From bd8f5bc12510206c29b07a4f5d113f1d1e25df9e Mon Sep 17 00:00:00 2001 From: Meik Date: Tue, 11 Nov 2025 10:24:08 +0100 Subject: [PATCH] first commit --- Dockerfile | 16 +++++ README.md | 0 app/main.py | 145 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 13 ++++ requirements.txt | 3 + 5 files changed, 177 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/main.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0f17bc5 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..ff5ae2b --- /dev/null +++ b/app/main.py @@ -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 = """ + + + + + Forbidden + + + + + + + """ + return HTMLResponse(content=html, status_code=403) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4bbe17c --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1827a5c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.6 +uvicorn[standard]==0.30.1 +mutagen==1.47.0