Track-Auswahl-Tabelle, nativer Windows-Worker (via UI angeboten), Anleitungs-Tab
Ampel / ampel (push) Successful in 29s

Volle Titel-Auswahl vor dem Rip (Etappe-12-Rest):
- worker.tasks.scan_tracks: makemkvcon info -> Titel-Liste (Dauer/Groesse/
  Kapitel, apdefs-Attr 8/9/11) in die settings-Tabelle 'tracks:<device>';
  API: POST /devices/{n}/scan-tracks + GET /devices/{n}/tracks (Polling).
- Rip-Dialog: 'Disc scannen' -> Tabelle mit Checkboxen, Vorauswahl ab
  5 min, Groessen-Summe; gewaehlte Titel als meta.titles in den Job.
- rip_titel_auswahl: ein makemkvcon-Aufruf je Titel (mkv kann nur einen
  Titel oder all), Fortschritt anteilig. Parser-Tests dabei.

Nativer Windows-Worker OHNE Docker (Commander-Wunsch):
- Die Rippy-Instanz versorgt sich selbst: GET /worker-setup/windows
  liefert install.ps1, /worker-setup/paket den Worker-Code als Zip
  (api-Dockerfile kopiert docker/worker/*.py als worker_dist/ ins Image).
- install.ps1: Python-Check, venv + requirements, HandBrakeCLI 1.9.2 vom
  offiziellen GitHub-Release (URL per HEAD verifiziert), start-worker.bat
  mit celery -Q transcode --pool=solo (Celery-Doku: Windows nur solo),
  optional -Autostart via schtasks onlogon.
- tasks.py laedt jetzt auch ohne fcntl (Import-Guard) — rip_disc ist auf
  solchen Workern hart verriegelt (Klartext-Fehler statt Crash).
- Einstellungen -> Worker: Varianten-Umschalter Linux(Docker)/Windows(nativ)
  mit passenden Copy-Paste-Befehlen.

Anleitungs-Tab: Rippy erklaert sich selbst (Disc-Weg, Serien, NAS,
Media-Server, Benachrichtigungen, Key, Worker, Downloads, FAQ).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-24 15:18:08 +02:00
parent 325d95af6f
commit 3a4eb25392
15 changed files with 738 additions and 44 deletions
+22
View File
@@ -148,6 +148,28 @@ def get_job(job_id: str) -> dict:
return dict(zeile) if zeile else None
def save_settings(werte: dict, key: str = "ui") -> None:
"""Upsert in die settings-Tabelle — der Worker legt hier z. B. die
Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie."""
import json
from sqlalchemy import select
payload = json.dumps(werte)
with engine.begin() as conn:
vorhanden = conn.execute(
select(settings_table.c.key).where(settings_table.c.key == key)
).first()
if vorhanden:
conn.execute(
settings_table.update()
.where(settings_table.c.key == key)
.values(value=payload)
)
else:
conn.execute(settings_table.insert().values(key=key, value=payload))
def get_job_status(job_id: str) -> str:
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
from sqlalchemy import select
+63 -1
View File
@@ -78,6 +78,62 @@ def parse_tinfo_dauern(ausgabe: str) -> dict:
return dauern
def parse_titel_info(ausgabe: str) -> list:
"""TINFO-Zeilen → volle Titel-Liste für die Auswahl-Tabelle (pure Funktion).
Attribut-IDs laut MakeMKV apdefs.h (makemkvgui/inc/lgpl/apdefs.h, LGPL):
8 = ChapterCount, 9 = Duration ("H:MM:SS"), 11 = DiskSizeBytes.
Rückgabe: [{"nr", "dauer_s", "groesse_bytes", "kapitel"}, …] sortiert.
"""
titel: dict = {}
for zeile in ausgabe.splitlines():
treffer = re.match(r'TINFO:(\d+),(\d+),\d+,"(.*)"\s*$', zeile.strip())
if not treffer:
continue
nr, attr, wert = int(treffer.group(1)), int(treffer.group(2)), treffer.group(3)
eintrag = titel.setdefault(nr, {"nr": nr, "dauer_s": 0, "groesse_bytes": 0, "kapitel": 0})
if attr == 9:
teile = wert.split(":")
if len(teile) == 3 and all(t.isdigit() for t in teile):
h, m, s = (int(t) for t in teile)
eintrag["dauer_s"] = h * 3600 + m * 60 + s
elif attr == 11 and wert.isdigit():
eintrag["groesse_bytes"] = int(wert)
elif attr == 8 and wert.isdigit():
eintrag["kapitel"] = int(wert)
return [titel[nr] for nr in sorted(titel)]
def lies_titel_info(device_path: str, timeout: int = 300) -> list:
"""Volle Titel-Liste der eingelegten Disc (makemkvcon info, Robot-Mode)."""
ergebnis = subprocess.run(
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
capture_output=True, text=True, timeout=timeout,
)
return parse_titel_info(ergebnis.stdout or "")
def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
progress_cb=None) -> dict:
"""Rippt GENAU die gewählten Titel (makemkvcon kann pro Aufruf nur einen
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
gesamt = len(titel_liste)
alle_dateien = []
for index, nr in enumerate(titel_liste):
def anteilig(p, _i=index):
if progress_cb:
progress_cb(int((_i * 100 + p) / gesamt))
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr))
if ergebnis.get("status") == "cancelled":
return ergebnis
if ergebnis.get("status") != "success":
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
return ergebnis
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
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)."""
if not dauern:
@@ -316,18 +372,24 @@ 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) -> dict:
output_dir: str = None, nur_hauptfilm: bool = False,
titel_liste: list = 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.
"""
if output_dir is None:
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
if titel_liste:
os.makedirs(output_dir, exist_ok=True)
return rip_titel_auswahl(device_path, output_dir, titel_liste, progress_cb)
titel = "all"
if nur_hauptfilm:
dauern = lies_titel_dauern(device_path)
+51 -5
View File
@@ -23,12 +23,22 @@ import db
import medien
import notify
from celery_app import celery_app
from detection import detect_disc_type, disc_size_bytes
# fcntl gibt es nur unter Linux — der NATIVE Windows-Transcode-Worker
# (deploy/worker-windows) lädt dieses Modul auch, bedient aber nur die
# transcode-Queue. Rippen ohne detection ist unten hart verriegelt.
try:
from detection import detect_disc_type, disc_size_bytes
except ImportError: # Windows: kein fcntl
detect_disc_type = None
disc_size_bytes = None
from ripping import (
DEFAULT_HB_PRESET,
RIP_OUTPUT_DIR,
RipAbbruch,
lies_datei_dauer,
lies_titel_info,
rip_cd,
rip_video,
run_handbrake,
@@ -252,6 +262,16 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
"""
db.init_db()
if detect_disc_type is None:
ergebnis = {
"status": "error",
"error": "Dieser Worker kann nur komprimieren (nativer "
"Transcode-Worker ohne Laufwerks-Zugriff) — gerippt "
"wird auf der Rippy-Hauptmaschine.",
}
_job_abschliessen(job_id, ergebnis)
return ergebnis
if _abbruch_angefordert(job_id):
_job_abschliessen(job_id, {"status": "cancelled"})
return {"status": "cancelled"}
@@ -311,12 +331,14 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
job.get("title") or "", meta.get("year"), job_id,
)
# Nur Hauptfilm (längster Titel): pro Rip wählbar, sonst Setting;
# bei Serien-Discs sinnlos (dort zählen ALLE Episoden-Titel).
# Titel-Wahl: explizite Auswahl aus der Track-Tabelle schlägt alles;
# sonst „Nur Hauptfilm" (pro Rip wählbar, Fallback: Setting);
# bei Serien-Discs ohne Auswahl zählen ALLE Episoden-Titel.
titel_liste = [int(t) for t in (meta.get("titles") or []) if str(t).isdigit()]
nur_hauptfilm = meta.get("main_feature_only")
if nur_hauptfilm is None:
nur_hauptfilm = bool(einstellungen.get("mainFeatureOnly", False))
if meta.get("series"):
if meta.get("series") or titel_liste:
nur_hauptfilm = False
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
db.update_job(job_id, output_path=final_dir)
@@ -326,7 +348,7 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
# Platz-Check VOR dem Rip: Disc-Größe ist per ioctl bekannt — eine volle
# Platte nach 40 GB wäre der teuerste Fehlschlag (4K-UHD: bis 100 GB roh).
try:
disc_bytes = disc_size_bytes(device_path)
disc_bytes = disc_size_bytes(device_path) if disc_size_bytes else 0
except OSError:
disc_bytes = 0
marge = 1024**3 # 1 GB Sicherheitsabstand
@@ -349,12 +371,14 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
progress_cb=fortschritt,
output_dir=raw_dir,
nur_hauptfilm=nur_hauptfilm,
titel_liste=titel_liste,
)
else:
ergebnis = rip_video(
device_path, job_id, disc_type,
progress_cb=fortschritt, output_dir=final_dir,
nur_hauptfilm=nur_hauptfilm,
titel_liste=titel_liste,
)
# UHD-Fehler in Klartext übersetzen — "Failed to open disc" allein hilft
@@ -397,6 +421,28 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
return ergebnis
@celery_app.task(name="worker.tasks.scan_tracks")
def scan_tracks(device_path: str):
"""Titel-Liste der eingelegten Disc erfassen (für die Auswahl-Tabelle).
Ergebnis landet in der settings-Tabelle (key 'tracks:<device>') — die
API pollt darauf. Läuft auf der Haupt-Queue (nur der Rip-Worker hat das
Laufwerk). Ein Info-Lauf dauert je nach Disc 20120 s.
"""
db.init_db()
key = f"tracks:{device_path}"
db.save_settings({"status": "running"}, key)
try:
titel = lies_titel_info(device_path)
db.save_settings({"status": "done", "tracks": titel}, key)
db.add_log("info", "worker", f"Titel-Scan {device_path}: {len(titel)} Titel gefunden")
return {"status": "done", "anzahl": len(titel)}
except Exception as e:
db.save_settings({"status": "error", "error": str(e)[:300]}, key)
db.add_log("error", "worker", f"Titel-Scan {device_path} fehlgeschlagen: {e}")
return {"status": "error", "error": str(e)}
@celery_app.task(bind=True, name="worker.tasks.transcode_files")
def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
"""Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße.
+17
View File
@@ -5,6 +5,7 @@ from ripping import (
laengster_titel,
parse_scan_dauer,
parse_tinfo_dauern,
parse_titel_info,
)
# Echte Robot-Mode-Zeilenform laut makemkv.com/developers/usage.txt:
@@ -39,6 +40,22 @@ def test_makemkv_cmd_default_bleibt_all():
assert cmd[-2:] == ["all", "/out"]
def test_parse_titel_info_volle_tabelle():
"""Attr 8=Kapitel, 9=Dauer, 11=Bytes (MakeMKV apdefs.h) → Auswahl-Tabelle."""
ausgabe = """
TINFO:0,8,0,"24"
TINFO:0,9,0,"1:52:29"
TINFO:0,11,0,"31234567890"
TINFO:1,9,0,"0:03:12"
TINFO:1,11,0,"98765432"
"""
titel = parse_titel_info(ausgabe)
assert titel == [
{"nr": 0, "dauer_s": 6749, "groesse_bytes": 31234567890, "kapitel": 24},
{"nr": 1, "dauer_s": 192, "groesse_bytes": 98765432, "kapitel": 0},
]
def test_parse_scan_dauer():
ausgabe = "\n + duration: 01:52:29\n"
assert parse_scan_dauer(ausgabe) == 6749