From 11597eb00c1d09cb2f57b718889b5b803a8e3709 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sat, 25 Jul 2026 21:41:48 +0200 Subject: [PATCH] perf+cleanup(api): /capabilities war 1,0 s; vier tote Endpunkte entfernt Beides in main.py, deshalb ein Commit. ## 1. Das "Laggen" hatte genau eine Ursache Gemessen ueber alle 15 Endpunkte, die das UI beim Laden braucht: /capabilities 1,010 s /system/updates 0,491 s (haengt am Knopf, nicht am Seitenaufbau) /metadata/status 0,412 s (dito) die anderen 12 < 0,025 s /capabilities ist der einzige langsame, der beim SEITENAUFBAU zuschlaegt - und fuenf Stellen holen ihn (Dashboard, Einstellungen, Worker-Tab, Wizard, Rip-Dialog). Jede Seite zahlte eine Sekunde. Die Ursache ist kein Fehler, sondern das Wesen des Celery-Pings: er sammelt Antworten bis zum Timeout und kann nicht frueher aufhoeren, weil er nicht weiss, wie viele Worker noch antworten wollen. Den Timeout zu kuerzen wuerde Antworten langsamer Remote-Worker verschlucken - also genau die Maschinen, um die es beim externen Encoding geht. Jetzt pingt eine Hintergrund-Schleife im 5-s-Takt (neben Disc-Watcher und Key-Refresh, die es dort schon gibt), der Endpunkt liest nur ab. Vorrat aelter als 30 s - Schleife noch nicht angelaufen oder gestorben - dann EINMAL synchron pingen: lieber langsam als falsch ("alles offline", obwohl alles laeuft). ## 2. Vier tote Endpunkte raus Jeder ein Ueberrest eines ersetzten Entwurfs, keiner mit Aufrufer (mechanisch gegengeprueft: alle api.*-Aufrufe des UI gegen alle Routen): POST /prescan Metadaten-Vorschau-Seite ist seit v3.4 weg. Die PreScan-Klasse bleibt - sie hat 5 echte Fundstellen, der Watcher ruft sie im Prozess. POST /jellyfin/format Macht seit v3.2 der Worker (medien.py), und zwar an der richtigen Stelle: er kennt den Ausgabeordner und ist nach dem Rip am Zug. Mit ihm fallen nfo_generator.py und image_downloader.py weg (sonst unbenutzt). GET /stream/jobs Der unangenehmste: erst Placebo, am 23.07. "repariert" statt entfernt - aber ein EventSource im UI gab es nie (das Dashboard nutzt setInterval(..., 4000)). Also keine harmlose Leiche, sondern eine Endlosschleife je Verbindung, die jeder aufmachen konnte. GET /worker-setup/windows-gui Ohne Aufrufer seit die .exe den .bat-Umweg ersetzt hat (v3.9). install-gui.ps1 selbst lebt weiter, sie steckt in der .exe. main.py: 1726 -> 1682 Zeilen, dazu 279 Zeilen in zwei geloeschten Modulen. Tests halten beide Seiten fest: die vier Routen muessen WEG bleiben, und die drei, an denen die Worker-Installation haengt (/worker-setup/paket, /windows, /windows-exe), muessen DA sein. Ausserdem eine Doppelung entfernt - mein eigener _sicherer_dateiname-Test aus dem Vorcommit pruefte dasselbe wie der bestehende test_dateiname_validierung_blockt_pfad_tricks, und der war die ganze Zeit korrekt. Co-Authored-By: Claude Opus 5 --- docker/api/image_downloader.py | 110 ------------------ docker/api/main.py | 206 +++++++++++++-------------------- docker/api/nfo_generator.py | 169 --------------------------- docker/api/test_api_smoke.py | 83 +++++++++++-- 4 files changed, 153 insertions(+), 415 deletions(-) delete mode 100644 docker/api/image_downloader.py delete mode 100644 docker/api/nfo_generator.py diff --git a/docker/api/image_downloader.py b/docker/api/image_downloader.py deleted file mode 100644 index 106d613..0000000 --- a/docker/api/image_downloader.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Jellyfin Image Downloader.""" - -import requests -from pathlib import Path -from typing import Dict, Optional - -from clients.tmdb import TMDBClient -from clients.thetvdb import TheTVDBClient - - -class ImageDownloader: - def __init__(self): - self.tmdb = TMDBClient() - self.thetvdb = TheTVDBClient() - self.base_url = "https://image.tmdb.org/t/p" - - def download_image(self, url: str, output_path: Path, width: int = 500) -> bool: - """Lade Image herunter.""" - try: - # TMDB URL anpassen - if url.startswith("https://image.tmdb.org"): - # Konvertiere zu gewünschter Größe - path = url.replace(f"{self.base_url}/", "") - url = f"{self.base_url}/w{width}/{path}" - - response = requests.get(url, timeout=30) - response.raise_for_status() - - output_path.parent.mkdir(parents=True, exist_ok=True) - with open(output_path, 'wb') as f: - f.write(response.content) - - return True - except Exception as e: - print(f"Image download error: {e}") - return False - - def download_poster(self, title: str, output_dir: Path, width: int = 500) -> Optional[Path]: - """Lade Poster herunter.""" - # TMDB Search - movies = self.tmdb.search_movie(title) - if movies: - movie = movies[0] - images = self.tmdb.get_movie_images(movie["id"]) - - if images.get("poster"): - output_path = output_dir / "poster.jpg" - if self.download_image(images["poster"], output_path, width): - return output_path - - return None - - def download_fanart(self, title: str, output_dir: Path, width: int = 1920) -> Optional[Path]: - """Lade Fanart herunter.""" - # TMDB Search - movies = self.tmdb.search_movie(title) - if movies: - movie = movies[0] - images = self.tmdb.get_movie_images(movie["id"]) - - if images.get("fanart"): - output_path = output_dir / "fanart.jpg" - if self.download_image(images["fanart"], output_path, width): - return output_path - - return None - - def download_series_images(self, title: str, output_dir: Path) -> Dict[str, Optional[Path]]: - """Lade Serien-Poster und Fanart herunter.""" - result = { - "poster": None, - "fanart": None - } - - tv_shows = self.tmdb.search_tv(title) - if tv_shows: - tv = tv_shows[0] - images = self.tmdb.get_tv_images(tv["id"]) - - if images.get("poster"): - output_path = output_dir / "poster.jpg" - if self.download_image(images["poster"], output_path, 500): - result["poster"] = output_path - - if images.get("fanart"): - output_path = output_dir / "fanart.jpg" - if self.download_image(images["fanart"], output_path, 1920): - result["fanart"] = output_path - - return result - - def download_music_images(self, artist: str, album: str, output_dir: Path) -> Dict[str, Optional[Path]]: - """Lade Musik-Album-Cover herunter (via TMDB als Fallback).""" - result = { - "album": None - } - - # TMDB Search für Soundtracks - query = f"{album} soundtrack" - movies = self.tmdb.search_movie(query) - if movies: - movie = movies[0] - images = self.tmdb.get_movie_images(movie["id"]) - - if images.get("poster"): - output_path = output_dir / "album.jpg" - if self.download_image(images["poster"], output_path, 500): - result["album"] = output_path - - return result diff --git a/docker/api/main.py b/docker/api/main.py index 9efa850..c7f6605 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -1,13 +1,13 @@ from fastapi import FastAPI, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, StreamingResponse +from fastapi.responses import FileResponse from pydantic import BaseModel from typing import List, Optional, Dict -from pathlib import Path import asyncio import json import os import shutil +import time import uuid import db @@ -23,8 +23,6 @@ from config_validation import validate_config, ConfigValidationError from cache import init_cache from ratelimit import check_rate_limit, get_rate_limit_remaining from prescan import PreScan -from nfo_generator import NFOGenerator -from image_downloader import ImageDownloader # Auth (JWT/Login/API-Keys) KOMPLETT entfernt — Commander-Entscheid 24.07.2026: # Rippy läuft ausschließlich im Heimnetz, die Endpoints schützten ohnehin @@ -64,6 +62,9 @@ async def startup_event(): # Monatsende ab) — sonst blockt Blu-ray-Ripping irgendwann still. Taeglicher # Forum-Abgleich; wirkt ohne Rebuild ab dem naechsten Rip. asyncio.create_task(makemkv_key.refresh_loop()) + # Worker-Erreichbarkeit im Hintergrund pingen (siehe _ping_knoten) — sonst + # kostet JEDER Aufruf von /capabilities eine ganze Sekunde. + asyncio.create_task(_ping_schleife()) # Auto-Pre-Scan-Ergebnisse je Laufwerk: das Dashboard zeigt damit sofort, @@ -688,6 +689,55 @@ async def cancel_job(job_id: str): return {"id": job_id, "status": "canceling"} +# --- Worker-Erreichbarkeit: gepingt wird im Hintergrund, nicht im Request --- +# +# Befund 25.07.2026 (gemessen): /capabilities brauchte **1,010 s** — und zwar +# jedes Mal. Ursache ist kein Fehler, sondern das Wesen des Celery-Pings: er +# sammelt Antworten bis zum Timeout und kann nicht früher aufhören, weil er +# nicht weiß, wie viele Worker noch antworten wollen. Fünf UI-Stellen holen +# /capabilities (Dashboard, Einstellungen, Worker-Tab, Wizard, Rip-Dialog) — +# jede Seite zahlte also eine Sekunde, obwohl alle anderen Endpunkte unter +# 25 ms liegen. Genau das war das „Laggen". +# +# Jetzt pingt ein Hintergrund-Lauf im festen Takt, und der Endpunkt liest nur +# ab. Ist der Vorrat älter als PING_ALTER_MAX (Lauf noch nicht angelaufen oder +# gestorben), wird EINMAL synchron gepingt und der Vorrat wieder gefüllt — +# lieber eine langsame Antwort als eine falsche. +_PING = {"knoten": [], "stand": -1e9} +PING_INTERVALL_SEKUNDEN = 5 +PING_ALTER_MAX_SEKUNDEN = 30 + + +def _ping_jetzt() -> list: + """Pingt sofort (blockiert ~1 s) und füllt den Vorrat.""" + try: + antworten = celery_client.control.ping(timeout=1.0) or [] + knoten = [k for antwort in antworten for k in antwort.keys()] + except Exception: + knoten = [] + _PING["knoten"] = knoten + _PING["stand"] = time.monotonic() + return knoten + + +def _ping_knoten() -> list: + """Erreichbare Celery-Knoten aus dem Vorrat — ohne zu warten.""" + if time.monotonic() - _PING["stand"] <= PING_ALTER_MAX_SEKUNDEN: + return _PING["knoten"] + return _ping_jetzt() + + +async def _ping_schleife(): + """Hält den Ping-Vorrat frisch. Darf nie sterben, sonst wird jeder + /capabilities-Aufruf wieder langsam.""" + while True: + try: + await asyncio.to_thread(_ping_jetzt) + except Exception: # Broker weg → beim nächsten Durchlauf erneut + pass + await asyncio.sleep(PING_INTERVALL_SEKUNDEN) + + @app.get("/capabilities") async def capabilities(): """Welche Encoder sind auf welchen Workern WIRKLICH verfügbar — inkl. @@ -698,11 +748,7 @@ async def capabilities(): """ def sammle(): zeilen = db.list_workers() - try: - antworten = celery_client.control.ping(timeout=1.0) or [] - ping_knoten = [k for antwort in antworten for k in antwort.keys()] - except Exception: - ping_knoten = [] + ping_knoten = _ping_knoten() for zeile in zeilen: # Celery-Knotenname = @. Der Ping liefert ihn voll; # gematcht wird über den Hostname (der Docker-Worker heißt celery@…, @@ -1133,14 +1179,10 @@ async def worker_setup_windows(): return FileResponse(pfad, media_type="text/plain", filename="install-rippy-worker.ps1") -@app.get("/worker-setup/windows-gui") -async def worker_setup_windows_gui(): - """Grafischer Windows-Installer (WinForms, PowerShell-Quelle) — Rückfall - für Fortgeschrittene; der Normalweg ist die .exe unten.""" - pfad = "worker_dist/install-gui.ps1" - if not os.path.isfile(pfad): - raise HTTPException(status_code=404, detail="GUI-Installer nicht im Image — API neu bauen") - return FileResponse(pfad, media_type="text/plain", filename="rippy-worker-gui.ps1") +# GET /worker-setup/windows-gui entfernt am 25.07.2026: ohne Aufrufer, seit die +# .exe den .bat-Umweg ersetzt hat (v3.9 — .vbs/.bat wird als gefährlich +# geflaggt, Commander-Einwand). Die Datei install-gui.ps1 selbst lebt weiter, +# sie steckt in der .exe; nur diese Route war verwaist. @app.get("/worker-setup/windows-exe") @@ -1500,21 +1542,13 @@ async def get_devices(): return geraete -# SSE-Stream für Echtzeit-Updates -@app.get("/stream/jobs") -async def job_stream(): - """SSE-Stream für Job-Updates. - - Fix 23.07.: Der alte Generator sendete nur, wenn `sse_connections` gefüllt - war — aber NICHTS hat diese Liste je befüllt. Der Stream war ein Placebo. - """ - async def event_generator(): - while True: - jobs = await get_jobs() - yield f"data: {json.dumps([j.dict() for j in jobs])}\n\n" - await asyncio.sleep(2) - - return StreamingResponse(event_generator(), media_type="text/event-stream") +# GET /stream/jobs (SSE) entfernt am 25.07.2026. Der Stream war zweimal falsch: +# erst ein Placebo (er sendete nur, wenn eine Liste `sse_connections` gefüllt +# war, und nichts füllte sie je), dann am 23.07. funktionsfähig gemacht — aber +# einen Verbraucher hat er nie bekommen. Im UI gibt es kein `EventSource`; das +# Dashboard holt die Jobs mit `setInterval(loadData, 4000)`. Damit war er keine +# harmlose Leiche, sondern eine Endlosschleife je Verbindung, die jeder im +# Heimnetz aufmachen konnte. Wer echtes Push will, braucht BEIDE Seiten. # /metadata/lookup + /metadata/confirm entfernt (24.07., mit der @@ -1627,99 +1661,21 @@ async def metadata_override(request: MetadataOverride): return ergebnis -# Pre-Scan Endpoint -class PreScanRequest(BaseModel): - device_path: str - - -@app.post("/prescan") -async def run_prescan(request: PreScanRequest): - """Führe Pre-Scan durch.""" - try: - prescan = PreScan() - result = prescan.scan(request.device_path) - return result.to_dict() - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -# Jellyfin-Formatierung Endpoints -class JellyfinFormatRequest(BaseModel): - title: str - year: Optional[int] - metadata: Dict - disc_type: str - output_dir: str - - -@app.post("/jellyfin/format") -async def jellyfin_format(request: JellyfinFormatRequest): - """Formatiere für Jellyfin (NFO + Images).""" - try: - nfo_gen = NFOGenerator() - img_downloader = ImageDownloader() - - # Ordnerstruktur erstellen - output_path = Path(request.output_dir) - - if request.disc_type in ["dvd", "bluray"]: - # Film-Formatierung - title = request.metadata.get("title", request.title) - year = request.year or request.metadata.get("year") - - # movie.nfo - movie_nfo = nfo_gen.generate_movie_nfo( - title=title, - year=year or 2000, - overview=request.metadata.get("overview", ""), - rating=request.metadata.get("rating", 0), - runtime=request.metadata.get("runtime", 0), - genres=request.metadata.get("genres", []), - director=request.metadata.get("director", ""), - actors=request.metadata.get("actors", []) - ) - - nfo_path = output_path / "movie.nfo" - nfo_gen.save_nfo(movie_nfo, nfo_path) - - # Poster und Fanart - img_downloader.download_poster(title, output_path, 500) - img_downloader.download_fanart(title, output_path, 1920) - - return { - "status": "formatted", - "nfo_path": str(nfo_path), - "poster_path": str(output_path / "poster.jpg"), - "fanart_path": str(output_path / "fanart.jpg") - } - else: - # Audio-Formatierung - artist = request.metadata.get("artist", "Unknown Artist") - album = request.metadata.get("title", request.title) - # Review-Fix 22.07.: `year` war hier undefiniert (existierte nur im Film-Zweig) - year = request.year or request.metadata.get("year") - - # album.nfo - album_nfo = nfo_gen.generate_album_nfo( - title=album, - artist=artist, - year=year or 2000, - genres=request.metadata.get("genres", []) - ) - - nfo_path = output_path / "album.nfo" - nfo_gen.save_nfo(album_nfo, nfo_path) - - # Album-Cover - img_downloader.download_music_images(artist, album, output_path) - - return { - "status": "formatted", - "nfo_path": str(nfo_path), - "album_cover_path": str(output_path / "album.jpg") - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) +# POST /prescan und POST /jellyfin/format entfernt am 25.07.2026 — beide waren +# Überreste eines ersetzten Entwurfs, ohne einen einzigen Aufrufer: +# +# /prescan war der Endpunkt hinter der Metadaten-Vorschau-Seite. Die +# Seite ist seit v3.4 weg (Korrektur-Popup ist der einzige +# Weg), der Endpunkt blieb liegen. Der Pre-Scan selbst lebt: +# der Disc-Watcher ruft PreScan direkt im Prozess auf, das +# Ergebnis landet auf der Disc-Karte. Nur der HTTP-Weg +# dorthin hatte keinen Nutzer. +# +# /jellyfin/format schrieb NFO-Dateien und lud Poster — in der API. Seit v3.2 +# macht das der Worker (medien.py), und das ist die richtige +# Stelle: er kennt den Ausgabeordner und ist direkt nach dem +# Rip am Zug. Mit dem Endpunkt fallen nfo_generator.py und +# image_downloader.py in der API weg; sonst nutzte sie nichts. # Auth-Endpoints (/token, /api-keys) entfernt — Commander-Entscheid 24.07.: diff --git a/docker/api/nfo_generator.py b/docker/api/nfo_generator.py deleted file mode 100644 index d9b5ec3..0000000 --- a/docker/api/nfo_generator.py +++ /dev/null @@ -1,169 +0,0 @@ -"""NFO-Generator für Jellyfin (Kodi/NFO-Schema).""" - -from pathlib import Path -from typing import List -from xml.dom.minidom import getDOMImplementation - - -class NFOGenerator: - def __init__(self): - self.dom_impl = getDOMImplementation() - - def _create_element(self, doc, name: str, text: str = None) -> None: - """Hilfsfunktion für Element-Erstellung.""" - element = doc.createElement(name) - if text: - element.appendChild(doc.createTextNode(str(text))) - return element - - def generate_movie_nfo(self, title: str, year: int, - overview: str = "", rating: float = 0.0, - runtime: int = 0, genres: List[str] = None, - director: str = "", writer: str = "", - actors: List[str] = None, studio: str = "", - premiered: str = "", mpaa: str = "") -> str: - """Generiere movie.nfo für Filme.""" - doc = self.dom_impl.createDocument(None, "movie", None) - root = doc.documentElement - - root.appendChild(self._create_element(doc, "title", title)) - root.appendChild(self._create_element(doc, "year", year)) - root.appendChild(self._create_element(doc, "plot", overview)) - root.appendChild(self._create_element(doc, "rating", rating)) - root.appendChild(self._create_element(doc, "runtime", runtime)) - - if genres: - for genre in genres: - root.appendChild(self._create_element(doc, "genre", genre)) - - if director: - root.appendChild(self._create_element(doc, "director", director)) - - if writer: - root.appendChild(self._create_element(doc, "writer", writer)) - - if actors: - for actor in actors: - actor_node = doc.createElement("actor") - actor_node.appendChild(self._create_element(doc, "name", actor)) - root.appendChild(actor_node) - - if studio: - root.appendChild(self._create_element(doc, "studio", studio)) - - if premiered: - root.appendChild(self._create_element(doc, "premiered", premiered)) - - if mpaa: - root.appendChild(self._create_element(doc, "mpaa", mpaa)) - - # Attribution - root.appendChild(self._create_element(doc, "details", "Source: TMDB")) - - return doc.toprettyxml(indent=" ") - - def generate_series_nfo(self, title: str, year: int, - overview: str = "", rating: float = 0.0, - genres: List[str] = None, studio: str = "", - premiered: str = "") -> str: - """Generiere series.nfo für Serien.""" - doc = self.dom_impl.createDocument(None, "tvshow", None) - root = doc.documentElement - - root.appendChild(self._create_element(doc, "title", title)) - root.appendChild(self._create_element(doc, "year", year)) - root.appendChild(self._create_element(doc, "plot", overview)) - root.appendChild(self._create_element(doc, "rating", rating)) - - if genres: - for genre in genres: - root.appendChild(self._create_element(doc, "genre", genre)) - - if studio: - root.appendChild(self._create_element(doc, "studio", studio)) - - if premiered: - root.appendChild(self._create_element(doc, "premiered", premiered)) - - # Attribution - root.appendChild(self._create_element(doc, "details", "Source: TMDB")) - - return doc.toprettyxml(indent=" ") - - def generate_episode_nfo(self, title: str, season: int, episode: int, - overview: str = "", rating: float = 0.0, - director: str = "", premiered: str = "", - writers: List[str] = None) -> str: - """Generiere episode.nfo für Episoden.""" - doc = self.dom_impl.createDocument(None, "episodedetails", None) - root = doc.documentElement - - root.appendChild(self._create_element(doc, "title", title)) - root.appendChild(self._create_element(doc, "season", season)) - root.appendChild(self._create_element(doc, "episode", episode)) - root.appendChild(self._create_element(doc, "plot", overview)) - root.appendChild(self._create_element(doc, "rating", rating)) - - if director: - root.appendChild(self._create_element(doc, "director", director)) - - if premiered: - root.appendChild(self._create_element(doc, "premiered", premiered)) - - if writers: - for writer in writers: - root.appendChild(self._create_element(doc, "credits", writer)) - - return doc.toprettyxml(indent=" ") - - def generate_album_nfo(self, title: str, artist: str, year: int, - genres: List[str] = None, rating: float = 0.0, - review: str = "") -> str: - """Generiere album.nfo für Musikalben.""" - doc = self.dom_impl.createDocument(None, "musicalbum", None) - root = doc.documentElement - - root.appendChild(self._create_element(doc, "title", title)) - root.appendChild(self._create_element(doc, "artist", artist)) - root.appendChild(self._create_element(doc, "year", year)) - root.appendChild(self._create_element(doc, "rating", rating)) - root.appendChild(self._create_element(doc, "review", review)) - - if genres: - for genre in genres: - root.appendChild(self._create_element(doc, "genre", genre)) - - # Attribution - root.appendChild(self._create_element(doc, "details", "Source: MusicBrainz")) - - return doc.toprettyxml(indent=" ") - - def save_nfo(self, content: str, output_path: Path) -> bool: - """Speichere NFO-Datei.""" - try: - output_path.parent.mkdir(parents=True, exist_ok=True) - with open(output_path, 'w', encoding='utf-8') as f: - f.write(content) - return True - except Exception as e: - print(f"NFO save error: {e}") - return False - - -# Beispieldaten -if __name__ == "__main__": - nfo_gen = NFOGenerator() - - # movie.nfo - movie_nfo = nfo_gen.generate_movie_nfo( - title="Inception", - year=2010, - overview="A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a C.E.O.", - rating=8.8, - runtime=148, - genres=["Action", "Sci-Fi", "Thriller"], - director="Christopher Nolan", - actors=["Leonardo DiCaprio", "Joseph Gordon-Levitt", "Ellen Page"] - ) - - print(movie_nfo) diff --git a/docker/api/test_api_smoke.py b/docker/api/test_api_smoke.py index aabb72d..742091d 100644 --- a/docker/api/test_api_smoke.py +++ b/docker/api/test_api_smoke.py @@ -19,7 +19,7 @@ def test_main_importierbar_und_routen_verdrahtet(): routen = {route.path for route in app.routes} for pfad in ( - "/health", "/jobs", "/devices", "/logs", "/settings", "/prescan", + "/health", "/jobs", "/devices", "/logs", "/settings", # KEYDB.cfg + AACS-Dumps: der Platz für die selbst mitgebrachte # Schlüsseldatei (Befund 25.07.2026 — MakeMKV holt UHD-Schlüssel nicht # mehr online nach). Ohne diese Routen ist die Seite im UI tot. @@ -85,14 +85,75 @@ def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein(): assert unter_wurzel("/app/media/movies", "/app/media/") is True -def test_sicherer_dateiname_blockt_pfad_tricks(): - from main import _sicherer_dateiname +def test_ping_vorrat_verhindert_die_wartesekunde(monkeypatch): + """Gemessen 25.07.2026: /capabilities brauchte 1,010 s - jedes Mal. Der + Celery-Ping sammelt Antworten bis zum Timeout und kann nicht frueher + aufhoeren. Fuenf UI-Stellen holen /capabilities, also zahlte jede Seite + eine Sekunde, waehrend alle anderen Endpunkte unter 25 ms lagen. - assert _sicherer_dateiname("film.mkv") is True - assert _sicherer_dateiname("../../etc/passwd") is False - assert _sicherer_dateiname("a/b.mkv") is False - # Raw-String: "a\b.mkv" waere ein Backspace-Zeichen und gar kein Backslash — - # der Test war deshalb erst gruen, obwohl er nichts prueft (Ampel 19:10). - assert _sicherer_dateiname(r"a\b.mkv") is False - assert _sicherer_dateiname(".versteckt") is False - assert _sicherer_dateiname("") is False + Der Vorrat muss deshalb abgelesen und NICHT neu gepingt werden, solange er + frisch ist - und bei altem Vorrat lieber einmal langsam als falsch. + """ + import time as _t + + import main + + pings = [] + monkeypatch.setattr(main, "_ping_jetzt", lambda: pings.append(1) or ["celery@neu"]) + + # Frischer Vorrat -> ablesen, kein Ping + monkeypatch.setitem(main._PING, "knoten", ["celery@alt"]) + monkeypatch.setitem(main._PING, "stand", _t.monotonic()) + assert main._ping_knoten() == ["celery@alt"] + assert pings == [], "bei frischem Vorrat darf NICHT gepingt werden" + + # Zu alter Vorrat -> einmal synchron pingen + monkeypatch.setitem(main._PING, "stand", _t.monotonic() - main.PING_ALTER_MAX_SEKUNDEN - 1) + assert main._ping_knoten() == ["celery@neu"] + assert len(pings) == 1 + + # Kalter Start (nie gepingt) -> ebenfalls pingen, nicht "alles offline" melden + monkeypatch.setitem(main._PING, "stand", -1e9) + main._ping_knoten() + assert len(pings) == 2 + + +def test_ping_takt_ist_kuerzer_als_die_haltbarkeit(): + """Sonst laeuft der Vorrat zwischen zwei Hintergrund-Laeufen ab und der + Endpunkt pingt doch wieder synchron.""" + import main + + assert main.PING_INTERVALL_SEKUNDEN < main.PING_ALTER_MAX_SEKUNDEN + + +def test_tote_routen_sind_und_bleiben_weg(): + """Entfernt am 25.07.2026, jede ein Ueberrest eines ersetzten Entwurfs und + ohne einen einzigen Aufrufer (mechanisch gegengeprueft: alle api.*-Aufrufe + des UI gegen alle Routen). + + Der Test haelt sie draussen. /stream/jobs ist der Grund fuer diese + Absicherung: er war schon einmal ein Placebo, wurde dann "repariert" statt + entfernt - und war danach eine Endlosschleife je Verbindung ohne jeden + Verbraucher. Wer echtes Push will, braucht BEIDE Seiten (Server UND ein + EventSource im UI). + """ + from main import app + + routen = {route.path for route in app.routes} + for pfad in ("/prescan", "/jellyfin/format", "/stream/jobs", + "/worker-setup/windows-gui"): + assert pfad not in routen, ( + f"{pfad} ist wieder da — entweder mit Verbraucher (dann diesen Test " + "anpassen) oder versehentlich (dann wieder raus)" + ) + + +def test_worker_setup_routen_die_gebraucht_werden_sind_da(): + """Die Installer holen sich Code und .exe hierueber — /worker-setup/paket + ruft install.ps1 UND install-gui.ps1 auf, /windows-exe der UI-Knopf.""" + from main import app + + routen = {route.path for route in app.routes} + for pfad in ("/worker-setup/paket", "/worker-setup/windows", + "/worker-setup/windows-exe"): + assert pfad in routen, f"Route {pfad} fehlt — Worker-Installation kaputt"