From bd6409038efddce8c25f0f439e3640736459de73 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 23 Jul 2026 15:33:27 +0200 Subject: [PATCH] UDF-Volume-Label-Leser (Blu-rays haben keine ISO-Bridge) + Etappe 12 (ARM) Praxis-Befund mit echter BD-50: reines UDF, der ISO-PVD-Leser griff nicht. Jetzt ECMA-167-Weg (AVDP Sektor 256 -> Main VDS -> PVD Tag-ID 1 -> Volume Identifier als d-string), mit Tests fuer das d-string-Parsing. ROADMAP Etappe 12 aus der ARM-Vollanalyse: was wir uebernehmen (Fingerprint-DB, bdmt_eng.xml, Suchdegradation, Manual Mode, Apprise, Multi-Drive, Backup-Modus) und wo wir ARM schlagen (Serien-Episoden- Matching, Main-Feature per Laufzeitabgleich). Co-Authored-By: Claude Fable 5 --- ROADMAP.md | 43 +++++++++++++++++++ docker/api/prescan/prescan.py | 67 +++++++++++++++++++++++++++--- docker/api/test_prescan_helpers.py | 21 +++++++++- 3 files changed, 124 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6fb6789..1d48331 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -251,3 +251,46 @@ Bind-Mounts statt devices: (EPERM), UI rief hartkodiert localhost:8000 auf. - [x] compose: devices: statt Bind-Mounts; Ausgabe aufs media-Volume - [ ] Jellyfin-Post-Processing an den Worker anbinden (NFO/Poster nach dem Rip) - [ ] Auth vor die schreibenden Endpoints (JWT existiert, schützt aber nichts) + +--- + +## Etappe 12: Besser als ARM (aus ARM-Analyse 23.07.2026) + +**Quelle:** Vollanalyse von Automatic Ripping Machine v2.24 (Code, arm.yaml, +Wiki, Issues). Was Rippy strukturell schon besser macht: Lossless-first statt +Transcode-first (ARMs BD-Default ist ein 1080p30-Downscale!), Celery+Postgres +statt Prozess-pro-udev-Event+SQLite (ARM-UI friert beim Rippen ein, #1046), +ioctl-Watcher statt fragiler udev-Ketten (ARMs größte Support-Quelle), NFO + +Jellyfin-Konvention nativ (ARM kann nur Emby-Refresh, keine NFOs). + +**Übernehmen (priorisiert):** +- [ ] **Disc-Fingerprint-DB (selbstlernend)** — ARMs beste Idee, besser gemacht: + Tabelle disc_fingerprints (DVD-CRC64/MusicBrainz-DiscID/BD-Label-Hash → + bestätigte Metadaten). Jede UI-Bestätigung schreibt zurück; zweite Disc + derselben Serie wird sofort erkannt. Lokal statt ARMs Crowd-Server + (Single Point of Failure auf Free-Hosting). +- [ ] **BD-Titel aus BDMV/META/DL/bdmt_eng.xml** als Identifikations-Stufe vor + den APIs (echter Disc-Titel statt Label-Raterei). +- [ ] **Progressive Suchdegradation** bei TMDB/OMDb: mit Jahr → ohne Jahr → + tokenweises Titel-Stripping; jede Stufe senkt die Confidence. +- [ ] **Duplikat-Schutz**: Fingerprint-Treffer → „schon gerippt am X — trotzdem?" +- [ ] **Track-Auswahl (Manual Mode), besser als ARM**: Job-Status + awaiting_selection nach makemkvcon-info; Track-Tabelle im UI mit + Heuristik-Vorauswahl; Timeout → Weiter mit Default (ARM bricht hart ab, + erlaubt nur EINE Auswahl). +- [ ] **Min/Max-Titellänge + Extras**: Bonusmaterial nach extras/ (versteht + Jellyfin nativ) statt alles flach. +- [ ] **Apprise-Benachrichtigungen**: eine Lib für Telegram/Discord/…, + Hooks auf job_completed/failed/awaiting_selection, Deep-Link ins UI. +- [ ] **Multi-Drive-Verwaltung**: drives-Tabelle (Seriennummer, Custom-Name, + UHD-fähig-Flag), parallele Rips, Job↔Drive-Zuordnung. +- [ ] **Backup-Modus** (makemkvcon backup --decrypt) als Plan B für Problem-Discs. +- [ ] **Jellyfin-Refresh** nach Ablage (POST /Library/Refresh). + +**ARM schlagen (Alleinstellung):** +- [ ] **Serien-Episoden-Erkennung** — ARMs offene Wunde seit 2019 (#395): + TVDB-Episodenlaufzeiten gegen Track-Laufzeiten matchen (±5 %, + Reihenfolge-Constraint, Disc-Nr aus Label), Vorschlag + „Show/Season 02/Show S02E05.mkv" im Preview bestätigen. +- [ ] **Main-Feature per Laufzeitabgleich** mit TMDB-Runtime (±3 min) statt + ARMs „größte Datei gewinnt" (#1297-Fehlklasse). diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py index f803e14..164867a 100644 --- a/docker/api/prescan/prescan.py +++ b/docker/api/prescan/prescan.py @@ -36,7 +36,8 @@ def read_iso_volume_label(device_path: str) -> Optional[str]: """Liest das Volume-Label aus dem ISO-9660 Primary Volume Descriptor. Sektor 16 (Offset 32768), Bytes 40-71 = Volume Identifier. Funktioniert für - DVDs und die meisten Blu-rays (UDF-Bridge) — ganz ohne Zusatzwerkzeuge. + DVDs — Blu-rays sind oft reines UDF ohne ISO-Bridge (dann greift + read_udf_volume_label). """ try: with open(device_path, "rb") as f: @@ -50,6 +51,59 @@ def read_iso_volume_label(device_path: str) -> Optional[str]: return None +def parse_udf_dstring(data: bytes) -> str: + """Dekodiert einen UDF d-string (ECMA-167 1/7.2.12; pure Funktion, testbar). + + Letztes Byte = genutzte Länge, erstes Byte = Kompressions-ID + (8 = Latin-1, 16 = UTF-16BE), dazwischen der Text. + """ + if not data: + return "" + laenge = data[-1] + if laenge < 2 or laenge > len(data) - 1: + return "" + comp_id, text = data[0], data[1:laenge] + try: + if comp_id == 8: + return text.decode("latin-1").strip() + if comp_id == 16: + return text.decode("utf-16-be").strip() + except UnicodeDecodeError: + pass + return "" + + +def read_udf_volume_label(device_path: str) -> Optional[str]: + """Liest das Volume-Label aus dem UDF Primary Volume Descriptor. + + Weg laut ECMA-167: Anchor Volume Descriptor Pointer bei Sektor 256 → + zeigt auf die Main Volume Descriptor Sequence → darin der PVD (Tag-ID 1) + mit dem Volume Identifier (d-string, 32 Bytes ab Offset 24). + """ + sektor = 2048 + try: + with open(device_path, "rb") as f: + f.seek(256 * sektor) + avdp = f.read(sektor) + if len(avdp) < 24 or int.from_bytes(avdp[0:2], "little") != 2: + return None + vds_ort = int.from_bytes(avdp[20:24], "little") + f.seek(vds_ort * sektor) + # VDS-Deskriptoren durchgehen, bis der PVD (Tag-ID 1) kommt + for _ in range(32): + block = f.read(sektor) + if len(block) < 56: + return None + tag_id = int.from_bytes(block[0:2], "little") + if tag_id == 1: + return parse_udf_dstring(block[24:56]) or None + if tag_id == 8: # Terminating Descriptor — Sequenz zu Ende + return None + except OSError: + return None + return None + + class PreScanResult: def __init__( self, @@ -129,11 +183,12 @@ class PreScan: "duration": 0 }) else: - # Titel-Quelle 1: ISO-Volume-Label direkt vom Medium — läuft - # überall. (makemkvcon gibt es NUR im Worker-Container; der - # alte Aufruf hier scheiterte in der API still und der Titel - # blieb ewig "Unknown Title".) - label = read_iso_volume_label(device_path) + # Titel-Quelle 1: Volume-Label direkt vom Medium — läuft + # überall. ISO-9660 für DVDs, UDF für Blu-rays (die haben + # meist keine ISO-Bridge). (makemkvcon gibt es NUR im + # Worker-Container; der alte Aufruf hier scheiterte in der + # API still und der Titel blieb ewig "Unknown Title".) + label = read_iso_volume_label(device_path) or read_udf_volume_label(device_path) if label: toc["title"] = normalize_disc_label(label) diff --git a/docker/api/test_prescan_helpers.py b/docker/api/test_prescan_helpers.py index fcd0836..5708f93 100644 --- a/docker/api/test_prescan_helpers.py +++ b/docker/api/test_prescan_helpers.py @@ -1,7 +1,26 @@ """Tests für die puren Pre-Scan-/OMDb-Helfer (Label-Normalisierung, Parsing).""" from clients.omdb import parse_runtime_minutes, parse_year -from prescan.prescan import normalize_disc_label +from prescan.prescan import normalize_disc_label, parse_udf_dstring + + +def test_udf_dstring_latin1(): + # d-string: [comp_id=8]["MEIN_FILM"][padding][len=10] + daten = bytes([8]) + b"MEIN_FILM" + bytes(21) + bytes([10]) + assert parse_udf_dstring(daten) == "MEIN_FILM" + + +def test_udf_dstring_utf16(): + text = "FILM".encode("utf-16-be") + daten = bytes([16]) + text + bytes(32 - 2 - len(text)) + bytes([1 + len(text)]) + assert parse_udf_dstring(daten) == "FILM" + + +def test_udf_dstring_kaputt_gibt_leer(): + assert parse_udf_dstring(b"") == "" + assert parse_udf_dstring(bytes(32)) == "" # Länge 0 + daten = bytes([8]) + b"X" + bytes(29) + bytes([99]) # Länge > Puffer + assert parse_udf_dstring(daten) == "" def test_label_normalisierung():