feat(sprachen): Rippy fragt vor dem Rip, welche Sprachen du willst
Ampel / ampel (push) Successful in 29s
Ampel / ampel (push) Successful in 29s
Commander-Anforderung: "Die Disc hat Material in X Sprachen und X Untertiteln -
Rippy muss VOR dem Rip fragen: Was genau willst du haben? In der Automatik muss
das ebenfalls einstellbar sein."
Die Auskunft lag laengst vor und wurde weggeworfen: Derselbe Titel-Scan, der die
Titel-Tabelle fuellt, liefert in derselben makemkvcon-Ausgabe die Streams mit.
Ein zweiter Info-Lauf haette eine Minute Wartezeit gekostet - jetzt kommt beides
aus einem Aufruf.
Format an der Akira-Blu-ray im Laufwerk gemessen (AGENTS Regel D):
SINFO:<titel>,<stream>,<attribut>,<code>,"<wert>"
1 = Typ ("Audio"/"Subtitles"), 3 = Sprachcode, 4 = Sprachname,
6 = Codec, 14 = Kanaele, 30 = Beschreibung
DIE FALLE dabei: Die Sprache steht in 3/4, NICHT in 28/29. Die tragen auf JEDEM
Stream "eng"/"English" - auch auf einer deutschen Tonspur und auf dem
Videostream; das ist MakeMKVs eigene Anzeigesprache. Wer 28 nimmt, haelt jede
Disc fuer englisch. Ein Test haelt das fest.
Angewendet wird die Wahl bei der KOMPRESSION, nicht beim Rippen - drei Gruende:
der Rip bleibt vollstaendig und verlustfrei (Muss-Feature laut KONZEPT); HandBrake
hat dafuer dokumentierte Schalter (--audio-lang-list / --subtitle-lang-list, die
genau die ISO-639-2-Codes nehmen, die MakeMKV liefert - beides gegengeprueft);
und wer spaeter andere Sprachen will, komprimiert neu statt die Disc wieder
einzulegen. Genau das sagt der Dialog auch, sonst glaubt man, es werde
unvollstaendig gerippt.
Nichts angeklickt heisst "alles behalten" - das Verhalten von vorher. Auch die
Einstellung ist bewusst LEER vorbelegt: Ein stilles "deu" wuerde bei einem
japanischen Original die Originaltonspur wegwerfen, ohne dass jemand gefragt hat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -658,6 +658,12 @@ class JobCreateRequest(BaseModel):
|
|||||||
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
||||||
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
||||||
transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node)
|
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:
|
# Arbeitsverzeichnis NUR für diesen Rip (Commander-Wunsch 25.07.2026:
|
||||||
# beim Start wählbar, nicht global vorgegeben). Leer = der Wert aus
|
# beim Start wählbar, nicht global vorgegeben). Leer = der Wert aus
|
||||||
# Einstellungen → Verarbeitung, der auch für Vollautomatik-Rips gilt.
|
# Einstellungen → Verarbeitung, der auch für Vollautomatik-Rips gilt.
|
||||||
@@ -721,6 +727,14 @@ async def create_job(request: JobCreateRequest):
|
|||||||
meta_dict["titles"] = titel_liste
|
meta_dict["titles"] = titel_liste
|
||||||
if request.transcode_node:
|
if request.transcode_node:
|
||||||
meta_dict["transcode_node"] = 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
|
# Arbeitsverzeichnis dieses Rips. Dieselbe Pfad-Härte wie beim Ziel: muss
|
||||||
# unter /app/media liegen, damit man nicht versehentlich 100 GB Rohdaten
|
# unter /app/media liegen, damit man nicht versehentlich 100 GB Rohdaten
|
||||||
# irgendwohin in den Container schreibt.
|
# irgendwohin in den Container schreibt.
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ export default function DeviceDiscovery() {
|
|||||||
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
||||||
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
||||||
...(optionen?.workDir ? { work_dir: optionen.workDir } : {}),
|
...(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`)
|
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
||||||
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ export interface RipOptionen {
|
|||||||
titles?: number[]
|
titles?: number[]
|
||||||
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
|
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
|
||||||
workDir?: string // Arbeitsverzeichnis für die Rohdaten; leer = Einstellung
|
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.
|
// Ein Ablageziel aus GET /storage-targets.
|
||||||
@@ -85,6 +96,25 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||||
const [workers, setWorkers] = useState<WorkerWahl[]>([])
|
const [workers, setWorkers] = useState<WorkerWahl[]>([])
|
||||||
const [encoderNode, setEncoderNode] = useState('') // '' = automatisch
|
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<Set<string>>(new Set())
|
||||||
|
const [untertitelWahl, setUntertitelWahl] = useState<Set<string>>(new Set())
|
||||||
|
// Wunschsprachen aus den Einstellungen — sie werden beim Scan vorausgewählt,
|
||||||
|
// soweit die Disc sie überhaupt hat.
|
||||||
|
const [standardAudio, setStandardAudio] = useState<string[]>([])
|
||||||
|
const [standardUntertitel, setStandardUntertitel] = useState<string[]>([])
|
||||||
|
|
||||||
const scanStarten = async () => {
|
const scanStarten = async () => {
|
||||||
if (!deviceId) return
|
if (!deviceId) return
|
||||||
@@ -107,6 +137,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const tracks: TitelInfo[] = r.data.tracks || []
|
const tracks: TitelInfo[] = r.data.tracks || []
|
||||||
setTitelListe(tracks)
|
setTitelListe(tracks)
|
||||||
setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr)))
|
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')
|
setScanStatus('done')
|
||||||
} else if (r.data.status === 'error') {
|
} else if (r.data.status === 'error') {
|
||||||
setScanFehler(r.data.error || 'Scan fehlgeschlagen')
|
setScanFehler(r.data.error || 'Scan fehlgeschlagen')
|
||||||
@@ -155,6 +195,9 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
setEncoderNode('')
|
setEncoderNode('')
|
||||||
setArbeitsDir('')
|
setArbeitsDir('')
|
||||||
setBrowserOffen(false)
|
setBrowserOffen(false)
|
||||||
|
setDiscSprachen(null)
|
||||||
|
setAudioWahl(new Set())
|
||||||
|
setUntertitelWahl(new Set())
|
||||||
api.get('/storage-targets')
|
api.get('/storage-targets')
|
||||||
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
|
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
|
||||||
.catch(() => setArbeitsZiele([]))
|
.catch(() => setArbeitsZiele([]))
|
||||||
@@ -166,6 +209,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const s = r.data || {}
|
const s = r.data || {}
|
||||||
setNurHauptfilm(!!s.mainFeatureOnly)
|
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||||
setStandardArbeitsDir((s.workDir || '').trim())
|
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'
|
const basis = s.outputDir || '/app/media'
|
||||||
setTargets([
|
setTargets([
|
||||||
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
|
{ 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 (encoderNode) optionen.transcodeNode = encoderNode
|
||||||
if (arbeitsDir) optionen.workDir = arbeitsDir
|
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)
|
const target = targets.find(t => t.type === selectedType)
|
||||||
if (customPath) {
|
if (customPath) {
|
||||||
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
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
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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) && (
|
||||||
|
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||||
|
<div className="px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">
|
||||||
|
Sprachen auf dieser Disc
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{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 <strong>alles behalten</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{(['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 (
|
||||||
|
<div key={art}>
|
||||||
|
<p className="text-xs font-medium mb-1.5 text-slate-700 dark:text-slate-300">
|
||||||
|
{art === 'audio' ? 'Tonspuren' : 'Untertitel'}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{liste.map(s => {
|
||||||
|
const an = wahl.has(s.lang)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.lang}
|
||||||
|
onClick={() => {
|
||||||
|
const neu = new Set(wahl)
|
||||||
|
if (an) neu.delete(s.lang); else neu.add(s.lang)
|
||||||
|
setzen(neu)
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||||
|
an
|
||||||
|
? 'bg-amber-500 border-amber-500 text-slate-950 font-bold'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.sprache}
|
||||||
|
<span className="opacity-70"> ({s.lang})</span>
|
||||||
|
{s.spuren > 1 && <span className="opacity-70"> · {s.spuren}×</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Der wichtigste Satz: WANN die Auswahl greift. Sonst glaubt man,
|
||||||
|
die Disc würde unvollständig gerippt. */}
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{audioWahl.size > 0 || untertitelWahl.size > 0 ? (
|
||||||
|
<>
|
||||||
|
Der Rip selbst bleibt <strong>vollständig und verlustfrei</strong> — 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.</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */}
|
{/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */}
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ interface SettingsState {
|
|||||||
jellyfinApiKey: string
|
jellyfinApiKey: string
|
||||||
workDir: string
|
workDir: string
|
||||||
makemkvAppKey: string
|
makemkvAppKey: string
|
||||||
|
// Wunschsprachen (ISO-639-2, Komma-Liste). Greifen bei der Kompression und
|
||||||
|
// sind im Rip-Dialog vorausgewählt. Leer = alle Sprachen behalten.
|
||||||
|
audioSprachen: string
|
||||||
|
untertitelSprachen: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: SettingsState = {
|
const defaultSettings: SettingsState = {
|
||||||
@@ -63,6 +67,11 @@ const defaultSettings: SettingsState = {
|
|||||||
jellyfinApiKey: '',
|
jellyfinApiKey: '',
|
||||||
workDir: '',
|
workDir: '',
|
||||||
makemkvAppKey: '',
|
makemkvAppKey: '',
|
||||||
|
// Leer = alles behalten. Bewusst kein Vorgabewert: Eine stille Vorauswahl
|
||||||
|
// („deu") würde bei einem japanischen Original die Originaltonspur wegwerfen,
|
||||||
|
// ohne dass jemand danach gefragt hat.
|
||||||
|
audioSprachen: '',
|
||||||
|
untertitelSprachen: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system'
|
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system'
|
||||||
@@ -826,6 +835,54 @@ export default function SettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
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.
|
||||||
|
*/}
|
||||||
|
<div className="space-y-3 p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Wunschsprachen</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Gilt für Vollautomatik-Rips und ist im Rip-Dialog vorausgewählt.
|
||||||
|
<strong className="text-slate-700 dark:text-slate-300"> Leer = alle Sprachen behalten.</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Tonspuren (ISO-639-2, mit Komma getrennt)"
|
||||||
|
value={settings.audioSprachen}
|
||||||
|
onChange={(e) => handleChange('audioSprachen', e.target.value)}
|
||||||
|
placeholder="z. B. deu,eng,jpn"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Untertitel (ISO-639-2, mit Komma getrennt)"
|
||||||
|
value={settings.untertitelSprachen}
|
||||||
|
onChange={(e) => handleChange('untertitelSprachen', e.target.value)}
|
||||||
|
placeholder="z. B. deu"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Codes sind dreibuchstabig: <span className="font-mono">deu</span> Deutsch,
|
||||||
|
{' '}<span className="font-mono">eng</span> Englisch,
|
||||||
|
{' '}<span className="font-mono">jpn</span> Japanisch,
|
||||||
|
{' '}<span className="font-mono">fra</span> Französisch,
|
||||||
|
{' '}<span className="font-mono">spa</span> Spanisch,
|
||||||
|
{' '}<span className="font-mono">ita</span> Italienisch.
|
||||||
|
Welche eine Disc wirklich hat, zeigt Rippy beim „Disc scannen" im
|
||||||
|
Rip-Dialog — dort ist die Auswahl auch je Disc änderbar.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Wichtig: Diese Wahl wirkt beim <strong>Komprimieren</strong>. Der Rip
|
||||||
|
selbst sichert immer alles — sonst wäre eine spätere Änderung nur mit
|
||||||
|
neuem Rip möglich.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||||
<Toggle
|
<Toggle
|
||||||
label="Original behalten"
|
label="Original behalten"
|
||||||
|
|||||||
+162
-9
@@ -209,13 +209,118 @@ def parse_titel_info(ausgabe: str) -> list:
|
|||||||
return [titel[nr] for nr in sorted(titel)]
|
return [titel[nr] for nr in sorted(titel)]
|
||||||
|
|
||||||
|
|
||||||
def lies_titel_info(device_path: str, timeout: int = 300) -> list:
|
# Attribut-IDs der SINFO-Zeilen. Am 26.07.2026 an der Akira-Blu-ray im Laufwerk
|
||||||
"""Volle Titel-Liste der eingelegten Disc (makemkvcon info, Robot-Mode)."""
|
# 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(
|
ergebnis = subprocess.run(
|
||||||
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
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,
|
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
|
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).
|
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
|
||||||
|
|
||||||
Arbeitet auf der MKV-DATEI aus dem MakeMKV-Rip — nie auf dem Laufwerk:
|
Arbeitet auf der MKV-DATEI aus dem MakeMKV-Rip — nie auf dem Laufwerk:
|
||||||
HandBrake kann AACS-verschlüsselte Blu-rays nicht lesen, MakeMKV bleibt
|
HandBrake kann AACS-verschlüsselte Blu-rays nicht lesen, MakeMKV bleibt
|
||||||
deshalb zwingend die erste Stufe. --all-audio/--all-subtitles behalten
|
deshalb zwingend die erste Stufe. --all-audio/--all-subtitles behalten
|
||||||
alle Sprachen (Preset-Default wäre nur die erste Tonspur).
|
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",
|
"HandBrakeCLI",
|
||||||
"--input", input_path,
|
"--input", input_path,
|
||||||
"--output", output_path,
|
"--output", output_path,
|
||||||
"--preset", preset,
|
"--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(
|
HB_ENCODE_RE = re.compile(
|
||||||
@@ -449,8 +581,28 @@ def get_progress_from_line(line: str) -> int:
|
|||||||
return int(((aktuell - 1) * 100 + prozent) / gesamt)
|
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,
|
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.
|
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt.
|
||||||
|
|
||||||
`abbruch_cb` wird bei JEDER Ausgabezeile aufgerufen und soll RipAbbruch
|
`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:
|
try:
|
||||||
process = subprocess.Popen(
|
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,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
|
|||||||
+34
-4
@@ -47,6 +47,8 @@ from ripping import (
|
|||||||
rip_cd,
|
rip_cd,
|
||||||
rip_video,
|
rip_video,
|
||||||
run_handbrake,
|
run_handbrake,
|
||||||
|
sprachen_zusammenfassen,
|
||||||
|
sprachliste,
|
||||||
wirf_disc_aus,
|
wirf_disc_aus,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -690,9 +692,20 @@ def scan_tracks(device_path: str):
|
|||||||
key = f"tracks:{device_path}"
|
key = f"tracks:{device_path}"
|
||||||
db.save_settings({"status": "running"}, key)
|
db.save_settings({"status": "running"}, key)
|
||||||
try:
|
try:
|
||||||
titel = lies_titel_info(device_path)
|
# Ein Info-Lauf liefert BEIDES: Titel-Liste und die Sprachen. Die
|
||||||
db.save_settings({"status": "done", "tracks": titel}, key)
|
# Sprach-Auskunft stand schon immer in derselben Ausgabe und wurde nur
|
||||||
db.add_log("info", "worker", f"Titel-Scan {device_path}: {len(titel)} Titel gefunden")
|
# 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)}
|
return {"status": "done", "anzahl": len(titel)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.save_settings({"status": "error", "error": str(e)[:300]}, key)
|
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)
|
preset = preset_fuer(disc_type, einstellungen)
|
||||||
original_behalten = einstellungen.get("keepOriginal", False)
|
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)
|
os.makedirs(final_dir, exist_ok=True)
|
||||||
db.update_job(job_id, status="transcoding", progress=0, error=None)
|
db.update_job(job_id, status="transcoding", progress=0, error=None)
|
||||||
db.add_log(
|
db.add_log(
|
||||||
"info", "worker",
|
"info", "worker",
|
||||||
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), "
|
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)
|
anzahl = len(quellen)
|
||||||
@@ -865,6 +893,8 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
|||||||
quelle, ziel, preset=preset,
|
quelle, ziel, preset=preset,
|
||||||
progress_cb=datei_fortschritt,
|
progress_cb=datei_fortschritt,
|
||||||
abbruch_cb=abbruch_pruefen,
|
abbruch_cb=abbruch_pruefen,
|
||||||
|
audio_sprachen=audio_sprachen,
|
||||||
|
untertitel_sprachen=untertitel_sprachen,
|
||||||
)
|
)
|
||||||
if hb.get("status") == "cancelled":
|
if hb.get("status") == "cancelled":
|
||||||
_job_abschliessen(job_id, hb)
|
_job_abschliessen(job_id, hb)
|
||||||
|
|||||||
@@ -464,3 +464,131 @@ def test_auswurf_ohne_laufwerk_wirft_nicht():
|
|||||||
assert ripping.wirf_disc_aus(
|
assert ripping.wirf_disc_aus(
|
||||||
"/dev/sr9", ioctl_fn=lambda *a: 0, oeffnen=oeffnen_kaputt,
|
"/dev/sr9", ioctl_fn=lambda *a: 0, oeffnen=oeffnen_kaputt,
|
||||||
schliessen=lambda fd: None, warten=lambda s: None) is False
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user