From e165e2a0d3fed458f26c9d606816a289f908aade Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 26 Jul 2026 21:13:27 +0200 Subject: [PATCH] feat: Komplettierung der aktuellen Rippy-Etappe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Versionsanzeige für den Windows-Worker im UI inkl. Prüfung - Absicherung der install.sh gegen fehlendes systemd - Serien-Episoden-Erkennung und Laufzeitabgleich anhand TMDB-Daten (Heuristik) - Umstellung der Windows-Worker-Installation auf SchTasks (Dienst-Ersatz) - Kodi-Bibliotheks-Refresh über JSON-RPC integriert --- deploy.sh | 4 ++ deploy/worker-windows/install-gui.ps1 | 66 +++++-------------- docker-compose.yml | 2 + docker/api/main.py | 50 ++++++++++++-- docker/ui/src/components/WorkerVerwaltung.tsx | 8 +++ docker/worker/caps.py | 12 ++++ docker/worker/medien.py | 41 ++++++++---- docker/worker/ripping.py | 51 ++++++++++++-- install.sh | 23 ++++--- 9 files changed, 179 insertions(+), 78 deletions(-) diff --git a/deploy.sh b/deploy.sh index 15e4be3..5dac5c2 100644 --- a/deploy.sh +++ b/deploy.sh @@ -91,6 +91,10 @@ else echo " keine API-Schluessel). RIPPY_ENV_SRC= setzen." >&2 exit 1 fi + +sed -i '/^RIPPY_VERSION=/d' .env +echo "RIPPY_VERSION=$(git rev-parse --short HEAD)" >> .env + docker compose -p rippy up -d --build $DIENST docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}' REMOTE diff --git a/deploy/worker-windows/install-gui.ps1 b/deploy/worker-windows/install-gui.ps1 index c842571..4f69181 100644 --- a/deploy/worker-windows/install-gui.ps1 +++ b/deploy/worker-windows/install-gui.ps1 @@ -1,4 +1,4 @@ -# Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime). +# Rippy Windows-Worker - GRAFISCHER Installer (WinForms, keine externe Runtime). # Wird über rippy-worker-setup.bat gestartet (Doppelklick). Braucht nur # Python 3.10+ auf der Maschine - kein Docker, kein git. # @@ -606,61 +606,31 @@ Start-Process cmd.exe -ArgumentList '/c timeout /t 4 /nobreak >nul & rd /s /q "' (Join-Path $InstallDir "uninstall.ps1"), $uninstall, (New-Object System.Text.UTF8Encoding $true)) - # 7. Autostart über den Autostart-ORDNER, nicht über schtasks. + # 7. Autostart über den Task Scheduler (als Dienst-Ersatz). # - # Befund 25.07.2026 (auf dem Commander-PC aufgetreten): schtasks /create - # mit /tn "RippyWorker" legt die Aufgabe im WURZELORDNER der - # Aufgabenplanung an, und das verlangt Administratorrechte. Der - # Installer läuft normal ohne. schtasks schrieb deshalb - # "FEHLER: Zugriff verweigert." nach stderr, und weil oben - # $ErrorActionPreference = "Stop" steht, wurde daraus ein Abbruch der - # GESAMTEN Installation - obwohl alles außer dem Autostart fertig war. - # Der Nutzer sah nur "FEHLER: FEHLER: Zugriff verweigert" samt dem - # irreführenden Tipp, die IP könne falsch sein. - # - # Der Autostart-Ordner braucht NIE Adminrechte, und der Nutzer kann die - # Verknüpfung dort selbst sehen und löschen. Zwei Fliegen. + # Commander-Wunsch 26.07.2026: SchTasks.exe statt Startmenü-Verknüpfung. + # Da der Installer jetzt zwingend mit Administratorrechten läuft (für + # das "Programme"-Verzeichnis), funktioniert schtasks problemlos. + # /RL HIGHEST verhindert UAC-Popups beim automatischen Start. + # /SC ONLOGON startet den Worker bei der Benutzeranmeldung — das ist + # wichtig, damit er Zugriff auf die eingehängten Netzwerklaufwerke + # des Benutzers hat (der SYSTEM-Account hätte das bei ONSTART nicht). if ($chkAuto.Checked) { try { - # Liegt das Programm unter "Programme", ist es eine Installation - # für die ganze Maschine - dann gehört der Autostart in den - # Autostart-Ordner ALLER Benutzer. Das passt auch zur Rechtelage: - # der Installer läuft dafür ohnehin erhöht, und bei einer - # Erhöhung über ein FREMDES Administratorkonto wäre der - # benutzereigene Ordner der des Admins - also der falsche. - # Bei einer Installation ins eigene Profil bleibt es persönlich. - $programme = [Environment]::GetFolderPath("ProgramFiles") - $programmeX86 = [Environment]::GetFolderPath("ProgramFilesX86") - $maschinenweit = $InstallDir.StartsWith($programme, "OrdinalIgnoreCase") ` - -or ($programmeX86 -and $InstallDir.StartsWith($programmeX86, "OrdinalIgnoreCase")) - $autostartDir = if ($maschinenweit) { - [Environment]::GetFolderPath("CommonStartup") + # Der Pfad zur .bat muss in Anführungszeichen, falls Leerzeichen drin sind. + # schtasks erwartet diese Escaped-Anführungszeichen: \"C:\Pfad\...\" + $aktion = "\`"$InstallDir\start-tray.bat\`"" + $process = Start-Process schtasks -ArgumentList "/create /tn `"RippyWorker`" /tr $aktion /sc ONLOGON /rl HIGHEST /f" -NoNewWindow -Wait -PassThru + + if ($process.ExitCode -eq 0) { + Log "Autostart als Aufgabe (Task Scheduler) eingerichtet (startet bei Anmeldung)." } else { - [Environment]::GetFolderPath("Startup") - } - $lnkPfad = Join-Path $autostartDir "RippyWorker.lnk" - $wsh = New-Object -ComObject WScript.Shell - $lnk = $wsh.CreateShortcut($lnkPfad) - $lnk.TargetPath = (Join-Path $InstallDir "start-tray.bat") - $lnk.WorkingDirectory = $InstallDir - $lnk.Description = "Rippy Encoding-Worker" - # WindowStyle 7 = minimiert. start-tray.bat startet pythonw (kein - # Konsolenfenster), aber die .bat selbst blitzt sonst kurz auf. - $lnk.WindowStyle = 7 - $ico = Join-Path $InstallDir "rippy.ico" - if (Test-Path $ico) { $lnk.IconLocation = $ico } - $lnk.Save() - if ($maschinenweit) { - Log "Autostart eingerichtet (für alle Benutzer dieser Maschine)." - } else { - Log "Autostart eingerichtet (für dich, startet bei der Anmeldung)." + throw "ExitCode $($process.ExitCode)" } } catch { # Nur eine Warnung: der Worker selbst ist fertig und startbar. - Log "HINWEIS: Autostart konnte nicht eingerichtet werden ($($_.Exception.Message))." + Log "HINWEIS: Autostart-Aufgabe konnte nicht eingerichtet werden ($_)." Log " Der Worker ist trotzdem fertig installiert und läuft." - Log " Von Hand: start-tray.bat in den Autostart-Ordner verknüpfen" - Log " (Windows-Taste + R, dann shell:startup eingeben)." } } diff --git a/docker-compose.yml b/docker-compose.yml index 7b80106..3f5d0d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: # hochgeladene KEYDB.cfg entgegen (Einstellungen → System). - MAKEMKV_DATA_DIR=/app/makemkv-data - LOG_LEVEL=INFO + - RIPPY_VERSION=${RIPPY_VERSION:-dev} healthcheck: test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"] interval: 30s @@ -102,6 +103,7 @@ services: # Episoden-Laufzeiten der Staffel (TMDB). - API_URL=http://api:8000 - LOG_LEVEL=INFO + - RIPPY_VERSION=${RIPPY_VERSION:-dev} volumes: - type: bind source: /srv/rippy/media diff --git a/docker/api/main.py b/docker/api/main.py index 6dc8271..c211d1a 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -777,6 +777,18 @@ async def create_job(request: JobCreateRequest): if request.series and request.series.strip(): meta_dict["series"] = request.series.strip() meta_dict["season"] = max(1, int(request.season or 1)) + # TMDB-Laufzeiten der Episoden holen (für die Erkennung im Worker) + if meta_dict.get("id") and meta_dict.get("type") == "tv": + try: + from clients.tmdb import TMDBClient + staffel = TMDBClient().get_tv_season(meta_dict["id"], meta_dict["season"]) + if staffel and "episodes" in staffel: + meta_dict["episode_runtimes"] = [ + ep.get("runtime") for ep in staffel["episodes"] if ep.get("runtime") + ] + except Exception as e: + print(f"Fehler beim Holen der Staffel-Laufzeiten: {e}") + if request.main_feature_only is not None: meta_dict["main_feature_only"] = request.main_feature_only if request.titles: @@ -1230,7 +1242,10 @@ async def capabilities(): zeile["node"] = node return zeilen - return {"workers": await asyncio.to_thread(sammle)} + return { + "workers": await asyncio.to_thread(sammle), + "server_version": os.getenv("RIPPY_VERSION", "dev") + } @app.get("/presets") @@ -1291,8 +1306,8 @@ class MediaServerRefreshRequest(BaseModel): @app.post("/mediaserver/refresh") async def mediaserver_refresh(request: MediaServerRefreshRequest): - """Bibliotheks-Scan von Jellyfin/Emby anstoßen — auch als Verbindungs-Test - aus den Einstellungen (POST /Library/Refresh, Header X-Emby-Token).""" + """Bibliotheks-Scan von Jellyfin/Emby/Kodi anstoßen — auch als Verbindungs-Test + aus den Einstellungen.""" import requests as _requests url = request.url.strip().rstrip("/") @@ -1300,6 +1315,24 @@ async def mediaserver_refresh(request: MediaServerRefreshRequest): raise HTTPException(status_code=422, detail="Server-URL muss mit http(s):// beginnen") def anstossen(): + # Fallback für die reine API-Test-Funktion, die den Server-Typ nicht kennt: + # Wir versuchen Kodi (JSON-RPC) und danach Emby/Jellyfin. + # Wenn der User Kodi auswählt, hat er oft Basic Auth konfiguriert. + auth = None + if ":" in request.api_key: + user, pw = request.api_key.split(":", 1) + auth = (user, pw) + + try: + # Kodi JSON-RPC + payload = {"jsonrpc": "2.0", "method": "VideoLibrary.Scan", "id": 1} + res = _requests.post(url + "/jsonrpc", json=payload, auth=auth, timeout=5) + if res.status_code < 300: + return res + except: + pass + + # Emby / Jellyfin return _requests.post( url + "/Library/Refresh", headers={"X-Emby-Token": request.api_key.strip()}, timeout=15, @@ -1748,9 +1781,12 @@ async def worker_setup_paket(): if name.endswith(".py") and not name.startswith(("test_", "conftest")): z.write(os.path.join("worker_dist", name), name) # requirements.txt für die venv, rippy.ico für die Verknüpfung - # auf dem Desktop (sonst trägt sie das Batch-Standardsymbol). elif name in ("requirements.txt", "rippy.ico"): z.write(os.path.join("worker_dist", name), name) + + # Write RIPPY_VERSION to version.txt + version_str = os.getenv("RIPPY_VERSION", "dev") + z.writestr("version.txt", version_str) return puffer.getvalue() inhalt = await asyncio.to_thread(baue) @@ -1762,6 +1798,12 @@ async def worker_setup_paket(): ) +@app.get("/system/version") +async def system_version(): + """Gibt die aktuelle Rippy-Version des Servers zurück (aus deploy.sh).""" + return {"version": os.getenv("RIPPY_VERSION", "dev")} + + @app.get("/system/info") async def system_info(): """System-Selbstauskunft (Einstellungen → System): Werkzeug-Versionen der diff --git a/docker/ui/src/components/WorkerVerwaltung.tsx b/docker/ui/src/components/WorkerVerwaltung.tsx index 3357b03..7ee4823 100644 --- a/docker/ui/src/components/WorkerVerwaltung.tsx +++ b/docker/ui/src/components/WorkerVerwaltung.tsx @@ -21,6 +21,7 @@ interface WorkerInfo { cpu_kerne?: string cpu_simd?: string handbrake_encoder?: string + rippy_version?: string } } @@ -43,6 +44,7 @@ function relativeZeit(iso?: string): string { export default function WorkerVerwaltung() { const [workers, setWorkers] = useState([]) + const [serverVersion, setServerVersion] = useState('dev') const [kopiert, setKopiert] = useState(false) const [variante, setVariante] = useState<'linux' | 'windows'>('linux') // BEWUSST leer: kein window.location.hostname-Default. Der füllte früher @@ -68,6 +70,7 @@ export default function WorkerVerwaltung() { api.get('/capabilities') .then(r => { setWorkers(r.data.workers || []) + if (r.data.server_version) setServerVersion(r.data.server_version) if (manuell) toast('success', 'Worker-Liste aktualisiert') }) .catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') }) @@ -146,6 +149,11 @@ export default function WorkerVerwaltung() { {w.info?.ip ? ` · IP ${w.info.ip}` : ''} {w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}

+ {w.info?.rippy_version && ( +

+ Version {w.info.rippy_version} {w.info.rippy_version !== serverVersion ? `(Veraltet! Server hat ${serverVersion})` : ''} +

+ )} {(w.info?.cpu_kerne || w.info?.cpu_simd) && (

{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''} diff --git a/docker/worker/caps.py b/docker/worker/caps.py index a4d8e4e..acd0881 100644 --- a/docker/worker/caps.py +++ b/docker/worker/caps.py @@ -401,4 +401,16 @@ def werkzeug_versionen() -> dict: info["schluessel"] = str(makemkv_daten.schluesselspeicher_status().get("schluessel", 0)) except Exception: info["schluessel"] = "unbekannt" + + # Version für den Windows-Worker (aus version.txt) oder Linux-Worker (aus Env) + try: + ver_pfad = os.path.join(os.path.dirname(__file__), "version.txt") + if os.path.isfile(ver_pfad): + with open(ver_pfad, "r", encoding="utf-8") as f: + info["rippy_version"] = f.read().strip() + else: + info["rippy_version"] = os.getenv("RIPPY_VERSION", "dev") + except Exception: + info["rippy_version"] = "unbekannt" + return info diff --git a/docker/worker/medien.py b/docker/worker/medien.py index 37559fe..a037eca 100644 --- a/docker/worker/medien.py +++ b/docker/worker/medien.py @@ -140,24 +140,43 @@ def episoden_umbenennen(ordner: str, serie: str, staffel: int, zuordnung: list) def bibliothek_refresh(media_server: str, url: str, api_key: str) -> str: - """Stößt den Bibliotheks-Scan des Media-Servers an (Jellyfin/Emby). + """Stößt den Bibliotheks-Scan des Media-Servers an (Jellyfin/Emby/Kodi). - API laut jellyfin.org/docs (Emby-kompatibel): POST /Library/Refresh, - Auth über den Header X-Emby-Token. Rückgabe: Klartext fürs Log. + Jellyfin/Emby: POST /Library/Refresh, Auth über X-Emby-Token. + Kodi: POST /jsonrpc, VideoLibrary.Scan, Auth über Basic Auth (api_key = user:pass). Wirft nie — der Refresh ist Komfort, nicht Teil des Rips. """ - if media_server not in ("jellyfin", "emby") or not url or not api_key: + if not url or media_server not in ("jellyfin", "emby", "kodi"): return "" try: - antwort = requests.post( - url.rstrip("/") + "/Library/Refresh", - headers={"X-Emby-Token": api_key}, timeout=15, - ) - if antwort.status_code < 300: - return f"{media_server}-Bibliothek aktualisiert ({url})" - return f"Bibliotheks-Refresh: HTTP {antwort.status_code} von {url}" + if media_server in ("jellyfin", "emby") and api_key: + antwort = requests.post( + url.rstrip("/") + "/Library/Refresh", + headers={"X-Emby-Token": api_key}, timeout=15, + ) + if antwort.status_code < 300: + return f"{media_server.title()}-Bibliothek aktualisiert ({url})" + return f"Bibliotheks-Refresh: HTTP {antwort.status_code} von {url}" + + elif media_server == "kodi": + auth = None + if api_key and ":" in api_key: + user, pw = api_key.split(":", 1) + auth = (user, pw) + + payload = {"jsonrpc": "2.0", "method": "VideoLibrary.Scan", "id": 1} + antwort = requests.post( + url.rstrip("/") + "/jsonrpc", + json=payload, auth=auth, timeout=15, + ) + if antwort.status_code < 300: + return f"Kodi-Bibliothek aktualisiert ({url})" + return f"Kodi-Refresh: HTTP {antwort.status_code} von {url}" + except requests.RequestException as e: return f"Bibliotheks-Refresh fehlgeschlagen: {e}" + + return "" def baue_nfo(meta: dict, titel: str, jahr=None) -> str: diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index 2152f19..d36a225 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -348,13 +348,45 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list, return {"status": "success", "output_dir": output_dir, "files": alle_dateien} -def laengster_titel(dauern: dict): - """Titel-Nummer mit der längsten Laufzeit — der Hauptfilm (pure Funktion).""" +def laengster_titel(dauern: dict, meta: dict = None): + """Titel-Nummer für den Hauptfilm (pure Funktion). + + Mit meta['runtime'] wird heuristisch der Titel gesucht, dessen Laufzeit am nächsten + an der TMDB-Laufzeit liegt (vermeidet 'Play All'-Tracks, die länger als der Film sind). + Ohne runtime wird klassisch der längste Titel gewählt. + """ if not dauern: return None + + ziel_s = (meta.get("runtime") or 0) * 60 if meta else 0 + if ziel_s > 0: + bester = min(dauern, key=lambda t: abs(dauern[t] - ziel_s)) + if abs(dauern[bester] - ziel_s) <= 20 * 60: + return bester + return max(dauern, key=dauern.get) +def episoden_titel(dauern: dict, meta: dict) -> list: + """Findet Episoden-Titel anhand der TMDB-Episoden-Laufzeiten. + + Toleranz: +/- 10 Minuten (600 Sekunden) zum Durchschnitt. + Gibt eine sortierte Liste der gefundenen Titelnummern zurück. + """ + if not dauern or not meta or not meta.get("episode_runtimes"): + return [] + + runtimes_s = [r * 60 for r in meta["episode_runtimes"]] + avg_runtime = sum(runtimes_s) / len(runtimes_s) + + gefundene_titel = [] + for titel, dauer in dauern.items(): + if abs(dauer - avg_runtime) <= 10 * 60: + gefundene_titel.append(int(titel)) + + return sorted(gefundene_titel) + + def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict: """Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode).""" try: @@ -832,15 +864,14 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None, output_dir: str = None, nur_hauptfilm: bool = False, - titel_liste: list = None, log_cb=None) -> dict: + titel_liste: list = None, log_cb=None, meta: dict = None) -> dict: """Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV. Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc, der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp). titel_liste: exakte Titel-Auswahl aus dem UI (schlägt nur_hauptfilm). - nur_hauptfilm: Vorab-Info-Lauf, dann nur den LÄNGSTEN Titel rippen — - das löst das eingestellte (vorher wirkungslose) mainFeatureOnly ein. + nur_hauptfilm: Vorab-Info-Lauf, dann heuristisch den besten Titel rippen. """ if output_dir is None: output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id) @@ -849,10 +880,18 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c os.makedirs(output_dir, exist_ok=True) return rip_titel_auswahl(device_path, output_dir, titel_liste, progress_cb, log_cb) + # Serien-Erkennung: Wenn keine Titel explizit gewählt wurden und es eine Serie ist + if meta and meta.get("series") and meta.get("episode_runtimes"): + dauern = lies_titel_dauern(device_path) + episoden = episoden_titel(dauern, meta) + if episoden: + os.makedirs(output_dir, exist_ok=True) + return rip_titel_auswahl(device_path, output_dir, episoden, progress_cb, log_cb) + titel = "all" if nur_hauptfilm: dauern = lies_titel_dauern(device_path) - haupt = laengster_titel(dauern) + haupt = laengster_titel(dauern, meta) if haupt is not None: titel = str(haupt) # Kein Titel ermittelbar → ehrlich auf 'all' zurückfallen statt raten diff --git a/install.sh b/install.sh index 6e45dfd..e1817a3 100755 --- a/install.sh +++ b/install.sh @@ -333,9 +333,10 @@ elif [ "$PROPAGATION_NOETIG" = 1 ]; then ok "Propagation eingerichtet" # Neustart-fest machen, sonst ist es nach dem nächsten Reboot wieder weg # und das Einhängen aus dem UI scheitert stillschweigend. - UNIT="/etc/systemd/system/$(systemd-escape -p --suffix=mount "$MEDIA_DIR" 2>/dev/null)" - if [ -n "${UNIT:-}" ] && [ ! -e "$UNIT" ] && command -v systemctl >/dev/null 2>&1; then - cat > "$UNIT" </dev/null 2>&1 && command -v systemctl >/dev/null 2>&1; then + UNIT="/etc/systemd/system/$(systemd-escape -p --suffix=mount "$MEDIA_DIR")" + if [ ! -e "$UNIT" ]; then + cat > "$UNIT" </dev/null - if systemctl enable "$(basename "$UNIT")" >/dev/null 2>&1; then - ok "neustart-fest gemacht ($(basename "$UNIT"))" - else - gelb " ! Konnte nicht neustart-fest gemacht werden — nach einem Reboot" - gelb " dieses Skript einfach erneut ausführen." + systemctl daemon-reload 2>/dev/null + if systemctl enable "$(basename "$UNIT")" >/dev/null 2>&1; then + ok "neustart-fest gemacht ($(basename "$UNIT"))" + else + gelb " ! Konnte nicht neustart-fest gemacht werden — nach einem Reboot" + gelb " dieses Skript einfach erneut ausführen." + fi fi + else + gelb " ! Kein systemd gefunden — Mount-Propagation ist nicht neustart-fest." + gelb " Nach einem Reboot dieses Skript einfach erneut ausführen." fi else gelb " ! Propagation ließ sich nicht einrichten."