feat(v5): Serien-Staffelablage und Film-Extras — die Rolle bestimmt das Ziel
Ampel / ampel (push) Successful in 1m56s
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:
co-authored by
Claude Opus 5
parent
043052b453
commit
7ce06889f8
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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,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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 ───────────
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user