DVD Integration

This commit is contained in:
2026-03-06 11:21:25 +00:00
parent 3abb53fb8e
commit e1a87af16a
20 changed files with 2900 additions and 697 deletions

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Other medium">
<defs>
<linearGradient id="othbg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f4f1da"/>
<stop offset="100%" stop-color="#bcae73"/>
</linearGradient>
</defs>
<circle cx="32" cy="32" r="30" fill="url(#othbg)"/>
<path d="M40 15v26.5c0 5.5-4 9.5-9.5 9.5C25.8 51 22 47.5 22 43c0-4.7 4-8.5 8.8-8.5 1.4 0 2.8.3 4.2 1V22.4l12-3.2V39c0 5.6-4 9.6-9.4 9.6-4.8 0-8.6-3.4-8.6-7.8 0-4.9 4.2-8.8 9.2-8.8 1 0 2 .2 2.8.5V15z" fill="#2f3440"/>
</svg>

After

Width:  |  Height:  |  Size: 575 B

View File

@@ -14,39 +14,41 @@ function normalizeSettingKey(value) {
return String(value || '').trim().toLowerCase();
}
const GENERAL_TOOL_KEYS = new Set([
'makemkv_command',
'makemkv_registration_key',
'makemkv_min_length_minutes',
'mediainfo_command',
'handbrake_command',
'handbrake_restart_delete_incomplete_output'
]);
const HANDBRAKE_PRESET_SETTING_KEYS = new Set([
'handbrake_preset',
'handbrake_preset_bluray',
'handbrake_preset_dvd'
]);
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 sowie Datei- und Ordnernamen-Template.',
match: (key) => key === 'output_extension' || key === 'filename_template' || key === 'output_folder_template'
}
];
const buckets = definitions.map((item) => ({
...item,
const generalBucket = {
id: 'general',
title: 'General',
description: 'Gemeinsame Tool-Settings für alle Medien.',
settings: []
}));
};
const blurayBucket = {
id: 'bluray',
title: 'BluRay',
description: 'Profil-spezifische Settings für Blu-ray.',
settings: []
};
const dvdBucket = {
id: 'dvd',
title: 'DVD',
description: 'Profil-spezifische Settings für DVD.',
settings: []
};
const fallbackBucket = {
id: 'other',
title: 'Weitere Tool-Settings',
@@ -56,20 +58,26 @@ function buildToolSections(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 (GENERAL_TOOL_KEYS.has(key)) {
generalBucket.settings.push(setting);
continue;
}
if (!assigned) {
fallbackBucket.settings.push(setting);
if (key.endsWith('_bluray')) {
blurayBucket.settings.push(setting);
continue;
}
if (key.endsWith('_dvd')) {
dvdBucket.settings.push(setting);
continue;
}
fallbackBucket.settings.push(setting);
}
const sections = buckets.filter((item) => item.settings.length > 0);
const sections = [
generalBucket,
blurayBucket,
dvdBucket
].filter((item) => item.settings.length > 0);
if (fallbackBucket.settings.length > 0) {
sections.push(fallbackBucket);
}
@@ -96,7 +104,8 @@ function buildSectionsForCategory(categoryName, settings) {
}
function isHandBrakePresetSetting(setting) {
return String(setting?.key || '').trim().toLowerCase() === 'handbrake_preset';
const key = String(setting?.key || '').trim().toLowerCase();
return HANDBRAKE_PRESET_SETTING_KEYS.has(key);
}
export default function DynamicSettingsForm({

View File

@@ -3,6 +3,7 @@ import { Button } from 'primereact/button';
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
import { getStatusLabel } from '../utils/statusPresentation';
function JsonView({ title, value }) {
@@ -14,9 +15,54 @@ function JsonView({ title, value }) {
);
}
function ScriptResultRow({ result }) {
const status = String(result?.status || '').toUpperCase();
const isSuccess = status === 'SUCCESS';
const isError = status === 'ERROR';
const isSkipped = status.startsWith('SKIPPED');
const icon = isSuccess ? 'pi-check-circle' : isError ? 'pi-times-circle' : 'pi-minus-circle';
const tone = isSuccess ? 'success' : isError ? 'danger' : 'warning';
return (
<div className="script-result-row">
<span className={`job-step-inline-${isSuccess ? 'ok' : isError ? 'no' : 'warn'}`}>
<i className={`pi ${icon}`} aria-hidden="true" />
</span>
<span className="script-result-name">{result?.scriptName || result?.chainName || `#${result?.scriptId ?? result?.chainId ?? '?'}`}</span>
<span className={`script-result-status tone-${tone}`}>{status}</span>
{result?.error ? <span className="script-result-error">{result.error}</span> : null}
</div>
);
}
function ScriptSummarySection({ title, summary }) {
if (!summary || summary.configured === 0) return null;
const results = Array.isArray(summary.results) ? summary.results : [];
return (
<div className="script-summary-block">
<strong>{title}:</strong>
<span className="script-summary-counts">
{summary.succeeded > 0 ? <span className="tone-success">{summary.succeeded} OK</span> : null}
{summary.failed > 0 ? <span className="tone-danger">{summary.failed} Fehler</span> : null}
{summary.skipped > 0 ? <span className="tone-warning">{summary.skipped} übersprungen</span> : null}
</span>
{results.length > 0 ? (
<div className="script-result-list">
{results.map((r, i) => <ScriptResultRow key={i} result={r} />)}
</div>
) : null}
</div>
);
}
function resolveMediaType(job) {
const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase();
return raw === 'bluray' ? 'bluray' : 'disc';
if (raw === 'bluray') {
return 'bluray';
}
if (raw === 'dvd' || raw === 'disc') {
return 'dvd';
}
return 'other';
}
function statusBadgeMeta(status, queued = false) {
@@ -132,9 +178,15 @@ export default function JobDetailDialog({
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
const logTruncated = Boolean(logMeta?.truncated);
const mediaType = resolveMediaType(job);
const mediaTypeLabel = mediaType === 'bluray' ? 'Blu-ray' : 'Sonstiges Medium';
const mediaTypeIcon = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
const mediaTypeAlt = mediaType === 'bluray' ? 'Blu-ray' : 'Disc';
const mediaTypeLabel = mediaType === 'bluray'
? 'Blu-ray'
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
const mediaTypeIcon = mediaType === 'bluray'
? blurayIndicatorIcon
: (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon);
const mediaTypeAlt = mediaType === 'bluray'
? 'Blu-ray'
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
@@ -267,6 +319,16 @@ export default function JobDetailDialog({
</div>
</section>
{(job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
<section className="job-meta-block job-meta-block-full">
<h4>Skripte</h4>
<div className="script-results-grid">
<ScriptSummarySection title="Pre-Encode" summary={job.handbrakeInfo?.preEncodeScripts} />
<ScriptSummarySection title="Post-Encode" summary={job.handbrakeInfo?.postEncodeScripts} />
</div>
</section>
) : null}
<div className="job-json-grid">
<JsonView title="OMDb Info" value={job.omdbInfo} />
<JsonView title="MakeMKV Info" value={job.makemkvInfo} />

View File

@@ -685,27 +685,19 @@ export default function MediaInfoReviewPanel({
allowTrackSelection = false,
trackSelectionByTitle = {},
onTrackSelectionChange = null,
availablePostScripts = [],
selectedPostEncodeScriptIds = [],
allowPostScriptSelection = false,
onAddPostEncodeScript = null,
onChangePostEncodeScript = null,
onRemovePostEncodeScript = null,
onReorderPostEncodeScript = null,
availablePreScripts = [],
selectedPreEncodeScriptIds = [],
allowPreScriptSelection = false,
onAddPreEncodeScript = null,
onChangePreEncodeScript = null,
onRemovePreEncodeScript = null,
availableScripts = [],
availableChains = [],
selectedPreEncodeChainIds = [],
selectedPostEncodeChainIds = [],
allowChainSelection = false,
onAddPreEncodeChain = null,
onRemovePreEncodeChain = null,
onAddPostEncodeChain = null,
onRemovePostEncodeChain = null
preEncodeItems = [],
postEncodeItems = [],
allowEncodeItemSelection = false,
onAddPreEncodeItem = null,
onChangePreEncodeItem = null,
onRemovePreEncodeItem = null,
onReorderPreEncodeItem = null,
onAddPostEncodeItem = null,
onChangePostEncodeItem = null,
onRemovePostEncodeItem = null,
onReorderPostEncodeItem = null
}) {
if (!review) {
return <p>Keine Mediainfo-Daten vorhanden.</p>;
@@ -718,30 +710,33 @@ export default function MediaInfoReviewPanel({
const totalFiles = Number(review.totalFiles || titles.length || 0);
const playlistRecommendation = review.playlistRecommendation || null;
const presetLabel = String(presetDisplayValue || review.selectors?.preset || '').trim() || '-';
const scriptRows = normalizeScriptIdList(selectedPostEncodeScriptIds);
const scriptCatalog = (Array.isArray(availablePostScripts) ? availablePostScripts : [])
const scriptCatalog = (Array.isArray(availableScripts) ? availableScripts : [])
.map((item) => ({
id: normalizeScriptId(item?.id),
name: String(item?.name || '').trim()
}))
.filter((item) => item.id !== null && item.name.length > 0);
const scriptById = new Map(scriptCatalog.map((item) => [item.id, item]));
const canAddScriptRow = allowPostScriptSelection && scriptCatalog.length > 0 && scriptRows.length < scriptCatalog.length;
const canReorderScriptRows = allowPostScriptSelection && scriptRows.length > 1;
const chainCatalog = (Array.isArray(availableChains) ? availableChains : [])
.map((item) => ({ id: Number(item?.id), name: String(item?.name || '').trim() }))
.filter((item) => Number.isFinite(item.id) && item.id > 0 && item.name.length > 0);
const chainById = new Map(chainCatalog.map((item) => [item.id, item]));
const handleScriptDrop = (event, targetIndex) => {
if (!allowPostScriptSelection || typeof onReorderPostEncodeScript !== 'function') {
const makeHandleDrop = (items, onReorder) => (event, targetIndex) => {
if (!allowEncodeItemSelection || typeof onReorder !== 'function' || items.length < 2) {
return;
}
event.preventDefault();
const fromText = event.dataTransfer?.getData('text/plain');
const fromIndex = Number(fromText);
const fromIndex = Number(event.dataTransfer?.getData('text/plain'));
if (!Number.isInteger(fromIndex)) {
return;
}
onReorderPostEncodeScript(fromIndex, targetIndex);
onReorder(fromIndex, targetIndex);
};
const handlePreDrop = makeHandleDrop(preEncodeItems, onReorderPreEncodeItem);
const handlePostDrop = makeHandleDrop(postEncodeItems, onReorderPostEncodeItem);
return (
<div className="media-review-wrap">
<div className="media-review-meta">
@@ -780,215 +775,196 @@ export default function MediaInfoReviewPanel({
</div>
) : null}
{/* Pre-Encode Scripts */}
{(allowPreScriptSelection || normalizeScriptIdList(selectedPreEncodeScriptIds).length > 0) ? (
{/* Pre-Encode Items (scripts + chains unified) */}
{(allowEncodeItemSelection || preEncodeItems.length > 0) ? (
<div className="post-script-box">
<h4>Pre-Encode Scripte (optional)</h4>
{(Array.isArray(availablePreScripts) ? availablePreScripts : []).length === 0 ? (
<small>Keine Scripte konfiguriert. In den Settings unter "Scripte" anlegen.</small>
<h4>Pre-Encode Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{normalizeScriptIdList(selectedPreEncodeScriptIds).length === 0 ? (
<small>Keine Pre-Encode Scripte ausgewählt.</small>
{preEncodeItems.length === 0 ? (
<small>Keine Pre-Encode Ausführungen ausgewählt.</small>
) : null}
{normalizeScriptIdList(selectedPreEncodeScriptIds).map((scriptId, rowIndex) => {
const preCatalog = (Array.isArray(availablePreScripts) ? availablePreScripts : [])
.map((item) => ({ id: normalizeScriptId(item?.id), name: String(item?.name || '') }))
.filter((item) => item.id !== null);
const preById = new Map(preCatalog.map((item) => [item.id, item]));
const script = preById.get(scriptId) || null;
const selectedElsewhere = new Set(
normalizeScriptIdList(selectedPreEncodeScriptIds).filter((_, i) => i !== rowIndex).map((id) => String(id))
{preEncodeItems.map((item, rowIndex) => {
const isScript = item.type === 'script';
const canDrag = allowEncodeItemSelection && preEncodeItems.length > 1;
const scriptObj = isScript ? (scriptById.get(normalizeScriptId(item.id)) || null) : null;
const chainObj = !isScript ? (chainById.get(Number(item.id)) || null) : null;
const name = isScript
? (scriptObj?.name || `Skript #${item.id}`)
: (chainObj?.name || `Kette #${item.id}`);
const usedScriptIds = new Set(
preEncodeItems.filter((it, i) => it.type === 'script' && i !== rowIndex).map((it) => String(normalizeScriptId(it.id)))
);
const options = preCatalog.map((item) => ({
label: item.name,
value: item.id,
disabled: selectedElsewhere.has(String(item.id))
const scriptOptions = scriptCatalog.map((s) => ({
label: s.name,
value: s.id,
disabled: usedScriptIds.has(String(s.id))
}));
return (
<div key={`pre-script-row-${rowIndex}-${scriptId}`} className={`post-script-row${allowPreScriptSelection ? ' editable' : ''}`}>
{allowPreScriptSelection ? (
<div
key={`pre-item-${rowIndex}-${item.type}-${item.id}`}
className={`post-script-row${allowEncodeItemSelection ? ' editable' : ''}`}
onDragOver={(event) => {
if (!canDrag) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
}}
onDrop={(event) => handlePreDrop(event, rowIndex)}
>
{allowEncodeItemSelection ? (
<>
<Dropdown
value={scriptId}
options={options}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => onChangePreEncodeScript?.(rowIndex, event.value)}
className="full-width"
/>
<Button
icon="pi pi-times"
severity="danger"
outlined
onClick={() => onRemovePreEncodeScript?.(rowIndex)}
<span
className={`post-script-drag-handle pi pi-bars${canDrag ? '' : ' disabled'}`}
title={canDrag ? 'Ziehen zum Umordnen' : 'Mindestens zwei Einträge zum Umordnen'}
draggable={canDrag}
onDragStart={(event) => {
if (!canDrag) return;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(rowIndex));
}}
/>
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
{isScript ? (
<Dropdown
value={normalizeScriptId(item.id)}
options={scriptOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => onChangePreEncodeItem?.(rowIndex, 'script', event.value)}
className="full-width"
/>
) : (
<span className="post-script-chain-name">{name}</span>
)}
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePreEncodeItem?.(rowIndex)} />
</>
) : (
<small>{`${rowIndex + 1}. ${script?.name || `Script #${scriptId}`}`}</small>
<small><i className={`pi ${isScript ? 'pi-code' : 'pi-link'}`} /> {`${rowIndex + 1}. ${name}`}</small>
)}
</div>
);
})}
{allowPreScriptSelection && (Array.isArray(availablePreScripts) ? availablePreScripts : []).length > normalizeScriptIdList(selectedPreEncodeScriptIds).length ? (
<Button
label="Pre-Script hinzufügen"
icon="pi pi-plus"
severity="secondary"
outlined
onClick={() => onAddPreEncodeScript?.()}
/>
) : null}
<small>Diese Scripte werden vor dem Encoding ausgeführt. Bei Fehler wird der Encode abgebrochen.</small>
</div>
) : null}
{/* Chain Selections */}
{(allowChainSelection || selectedPreEncodeChainIds.length > 0 || selectedPostEncodeChainIds.length > 0) ? (
<div className="post-script-box">
<h4>Skriptketten (optional)</h4>
{(Array.isArray(availableChains) ? availableChains : []).length === 0 ? (
<small>Keine Skriptketten konfiguriert. In den Settings unter "Skriptketten" anlegen.</small>
) : null}
{(Array.isArray(availableChains) ? availableChains : []).length > 0 ? (
<div className="chain-selection-groups">
<div className="chain-selection-group">
<strong>Pre-Encode Ketten</strong>
{selectedPreEncodeChainIds.length === 0 ? <small>Keine ausgewählt.</small> : null}
{selectedPreEncodeChainIds.map((chainId, index) => {
const chain = (Array.isArray(availableChains) ? availableChains : []).find((c) => Number(c.id) === chainId);
return (
<div key={`pre-chain-${index}-${chainId}`} className="post-script-row editable">
<small>{`${index + 1}. ${chain?.name || `Kette #${chainId}`}`}</small>
{allowChainSelection ? (
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePreEncodeChain?.(index)} />
) : null}
</div>
);
})}
{allowChainSelection ? (
<Dropdown
value={null}
options={(Array.isArray(availableChains) ? availableChains : [])
.filter((c) => !selectedPreEncodeChainIds.includes(Number(c.id)))
.map((c) => ({ label: c.name, value: c.id }))}
onChange={(e) => onAddPreEncodeChain?.(e.value)}
placeholder="Kette hinzufügen..."
className="chain-add-dropdown"
/>
) : null}
</div>
<div className="chain-selection-group">
<strong>Post-Encode Ketten</strong>
{selectedPostEncodeChainIds.length === 0 ? <small>Keine ausgewählt.</small> : null}
{selectedPostEncodeChainIds.map((chainId, index) => {
const chain = (Array.isArray(availableChains) ? availableChains : []).find((c) => Number(c.id) === chainId);
return (
<div key={`post-chain-${index}-${chainId}`} className="post-script-row editable">
<small>{`${index + 1}. ${chain?.name || `Kette #${chainId}`}`}</small>
{allowChainSelection ? (
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePostEncodeChain?.(index)} />
) : null}
</div>
);
})}
{allowChainSelection ? (
<Dropdown
value={null}
options={(Array.isArray(availableChains) ? availableChains : [])
.filter((c) => !selectedPostEncodeChainIds.includes(Number(c.id)))
.map((c) => ({ label: c.name, value: c.id }))}
onChange={(e) => onAddPostEncodeChain?.(e.value)}
placeholder="Kette hinzufügen..."
className="chain-add-dropdown"
/>
) : null}
</div>
{allowEncodeItemSelection ? (
<div className="encode-item-add-row">
{scriptCatalog.length > preEncodeItems.filter((i) => i.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => onAddPreEncodeItem?.('script')}
/>
) : null}
{chainCatalog.length > preEncodeItems.filter((i) => i.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => onAddPreEncodeItem?.('chain')}
/>
) : null}
</div>
) : null}
<small>Ausführung vor dem Encoding, strikt nacheinander. Bei Fehler wird der Encode abgebrochen.</small>
</div>
) : null}
{/* Post-Encode Items (scripts + chains unified) */}
<div className="post-script-box">
<h4>Post-Encode Scripte (optional)</h4>
{scriptCatalog.length === 0 ? (
<small>Keine Scripte konfiguriert. In den Settings unter "Scripte" anlegen.</small>
<h4>Post-Encode Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{scriptRows.length === 0 ? (
<small>Keine Post-Encode Scripte ausgewählt.</small>
{postEncodeItems.length === 0 ? (
<small>Keine Post-Encode Ausführungen ausgewählt.</small>
) : null}
{scriptRows.map((scriptId, rowIndex) => {
const script = scriptById.get(scriptId) || null;
const selectedInOtherRows = new Set(
scriptRows.filter((id, index) => index !== rowIndex).map((id) => String(id))
{postEncodeItems.map((item, rowIndex) => {
const isScript = item.type === 'script';
const canDrag = allowEncodeItemSelection && postEncodeItems.length > 1;
const scriptObj = isScript ? (scriptById.get(normalizeScriptId(item.id)) || null) : null;
const chainObj = !isScript ? (chainById.get(Number(item.id)) || null) : null;
const name = isScript
? (scriptObj?.name || `Skript #${item.id}`)
: (chainObj?.name || `Kette #${item.id}`);
const usedScriptIds = new Set(
postEncodeItems.filter((it, i) => it.type === 'script' && i !== rowIndex).map((it) => String(normalizeScriptId(it.id)))
);
const options = scriptCatalog.map((item) => ({
label: item.name,
value: item.id,
disabled: selectedInOtherRows.has(String(item.id))
const scriptOptions = scriptCatalog.map((s) => ({
label: s.name,
value: s.id,
disabled: usedScriptIds.has(String(s.id))
}));
return (
<div
key={`post-script-row-${rowIndex}-${scriptId}`}
className={`post-script-row${allowPostScriptSelection ? ' editable' : ''}`}
key={`post-item-${rowIndex}-${item.type}-${item.id}`}
className={`post-script-row${allowEncodeItemSelection ? ' editable' : ''}`}
onDragOver={(event) => {
if (!canReorderScriptRows) {
return;
}
if (!canDrag) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
}}
onDrop={(event) => handleScriptDrop(event, rowIndex)}
onDrop={(event) => handlePostDrop(event, rowIndex)}
>
{allowPostScriptSelection ? (
{allowEncodeItemSelection ? (
<>
<span
className={`post-script-drag-handle pi pi-bars${canReorderScriptRows ? '' : ' disabled'}`}
title={canReorderScriptRows ? 'Ziehen zum Umordnen' : 'Mindestens zwei Scripte zum Umordnen'}
draggable={canReorderScriptRows}
className={`post-script-drag-handle pi pi-bars${canDrag ? '' : ' disabled'}`}
title={canDrag ? 'Ziehen zum Umordnen' : 'Mindestens zwei Einträge zum Umordnen'}
draggable={canDrag}
onDragStart={(event) => {
if (!canReorderScriptRows) {
return;
}
if (!canDrag) return;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(rowIndex));
}}
/>
<Dropdown
value={scriptId}
options={options}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => onChangePostEncodeScript?.(rowIndex, event.value)}
className="full-width"
/>
<Button
icon="pi pi-times"
severity="danger"
outlined
onClick={() => onRemovePostEncodeScript?.(rowIndex)}
/>
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
{isScript ? (
<Dropdown
value={normalizeScriptId(item.id)}
options={scriptOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => onChangePostEncodeItem?.(rowIndex, 'script', event.value)}
className="full-width"
/>
) : (
<span className="post-script-chain-name">{name}</span>
)}
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePostEncodeItem?.(rowIndex)} />
</>
) : (
<small>{`${rowIndex + 1}. ${script?.name || `Script #${scriptId}`}`}</small>
<small><i className={`pi ${isScript ? 'pi-code' : 'pi-link'}`} /> {`${rowIndex + 1}. ${name}`}</small>
)}
</div>
);
})}
{canAddScriptRow ? (
<Button
label="Script hinzufügen"
icon="pi pi-plus"
severity="secondary"
outlined
onClick={() => onAddPostEncodeScript?.()}
/>
{allowEncodeItemSelection ? (
<div className="encode-item-add-row">
{scriptCatalog.length > postEncodeItems.filter((i) => i.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => onAddPostEncodeItem?.('script')}
/>
) : null}
{chainCatalog.length > postEncodeItems.filter((i) => i.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => onAddPostEncodeItem?.('chain')}
/>
) : null}
</div>
) : null}
<small>Ausführung erfolgt nur nach erfolgreichem Encode, strikt nacheinander in genau dieser Reihenfolge (Drag-and-Drop möglich).</small>
<small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small>
</div>
<h4>Titel</h4>

View File

@@ -78,6 +78,14 @@ function normalizeScriptIdList(values) {
return output;
}
function normalizeChainId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function isBurnedSubtitleTrack(track) {
const flags = Array.isArray(track?.subtitlePreviewFlags)
? track.subtitlePreviewFlags
@@ -225,10 +233,9 @@ export default function PipelineStatusCard({
const [presetDisplayMap, setPresetDisplayMap] = useState({});
const [scriptCatalog, setScriptCatalog] = useState([]);
const [chainCatalog, setChainCatalog] = useState([]);
const [selectedPostEncodeScriptIds, setSelectedPostEncodeScriptIds] = useState([]);
const [selectedPreEncodeScriptIds, setSelectedPreEncodeScriptIds] = useState([]);
const [selectedPostEncodeChainIds, setSelectedPostEncodeChainIds] = useState([]);
const [selectedPreEncodeChainIds, setSelectedPreEncodeChainIds] = useState([]);
// Unified ordered lists: [{type: 'script'|'chain', id: number}]
const [preEncodeItems, setPreEncodeItems] = useState([]);
const [postEncodeItems, setPostEncodeItems] = useState([]);
useEffect(() => {
let cancelled = false;
@@ -282,20 +289,15 @@ export default function PipelineStatusCard({
const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId);
setSelectedEncodeTitleId(fromReview);
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
setSelectedPostEncodeScriptIds(
normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || [])
);
setSelectedPreEncodeScriptIds(
normalizeScriptIdList(mediaInfoReview?.preEncodeScriptIds || [])
);
setSelectedPostEncodeChainIds(
(Array.isArray(mediaInfoReview?.postEncodeChainIds) ? mediaInfoReview.postEncodeChainIds : [])
.map(Number).filter((id) => Number.isFinite(id) && id > 0)
);
setSelectedPreEncodeChainIds(
(Array.isArray(mediaInfoReview?.preEncodeChainIds) ? mediaInfoReview.preEncodeChainIds : [])
.map(Number).filter((id) => Number.isFinite(id) && id > 0)
);
const normChain = (raw) => (Array.isArray(raw) ? raw : []).map(Number).filter((id) => Number.isFinite(id) && id > 0);
setPreEncodeItems([
...normalizeScriptIdList(mediaInfoReview?.preEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
...normChain(mediaInfoReview?.preEncodeChainIds).map((id) => ({ type: 'chain', id }))
]);
setPostEncodeItems([
...normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
...normChain(mediaInfoReview?.postEncodeChainIds).map((id) => ({ type: 'chain', id }))
]);
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
useEffect(() => {
@@ -454,17 +456,17 @@ export default function PipelineStatusCard({
}
}
: null;
const selectedPostScriptIds = normalizeScriptIdList(selectedPostEncodeScriptIds);
const selectedPreScriptIds = normalizeScriptIdList(selectedPreEncodeScriptIds);
const normalizeChainIdList = (raw) =>
(Array.isArray(raw) ? raw : []).map(Number).filter((id) => Number.isFinite(id) && id > 0);
const selectedPostScriptIds = postEncodeItems.filter((i) => i.type === 'script').map((i) => i.id);
const selectedPreScriptIds = preEncodeItems.filter((i) => i.type === 'script').map((i) => i.id);
const selectedPostChainIds = postEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id);
const selectedPreChainIds = preEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id);
return {
encodeTitleId,
selectedTrackSelection,
selectedPostScriptIds,
selectedPreScriptIds,
selectedPostChainIds: normalizeChainIdList(selectedPostEncodeChainIds),
selectedPreChainIds: normalizeChainIdList(selectedPreEncodeChainIds)
selectedPostChainIds,
selectedPreChainIds
};
};
@@ -776,143 +778,219 @@ export default function PipelineStatusCard({
};
});
}}
availablePostScripts={scriptCatalog}
selectedPostEncodeScriptIds={selectedPostEncodeScriptIds}
allowPostScriptSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
onAddPostEncodeScript={() => {
setSelectedPostEncodeScriptIds((prev) => {
const normalizedCurrent = normalizeScriptIdList(prev);
const selectedSet = new Set(normalizedCurrent.map((id) => String(id)));
availableScripts={scriptCatalog}
availableChains={chainCatalog}
preEncodeItems={preEncodeItems}
postEncodeItems={postEncodeItems}
allowEncodeItemSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
onAddPreEncodeItem={(itemType) => {
setPreEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
if (itemType === 'chain') {
const selectedSet = new Set(
current
.filter((item) => item?.type === 'chain')
.map((item) => normalizeChainId(item?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const nextCandidate = (Array.isArray(chainCatalog) ? chainCatalog : [])
.map((item) => normalizeChainId(item?.id))
.find((id) => id !== null && !selectedSet.has(String(id)));
if (nextCandidate === undefined || nextCandidate === null) {
return current;
}
return [...current, { type: 'chain', id: nextCandidate }];
}
const selectedSet = new Set(
current
.filter((item) => item?.type === 'script')
.map((item) => normalizeScriptId(item?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const nextCandidate = (Array.isArray(scriptCatalog) ? scriptCatalog : [])
.map((item) => normalizeScriptId(item?.id))
.find((id) => id !== null && !selectedSet.has(String(id)));
if (nextCandidate === undefined || nextCandidate === null) {
return normalizedCurrent;
return current;
}
return [...normalizedCurrent, nextCandidate];
return [...current, { type: 'script', id: nextCandidate }];
});
}}
onChangePostEncodeScript={(rowIndex, nextScriptId) => {
setSelectedPostEncodeScriptIds((prev) => {
const normalizedCurrent = normalizeScriptIdList(prev);
if (!Number.isFinite(Number(rowIndex)) || rowIndex < 0 || rowIndex >= normalizedCurrent.length) {
return normalizedCurrent;
onChangePreEncodeItem={(rowIndex, itemType, nextId) => {
setPreEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
const index = Number(rowIndex);
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
return current;
}
const normalizedScriptId = normalizeScriptId(nextScriptId);
if (normalizedScriptId === null) {
return normalizedCurrent;
const type = itemType === 'chain' ? 'chain' : 'script';
if (type === 'chain') {
const normalizedId = normalizeChainId(nextId);
if (normalizedId === null) {
return current;
}
const duplicate = current.some((item, idx) =>
idx !== index
&& item?.type === 'chain'
&& String(normalizeChainId(item?.id)) === String(normalizedId)
);
if (duplicate) {
return current;
}
const next = [...current];
next[index] = { type: 'chain', id: normalizedId };
return next;
}
const duplicateAtOtherIndex = normalizedCurrent.some((id, idx) =>
idx !== rowIndex && String(id) === String(normalizedScriptId)
const normalizedId = normalizeScriptId(nextId);
if (normalizedId === null) {
return current;
}
const duplicate = current.some((item, idx) =>
idx !== index
&& item?.type === 'script'
&& String(normalizeScriptId(item?.id)) === String(normalizedId)
);
if (duplicateAtOtherIndex) {
return normalizedCurrent;
if (duplicate) {
return current;
}
const next = [...normalizedCurrent];
next[rowIndex] = normalizedScriptId;
const next = [...current];
next[index] = { type: 'script', id: normalizedId };
return next;
});
}}
onRemovePostEncodeScript={(rowIndex) => {
setSelectedPostEncodeScriptIds((prev) => {
const normalizedCurrent = normalizeScriptIdList(prev);
if (!Number.isFinite(Number(rowIndex)) || rowIndex < 0 || rowIndex >= normalizedCurrent.length) {
return normalizedCurrent;
onRemovePreEncodeItem={(rowIndex) => {
setPreEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
const index = Number(rowIndex);
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
return current;
}
return normalizedCurrent.filter((_, idx) => idx !== rowIndex);
return current.filter((_, idx) => idx !== index);
});
}}
onReorderPostEncodeScript={(fromIndex, toIndex) => {
setSelectedPostEncodeScriptIds((prev) => {
const normalizedCurrent = normalizeScriptIdList(prev);
onReorderPreEncodeItem={(fromIndex, toIndex) => {
setPreEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
const from = Number(fromIndex);
const to = Number(toIndex);
if (!Number.isInteger(from) || !Number.isInteger(to)) {
return normalizedCurrent;
return current;
}
if (from < 0 || to < 0 || from >= normalizedCurrent.length || to >= normalizedCurrent.length) {
return normalizedCurrent;
if (from < 0 || to < 0 || from >= current.length || to >= current.length || from === to) {
return current;
}
if (from === to) {
return normalizedCurrent;
}
const next = [...normalizedCurrent];
const next = [...current];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
}}
availablePreScripts={scriptCatalog}
selectedPreEncodeScriptIds={selectedPreEncodeScriptIds}
allowPreScriptSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
onAddPreEncodeScript={() => {
setSelectedPreEncodeScriptIds((prev) => {
const normalizedCurrent = normalizeScriptIdList(prev);
const selectedSet = new Set(normalizedCurrent.map((id) => String(id)));
onAddPostEncodeItem={(itemType) => {
setPostEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
if (itemType === 'chain') {
const selectedSet = new Set(
current
.filter((item) => item?.type === 'chain')
.map((item) => normalizeChainId(item?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const nextCandidate = (Array.isArray(chainCatalog) ? chainCatalog : [])
.map((item) => normalizeChainId(item?.id))
.find((id) => id !== null && !selectedSet.has(String(id)));
if (nextCandidate === undefined || nextCandidate === null) {
return current;
}
return [...current, { type: 'chain', id: nextCandidate }];
}
const selectedSet = new Set(
current
.filter((item) => item?.type === 'script')
.map((item) => normalizeScriptId(item?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const nextCandidate = (Array.isArray(scriptCatalog) ? scriptCatalog : [])
.map((item) => normalizeScriptId(item?.id))
.find((id) => id !== null && !selectedSet.has(String(id)));
if (nextCandidate === undefined || nextCandidate === null) {
return normalizedCurrent;
return current;
}
return [...normalizedCurrent, nextCandidate];
return [...current, { type: 'script', id: nextCandidate }];
});
}}
onChangePreEncodeScript={(rowIndex, nextScriptId) => {
setSelectedPreEncodeScriptIds((prev) => {
const normalizedCurrent = normalizeScriptIdList(prev);
if (!Number.isFinite(Number(rowIndex)) || rowIndex < 0 || rowIndex >= normalizedCurrent.length) {
return normalizedCurrent;
onChangePostEncodeItem={(rowIndex, itemType, nextId) => {
setPostEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
const index = Number(rowIndex);
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
return current;
}
const normalizedScriptId = normalizeScriptId(nextScriptId);
if (normalizedScriptId === null) {
return normalizedCurrent;
const type = itemType === 'chain' ? 'chain' : 'script';
if (type === 'chain') {
const normalizedId = normalizeChainId(nextId);
if (normalizedId === null) {
return current;
}
const duplicate = current.some((item, idx) =>
idx !== index
&& item?.type === 'chain'
&& String(normalizeChainId(item?.id)) === String(normalizedId)
);
if (duplicate) {
return current;
}
const next = [...current];
next[index] = { type: 'chain', id: normalizedId };
return next;
}
if (normalizedCurrent.some((id, idx) => idx !== rowIndex && String(id) === String(normalizedScriptId))) {
return normalizedCurrent;
const normalizedId = normalizeScriptId(nextId);
if (normalizedId === null) {
return current;
}
const next = [...normalizedCurrent];
next[rowIndex] = normalizedScriptId;
const duplicate = current.some((item, idx) =>
idx !== index
&& item?.type === 'script'
&& String(normalizeScriptId(item?.id)) === String(normalizedId)
);
if (duplicate) {
return current;
}
const next = [...current];
next[index] = { type: 'script', id: normalizedId };
return next;
});
}}
onRemovePreEncodeScript={(rowIndex) => {
setSelectedPreEncodeScriptIds((prev) => {
const normalizedCurrent = normalizeScriptIdList(prev);
if (!Number.isFinite(Number(rowIndex)) || rowIndex < 0 || rowIndex >= normalizedCurrent.length) {
return normalizedCurrent;
onRemovePostEncodeItem={(rowIndex) => {
setPostEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
const index = Number(rowIndex);
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
return current;
}
return normalizedCurrent.filter((_, idx) => idx !== rowIndex);
return current.filter((_, idx) => idx !== index);
});
}}
availableChains={chainCatalog}
selectedPreEncodeChainIds={selectedPreEncodeChainIds}
selectedPostEncodeChainIds={selectedPostEncodeChainIds}
allowChainSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
onAddPreEncodeChain={(chainId) => {
setSelectedPreEncodeChainIds((prev) => {
const id = Number(chainId);
if (!Number.isFinite(id) || id <= 0 || prev.includes(id)) {
return prev;
onReorderPostEncodeItem={(fromIndex, toIndex) => {
setPostEncodeItems((prev) => {
const current = Array.isArray(prev) ? prev : [];
const from = Number(fromIndex);
const to = Number(toIndex);
if (!Number.isInteger(from) || !Number.isInteger(to)) {
return current;
}
return [...prev, id];
});
}}
onRemovePreEncodeChain={(index) => {
setSelectedPreEncodeChainIds((prev) => prev.filter((_, i) => i !== index));
}}
onAddPostEncodeChain={(chainId) => {
setSelectedPostEncodeChainIds((prev) => {
const id = Number(chainId);
if (!Number.isFinite(id) || id <= 0 || prev.includes(id)) {
return prev;
if (from < 0 || to < 0 || from >= current.length || to >= current.length || from === to) {
return current;
}
return [...prev, id];
const next = [...current];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
}}
onRemovePostEncodeChain={(index) => {
setSelectedPostEncodeChainIds((prev) => prev.filter((_, i) => i !== index));
}}
/>
</div>
) : null}

View File

@@ -11,6 +11,7 @@ import PipelineStatusCard from '../components/PipelineStatusCard';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'];
@@ -179,7 +180,13 @@ function getAnalyzeContext(job) {
function resolveMediaType(job) {
const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase();
return raw === 'bluray' ? 'bluray' : 'disc';
if (raw === 'bluray') {
return 'bluray';
}
if (raw === 'dvd' || raw === 'disc') {
return 'dvd';
}
return 'other';
}
function mediaIndicatorMeta(job) {
@@ -191,11 +198,18 @@ function mediaIndicatorMeta(job) {
alt: 'Blu-ray',
title: 'Blu-ray'
}
: mediaType === 'dvd'
? {
mediaType,
src: discIndicatorIcon,
alt: 'DVD',
title: 'DVD'
}
: {
mediaType,
src: discIndicatorIcon,
alt: 'Disc',
title: 'CD/sonstiges Medium'
src: otherIndicatorIcon,
alt: 'Sonstiges Medium',
title: 'Sonstiges Medium'
};
}

View File

@@ -12,6 +12,7 @@ import JobDetailDialog from '../components/JobDetailDialog';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
import {
getStatusLabel,
getStatusSeverity,
@@ -21,7 +22,13 @@ import {
function resolveMediaType(row) {
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
return raw === 'bluray' ? 'bluray' : 'disc';
if (raw === 'bluray') {
return 'bluray';
}
if (raw === 'dvd' || raw === 'disc') {
return 'dvd';
}
return 'other';
}
function normalizeJobId(value) {
@@ -573,10 +580,18 @@ export default function DatabasePage() {
};
const mediaBody = (row) => {
const mediaType = resolveMediaType(row);
const src = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
const alt = mediaType === 'bluray' ? 'Blu-ray' : 'Disc';
const title = mediaType === 'bluray' ? 'Blu-ray' : 'Sonstiges Medium';
const label = mediaType === 'bluray' ? 'Blu-ray' : 'Sonstiges';
const src = mediaType === 'bluray'
? blurayIndicatorIcon
: (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon);
const alt = mediaType === 'bluray'
? 'Blu-ray'
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
const title = mediaType === 'bluray'
? 'Blu-ray'
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
const label = mediaType === 'bluray'
? 'Blu-ray'
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges');
return (
<span className="job-step-cell">
<img src={src} alt={alt} title={title} className="media-indicator-icon" />

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Card } from 'primereact/card';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { DataView, DataViewLayoutOptions } from 'primereact/dataview';
import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
import { Button } from 'primereact/button';
@@ -11,17 +10,79 @@ import { api } from '../api/client';
import JobDetailDialog from '../components/JobDetailDialog';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
import {
getStatusLabel,
getStatusSeverity,
getProcessStatusLabel,
normalizeStatus,
STATUS_FILTER_OPTIONS
} from '../utils/statusPresentation';
const MEDIA_FILTER_OPTIONS = [
{ label: 'Alle Medien', value: '' },
{ label: 'Blu-ray', value: 'bluray' },
{ label: 'DVD', value: 'dvd' },
{ label: 'Sonstiges', value: 'other' }
];
const BASE_SORT_FIELD_OPTIONS = [
{ label: 'Startzeit', value: 'start_time' },
{ label: 'Endzeit', value: 'end_time' },
{ label: 'Titel', value: 'title' },
{ label: 'Medium', value: 'mediaType' }
];
const OPTIONAL_SORT_FIELD_OPTIONS = [
{ label: 'Keine', value: '' },
...BASE_SORT_FIELD_OPTIONS
];
const SORT_DIRECTION_OPTIONS = [
{ label: 'Aufsteigend', value: 1 },
{ label: 'Absteigend', value: -1 }
];
const MEDIA_SORT_RANK = {
bluray: 0,
dvd: 1,
other: 2
};
function resolveMediaType(row) {
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
return raw === 'bluray' ? 'bluray' : 'disc';
if (raw === 'bluray') {
return 'bluray';
}
if (raw === 'dvd' || raw === 'disc') {
return 'dvd';
}
return 'other';
}
function resolveMediaTypeMeta(row) {
const mediaType = resolveMediaType(row);
if (mediaType === 'bluray') {
return {
mediaType,
icon: blurayIndicatorIcon,
label: 'Blu-ray',
alt: 'Blu-ray'
};
}
if (mediaType === 'dvd') {
return {
mediaType,
icon: discIndicatorIcon,
label: 'DVD',
alt: 'DVD'
};
}
return {
mediaType,
icon: otherIndicatorIcon,
label: 'Sonstiges',
alt: 'Sonstiges Medium'
};
}
function normalizeJobId(value) {
@@ -36,10 +97,126 @@ function getQueueActionResult(response) {
return response?.result && typeof response.result === 'object' ? response.result : {};
}
function normalizeSortText(value) {
return String(value || '').trim().toLocaleLowerCase('de-DE');
}
function normalizeSortDate(value) {
if (!value) {
return null;
}
const ts = new Date(value).getTime();
return Number.isFinite(ts) ? ts : null;
}
function compareSortValues(a, b) {
const aMissing = a === null || a === undefined || a === '';
const bMissing = b === null || b === undefined || b === '';
if (aMissing && bMissing) {
return 0;
}
if (aMissing) {
return 1;
}
if (bMissing) {
return -1;
}
if (typeof a === 'number' && typeof b === 'number') {
if (a === b) {
return 0;
}
return a > b ? 1 : -1;
}
return String(a).localeCompare(String(b), 'de', {
sensitivity: 'base',
numeric: true
});
}
function resolveSortValue(row, field) {
switch (field) {
case 'start_time':
return normalizeSortDate(row?.start_time);
case 'end_time':
return normalizeSortDate(row?.end_time);
case 'title':
return normalizeSortText(row?.title || row?.detected_title || '');
case 'mediaType': {
const mediaType = resolveMediaType(row);
return MEDIA_SORT_RANK[mediaType] ?? MEDIA_SORT_RANK.other;
}
default:
return null;
}
}
function sanitizeRating(value) {
const raw = String(value || '').trim();
if (!raw || raw.toUpperCase() === 'N/A') {
return null;
}
return raw;
}
function findOmdbRatingBySource(omdbInfo, sourceName) {
const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : [];
const source = String(sourceName || '').trim().toLowerCase();
const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === source);
return sanitizeRating(entry?.Value);
}
function resolveRatings(row) {
const omdbInfo = row?.omdbInfo && typeof row.omdbInfo === 'object' ? row.omdbInfo : null;
if (!omdbInfo) {
return [];
}
const imdb = sanitizeRating(omdbInfo?.imdbRating)
|| findOmdbRatingBySource(omdbInfo, 'Internet Movie Database');
const rotten = findOmdbRatingBySource(omdbInfo, 'Rotten Tomatoes');
const metascore = sanitizeRating(omdbInfo?.Metascore);
const ratings = [];
if (imdb) {
ratings.push({ key: 'imdb', label: 'IMDb', value: imdb });
}
if (rotten) {
ratings.push({ key: 'rt', label: 'RT', value: rotten });
}
if (metascore) {
ratings.push({ key: 'meta', label: 'Meta', value: metascore });
}
return ratings;
}
function formatDateTime(value) {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return String(value);
}
return date.toLocaleString('de-DE', {
dateStyle: 'short',
timeStyle: 'short'
});
}
export default function HistoryPage() {
const [jobs, setJobs] = useState([]);
const [search, setSearch] = useState('');
const [status, setStatus] = useState('');
const [mediumFilter, setMediumFilter] = useState('');
const [layout, setLayout] = useState('list');
const [sortPrimaryField, setSortPrimaryField] = useState('start_time');
const [sortPrimaryOrder, setSortPrimaryOrder] = useState(-1);
const [sortSecondaryField, setSortSecondaryField] = useState('title');
const [sortSecondaryOrder, setSortSecondaryOrder] = useState(1);
const [sortTertiaryField, setSortTertiaryField] = useState('mediaType');
const [sortTertiaryOrder, setSortTertiaryOrder] = useState(1);
const [selectedJob, setSelectedJob] = useState(null);
const [detailVisible, setDetailVisible] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
@@ -49,6 +226,7 @@ export default function HistoryPage() {
const [loading, setLoading] = useState(false);
const [queuedJobIds, setQueuedJobIds] = useState([]);
const toastRef = useRef(null);
const queuedJobIdSet = useMemo(() => {
const next = new Set();
for (const value of Array.isArray(queuedJobIds) ? queuedJobIds : []) {
@@ -60,6 +238,52 @@ export default function HistoryPage() {
return next;
}, [queuedJobIds]);
const sortDescriptors = useMemo(() => {
const seen = new Set();
const rawDescriptors = [
{ field: String(sortPrimaryField || '').trim(), order: Number(sortPrimaryOrder || -1) >= 0 ? 1 : -1 },
{ field: String(sortSecondaryField || '').trim(), order: Number(sortSecondaryOrder || -1) >= 0 ? 1 : -1 },
{ field: String(sortTertiaryField || '').trim(), order: Number(sortTertiaryOrder || -1) >= 0 ? 1 : -1 }
];
const descriptors = [];
for (const descriptor of rawDescriptors) {
if (!descriptor.field || seen.has(descriptor.field)) {
continue;
}
seen.add(descriptor.field);
descriptors.push(descriptor);
}
return descriptors;
}, [sortPrimaryField, sortPrimaryOrder, sortSecondaryField, sortSecondaryOrder, sortTertiaryField, sortTertiaryOrder]);
const visibleJobs = useMemo(() => {
const filtered = mediumFilter
? jobs.filter((job) => resolveMediaType(job) === mediumFilter)
: [...jobs];
if (sortDescriptors.length === 0) {
return filtered;
}
filtered.sort((a, b) => {
for (const descriptor of sortDescriptors) {
const valueA = resolveSortValue(a, descriptor.field);
const valueB = resolveSortValue(b, descriptor.field);
const compared = compareSortValues(valueA, valueB);
if (compared !== 0) {
return compared * descriptor.order;
}
}
const idA = Number(a?.id || 0);
const idB = Number(b?.id || 0);
return idB - idA;
});
return filtered;
}, [jobs, mediumFilter, sortDescriptors]);
const load = async () => {
setLoading(true);
try {
@@ -211,8 +435,8 @@ export default function HistoryPage() {
const title = row.title || row.detected_title || `Job #${row.id}`;
if (row?.encodeSuccess) {
const confirmed = window.confirm(
`Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n` +
'Es wird eine neue Datei mit Kollisionsprüfung angelegt.'
`Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n`
+ 'Es wird eine neue Datei mit Kollisionsprüfung angelegt.'
);
if (!confirmed) {
return;
@@ -279,7 +503,7 @@ export default function HistoryPage() {
}
};
const statusBody = (row) => {
const renderStatusTag = (row) => {
const normalizedStatus = normalizeStatus(row?.status);
const rowId = normalizeJobId(row?.id);
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
@@ -290,88 +514,297 @@ export default function HistoryPage() {
/>
);
};
const mkBody = (row) => (
<span className="job-step-cell">
{row?.backupSuccess ? <i className="pi pi-check-circle job-step-ok-icon" aria-label="Backup erfolgreich" title="Backup erfolgreich" /> : null}
<span>
{row.makemkvInfo
? `${getProcessStatusLabel(row.makemkvInfo.status)} ${typeof row.makemkvInfo.lastProgress === 'number' ? `${row.makemkvInfo.lastProgress.toFixed(1)}%` : ''}`
: '-'}
</span>
</span>
);
const hbBody = (row) => (
<span className="job-step-cell">
{row?.encodeSuccess ? <i className="pi pi-check-circle job-step-ok-icon" aria-label="Encode erfolgreich" title="Encode erfolgreich" /> : null}
<span>
{row.handbrakeInfo
? `${getProcessStatusLabel(row.handbrakeInfo.status)} ${typeof row.handbrakeInfo.lastProgress === 'number' ? `${row.handbrakeInfo.lastProgress.toFixed(1)}%` : ''}`
: '-'}
</span>
</span>
);
const mediaBody = (row) => {
const mediaType = resolveMediaType(row);
const src = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
const alt = mediaType === 'bluray' ? 'Blu-ray' : 'Disc';
const title = mediaType === 'bluray' ? 'Blu-ray' : 'CD/sonstiges Medium';
return <img src={src} alt={alt} title={title} className="media-indicator-icon" />;
const renderPoster = (row, className = 'history-dv-poster') => {
const title = row?.title || row?.detected_title || 'Poster';
if (row?.poster_url && row.poster_url !== 'N/A') {
return <img src={row.poster_url} alt={title} className={className} loading="lazy" />;
}
return <div className="history-dv-poster-fallback">Kein Poster</div>;
};
const posterBody = (row) =>
row.poster_url && row.poster_url !== 'N/A' ? (
<img src={row.poster_url} alt={row.title || row.detected_title || 'Poster'} className="poster-thumb" />
) : (
<span>-</span>
const renderPresenceChip = (label, available) => (
<span className={`history-dv-chip ${available ? 'tone-ok' : 'tone-no'}`}>
<i className={`pi ${available ? 'pi-check-circle' : 'pi-times-circle'}`} aria-hidden="true" />
<span>{label}: {available ? 'Ja' : 'Nein'}</span>
</span>
);
const renderRatings = (row) => {
const ratings = resolveRatings(row);
if (ratings.length === 0) {
return <span className="history-dv-subtle">Keine Ratings</span>;
}
return ratings.map((rating) => (
<span key={`${row?.id}-${rating.key}`} className="history-dv-rating-chip">
<strong>{rating.label}</strong>
<span>{rating.value}</span>
</span>
));
};
const onItemKeyDown = (event, row) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
void openDetail(row);
}
};
const renderListItem = (row) => {
const mediaMeta = resolveMediaTypeMeta(row);
const title = row?.title || row?.detected_title || '-';
const imdb = row?.imdb_id || '-';
return (
<div
className="history-dv-item history-dv-item-list"
role="button"
tabIndex={0}
onKeyDown={(event) => onItemKeyDown(event, row)}
onClick={() => {
void openDetail(row);
}}
>
<div className="history-dv-poster-wrap">
{renderPoster(row)}
</div>
<div className="history-dv-main">
<div className="history-dv-head">
<div className="history-dv-title-block">
<strong className="history-dv-title">{title}</strong>
<small className="history-dv-subtle">
#{row?.id || '-'} | {row?.year || '-'} | {imdb}
</small>
</div>
{renderStatusTag(row)}
</div>
<div className="history-dv-meta-row">
<span className="job-step-cell">
<img src={mediaMeta.icon} alt={mediaMeta.alt} title={mediaMeta.label} className="media-indicator-icon" />
<span>{mediaMeta.label}</span>
</span>
<span className="history-dv-subtle">Start: {formatDateTime(row?.start_time)}</span>
<span className="history-dv-subtle">Ende: {formatDateTime(row?.end_time)}</span>
</div>
<div className="history-dv-flags-row">
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
</div>
<div className="history-dv-ratings-row">
{renderRatings(row)}
</div>
</div>
<div className="history-dv-actions">
<Button
label="Details"
icon="pi pi-search"
size="small"
onClick={(event) => {
event.stopPropagation();
void openDetail(row);
}}
/>
</div>
</div>
);
};
const renderGridItem = (row) => {
const mediaMeta = resolveMediaTypeMeta(row);
const title = row?.title || row?.detected_title || '-';
return (
<div className="history-dv-grid-cell">
<div
className="history-dv-item history-dv-item-grid"
role="button"
tabIndex={0}
onKeyDown={(event) => onItemKeyDown(event, row)}
onClick={() => {
void openDetail(row);
}}
>
<div className="history-dv-grid-head">
{renderPoster(row, 'history-dv-poster-lg')}
<div className="history-dv-grid-title-wrap">
<strong className="history-dv-title">{title}</strong>
<small className="history-dv-subtle">
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
</small>
<span className="job-step-cell">
<img src={mediaMeta.icon} alt={mediaMeta.alt} title={mediaMeta.label} className="media-indicator-icon" />
<span>{mediaMeta.label}</span>
</span>
</div>
</div>
<div className="history-dv-grid-status-row">
{renderStatusTag(row)}
</div>
<div className="history-dv-grid-time-row">
<span className="history-dv-subtle">Start: {formatDateTime(row?.start_time)}</span>
<span className="history-dv-subtle">Ende: {formatDateTime(row?.end_time)}</span>
</div>
<div className="history-dv-flags-row">
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
</div>
<div className="history-dv-ratings-row">
{renderRatings(row)}
</div>
<div className="history-dv-actions history-dv-actions-grid">
<Button
label="Details"
icon="pi pi-search"
size="small"
onClick={(event) => {
event.stopPropagation();
void openDetail(row);
}}
/>
</div>
</div>
</div>
);
};
const itemTemplate = (row, currentLayout) => {
if (!row) {
return null;
}
if (currentLayout === 'grid') {
return renderGridItem(row);
}
return renderListItem(row);
};
const dataViewHeader = (
<div>
<div className="history-dv-toolbar">
<InputText
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Suche nach Titel oder IMDb"
/>
<Dropdown
value={status}
options={STATUS_FILTER_OPTIONS}
optionLabel="label"
optionValue="value"
onChange={(event) => setStatus(event.value)}
placeholder="Status"
/>
<Dropdown
value={mediumFilter}
options={MEDIA_FILTER_OPTIONS}
optionLabel="label"
optionValue="value"
onChange={(event) => setMediumFilter(event.value || '')}
placeholder="Medium"
/>
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
<div className="history-dv-layout-toggle">
<DataViewLayoutOptions
layout={layout}
onChange={(event) => setLayout(event.value)}
/>
</div>
</div>
<div className="history-dv-sortbar">
<div className="history-dv-sort-rule">
<strong>1.</strong>
<Dropdown
value={sortPrimaryField}
options={BASE_SORT_FIELD_OPTIONS}
optionLabel="label"
optionValue="value"
onChange={(event) => setSortPrimaryField(event.value || 'start_time')}
placeholder="Primär"
/>
<Dropdown
value={sortPrimaryOrder}
options={SORT_DIRECTION_OPTIONS}
optionLabel="label"
optionValue="value"
onChange={(event) => setSortPrimaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)}
placeholder="Richtung"
/>
</div>
<div className="history-dv-sort-rule">
<strong>2.</strong>
<Dropdown
value={sortSecondaryField}
options={OPTIONAL_SORT_FIELD_OPTIONS}
optionLabel="label"
optionValue="value"
onChange={(event) => setSortSecondaryField(event.value || '')}
placeholder="Sekundär"
/>
<Dropdown
value={sortSecondaryOrder}
options={SORT_DIRECTION_OPTIONS}
optionLabel="label"
optionValue="value"
onChange={(event) => setSortSecondaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)}
placeholder="Richtung"
disabled={!sortSecondaryField}
/>
</div>
<div className="history-dv-sort-rule">
<strong>3.</strong>
<Dropdown
value={sortTertiaryField}
options={OPTIONAL_SORT_FIELD_OPTIONS}
optionLabel="label"
optionValue="value"
onChange={(event) => setSortTertiaryField(event.value || '')}
placeholder="Tertiär"
/>
<Dropdown
value={sortTertiaryOrder}
options={SORT_DIRECTION_OPTIONS}
optionLabel="label"
optionValue="value"
onChange={(event) => setSortTertiaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)}
placeholder="Richtung"
disabled={!sortTertiaryField}
/>
</div>
</div>
</div>
);
return (
<div className="page-grid">
<Toast ref={toastRef} />
<Card title="Historie" subTitle="Alle Jobs mit Details und Logs">
<div className="table-filters">
<InputText
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Suche nach Titel oder IMDb"
/>
<Dropdown
value={status}
options={STATUS_FILTER_OPTIONS}
optionLabel="label"
optionValue="value"
onChange={(event) => setStatus(event.value)}
placeholder="Status"
/>
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
</div>
<div className="table-scroll-wrap table-scroll-wide">
<DataTable
value={jobs}
dataKey="id"
paginator
rows={10}
loading={loading}
onRowClick={(event) => openDetail(event.data)}
className="clickable-table"
emptyMessage="Keine Einträge"
responsiveLayout="scroll"
>
<Column field="id" header="#" style={{ width: '5rem' }} />
<Column header="Medium" body={mediaBody} style={{ width: '6rem' }} />
<Column header="Poster" body={posterBody} style={{ width: '7rem' }} />
<Column field="title" header="Titel" body={(row) => row.title || row.detected_title || '-'} />
<Column field="year" header="Jahr" style={{ width: '6rem' }} />
<Column field="imdb_id" header="IMDb" style={{ width: '10rem' }} />
<Column field="status" header="Status" body={statusBody} style={{ width: '12rem' }} />
<Column header="MakeMKV" body={mkBody} style={{ width: '12rem' }} />
<Column header="HandBrake" body={hbBody} style={{ width: '12rem' }} />
<Column field="start_time" header="Start" style={{ width: '16rem' }} />
<Column field="end_time" header="Ende" style={{ width: '16rem' }} />
<Column field="output_path" header="Output" />
</DataTable>
</div>
<Card title="Historie" subTitle="DataView mit Poster, Status, Dateiverfügbarkeit, Encode-Status und Ratings">
<DataView
value={visibleJobs}
layout={layout}
itemTemplate={itemTemplate}
paginator
rows={12}
rowsPerPageOptions={[12, 24, 48]}
header={dataViewHeader}
loading={loading}
emptyMessage="Keine Einträge"
className="history-dataview"
/>
</Card>
<JobDetailDialog

View File

@@ -29,11 +29,12 @@ function isSameValue(a, b) {
function injectHandBrakePresetOptions(categories, presetPayload) {
const list = Array.isArray(categories) ? categories : [];
const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : [];
const presetSettingKeys = new Set(['handbrake_preset', 'handbrake_preset_bluray', 'handbrake_preset_dvd']);
return list.map((category) => ({
...category,
settings: (category?.settings || []).map((setting) => {
if (setting?.key !== 'handbrake_preset') {
if (!presetSettingKeys.has(String(setting?.key || '').trim().toLowerCase())) {
return setting;
}

View File

@@ -552,6 +552,78 @@ body {
font-size: 0.82rem;
}
.script-results-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.script-summary-block {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.script-summary-counts {
display: flex;
gap: 0.6rem;
font-size: 0.82rem;
margin-left: 0.2rem;
}
.script-summary-counts .tone-success { color: #1c8a3a; font-weight: 600; }
.script-summary-counts .tone-danger { color: #9c2d2d; font-weight: 600; }
.script-summary-counts .tone-warning { color: #b45309; font-weight: 600; }
.script-result-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-left: 0.5rem;
margin-top: 0.15rem;
}
.script-result-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.83rem;
}
.script-result-name {
flex: 1;
color: var(--rip-text);
}
.script-result-status {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.script-result-status.tone-success { color: #1c8a3a; }
.script-result-status.tone-danger { color: #9c2d2d; }
.script-result-status.tone-warning { color: #b45309; }
.script-result-error {
font-size: 0.75rem;
color: #9c2d2d;
font-style: italic;
}
.job-step-inline-warn {
display: inline-flex;
align-items: center;
gap: 0.28rem;
color: #b45309;
font-weight: 600;
font-size: 0.8rem;
}
.job-step-inline-warn .pi {
font-size: 0.85rem;
}
.pipeline-queue-item {
border: 1px dashed var(--rip-border);
border-radius: 0.45rem;
@@ -1173,6 +1245,234 @@ body {
gap: 0.5rem;
}
.history-dataview .p-dataview-header {
background: transparent;
border: none;
padding: 0;
margin-bottom: 0.85rem;
}
.history-dataview .p-dataview-content {
background: transparent;
border: none;
padding: 0;
}
.history-dataview .p-paginator {
margin-top: 0.75rem;
}
.history-dv-toolbar {
margin-bottom: 0.7rem;
display: grid;
grid-template-columns: minmax(0, 1fr) 12rem 10rem auto auto;
gap: 0.5rem;
align-items: center;
}
.history-dv-layout-toggle {
display: flex;
justify-content: flex-end;
}
.history-dv-sortbar {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.history-dv-sort-rule {
display: grid;
grid-template-columns: 1.4rem minmax(0, 1fr) 9.4rem;
gap: 0.35rem;
align-items: center;
border: 1px solid var(--rip-border);
border-radius: 0.5rem;
background: var(--rip-panel-soft);
padding: 0.35rem 0.45rem;
}
.history-dv-sort-rule strong {
margin: 0;
color: var(--rip-brown-700);
text-align: center;
}
.history-dv-item {
border: 1px solid var(--rip-border);
border-radius: 0.55rem;
background: var(--rip-panel-soft);
box-shadow: 0 2px 7px rgba(58, 29, 18, 0.06);
}
.history-dv-item:focus-visible {
outline: none;
box-shadow: var(--focus-ring), 0 2px 7px rgba(58, 29, 18, 0.06);
}
.history-dv-item-list {
display: grid;
grid-template-columns: 60px minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: start;
padding: 0.6rem 0.7rem;
}
.history-dv-main {
min-width: 0;
display: grid;
gap: 0.35rem;
}
.history-dv-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.history-dv-title-block {
min-width: 0;
display: grid;
gap: 0.1rem;
}
.history-dv-title {
margin: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.history-dv-subtle {
color: var(--rip-muted);
font-size: 0.78rem;
}
.history-dv-meta-row,
.history-dv-flags-row,
.history-dv-ratings-row {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
}
.history-dv-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--rip-border);
border-radius: 999px;
padding: 0.12rem 0.48rem;
font-size: 0.76rem;
background: var(--rip-panel);
}
.history-dv-chip.tone-ok {
color: #176635;
border-color: #9ed5ad;
background: #dcf3e2;
}
.history-dv-chip.tone-no {
color: #8b2c2c;
border-color: #d4a29e;
background: #f8e1df;
}
.history-dv-rating-chip {
display: inline-flex;
align-items: center;
gap: 0.34rem;
border: 1px dashed var(--rip-border);
border-radius: 999px;
padding: 0.12rem 0.5rem;
font-size: 0.76rem;
background: var(--rip-panel);
}
.history-dv-poster-wrap {
width: 60px;
}
.history-dv-poster,
.history-dv-poster-lg {
display: block;
width: 100%;
object-fit: cover;
border-radius: 0.35rem;
border: 1px solid #cba266;
background: #f6ebd6;
}
.history-dv-poster {
height: 88px;
}
.history-dv-poster-lg {
width: 66px;
height: 96px;
}
.history-dv-poster-fallback {
width: 100%;
height: 88px;
border-radius: 0.35rem;
border: 1px dashed var(--rip-border);
background: #f3e5cc;
color: var(--rip-muted);
font-size: 0.72rem;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 0.3rem;
}
.history-dv-actions {
display: flex;
align-items: flex-start;
}
.history-dv-grid-cell {
padding: 0.35rem;
}
.history-dv-item-grid {
display: grid;
gap: 0.45rem;
padding: 0.65rem;
height: 100%;
}
.history-dv-grid-head {
display: grid;
grid-template-columns: 66px minmax(0, 1fr);
gap: 0.65rem;
}
.history-dv-grid-title-wrap {
display: grid;
gap: 0.22rem;
min-width: 0;
}
.history-dv-grid-status-row {
display: flex;
justify-content: flex-start;
}
.history-dv-grid-time-row {
display: grid;
gap: 0.12rem;
}
.history-dv-actions-grid {
justify-content: flex-end;
margin-top: 0.2rem;
}
.table-scroll-wrap {
width: 100%;
max-width: 100%;
@@ -1718,6 +2018,8 @@ body {
.job-meta-grid,
.job-film-info-grid,
.table-filters,
.history-dv-toolbar,
.history-dv-sortbar,
.job-head-row,
.job-json-grid,
.selected-meta,
@@ -1780,6 +2082,28 @@ body {
.hardware-core-metric {
justify-self: start;
}
.history-dv-sort-rule {
grid-template-columns: 1.4rem minmax(0, 1fr) 8.8rem;
}
.history-dv-item-list {
grid-template-columns: 52px minmax(0, 1fr);
}
.history-dv-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
.history-dv-grid-head {
grid-template-columns: 58px minmax(0, 1fr);
}
.history-dv-poster-lg {
width: 58px;
height: 84px;
}
}
@media (max-width: 640px) {
@@ -1791,6 +2115,30 @@ body {
grid-template-columns: 1fr;
}
.history-dv-sort-rule {
grid-template-columns: 1.4rem minmax(0, 1fr);
}
.history-dv-sort-rule .p-dropdown:last-child {
grid-column: 1 / -1;
}
.history-dv-item-list {
grid-template-columns: 1fr;
}
.history-dv-poster-wrap {
width: 54px;
}
.history-dv-poster {
height: 80px;
}
.history-dv-poster-fallback {
height: 80px;
}
.search-row {
grid-template-columns: 1fr;
}