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:
@@ -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
|
|
||||||
+81
-125
@@ -1,13 +1,13 @@
|
|||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from pathlib import Path
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import db
|
import db
|
||||||
@@ -23,8 +23,6 @@ from config_validation import validate_config, ConfigValidationError
|
|||||||
from cache import init_cache
|
from cache import init_cache
|
||||||
from ratelimit import check_rate_limit, get_rate_limit_remaining
|
from ratelimit import check_rate_limit, get_rate_limit_remaining
|
||||||
from prescan import PreScan
|
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:
|
# Auth (JWT/Login/API-Keys) KOMPLETT entfernt — Commander-Entscheid 24.07.2026:
|
||||||
# Rippy läuft ausschließlich im Heimnetz, die Endpoints schützten ohnehin
|
# 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
|
# Monatsende ab) — sonst blockt Blu-ray-Ripping irgendwann still. Taeglicher
|
||||||
# Forum-Abgleich; wirkt ohne Rebuild ab dem naechsten Rip.
|
# Forum-Abgleich; wirkt ohne Rebuild ab dem naechsten Rip.
|
||||||
asyncio.create_task(makemkv_key.refresh_loop())
|
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,
|
# 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"}
|
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")
|
@app.get("/capabilities")
|
||||||
async def capabilities():
|
async def capabilities():
|
||||||
"""Welche Encoder sind auf welchen Workern WIRKLICH verfügbar — inkl.
|
"""Welche Encoder sind auf welchen Workern WIRKLICH verfügbar — inkl.
|
||||||
@@ -698,11 +748,7 @@ async def capabilities():
|
|||||||
"""
|
"""
|
||||||
def sammle():
|
def sammle():
|
||||||
zeilen = db.list_workers()
|
zeilen = db.list_workers()
|
||||||
try:
|
ping_knoten = _ping_knoten()
|
||||||
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 = []
|
|
||||||
for zeile in zeilen:
|
for zeile in zeilen:
|
||||||
# Celery-Knotenname = <name>@<hostname>. Der Ping liefert ihn voll;
|
# Celery-Knotenname = <name>@<hostname>. Der Ping liefert ihn voll;
|
||||||
# gematcht wird über den Hostname (der Docker-Worker heißt celery@…,
|
# 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")
|
return FileResponse(pfad, media_type="text/plain", filename="install-rippy-worker.ps1")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/worker-setup/windows-gui")
|
# GET /worker-setup/windows-gui entfernt am 25.07.2026: ohne Aufrufer, seit die
|
||||||
async def worker_setup_windows_gui():
|
# .exe den .bat-Umweg ersetzt hat (v3.9 — .vbs/.bat wird als gefährlich
|
||||||
"""Grafischer Windows-Installer (WinForms, PowerShell-Quelle) — Rückfall
|
# geflaggt, Commander-Einwand). Die Datei install-gui.ps1 selbst lebt weiter,
|
||||||
für Fortgeschrittene; der Normalweg ist die .exe unten."""
|
# sie steckt in der .exe; nur diese Route war verwaist.
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/worker-setup/windows-exe")
|
@app.get("/worker-setup/windows-exe")
|
||||||
@@ -1500,21 +1542,13 @@ async def get_devices():
|
|||||||
return geraete
|
return geraete
|
||||||
|
|
||||||
|
|
||||||
# SSE-Stream für Echtzeit-Updates
|
# GET /stream/jobs (SSE) entfernt am 25.07.2026. Der Stream war zweimal falsch:
|
||||||
@app.get("/stream/jobs")
|
# erst ein Placebo (er sendete nur, wenn eine Liste `sse_connections` gefüllt
|
||||||
async def job_stream():
|
# war, und nichts füllte sie je), dann am 23.07. funktionsfähig gemacht — aber
|
||||||
"""SSE-Stream für Job-Updates.
|
# 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
|
||||||
Fix 23.07.: Der alte Generator sendete nur, wenn `sse_connections` gefüllt
|
# harmlose Leiche, sondern eine Endlosschleife je Verbindung, die jeder im
|
||||||
war — aber NICHTS hat diese Liste je befüllt. Der Stream war ein Placebo.
|
# Heimnetz aufmachen konnte. Wer echtes Push will, braucht BEIDE Seiten.
|
||||||
"""
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
# /metadata/lookup + /metadata/confirm entfernt (24.07., mit der
|
# /metadata/lookup + /metadata/confirm entfernt (24.07., mit der
|
||||||
@@ -1627,99 +1661,21 @@ async def metadata_override(request: MetadataOverride):
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
# Pre-Scan Endpoint
|
# POST /prescan und POST /jellyfin/format entfernt am 25.07.2026 — beide waren
|
||||||
class PreScanRequest(BaseModel):
|
# Überreste eines ersetzten Entwurfs, ohne einen einzigen Aufrufer:
|
||||||
device_path: str
|
#
|
||||||
|
# /prescan war der Endpunkt hinter der Metadaten-Vorschau-Seite. Die
|
||||||
|
# Seite ist seit v3.4 weg (Korrektur-Popup ist der einzige
|
||||||
@app.post("/prescan")
|
# Weg), der Endpunkt blieb liegen. Der Pre-Scan selbst lebt:
|
||||||
async def run_prescan(request: PreScanRequest):
|
# der Disc-Watcher ruft PreScan direkt im Prozess auf, das
|
||||||
"""Führe Pre-Scan durch."""
|
# Ergebnis landet auf der Disc-Karte. Nur der HTTP-Weg
|
||||||
try:
|
# dorthin hatte keinen Nutzer.
|
||||||
prescan = PreScan()
|
#
|
||||||
result = prescan.scan(request.device_path)
|
# /jellyfin/format schrieb NFO-Dateien und lud Poster — in der API. Seit v3.2
|
||||||
return result.to_dict()
|
# macht das der Worker (medien.py), und das ist die richtige
|
||||||
except Exception as e:
|
# Stelle: er kennt den Ausgabeordner und ist direkt nach dem
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
# Rip am Zug. Mit dem Endpunkt fallen nfo_generator.py und
|
||||||
|
# image_downloader.py in der API weg; sonst nutzte sie nichts.
|
||||||
|
|
||||||
# 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))
|
|
||||||
|
|
||||||
|
|
||||||
# Auth-Endpoints (/token, /api-keys) entfernt — Commander-Entscheid 24.07.:
|
# Auth-Endpoints (/token, /api-keys) entfernt — Commander-Entscheid 24.07.:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -19,7 +19,7 @@ def test_main_importierbar_und_routen_verdrahtet():
|
|||||||
|
|
||||||
routen = {route.path for route in app.routes}
|
routen = {route.path for route in app.routes}
|
||||||
for pfad in (
|
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
|
# KEYDB.cfg + AACS-Dumps: der Platz für die selbst mitgebrachte
|
||||||
# Schlüsseldatei (Befund 25.07.2026 — MakeMKV holt UHD-Schlüssel nicht
|
# Schlüsseldatei (Befund 25.07.2026 — MakeMKV holt UHD-Schlüssel nicht
|
||||||
# mehr online nach). Ohne diese Routen ist die Seite im UI tot.
|
# 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
|
assert unter_wurzel("/app/media/movies", "/app/media/") is True
|
||||||
|
|
||||||
|
|
||||||
def test_sicherer_dateiname_blockt_pfad_tricks():
|
def test_ping_vorrat_verhindert_die_wartesekunde(monkeypatch):
|
||||||
from main import _sicherer_dateiname
|
"""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
|
Der Vorrat muss deshalb abgelesen und NICHT neu gepingt werden, solange er
|
||||||
assert _sicherer_dateiname("../../etc/passwd") is False
|
frisch ist - und bei altem Vorrat lieber einmal langsam als falsch.
|
||||||
assert _sicherer_dateiname("a/b.mkv") is False
|
"""
|
||||||
# Raw-String: "a\b.mkv" waere ein Backspace-Zeichen und gar kein Backslash —
|
import time as _t
|
||||||
# der Test war deshalb erst gruen, obwohl er nichts prueft (Ampel 19:10).
|
|
||||||
assert _sicherer_dateiname(r"a\b.mkv") is False
|
import main
|
||||||
assert _sicherer_dateiname(".versteckt") is False
|
|
||||||
assert _sicherer_dateiname("") is False
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user