feat(v5): Etappe W-3 — Komprimieren + Ablegen, mit Vollbeweis
Ampel / ampel (push) Failing after 1m18s
Ampel / ampel (push) Failing after 1m18s
komprimieren/: handbrake.ts traegt ALLE bezahlten Fallen aus dem Docker-Zweig — --aencoder statt --audio-codec (mit Anti-Regressions- Wächter im Test: der alte Test hatte den Fehler FESTGESCHRIEBEN), --format aus der Endung (Container kommt sonst aus dem Preset), Fortschritt NUR aus der Encoding-Zeile (Scan-99%-Falle), letzte 12 Zeilen fuer den Fehlerfall, unknown-option-Erkennung VOR allem anderen (kommt mit Rueckgabewert 0!), Datei-daneben-Suche, CR-Zeilentrennung mit 0x81-Kodierungsfalle. encoder.ts misst statt behauptet (--help, --preset-list, Familien statt Sammelbegriff). presets.ts: je Disc-Typ, PRESET_KEINE, Sprachlisten. ablage/: struktur.ts (sichere Namen, '<Titel> (Jahr)', Season NN, Episoden-Zuordnung NUR bei Eindeutigkeit), nfo.ts (Kodi-Schema), medienserver.ts (Jellyfin/Emby/Kodi-Refresh, wirft nie). ablauf/pipeline.ts ERSETZT rippen.ts: Rip → Kompression → Ablage als EINE Kette; Kompressions-Fehler laesst den verlustfreien Rip STEHEN und nennt den Ort; Lesefehler ueberleben bis in die Fertig-Meldung. Roh wird in dieser Etappe grundsaetzlich behalten (Loeschen erst mit W-5-UI). GEMESSEN am echten System (30.08.2026): - Encoder: 22 Stueck, Backends cpu-x264/x265/av1 + vce + vce-av1 (RX 9070 XT als VCE-Familie — nicht unter Intel-Namen), 107 Presets. - Probe-Encode 5 s Material: success in 45,5 s. - VOLLBEWEIS: Spartacus-Rohschnitt (17,71 GB — die Datei, die in rc11 am falschen Schalter starb) mit Preset 'H.265 VCN 1080p' in 2,1 min auf 1,17 GB komprimiert und nach Jellyfin-Schema abgelegt: E:\Rippy-v5-Beweis\Filme\Spartacus - Gods of the Arena - Disc 2 (2011)\ mit movie.nfo. - Nebenbefund: Der Evangelion-Roh-Rip vom 29.08. traegt 'This file was not properly finalized' im Kopf — 23,5 GB, die wie fertig aussehen und es nicht sind (der abgebrochene rc11-Rip). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ca338ad467
commit
3dc296f071
@@ -0,0 +1,134 @@
|
||||
// Zielstruktur für Jellyfin/Emby/Kodi/Plex (KONZEPT § 4.2) — Regeln aus
|
||||
// medien.py: Ordner heißt „<Titel> (Jahr)" (daran erkennen die Server den
|
||||
// Film; jellyfin.org/docs …/movies, support.plex.tv „Naming"), Serien
|
||||
// liegen als <Serie>/Season NN. Episoden werden NUR bei EINDEUTIGER
|
||||
// Zuordnung umbenannt — lieber gar nicht als falsch (ARMs offene Wunde
|
||||
// #395 war genau das Ratespiel).
|
||||
import { existsSync, readdirSync, renameSync } from 'node:fs'
|
||||
import { win32 as pfad } from 'node:path'
|
||||
|
||||
const { join } = pfad
|
||||
|
||||
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen.
|
||||
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g
|
||||
|
||||
/** Titel → dateisystem-tauglicher Ordnername (pur). */
|
||||
export function sichererName(titel: string, jahr?: number | string | null): string {
|
||||
let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '')
|
||||
name = name.replace(/\s+/g, ' ').slice(0, 150).trim()
|
||||
if (name.length === 0) return ''
|
||||
// Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen.
|
||||
if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) {
|
||||
name = `${name} (${jahr})`
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
|
||||
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */
|
||||
export function zielordner(
|
||||
basis: string,
|
||||
titel: string,
|
||||
jahr: number | string | null,
|
||||
jobId: string,
|
||||
existiert: (p: string) => boolean = existsSync,
|
||||
): string {
|
||||
const name = sichererName(titel, jahr)
|
||||
if (name.length === 0) return join(basis, jobId)
|
||||
const ziel = join(basis, name)
|
||||
if (existiert(ziel)) return join(basis, `${name} [${jobId.slice(0, 8)}]`)
|
||||
return ziel
|
||||
}
|
||||
|
||||
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
|
||||
export function serienOrdner(basis: string, serie: string, staffel: number): string {
|
||||
const name = sichererName(serie) || 'Unbekannte Serie'
|
||||
return join(basis, name, `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
/** Ordnet Dateien Episoden zu — über Laufzeiten, ordnungserhaltend.
|
||||
* Rückgabe: Episoden-Nummern je Datei, oder null wenn NICHT eindeutig.
|
||||
*
|
||||
* Stufe 1: komplette Staffel (Dateizahl == Episodenzahl) → 1:1 in
|
||||
* Reihenfolge. Stufe 2: Einzel-Zuordnung über distinkte Laufzeiten;
|
||||
* passt eine Datei auf mehrere Episoden ähnlich gut → null. */
|
||||
export function matcheEpisoden(
|
||||
dateiSekunden: readonly number[],
|
||||
episoden: ReadonlyArray<readonly [nummer: number, laufzeitS: number]>,
|
||||
toleranzProzent = 8.0,
|
||||
toleranzMinS = 180,
|
||||
): number[] | null {
|
||||
if (dateiSekunden.length === 0 || episoden.length === 0) return null
|
||||
|
||||
const toleranz = (dauer: number): number => Math.max(toleranzMinS, (dauer * toleranzProzent) / 100)
|
||||
|
||||
if (dateiSekunden.length === episoden.length) {
|
||||
const passt = dateiSekunden.every((dauer, i) => {
|
||||
const epDauer = episoden[i][1]
|
||||
return epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer)
|
||||
})
|
||||
if (passt) return episoden.map(([nr]) => nr)
|
||||
}
|
||||
|
||||
const zuordnung: number[] = []
|
||||
let start = 0
|
||||
for (const dauer of dateiSekunden) {
|
||||
const kandidaten = episoden
|
||||
.slice(start)
|
||||
.filter(([, epDauer]) => epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer))
|
||||
if (kandidaten.length === 0) return null
|
||||
let gewaehlt = kandidaten[0]
|
||||
if (kandidaten.length > 1) {
|
||||
const sortiert = [...kandidaten].sort((a, b) => Math.abs(dauer - a[1]) - Math.abs(dauer - b[1]))
|
||||
// Nur eindeutig, wenn der beste KLAR besser passt (doppelt so nah).
|
||||
if (!(Math.abs(dauer - sortiert[0][1]) * 2 < Math.abs(dauer - sortiert[1][1]))) return null
|
||||
gewaehlt = sortiert[0]
|
||||
}
|
||||
zuordnung.push(gewaehlt[0])
|
||||
start = episoden.findIndex(([nr]) => nr === gewaehlt[0]) + 1
|
||||
}
|
||||
return zuordnung
|
||||
}
|
||||
|
||||
/** Benennt MKVs nach Jellyfin-Schema um: „Serie S01E02.mkv". Reihenfolge =
|
||||
* sortierte Dateinamen (MakeMKV nummeriert in Disc-Reihenfolge). */
|
||||
export function episodenUmbenennen(
|
||||
ordner: string,
|
||||
serie: string,
|
||||
staffel: number,
|
||||
zuordnung: readonly number[],
|
||||
fs: {
|
||||
listen: (o: string) => string[]
|
||||
existiert: (p: string) => boolean
|
||||
umbenennen: (von: string, nach: string) => void
|
||||
} = {
|
||||
listen: (o) => readdirSync(o),
|
||||
existiert: existsSync,
|
||||
umbenennen: renameSync,
|
||||
},
|
||||
): string[] {
|
||||
const meldungen: string[] = []
|
||||
const dateien = fs
|
||||
.listen(ordner)
|
||||
.filter((f) => f.endsWith('.mkv'))
|
||||
.sort()
|
||||
if (dateien.length !== zuordnung.length) {
|
||||
return [`Umbenennen übersprungen: ${dateien.length} Dateien, ${zuordnung.length} Zuordnungen`]
|
||||
}
|
||||
const serienName = sichererName(serie) || 'Serie'
|
||||
for (let i = 0; i < dateien.length; i++) {
|
||||
const alt = dateien[i]
|
||||
const neu = `${serienName} S${String(Math.trunc(staffel)).padStart(2, '0')}E${String(Math.trunc(zuordnung[i])).padStart(2, '0')}.mkv`
|
||||
if (alt === neu) continue
|
||||
const ziel = join(ordner, neu)
|
||||
if (fs.existiert(ziel)) {
|
||||
meldungen.push(`Umbenennen übersprungen (${neu} existiert schon)`)
|
||||
continue
|
||||
}
|
||||
fs.umbenennen(join(ordner, alt), ziel)
|
||||
meldungen.push(`${alt} → ${neu}`)
|
||||
}
|
||||
return meldungen
|
||||
}
|
||||
Reference in New Issue
Block a user