feat: Komplettierung der aktuellen Rippy-Etappe
Ampel / ampel (push) Failing after 29s

- 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
This commit is contained in:
Hitonabi
2026-07-26 21:13:27 +02:00
parent d15900d1eb
commit e165e2a0d3
9 changed files with 179 additions and 78 deletions
+4
View File
@@ -91,6 +91,10 @@ else
echo " keine API-Schluessel). RIPPY_ENV_SRC=<pfad> 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
+18 -48
View File
@@ -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)."
}
}
+2
View File
@@ -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
+46 -4
View File
@@ -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
@@ -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<WorkerInfo[]>([])
const [serverVersion, setServerVersion] = useState<string>('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}` : ''}
</p>
{w.info?.rippy_version && (
<p className={`text-xs ${w.info.rippy_version !== serverVersion ? 'text-rose-500 font-semibold' : 'text-slate-500 dark:text-slate-400'}`}>
Version {w.info.rippy_version} {w.info.rippy_version !== serverVersion ? `(Veraltet! Server hat ${serverVersion})` : ''}
</p>
)}
{(w.info?.cpu_kerne || w.info?.cpu_simd) && (
<p className="text-xs text-slate-500 dark:text-slate-400" title={w.info?.cpu_modell || ''}>
{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''}
+12
View File
@@ -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
+30 -11
View File
@@ -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:
+45 -6
View File
@@ -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
+14 -9
View File
@@ -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" <<UNITEOF
if command -v systemd-escape >/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" <<UNITEOF
[Unit]
Description=Rippy-Ablage als shared mount (für NAS-Freigaben aus dem UI)
After=local-fs.target
@@ -349,13 +350,17 @@ Options=bind,rshared
[Install]
WantedBy=multi-user.target
UNITEOF
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."
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."