diff --git a/docker/api/main.py b/docker/api/main.py index 52040dc..21e3bce 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -658,6 +658,12 @@ class JobCreateRequest(BaseModel): main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle) transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node) + # Sprachauswahl für DIESEN Rip (ISO-639-2, z. B. ["deu","eng"]). + # Leer = alles behalten. Greift bei der Kompression, nicht beim Rippen — + # der Rip bleibt vollständig und verlustfrei (Begründung in + # worker/ripping.build_handbrake_cmd). + audio_sprachen: Optional[List[str]] = None + untertitel_sprachen: Optional[List[str]] = None # Arbeitsverzeichnis NUR für diesen Rip (Commander-Wunsch 25.07.2026: # beim Start wählbar, nicht global vorgegeben). Leer = der Wert aus # Einstellungen → Verarbeitung, der auch für Vollautomatik-Rips gilt. @@ -721,6 +727,14 @@ async def create_job(request: JobCreateRequest): meta_dict["titles"] = titel_liste if request.transcode_node: meta_dict["transcode_node"] = request.transcode_node + # Sprachauswahl dieses Rips. Nur schreiben, wenn wirklich gewählt wurde — + # eine leere Liste würde im Worker als „alles" gelesen, was derselbe Fall + # ist, aber die Absicht verschleiert. + for feld, wert in (("audio_sprachen", request.audio_sprachen), + ("untertitel_sprachen", request.untertitel_sprachen)): + sauber = [str(s).strip().lower() for s in (wert or []) if str(s).strip()] + if sauber: + meta_dict[feld] = sauber # Arbeitsverzeichnis dieses Rips. Dieselbe Pfad-Härte wie beim Ziel: muss # unter /app/media liegen, damit man nicht versehentlich 100 GB Rohdaten # irgendwohin in den Container schreibt. diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx index ceed4f0..b5fdc79 100644 --- a/docker/ui/src/components/DeviceDiscovery.tsx +++ b/docker/ui/src/components/DeviceDiscovery.tsx @@ -91,6 +91,10 @@ export default function DeviceDiscovery() { ...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}), ...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}), ...(optionen?.workDir ? { work_dir: optionen.workDir } : {}), + // Sprachauswahl dieses Rips (leer = alles behalten) + ...(optionen?.audioSprachen?.length ? { audio_sprachen: optionen.audioSprachen } : {}), + ...(optionen?.untertitelSprachen?.length + ? { untertitel_sprachen: optionen.untertitelSprachen } : {}), }) setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`) toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"') diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx index 9bee217..ef3b413 100644 --- a/docker/ui/src/components/RipTargetModal.tsx +++ b/docker/ui/src/components/RipTargetModal.tsx @@ -21,6 +21,17 @@ export interface RipOptionen { titles?: number[] transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto workDir?: string // Arbeitsverzeichnis für die Rohdaten; leer = Einstellung + // Sprachauswahl (ISO-639-2). Leer = alles behalten. Wirkt bei der + // KOMPRESSION — der Rip bleibt vollständig und verlustfrei. + audioSprachen?: string[] + untertitelSprachen?: string[] +} + +// Eine Sprache, die die Disc anbietet (aus dem Titel-Scan, MakeMKV SINFO). +interface DiscSprache { + lang: string // ISO-639-2, z. B. "deu" + sprache: string // Klartext, z. B. "German" + spuren: number } // Ein Ablageziel aus GET /storage-targets. @@ -85,6 +96,25 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI const [gewaehlt, setGewaehlt] = useState>(new Set()) const [workers, setWorkers] = useState([]) const [encoderNode, setEncoderNode] = useState('') // '' = automatisch + /* + * Sprachen der Disc (Commander-Anforderung 26.07.2026: „Die Disc hat Material + * in X Sprachen und X Untertiteln — Rippy muss VOR dem Rip fragen: Was genau + * willst du haben?"). + * + * Die Auskunft kommt aus demselben Titel-Scan, der schon für die + * Titel-Auswahl läuft — MakeMKV liefert sie in derselben Ausgabe mit, sie + * wurde bisher nur weggeworfen. + * + * Leere Auswahl heißt bewusst „alles behalten": Wer nichts anklickt, bekommt + * das Verhalten von vorher, und niemand verliert versehentlich seine Tonspur. + */ + const [discSprachen, setDiscSprachen] = useState<{ audio: DiscSprache[], untertitel: DiscSprache[] } | null>(null) + const [audioWahl, setAudioWahl] = useState>(new Set()) + const [untertitelWahl, setUntertitelWahl] = useState>(new Set()) + // Wunschsprachen aus den Einstellungen — sie werden beim Scan vorausgewählt, + // soweit die Disc sie überhaupt hat. + const [standardAudio, setStandardAudio] = useState([]) + const [standardUntertitel, setStandardUntertitel] = useState([]) const scanStarten = async () => { if (!deviceId) return @@ -107,6 +137,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI const tracks: TitelInfo[] = r.data.tracks || [] setTitelListe(tracks) setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr))) + const spr = r.data.sprachen || null + setDiscSprachen(spr) + // Wunschsprachen vorauswählen — aber nur, was die Disc wirklich hat. + // Sonst stünde da eine Auswahl, die nichts bewirkt. + if (spr) { + const vorhanden = (liste: DiscSprache[], wunsch: string[]) => + new Set(liste.filter(s => wunsch.includes(s.lang)).map(s => s.lang)) + setAudioWahl(vorhanden(spr.audio || [], standardAudio)) + setUntertitelWahl(vorhanden(spr.untertitel || [], standardUntertitel)) + } setScanStatus('done') } else if (r.data.status === 'error') { setScanFehler(r.data.error || 'Scan fehlgeschlagen') @@ -155,6 +195,9 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI setEncoderNode('') setArbeitsDir('') setBrowserOffen(false) + setDiscSprachen(null) + setAudioWahl(new Set()) + setUntertitelWahl(new Set()) api.get('/storage-targets') .then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : [])) .catch(() => setArbeitsZiele([])) @@ -166,6 +209,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI const s = r.data || {} setNurHauptfilm(!!s.mainFeatureOnly) setStandardArbeitsDir((s.workDir || '').trim()) + const codes = (wert: any) => String(wert || '') + .split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean) + setStandardAudio(codes(s.audioSprachen)) + setStandardUntertitel(codes(s.untertitelSprachen)) const basis = s.outputDir || '/app/media' setTargets([ { id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true }, @@ -216,6 +263,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI } if (encoderNode) optionen.transcodeNode = encoderNode if (arbeitsDir) optionen.workDir = arbeitsDir + if (audioWahl.size > 0) optionen.audioSprachen = [...audioWahl] + if (untertitelWahl.size > 0) optionen.untertitelSprachen = [...untertitelWahl] const target = targets.find(t => t.type === selectedType) if (customPath) { onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen) @@ -430,6 +479,80 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI )} + {/* Sprachen der Disc — steht direkt unter der Titel-Auswahl, weil beides + aus DEMSELBEN Scan kommt. Vorher warf Rippy diese Auskunft weg. */} + {scanStatus === 'done' && discSprachen + && (discSprachen.audio.length > 0 || discSprachen.untertitel.length > 0) && ( +
+
+ + Sprachen auf dieser Disc + +

+ {discSprachen.audio.length} Tonsprache{discSprachen.audio.length === 1 ? '' : 'n'} + {discSprachen.untertitel.length > 0 + && `, ${discSprachen.untertitel.length} Untertitelsprache${discSprachen.untertitel.length === 1 ? '' : 'n'}`} + {' — '}nichts angeklickt heißt alles behalten. +

+
+ +
+ {(['audio', 'untertitel'] as const).map(art => { + const liste = discSprachen[art] + if (liste.length === 0) return null + const wahl = art === 'audio' ? audioWahl : untertitelWahl + const setzen = art === 'audio' ? setAudioWahl : setUntertitelWahl + return ( +
+

+ {art === 'audio' ? 'Tonspuren' : 'Untertitel'} +

+
+ {liste.map(s => { + const an = wahl.has(s.lang) + return ( + + ) + })} +
+
+ ) + })} + + {/* Der wichtigste Satz: WANN die Auswahl greift. Sonst glaubt man, + die Disc würde unvollständig gerippt. */} +

+ {audioWahl.size > 0 || untertitelWahl.size > 0 ? ( + <> + Der Rip selbst bleibt vollständig und verlustfrei — die + Auswahl wirkt beim Komprimieren. Du kannst also später jederzeit + „Neu komprimieren" mit anderen Sprachen wählen, ohne die Disc noch + einmal einzulegen. + + ) : ( + <>Ohne Auswahl behält Rippy alle Sprachen — wie bisher. + )} +

+
+
+ )} + {/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */}
+ {/* + Wunschsprachen (Commander-Anforderung 26.07.2026: die Auswahl + muss „in der Automatik ebenfalls einstellbar" sein). + + Sie greifen bei der KOMPRESSION, nicht beim Rippen — der Rip + bleibt vollständig und verlustfrei (Muss-Feature). Damit lässt + sich später mit anderer Sprachwahl neu komprimieren, ohne die + Disc wieder einzulegen. + */} +
+
+

Wunschsprachen

+

+ Gilt für Vollautomatik-Rips und ist im Rip-Dialog vorausgewählt. + Leer = alle Sprachen behalten. +

+
+ + handleChange('audioSprachen', e.target.value)} + placeholder="z. B. deu,eng,jpn" + /> + handleChange('untertitelSprachen', e.target.value)} + placeholder="z. B. deu" + /> + +

+ Codes sind dreibuchstabig: deu Deutsch, + {' '}eng Englisch, + {' '}jpn Japanisch, + {' '}fra Französisch, + {' '}spa Spanisch, + {' '}ita Italienisch. + Welche eine Disc wirklich hat, zeigt Rippy beim „Disc scannen" im + Rip-Dialog — dort ist die Auswahl auch je Disc änderbar. +

+

+ Wichtig: Diese Wahl wirkt beim Komprimieren. Der Rip + selbst sichert immer alles — sonst wäre eine spätere Änderung nur mit + neuem Rip möglich. +

+
+
list: 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).""" +# Attribut-IDs der SINFO-Zeilen. Am 26.07.2026 an der Akira-Blu-ray im Laufwerk +# der Rippy-VM abgenommen (AGENTS Regel D): +# +# SINFO:0,1,1,6202,"Audio" +# SINFO:0,1,3,0,"deu" +# SINFO:0,1,4,0,"German" +# SINFO:0,1,6,0,"DTS-HD MA" +# SINFO:0,1,14,0,"2" +# SINFO:0,1,30,0,"DTS-HD MA Stereo German" +# +# ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29. Die tragen auf JEDEM Stream +# „eng"/„English", auch auf dem Videostream einer deutschen Tonspur; das ist +# MakeMKVs eigene Anzeigesprache und nicht die des Tracks. Wer 28 nimmt, hält +# jede Disc für englisch. +SINFO_TYP = 1 +SINFO_SPRACHE_CODE = 3 +SINFO_SPRACHE_NAME = 4 +SINFO_CODEC = 6 +SINFO_KANAELE = 14 +SINFO_BESCHREIBUNG = 30 + +SINFO_ZEILE = re.compile(r'SINFO:(\d+),(\d+),(\d+),\d+,"(.*)"\s*$') + + +def parse_stream_info(ausgabe: str) -> dict: + """SINFO-Zeilen → Tonspuren und Untertitel je Titel (pure Funktion). + + Commander-Anforderung 26.07.2026: *„Es muss verfügbar sein, dass man die + Sprache von Medien auswählen kann … Rippy muss VOR dem Rip fragen: Was genau + willst du haben?"* Die Auskunft liegt längst vor — der Titel-Scan liest sie + mit und hat sie bisher weggeworfen. + + Rückgabe: {titel_nr: {"audio": [...], "untertitel": [...]}}, jeder Eintrag + {"nr", "lang", "sprache", "codec", "kanaele", "beschreibung"}. + """ + titel: dict = {} + for zeile in (ausgabe or "").splitlines(): + treffer = SINFO_ZEILE.match(zeile.strip()) + if not treffer: + continue + t_nr, s_nr, attr = (int(treffer.group(i)) for i in (1, 2, 3)) + wert = treffer.group(4) + streams = titel.setdefault(t_nr, {}) + eintrag = streams.setdefault(s_nr, {"nr": s_nr, "typ": "", "lang": "", + "sprache": "", "codec": "", + "kanaele": "", "beschreibung": ""}) + if attr == SINFO_TYP: + eintrag["typ"] = wert + elif attr == SINFO_SPRACHE_CODE: + eintrag["lang"] = wert + elif attr == SINFO_SPRACHE_NAME: + eintrag["sprache"] = wert + elif attr == SINFO_CODEC: + eintrag["codec"] = wert + elif attr == SINFO_KANAELE: + eintrag["kanaele"] = wert + elif attr == SINFO_BESCHREIBUNG: + eintrag["beschreibung"] = wert + + ergebnis = {} + for t_nr, streams in titel.items(): + audio, untertitel = [], [] + for s_nr in sorted(streams): + s = streams[s_nr] + # MakeMKV schreibt „Subtitles" (englisch, Plural) — gemessen. + if s["typ"] == "Audio": + audio.append(s) + elif s["typ"].startswith("Subtitle"): + untertitel.append(s) + ergebnis[t_nr] = {"audio": audio, "untertitel": untertitel} + return ergebnis + + +def sprachen_zusammenfassen(streams_je_titel: dict) -> dict: + """Welche Sprachen hat die DISC insgesamt? (pure Funktion) + + Für die Frage vor dem Rip zählt nicht Titel 3 Stream 7, sondern: „Diese Disc + hat Ton auf Deutsch, Englisch und Japanisch, Untertitel auf Deutsch und + Englisch." Deshalb über alle Titel vereinigt, Reihenfolge stabil (so wie sie + auf der Disc stehen). + + Rückgabe: {"audio": [{"lang","sprache","spuren"}], "untertitel": [...]} + """ + def sammeln(schluessel): + gefunden = {} + for streams in (streams_je_titel or {}).values(): + for s in streams.get(schluessel) or []: + code = s.get("lang") or "" + if not code: + continue + eintrag = gefunden.setdefault( + code, {"lang": code, "sprache": s.get("sprache") or code, + "spuren": 0}) + eintrag["spuren"] += 1 + return list(gefunden.values()) + + return {"audio": sammeln("audio"), "untertitel": sammeln("untertitel")} + + +def lies_titel_info(device_path: str, timeout: int = 300) -> tuple: + """(Titel-Liste, Streams je Titel) der eingelegten Disc — EIN Info-Lauf. + + Beides aus demselben Aufruf: Ein `makemkvcon info` dauert je Disc 20–120 s, + und die Sprach-Auskunft steht in derselben Ausgabe. Sie zweimal zu holen + wäre eine Minute Wartezeit für nichts. + """ 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 "") + ausgabe = ergebnis.stdout or "" + return parse_titel_info(ausgabe), parse_stream_info(ausgabe) def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list, @@ -393,22 +498,49 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str: return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET -def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET) -> list: +def build_handbrake_cmd(input_path: str, output_path: str, + preset: str = DEFAULT_HB_PRESET, + audio_sprachen=None, untertitel_sprachen=None) -> list: """Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion). Arbeitet auf der MKV-DATEI aus dem MakeMKV-Rip — nie auf dem Laufwerk: HandBrake kann AACS-verschlüsselte Blu-rays nicht lesen, MakeMKV bleibt deshalb zwingend die erste Stufe. --all-audio/--all-subtitles behalten alle Sprachen (Preset-Default wäre nur die erste Tonspur). + + ## Die Sprachauswahl greift HIER, nicht beim Rippen + + Commander-Anforderung 26.07.2026. Bewusst in der KOMPRESSION und nicht im + Rip, aus drei Gründen: + + 1. Der Rip bleibt vollständig und verlustfrei — das ist ein Muss-Feature + (KONZEPT). Wer später eine andere Sprachwahl will, komprimiert neu, statt + die Disc wieder einzulegen. + 2. MakeMKV kann Tracks nur über eine Auswahl-Profildatei filtern; HandBrake + hat dafür dokumentierte Schalter, die genau die Codes nehmen, die MakeMKV + liefert (ISO 639-2, „deu"/„eng" — beides am 26.07.2026 gegengeprüft). + 3. Wird ein Disc-Typ NICHT komprimiert (4K verlustfrei), bleiben ohnehin alle + Sprachen drin — und das ist bei einer verlustfreien Ablage richtig. + + `--audio-lang-list` zusammen mit `--all-audio` heißt laut HandBrake-Hilfe: + alle Tonspuren, die zu einer der Sprachen passen. Leere Liste = alles, wie + bisher. """ - return [ + befehl = [ "HandBrakeCLI", "--input", input_path, "--output", output_path, "--preset", preset, - "--all-audio", - "--all-subtitles", ] + audio = [s for s in (audio_sprachen or []) if s] + if audio: + befehl += ["--audio-lang-list", ",".join(audio)] + befehl.append("--all-audio") + untertitel = [s for s in (untertitel_sprachen or []) if s] + if untertitel: + befehl += ["--subtitle-lang-list", ",".join(untertitel)] + befehl.append("--all-subtitles") + return befehl HB_ENCODE_RE = re.compile( @@ -449,8 +581,28 @@ def get_progress_from_line(line: str) -> int: return int(((aktuell - 1) * 100 + prozent) / gesamt) +def sprachliste(wert) -> list: + """„deu, eng , ,DEU" → ["deu", "eng"] (pure Funktion). + + Nimmt Komma-Text aus den Einstellungen oder eine fertige Liste. Doppelte und + Leeres fliegen raus, Kleinschreibung wird erzwungen — HandBrake will + ISO-639-2 in Kleinbuchstaben, und ein „DEU" aus einem Eingabefeld soll nicht + still die ganze Auswahl unwirksam machen. + """ + if not wert: + return [] + teile = wert.split(",") if isinstance(wert, str) else list(wert) + sauber = [] + for t in teile: + code = str(t).strip().lower() + if code and code not in sauber: + sauber.append(code) + return sauber + + def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, - progress_cb=None, abbruch_cb=None) -> dict: + progress_cb=None, abbruch_cb=None, + audio_sprachen=None, untertitel_sprachen=None) -> dict: """Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt. `abbruch_cb` wird bei JEDER Ausgabezeile aufgerufen und soll RipAbbruch @@ -467,7 +619,8 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR try: process = subprocess.Popen( - build_handbrake_cmd(input_path, output_path, preset), + build_handbrake_cmd(input_path, output_path, preset, + audio_sprachen, untertitel_sprachen), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, diff --git a/docker/worker/tasks.py b/docker/worker/tasks.py index f56ae54..7509643 100644 --- a/docker/worker/tasks.py +++ b/docker/worker/tasks.py @@ -47,6 +47,8 @@ from ripping import ( rip_cd, rip_video, run_handbrake, + sprachen_zusammenfassen, + sprachliste, wirf_disc_aus, ) @@ -690,9 +692,20 @@ def scan_tracks(device_path: str): 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") + # Ein Info-Lauf liefert BEIDES: Titel-Liste und die Sprachen. Die + # Sprach-Auskunft stand schon immer in derselben Ausgabe und wurde nur + # weggeworfen (Commander-Anforderung 26.07.2026: vor dem Rip fragen, + # was man haben will). + titel, streams = lies_titel_info(device_path) + sprachen = sprachen_zusammenfassen(streams) + db.save_settings( + {"status": "done", "tracks": titel, "sprachen": sprachen}, key) + db.add_log( + "info", "worker", + f"Titel-Scan {device_path}: {len(titel)} Titel, " + f"{len(sprachen['audio'])} Tonsprache(n), " + f"{len(sprachen['untertitel'])} Untertitelsprache(n)", + ) return {"status": "done", "anzahl": len(titel)} except Exception as e: db.save_settings({"status": "error", "error": str(e)[:300]}, key) @@ -817,12 +830,27 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str): preset = preset_fuer(disc_type, einstellungen) original_behalten = einstellungen.get("keepOriginal", False) + # Sprachauswahl: Wahl für DIESEN Rip (aus meta) schlägt die Einstellung. + # Leer heißt „alles behalten" — genau das Verhalten von vorher. + try: + job_meta = json.loads(job.get("meta") or "{}") + except ValueError: + job_meta = {} + audio_sprachen = sprachliste( + job_meta.get("audio_sprachen") or einstellungen.get("audioSprachen")) + untertitel_sprachen = sprachliste( + job_meta.get("untertitel_sprachen") or einstellungen.get("untertitelSprachen")) + os.makedirs(final_dir, exist_ok=True) db.update_job(job_id, status="transcoding", progress=0, error=None) db.add_log( "info", "worker", f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), " - f"Disc-Typ '{disc_type or 'unbekannt'}', Preset '{preset}')", + f"Disc-Typ '{disc_type or 'unbekannt'}', Preset '{preset}'" + + (f", Ton: {','.join(audio_sprachen)}" if audio_sprachen else ", Ton: alle") + + (f", Untertitel: {','.join(untertitel_sprachen)}" + if untertitel_sprachen else ", Untertitel: alle") + + ")", ) anzahl = len(quellen) @@ -865,6 +893,8 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str): quelle, ziel, preset=preset, progress_cb=datei_fortschritt, abbruch_cb=abbruch_pruefen, + audio_sprachen=audio_sprachen, + untertitel_sprachen=untertitel_sprachen, ) if hb.get("status") == "cancelled": _job_abschliessen(job_id, hb) diff --git a/docker/worker/test_ripping_helpers.py b/docker/worker/test_ripping_helpers.py index 2004fae..9f57d45 100644 --- a/docker/worker/test_ripping_helpers.py +++ b/docker/worker/test_ripping_helpers.py @@ -464,3 +464,131 @@ def test_auswurf_ohne_laufwerk_wirft_nicht(): assert ripping.wirf_disc_aus( "/dev/sr9", ioctl_fn=lambda *a: 0, oeffnen=oeffnen_kaputt, schliessen=lambda fd: None, warten=lambda s: None) is False + + +# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) ----------- +# +# Woertlich aus `makemkvcon -r --noscan info dev:/dev/sr0` im Worker-Container +# der Rippy-VM, gekuerzt auf die Attribute, die Rippy liest (AGENTS Regel D). + +SINFO_ECHT = """SINFO:0,0,1,6201,"Video" +SINFO:0,0,19,0,"1920x1080" +SINFO:0,0,28,0,"eng" +SINFO:0,0,29,0,"English" +SINFO:0,1,1,6202,"Audio" +SINFO:0,1,3,0,"deu" +SINFO:0,1,4,0,"German" +SINFO:0,1,6,0,"DTS-HD MA" +SINFO:0,1,14,0,"2" +SINFO:0,1,28,0,"eng" +SINFO:0,1,29,0,"English" +SINFO:0,1,30,0,"DTS-HD MA Stereo German" +SINFO:0,2,1,6202,"Audio" +SINFO:0,2,3,0,"jpn" +SINFO:0,2,4,0,"Japanese" +SINFO:0,2,6,0,"DTS-HD MA" +SINFO:0,2,14,0,"6" +SINFO:0,2,30,0,"DTS-HD MA 5.1 Japanese" +SINFO:0,3,1,6203,"Subtitles" +SINFO:0,3,3,0,"deu" +SINFO:0,3,4,0,"German" +SINFO:0,3,6,0,"PGS" +SINFO:1,0,1,6202,"Audio" +SINFO:1,0,3,0,"eng" +SINFO:1,0,4,0,"English" +SINFO:1,0,6,0,"AC3" +""" + + +def test_streams_werden_nach_typ_getrennt(): + from ripping import parse_stream_info + + je_titel = parse_stream_info(SINFO_ECHT) + assert set(je_titel) == {0, 1} + assert [a["lang"] for a in je_titel[0]["audio"]] == ["deu", "jpn"] + assert [u["lang"] for u in je_titel[0]["untertitel"]] == ["deu"] + assert je_titel[0]["audio"][1]["kanaele"] == "6" + assert je_titel[0]["audio"][0]["codec"] == "DTS-HD MA" + + +def test_die_sprache_steht_in_attribut_3_nicht_28(): + """DIE Falle: 28/29 tragen auf JEDEM Stream "eng"/"English" - auch auf einer + deutschen Tonspur und auf dem Videostream. Das ist MakeMKVs Anzeigesprache. + Wer 28 nimmt, haelt jede Disc fuer englisch.""" + from ripping import parse_stream_info + + deutsche_spur = parse_stream_info(SINFO_ECHT)[0]["audio"][0] + assert deutsche_spur["lang"] == "deu" + assert deutsche_spur["sprache"] == "German" + + +def test_video_taucht_nicht_als_tonspur_auf(): + from ripping import parse_stream_info + + je_titel = parse_stream_info(SINFO_ECHT) + assert all(a["typ"] == "Audio" for a in je_titel[0]["audio"]) + + +def test_sprachen_werden_ueber_alle_titel_vereinigt(): + """Vor dem Rip zaehlt nicht "Titel 3 Stream 7", sondern was die DISC hat.""" + from ripping import parse_stream_info, sprachen_zusammenfassen + + s = sprachen_zusammenfassen(parse_stream_info(SINFO_ECHT)) + assert [a["lang"] for a in s["audio"]] == ["deu", "jpn", "eng"] + assert [a["sprache"] for a in s["audio"]] == ["German", "Japanese", "English"] + assert [u["lang"] for u in s["untertitel"]] == ["deu"] + # deu kommt in Titel 0 einmal vor + assert s["audio"][0]["spuren"] == 1 + + +def test_leere_ausgabe_gibt_leere_sprachen(): + from ripping import parse_stream_info, sprachen_zusammenfassen + + assert parse_stream_info("") == {} + assert sprachen_zusammenfassen({}) == {"audio": [], "untertitel": []} + assert sprachen_zusammenfassen(None) == {"audio": [], "untertitel": []} + + +# --- Sprachliste aus den Einstellungen -------------------------------------- + + +def test_sprachliste_raeumt_auf(): + from ripping import sprachliste + + assert sprachliste("deu, eng , ,DEU") == ["deu", "eng"] + assert sprachliste(["deu", "deu", "jpn"]) == ["deu", "jpn"] + assert sprachliste("") == [] + assert sprachliste(None) == [] + + +def test_grossschreibung_macht_die_auswahl_nicht_unwirksam(): + """HandBrake will ISO-639-2 klein. Ein "DEU" aus einem Eingabefeld darf nicht + still die ganze Auswahl aushebeln.""" + from ripping import sprachliste + + assert sprachliste("DEU,ENG") == ["deu", "eng"] + + +# --- Das HandBrake-Kommando ------------------------------------------------- + + +def test_handbrake_kommando_ohne_auswahl_bleibt_wie_bisher(): + from ripping import build_handbrake_cmd + + cmd = build_handbrake_cmd("/a.mkv", "/b.mkv", "H.265 MKV 1080p30") + assert "--audio-lang-list" not in cmd + assert "--subtitle-lang-list" not in cmd + assert "--all-audio" in cmd and "--all-subtitles" in cmd + + +def test_handbrake_kommando_mit_sprachauswahl(): + """--audio-lang-list zusammen mit --all-audio heisst laut HandBrake-Hilfe: + alle Tonspuren, die zu einer der Sprachen passen.""" + from ripping import build_handbrake_cmd + + cmd = build_handbrake_cmd("/a.mkv", "/b.mkv", "H.265 MKV 1080p30", + audio_sprachen=["deu", "jpn"], + untertitel_sprachen=["deu"]) + assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn" + assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu" + assert "--all-audio" in cmd and "--all-subtitles" in cmd