""" app.py FastAPI‑Server für das Ausliefern von Musik‑ und Bilddateien inkl. Metadaten. """ from __future__ import annotations import base64 import json import logging import os import subprocess import urllib.parse from pathlib import Path from typing import List import uvicorn from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from mutagen import File as MutagenFile from dominant_color import get_dominant_color # --------------------------------------------------------------------------- # # --------------------------- Logging-Konfiguration ------------------------ # # --------------------------------------------------------------------------- # # Farben für die Konsole (nur für Development, bei Produktion meist deaktivieren) class AnsiColor: GREY = "\033[90m" BLUE = "\033[94m" YELLOW = "\033[93m" RED_BG = "\033[101m" RED = "\033[31m" RESET = "\033[0m" logging.basicConfig( level=logging.INFO, format="%(levelname)s %(message)s", handlers=[logging.StreamHandler()], ) # Schönere Level‑Bezeichnungen (optional) logging.addLevelName(logging.DEBUG, f"{AnsiColor.GREY}DEBUG{AnsiColor.RESET}") logging.addLevelName(logging.INFO, f"{AnsiColor.BLUE}INFO{AnsiColor.RESET}") logging.addLevelName(logging.WARNING, f"{AnsiColor.YELLOW}WARNING{AnsiColor.RESET}") logging.addLevelName(logging.ERROR, f"{AnsiColor.RED_BG}{AnsiColor.RED}ERROR{AnsiColor.RESET}") log = logging.getLogger(__name__) # --------------------------------------------------------------------------- # # ---------------------------- Konfiguration -------------------------------- # # --------------------------------------------------------------------------- # SERVER_HOST = "0.0.0.0" SERVER_PORT = 7860 BASE_URL = os.getenv("BASE_URL", "https://sebastiankay-my-ai-songs.hf.space/") GET_FILE_URL = f"{BASE_URL}getfile/" # Pfade STATIC_DIR = Path("static") TEMPLATE_DIR = Path("templates") MUSIC_DIR = Path("files/music") CACHE_FILE = Path("music_files.json") # --------------------------------------------------------------------------- # # ---------------------------- FastAPI‑App ---------------------------------- # # --------------------------------------------------------------------------- # app = FastAPI( title="Sebastians Music API", description="Ausliefern von Musikdateien, Cover‑Art und Metadaten.", version="1.0.0", ) # CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Statisches Verzeichnis (HTML/JS/CSS) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") # Für direkte Downloads aus dem music‑Ordner app.mount("/files/music", StaticFiles(directory=MUSIC_DIR), name="music") templates = Jinja2Templates(directory=TEMPLATE_DIR) # --------------------------------------------------------------------------- # # --------------------------- Hilfs‑Funktionen ------------------------------- # # --------------------------------------------------------------------------- # def _create_peaks_file(stem: str) -> None: """ Erstellt die *.peaks.json‑Datei mit dem externen Tool `audiowaveform`. Wir nutzen `subprocess.run` – das gibt uns besseres Fehler‑Handling als `subprocess.call`. """ input_path = MUSIC_DIR / f"{stem}.mp3" output_path = MUSIC_DIR / f"{stem}.peaks.json" if not input_path.is_file(): log.warning(f"Audio‑Datei nicht gefunden: {input_path}") return cmd = [ "audiowaveform", "-i", str(input_path), "-o", str(output_path), "-b", "8", ] try: subprocess.run(cmd, check=True, capture_output=True) log.info(f"Peaks‑File erzeugt: {output_path.name}") except subprocess.CalledProcessError as exc: log.error(f"Fehler beim Erzeugen von {output_path.name}: {exc.stderr.decode()}") def _extract_metadata(file_path: Path) -> dict | None: """ Liest Metadaten mit Mutagen und gibt ein Dictionary zurück. Rückgabe `None` bei komplettem Fehlschlag (z. B. kein unterstütztes Format). """ audio = MutagenFile(file_path) if audio is None: log.warning(f"Mutagen kann {file_path.name} nicht lesen.") return None # Track‑Nummer (z. B. "5/12") → int, fallback 0 track_raw = audio.get("TRCK", ["0"])[0].split("/")[0] track_number = int(track_raw) if track_raw.isdigit() else 0 # Grund‑Metadaten (mit sinnvollen Fallbacks) meta = { "encoded_title": base64.b64encode(file_path.name.encode()).decode(), "title": str(audio.get("TIT2", ["Unknown"])[0]), "artist": str(audio.get("TPE1", ["Unknown"])[0]), "genre": str(audio.get("TCON", ["Unknown"])[0]), "duration": int(audio.info.length) if getattr(audio, "info", None) else 0, "track_number": track_number, "audiofile": f"{GET_FILE_URL}{file_path.name}", } # Cover‑Art und Peaks‑File ergänzen (falls vorhanden) stem = file_path.stem cover_name = f"{stem}.coverart.png" peaks_name = f"{stem}.peaks.json" meta["coverart"] = f"{GET_FILE_URL}{urllib.parse.quote(cover_name)}" if (MUSIC_DIR / peaks_name).is_file(): meta["peaksfile"] = f"{BASE_URL}api/peaks/{urllib.parse.quote(stem)}" else: # Wenn das Peaks‑File fehlt, on‑the‑fly erzeugen _create_peaks_file(stem) if (MUSIC_DIR / peaks_name).is_file(): meta["peaksfile"] = f"{BASE_URL}api/peaks/{urllib.parse.quote(stem)}" else: meta["peaksfile"] = "" # Farben aus dem Cover‑Art bestimmen (falls vorhanden) cover_path = MUSIC_DIR / cover_name if cover_path.is_file(): try: # Wir holen vier Farben: 2 dominante + 2 komplementäre colors = get_dominant_color( cover_path, min_brightness=0.4, max_brightness=0.8, n_clusters=4, ) meta.update( { "dominant_color_1": ", ".join(map(str, colors[0])), "complementary_color_1": ", ".join(map(str, colors[1])), "dominant_color_2": ", ".join(map(str, colors[2])), "complementary_color_2": ", ".join(map(str, colors[3])), } ) except Exception as exc: log.error(f"Fehler beim Auswerten von {cover_name}: {exc}") else: log.debug(f"Kein Cover‑Art gefunden: {cover_name}") return meta def _load_or_build_cache() -> List[dict]: """ Lädt die JSON‑Cache‑Datei, wenn sie existiert, ansonsten wird sie neu gebaut. Der Cache reduziert das Laden von Metadaten bei jedem Request dramatisch. """ if CACHE_FILE.is_file(): log.info("Cache‑Datei gefunden → lade Metadaten") try: return json.loads(CACHE_FILE.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: log.warning(f"Cache ist korrupt: {exc} → neu bauen") CACHE_FILE.unlink(missing_ok=True) # ---- Neu bauen --------------------------------------------------------- log.info("Cache wird neu erzeugt …") music_data: List[dict] = [] for entry in MUSIC_DIR.iterdir(): if not entry.is_file(): continue # Ignorier‑Regeln (wie du sie hattest) if any( ( "_HIDETHIS" in entry.name, entry.suffix.lower() in {".json", ".png"}, ) ): log.debug(f"Ignoriere Datei: {entry.name}") continue meta = _extract_metadata(entry) if meta: music_data.append(meta) # Sortieren nach Track‑Nummer (absteigend) music_data.sort(key=lambda x: x["track_number"], reverse=True) # Cache schreiben CACHE_FILE.write_text(json.dumps(music_data, indent=4, ensure_ascii=False), encoding="utf-8") log.info("Cache geschrieben → %s Einträge", len(music_data)) return music_data # --------------------------------------------------------------------------- # # ----------------------------- API‑Endpoints -------------------------------- # # --------------------------------------------------------------------------- # @app.get("/", response_class=FileResponse, include_in_schema=False) async def serve_index(): """ Liefert das `index.html` aus dem static‑Verzeichnis. """ index_path = STATIC_DIR / "index.html" if not index_path.is_file(): raise HTTPException(status_code=404, detail="index.html fehlt") return FileResponse(index_path) @app.get("/{track_name}", response_class=FileResponse, include_in_schema=False) async def serve_spa(track_name: str): """ Für Single‑Page‑Applications: jede unbekannte Route liefert wieder das `index.html`. """ # Wir ignorieren den Parameter – das Frontend übernimmt das Routing. return await serve_index() @app.get("/getfile/{filename}") async def download_file(filename: str): """ Stellt jede Datei aus dem Musik‑Ordner zum Download bereit. Sicherheits‑Check verhindert Pfad‑Traversal. """ if ".." in filename or filename.startswith("/"): raise HTTPException(status_code=400, detail="Ungültiger Dateiname") file_path = MUSIC_DIR / filename if not file_path.is_file(): raise HTTPException(status_code=404, detail="Datei nicht gefunden") # Media‑Type ermitteln – kann später zu einem Mapping ausgelagert werden. mime = { ".wav": "audio/wav", ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".flac": "audio/flac", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", }.get(file_path.suffix.lower(), "application/octet-stream") return FileResponse(path=file_path, media_type=mime, filename=filename) @app.get("/api/peaks/{stem}") async def get_peaks(stem: str): """ Liefert das bereits generierte Peaks‑JSON. """ peaks_path = MUSIC_DIR / f"{stem}.peaks.json" if not peaks_path.is_file(): # Noch nicht vorhanden → versuchen wir, es on‑the‑fly zu erzeugen _create_peaks_file(stem) if not peaks_path.is_file(): raise HTTPException(status_code=404, detail="Peaks‑File nicht gefunden") try: data = json.loads(peaks_path.read_text(encoding="utf-8")) return JSONResponse(content=data.get("data", [])) except json.JSONDecodeError: raise HTTPException(status_code=500, detail="Ungültiges Peaks‑JSON") @app.get("/api/music") async def list_music(): """ Gibt die komplette Metadaten‑Liste zurück. """ return _load_or_build_cache() # --------------------------------------------------------------------------- # # ------------------------------- Startup ------------------------------------ # # --------------------------------------------------------------------------- # @app.on_event("startup") async def startup_event(): """ Beim Server‑Start den Cache (wenn nötig) bauen. """ # Nur einmal beim Start – danach bleibt das im Speicher. _load_or_build_cache() log.info("FastAPI‑Server bereit 🎉") # --------------------------------------------------------------------------- # # ------------------------------ Main‑Block --------------------------------- # # --------------------------------------------------------------------------- # if __name__ == "__main__": # Im Entwicklungs‑Modus alte Cache‑Datei entfernen (optional) if CACHE_FILE.is_file(): log.debug("Alte Cache‑Datei wird gelöscht") CACHE_FILE.unlink() # Server starten uvicorn.run( app, host=SERVER_HOST, port=SERVER_PORT, log_level="info", # reload=True, # <-- für lokales Entwickeln aktivieren )