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.
|
Serien landen jetzt dort, wo Jellyfin sie sucht.
|
||||||
Bisher hat Rippy immer ALLES von der Disc geholt — auch Trailer, Werbung
|
Bisher kamen auch Folgen unter "Filme" — Jellyfin sah lauter Einzelfilme
|
||||||
und Alterskennzeichen. Ein Klick auf "Rippen" liest jetzt erst die Titel
|
statt einer Staffel. Jetzt legt Rippy sie unter Serien/<Titel>/Season 01
|
||||||
der Disc (das dauert bis zu zwei Minuten, solange dreht das Laufwerk) und
|
ab. Die Episoden-Nummern (S01E02) setzt es NUR, wenn die Laufzeiten
|
||||||
zeigt sie dir mit Dauer, Größe und Kapitelzahl.
|
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 Filmen wählst du jetzt auch, was mit soll.
|
||||||
Bei einer Serie hakt es so viele Titel an, wie die Disc Folgen nennt —
|
Nur das Hauptfeature, oder Hauptfeature plus Extras. In der Auswahl legst
|
||||||
und sagt dazu, ob die Zahl passt. Bei einem Film den längsten Titel. Zwei
|
du je Titel fest, was er ist: Hauptfilm, Extra oder Folge. Extras landen
|
||||||
Fallen kennt es: einen Sammeltitel, der alle Folgen am Stück enthält,
|
im Unterordner "extras" neben dem Film — so lesen Jellyfin, Emby und Kodi
|
||||||
wählt es NICHT vor; und wenn mehrere Titel fast gleich lang sind, sagt es
|
sie als Zugaben und nicht als zweiten Film.
|
||||||
offen, dass es unsicher ist, statt sich stumm zu entscheiden.
|
|
||||||
|
|
||||||
Ein Defekt auf der Disc kostet nicht mehr alles.
|
Neuer Knopf in der Auswahl: "Hauptinhalt + Extras".
|
||||||
Rippy rippt jetzt Titel für Titel. Merkt es, dass MakeMKV immer wieder an
|
Hakt alles Sehenswerte an, aber weder Sammeltitel noch Logos und
|
||||||
derselben Stelle hängt, sagt es das im Klartext und bietet an, diesen
|
Alterskennzeichen.
|
||||||
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.
|
|
||||||
|
|
||||||
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.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",
|
"name": "rippy",
|
||||||
"version": "5.2.0",
|
"version": "5.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rippy",
|
"name": "rippy",
|
||||||
"version": "5.2.0",
|
"version": "5.3.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.9",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rippy",
|
"name": "rippy",
|
||||||
"productName": "Rippy",
|
"productName": "Rippy",
|
||||||
"version": "5.2.0",
|
"version": "5.3.0",
|
||||||
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
|
||||||
"main": "out/haupt/index.js",
|
"main": "out/haupt/index.js",
|
||||||
"author": "KrBrZ",
|
"author": "KrBrZ",
|
||||||
|
|||||||
@@ -8,7 +8,15 @@
|
|||||||
// über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4
|
// über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4
|
||||||
// (Fehler stehen im Fenster — bei Systemfehlern als rote Karte).
|
// (Fehler stehen im Fenster — bei Systemfehlern als rote Karte).
|
||||||
import { useEffect, useState } from 'react'
|
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 { DEFAULT_HB_PRESET, presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||||
import {
|
import {
|
||||||
aufVerbindung,
|
aufVerbindung,
|
||||||
@@ -376,8 +384,12 @@ function TitelWahlDialog({
|
|||||||
schliessen: () => void
|
schliessen: () => void
|
||||||
}) {
|
}) {
|
||||||
const [gewaehlt, setGewaehlt] = useState<Set<number> | null>(null)
|
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.
|
// Rippys Vorauswahl gilt, bis der Nutzer sie anfasst.
|
||||||
const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.nr))
|
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 umschalten = (nr: number): void => {
|
||||||
const neu = new Set(aktiv)
|
const neu = new Set(aktiv)
|
||||||
if (neu.has(nr)) neu.delete(nr)
|
if (neu.has(nr)) neu.delete(nr)
|
||||||
@@ -418,6 +430,22 @@ function TitelWahlDialog({
|
|||||||
>
|
>
|
||||||
Rippys Vorschlag
|
Rippys Vorschlag
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setGewaehlt(new Set(alle.map((t) => t.nr)))}
|
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"
|
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">
|
<span className="hidden w-20 flex-none font-mono text-[11px] text-nebel sm:inline">
|
||||||
{t.kapitel} Kap.
|
{t.kapitel} Kap.
|
||||||
</span>
|
</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>
|
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
{alle.length === 0 && (
|
{alle.length === 0 && (
|
||||||
@@ -463,6 +505,11 @@ function TitelWahlDialog({
|
|||||||
<p className="mt-3 font-mono text-[11px] text-nebel">
|
<p className="mt-3 font-mono text-[11px] text-nebel">
|
||||||
{aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)}
|
{aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)}
|
||||||
</p>
|
</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>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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()
|
schliessen()
|
||||||
}}
|
}}
|
||||||
disabled={aktiv.size === 0 || stand === undefined || stand.laeuft}
|
disabled={aktiv.size === 0 || stand === undefined || stand.laeuft}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type KernNachricht,
|
type KernNachricht,
|
||||||
type MetaVorschlag,
|
type MetaVorschlag,
|
||||||
type TitelStand,
|
type TitelStand,
|
||||||
|
type RipAuftragTitel,
|
||||||
type RipStatus,
|
type RipStatus,
|
||||||
type WerkzeugAuskunft,
|
type WerkzeugAuskunft,
|
||||||
} from '../gemeinsam/nachrichten'
|
} from '../gemeinsam/nachrichten'
|
||||||
@@ -253,7 +254,7 @@ export function aufVerbindung(h: Horcher): () => void {
|
|||||||
return () => horcher.delete(h)
|
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) {
|
if (port === null) {
|
||||||
meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`)
|
meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`)
|
||||||
melden()
|
melden()
|
||||||
@@ -271,9 +272,9 @@ export function titelLesen(id: string): void {
|
|||||||
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
|
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `titel` leer heißt: alles rippen. */
|
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
|
||||||
export function ripStarten(id: string, titel: number[] = []): void {
|
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = []): void {
|
||||||
anKern({ art: 'rip-start', id, titel }, `Rip ${id}`)
|
anKern({ art: 'rip-start', id, auftrag }, `Rip ${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */
|
/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */
|
||||||
|
|||||||
@@ -67,6 +67,23 @@ export interface RipStatus {
|
|||||||
haengt?: { offset: string; wiederholungen: number } | null
|
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). */
|
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
|
||||||
export interface DiscTitelVorschlag {
|
export interface DiscTitelVorschlag {
|
||||||
nr: number
|
nr: number
|
||||||
@@ -75,6 +92,8 @@ export interface DiscTitelVorschlag {
|
|||||||
kapitel: number
|
kapitel: number
|
||||||
/** Von Rippy vorausgewählt. */
|
/** Von Rippy vorausgewählt. */
|
||||||
gewaehlt: boolean
|
gewaehlt: boolean
|
||||||
|
/** Als was Rippy ihn einsortieren würde — im Dialog änderbar. */
|
||||||
|
rolle: TitelRolle
|
||||||
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
|
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
|
||||||
grund: string
|
grund: string
|
||||||
}
|
}
|
||||||
@@ -202,8 +221,8 @@ export type FensterNachricht =
|
|||||||
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
|
* und nicht schon beim Einlegen (der 30.08.-Fund „das Erkennen dauert
|
||||||
* sehr sehr lange"). */
|
* sehr sehr lange"). */
|
||||||
| { art: 'titel-lesen'; id: string }
|
| { art: 'titel-lesen'; id: string }
|
||||||
/** `titel` leer heißt: alles rippen — wie vor 5.2. */
|
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
|
||||||
| { art: 'rip-start'; id: string; titel?: number[] }
|
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[] }
|
||||||
| { art: 'rip-abbruch'; id: string }
|
| { art: 'rip-abbruch'; id: string }
|
||||||
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
|
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
|
||||||
* — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
|
* — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
|
||||||
|
|||||||
@@ -10,11 +10,17 @@
|
|||||||
import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
||||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||||
import { basename, join } from 'node:path'
|
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 { baueNfo } from '../ablage/nfo'
|
||||||
import { bibliothekRefresh } from '../ablage/medienserver'
|
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||||
import { posterSpeichern } from '../ablage/poster'
|
import { posterSpeichern } from '../ablage/poster'
|
||||||
import { zielordner } from '../ablage/struktur'
|
import { episodenUmbenennen, matcheEpisoden, serienOrdner, zielordner } from '../ablage/struktur'
|
||||||
import { komprimieren } from '../komprimieren/handbrake'
|
import { komprimieren } from '../komprimieren/handbrake'
|
||||||
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
|
||||||
import type { Zuordnung } from '../metadaten/zuordnung'
|
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||||
@@ -66,6 +72,14 @@ export interface PipelineUmgebung {
|
|||||||
zuordnung?: (id: string) => Zuordnung | null
|
zuordnung?: (id: string) => Zuordnung | null
|
||||||
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
|
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
|
||||||
fingerprint?: (id: string) => string | 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). */
|
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */
|
||||||
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
|
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
|
||||||
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
|
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
|
||||||
@@ -102,8 +116,8 @@ export class Pipeline {
|
|||||||
vorgang.titelAbbruch.abort()
|
vorgang.titelAbbruch.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Startet die Kette. `titelWahl` leer heißt: alles rippen (wie früher). */
|
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */
|
||||||
async starten(id: string, titelWahl: readonly number[] = []): Promise<void> {
|
async starten(id: string, auftrag: readonly RipAuftragTitel[] = []): Promise<void> {
|
||||||
if (this.laufend.has(id)) {
|
if (this.laufend.has(id)) {
|
||||||
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
|
||||||
return
|
return
|
||||||
@@ -168,7 +182,11 @@ export class Pipeline {
|
|||||||
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
|
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
|
||||||
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
|
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
|
||||||
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
|
// 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>()
|
const alleDateien = new Set<string>()
|
||||||
let lesefehlerGesamt = false
|
let lesefehlerGesamt = false
|
||||||
const gescheitert: string[] = []
|
const gescheitert: string[] = []
|
||||||
@@ -179,8 +197,8 @@ export class Pipeline {
|
|||||||
status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false },
|
status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
for (let i = 0; i < auftrag.length; i++) {
|
for (let i = 0; i < laeufe.length; i++) {
|
||||||
const welcher = auftrag[i]
|
const welcher = laeufe[i]
|
||||||
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
|
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
|
||||||
// „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch.
|
// „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch.
|
||||||
const titelAbbruch = new AbortController()
|
const titelAbbruch = new AbortController()
|
||||||
@@ -193,7 +211,7 @@ export class Pipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const beschriftung =
|
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({
|
this.melden({
|
||||||
id,
|
id,
|
||||||
phase: 'rippt',
|
phase: 'rippt',
|
||||||
@@ -202,7 +220,7 @@ export class Pipeline {
|
|||||||
dateien: [...alleDateien],
|
dateien: [...alleDateien],
|
||||||
lesefehler: lesefehlerGesamt,
|
lesefehler: lesefehlerGesamt,
|
||||||
titelNr: i + 1,
|
titelNr: i + 1,
|
||||||
titelGesamt: auftrag.length,
|
titelGesamt: laeufe.length,
|
||||||
haengt: null,
|
haengt: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -256,6 +274,12 @@ export class Pipeline {
|
|||||||
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
|
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
|
||||||
const neu = rip.dateien.filter((d) => !alleDateien.has(d))
|
const neu = rip.dateien.filter((d) => !alleDateien.has(d))
|
||||||
for (const d of rip.dateien) alleDateien.add(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) {
|
if (neu.length === 0) {
|
||||||
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
|
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
|
||||||
// (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert.
|
// (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
|
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
|
||||||
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
|
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
|
||||||
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
|
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'), zuordnung.titel, zuordnung.jahr, vorgangsName)
|
||||||
: zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
|
: zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
|
||||||
mkdirSync(fertigOrdner, { recursive: true })
|
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[] = []
|
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++) {
|
for (let i = 0; i < rip.dateien.length; i++) {
|
||||||
const eingabe = rip.dateien[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
|
const alt = this.laufend.get(id)?.status
|
||||||
if (alt !== undefined) {
|
if (alt !== undefined) {
|
||||||
this.melden({
|
this.melden({
|
||||||
@@ -372,6 +420,31 @@ export class Pipeline {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
fertigeDateien.push(encode.ausgabe)
|
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 ───────────
|
// ── 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),
|
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
|
||||||
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
|
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
|
||||||
zuordnung: (id) => discZuordnungen.get(id) ?? null,
|
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,
|
fingerprint: (id) => discFingerprints.get(id) ?? null,
|
||||||
bibliothekEintragen: (eintrag) => {
|
bibliothekEintragen: (eintrag) => {
|
||||||
try {
|
try {
|
||||||
@@ -633,7 +648,7 @@ process.parentPort.on('message', (ereignis) => {
|
|||||||
} else if (frage.art === 'titel-lesen') {
|
} else if (frage.art === 'titel-lesen') {
|
||||||
void titelLesen(frage.id)
|
void titelLesen(frage.id)
|
||||||
} else if (frage.art === 'rip-start') {
|
} 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') {
|
} else if (frage.art === 'rip-abbruch') {
|
||||||
ripVerwaltung.abbrechen(frage.id)
|
ripVerwaltung.abbrechen(frage.id)
|
||||||
} else if (frage.art === 'rip-ueberspringen') {
|
} else if (frage.art === 'rip-ueberspringen') {
|
||||||
|
|||||||
@@ -13,8 +13,12 @@
|
|||||||
// (Folgenzahl aus dem bdmt-Inhaltsverzeichnis, siehe discmerkmale.ts).
|
// (Folgenzahl aus dem bdmt-Inhaltsverzeichnis, siehe discmerkmale.ts).
|
||||||
// Wo es nicht eindeutig ist, sagt sie das — statt sich zu entscheiden und
|
// Wo es nicht eindeutig ist, sagt sie das — statt sich zu entscheiden und
|
||||||
// zu schweigen.
|
// zu schweigen.
|
||||||
|
// Die Rolle ist EINE Definition für Kern und Fenster (gemeinsam/).
|
||||||
|
import type { TitelRolle } from '../../gemeinsam/nachrichten'
|
||||||
import type { TitelInfo } from './parser'
|
import type { TitelInfo } from './parser'
|
||||||
|
|
||||||
|
export type { TitelRolle }
|
||||||
|
|
||||||
/** Kürzer als das ist kein Film und keine Folge, sondern ein Logo, eine
|
/** Kürzer als das ist kein Film und keine Folge, sondern ein Logo, eine
|
||||||
* Warnung oder ein Menü-Schnipsel. Dieselbe Grenze benutzt makemkvcon
|
* Warnung oder ein Menü-Schnipsel. Dieselbe Grenze benutzt makemkvcon
|
||||||
* standardmäßig für seine eigene Mindestlänge. */
|
* standardmäßig für seine eigene Mindestlänge. */
|
||||||
@@ -37,6 +41,8 @@ export const SAMMEL_TOLERANZ = 0.1
|
|||||||
export interface TitelVorschlag extends TitelInfo {
|
export interface TitelVorschlag extends TitelInfo {
|
||||||
/** Von Rippy vorausgewählt? */
|
/** Von Rippy vorausgewählt? */
|
||||||
gewaehlt: boolean
|
gewaehlt: boolean
|
||||||
|
/** Als was Rippy diesen Titel einsortieren würde. */
|
||||||
|
rolle: TitelRolle
|
||||||
/** Warum — im Klartext, das Fenster zeigt es an. */
|
/** Warum — im Klartext, das Fenster zeigt es an. */
|
||||||
grund: string
|
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) => ({
|
const titel: TitelVorschlag[] = alle.map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
gewaehlt: gewaehlt.has(t.nr),
|
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)
|
grund: gewaehlt.has(t.nr)
|
||||||
? hinweis.istSerie && hinweis.folgen >= 2
|
? alsSerie
|
||||||
? 'Folge'
|
? 'Folge'
|
||||||
: 'Hauptinhalt'
|
: 'Hauptinhalt'
|
||||||
: sammel.has(t.nr)
|
: 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 ──────────────────────────────────────────────────
|
// ── Der Defekt-Wächter ──────────────────────────────────────────────────
|
||||||
// Gemessen an der Spartacus-Disc (01.09.2026): MakeMKV meldete eine halbe
|
// Gemessen an der Spartacus-Disc (01.09.2026): MakeMKV meldete eine halbe
|
||||||
// Stunde lang denselben Offset, makemkvcon verbrauchte dabei 1,7 s CPU
|
// Stunde lang denselben Offset, makemkvcon verbrauchte dabei 1,7 s CPU
|
||||||
|
|||||||
Reference in New Issue
Block a user