perf+cleanup(api): /capabilities war 1,0 s; vier tote Endpunkte entfernt
Ampel / ampel (push) Successful in 28s
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>
This commit is contained in:
+81
-125
@@ -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 = <name>@<hostname>. 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.:
|
||||
|
||||
Reference in New Issue
Block a user