Track-Auswahl-Tabelle, nativer Windows-Worker (via UI angeboten), Anleitungs-Tab
Ampel / ampel (push) Successful in 29s
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 20–120 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user