feat(v5): Serien-Staffelablage und Film-Extras — die Rolle bestimmt das Ziel
Ampel / ampel (push) Successful in 1m56s

Commander (01.09.2026): "Mach das gerne noch mit den Serien." und "Fuer
FILME sollte so eine auswahl auch gelten z.B. Nur hauptfeature oder
hauptfeature + extras usw. Rippy muss das auch selbst zusammenbasteln."

Wieder lagen die Teile fertig herum und waren nur nicht verbunden:
serienOrdner(), matcheEpisoden(), episodenUmbenennen() (struktur.ts) und
tvStaffel() (tmdb.ts) sind gebaut UND getestet — aufgerufen hat sie
niemand. Folgen landeten deshalb unter Filme/, und Jellyfin sah lauter
Einzelfilme statt einer Staffel.

Neu: jeder Titel im Auftrag traegt eine ROLLE (gemeinsam/nachrichten.ts,
EINE Definition fuer Kern und Fenster):

  hauptfilm -> Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
  extra     -> Filme/<Titel> (Jahr)/extras/...
  folge     -> Serien/<Titel>/Season NN/... S01E02.mkv

* Rippy schlaegt die Rolle vor (Serie -> Folge, Film -> Hauptfilm, Rest
  -> Extra), im Dialog ist sie je Titel aenderbar.
* Die Zuordnung Datei->Rolle ist EXAKT, nicht aus Dateinamen geraten: Je
  makemkvcon-Lauf kommt genau ein Titel dazu, und genau der bekommt die
  Rolle des Auftragseintrags.
* Extras liegen im Unterordner "extras" neben dem Film — die Konvention,
  die Jellyfin, Emby und Kodi als Zugaben lesen. Der Ordner entsteht nur,
  wenn es wirklich Extras gibt.
* Neuer Schnellwahl-Knopf "Hauptinhalt + Extras": alles Sehenswerte, aber
  weder Sammeltitel noch Logos/Alterskennzeichen.

Episoden-Nummern: Die Laufzeit jedes gewaehlten Titels reist im Auftrag
mit (RipAuftragTitel.dauerS), die Staffel-Laufzeiten kommen ueber einen
injizierten Rueckruf von TMDb (tvStaffel). matcheEpisoden() benennt NUR
bei EINDEUTIGER Zuordnung um. Sonst behalten die Dateien ihre Namen, und
Rippy SAGT warum — entweder "keine Staffel-Laufzeiten von TMDb" oder
"nicht eindeutig zuzuordnen". Lieber gar nicht als falsch; das war ARMs
offene Wunde #395.

Die Staffel-Nummer kommt aus dem Disc-Titel (discZusatzAbtrennen, seit
5.1.1) — sonst 1.

257 Tests gruen (vorher 254), Typpruefung sauber. Version 5.3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-01 19:37:10 +02:00
co-authored by Claude Opus 5
parent 043052b453
commit 7ce06889f8
10 changed files with 4493 additions and 4306 deletions
+18 -23
View File
@@ -1,29 +1,24 @@
Version 5.2.0
Version 5.3.0
Du wählst jetzt aus, was gerippt wird.
Bisher hat Rippy immer ALLES von der Disc geholt — auch Trailer, Werbung
und Alterskennzeichen. Ein Klick auf "Rippen" liest jetzt erst die Titel
der Disc (das dauert bis zu zwei Minuten, solange dreht das Laufwerk) und
zeigt sie dir mit Dauer, Größe und Kapitelzahl.
Serien landen jetzt dort, wo Jellyfin sie sucht.
Bisher kamen auch Folgen unter "Filme" — Jellyfin sah lauter Einzelfilme
statt einer Staffel. Jetzt legt Rippy sie unter Serien/<Titel>/Season 01
ab. Die Episoden-Nummern (S01E02) setzt es NUR, wenn die Laufzeiten
eindeutig zur Staffel passen; sonst behalten die Dateien ihre Namen und
Rippy sagt das. Lieber gar nicht als falsch.
Rippy macht dabei einen Vorschlag.
Bei einer Serie hakt es so viele Titel an, wie die Disc Folgen nennt —
und sagt dazu, ob die Zahl passt. Bei einem Film den längsten Titel. Zwei
Fallen kennt es: einen Sammeltitel, der alle Folgen am Stück enthält,
wählt es NICHT vor; und wenn mehrere Titel fast gleich lang sind, sagt es
offen, dass es unsicher ist, statt sich stumm zu entscheiden.
Bei Filmen wählst du jetzt auch, was mit soll.
Nur das Hauptfeature, oder Hauptfeature plus Extras. In der Auswahl legst
du je Titel fest, was er ist: Hauptfilm, Extra oder Folge. Extras landen
im Unterordner "extras" neben dem Film — so lesen Jellyfin, Emby und Kodi
sie als Zugaben und nicht als zweiten Film.
Ein Defekt auf der Disc kostet nicht mehr alles.
Rippy rippt jetzt Titel für Titel. Merkt es, dass MakeMKV immer wieder an
derselben Stelle hängt, sagt es das im Klartext und bietet an, diesen
einen Titel zu überspringen — der Rest der Disc läuft weiter. Vorher riss
ein kaputter Sektor den ganzen Durchlauf mit, und im Protokoll rauschte
stundenlang dieselbe Zeile durch.
Neuer Knopf in der Auswahl: "Hauptinhalt + Extras".
Hakt alles Sehenswerte an, aber weder Sammeltitel noch Logos und
Alterskennzeichen.
Aus 5.1.5: Windows-Rand weg, Erst-Einrichtung überarbeitet.
Aus 5.2.0: Titel-Auswahl vor dem Rip und der Defekt-Waechter.
Aus 5.1.5: Windows-Rand weg, Erst-Einrichtung ueberarbeitet.
Aus 5.1.4: Das Update installiert sich wirklich.
Aus 5.1.3: Eigenes Fenster, Layout wächst mit.
Aus 5.1.2: Rippy sagt Bescheid, wenn ein Update da ist.
Aus 5.1.1: Serien-Discs werden richtig erkannt.
Unverändert: Updates warten immer, bis kein Rip mehr läuft.
Unveraendert: Updates warten immer, bis kein Rip mehr laeuft.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rippy",
"version": "5.2.0",
"version": "5.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rippy",
"version": "5.2.0",
"version": "5.3.0",
"license": "UNLICENSED",
"dependencies": {
"electron-updater": "^6.8.9",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rippy",
"productName": "Rippy",
"version": "5.2.0",
"version": "5.3.0",
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
"main": "out/haupt/index.js",
"author": "KrBrZ",
+54 -2
View File
@@ -8,7 +8,15 @@
// über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4
// (Fehler stehen im Fenster — bei Systemfehlern als rote Karte).
import { useEffect, useState } from 'react'
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus, TitelStand } from '../gemeinsam/nachrichten'
import type {
DiscInfoStand,
DiscTitelVorschlag,
GeraeteInfo,
HauptStatus,
RipStatus,
TitelRolle,
TitelStand,
} from '../gemeinsam/nachrichten'
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
import {
aufVerbindung,
@@ -376,8 +384,12 @@ function TitelWahlDialog({
schliessen: () => void
}) {
const [gewaehlt, setGewaehlt] = useState<Set<number> | null>(null)
// Eigene Rollen-Wahl je Titel; was hier nicht drinsteht, behaelt Rippys
// Vorschlag ('hauptfilm' beim Film, 'folge' bei der Serie, sonst 'extra').
const [rollen, setRollen] = useState<Record<number, TitelRolle>>({})
// Rippys Vorauswahl gilt, bis der Nutzer sie anfasst.
const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.nr))
const rolleVon = (t: DiscTitelVorschlag): TitelRolle => rollen[t.nr] ?? t.rolle
const umschalten = (nr: number): void => {
const neu = new Set(aktiv)
if (neu.has(nr)) neu.delete(nr)
@@ -418,6 +430,22 @@ function TitelWahlDialog({
>
Rippys Vorschlag
</button>
<button
onClick={() =>
setGewaehlt(
// Alles Sehenswerte: Hauptinhalt und Zugaben, aber weder
// Sammeltitel noch Logos/Warnungen.
new Set(
alle
.filter((t) => !t.grund.startsWith('Sammeltitel') && !t.grund.startsWith('zu kurz'))
.map((t) => t.nr),
),
)
}
className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
>
Hauptinhalt + Extras
</button>
<button
onClick={() => setGewaehlt(new Set(alle.map((t) => t.nr)))}
className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
@@ -452,7 +480,21 @@ function TitelWahlDialog({
<span className="hidden w-20 flex-none font-mono text-[11px] text-nebel sm:inline">
{t.kapitel} Kap.
</span>
{aktiv.has(t.nr) ? (
<select
value={rolleVon(t)}
onChange={(e) => setRollen({ ...rollen, [t.nr]: e.target.value as TitelRolle })}
onClick={(e) => e.stopPropagation()}
className="flex-none rounded-lg border border-white/15 bg-samt/70 px-2 py-0.5 text-[11px] text-dunst"
title="Bestimmt, WOHIN der Titel abgelegt wird"
>
<option value="hauptfilm">Hauptfilm</option>
<option value="extra">Extra</option>
<option value="folge">Folge</option>
</select>
) : (
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
)}
</label>
))}
{alle.length === 0 && (
@@ -463,6 +505,11 @@ function TitelWahlDialog({
<p className="mt-3 font-mono text-[11px] text-nebel">
{aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)}
</p>
<p className="mt-1 text-[11px] text-nebel">
{alle.some((t) => aktiv.has(t.nr) && rolleVon(t) === 'folge')
? 'Folgen landen unter Serien/… /Season NN. Die Episoden-Nummern setzt Rippy nur, wenn die Laufzeiten eindeutig passen — sonst behalten die Dateien ihre Namen.'
: 'Der Hauptfilm landet in seinem eigenen Ordner, Extras daneben im Unterordner „extras" — so lesen Jellyfin, Emby und Kodi sie als Zugaben.'}
</p>
</>
)}
@@ -475,7 +522,12 @@ function TitelWahlDialog({
</button>
<button
onClick={() => {
ripStarten(id, [...aktiv])
ripStarten(
id,
alle
.filter((t) => aktiv.has(t.nr))
.map((t) => ({ nr: t.nr, rolle: rolleVon(t), dauerS: t.dauerS })),
)
schliessen()
}}
disabled={aktiv.size === 0 || stand === undefined || stand.laeuft}
+5 -4
View File
@@ -15,6 +15,7 @@ import {
type KernNachricht,
type MetaVorschlag,
type TitelStand,
type RipAuftragTitel,
type RipStatus,
type WerkzeugAuskunft,
} from '../gemeinsam/nachrichten'
@@ -253,7 +254,7 @@ export function aufVerbindung(h: Horcher): () => void {
return () => horcher.delete(h)
}
function anKern(nachricht: { art: string; id: string; titel?: number[] }, wasWar: string): void {
function anKern(nachricht: { art: string; id: string; auftrag?: RipAuftragTitel[] }, wasWar: string): void {
if (port === null) {
meldung(`${wasWar}: keine Verbindung zum Kern`)
melden()
@@ -271,9 +272,9 @@ export function titelLesen(id: string): void {
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
}
/** `titel` leer heißt: alles rippen. */
export function ripStarten(id: string, titel: number[] = []): void {
anKern({ art: 'rip-start', id, titel }, `Rip ${id}`)
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = []): void {
anKern({ art: 'rip-start', id, auftrag }, `Rip ${id}`)
}
/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */
+21 -2
View File
@@ -67,6 +67,23 @@ export interface RipStatus {
haengt?: { offset: string; wiederholungen: number } | null
}
/** Was ein Titel IST — davon hängt ab, WOHIN er abgelegt wird:
* hauptfilm → Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
* extra → Filme/<Titel> (Jahr)/extras/… (Jellyfin/Emby/Kodi)
* folge → Serien/<Titel>/Season NN/… S01E02.mkv
* EINE Definition für Kern und Fenster. */
export type TitelRolle = 'hauptfilm' | 'extra' | 'folge'
/** Ein Titel im Rip-Auftrag: welcher, als was — und wie lang.
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
* bei eindeutiger Zuordnung um (§ 4.2). */
export interface RipAuftragTitel {
nr: number
rolle: TitelRolle
dauerS: number
}
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
export interface DiscTitelVorschlag {
nr: number
@@ -75,6 +92,8 @@ export interface DiscTitelVorschlag {
kapitel: number
/** Von Rippy vorausgewählt. */
gewaehlt: boolean
/** Als was Rippy ihn einsortieren würde — im Dialog änderbar. */
rolle: TitelRolle
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
grund: string
}
@@ -202,8 +221,8 @@ export type FensterNachricht =
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
* sehr sehr lange"). */
| { art: 'titel-lesen'; id: string }
/** `titel` leer heißt: alles rippen — wie vor 5.2. */
| { art: 'rip-start'; id: string; titel?: number[] }
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[] }
| { art: 'rip-abbruch'; id: string }
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
* — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
+85 -12
View File
@@ -10,11 +10,17 @@
import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
import { basename, join } from 'node:path'
import type { BibliothekEintrag, GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
import type {
BibliothekEintrag,
GeraeteInfo,
RipAuftragTitel,
RipStatus,
TitelRolle,
} from '../../gemeinsam/nachrichten'
import { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster'
import { zielordner } from '../ablage/struktur'
import { episodenUmbenennen, matcheEpisoden, serienOrdner, zielordner } from '../ablage/struktur'
import { komprimieren } from '../komprimieren/handbrake'
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
import type { Zuordnung } from '../metadaten/zuordnung'
@@ -66,6 +72,14 @@ export interface PipelineUmgebung {
zuordnung?: (id: string) => Zuordnung | null
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
fingerprint?: (id: string) => string | null
/** Was die Disc über sich selbst sagt — für die Staffel-Nummer (§ 4.2). */
discAngaben?: (id: string) => { staffel: number | null; discNr: number | null } | null
/** Laufzeiten einer Staffel von TMDb: [Episodennummer, Sekunden].
* Injiziert, damit die Pipeline ohne Netz prüfbar bleibt. */
episodenLaufzeiten?: (
tmdbId: number,
staffel: number,
) => Promise<ReadonlyArray<readonly [number, number]>>
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
@@ -102,8 +116,8 @@ export class Pipeline {
vorgang.titelAbbruch.abort()
}
/** Startet die Kette. `titelWahl` leer heißt: alles rippen (wie früher). */
async starten(id: string, titelWahl: readonly number[] = []): Promise<void> {
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */
async starten(id: string, auftrag: readonly RipAuftragTitel[] = []): Promise<void> {
if (this.laufend.has(id)) {
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
return
@@ -168,7 +182,11 @@ export class Pipeline {
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
const auftrag = titelWahl.length > 0 ? titelWahl.map(String) : ['all']
const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all']
// Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je
// Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten.
const rolleJeDatei = new Map<string, TitelRolle>()
const dauerJeDatei = new Map<string, number>()
const alleDateien = new Set<string>()
let lesefehlerGesamt = false
const gescheitert: string[] = []
@@ -179,8 +197,8 @@ export class Pipeline {
status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false },
})
for (let i = 0; i < auftrag.length; i++) {
const welcher = auftrag[i]
for (let i = 0; i < laeufe.length; i++) {
const welcher = laeufe[i]
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
// „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch.
const titelAbbruch = new AbortController()
@@ -193,7 +211,7 @@ export class Pipeline {
}
const beschriftung =
welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${auftrag.length})`
welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${laeufe.length})`
this.melden({
id,
phase: 'rippt',
@@ -202,7 +220,7 @@ export class Pipeline {
dateien: [...alleDateien],
lesefehler: lesefehlerGesamt,
titelNr: i + 1,
titelGesamt: auftrag.length,
titelGesamt: laeufe.length,
haengt: null,
})
@@ -256,6 +274,12 @@ export class Pipeline {
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
const neu = rip.dateien.filter((d) => !alleDateien.has(d))
for (const d of rip.dateien) alleDateien.add(d)
// Ohne Auswahl ('all') gilt für alles die Film-Regel.
const rolle: TitelRolle = auftrag[i]?.rolle ?? 'hauptfilm'
for (const d of neu) {
rolleJeDatei.set(d, rolle)
dauerJeDatei.set(d, auftrag[i]?.dauerS ?? 0)
}
if (neu.length === 0) {
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
// (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert.
@@ -313,16 +337,40 @@ export class Pipeline {
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
const fertigOrdner =
zuordnung !== null && zuordnung.titel.length > 0
// ── WOHIN? (§ 4.2) ──────────────────────────────────────────────────
// Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter
// Einzelfilme statt einer Staffel. Serie ist es, wenn der Auftrag
// Folgen enthält (der Nutzer hat es im Dialog so bestätigt).
const alsSerie = auftrag.some((a) => a.rolle === 'folge')
const staffel = this.umgebung.discAngaben?.(id)?.staffel ?? 1
const serienTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
const fertigOrdner = alsSerie
? serienOrdner(join(this.umgebung.ablage(), 'Serien'), serienTitel, staffel)
: zuordnung !== null && zuordnung.titel.length > 0
? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName)
: zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
mkdirSync(fertigOrdner, { recursive: true })
// Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so
// lesen Jellyfin, Emby und Kodi sie als Zugaben und nicht als
// zweiten Film. Der Ordner entsteht nur, wenn es wirklich Extras gibt.
const extrasOrdner = join(fertigOrdner, 'extras')
const zielFuer = (roh: string): string => {
const rolle = rolleJeDatei.get(roh) ?? 'hauptfilm'
return rolle === 'extra' ? extrasOrdner : fertigOrdner
}
if (rip.dateien.some((d) => zielFuer(d) === extrasOrdner)) {
mkdirSync(extrasOrdner, { recursive: true })
}
const fertigeDateien: string[] = []
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
// Episoden-Zuordnung weiter unten.
const folgenSekunden: number[] = []
for (let i = 0; i < rip.dateien.length; i++) {
const eingabe = rip.dateien[i]
const ausgabe = join(fertigOrdner, basename(eingabe))
const ausgabe = join(zielFuer(eingabe), basename(eingabe))
const alt = this.laufend.get(id)?.status
if (alt !== undefined) {
this.melden({
@@ -372,6 +420,31 @@ export class Pipeline {
return
}
fertigeDateien.push(encode.ausgabe)
if ((rolleJeDatei.get(eingabe) ?? 'hauptfilm') === 'folge') {
folgenSekunden.push(dauerJeDatei.get(eingabe) ?? 0)
}
}
// ── Serien-Staffelablage (§ 4.2) ────────────────────────────────────
// Umbenannt wird NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als
// falsch (die offene Wunde #395 von ARM war genau dieses Ratespiel).
// Die Laufzeiten der Staffel kommen von TMDb; ohne sie bleibt es beim
// MakeMKV-Namen, und Rippy SAGT das.
const ablageMeldungen: string[] = []
if (alsSerie && folgenSekunden.length > 0) {
const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null
const laufzeiten =
tmdbId !== null ? ((await this.umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
const episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
if (episoden !== null) {
ablageMeldungen.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
} else {
ablageMeldungen.push(
laufzeiten.length === 0
? 'Episoden-Nummern unbekannt (keine Staffel-Laufzeiten von TMDb) — die Dateien behalten ihre Namen.'
: 'Episoden ließen sich nicht EINDEUTIG zuordnen — die Dateien behalten ihre Namen, lieber gar nicht als falsch.',
)
}
}
// ── Ablage (W-3): NFO daneben; echte Titel kommen mit W-4 ───────────
+16 -1
View File
@@ -386,6 +386,21 @@ const ripVerwaltung = new Pipeline({
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
zuordnung: (id) => discZuordnungen.get(id) ?? null,
discAngaben: (id) => {
const m = discMerkmale.get(id)
return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr }
},
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
episodenLaufzeiten: async (tmdbId, staffel) => {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
if (!tmdb.verfuegbar) return []
const antwort = await tmdb.tvStaffel(tmdbId, staffel)
const paare: Array<[number, number]> = []
for (const ep of antwort?.episodes ?? []) {
if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60])
}
return paare
},
fingerprint: (id) => discFingerprints.get(id) ?? null,
bibliothekEintragen: (eintrag) => {
try {
@@ -633,7 +648,7 @@ process.parentPort.on('message', (ereignis) => {
} else if (frage.art === 'titel-lesen') {
void titelLesen(frage.id)
} else if (frage.art === 'rip-start') {
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, frage.titel ?? []))
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, frage.auftrag ?? []))
} else if (frage.art === 'rip-abbruch') {
ripVerwaltung.abbrechen(frage.id)
} else if (frage.art === 'rip-ueberspringen') {
+12 -1
View File
@@ -13,8 +13,12 @@
// (Folgenzahl aus dem bdmt-Inhaltsverzeichnis, siehe discmerkmale.ts).
// Wo es nicht eindeutig ist, sagt sie das — statt sich zu entscheiden und
// zu schweigen.
// Die Rolle ist EINE Definition für Kern und Fenster (gemeinsam/).
import type { TitelRolle } from '../../gemeinsam/nachrichten'
import type { TitelInfo } from './parser'
export type { TitelRolle }
/** Kürzer als das ist kein Film und keine Folge, sondern ein Logo, eine
* Warnung oder ein Menü-Schnipsel. Dieselbe Grenze benutzt makemkvcon
* standardmäßig für seine eigene Mindestlänge. */
@@ -37,6 +41,8 @@ export const SAMMEL_TOLERANZ = 0.1
export interface TitelVorschlag extends TitelInfo {
/** Von Rippy vorausgewählt? */
gewaehlt: boolean
/** Als was Rippy diesen Titel einsortieren würde. */
rolle: TitelRolle
/** Warum — im Klartext, das Fenster zeigt es an. */
grund: string
}
@@ -129,11 +135,16 @@ export function titelVorauswahl(alle: readonly TitelInfo[], hinweis: DiscHinweis
}
}
const alsSerie = hinweis.istSerie && hinweis.folgen >= 2
const titel: TitelVorschlag[] = alle.map((t) => ({
...t,
gewaehlt: gewaehlt.has(t.nr),
// Gewählt UND Serie heißt Folge; gewählt und Film heißt Hauptfilm.
// Alles andere ist eine Zugabe — wer sie mit anhakt, bekommt sie als
// Extra abgelegt, nicht als zweiten Hauptfilm.
rolle: gewaehlt.has(t.nr) ? (alsSerie ? 'folge' : 'hauptfilm') : 'extra',
grund: gewaehlt.has(t.nr)
? hinweis.istSerie && hinweis.folgen >= 2
? alsSerie
? 'Folge'
: 'Hauptinhalt'
: sammel.has(t.nr)
+21
View File
@@ -120,6 +120,27 @@ describe('titelVorauswahl — wenn nichts passt, wird nichts behauptet', () => {
})
})
describe('titelVorauswahl — die Rolle bestimmt, WOHIN abgelegt wird', () => {
it('Serie: gewählte Titel sind Folgen', () => {
const wahl = titelVorauswahl([titel(0, 52), titel(1, 50), titel(2, 3)], { istSerie: true, folgen: 2 })
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.rolle)).toEqual(['folge', 'folge'])
})
it('Film: der Hauptinhalt ist Hauptfilm, alles andere Extra', () => {
// Hakt der Nutzer eine Zugabe zusätzlich an, landet sie damit unter
// extras/ statt als zweiter Film daneben.
const wahl = titelVorauswahl([titel(0, 118), titel(1, 6), titel(2, 4)], KEIN_HINWEIS)
expect(wahl.titel[0].rolle).toBe('hauptfilm')
expect(wahl.titel[1].rolle).toBe('extra')
expect(wahl.titel[2].rolle).toBe('extra')
})
it('ohne Vorauswahl ist nichts Hauptfilm', () => {
const wahl = titelVorauswahl([titel(0, 1, 1), titel(1, 0.5, 1)], KEIN_HINWEIS)
expect(wahl.titel.every((t) => t.rolle === 'extra')).toBe(true)
})
})
// ── Der Defekt-Wächter ──────────────────────────────────────────────────
// Gemessen an der Spartacus-Disc (01.09.2026): MakeMKV meldete eine halbe
// Stunde lang denselben Offset, makemkvcon verbrauchte dabei 1,7 s CPU