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."