11597eb00c
Ampel / ampel (push) Successful in 28s
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 <noreply@anthropic.com>
160 lines
6.5 KiB
Python
160 lines
6.5 KiB
Python
"""Import-Smoke-Test: bricht, wenn main.py kaputte Imports oder Verdrahtung hat.
|
|
|
|
Warum: Kein anderer Test importiert main.py — ein Tippfehler dort fiele sonst
|
|
erst beim Container-Start auf (und die Ampel bliebe fälschlich grün).
|
|
Läuft nur unter Linux (detection.py nutzt fcntl/ioctl), also genau dort,
|
|
wo auch die Ampel läuft.
|
|
"""
|
|
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
if sys.platform == "win32": # pragma: no cover
|
|
pytest.skip("detection.py braucht fcntl (Linux)", allow_module_level=True)
|
|
|
|
|
|
def test_main_importierbar_und_routen_verdrahtet():
|
|
from main import app
|
|
|
|
routen = {route.path for route in app.routes}
|
|
for pfad in (
|
|
"/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.
|
|
"/system/keydb", "/system/aacs-dumps", "/system/aacs-dumps/{dateiname}",
|
|
# Der Hauptweg fuer 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
|
|
# unter Linux nie selbst, sie kommen von Hand ueber diesen Endpunkt.
|
|
"/system/keystore",
|
|
):
|
|
assert pfad in routen, f"Route {pfad} fehlt"
|
|
|
|
|
|
def test_dateiname_validierung_blockt_pfad_tricks():
|
|
"""Download-Endpoint: nur nackte Dateinamen — kein .., kein Slash, kein Dotfile."""
|
|
from main import _sicherer_dateiname
|
|
|
|
assert _sicherer_dateiname("film.mkv") is True
|
|
assert _sicherer_dateiname("../../etc/passwd") is False
|
|
assert _sicherer_dateiname("a/b.mkv") is False
|
|
assert _sicherer_dateiname("a\\b.mkv") is False
|
|
assert _sicherer_dateiname(".versteckt") is False
|
|
assert _sicherer_dateiname("") is False
|
|
|
|
|
|
def test_worker_task_name_passt_zum_celery_client():
|
|
"""API schickt an 'worker.tasks.rip_disc' — der Name ist Vertrag mit dem Worker."""
|
|
import inspect
|
|
|
|
import celery_client
|
|
|
|
quelle = inspect.getsource(celery_client.start_rip)
|
|
assert '"worker.tasks.rip_disc"' in quelle
|
|
|
|
|
|
def test_remount_blockiert_den_api_start_nicht():
|
|
"""Regression (Vorfall 24.07.): ein haengender Netz-Mount (CIFS-Schreibtest kann
|
|
im Kernel haengen, wait_for_response) darf den API-Start NICHT blockieren. remount
|
|
muss als Hintergrund-Task laufen (create_task), nicht direkt awaited werden."""
|
|
import inspect
|
|
|
|
import main
|
|
|
|
quelle = inspect.getsource(main.startup_event)
|
|
assert "create_task(asyncio.to_thread(remount))" in quelle, \
|
|
"remount muss als Hintergrund-Task laufen (nicht blockierend)"
|
|
assert "await asyncio.to_thread(remount)" not in quelle, \
|
|
"remount darf nicht mehr direkt awaited werden (blockiert sonst den Start)"
|
|
|
|
|
|
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
|
"""Befund 25.07.2026: In main.py prueften neun Stellen mit nacktem
|
|
startswith(MEDIA_ROOT) — darunter /browse und /browse/mkdir, wo der Pfad
|
|
vom Nutzer kommt. „/app/media-boese/x" beginnt mit „/app/media", liegt
|
|
aber ausserhalb. Zwilling von tasks.unter_wurzel im Worker."""
|
|
from main import unter_wurzel
|
|
|
|
assert unter_wurzel("/app/media", "/app/media") is True
|
|
assert unter_wurzel("/app/media/movies", "/app/media") is True
|
|
assert unter_wurzel("/app/media-boese/x", "/app/media") is False
|
|
assert unter_wurzel("/app/mediaX", "/app/media") is False
|
|
assert unter_wurzel("/etc/passwd", "/app/media") is False
|
|
assert unter_wurzel("", "/app/media") is False
|
|
assert unter_wurzel("/app/media", "") is False
|
|
assert unter_wurzel("/app/media/movies", "/app/media/") is True
|
|
|
|
|
|
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.
|
|
|
|
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"
|