feat(v5): 5.6.0 — Vorgangs-Leiste, mehrere Filme je Disc, Filmnamen für Ordner und Dateien, Untertitel-Vorwahl je Extra, Restzeit, Hell-Modus
Die sechs Punkte des Commanders vom 02.09.2026 abends und seine Entscheide:
* Restzeit der Kompression aus HandBrakes eigener ETA-Klammer (Formatstring
aus dem Binary 1.11.2 gelesen), KompressionStand.restS.
* Kachel zeigt nach dem Rip nur noch eine Zeile — Balken, Prozent, Ziel in
der Kompressions-Karte und im Verlauf ("nur in der Kompressions-Karte").
* Kopfzeile bleibt beim Scrollen stehen (sticky), Sektionswahl darunter.
* Untertitel-Vorwahl je Extra ("NUR für Extras, IMMER OPTIONAL"): welche
Untertitel-Spur beim Abspielen an ist — weiche Spur, nie eingebrannt;
RipAuftragTitel/RohDatei.untertitelVorwahl, sprachwahl.
* Mehrere Filme je Disc: "anderer Film …" je Titel mit TMDb-Suche;
filme.ts filmGruppen trennt am Rip-Ende in eigene Roh-Ordner, Vorgänge
und Kompressions-Aufträge mit voller Zuordnung (erkennung.zuordnungAus).
* Roh-Ordner und Dateien tragen den Filmnamen (filme.ts rohOrdnerName,
ablage/dateinamen.ts: Akira (1988).mkv, - Fassung n, - Extra nn).
* Hell-Modus: Einstellung design (dunkel/hell/system), data-design auf
<html>, Token --color-tinte statt white/-Alpha-Klassen.
* Vorgangs-Leiste (Commander: "Phase 1–4 plus Komprimierung — zu einem
machen"): gemeinsam/vorgangsleiste.ts + uebersicht/VorgangsLeiste.tsx,
vier Stationen (Rettung als Zwischenstation), Segment-Balken in der
aktiven Station; Kachel, Kompressions-Karte, Verlauf. PHASE-x/4-Texte weg.
* Automatik-Kasten leert sich, sobald ein Rip startet.
Beweise: 446 Tests (neu: filme, dateinamen, vorgangsleiste; erweitert:
auftrag, handbrake, automatik, ablegen), drei Typprüfungen, Klick-Beweis
33 Schritte inkl. Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit,
Hell-Modus (Bilder beweise/klick/01…11).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
76ed3b337a
commit
f5dab5d1c1
@@ -0,0 +1,47 @@
|
||||
// Dateinamen in der Ablage (5.6.0, Commander: „die Ordner … müssen direkt
|
||||
// den Filmnamen haben" — und die Dateien darin auch): Statt MakeMKVs
|
||||
// title_t00.mkv heißt der Hauptfilm „Akira (1988).mkv", zwei Fassungen
|
||||
// „Akira (1988) - Fassung 1.mkv" und „… - Fassung 2.mkv", Zugaben „Akira
|
||||
// (1988) - Extra 01.mkv" im Unterordner extras. Folgen behalten ihren
|
||||
// Namen, bis die Episoden-Zuordnung sie umbenennt (struktur.ts) — ohne
|
||||
// Zuordnung bleiben sie, wie sie sind (lieber gar nicht als falsch). Ohne
|
||||
// erkannten Titel bleibt alles beim MakeMKV-Namen. PUR.
|
||||
import { extname } from 'node:path'
|
||||
import { sichererName } from '../../gemeinsam/ablagepfad'
|
||||
import type { TitelRolle } from '../../gemeinsam/nachrichten'
|
||||
|
||||
export interface NamensEingabe {
|
||||
titel: string
|
||||
jahr: number | null
|
||||
alsSerie: boolean
|
||||
rolle: TitelRolle
|
||||
/** 1-basierte Nummer unter den Dateien GLEICHER Rolle (Ablage-Reihenfolge). */
|
||||
nummer: number
|
||||
/** Wie viele Dateien dieser Rolle es gibt. */
|
||||
anzahl: number
|
||||
/** MakeMKVs Dateiname (mit Endung). */
|
||||
original: string
|
||||
}
|
||||
|
||||
export function zielDateiname(e: NamensEingabe): string {
|
||||
const endung = extname(e.original) || '.mkv'
|
||||
const name = e.alsSerie ? sichererName(e.titel) : sichererName(e.titel, e.jahr)
|
||||
if (e.titel.trim().length === 0 || name.length === 0) return e.original
|
||||
const nn = String(e.nummer).padStart(2, '0')
|
||||
if (e.rolle === 'extra') return `${name} - Extra ${nn}${endung}`
|
||||
if (e.alsSerie) return e.original
|
||||
if (e.rolle === 'hauptfilm') return e.anzahl > 1 ? `${name} - Fassung ${e.nummer}${endung}` : `${name}${endung}`
|
||||
return e.original
|
||||
}
|
||||
|
||||
/** Nummern je Rolle in Ablage-Reihenfolge (pur): [nummer, anzahl] je Index. */
|
||||
export function rollenNummern(rollen: readonly TitelRolle[]): Array<[number, number]> {
|
||||
const zaehler = new Map<TitelRolle, number>()
|
||||
const gesamt = new Map<TitelRolle, number>()
|
||||
for (const r of rollen) gesamt.set(r, (gesamt.get(r) ?? 0) + 1)
|
||||
return rollen.map((r) => {
|
||||
const n = (zaehler.get(r) ?? 0) + 1
|
||||
zaehler.set(r, n)
|
||||
return [n, gesamt.get(r) ?? 1]
|
||||
})
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||
import { basename, join } from 'node:path'
|
||||
import type { BibliothekEintrag, KompressionStand } from '../../gemeinsam/nachrichten'
|
||||
import { rollenNummern, zielDateiname } from '../ablage/dateinamen'
|
||||
import { baueNfo } from '../ablage/nfo'
|
||||
import { bibliothekRefresh } from '../ablage/medienserver'
|
||||
import { posterSpeichern } from '../ablage/poster'
|
||||
@@ -87,7 +88,7 @@ export interface AblageErgebnis {
|
||||
folgenVergeben: number[]
|
||||
}
|
||||
|
||||
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text'>) => void
|
||||
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text' | 'restS'>) => void
|
||||
|
||||
/** Verschieben auf demselben Datenträger ist ein Umbenennen; über
|
||||
* Datenträger-Grenzen (EXDEV) hilft nur Kopieren + Löschen. */
|
||||
@@ -188,9 +189,24 @@ export async function komprimierenUndAblegen(
|
||||
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
|
||||
// Episoden-Zuordnung weiter unten.
|
||||
const folgenSekunden: number[] = []
|
||||
// 5.6.0: Dateien heißen wie der Film (dateinamen.ts) — Folgen behalten
|
||||
// ihren Namen bis zur Episoden-Zuordnung.
|
||||
const nummern = rollenNummern(dateien.map((d) => d.rolle))
|
||||
for (let i = 0; i < dateien.length; i++) {
|
||||
const datei = dateien[i]
|
||||
const zielDatei = join(zielFuer(datei.rolle), basename(datei.pfad))
|
||||
const [nummer, anzahl] = nummern[i]
|
||||
const zielDatei = join(
|
||||
zielFuer(datei.rolle),
|
||||
zielDateiname({
|
||||
titel: zuordnung?.titel ?? '',
|
||||
jahr: zuordnung?.jahr ?? null,
|
||||
alsSerie,
|
||||
rolle: datei.rolle,
|
||||
nummer,
|
||||
anzahl,
|
||||
original: basename(datei.pfad),
|
||||
}),
|
||||
)
|
||||
if (abbruch.aborted) return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
|
||||
if (!komprimiert) {
|
||||
melden({
|
||||
@@ -237,6 +253,7 @@ export async function komprimierenUndAblegen(
|
||||
melden({
|
||||
phase: 'komprimiert',
|
||||
prozent: gewichteterFortschritt(gewichte, i, 0),
|
||||
restS: -1,
|
||||
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${
|
||||
wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''
|
||||
}${wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''} …`,
|
||||
@@ -250,10 +267,11 @@ export async function komprimierenUndAblegen(
|
||||
untertitelSprachen: wahl.untertitel,
|
||||
untertitelStandard: wahl.untertitelStandard,
|
||||
abbruch,
|
||||
aufFortschritt: (prozent) => {
|
||||
aufFortschritt: (prozent, restS) => {
|
||||
melden({
|
||||
phase: 'komprimiert',
|
||||
prozent: gewichteterFortschritt(gewichte, i, prozent),
|
||||
restS,
|
||||
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}") …`,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
|
||||
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
|
||||
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
|
||||
import type { TitelRolle } from '../../gemeinsam/nachrichten'
|
||||
import type { MetaVorschlag, TitelRolle } from '../../gemeinsam/nachrichten'
|
||||
import type { Zuordnung } from '../metadaten/zuordnung'
|
||||
|
||||
export interface RohDatei {
|
||||
@@ -19,6 +19,10 @@ export interface RohDatei {
|
||||
/** Sprachen je Datei (leer = die globale Einstellung gilt). */
|
||||
audioSprachen: string[]
|
||||
untertitelSprachen: string[]
|
||||
/** 5.6.0 (Extras): vorgewählte Untertitel-Sprache; null = keine; fehlend = Regel. */
|
||||
untertitelVorwahl?: string | null
|
||||
/** 5.6.0: der Film, zu dem diese Datei gehört, wenn nicht die Disc-Zuordnung. */
|
||||
film?: MetaVorschlag
|
||||
}
|
||||
|
||||
export interface KompressionsAuftrag {
|
||||
@@ -81,6 +85,8 @@ export function auftragAusJson(text: string): KompressionsAuftrag | null {
|
||||
groesseBytes: typeof e.groesseBytes === 'number' ? e.groesseBytes : 0,
|
||||
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
|
||||
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [],
|
||||
...(e.untertitelVorwahl === null || typeof e.untertitelVorwahl === 'string' ? { untertitelVorwahl: e.untertitelVorwahl } : {}),
|
||||
...(filmAus(e.film) !== null ? { film: filmAus(e.film)! } : {}),
|
||||
})
|
||||
}
|
||||
return {
|
||||
@@ -103,6 +109,20 @@ export function auftragAusJson(text: string): KompressionsAuftrag | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Film-Vorschlag aus dem JSON — null, wenn er nicht die Form hat. */
|
||||
export function filmAus(wert: unknown): MetaVorschlag | null {
|
||||
if (typeof wert !== 'object' || wert === null) return null
|
||||
const f = wert as Record<string, unknown>
|
||||
if (typeof f.tmdbId !== 'number' || typeof f.titel !== 'string') return null
|
||||
return {
|
||||
typ: f.typ === 'tv' ? 'tv' : 'movie',
|
||||
tmdbId: f.tmdbId,
|
||||
titel: f.titel,
|
||||
jahr: typeof f.jahr === 'number' ? f.jahr : null,
|
||||
posterPfad: typeof f.posterPfad === 'string' ? f.posterPfad : '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Was HandBrake für EINE Datei bekommt. */
|
||||
export interface SprachWahl {
|
||||
audio: string[]
|
||||
@@ -123,11 +143,18 @@ export interface SprachWahl {
|
||||
* komprimieren/handbrake.ts).
|
||||
*/
|
||||
export function sprachwahl(
|
||||
datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[] },
|
||||
datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[]; untertitelVorwahl?: string | null },
|
||||
global: { audio: readonly string[]; untertitel: readonly string[] },
|
||||
): SprachWahl {
|
||||
const audio = datei.audioSprachen.length > 0 ? [...datei.audioSprachen] : [...global.audio]
|
||||
let untertitel = datei.untertitelSprachen.length > 0 ? [...datei.untertitelSprachen] : [...global.untertitel]
|
||||
// 5.6.0: Die Vorwahl des Nutzers (nur Extras, immer optional) schlägt die
|
||||
// Regel — null heißt ausdrücklich „keine Vorwahl".
|
||||
if (datei.untertitelVorwahl === null) return { audio, untertitel, untertitelStandard: false }
|
||||
if (typeof datei.untertitelVorwahl === 'string' && datei.untertitelVorwahl.length > 0) {
|
||||
const v = datei.untertitelVorwahl
|
||||
return { audio, untertitel: [v, ...untertitel.filter((u) => u !== v)], untertitelStandard: true }
|
||||
}
|
||||
const wunsch = global.audio[0] ?? ''
|
||||
const tonFremd = wunsch.length > 0 && audio.length > 0 && audio[0] !== wunsch
|
||||
const untertitelStandard = tonFremd && untertitel.includes(wunsch)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Mehrere Filme auf einer Disc und Ordner mit Filmnamen (5.6.0, Commander:
|
||||
// „je Titel eigener Film im Dialog" und „die Ordner, die Rippy generiert,
|
||||
// müssen direkt den Filmnamen haben, z. B. Akira (Jahr)") — PUR, damit die
|
||||
// Ampel die Regeln prüft:
|
||||
// * rohOrdnerName: Der Roh-Ordner heißt wie der Film (oder die Serie mit
|
||||
// Staffel und Disc), bei Wiederholung mit „(2)", „(3)" …; ohne Zuordnung
|
||||
// bleibt der Zeitstempel-Name des Vorgangs.
|
||||
// * filmGruppen: Titel, die der Nutzer einem ANDEREN Film zugeordnet hat,
|
||||
// werden zu eigenen Gruppen — jede bekommt eigenen Roh-Ordner, Vorgang
|
||||
// und Zielordner. Hat kein Titel die Disc-Zuordnung behalten, wird die
|
||||
// erste Gruppe zum Hauptvorgang (der Roh-Ordner ist schon da).
|
||||
// * zuordnungAusVorschlag: Notnagel ohne TMDb-Details.
|
||||
import type { MetaVorschlag } from '../../gemeinsam/nachrichten'
|
||||
import { sichererName } from '../../gemeinsam/ablagepfad'
|
||||
import { LEERER_TREFFER, type Zuordnung } from '../metadaten/zuordnung'
|
||||
import type { RohDatei } from './auftrag'
|
||||
|
||||
export interface DiscAngaben {
|
||||
staffel: number | null
|
||||
discNr: number | null
|
||||
}
|
||||
|
||||
export function rohOrdnerName(
|
||||
zuordnung: Zuordnung | null,
|
||||
angaben: DiscAngaben | null,
|
||||
vorgangsName: string,
|
||||
existiert: (name: string) => boolean,
|
||||
): string {
|
||||
if (zuordnung === null || zuordnung.titel.trim().length === 0) return vorgangsName
|
||||
let basis: string
|
||||
if (zuordnung.meta.typ === 'tv') {
|
||||
basis = sichererName(zuordnung.titel)
|
||||
if (basis.length > 0 && angaben?.staffel !== null && angaben?.staffel !== undefined) basis += ` - Staffel ${angaben.staffel}`
|
||||
if (basis.length > 0 && angaben?.discNr !== null && angaben?.discNr !== undefined) basis += ` Disc ${angaben.discNr}`
|
||||
} else {
|
||||
basis = sichererName(zuordnung.titel, zuordnung.jahr)
|
||||
}
|
||||
if (basis.length === 0) return vorgangsName
|
||||
if (!existiert(basis)) return basis
|
||||
for (let n = 2; n < 100; n++) {
|
||||
const kandidat = `${basis} (${n})`
|
||||
if (!existiert(kandidat)) return kandidat
|
||||
}
|
||||
return vorgangsName
|
||||
}
|
||||
|
||||
export interface FilmGruppe {
|
||||
film: MetaVorschlag
|
||||
dateien: RohDatei[]
|
||||
}
|
||||
|
||||
export interface FilmGruppen {
|
||||
/** Die Dateien des Hauptvorgangs (Disc-Zuordnung). */
|
||||
primaer: RohDatei[]
|
||||
/** Gesetzt, wenn KEIN Titel die Disc-Zuordnung behielt: Der Hauptvorgang
|
||||
* übernimmt diesen Film. */
|
||||
primaerFilm: MetaVorschlag | null
|
||||
weitere: FilmGruppe[]
|
||||
}
|
||||
|
||||
export function filmGruppen(dateien: readonly RohDatei[], discFilmId: number | null): FilmGruppen {
|
||||
const primaer: RohDatei[] = []
|
||||
const weitere: FilmGruppe[] = []
|
||||
for (const d of dateien) {
|
||||
if (d.film === undefined || (d.film.typ === 'movie' && d.film.tmdbId === discFilmId)) {
|
||||
primaer.push(d)
|
||||
continue
|
||||
}
|
||||
const gruppe = weitere.find((g) => g.film.tmdbId === d.film!.tmdbId && g.film.typ === d.film!.typ)
|
||||
if (gruppe !== undefined) gruppe.dateien.push(d)
|
||||
else weitere.push({ film: d.film, dateien: [d] })
|
||||
}
|
||||
if (primaer.length === 0 && weitere.length > 0) {
|
||||
const erste = weitere.shift()!
|
||||
return { primaer: erste.dateien, primaerFilm: erste.film, weitere }
|
||||
}
|
||||
return { primaer, primaerFilm: null, weitere }
|
||||
}
|
||||
|
||||
/** Eine Zuordnung aus dem Vorschlag allein — wenn TMDb-Details nicht zu
|
||||
* holen sind (kein Netz, kein Schlüssel). Vom Nutzer gewählt = sicher. */
|
||||
export function zuordnungAusVorschlag(v: MetaVorschlag): Zuordnung {
|
||||
return {
|
||||
titel: v.titel,
|
||||
jahr: v.jahr,
|
||||
confidence: 1,
|
||||
meta: { ...LEERER_TREFFER, typ: v.typ, id: v.tmdbId, titel: v.titel, jahr: v.jahr, posterPfad: v.posterPfad, quelle: 'tmdb' },
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,9 @@
|
||||
// 5.5.0: das Rettungs-Abbild (rip/rettung.ts) für Discs, an denen Rippy
|
||||
// Lesefehler GESEHEN hat — erst das Abbild mit Nullen, dann derselbe Rip
|
||||
// aus dem Abbild (iso:), das nicht mehr hängen kann.
|
||||
import { mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
||||
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
|
||||
import { join } from 'node:path'
|
||||
import { basename, join } from 'node:path'
|
||||
import type {
|
||||
BibliothekEintrag,
|
||||
GeraeteInfo,
|
||||
@@ -38,6 +38,7 @@ import { werkzeugSpawn } from '../werkzeuge/aufruf'
|
||||
import { mkdtempSync, createWriteStream } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
|
||||
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from './filme'
|
||||
|
||||
interface LaufenderVorgang {
|
||||
/** Beendet den GANZEN Auftrag. */
|
||||
@@ -99,6 +100,9 @@ export interface PipelineUmgebung {
|
||||
/** 5.4.0: Der Rip ist durch — der Kern merkt sich den Vorgang und reiht
|
||||
* die Kompression ein. Liefert den Platz in der Schlange (1 = jetzt). */
|
||||
vorgangEinreihen?: (auftrag: KompressionsAuftrag, titel: string) => number
|
||||
/** 5.6.0: die volle Zuordnung zu einem TMDb-Vorschlag (zweiter Film einer
|
||||
* Disc) — null, wenn nichts zu holen ist. */
|
||||
zuordnungFuer?: (vorschlag: NonNullable<RohDatei['film']>) => Promise<Zuordnung | null>
|
||||
/** 5.4.0: Ein Vorgang beginnt — Datensatz anlegen. */
|
||||
vorgangBeginnt?: (vorgang: VorgangBeginn) => void
|
||||
/** 5.4.0: Der Rip endete ohne Auftrag — Fehler oder Abbruch. */
|
||||
@@ -237,9 +241,21 @@ export class Pipeline {
|
||||
}
|
||||
|
||||
this.letzterAuftrag.set(id, { auftrag: [...auftrag], optionen: { ...optionen } })
|
||||
const rohOrdner = join(this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh'), vorgangsName)
|
||||
mkdirSync(rohOrdner, { recursive: true })
|
||||
// 5.6.0: Der Roh-Ordner heißt wie der Film (filme.ts) — der Vorgang
|
||||
// behält seinen Zeitstempel-Namen als Kennung.
|
||||
const rohWurzel = this.umgebung.rohWurzel?.() ?? join(this.umgebung.ablage(), 'roh')
|
||||
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
|
||||
const startAngaben = this.umgebung.discAngaben?.(id) ?? null
|
||||
const rohOrdner = join(
|
||||
rohWurzel,
|
||||
rohOrdnerName(
|
||||
zuordnung,
|
||||
{ staffel: optionen.staffel ?? startAngaben?.staffel ?? null, discNr: startAngaben?.discNr ?? null },
|
||||
vorgangsName,
|
||||
(name) => existsSync(join(rohWurzel, name)),
|
||||
),
|
||||
)
|
||||
mkdirSync(rohOrdner, { recursive: true })
|
||||
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
|
||||
this.umgebung.vorgangBeginnt?.({
|
||||
id: vorgangsName,
|
||||
@@ -476,16 +492,77 @@ export class Pipeline {
|
||||
groesseBytes: groesseVon(pfad),
|
||||
audioSprachen: a?.audioSprachen ?? [],
|
||||
untertitelSprachen: a?.untertitelSprachen ?? [],
|
||||
...(a?.untertitelVorwahl === null || typeof a?.untertitelVorwahl === 'string' ? { untertitelVorwahl: a.untertitelVorwahl } : {}),
|
||||
...(a?.film !== undefined ? { film: a.film } : {}),
|
||||
}
|
||||
})
|
||||
const angaben = this.umgebung.discAngaben?.(id) ?? null
|
||||
|
||||
// ── 5.6.0: Titel eines ANDEREN Films bekommen eigenen Roh-Ordner und
|
||||
// Vorgang — jeder Film seinen Ordner (Commander, Double Feature). ──
|
||||
const gruppen = filmGruppen(dateien, zuordnung !== null && zuordnung.meta.typ === 'movie' ? Number(zuordnung.meta.id) : null)
|
||||
let hauptDateien = gruppen.primaer
|
||||
let hauptZuordnung = zuordnung
|
||||
if (gruppen.primaerFilm !== null) {
|
||||
hauptZuordnung = (await this.umgebung.zuordnungFuer?.(gruppen.primaerFilm)) ?? zuordnungAusVorschlag(gruppen.primaerFilm)
|
||||
}
|
||||
const weitereAuftraege: Array<{ auftrag: Omit<KompressionsAuftrag, 'vorgangId' | 'rohOrdner' | 'dateien' | 'zuordnung'> & Pick<KompressionsAuftrag, 'vorgangId' | 'rohOrdner' | 'dateien' | 'zuordnung'>; titel: string }> = []
|
||||
for (let n = 0; n < gruppen.weitere.length; n++) {
|
||||
const gruppe = gruppen.weitere[n]
|
||||
const gruppenZuordnung = (await this.umgebung.zuordnungFuer?.(gruppe.film)) ?? zuordnungAusVorschlag(gruppe.film)
|
||||
const gruppenId = `${vorgangsName}-${n + 2}`
|
||||
const gruppenOrdner = join(rohWurzel, rohOrdnerName(gruppenZuordnung, null, gruppenId, (name) => existsSync(join(rohWurzel, name))))
|
||||
try {
|
||||
mkdirSync(gruppenOrdner, { recursive: true })
|
||||
for (const d of gruppe.dateien) {
|
||||
const neu = join(gruppenOrdner, basename(d.pfad))
|
||||
renameSync(d.pfad, neu)
|
||||
d.pfad = neu
|
||||
}
|
||||
} catch (fehler) {
|
||||
hinweise.push(`„${gruppe.film.titel}" bekam keinen eigenen Roh-Ordner (${String(fehler)}) — die Titel bleiben beim ersten Film.`)
|
||||
hauptDateien = [...hauptDateien, ...gruppe.dateien]
|
||||
continue
|
||||
}
|
||||
this.umgebung.vorgangBeginnt?.({
|
||||
id: gruppenId,
|
||||
laufwerk: id,
|
||||
typ: geraet.typ,
|
||||
titel: gruppenZuordnung.titel,
|
||||
jahr: gruppenZuordnung.jahr,
|
||||
rohOrdner: gruppenOrdner,
|
||||
begonnen: startZeit.toISOString(),
|
||||
})
|
||||
weitereAuftraege.push({
|
||||
titel: gruppenZuordnung.titel,
|
||||
auftrag: {
|
||||
vorgangId: gruppenId,
|
||||
laufwerk: id,
|
||||
discTyp: geraet.typ,
|
||||
rohOrdner: gruppenOrdner,
|
||||
dateien: gruppe.dateien,
|
||||
zuordnung: gruppenZuordnung,
|
||||
staffel: null,
|
||||
ersteFolge: null,
|
||||
discNr: angaben?.discNr ?? null,
|
||||
fingerprint: null,
|
||||
lesefehler: lesefehlerGesamt,
|
||||
hinweise: [],
|
||||
begonnen: startZeit.toISOString(),
|
||||
preset: '',
|
||||
zielOrdner: '',
|
||||
},
|
||||
})
|
||||
}
|
||||
const hauptTitel = hauptZuordnung !== null && hauptZuordnung.titel.length > 0 ? hauptZuordnung.titel : anzeigeTitel
|
||||
|
||||
const kompression: KompressionsAuftrag = {
|
||||
vorgangId: vorgangsName,
|
||||
laufwerk: id,
|
||||
discTyp: geraet.typ,
|
||||
rohOrdner,
|
||||
dateien,
|
||||
zuordnung,
|
||||
dateien: hauptDateien,
|
||||
zuordnung: hauptZuordnung,
|
||||
staffel: optionen.staffel ?? angaben?.staffel ?? null,
|
||||
ersteFolge: optionen.ersteFolge ?? null,
|
||||
discNr: angaben?.discNr ?? null,
|
||||
@@ -496,7 +573,12 @@ export class Pipeline {
|
||||
preset: '',
|
||||
zielOrdner: '',
|
||||
}
|
||||
const platz = this.umgebung.vorgangEinreihen?.(kompression, anzeigeTitel) ?? 0
|
||||
const platz = this.umgebung.vorgangEinreihen?.(kompression, hauptTitel) ?? 0
|
||||
const weitereTitel: string[] = []
|
||||
for (const w of weitereAuftraege) {
|
||||
this.umgebung.vorgangEinreihen?.(w.auftrag, w.titel)
|
||||
weitereTitel.push(w.titel)
|
||||
}
|
||||
const rohBytes = dateien.reduce((s, d) => s + d.groesseBytes, 0)
|
||||
|
||||
// Das Laufwerk ist FREI — die Kompression läuft ohne es weiter.
|
||||
@@ -511,6 +593,7 @@ export class Pipeline {
|
||||
(platz > 0
|
||||
? `Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}, das Laufwerk ist frei.`
|
||||
: 'Kompression nicht eingereiht (kein Empfänger) — die Roh-Dateien liegen unter ' + rohOrdner + '.') +
|
||||
(weitereTitel.length > 0 ? ` Weitere${weitereTitel.length === 1 ? 'r' : ''} Film${weitereTitel.length === 1 ? '' : 'e'} mit eigenem Vorgang: ${weitereTitel.join(', ')}.` : '') +
|
||||
(lesefehlerGesamt
|
||||
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
|
||||
: '') +
|
||||
|
||||
@@ -153,6 +153,14 @@ export class AutomatikDienst {
|
||||
if (grund.length > 0) this.melden(id, false, 0, grund)
|
||||
}
|
||||
|
||||
/** Der Kasten in der Kachel verschwindet — ein Rip hat begonnen (5.6.0):
|
||||
* Countdown weg, Text weg. Ohne das stand „Automatik gestoppt — du
|
||||
* entscheidest." noch unter dem fertigen Rip. */
|
||||
leeren(id: string): void {
|
||||
this.stopp(id, '')
|
||||
if (this.staende.has(id)) this.melden(id, false, 0, '')
|
||||
}
|
||||
|
||||
/** „Jetzt": den Countdown überspringen. */
|
||||
jetzt(id: string): void {
|
||||
const stand = this.staende.get(id)
|
||||
|
||||
@@ -36,6 +36,7 @@ export const ERLAUBTE_EINSTELLUNGEN: ReadonlySet<string> = new Set([
|
||||
'discordWebhook',
|
||||
'telegramToken',
|
||||
'telegramChatId',
|
||||
'design',
|
||||
])
|
||||
|
||||
/** Die Schlüssel, die Rip und Kompression lesen (Pipeline/Ablage). Das
|
||||
|
||||
@@ -238,32 +238,43 @@ export class ErkennungDienst {
|
||||
}
|
||||
}
|
||||
|
||||
/** Aus einem TMDb-Vorschlag die volle Zuordnung (Details holen) — für
|
||||
* „Ändern …" und seit 5.6.0 für den zweiten Film einer Disc. Vom Nutzer
|
||||
* gewählt = sicher (confidence 1). Ohne Details bleibt der Vorschlag. */
|
||||
async zuordnungAus(vorschlag: MetaVorschlag): Promise<Zuordnung> {
|
||||
const tmdb = this.tmdb()
|
||||
let details = null
|
||||
try {
|
||||
details = vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`TMDb-Details für „${vorschlag.titel}": ${String(fehler)} — Zuordnung ohne Details.`)
|
||||
}
|
||||
const meta =
|
||||
details !== null
|
||||
? ausDetails(vorschlag.typ, vorschlag.tmdbId, details)
|
||||
: {
|
||||
...LEERER_TREFFER,
|
||||
typ: vorschlag.typ,
|
||||
id: vorschlag.tmdbId,
|
||||
titel: vorschlag.titel,
|
||||
jahr: vorschlag.jahr,
|
||||
posterPfad: vorschlag.posterPfad,
|
||||
quelle: 'tmdb' as const,
|
||||
}
|
||||
return {
|
||||
titel: meta.titel.length > 0 ? meta.titel : vorschlag.titel,
|
||||
jahr: meta.jahr ?? vorschlag.jahr,
|
||||
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
|
||||
confidence: 1,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
async zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
|
||||
try {
|
||||
const tmdb = this.tmdb()
|
||||
const details =
|
||||
vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
|
||||
const meta =
|
||||
details !== null
|
||||
? ausDetails(vorschlag.typ, vorschlag.tmdbId, details)
|
||||
: {
|
||||
...LEERER_TREFFER,
|
||||
typ: vorschlag.typ,
|
||||
id: vorschlag.tmdbId,
|
||||
titel: vorschlag.titel,
|
||||
jahr: vorschlag.jahr,
|
||||
posterPfad: vorschlag.posterPfad,
|
||||
quelle: 'tmdb' as const,
|
||||
}
|
||||
const zuordnung: Zuordnung = {
|
||||
titel: meta.titel.length > 0 ? meta.titel : vorschlag.titel,
|
||||
jahr: meta.jahr ?? vorschlag.jahr,
|
||||
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
|
||||
confidence: 1,
|
||||
meta,
|
||||
}
|
||||
const zuordnung = await this.zuordnungAus(vorschlag)
|
||||
this.zuordnungen.set(id, zuordnung)
|
||||
this.kontext.protokoll.schreiben(`Zuordnung ${id} vom Nutzer: „${zuordnung.titel}" (${meta.typ} ${String(meta.id)})`)
|
||||
this.kontext.protokoll.schreiben(`Zuordnung ${id} vom Nutzer: „${zuordnung.titel}" (${zuordnung.meta.typ} ${String(zuordnung.meta.id)})`)
|
||||
this.kontext.anHauptUndFenster({ art: 'disc-info', info: this.discInfoStand(id, zuordnung, 'deine Wahl') })
|
||||
} catch (fehler) {
|
||||
this.kontext.fehler(`Zuordnung übernehmen: ${String(fehler)}`)
|
||||
|
||||
@@ -147,6 +147,7 @@ ripVerwaltung = new Pipeline({
|
||||
zuordnung: (id) => erkennung.zuordnung(id),
|
||||
discAngaben: (id) => erkennung.discAngaben(id),
|
||||
fingerprint: (id) => erkennung.fingerprint(id),
|
||||
zuordnungFuer: (vorschlag) => erkennung.zuordnungAus(vorschlag),
|
||||
bibliothekEintragen: (eintrag) => vorgaenge.bibliothekEintragen(eintrag),
|
||||
vorgangBeginnt: (v) => vorgaenge.vorgangBeginnt(v),
|
||||
vorgangEinreihen: (auftrag, titel) => vorgaenge.vorgangEinreihen(auftrag, titel),
|
||||
@@ -192,6 +193,8 @@ function ripMitPlatz(id: string, auftrag: RipAuftragTitel[], optionen: RipAuftra
|
||||
}
|
||||
if (urteil.text.length > 0) kontext.hinweis(`Platz im Arbeitsordner: ${urteil.text}`)
|
||||
}
|
||||
// Der Rip beginnt — der Automatik-Kasten in der Kachel hat ausgedient.
|
||||
automatik.leeren(id)
|
||||
void laufwerke.bereit.then(() => ripVerwaltung.starten(id, auftrag, optionen))
|
||||
}
|
||||
|
||||
@@ -229,7 +232,7 @@ function fensterNachricht(frage: unknown): void {
|
||||
ripVerwaltung.ueberspringen(frage.id)
|
||||
return
|
||||
case 'rettung-start':
|
||||
automatik.stopp(frage.id, '')
|
||||
automatik.leeren(frage.id)
|
||||
void laufwerke.bereit.then(() => ripVerwaltung.rettungStarten(frage.id))
|
||||
return
|
||||
case 'metadaten-suchen':
|
||||
|
||||
@@ -72,6 +72,20 @@ export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
|
||||
// Encoding: … Searching for start time, %.2f → Vorlauf, NICHT Encode
|
||||
const HB_ENCODE = /^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%/
|
||||
|
||||
// 5.6.0 ETA — Formatstring aus dem Binary gelesen (HandBrakeCLI 1.11.2,
|
||||
// grep -a, 02.09.2026):
|
||||
// Encoding: task %d of %d, %.2f %% (%.2f fps, avg %.2f fps, ETA %02dh%02dm%02ds)
|
||||
// Die Klammer fehlt in den ersten Sekunden (noch keine fps) — dann -1.
|
||||
const HB_ETA = /ETA ([0-9]+)h([0-9]+)m([0-9]+)s/
|
||||
|
||||
/** Restzeit in Sekunden aus einer Encoding-Zeile, -1 ohne Angabe. */
|
||||
export function etaAusZeile(zeile: string): number {
|
||||
if (!HB_ENCODE.test(zeile.trim())) return -1
|
||||
const t = HB_ETA.exec(zeile)
|
||||
if (t === null) return -1
|
||||
return Number(t[1]) * 3600 + Number(t[2]) * 60 + Number(t[3])
|
||||
}
|
||||
|
||||
/** Encode-Fortschritt 0–100 oder -1 („keine Angabe" — ein echtes 0 % muss
|
||||
* von „nichts" unterscheidbar bleiben). Mehrere Durchläufe (task N of M)
|
||||
* werden zusammengerechnet, sonst springt die Anzeige zurück auf 0. */
|
||||
@@ -122,11 +136,12 @@ export function dateiDaneben(erwartet: string, existiert: (p: string) => boolean
|
||||
/** Auswertung des Laufs — pur, zeilenweise, testbar ohne HandBrake. */
|
||||
export class HbAuswertung {
|
||||
prozent = -1
|
||||
restS = -1
|
||||
falschesPreset = ''
|
||||
falscherSchalter = ''
|
||||
letzteZeilen: string[] = []
|
||||
|
||||
verarbeite(zeile: string): { prozent?: number } {
|
||||
verarbeite(zeile: string): { prozent?: number; restS?: number } {
|
||||
if (zeile.trim().length > 0) {
|
||||
this.letzteZeilen.push(zeile)
|
||||
if (this.letzteZeilen.length > HB_ZEILEN_PUFFER) this.letzteZeilen.shift()
|
||||
@@ -134,9 +149,13 @@ export class HbAuswertung {
|
||||
if (this.falschesPreset.length === 0) this.falschesPreset = unbekanntesPreset(zeile)
|
||||
if (this.falscherSchalter.length === 0) this.falscherSchalter = unbekannterSchalter(zeile)
|
||||
const p = progressAusZeile(zeile)
|
||||
if (p >= 0 && p !== this.prozent) {
|
||||
this.prozent = p
|
||||
return { prozent: p }
|
||||
if (p >= 0) {
|
||||
const rest = etaAusZeile(zeile)
|
||||
if (p !== this.prozent || (rest >= 0 && rest !== this.restS)) {
|
||||
this.prozent = p
|
||||
this.restS = rest
|
||||
return rest >= 0 ? { prozent: p, restS: rest } : { prozent: p }
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
@@ -245,7 +264,8 @@ export interface HbLaufOptionen {
|
||||
untertitelStandard?: boolean
|
||||
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
|
||||
extraArgs?: readonly string[]
|
||||
aufFortschritt?: (prozent: number) => void
|
||||
/** Fortschritt 0–100 und Restzeit in Sekunden (-1 = noch keine Angabe). */
|
||||
aufFortschritt?: (prozent: number, restS: number) => void
|
||||
abbruch?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -267,7 +287,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
|
||||
|
||||
const zeile = (z: string): void => {
|
||||
const ergebnis = auswertung.verarbeite(z)
|
||||
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
|
||||
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent, ergebnis.restS ?? -1)
|
||||
}
|
||||
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
|
||||
|
||||
Reference in New Issue
Block a user