Initial commit mit MkDocs-Dokumentation

This commit is contained in:
2026-03-04 14:18:33 +00:00
parent 6115090da1
commit 31d3e36597
97 changed files with 27518 additions and 1 deletions

View File

@@ -0,0 +1,39 @@
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
export default function DiscDetectedDialog({ visible, device, onHide, onAnalyze, busy }) {
return (
<Dialog
header="Neue Disk erkannt"
visible={visible}
onHide={onHide}
style={{ width: '32rem', maxWidth: '96vw' }}
className="disc-detected-dialog"
breakpoints={{ '768px': '96vw', '560px': '98vw' }}
modal
>
<p>
Laufwerk: <strong>{device?.path || 'unbekannt'}</strong>
</p>
<p>
Disk-Label: <strong>{device?.discLabel || 'n/a'}</strong>
</p>
<p>
Laufwerks-Label: <strong>{device?.label || 'n/a'}</strong>
</p>
<p>
Modell: <strong>{device?.model || 'n/a'}</strong>
</p>
<div className="dialog-actions">
<Button label="Schließen" severity="secondary" onClick={onHide} text />
<Button
label="Analyse starten"
icon="pi pi-search"
onClick={onAnalyze}
loading={busy}
/>
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,224 @@
import { useEffect, useState } from 'react';
import { TabView, TabPanel } from 'primereact/tabview';
import { InputText } from 'primereact/inputtext';
import { InputNumber } from 'primereact/inputnumber';
import { InputSwitch } from 'primereact/inputswitch';
import { Dropdown } from 'primereact/dropdown';
import { Tag } from 'primereact/tag';
function normalizeText(value) {
return String(value || '').trim().toLowerCase();
}
function normalizeSettingKey(value) {
return String(value || '').trim().toLowerCase();
}
function buildToolSections(settings) {
const list = Array.isArray(settings) ? settings : [];
const definitions = [
{
id: 'makemkv',
title: 'MakeMKV',
description: 'Disc-Analyse und Rip-Einstellungen.',
match: (key) => key.startsWith('makemkv_')
},
{
id: 'mediainfo',
title: 'MediaInfo',
description: 'Track-Analyse und zusätzliche mediainfo Parameter.',
match: (key) => key.startsWith('mediainfo_')
},
{
id: 'handbrake',
title: 'HandBrake',
description: 'Preset, Encoding-CLI und HandBrake-Optionen.',
match: (key) => key.startsWith('handbrake_')
},
{
id: 'output',
title: 'Output',
description: 'Container-Format und Dateinamen-Template.',
match: (key) => key === 'output_extension' || key === 'filename_template'
}
];
const buckets = definitions.map((item) => ({
...item,
settings: []
}));
const fallbackBucket = {
id: 'other',
title: 'Weitere Tool-Settings',
description: null,
settings: []
};
for (const setting of list) {
const key = normalizeSettingKey(setting?.key);
let assigned = false;
for (const bucket of buckets) {
if (bucket.match(key)) {
bucket.settings.push(setting);
assigned = true;
break;
}
}
if (!assigned) {
fallbackBucket.settings.push(setting);
}
}
const sections = buckets.filter((item) => item.settings.length > 0);
if (fallbackBucket.settings.length > 0) {
sections.push(fallbackBucket);
}
return sections;
}
function buildSectionsForCategory(categoryName, settings) {
const list = Array.isArray(settings) ? settings : [];
const normalizedCategory = normalizeText(categoryName);
if (normalizedCategory === 'tools') {
const sections = buildToolSections(list);
if (sections.length > 0) {
return sections;
}
}
return [
{
id: 'all',
title: null,
description: null,
settings: list
}
];
}
export default function DynamicSettingsForm({
categories,
values,
errors,
dirtyKeys,
onChange
}) {
const safeCategories = Array.isArray(categories) ? categories : [];
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
if (safeCategories.length === 0) {
setActiveIndex(0);
return;
}
if (activeIndex < 0 || activeIndex >= safeCategories.length) {
setActiveIndex(0);
}
}, [activeIndex, safeCategories.length]);
if (safeCategories.length === 0) {
return <p>Keine Kategorien vorhanden.</p>;
}
return (
<TabView
className="settings-tabview"
activeIndex={activeIndex}
onTabChange={(event) => setActiveIndex(Number(event.index || 0))}
scrollable
>
{safeCategories.map((category, categoryIndex) => (
<TabPanel
key={`${category.category || 'category'}-${categoryIndex}`}
header={category.category || `Kategorie ${categoryIndex + 1}`}
>
{(() => {
const sections = buildSectionsForCategory(category?.category, category?.settings || []);
const grouped = sections.length > 1;
return (
<div className="settings-sections">
{sections.map((section) => (
<section
key={`${category?.category || 'category'}-${section.id}`}
className={`settings-section${grouped ? ' grouped' : ''}`}
>
{section.title ? (
<div className="settings-section-head">
<h4>{section.title}</h4>
{section.description ? <small>{section.description}</small> : null}
</div>
) : null}
<div className="settings-grid">
{(section.settings || []).map((setting) => {
const value = values?.[setting.key];
const error = errors?.[setting.key] || null;
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
return (
<div key={setting.key} className="setting-row">
<label htmlFor={setting.key}>
{setting.label}
{setting.required && <span className="required">*</span>}
</label>
{setting.type === 'string' || setting.type === 'path' ? (
<InputText
id={setting.key}
value={value ?? ''}
onChange={(event) => onChange?.(setting.key, event.target.value)}
/>
) : null}
{setting.type === 'number' ? (
<InputNumber
id={setting.key}
value={value ?? 0}
onValueChange={(event) => onChange?.(setting.key, event.value)}
mode="decimal"
useGrouping={false}
/>
) : null}
{setting.type === 'boolean' ? (
<InputSwitch
id={setting.key}
checked={Boolean(value)}
onChange={(event) => onChange?.(setting.key, event.value)}
/>
) : null}
{setting.type === 'select' ? (
<Dropdown
id={setting.key}
value={value}
options={setting.options}
optionLabel="label"
optionValue="value"
onChange={(event) => onChange?.(setting.key, event.value)}
/>
) : null}
<small>{setting.description || ''}</small>
{error ? (
<small className="error-text">{error}</small>
) : (
<Tag
value={dirty ? 'Ungespeichert' : 'Gespeichert'}
severity={dirty ? 'warning' : 'success'}
className="saved-tag"
/>
)}
</div>
);
})}
</div>
</section>
))}
</div>
);
})()}
</TabPanel>
))}
</TabView>
);
}

View File

@@ -0,0 +1,230 @@
import { Dialog } from 'primereact/dialog';
import { Tag } from 'primereact/tag';
import { Button } from 'primereact/button';
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
function JsonView({ title, value }) {
return (
<div>
<h4>{title}</h4>
<pre className="json-box">{value ? JSON.stringify(value, null, 2) : '-'}</pre>
</div>
);
}
export default function JobDetailDialog({
visible,
job,
onHide,
detailLoading = false,
onLoadLog,
logLoadingMode = null,
onAssignOmdb,
onReencode,
onDeleteFiles,
onDeleteEntry,
omdbAssignBusy = false,
actionBusy = false,
reencodeBusy = false,
deleteEntryBusy = false
}) {
const mkDone = !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
const showFinalLog = !running;
const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && mkDone && !running);
const canDeleteEntry = !running;
const logCount = Number(job?.log_count || 0);
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
const logTruncated = Boolean(logMeta?.truncated);
return (
<Dialog
header={`Job #${job?.id || ''}`}
visible={visible}
onHide={onHide}
style={{ width: '70rem', maxWidth: '96vw' }}
className="job-detail-dialog"
breakpoints={{ '1440px': '94vw', '1024px': '96vw', '640px': '98vw' }}
modal
>
{!job ? null : (
<>
{detailLoading ? <p>Details werden geladen ...</p> : null}
<div className="job-head-row">
{job.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={job.title || 'Poster'} className="poster-large" />
) : (
<div className="poster-large poster-fallback">Kein Poster</div>
)}
<div className="job-meta-grid">
<div>
<strong>Titel:</strong> {job.title || job.detected_title || '-'}
</div>
<div>
<strong>Jahr:</strong> {job.year || '-'}
</div>
<div>
<strong>IMDb:</strong> {job.imdb_id || '-'}
</div>
<div>
<strong>OMDb Match:</strong>{' '}
<Tag value={job.selected_from_omdb ? 'Ja' : 'Nein'} severity={job.selected_from_omdb ? 'success' : 'secondary'} />
</div>
<div>
<strong>Status:</strong> <Tag value={job.status} />
</div>
<div>
<strong>Start:</strong> {job.start_time || '-'}
</div>
<div>
<strong>Ende:</strong> {job.end_time || '-'}
</div>
<div>
<strong>RAW Pfad:</strong> {job.raw_path || '-'}
</div>
<div>
<strong>Output:</strong> {job.output_path || '-'}
</div>
<div>
<strong>Encode Input:</strong> {job.encode_input_path || '-'}
</div>
<div>
<strong>Mediainfo bestätigt:</strong> {job.encode_review_confirmed ? 'ja' : 'nein'}
</div>
<div>
<strong>RAW vorhanden:</strong> {job.rawStatus?.exists ? 'ja' : 'nein'}
</div>
<div>
<strong>RAW leer:</strong> {job.rawStatus?.isEmpty === null ? '-' : job.rawStatus?.isEmpty ? 'ja' : 'nein'}
</div>
<div>
<strong>Movie Datei vorhanden:</strong> {job.outputStatus?.exists ? 'ja' : 'nein'}
</div>
<div>
<strong>Movie-Dir leer:</strong> {job.movieDirStatus?.isEmpty === null ? '-' : job.movieDirStatus?.isEmpty ? 'ja' : 'nein'}
</div>
<div>
<strong>Fehler:</strong> {job.error_message || '-'}
</div>
</div>
</div>
<div className="job-json-grid">
<JsonView title="OMDb Info" value={job.omdbInfo} />
<JsonView title="MakeMKV Info" value={job.makemkvInfo} />
<JsonView title="HandBrake Info" value={job.handbrakeInfo} />
<JsonView title="Mediainfo Info" value={job.mediainfoInfo} />
<JsonView title="Encode Plan" value={job.encodePlan} />
</div>
{job.encodePlan ? (
<>
<h4>Mediainfo-Prüfung (Auswertung)</h4>
<MediaInfoReviewPanel review={job.encodePlan} />
</>
) : null}
<h4>Aktionen</h4>
<div className="actions-row">
<Button
label="OMDb neu zuordnen"
icon="pi pi-search"
severity="secondary"
size="small"
onClick={() => onAssignOmdb?.(job)}
loading={omdbAssignBusy}
disabled={running}
/>
<Button
label="RAW neu encodieren"
icon="pi pi-cog"
severity="info"
size="small"
onClick={() => onReencode?.(job)}
loading={reencodeBusy}
disabled={!canReencode}
/>
<Button
label="RAW löschen"
icon="pi pi-trash"
severity="warning"
outlined
size="small"
onClick={() => onDeleteFiles?.(job, 'raw')}
loading={actionBusy}
disabled={!job.rawStatus?.exists}
/>
<Button
label="Movie löschen"
icon="pi pi-trash"
severity="warning"
outlined
size="small"
onClick={() => onDeleteFiles?.(job, 'movie')}
loading={actionBusy}
disabled={!job.outputStatus?.exists}
/>
<Button
label="Beides löschen"
icon="pi pi-times"
severity="danger"
size="small"
onClick={() => onDeleteFiles?.(job, 'both')}
loading={actionBusy}
disabled={!job.rawStatus?.exists && !job.outputStatus?.exists}
/>
<Button
label="Historieneintrag löschen"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
onClick={() => onDeleteEntry?.(job)}
loading={deleteEntryBusy}
disabled={!canDeleteEntry}
/>
</div>
<h4>Log</h4>
{showFinalLog ? (
<>
<div className="actions-row">
<Button
label={logLoaded ? 'Tail neu laden (800)' : 'Tail laden (800)'}
icon="pi pi-download"
severity="secondary"
outlined
size="small"
onClick={() => onLoadLog?.(job, 'tail')}
loading={logLoadingMode === 'tail'}
/>
<Button
label="Vollständiges Log laden"
icon="pi pi-list"
severity="secondary"
outlined
size="small"
onClick={() => onLoadLog?.(job, 'all')}
loading={logLoadingMode === 'all'}
disabled={logCount <= 0}
/>
<small>{`Log-Zeilen: ${logCount}`}</small>
{logTruncated ? <small>(gekürzt auf letzte 800 Zeilen)</small> : null}
</div>
{logLoaded ? (
<pre className="log-box">{job.log || ''}</pre>
) : (
<p>Log nicht vorgeladen. Über die Buttons oben laden.</p>
)}
</>
) : (
<p>Live-Log wird nur im Dashboard während laufender Analyse/Rip/Encode angezeigt.</p>
)}
</>
)}
</Dialog>
);
}

View File

@@ -0,0 +1,827 @@
function formatDuration(minutes) {
const value = Number(minutes || 0);
if (!Number.isFinite(value)) {
return '-';
}
return `${value.toFixed(2)} min`;
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) {
return '-';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = value;
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return `${size.toFixed(2)} ${units[index]}`;
}
function normalizeTrackId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeTrackIdList(values) {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const normalized = normalizeTrackId(value);
if (normalized === null) {
continue;
}
const key = String(normalized);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(normalized);
}
return output;
}
function splitArgs(input) {
if (!input || typeof input !== 'string') {
return [];
}
const args = [];
let current = '';
let quote = null;
let escaping = false;
for (const ch of input) {
if (escaping) {
current += ch;
escaping = false;
continue;
}
if (ch === '\\') {
escaping = true;
continue;
}
if (quote) {
if (ch === quote) {
quote = null;
} else {
current += ch;
}
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (/\s/.test(ch)) {
if (current.length > 0) {
args.push(current);
current = '';
}
continue;
}
current += ch;
}
if (current.length > 0) {
args.push(current);
}
return args;
}
const AUDIO_SELECTION_KEYS_WITH_VALUE = new Set(['-a', '--audio', '--audio-lang-list']);
const AUDIO_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-audio', '--first-audio']);
const SUBTITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-s', '--subtitle', '--subtitle-lang-list']);
const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-subtitle']);
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
function removeSelectionArgs(extraArgs) {
const args = Array.isArray(extraArgs) ? extraArgs : [];
const filtered = [];
for (let i = 0; i < args.length; i += 1) {
const token = String(args[i] || '');
const key = token.includes('=') ? token.slice(0, token.indexOf('=')) : token;
const isAudioWithValue = AUDIO_SELECTION_KEYS_WITH_VALUE.has(key);
const isAudioFlagOnly = AUDIO_SELECTION_KEYS_FLAG_ONLY.has(key);
const isSubtitleWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key)
|| SUBTITLE_FLAG_KEYS_WITH_VALUE.has(key);
const isSubtitleFlagOnly = SUBTITLE_SELECTION_KEYS_FLAG_ONLY.has(key);
const isTitleWithValue = TITLE_SELECTION_KEYS_WITH_VALUE.has(key);
const skip = isAudioWithValue || isAudioFlagOnly || isSubtitleWithValue || isSubtitleFlagOnly || isTitleWithValue;
if (!skip) {
filtered.push(token);
continue;
}
if ((isAudioWithValue || isSubtitleWithValue || isTitleWithValue) && !token.includes('=')) {
const nextToken = String(args[i + 1] || '');
if (nextToken && !nextToken.startsWith('-')) {
i += 1;
}
}
}
return filtered;
}
function shellQuote(value) {
const raw = String(value ?? '');
if (raw.length === 0) {
return "''";
}
if (/^[A-Za-z0-9_./:=,+-]+$/.test(raw)) {
return raw;
}
return `'${raw.replace(/'/g, `'"'"'`)}'`;
}
function buildHandBrakeCommandPreview({
review,
title,
selectedAudioTrackIds,
selectedSubtitleTrackIds,
commandOutputPath = null
}) {
const inputPath = String(title?.filePath || review?.encodeInputPath || '').trim();
const handBrakeCmd = String(
review?.selectors?.handbrakeCommand
|| review?.selectors?.handBrakeCommand
|| 'HandBrakeCLI'
).trim() || 'HandBrakeCLI';
const preset = String(review?.selectors?.preset || '').trim();
const extraArgs = String(review?.selectors?.extraArgs || '').trim();
const rawMappedTitleId = Number(review?.handBrakeTitleId);
const mappedTitleId = Number.isFinite(rawMappedTitleId) && rawMappedTitleId > 0
? Math.trunc(rawMappedTitleId)
: null;
const selectedSubtitleSet = new Set(normalizeTrackIdList(selectedSubtitleTrackIds).map((id) => String(id)));
const selectedSubtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).filter((track) => {
const id = normalizeTrackId(track?.id);
return id !== null && selectedSubtitleSet.has(String(id));
});
const subtitleBurnTrackId = normalizeTrackIdList(
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewBurnIn || track?.burnIn)).map((track) => track?.id)
)[0] || null;
const subtitleDefaultTrackId = normalizeTrackIdList(
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewDefaultTrack || track?.defaultTrack)).map((track) => track?.id)
)[0] || null;
const subtitleForcedTrackId = normalizeTrackIdList(
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewForced || track?.forced)).map((track) => track?.id)
)[0] || null;
const subtitleForcedOnly = selectedSubtitleTracks.some((track) => Boolean(track?.subtitlePreviewForcedOnly || track?.forcedOnly));
const baseArgs = [
'-i',
inputPath || '<encode-input>',
'-o',
String(commandOutputPath || '').trim() || '<encode-output>'
];
if (mappedTitleId !== null) {
baseArgs.push('-t', String(mappedTitleId));
}
if (preset) {
baseArgs.push('-Z', preset);
}
const filteredExtra = removeSelectionArgs(splitArgs(extraArgs));
const overrideArgs = [
'-a',
normalizeTrackIdList(selectedAudioTrackIds).join(',') || 'none',
'-s',
normalizeTrackIdList(selectedSubtitleTrackIds).join(',') || 'none'
];
if (subtitleBurnTrackId !== null) {
overrideArgs.push(`--subtitle-burned=${subtitleBurnTrackId}`);
}
if (subtitleDefaultTrackId !== null) {
overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`);
}
if (subtitleForcedTrackId !== null) {
overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`);
} else if (subtitleForcedOnly) {
overrideArgs.push('--subtitle-forced');
}
const finalArgs = [...baseArgs, ...filteredExtra, ...overrideArgs];
return `${handBrakeCmd} ${finalArgs.map((arg) => shellQuote(arg)).join(' ')}`;
}
function toLang2(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return 'und';
}
const map = {
en: 'en',
eng: 'en',
de: 'de',
deu: 'de',
ger: 'de',
tr: 'tr',
tur: 'tr',
fr: 'fr',
fra: 'fr',
fre: 'fr',
es: 'es',
spa: 'es',
it: 'it',
ita: 'it'
};
if (map[raw]) {
return map[raw];
}
if (raw.length === 2) {
return raw;
}
if (raw.length >= 3) {
return raw.slice(0, 2);
}
return raw;
}
function simplifyCodec(type, value, hint = null) {
const raw = String(value || '').trim();
const hintRaw = String(hint || '').trim();
const lower = raw.toLowerCase();
const merged = `${raw} ${hintRaw}`.toLowerCase();
if (!raw) {
return '-';
}
if (type === 'subtitle') {
if (merged.includes('pgs')) {
return 'PGS';
}
return raw.toUpperCase();
}
if (merged.includes('dts-hd ma') || merged.includes('dts hd ma')) {
return 'DTS-HD MA';
}
if (merged.includes('dts-hd hra') || merged.includes('dts hd hra')) {
return 'DTS-HD HRA';
}
if (merged.includes('dts-hd') || merged.includes('dts hd')) {
return 'DTS-HD';
}
if (merged.includes('dts') || merged.includes('dca')) {
return 'DTS';
}
if (merged.includes('truehd')) {
return 'TRUEHD';
}
if (merged.includes('e-ac-3') || merged.includes('eac3') || merged.includes('dd+')) {
return 'E-AC-3';
}
if (merged.includes('ac-3') || merged.includes('ac3') || merged.includes('dolby digital')) {
return 'AC-3';
}
const numeric = Number(raw);
if (Number.isFinite(numeric)) {
if (numeric === 262144) {
return 'DTS-HD';
}
if (numeric === 131072) {
return 'DTS';
}
}
return raw.toUpperCase();
}
function extractAudioVariant(hint) {
const raw = String(hint || '').trim();
if (!raw) {
return '';
}
const paren = raw.match(/\(([^)]+)\)/);
if (!paren) {
return '';
}
const parts = paren[1]
.split(',')
.map((item) => item.trim())
.filter(Boolean);
const extras = parts.filter((item) => {
const lower = item.toLowerCase();
if (lower.includes('dts') || lower.includes('ac3') || lower.includes('e-ac3') || lower.includes('eac3')) {
return false;
}
if (/\d+(?:\.\d+)?\s*ch/i.test(item)) {
return false;
}
if (/\d+\s*kbps/i.test(lower)) {
return false;
}
return true;
});
return extras.join(', ');
}
function channelCount(rawValue) {
const raw = String(rawValue || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (raw.includes('7.1')) {
return 8;
}
if (raw.includes('5.1')) {
return 6;
}
if (raw.includes('stereo') || raw.includes('2.0') || raw.includes('downmix')) {
return 2;
}
if (raw.includes('mono') || raw.includes('1.0')) {
return 1;
}
const numeric = Number(raw);
if (Number.isFinite(numeric) && numeric > 0) {
if (Math.abs(numeric - 7.1) < 0.2) {
return 8;
}
if (Math.abs(numeric - 5.1) < 0.2) {
return 6;
}
return Math.trunc(numeric);
}
const match = raw.match(/(\d+)\s*ch/);
if (match) {
const value = Number(match[1]);
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : null;
}
return null;
}
function audioChannelLabel(rawValue) {
const raw = String(rawValue || '').trim().toLowerCase();
const count = channelCount(rawValue);
if (raw.includes('7.1') || count === 8) {
return 'Surround 7.1';
}
if (raw.includes('5.1') || count === 6) {
return 'Surround 5.1';
}
if (raw.includes('stereo') || raw.includes('2.0') || raw.includes('downmix') || count === 2) {
return 'Stereo';
}
if (count === 1) {
return 'Mono';
}
return '';
}
const DEFAULT_AUDIO_FALLBACK_PREVIEW = 'av_aac';
function mapTrackToCopyCodec(track) {
const raw = [
track?.codecToken,
track?.format,
track?.codecName,
track?.description,
track?.title
]
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean)
.join(' ');
if (!raw) {
return null;
}
if (raw.includes('e-ac-3') || raw.includes('eac3') || raw.includes('dd+')) {
return 'eac3';
}
if (raw.includes('ac-3') || raw.includes('ac3') || raw.includes('dolby digital')) {
return 'ac3';
}
if (raw.includes('truehd')) {
return 'truehd';
}
if (raw.includes('dts-hd') || raw.includes('dtshd')) {
return 'dtshd';
}
if (raw.includes('dca') || raw.includes('dts')) {
return 'dts';
}
if (raw.includes('aac')) {
return 'aac';
}
if (raw.includes('flac')) {
return 'flac';
}
if (raw.includes('mp3') || raw.includes('mpeg audio')) {
return 'mp3';
}
if (raw.includes('opus')) {
return 'opus';
}
if (raw.includes('pcm') || raw.includes('lpcm')) {
return 'lpcm';
}
return null;
}
function resolveAudioEncoderPreviewLabel(track, encoderToken, copyMask, fallbackEncoder) {
const normalizedToken = String(encoderToken || '').trim().toLowerCase();
if (!normalizedToken || normalizedToken === 'preset-default') {
return 'Preset-Default (HandBrake)';
}
if (normalizedToken.startsWith('copy')) {
const sourceCodec = mapTrackToCopyCodec(track);
const explicitCopyCodec = normalizedToken.includes(':')
? normalizedToken.split(':').slice(1).join(':').trim().toLowerCase()
: null;
const normalizedCopyMask = Array.isArray(copyMask)
? copyMask.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
: [];
let canCopy = false;
if (explicitCopyCodec) {
canCopy = Boolean(sourceCodec && sourceCodec === explicitCopyCodec);
} else if (sourceCodec && normalizedCopyMask.length > 0) {
canCopy = normalizedCopyMask.includes(sourceCodec);
}
if (canCopy) {
return `Copy (${sourceCodec || track?.format || 'Quelle'})`;
}
const fallback = String(fallbackEncoder || DEFAULT_AUDIO_FALLBACK_PREVIEW).trim().toLowerCase() || DEFAULT_AUDIO_FALLBACK_PREVIEW;
return `Fallback Transcode (${fallback})`;
}
return `Transcode (${normalizedToken})`;
}
function buildAudioActionPreviewSummary(track, selectedIndex, audioSelector) {
const selector = audioSelector && typeof audioSelector === 'object' ? audioSelector : {};
const availableEncoders = Array.isArray(selector.encoders) ? selector.encoders : [];
let encoderPlan = [];
if (selector.encoderSource === 'args' && availableEncoders.length > 0) {
const safeIndex = Number.isFinite(selectedIndex) && selectedIndex >= 0 ? selectedIndex : 0;
encoderPlan = [availableEncoders[Math.min(safeIndex, availableEncoders.length - 1)]];
} else if (availableEncoders.length > 0) {
encoderPlan = [...availableEncoders];
} else {
encoderPlan = ['preset-default'];
}
const labels = encoderPlan
.map((token) => resolveAudioEncoderPreviewLabel(track, token, selector.copyMask, selector.fallbackEncoder))
.filter(Boolean);
return labels.join(' + ') || 'Übernehmen';
}
function TrackList({
title,
tracks,
type = 'generic',
allowSelection = false,
selectedTrackIds = [],
onToggleTrack = null,
audioSelector = null
}) {
const selectedIds = normalizeTrackIdList(selectedTrackIds);
const checkedTrackOrder = (Array.isArray(tracks) ? tracks : [])
.map((track) => normalizeTrackId(track?.id))
.filter((trackId, index) => {
if (trackId === null) {
return false;
}
if (allowSelection) {
return selectedIds.includes(trackId);
}
const track = tracks[index];
return Boolean(track?.selectedForEncode);
});
return (
<div>
<h4>{title}</h4>
{!tracks || tracks.length === 0 ? (
<p>Keine Einträge.</p>
) : (
<div className="media-track-list">
{tracks.map((track) => {
const trackId = normalizeTrackId(track.id);
const checked = allowSelection
? (trackId !== null && selectedIds.includes(trackId))
: Boolean(track.selectedForEncode);
const selectedIndex = trackId !== null
? checkedTrackOrder.indexOf(trackId)
: -1;
const actionInfo = type === 'audio'
? (checked
? (() => {
const base = String(track.encodePreviewSummary || track.encodeActionSummary || '').trim();
const staleUnselectedSummary = /^nicht übernommen$/i.test(base);
if (staleUnselectedSummary) {
return buildAudioActionPreviewSummary(track, selectedIndex, audioSelector);
}
return base || buildAudioActionPreviewSummary(track, selectedIndex, audioSelector);
})()
: 'Nicht übernommen')
: type === 'subtitle'
? (checked
? (() => {
const base = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim();
return /^nicht übernommen$/i.test(base) ? 'Übernehmen' : (base || 'Übernehmen');
})()
: 'Nicht übernommen')
: null;
const subtitleFlags = type === 'subtitle' && checked
? (Array.isArray(track.subtitlePreviewFlags)
? track.subtitlePreviewFlags
: (Array.isArray(track.flags) ? track.flags : []))
: [];
const displayLanguage = toLang2(track.language || track.languageLabel || 'und');
const displayHint = track.description || track.title;
const displayCodec = simplifyCodec(type, track.format, displayHint);
const displayChannelCount = channelCount(track.channels);
const displayAudioTitle = audioChannelLabel(track.channels);
const audioVariant = type === 'audio' ? extractAudioVariant(displayHint) : '';
const burned = type === 'subtitle' && checked
? Boolean(
track.subtitlePreviewBurnIn
|| track.burnIn
|| subtitleFlags.includes('burned')
|| /burned/i.test(String(track.subtitlePreviewSummary || track.subtitleActionSummary || ''))
)
: false;
let displayText = `#${track.id} | ${displayLanguage} | ${displayCodec}`;
if (type === 'audio') {
if (displayChannelCount !== null) {
displayText += ` | ${displayChannelCount}ch`;
}
if (displayAudioTitle) {
displayText += ` | ${displayAudioTitle}`;
}
if (audioVariant) {
displayText += ` | ${audioVariant}`;
}
}
if (type === 'subtitle' && burned) {
displayText += ' | burned';
}
return (
<div key={`${title}-${track.id}`} className="media-track-item">
<label className="readonly-check-row">
<input
type="checkbox"
checked={checked}
onChange={(event) => {
if (!allowSelection || typeof onToggleTrack !== 'function' || trackId === null) {
return;
}
onToggleTrack(trackId, event.target.checked);
}}
readOnly={!allowSelection}
disabled={!allowSelection}
/>
<span>{displayText}</span>
</label>
{actionInfo ? <small className="track-action-note">Encode: {actionInfo}</small> : null}
</div>
);
})}
</div>
)}
</div>
);
}
function normalizeTitleId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
export default function MediaInfoReviewPanel({
review,
commandOutputPath = null,
selectedEncodeTitleId = null,
allowTitleSelection = false,
onSelectEncodeTitle = null,
allowTrackSelection = false,
trackSelectionByTitle = {},
onTrackSelectionChange = null
}) {
if (!review) {
return <p>Keine Mediainfo-Daten vorhanden.</p>;
}
const titles = review.titles || [];
const currentSelectedId = normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(review.encodeInputTitleId);
const encodeInputTitle = titles.find((item) => item.id === currentSelectedId) || null;
const processedFiles = Number(review.processedFiles || titles.length || 0);
const totalFiles = Number(review.totalFiles || titles.length || 0);
const playlistRecommendation = review.playlistRecommendation || null;
return (
<div className="media-review-wrap">
<div className="media-review-meta">
<div><strong>Preset:</strong> {review.selectors?.preset || '-'}</div>
<div><strong>Extra Args:</strong> {review.selectors?.extraArgs || '(keine)'}</div>
<div><strong>Preset-Profil:</strong> {review.selectors?.presetProfileSource || '-'}</div>
<div><strong>MIN_LENGTH_MINUTES:</strong> {review.minLengthMinutes}</div>
<div><strong>Encode Input:</strong> {encodeInputTitle?.fileName || '-'}</div>
<div><strong>Audio Auswahl:</strong> {review.selectors?.audio?.mode || '-'}</div>
<div><strong>Audio Encoder:</strong> {(review.selectors?.audio?.encoders || []).join(', ') || 'Preset-Default'}</div>
<div><strong>Audio Copy-Mask:</strong> {(review.selectors?.audio?.copyMask || []).join(', ') || '-'}</div>
<div><strong>Audio Fallback:</strong> {review.selectors?.audio?.fallbackEncoder || '-'}</div>
<div><strong>Subtitle Auswahl:</strong> {review.selectors?.subtitle?.mode || '-'}</div>
<div><strong>Subtitle Flags:</strong> {review.selectors?.subtitle?.forcedOnly ? 'forced-only' : '-'}{review.selectors?.subtitle?.burnBehavior === 'first' ? ' + burned(first)' : ''}</div>
</div>
{review.partial ? (
<small>Zwischenstand: {processedFiles}/{totalFiles} Datei(en) analysiert.</small>
) : null}
{playlistRecommendation ? (
<div className="playlist-recommendation-box">
<small>
<strong>Empfehlung:</strong> {playlistRecommendation.playlistFile || '-'}
{playlistRecommendation.reviewTitleId ? ` (Titel #${playlistRecommendation.reviewTitleId})` : ''}
</small>
{playlistRecommendation.reason ? <small>{playlistRecommendation.reason}</small> : null}
</div>
) : null}
{Array.isArray(review.notes) && review.notes.length > 0 ? (
<div className="media-review-notes">
{review.notes.map((note, idx) => (
<small key={`${idx}-${note}`}>{note}</small>
))}
</div>
) : null}
<h4>Titel</h4>
<div className="media-title-list">
{titles.length === 0 ? (
<p>Keine Titel analysiert.</p>
) : titles.map((title) => {
const titleEligible = title?.eligibleForEncode !== false;
const titleChecked = allowTitleSelection
? currentSelectedId === normalizeTitleId(title.id)
: Boolean(title.selectedForEncode);
const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
const defaultAudioTrackIds = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
.filter((track) => Boolean(track?.selectedByRule))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const defaultSubtitleTrackIds = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [])
.filter((track) => Boolean(track?.selectedByRule))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const selectedAudioTrackIds = normalizeTrackIdList(
Array.isArray(titleSelectionEntry?.audioTrackIds)
? titleSelectionEntry.audioTrackIds
: defaultAudioTrackIds
);
const selectedSubtitleTrackIds = normalizeTrackIdList(
Array.isArray(titleSelectionEntry?.subtitleTrackIds)
? titleSelectionEntry.subtitleTrackIds
: defaultSubtitleTrackIds
);
const allowTrackSelectionForTitle = Boolean(
allowTrackSelection
&& allowTitleSelection
&& titleChecked
&& titleEligible
);
return (
<div key={title.id} className="media-title-block">
<label className="readonly-check-row">
<input
type="checkbox"
checked={titleChecked}
onChange={() => {
if (!allowTitleSelection || typeof onSelectEncodeTitle !== 'function') {
return;
}
onSelectEncodeTitle(normalizeTitleId(title.id));
}}
readOnly={!allowTitleSelection}
disabled={!allowTitleSelection || !titleEligible}
/>
<span>
#{title.id} | {title.fileName} | {formatDuration(title.durationMinutes)} | {formatBytes(title.sizeBytes)}
{title.encodeInput ? ' | Encode-Input' : ''}
</span>
</label>
{title.playlistFile || title.playlistEvaluationLabel || title.playlistSegmentCommand ? (
<div className="playlist-info-box">
<small>
<strong>Playlist:</strong> {title.playlistFile || '-'}
{title.playlistRecommended ? ' | empfohlen' : ''}
</small>
{title.playlistEvaluationLabel ? (
<small><strong>Bewertung:</strong> {title.playlistEvaluationLabel}</small>
) : null}
{title.playlistSegmentCommand ? (
<small><strong>Analyse-Command:</strong> {title.playlistSegmentCommand}</small>
) : null}
{Array.isArray(title.playlistSegmentFiles) && title.playlistSegmentFiles.length > 0 ? (
<details className="playlist-segment-toggle">
<summary>Segment-Dateien anzeigen ({title.playlistSegmentFiles.length})</summary>
<pre className="playlist-segment-output">{title.playlistSegmentFiles.join('\n')}</pre>
</details>
) : (
<small>Segment-Ausgabe: keine m2ts-Einträge gefunden.</small>
)}
</div>
) : null}
<div className="media-track-grid">
<TrackList
title={`Tonspuren (Titel #${title.id})`}
tracks={title.audioTracks || []}
type="audio"
allowSelection={allowTrackSelectionForTitle}
selectedTrackIds={selectedAudioTrackIds}
audioSelector={review?.selectors?.audio || null}
onToggleTrack={(trackId, checked) => {
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
return;
}
onTrackSelectionChange(title.id, 'audio', trackId, checked);
}}
/>
<TrackList
title={`Subtitles (Titel #${title.id})`}
tracks={title.subtitleTracks || []}
type="subtitle"
allowSelection={allowTrackSelectionForTitle}
selectedTrackIds={selectedSubtitleTrackIds}
onToggleTrack={(trackId, checked) => {
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
return;
}
onTrackSelectionChange(title.id, 'subtitle', trackId, checked);
}}
/>
</div>
{titleChecked ? (() => {
const commandPreview = buildHandBrakeCommandPreview({
review,
title,
selectedAudioTrackIds,
selectedSubtitleTrackIds,
commandOutputPath
});
return (
<div className="handbrake-command-preview">
<small><strong>Finaler HandBrakeCLI-Befehl (Preview):</strong></small>
<pre>{commandPreview}</pre>
</div>
);
})() : null}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
import { useEffect, useMemo, useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { InputText } from 'primereact/inputtext';
export default function MetadataSelectionDialog({
visible,
context,
onHide,
onSubmit,
onSearch,
busy
}) {
const [selected, setSelected] = useState(null);
const [query, setQuery] = useState('');
const [manualTitle, setManualTitle] = useState('');
const [manualYear, setManualYear] = useState('');
const [manualImdb, setManualImdb] = useState('');
const [extraResults, setExtraResults] = useState([]);
useEffect(() => {
if (!visible) {
return;
}
const selectedMetadata = context?.selectedMetadata || {};
const defaultTitle = selectedMetadata.title || context?.detectedTitle || '';
const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : '';
const defaultImdb = selectedMetadata.imdbId || '';
setSelected(null);
setQuery(defaultTitle);
setManualTitle(defaultTitle);
setManualYear(defaultYear);
setManualImdb(defaultImdb);
setExtraResults([]);
}, [visible, context]);
const rows = useMemo(() => {
const base = context?.omdbCandidates || [];
const all = [...base, ...extraResults];
const map = new Map();
all.forEach((item) => {
if (item?.imdbId) {
map.set(item.imdbId, item);
}
});
return Array.from(map.values());
}, [context, extraResults]);
const titleWithPosterBody = (row) => (
<div className="omdb-row">
{row.poster && row.poster !== 'N/A' ? (
<img src={row.poster} alt={row.title} className="poster-thumb-lg" />
) : (
<div className="poster-thumb-lg poster-fallback">-</div>
)}
<div>
<div><strong>{row.title}</strong></div>
<small>{row.year} | {row.imdbId}</small>
</div>
</div>
);
const handleSearch = async () => {
if (!query.trim()) {
return;
}
const results = await onSearch(query.trim());
setExtraResults(results || []);
};
const handleSubmit = async () => {
const payload = selected
? {
jobId: context.jobId,
title: selected.title,
year: selected.year,
imdbId: selected.imdbId,
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
fromOmdb: true
}
: {
jobId: context.jobId,
title: manualTitle,
year: manualYear,
imdbId: manualImdb,
poster: null,
fromOmdb: false
};
await onSubmit(payload);
};
return (
<Dialog
header="Metadaten auswählen"
visible={visible}
onHide={onHide}
style={{ width: '52rem', maxWidth: '95vw' }}
className="metadata-selection-dialog"
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
modal
>
<div className="search-row">
<InputText
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Titel suchen"
/>
<Button label="OMDb Suche" icon="pi pi-search" onClick={handleSearch} loading={busy} />
</div>
<div className="table-scroll-wrap table-scroll-medium">
<DataTable
value={rows}
selectionMode="single"
selection={selected}
onSelectionChange={(event) => setSelected(event.value)}
dataKey="imdbId"
size="small"
scrollable
scrollHeight="22rem"
emptyMessage="Keine Treffer"
responsiveLayout="stack"
breakpoint="960px"
>
<Column header="Titel" body={titleWithPosterBody} />
<Column field="year" header="Jahr" style={{ width: '8rem' }} />
<Column field="imdbId" header="IMDb" style={{ width: '10rem' }} />
</DataTable>
</div>
<h4>Manuelle Eingabe</h4>
<div className="metadata-grid">
<InputText
value={manualTitle}
onChange={(event) => setManualTitle(event.target.value)}
placeholder="Titel"
disabled={!!selected}
/>
<InputText
value={manualYear}
onChange={(event) => setManualYear(event.target.value)}
placeholder="Jahr"
disabled={!!selected}
/>
<InputText
value={manualImdb}
onChange={(event) => setManualImdb(event.target.value)}
placeholder="IMDb-ID"
disabled={!!selected}
/>
</div>
<div className="dialog-actions">
<Button label="Abbrechen" severity="secondary" text onClick={onHide} />
<Button
label="Auswahl übernehmen"
icon="pi pi-play"
onClick={handleSubmit}
loading={busy}
disabled={!selected && !manualTitle.trim() && !manualImdb.trim()}
/>
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,598 @@
import { useEffect, useMemo, useState } from 'react';
import { Card } from 'primereact/card';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Button } from 'primereact/button';
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
import { api } from '../api/client';
const severityMap = {
IDLE: 'success',
DISC_DETECTED: 'info',
ANALYZING: 'warning',
METADATA_SELECTION: 'warning',
WAITING_FOR_USER_DECISION: 'warning',
READY_TO_START: 'info',
MEDIAINFO_CHECK: 'warning',
READY_TO_ENCODE: 'info',
RIPPING: 'warning',
ENCODING: 'warning',
FINISHED: 'success',
ERROR: 'danger'
};
function normalizeTitleId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizePlaylistId(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
const match = raw.match(/(\d{1,5})(?:\.mpls)?$/i);
return match ? String(match[1]).padStart(5, '0') : null;
}
function normalizeTrackId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeTrackIdList(values) {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const normalized = normalizeTrackId(value);
if (normalized === null) {
continue;
}
const key = String(normalized);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(normalized);
}
return output;
}
function buildDefaultTrackSelection(review) {
const titles = Array.isArray(review?.titles) ? review.titles : [];
const selection = {};
for (const title of titles) {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
continue;
}
selection[titleId] = {
audioTrackIds: normalizeTrackIdList(
(Array.isArray(title?.audioTracks) ? title.audioTracks : [])
.filter((track) => Boolean(track?.selectedByRule))
.map((track) => track?.id)
),
subtitleTrackIds: normalizeTrackIdList(
(Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [])
.filter((track) => Boolean(track?.selectedByRule))
.map((track) => track?.id)
)
};
}
return selection;
}
function defaultTrackSelectionForTitle(review, titleId) {
const defaults = buildDefaultTrackSelection(review);
return defaults[titleId] || defaults[String(titleId)] || { audioTrackIds: [], subtitleTrackIds: [] };
}
function buildSettingsMap(categories) {
const map = {};
const list = Array.isArray(categories) ? categories : [];
for (const category of list) {
for (const setting of (Array.isArray(category?.settings) ? category.settings : [])) {
map[setting.key] = setting.value;
}
}
return map;
}
function sanitizeFileName(input) {
return String(input || 'untitled')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180);
}
function renderTemplate(template, values) {
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
const value = values[key.trim()];
if (value === undefined || value === null || value === '') {
return 'unknown';
}
return String(value);
});
}
function buildOutputPathPreview(settings, metadata, fallbackJobId = null) {
const movieDir = String(settings?.movie_dir || '').trim();
if (!movieDir) {
return null;
}
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
const year = metadata?.year || new Date().getFullYear();
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
const template = settings?.filename_template || '${title} (${year})';
const folderName = sanitizeFileName(renderTemplate('${title} (${year})', { title, year, imdbId }));
const baseName = sanitizeFileName(renderTemplate(template, { title, year, imdbId }));
const ext = String(settings?.output_extension || 'mkv').trim() || 'mkv';
const root = movieDir.replace(/\/+$/g, '');
return `${root}/${folderName}/${baseName}.${ext}`;
}
export default function PipelineStatusCard({
pipeline,
onAnalyze,
onReanalyze,
onStart,
onRestartEncode,
onConfirmReview,
onSelectPlaylist,
onCancel,
onRetry,
busy,
liveJobLog = ''
}) {
const state = pipeline?.state || 'IDLE';
const progress = Number(pipeline?.progress || 0);
const running = state === 'ANALYZING' || state === 'RIPPING' || state === 'ENCODING' || state === 'MEDIAINFO_CHECK';
const retryJobId = pipeline?.context?.jobId;
const selectedMetadata = pipeline?.context?.selectedMetadata || null;
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
const playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null;
const reviewConfirmed = Boolean(pipeline?.context?.reviewConfirmed || mediaInfoReview?.reviewConfirmed);
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
const [settingsMap, setSettingsMap] = useState({});
useEffect(() => {
let cancelled = false;
const loadSettings = async () => {
try {
const response = await api.getSettings();
if (!cancelled) {
setSettingsMap(buildSettingsMap(response?.categories || []));
}
} catch (_error) {
if (!cancelled) {
setSettingsMap({});
}
}
};
loadSettings();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId);
setSelectedEncodeTitleId(fromReview);
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
useEffect(() => {
const currentTitleId = normalizeTitleId(selectedEncodeTitleId);
if (!currentTitleId) {
return;
}
setTrackSelectionByTitle((prev) => {
if (prev?.[currentTitleId] || prev?.[String(currentTitleId)]) {
return prev;
}
const defaults = buildDefaultTrackSelection(mediaInfoReview);
const fallback = defaults[currentTitleId] || { audioTrackIds: [], subtitleTrackIds: [] };
return {
...prev,
[currentTitleId]: fallback
};
});
}, [selectedEncodeTitleId, mediaInfoReview?.generatedAt]);
const reviewPlaylistDecisionRequired = Boolean(mediaInfoReview?.playlistDecisionRequired);
const hasSelectedEncodeTitle = Boolean(normalizeTitleId(selectedEncodeTitleId));
const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle;
const canStartReadyJob = isPreRipReview
? Boolean(retryJobId)
: Boolean(retryJobId && encodeInputPath);
const canRestartEncodeFromLastSettings = Boolean(
state === 'ERROR'
&& retryJobId
&& pipeline?.context?.canRestartEncodeFromLastSettings
);
const waitingPlaylistRows = useMemo(() => {
const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates)
? playlistAnalysis.evaluatedCandidates
: [];
const rows = evaluated.length > 0
? evaluated
: (Array.isArray(pipeline?.context?.playlistCandidates) ? pipeline.context.playlistCandidates : []);
const normalized = rows
.map((item) => {
const playlistId = normalizePlaylistId(item?.playlistId || item?.playlistFile || item);
if (!playlistId) {
return null;
}
const playlistFile = `${playlistId}.mpls`;
const score = Number(item?.score);
const sequenceCoherence = Number(
item?.structuralMetrics?.sequenceCoherence ?? item?.sequenceCoherence
);
const handBrakeTitleId = Number(item?.handBrakeTitleId);
return {
playlistId,
playlistFile,
titleId: Number.isFinite(Number(item?.titleId)) ? Number(item.titleId) : null,
score: Number.isFinite(score) ? score : null,
evaluationLabel: item?.evaluationLabel || null,
segmentCommand: item?.segmentCommand
|| `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
segmentFiles: Array.isArray(item?.segmentFiles) ? item.segmentFiles : [],
sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null,
recommended: Boolean(item?.recommended),
handBrakeTitleId: Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0
? Math.trunc(handBrakeTitleId)
: null,
audioSummary: item?.audioSummary || null,
audioTrackPreview: Array.isArray(item?.audioTrackPreview) ? item.audioTrackPreview : []
};
})
.filter(Boolean);
const dedup = [];
const seen = new Set();
for (const row of normalized) {
if (seen.has(row.playlistId)) {
continue;
}
seen.add(row.playlistId);
dedup.push(row);
}
return dedup;
}, [playlistAnalysis, pipeline?.context?.playlistCandidates]);
useEffect(() => {
if (state !== 'WAITING_FOR_USER_DECISION') {
setSelectedPlaylistId(null);
return;
}
const current = normalizePlaylistId(pipeline?.context?.selectedPlaylist);
if (current) {
setSelectedPlaylistId(current);
return;
}
const recommendedFromRows = waitingPlaylistRows.find((item) => item.recommended)?.playlistId || null;
const recommendedFromAnalysis = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId);
const fallback = waitingPlaylistRows[0]?.playlistId || null;
setSelectedPlaylistId(recommendedFromRows || recommendedFromAnalysis || fallback);
}, [
state,
retryJobId,
waitingPlaylistRows,
playlistAnalysis?.recommendation?.playlistId,
pipeline?.context?.selectedPlaylist
]);
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION';
const commandOutputPath = useMemo(
() => buildOutputPathPreview(settingsMap, selectedMetadata, retryJobId),
[settingsMap, selectedMetadata, retryJobId]
);
return (
<Card title="Pipeline Status" subTitle="Live Zustand und Fortschritt">
<div className="status-row">
<Tag value={state} severity={severityMap[state] || 'secondary'} />
<span>{pipeline?.statusText || 'Bereit'}</span>
</div>
{running && (
<div className="progress-wrap">
<ProgressBar value={progress} showValue />
<small>{pipeline?.eta ? `ETA ${pipeline.eta}` : 'ETA unbekannt'}</small>
</div>
)}
{state === 'FINISHED' && (
<div className="progress-wrap">
<ProgressBar value={100} showValue />
</div>
)}
<div className="actions-row">
{(state === 'DISC_DETECTED' || state === 'IDLE') && (
<Button
label="Analyse starten"
icon="pi pi-search"
onClick={onAnalyze}
loading={busy}
/>
)}
{state === 'READY_TO_START' && retryJobId && (
<Button
label="Job starten"
icon="pi pi-play"
severity="success"
onClick={() => onStart(retryJobId)}
loading={busy}
/>
)}
{state === 'READY_TO_ENCODE' && retryJobId && (
<Button
label="Auswahl bestätigen"
icon="pi pi-check"
severity="warning"
outlined
onClick={() => {
const encodeTitleId = normalizeTitleId(selectedEncodeTitleId);
const selectionEntry = encodeTitleId
? (trackSelectionByTitle?.[encodeTitleId] || trackSelectionByTitle?.[String(encodeTitleId)] || null)
: null;
const fallbackSelection = encodeTitleId
? defaultTrackSelectionForTitle(mediaInfoReview, encodeTitleId)
: { audioTrackIds: [], subtitleTrackIds: [] };
const effectiveSelection = selectionEntry || fallbackSelection;
const selectedTrackSelection = encodeTitleId
? {
[encodeTitleId]: {
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
subtitleTrackIds: normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
}
}
: null;
onConfirmReview(retryJobId, encodeTitleId, selectedTrackSelection);
}}
loading={busy}
disabled={reviewConfirmed || !canConfirmReview}
/>
)}
{playlistDecisionRequiredBeforeStart && retryJobId && (
<Button
label="Playlist übernehmen"
icon="pi pi-check"
severity="warning"
outlined
onClick={() => onSelectPlaylist?.(retryJobId, selectedPlaylistId)}
loading={busy}
disabled={!normalizePlaylistId(selectedPlaylistId)}
/>
)}
{state === 'READY_TO_ENCODE' && retryJobId && (
<Button
label={isPreRipReview ? 'Backup + Encode starten' : 'Encode starten'}
icon="pi pi-play"
severity="success"
onClick={() => onStart(retryJobId)}
loading={busy}
disabled={!canStartReadyJob || !reviewConfirmed}
/>
)}
{running && (
<Button
label="Abbrechen"
icon="pi pi-stop"
severity="danger"
onClick={onCancel}
loading={busy}
/>
)}
{canRestartEncodeFromLastSettings ? (
<Button
label="Encode neu starten"
icon="pi pi-play"
severity="success"
onClick={() => onRestartEncode?.(retryJobId)}
loading={busy}
disabled={!retryJobId}
/>
) : null}
{state === 'ERROR' && retryJobId && (
<Button
label="Retry Rippen"
icon="pi pi-refresh"
severity="warning"
onClick={() => onRetry(retryJobId)}
loading={busy}
/>
)}
{state === 'ERROR' ? (
<Button
label="Disk-Analyse neu starten"
icon="pi pi-search"
severity="secondary"
onClick={onReanalyze || onAnalyze}
loading={busy}
/>
) : null}
</div>
{running ? (
<div className="live-log-block">
<h4>Aktueller Job-Log</h4>
<pre className="log-box">{liveJobLog || 'Noch keine Log-Ausgabe vorhanden.'}</pre>
</div>
) : null}
{playlistDecisionRequiredBeforeStart ? (
<div className="playlist-decision-block">
<h3>Playlist-Auswahl erforderlich</h3>
<small>
Metadaten sind abgeschlossen. Vor Start muss ein Titel/Playlist manuell per Checkbox gewählt werden.
</small>
{waitingPlaylistRows.length > 0 ? (
<div className="playlist-decision-list">
{waitingPlaylistRows.map((row) => (
<div key={row.playlistId} className="playlist-decision-item">
<label className="readonly-check-row">
<input
type="checkbox"
checked={normalizePlaylistId(selectedPlaylistId) === row.playlistId}
onChange={() => {
const next = normalizePlaylistId(selectedPlaylistId) === row.playlistId ? null : row.playlistId;
setSelectedPlaylistId(next);
}}
/>
<span>
{row.playlistFile}
{row.titleId !== null ? ` | Titel #${row.titleId}` : ''}
{row.score !== null ? ` | Score ${row.score}` : ''}
{row.recommended ? ' | empfohlen' : ''}
</span>
</label>
{row.evaluationLabel ? <small className="track-action-note">{row.evaluationLabel}</small> : null}
{row.sequenceCoherence !== null ? (
<small className="track-action-note">Sequenz-Kohärenz: {row.sequenceCoherence.toFixed(3)}</small>
) : null}
{row.handBrakeTitleId !== null ? (
<small className="track-action-note">HandBrake Titel: -t {row.handBrakeTitleId}</small>
) : null}
{row.audioSummary ? (
<small className="track-action-note">Audio: {row.audioSummary}</small>
) : null}
{row.segmentCommand ? <small className="track-action-note">Info: {row.segmentCommand}</small> : null}
{Array.isArray(row.audioTrackPreview) && row.audioTrackPreview.length > 0 ? (
<details className="playlist-segment-toggle">
<summary>Audio-Spuren anzeigen ({row.audioTrackPreview.length})</summary>
<pre className="playlist-segment-output">{row.audioTrackPreview.join('\n')}</pre>
</details>
) : null}
{Array.isArray(row.segmentFiles) && row.segmentFiles.length > 0 ? (
<details className="playlist-segment-toggle">
<summary>Segment-Dateien anzeigen ({row.segmentFiles.length})</summary>
<pre className="playlist-segment-output">{row.segmentFiles.join('\n')}</pre>
</details>
) : (
<small className="track-action-note">Keine Segmentliste aus TINFO:26 verfügbar.</small>
)}
</div>
))}
</div>
) : (
<small>Keine Kandidaten gefunden. Bitte Analyse erneut ausführen.</small>
)}
</div>
) : null}
{selectedMetadata ? (
<div className="pipeline-meta-inline">
{selectedMetadata.poster ? (
<img
src={selectedMetadata.poster}
alt={selectedMetadata.title || 'Poster'}
className="poster-large"
/>
) : (
<div className="poster-large poster-fallback">Kein Poster</div>
)}
<div className="device-meta">
<div>
<strong>Titel:</strong> {selectedMetadata.title || '-'}
</div>
<div>
<strong>Jahr:</strong> {selectedMetadata.year || '-'}
</div>
<div>
<strong>IMDb:</strong> {selectedMetadata.imdbId || '-'}
</div>
<div>
<strong>Status:</strong> {state}
</div>
</div>
</div>
) : null}
{(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) ? (
<div className="mediainfo-review-block">
<h3>Titel-/Spurprüfung</h3>
{state === 'READY_TO_ENCODE' && !reviewConfirmed ? (
<small>
{isPreRipReview
? 'Backup/Rip + Encode ist gesperrt, bis die Spurauswahl bestätigt wurde.'
: 'Encode ist gesperrt, bis die Titel-/Spurauswahl bestätigt wurde.'}
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
</small>
) : null}
<MediaInfoReviewPanel
review={mediaInfoReview}
commandOutputPath={commandOutputPath}
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
allowTitleSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
onSelectEncodeTitle={(titleId) => setSelectedEncodeTitleId(normalizeTitleId(titleId))}
allowTrackSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
trackSelectionByTitle={trackSelectionByTitle}
onTrackSelectionChange={(titleId, trackType, trackId, checked) => {
const normalizedTitleId = normalizeTitleId(titleId);
const normalizedTrackId = normalizeTrackId(trackId);
if (!normalizedTitleId || normalizedTrackId === null) {
return;
}
setTrackSelectionByTitle((prev) => {
const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || {
audioTrackIds: [],
subtitleTrackIds: []
};
const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds';
const existing = normalizeTrackIdList(current?.[key] || []);
const next = checked
? normalizeTrackIdList([...existing, normalizedTrackId])
: existing.filter((id) => id !== normalizedTrackId);
return {
...prev,
[normalizedTitleId]: {
...current,
[key]: next
}
};
});
}}
/>
</div>
) : null}
</Card>
);
}