0.12.0-16 misc fixes

This commit is contained in:
2026-03-30 14:37:23 +00:00
parent 76b020d5c0
commit 5284dd2a43
30 changed files with 6955 additions and 896 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.12.0-15",
"version": "0.12.0-16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.12.0-15",
"version": "0.12.0-16",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.12.0-15",
"version": "0.12.0-16",
"private": true,
"type": "module",
"scripts": {
+28
View File
@@ -617,6 +617,7 @@ export const api = {
const body = {};
if (options.keepBoth) body.keepBoth = true;
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
if (options.reuseCurrentJob) body.reuseCurrentJob = true;
const result = await request(`/pipeline/restart-review/${jobId}`, {
method: 'POST',
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
@@ -920,6 +921,33 @@ export const api = {
body: JSON.stringify({ relPaths, audioMode })
});
},
converterAssignFilesToJob(jobId, relPaths = []) {
afterMutationInvalidate(['/converter/jobs']);
return request(`/converter/jobs/${encodeURIComponent(jobId)}/assign-files`, {
method: 'POST',
body: JSON.stringify({ relPaths })
});
},
converterRemoveFileFromJob(jobId, relPath) {
afterMutationInvalidate(['/converter/jobs']);
return request(`/converter/jobs/${encodeURIComponent(jobId)}/remove-file`, {
method: 'POST',
body: JSON.stringify({ relPath })
});
},
converterRemoveInputFromJob(jobId, inputPath) {
afterMutationInvalidate(['/converter/jobs']);
return request(`/converter/jobs/${encodeURIComponent(jobId)}/remove-input`, {
method: 'POST',
body: JSON.stringify({ inputPath })
});
},
converterUpdateJobConfig(jobId, config = {}) {
return request(`/converter/jobs/${encodeURIComponent(jobId)}/config`, {
method: 'POST',
body: JSON.stringify(config)
});
},
converterUploadFiles(formData) {
return request('/converter/upload', { method: 'POST', body: formData });
},
+3 -1
View File
@@ -86,6 +86,8 @@ export default function CdMetadataDialog({
const tocTracks = Array.isArray(context?.tracks) ? context.tracks : [];
const contextJobId = Number(context?.jobId || 0);
useEffect(() => {
if (!visible) {
return;
@@ -103,7 +105,7 @@ export default function CdMetadataDialog({
titles[t.position] = t.title || `Track ${t.position}`;
}
setTrackTitles(titles);
}, [visible, context]);
}, [visible, contextJobId]);
useEffect(() => {
if (!selected) {
+6 -1
View File
@@ -64,7 +64,12 @@ function normalizePosition(value) {
}
function normalizeTrackText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
return String(value || '')
.normalize('NFC')
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
.replace(/\p{C}+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function normalizeYear(value) {
+309 -60
View File
@@ -26,7 +26,7 @@ function mediaTypeBadge(type) {
return <Tag value={m.label} severity={m.severity} />;
}
/** Navigiert den Baum per Pfad-String */
/** Navigiert den Baum per Pfad-String (Ordner UND Dateien) */
function getNodeByPath(root, targetPath) {
if (!root) return null;
if ((root.path || '') === (targetPath || '')) return root;
@@ -34,6 +34,8 @@ function getNodeByPath(root, targetPath) {
if (child.type === 'folder') {
const found = getNodeByPath(child, targetPath);
if (found) return found;
} else if ((child.path || '') === (targetPath || '')) {
return child;
}
}
return null;
@@ -78,6 +80,52 @@ function collectDescendantFilePaths(node) {
return result;
}
/** Zustände, in denen ein Job aktiv läuft → Dateien sind gesperrt */
const LOCKED_JOB_STATES = new Set([
'ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK',
'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'
]);
/** Datei ist gesperrt wenn ihr Job gerade aktiv läuft */
function isNodeLocked(node) {
if (!node || node.type !== 'file') return false;
const jobId = Number(node.jobId);
if (!Number.isFinite(jobId) || jobId <= 0) return false;
return LOCKED_JOB_STATES.has(String(node.jobStatus || '').trim().toUpperCase());
}
/** Alle nicht bereits einem Job zugewiesenen und nicht gesperrten Dateipfade sammeln */
function collectDescendantSelectableFilePaths(node) {
const result = [];
for (const child of (node?.children || [])) {
if (child.type === 'file') {
const assignedJobId = Number(child.jobId);
if (!Number.isFinite(assignedJobId) || assignedJobId <= 0) {
result.push(child.path || '');
}
} else if (child.type === 'folder') {
result.push(...collectDescendantSelectableFilePaths(child));
}
}
return result;
}
/** Alle nicht bereits einem Job zugewiesenen und nicht gesperrten Datei-Knoten sammeln */
function collectDescendantSelectableFileNodes(node) {
const result = [];
for (const child of (node?.children || [])) {
if (child.type === 'file') {
const assignedJobId = Number(child.jobId);
if (!Number.isFinite(assignedJobId) || assignedJobId <= 0) {
result.push(child);
}
} else if (child.type === 'folder') {
result.push(...collectDescendantSelectableFileNodes(child));
}
}
return result;
}
/** Nur "Wurzel"-Selektionen: Pfade, die keinen ausgewählten Elternpfad haben */
function getRootSelections(paths) {
const set = new Set(paths);
@@ -98,7 +146,12 @@ function collectFolderPaths(node, result = []) {
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function ConverterFileExplorer({ onSelectionChange, refreshToken, navigateToPath }) {
export default function ConverterFileExplorer({
onSelectionChange,
refreshToken,
navigateToPath,
onAssignmentChanged
}) {
const toastRef = useRef(null);
const explorerRef = useRef(null);
@@ -111,8 +164,11 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
const [currentPath, setCurrentPath] = useState('');
const [expandedFolders, setExpandedFolders] = useState(() => new Set(['']));
// Auswahl
// Auswahl (Checkbox → Job-Zuweisung)
const [selectedPaths, setSelectedPaths] = useState([]);
// Aktive Zeilen-Auswahl (Klick → Rename/Delete/Move)
const [activePaths, setActivePaths] = useState([]);
const [activeAnchor, setActiveAnchor] = useState(null);
// Seitenleiste
const [sidebarQuery, setSidebarQuery] = useState('');
@@ -123,10 +179,13 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
const [renameName, setRenameName] = useState('');
const [moveTarget, setMoveTarget] = useState('');
const [busy, setBusy] = useState(false);
const [jobUnassignTarget, setJobUnassignTarget] = useState(null);
// Stabile Ref für onSelectionChange
const onSelectionChangeRef = useRef(onSelectionChange);
useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]);
const onAssignmentChangedRef = useRef(onAssignmentChanged);
useEffect(() => { onAssignmentChangedRef.current = onAssignmentChanged; }, [onAssignmentChanged]);
// ── Baum laden ─────────────────────────────────────────────────────────────
@@ -155,35 +214,42 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigateToPath]);
// Auswahl an Eltern melden: nur Wurzel-Selektionen (keine redundanten Kind-Pfade)
// Auswahl an Eltern melden: Ordner werden zu ihren Datei-Kindern expandiert
useEffect(() => {
if (!tree) return;
const rootPaths = getRootSelections(selectedPaths);
const report = rootPaths.map((p) => {
const report = [];
for (const p of rootPaths) {
const node = getNodeByPath(tree, p);
if (!node) return null;
return {
relPath: node.path,
entryType: node.type === 'folder' ? 'directory' : 'file',
detectedMediaType: node.detectedMediaType || null,
detectedFormat: node.detectedFormat || null
};
}).filter(Boolean);
if (!node) continue;
if (node.type === 'folder') {
const fileNodes = collectDescendantSelectableFileNodes(node);
if (fileNodes.length > 0) {
for (const fn of fileNodes) {
report.push({
relPath: fn.path,
entryType: 'file',
detectedMediaType: fn.detectedMediaType || null,
detectedFormat: fn.detectedFormat || null
});
}
} else {
// Leerer Ordner: als Ordner-Eintrag weitergeben
report.push({ relPath: node.path, entryType: 'directory', detectedMediaType: null, detectedFormat: null });
}
} else {
report.push({
relPath: node.path,
entryType: 'file',
detectedMediaType: node.detectedMediaType || null,
detectedFormat: node.detectedFormat || null
});
}
}
onSelectionChangeRef.current?.(report);
}, [selectedPaths, tree]);
// Outside-Click → Auswahl aufheben
useEffect(() => {
function handleOutside(event) {
if (activeModal) return;
if (!explorerRef.current) return;
if (!explorerRef.current.contains(event.target)) {
setSelectedPaths([]);
}
}
window.addEventListener('mousedown', handleOutside);
return () => window.removeEventListener('mousedown', handleOutside);
}, [activeModal]);
// Auswahl bleibt erhalten — kein Outside-Click-Handler
// ── Navigation ─────────────────────────────────────────────────────────────
@@ -194,7 +260,8 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
function navigateTo(pathStr) {
setCurrentPath(pathStr || '');
setSelectedPaths([]);
setActivePaths([]);
setActiveAnchor(null);
setExpandedFolders((prev) => new Set(prev).add(pathStr || ''));
}
@@ -236,10 +303,31 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
function handleCheckboxChange(item, checked) {
const p = item.path || '';
if (item.type === 'file') {
// Gesperrte Dateien (Job läuft) → keine Aktion möglich
if (isNodeLocked(item)) return;
const assignedJobId = Number(item.jobId);
const isAssigned = Number.isFinite(assignedJobId) && assignedJobId > 0;
if (isAssigned && !checked) {
setJobUnassignTarget({
relPath: p,
jobId: Math.trunc(assignedJobId),
jobTitle: String(item.jobTitle || '').trim() || null
});
setActiveModal('job-unassign');
return;
}
if (isAssigned) {
return;
}
}
if (item.type === 'folder') {
const folderNode = getNodeByPath(tree, p);
const descendantFiles = collectDescendantFilePaths(folderNode);
const descendantFiles = collectDescendantSelectableFilePaths(folderNode);
if (checked) {
if (descendantFiles.length === 0) return;
setSelectedPaths((prev) => Array.from(new Set([...prev, p, ...descendantFiles])));
} else {
const toRemove = new Set([p, ...descendantFiles]);
@@ -271,6 +359,35 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
}
}
async function handleRemoveFromJob() {
if (!jobUnassignTarget?.jobId || !jobUnassignTarget?.relPath) return;
setBusy(true);
try {
const result = await api.converterRemoveFileFromJob(jobUnassignTarget.jobId, jobUnassignTarget.relPath);
const removedRelPath = String(result?.removedRelPath || jobUnassignTarget.relPath || '').trim();
toastRef.current?.show({
severity: 'success',
summary: 'Aus Job entfernt',
detail: removedRelPath || 'Datei wurde aus dem Job entfernt.',
life: 2800
});
setSelectedPaths((prev) => prev.filter((entryPath) => entryPath !== removedRelPath));
setJobUnassignTarget(null);
setActiveModal('');
await loadTree();
onAssignmentChangedRef.current?.();
} catch (err) {
toastRef.current?.show({
severity: 'error',
summary: 'Fehler',
detail: err.message || 'Datei konnte nicht aus dem Job entfernt werden.',
life: 4200
});
} finally {
setBusy(false);
}
}
function handleOpen(item) {
if (item.type !== 'folder') return;
navigateTo(item.path || '');
@@ -294,16 +411,16 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
}
async function handleRename() {
if (!selectedPaths.length) return;
if (!activePaths.length) return;
const name = renameName.trim();
if (!name) return;
setBusy(true);
try {
await api.converterRenameFile(rootSelected[0], name);
await api.converterRenameFile(activePaths[0], name);
toastRef.current?.show({ severity: 'success', summary: 'Umbenannt', detail: `${name}`, life: 2500 });
setRenameName('');
setActiveModal('');
setSelectedPaths([]);
setActivePaths([]);
await loadTree();
} catch (err) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
@@ -311,14 +428,15 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
}
async function handleDeleteSelected() {
if (!rootSelected.length) return;
if (!activePaths.length) return;
setBusy(true);
try {
for (const p of rootSelected) {
for (const p of activePaths) {
await api.converterDeleteFile(p);
}
toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', detail: `${rootSelected.length} Eintrag/Einträge`, life: 2500 });
setSelectedPaths([]);
toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', detail: `${activePaths.length} Eintrag/Einträge`, life: 2500 });
setActivePaths([]);
setSelectedPaths((prev) => prev.filter((x) => !activePaths.includes(x)));
setActiveModal('');
await loadTree();
} catch (err) {
@@ -327,14 +445,14 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
}
async function handleMoveSelected() {
if (!selectedPaths.length) return;
if (!activePaths.length) return;
setBusy(true);
try {
const isRoot = moveTarget === '' || moveTarget === '__root__';
await api.converterMoveFile(rootSelected[0], isRoot ? '' : moveTarget);
await api.converterMoveFile(activePaths[0], isRoot ? '' : moveTarget);
toastRef.current?.show({ severity: 'success', summary: 'Verschoben', life: 2500 });
setMoveTarget('');
setSelectedPaths([]);
setActivePaths([]);
setActiveModal('');
await loadTree();
} catch (err) {
@@ -342,6 +460,49 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
} finally { setBusy(false); }
}
function handleSelectActive() {
const newPaths = [];
for (const p of activePaths) {
const node = getNodeByPath(tree, p);
if (!node || isNodeLocked(node)) continue;
if (node.type === 'folder') {
newPaths.push(p, ...collectDescendantSelectableFilePaths(node));
} else {
newPaths.push(p);
}
}
setSelectedPaths((prev) => Array.from(new Set([...prev, ...newPaths])));
}
function handleRowClick(e, item) {
if (isNodeLocked(item)) return;
const p = item.path || '';
const items = currentItems;
if (e.shiftKey && activeAnchor) {
const anchorIdx = items.findIndex((i) => (i.path || '') === activeAnchor);
const clickIdx = items.findIndex((i) => (i.path || '') === p);
if (anchorIdx !== -1 && clickIdx !== -1) {
const from = Math.min(anchorIdx, clickIdx);
const to = Math.max(anchorIdx, clickIdx);
const rangePaths = items.slice(from, to + 1).map((i) => i.path || '');
if (e.ctrlKey || e.metaKey) {
setActivePaths((prev) => Array.from(new Set([...prev, ...rangePaths])));
} else {
setActivePaths(rangePaths);
}
}
} else if (e.ctrlKey || e.metaKey) {
setActivePaths((prev) =>
prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p]
);
setActiveAnchor(p);
} else {
setActivePaths([p]);
setActiveAnchor(p);
}
}
// ── Ordnerbaum (Seitenleiste) ───────────────────────────────────────────────
function renderFolderTree(node, depth) {
@@ -352,6 +513,9 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
const childFolders = (node.children || []).filter((c) => c.type === 'folder');
const hasChildren = childFolders.length > 0;
const nodeSel = isSelected(key);
const nodeIndet = isIndeterminate(node);
return (
<div key={key || '__root__'} className={`tree-node depth-${depth}`}>
<div
@@ -374,6 +538,15 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
) : (
<span className="tree-caret disabled" aria-hidden="true" />
)}
<span className="tree-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={nodeSel}
ref={(el) => { if (el) el.indeterminate = nodeIndet; }}
onChange={(e) => handleCheckboxChange(node, e.target.checked)}
aria-label={`${node.name || 'raw'} auswählen`}
/>
</span>
<span className="tree-icon folder">
<i className="pi pi-folder" />
</span>
@@ -389,9 +562,15 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
const allFolders = tree ? collectFolderPaths(tree) : [{ label: '/ (Root)', value: '' }];
const breadcrumb = buildBreadcrumb(currentPath);
const rootSelected = getRootSelections(selectedPaths);
const canRename = rootSelected.length === 1;
const canDelete = selectedPaths.length > 0;
const canMove = rootSelected.length === 1;
// Aktionen basieren auf activePaths (Zeilen-Klick-Auswahl)
const hasLockedActive = activePaths.some((p) => {
const node = tree ? getNodeByPath(tree, p) : null;
return node ? isNodeLocked(node) : false;
});
const canRename = activePaths.length === 1 && !hasLockedActive;
const canDelete = activePaths.length > 0 && !hasLockedActive;
const canMove = activePaths.length === 1 && !hasLockedActive;
const canSelectActive = activePaths.length > 1;
// ── Render ─────────────────────────────────────────────────────────────────
@@ -500,11 +679,22 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
>
<i className="pi pi-folder-plus" />
</button>
{canSelectActive && (
<button
type="button"
className="icon-button"
onClick={handleSelectActive}
title="Auswahl als Checkbox setzen"
aria-label="Auswahl als Checkbox setzen"
>
<i className="pi pi-check-square" />
</button>
)}
<button
type="button"
className="icon-button"
onClick={() => {
const node = getNodeByPath(tree, rootSelected[0]);
const node = getNodeByPath(tree, activePaths[0]);
setRenameName(node?.name || '');
setActiveModal('rename');
}}
@@ -545,6 +735,7 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
<span>Name</span>
<span>Typ</span>
<span>Größe</span>
<span>Job</span>
</div>
{/* Zeilen */}
@@ -553,31 +744,36 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
Leer.
</div>
) : currentItems.map((item) => {
const sel = isSelected(item.path);
const assignedJobId = Number(item.jobId);
const assigned = item.type === 'file' && Number.isFinite(assignedJobId) && assignedJobId > 0;
const locked = isNodeLocked(item);
const sel = (assigned || isSelected(item.path)) && !locked;
const indet = isIndeterminate(item);
const active = activePaths.includes(item.path || '');
const jobTitle = String(item.jobTitle || '').trim();
return (
<div
key={item.path}
className={`explorer-row${sel ? ' selected' : ''}`}
onClick={() => {
if (item.type === 'folder') {
handleOpen(item);
} else {
handleCheckboxChange(item, !sel);
}
}}
className={`explorer-row${sel ? ' selected' : ''}${active ? ' row-active' : ''}${locked ? ' row-locked' : ''}`}
onClick={(e) => handleRowClick(e, item)}
onDoubleClick={() => item.type === 'folder' && handleOpen(item)}
role="row"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') item.type === 'folder' ? handleOpen(item) : handleCheckboxChange(item, !sel); }}
tabIndex={locked ? -1 : 0}
onKeyDown={(e) => { if (locked) return; if (e.key === 'Enter') item.type === 'folder' ? handleOpen(item) : handleCheckboxChange(item, !sel); }}
>
<span className="row-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={sel}
ref={(el) => { if (el) el.indeterminate = indet; }}
onChange={(e) => handleCheckboxChange(item, e.target.checked)}
aria-label={`${item.name} auswählen`}
/>
<span className="row-checkbox">
{locked ? (
<i className="pi pi-lock" style={{ fontSize: '0.8rem', color: 'var(--rip-muted)' }} title="Datei wird gerade verarbeitet" />
) : (
<input
type="checkbox"
checked={sel}
ref={(el) => { if (el) el.indeterminate = indet; }}
onChange={(e) => handleCheckboxChange(item, e.target.checked)}
onClick={(e) => e.stopPropagation()}
aria-label={`${item.name} auswählen`}
/>
)}
</span>
<span className="row-name">
<span className="row-icon">
@@ -593,6 +789,24 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
<span style={{ textAlign: 'right', color: 'var(--rip-muted)', fontSize: '0.78rem' }}>
{formatBytes(item.size)}
</span>
<span className="row-job">
{locked ? (
<small className="row-job-assignment" style={{ color: 'var(--orange-600, #e65100)' }}
title={`Job #${item.jobId} läuft (${item.jobStatus})`}>
<i className="pi pi-spin pi-spinner" style={{ fontSize: '0.65rem', marginRight: 3 }} />
#{item.jobId} läuft
</small>
) : assigned ? (
<small
className="row-job-assignment"
title={jobTitle ? `#${item.jobId} | ${jobTitle}` : `#${item.jobId}`}
>
#{item.jobId}{jobTitle ? ` | ${jobTitle}` : ''}
</small>
) : (
<small className="row-job-empty">-</small>
)}
</span>
</div>
);
})}
@@ -603,6 +817,7 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
<span />
<span />
<span />
<span />
<span className="footer-count">{getRootSelections(selectedPaths).length} ausgewählt</span>
</div>
)}
@@ -618,6 +833,40 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken,
{/* ── Dialoge ─────────────────────────────────────────────────────────── */}
{/* Aus Job entfernen */}
<Dialog
header="Aus Job entfernen?"
visible={activeModal === 'job-unassign'}
onHide={() => { setActiveModal(''); setJobUnassignTarget(null); }}
style={{ width: '420px' }}
footer={(
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button
label="Abbrechen"
outlined
onClick={() => { setActiveModal(''); setJobUnassignTarget(null); }}
disabled={busy}
/>
<Button
label="Entfernen"
severity="danger"
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-times'}
disabled={busy}
onClick={handleRemoveFromJob}
/>
</div>
)}
modal
>
<p style={{ margin: 0, lineHeight: 1.5 }}>
Soll die Datei aus dem Job&nbsp;
<strong>
{jobUnassignTarget?.jobTitle || (jobUnassignTarget?.jobId ? `#${jobUnassignTarget.jobId}` : '-')}
</strong>
&nbsp;entfernt werden?
</p>
</Dialog>
{/* Neuer Ordner */}
<Dialog
header="Neuen Ordner erstellen"
File diff suppressed because it is too large Load Diff
+374 -115
View File
@@ -300,6 +300,18 @@ function resolveMediaType(job) {
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
if (raw === 'converter') {
return 'converter';
}
}
if (String(encodePlan?.mediaProfile || '').trim().toLowerCase() === 'converter') {
return 'converter';
}
if (String(job?.media_type || '').trim().toLowerCase() === 'converter') {
return 'converter';
}
if (resolveConverterMediaType(job)) {
return 'converter';
}
const statusCandidates = [job?.status, job?.last_state, job?.makemkvInfo?.lastState];
if (statusCandidates.some((v) => String(v || '').trim().toUpperCase().startsWith('CD_'))) {
@@ -325,6 +337,26 @@ function resolveMediaType(job) {
return 'other';
}
function resolveConverterMediaType(job) {
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const candidates = [
encodePlan?.converterMediaType,
job?.converterMediaType,
job?.makemkvInfo?.converterMediaType,
job?.mediainfoInfo?.converterMediaType
];
for (const candidate of candidates) {
const raw = String(candidate || '').trim().toLowerCase();
if (!raw) {
continue;
}
if (raw === 'audio' || raw === 'video' || raw === 'iso') {
return raw;
}
}
return null;
}
function resolveCdDetails(job) {
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
@@ -595,10 +627,12 @@ export default function JobDetailDialog({
onDownloadArchive,
onDownloadOutputFolder,
onRemoveFromQueue,
onCancel,
isQueued = false,
omdbAssignBusy = false,
cdMetadataAssignBusy = false,
actionBusy = false,
cancelBusy = false,
reencodeBusy = false,
deleteEntryBusy = false,
downloadBusyTarget = null,
@@ -609,11 +643,19 @@ export default function JobDetailDialog({
const isUnencodedOrphanImport = Boolean(
job?.makemkvInfo?.source === 'orphan_raw_import' && !job?.handbrakeInfo
);
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(job?.status);
const statusUpper = String(job?.status || '').trim().toUpperCase();
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(statusUpper);
const softCancelable = ['READY_TO_START', 'READY_TO_ENCODE', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP'].includes(statusUpper);
const showCancelAction = (running || softCancelable) && typeof onCancel === 'function';
const showFinalLog = !running;
const mediaType = resolveMediaType(job);
const isCd = mediaType === 'cd';
const isAudiobook = mediaType === 'audiobook';
const isConverter = mediaType === 'converter';
const converterMediaType = isConverter ? (resolveConverterMediaType(job) || 'video') : null;
const converterMediaTypeLabel = converterMediaType === 'audio'
? 'Audio'
: (converterMediaType === 'iso' ? 'ISO (Video)' : 'Video');
const cdRawPathCandidates = [
job?.raw_path,
job?.makemkvInfo?.rawPath,
@@ -704,6 +746,62 @@ export default function JobDetailDialog({
&& mediaType !== 'audiobook'
&& typeof onRestartReview === 'function'
);
const converterPlan = isConverter && job?.encodePlan && typeof job.encodePlan === 'object'
? job.encodePlan
: {};
const converterMetadata = isConverter && converterPlan?.metadata && typeof converterPlan.metadata === 'object'
? converterPlan.metadata
: {};
const converterInputPaths = isConverter
? (
Array.isArray(converterPlan?.inputPaths) && converterPlan.inputPaths.length > 0
? converterPlan.inputPaths
: [converterPlan?.inputPath || job?.raw_path]
)
.map((value) => String(value || '').trim())
.filter(Boolean)
: [];
const converterTrackList = isConverter && Array.isArray(converterPlan?.tracks)
? converterPlan.tracks
: [];
const converterOutputFormat = String(
converterPlan?.outputFormat
|| job?.handbrakeInfo?.format
|| converterPlan?.format
|| ''
).trim().toLowerCase();
const converterPresetLabel = String(
converterPlan?.userPreset?.name
|| converterPlan?.userPreset?.handbrakePreset
|| ''
).trim() || null;
const converterAudioQualityLabel = isConverter && converterMediaType === 'audio'
? (() => {
const opts = converterPlan?.audioFormatOptions && typeof converterPlan.audioFormatOptions === 'object'
? converterPlan.audioFormatOptions
: {};
if (converterOutputFormat === 'flac') {
return `Kompression ${Number(opts?.flacCompression ?? 5)}`;
}
if (converterOutputFormat === 'mp3') {
const mode = String(opts?.mp3Mode || 'cbr').trim().toLowerCase();
if (mode === 'vbr') {
return `VBR V${Number(opts?.mp3Quality ?? 4)}`;
}
return `CBR ${Number(opts?.mp3Bitrate ?? 192)} kbps`;
}
if (converterOutputFormat === 'aac') {
return `${Number(opts?.aacBitrate ?? 256)} kbps`;
}
if (converterOutputFormat === 'opus') {
return `${Number(opts?.opusBitrate ?? 160)} kbps`;
}
if (converterOutputFormat === 'ogg') {
return `Qualität ${Number(opts?.oggQuality ?? 6)}`;
}
return converterOutputFormat ? converterOutputFormat.toUpperCase() : '-';
})()
: null;
const canDeleteEntry = !running && typeof onDeleteEntry === 'function';
const queueLocked = Boolean(isQueued && job?.id);
const logCount = Number(job?.log_count || 0);
@@ -719,7 +817,7 @@ export default function JobDetailDialog({
? 'DVD'
: isCd
? 'Audio CD'
: (isAudiobook ? 'Audiobook' : 'Sonstiges Medium');
: (isAudiobook ? 'Audiobook' : (isConverter ? `Converter ${converterMediaTypeLabel}` : 'Sonstiges Medium'));
const mediaTypeIcon = mediaType === 'bluray'
? blurayIndicatorIcon
: mediaType === 'dvd'
@@ -742,6 +840,11 @@ export default function JobDetailDialog({
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
const hasAnyOutputFolder = outputFolders.length > 0 || Boolean(job?.outputStatus?.exists);
const canShowGeneralEncodeActions = canResumeReady
|| typeof onRestartEncode === 'function'
|| typeof onRestartReview === 'function'
|| typeof onReencode === 'function';
const useAudioPosterLayout = isCd || isAudiobook || (isConverter && converterMediaType === 'audio');
return (
<Dialog
@@ -759,9 +862,9 @@ export default function JobDetailDialog({
<div className="job-head-row">
{job.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={job.title || 'Poster'} className={isCd || isAudiobook ? 'poster-large-audio' : 'poster-large'} />
<img src={job.poster_url} alt={job.title || 'Poster'} className={useAudioPosterLayout ? 'poster-large-audio' : 'poster-large'} />
) : (
<div className={`${isCd || isAudiobook ? 'poster-large-audio' : 'poster-large'} poster-fallback`}>{isCd || isAudiobook ? 'Kein Cover' : 'Kein Poster'}</div>
<div className={`${useAudioPosterLayout ? 'poster-large-audio' : 'poster-large'} poster-fallback`}>{useAudioPosterLayout ? 'Kein Cover' : 'Kein Poster'}</div>
)}
<div className="job-film-info-grid">
@@ -812,6 +915,70 @@ export default function JobDetailDialog({
</div>
</div>
</section>
) : isConverter ? (
<section className="job-meta-block job-meta-block-film">
<h4>{converterMediaType === 'audio' ? 'Converter Audio' : 'Converter Video'}</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Titel:</strong>
<span>{job.title || converterMetadata?.albumTitle || (job?.id ? `Job #${job.id}` : '-')}</span>
</div>
<div className="job-meta-item">
<strong>Typ:</strong>
<span>{converterMediaTypeLabel}</span>
</div>
<div className="job-meta-item">
<strong>Eingaben:</strong>
<span>{converterInputPaths.length > 0 ? `${converterInputPaths.length} Datei${converterInputPaths.length !== 1 ? 'en' : ''}` : '-'}</span>
</div>
<div className="job-meta-item">
<strong>Format:</strong>
<span>{converterOutputFormat ? converterOutputFormat.toUpperCase() : '-'}</span>
</div>
{converterMediaType === 'audio' ? (
<>
<div className="job-meta-item">
<strong>Album:</strong>
<span>{converterMetadata?.albumTitle || job?.title || (job?.id ? `Job #${job.id}` : '-')}</span>
</div>
<div className="job-meta-item">
<strong>Interpret:</strong>
<span>{converterMetadata?.albumArtist || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Jahr:</strong>
<span>{converterMetadata?.albumYear || job?.year || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Tracks:</strong>
<span>{converterTrackList.length > 0 ? converterTrackList.length : converterInputPaths.length || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Qualität:</strong>
<span>{converterAudioQualityLabel || '-'}</span>
</div>
</>
) : (
<>
<div className="job-meta-item">
<strong>Preset:</strong>
<span>{converterPresetLabel || '-'}</span>
</div>
<div className="job-meta-item">
<strong>HandBrake Titel:</strong>
<span>{converterPlan?.handBrakeTitleId || '-'}</span>
</div>
</>
)}
<div className="job-meta-item">
<strong>Medium:</strong>
<span className="job-step-cell">
<img src={mediaTypeIcon} alt={mediaTypeAlt} title={mediaTypeLabel} className="media-indicator-icon" />
<span>{mediaTypeLabel}</span>
</span>
</div>
</div>
</section>
) : (
<>
<section className="job-meta-block job-meta-block-film">
@@ -937,7 +1104,7 @@ export default function JobDetailDialog({
</>
) : (
<>
<span><strong>{isAudiobook ? 'Import:' : 'Backup:'}</strong> <BoolState value={job?.backupSuccess} /></span>
<span><strong>{isAudiobook || isConverter ? 'Import:' : 'Backup:'}</strong> <BoolState value={job?.backupSuccess} /></span>
<span className="job-infos-sep">|</span>
<span><strong>Encode:</strong> <BoolState value={job?.encodeSuccess} /></span>
</>
@@ -950,7 +1117,7 @@ export default function JobDetailDialog({
<div><strong>Ende:</strong> {job.end_time || '-'}</div>
{/* Zeile 3+4: Pfade */}
<PathField
label={isCd ? 'WAV:' : 'RAW:'}
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
value={isCd ? (job.raw_path || job.output_path) : job.raw_path}
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
downloadDisabled={!canDownloadRaw}
@@ -990,7 +1157,72 @@ export default function JobDetailDialog({
</div>
</section>
{!isCd && !isAudiobook && (hasConfiguredSelection || encodePlanUserPreset || job.encodePlan?.minLengthMinutes != null || Array.isArray(job.encodePlan?.titles)) ? (
{isConverter ? (
<section className="job-meta-block job-meta-block-full">
<h4>Encode-Konfiguration</h4>
<div className="job-meta-grid job-meta-grid-compact">
<div>
<strong>Typ:</strong> {converterMediaTypeLabel}
</div>
<div>
<strong>Format:</strong> {converterOutputFormat ? converterOutputFormat.toUpperCase() : '-'}
</div>
<div>
<strong>Eingabe-Modus:</strong> {converterPlan?.isFolder ? 'Ordner' : (converterPlan?.isSharedAudio ? 'Gemeinsam' : 'Einzeln')}
</div>
<div>
<strong>Eingabe-Dateien:</strong> {converterInputPaths.length > 0 ? converterInputPaths.length : '-'}
</div>
<div>
<strong>Preset:</strong> {converterPresetLabel || '-'}
</div>
<div>
<strong>HandBrake Titel:</strong> {converterPlan?.handBrakeTitleId || '-'}
</div>
</div>
{converterMediaType === 'audio' ? (
<div className="job-meta-grid job-meta-grid-compact">
<div>
<strong>Album:</strong> {converterMetadata?.albumTitle || job?.title || (job?.id ? `Job #${job.id}` : '-')}
</div>
<div>
<strong>Interpret:</strong> {converterMetadata?.albumArtist || '-'}
</div>
<div>
<strong>Jahr:</strong> {converterMetadata?.albumYear || job?.year || '-'}
</div>
<div>
<strong>Qualität:</strong> {converterAudioQualityLabel || '-'}
</div>
</div>
) : null}
{converterInputPaths.length > 0 ? (
<div className="track-group">
{converterInputPaths.map((inputPath, index) => (
<div key={`${inputPath}-${index}`} className="track-item">
<span>#{index + 1} | {inputPath}</span>
</div>
))}
</div>
) : null}
{converterMediaType === 'audio' && converterTrackList.length > 0 ? (
<div className="track-group">
{converterTrackList.map((track, index) => {
const position = Number(track?.position) > 0 ? Math.trunc(Number(track.position)) : (index + 1);
const title = String(track?.title || '').trim() || `Track ${position}`;
const artist = String(track?.artist || '').trim();
return (
<div key={`${position}-${title}`} className="track-item">
<span>#{position} | {artist ? `${artist} - ` : ''}{title}</span>
</div>
);
})}
</div>
) : null}
</section>
) : null}
{!isCd && !isAudiobook && !isConverter && (hasConfiguredSelection || encodePlanUserPreset || job.encodePlan?.minLengthMinutes != null || Array.isArray(job.encodePlan?.titles)) ? (
<section className="job-meta-block job-meta-block-full">
<h4>Encode-Konfiguration</h4>
{/* Zeile 1: Preset + Mindestlaufzeit */}
@@ -1193,13 +1425,13 @@ export default function JobDetailDialog({
<section className="job-meta-block job-meta-block-full">
<h4>Logs</h4>
<div className="job-json-grid">
{!isCd && !isAudiobook ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
{!isCd && !isAudiobook && !isConverter ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
{isCd ? <JsonView title="MusicBrainz" value={job.makemkvInfo?.selectedMetadata ?? null} /> : null}
{isAudiobook ? <JsonView title="Metadaten" value={job.handbrakeInfo?.metadata ?? job.makemkvInfo?.selectedMetadata ?? null} /> : null}
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : 'MakeMKV Info')} value={job.makemkvInfo} />
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={job.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : 'Encode Plan'} value={job.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : 'HandBrake Info')} value={job.handbrakeInfo} />
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={job.handbrakeInfo} />
</div>
</section>
@@ -1257,6 +1489,27 @@ export default function JobDetailDialog({
</div>
) : (
<div className="actions-section">
{showCancelAction ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-ban" /> {running ? 'Laufender Job' : 'Wartender Job'}</div>
<div className="action-item">
<Button
label="Job abbrechen"
icon="pi pi-times"
severity="warning"
size="small"
onClick={() => onCancel?.(job)}
loading={cancelBusy}
/>
<span className="action-desc">
{running
? 'Bricht den aktuell laufenden Job sofort ab.'
: `Bricht den wartenden Job im Status ${statusUpper || '-'} ab.`}
</span>
</div>
</div>
) : null}
{isCd ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Audio CD</div>
@@ -1304,73 +1557,77 @@ export default function JobDetailDialog({
</div>
</div>
) : (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Kodierung</div>
{canResumeReady ? (
<div className="action-item">
<Button
label="Im Dashboard öffnen"
icon="pi pi-window-maximize"
severity="info"
outlined
size="small"
onClick={() => onResumeReady?.(job)}
loading={actionBusy}
/>
<span className="action-desc">Öffnet den wartenden Job im Dashboard zur Weiterverarbeitung.</span>
</div>
) : null}
{typeof onRestartEncode === 'function' ? (
<div className="action-item">
<Button
label="Encode neu starten"
icon="pi pi-play"
severity="success"
size="small"
onClick={() => onRestartEncode?.(job)}
loading={actionBusy}
disabled={!canRestartEncode}
/>
<span className="action-desc">Startet {isAudiobook ? 'FFmpeg' : 'HandBrake'} mit den zuletzt gespeicherten Einstellungen direkt neu ohne Review.</span>
</div>
) : null}
{typeof onRestartReview === 'function' ? (
<div className="action-item">
<Button
label={isAudiobook ? 'Kapitel-Auswahl öffnen' : 'Spur-Auswahl öffnen'}
icon="pi pi-list"
severity="info"
outlined
size="small"
onClick={() => onRestartReview?.(job)}
loading={actionBusy}
disabled={!canRestartReview || isUnencodedOrphanImport}
/>
<span className="action-desc">{isAudiobook
? 'Öffnet die Kapitel-Auswahl erneut, um Kapitel anzupassen bevor der Encode startet.'
: 'Öffnet die Spur- und Preset-Auswahl erneut, um Einstellungen vor dem Encode zu ändern.'
}</span>
</div>
) : null}
<div className="action-item">
<Button
label="Neustart"
icon="pi pi-sync"
severity="info"
size="small"
onClick={() => isUnencodedOrphanImport ? onRestartReview?.(job) : onReencode?.(job)}
loading={isUnencodedOrphanImport ? actionBusy : reencodeBusy}
disabled={isUnencodedOrphanImport ? !canRestartReview : (!canReencode || typeof onReencode !== 'function')}
/>
<span className="action-desc">{isAudiobook
? 'Liest die AAX-Datei neu ein und öffnet danach die Kapitel-Auswahl. Sinnvoll wenn sich die Quelldatei geändert hat.'
: 'Analysiert die Quelldatei neu (MediaInfo, Playlist-Check) und öffnet dann die Spur-Auswahl. Unterschied zu „Spur-Auswahl öffnen": der Analyse-Schritt wird komplett wiederholt.'
}</span>
canShowGeneralEncodeActions ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Kodierung</div>
{canResumeReady ? (
<div className="action-item">
<Button
label="Im Dashboard öffnen"
icon="pi pi-window-maximize"
severity="info"
outlined
size="small"
onClick={() => onResumeReady?.(job)}
loading={actionBusy}
/>
<span className="action-desc">Öffnet den wartenden Job im Dashboard zur Weiterverarbeitung.</span>
</div>
) : null}
{typeof onRestartEncode === 'function' ? (
<div className="action-item">
<Button
label="Encode neu starten"
icon="pi pi-play"
severity="success"
size="small"
onClick={() => onRestartEncode?.(job)}
loading={actionBusy}
disabled={!canRestartEncode}
/>
<span className="action-desc">Startet {isAudiobook ? 'FFmpeg' : 'HandBrake'} mit den zuletzt gespeicherten Einstellungen direkt neu ohne Review.</span>
</div>
) : null}
{typeof onRestartReview === 'function' ? (
<div className="action-item">
<Button
label={isAudiobook ? 'Kapitel-Auswahl öffnen' : 'Spur-Auswahl öffnen'}
icon="pi pi-list"
severity="info"
outlined
size="small"
onClick={() => onRestartReview?.(job)}
loading={actionBusy}
disabled={!canRestartReview || isUnencodedOrphanImport}
/>
<span className="action-desc">{isAudiobook
? 'Öffnet die Kapitel-Auswahl erneut, um Kapitel anzupassen bevor der Encode startet.'
: 'Öffnet die Spur- und Preset-Auswahl erneut, um Einstellungen vor dem Encode zu ändern.'
}</span>
</div>
) : null}
{typeof onReencode === 'function' ? (
<div className="action-item">
<Button
label="Neustart"
icon="pi pi-sync"
severity="info"
size="small"
onClick={() => isUnencodedOrphanImport ? onRestartReview?.(job) : onReencode?.(job)}
loading={isUnencodedOrphanImport ? actionBusy : reencodeBusy}
disabled={isUnencodedOrphanImport ? !canRestartReview : !canReencode}
/>
<span className="action-desc">{isAudiobook
? 'Liest die AAX-Datei neu ein und öffnet danach die Kapitel-Auswahl. Sinnvoll wenn sich die Quelldatei geändert hat.'
: 'Analysiert die Quelldatei neu (MediaInfo, Playlist-Check) und öffnet dann die Spur-Auswahl. Unterschied zu „Spur-Auswahl öffnen": der Analyse-Schritt wird komplett wiederholt.'
}</span>
</div>
) : null}
</div>
</div>
) : null
)}
{!isCd && !isAudiobook && typeof onAssignOmdb === 'function' ? (
{!isCd && !isAudiobook && !isConverter && typeof onAssignOmdb === 'function' ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
<div className="action-item">
@@ -1389,47 +1646,49 @@ export default function JobDetailDialog({
</div>
) : null}
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-trash" /> Dateien löschen</div>
<div className="action-item">
<Button
label="RAW löschen"
icon="pi pi-trash"
severity="warning"
outlined
size="small"
onClick={() => onDeleteFiles?.(job, 'raw')}
loading={actionBusy}
disabled={!job.rawStatus?.exists || typeof onDeleteFiles !== 'function'}
/>
<span className="action-desc">Löscht die Quelldateien nach dem Rip ({isCd ? 'Audio-Rohdaten' : isAudiobook ? 'AAX/MP3-Quelldatei' : 'MKV-Datei von MakeMKV'}).</span>
{typeof onDeleteFiles === 'function' ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-trash" /> Dateien löschen</div>
<div className="action-item">
<Button
label="RAW löschen"
icon="pi pi-trash"
severity="warning"
outlined
size="small"
onClick={() => onDeleteFiles?.(job, 'raw')}
loading={actionBusy}
disabled={!job.rawStatus?.exists}
/>
<span className="action-desc">Löscht die Quelldateien nach dem Rip ({isCd ? 'Audio-Rohdaten' : isAudiobook ? 'AAX/MP3-Quelldatei' : isConverter ? 'Quelldatei aus dem Converter-Import' : 'MKV-Datei von MakeMKV'}).</span>
</div>
<div className="action-item">
<Button
label={isCd ? 'Audio löschen' : (isAudiobook ? 'Ausgabe löschen' : (isConverter ? 'Output löschen' : 'Movie löschen'))}
icon="pi pi-trash"
severity="warning"
outlined
size="small"
onClick={() => onDeleteFiles?.(job, 'movie')}
loading={actionBusy}
disabled={!hasAnyOutputFolder}
/>
<span className="action-desc">Löscht {isCd ? 'die fertig gerippten Audiodateien' : isAudiobook ? 'die fertig konvertierte Audiobook-Ausgabe' : isConverter ? 'die fertig konvertierte Ausgabe' : 'die fertig encodierte Filmdatei'}.</span>
</div>
<div className="action-item">
<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}
/>
<span className="action-desc">Löscht RAW-Quelldateien und Ausgabe gemeinsam.</span>
</div>
</div>
<div className="action-item">
<Button
label={isCd ? 'Audio löschen' : (isAudiobook ? 'Ausgabe löschen' : 'Movie löschen')}
icon="pi pi-trash"
severity="warning"
outlined
size="small"
onClick={() => onDeleteFiles?.(job, 'movie')}
loading={actionBusy}
disabled={!hasAnyOutputFolder || typeof onDeleteFiles !== 'function'}
/>
<span className="action-desc">Löscht {isCd ? 'die fertig gerippten Audiodateien' : isAudiobook ? 'die fertig konvertierte Audiobook-Ausgabe' : 'die fertig encodierte Filmdatei'}.</span>
</div>
<div className="action-item">
<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) || typeof onDeleteFiles !== 'function'}
/>
<span className="action-desc">Löscht RAW-Quelldateien und Ausgabe gemeinsam.</span>
</div>
</div>
) : null}
</div>
)}
+492 -54
View File
@@ -65,21 +65,246 @@ function isBurnedSubtitleTrack(track) {
);
}
function isForcedOnlySubtitleTrack(track) {
const summary = `${track?.title || ''} ${track?.description || ''} ${track?.languageLabel || ''}`.toLowerCase();
return Boolean(
track?.forcedTrack
|| /forced only/.test(summary)
|| /nur erzwungen/.test(summary)
|| /\berzwungen\b/.test(summary)
);
function isDuplicateSubtitleTrack(track) {
return Boolean(track?.duplicate);
}
function hasForcedSubtitleAvailable(track) {
const sourceTrackIds = normalizeTrackIdList(
Array.isArray(track?.forcedSourceTrackIds) ? track.forcedSourceTrackIds : []
function normalizeTrackIdSequence(values, options = {}) {
const list = Array.isArray(values) ? values : [];
const dedupe = options?.dedupe !== false;
const seen = new Set();
const output = [];
for (const value of list) {
const normalized = normalizeTrackId(value);
if (normalized === null) {
continue;
}
const key = String(normalized);
if (dedupe && seen.has(key)) {
continue;
}
if (dedupe) {
seen.add(key);
}
output.push(normalized);
}
return output;
}
function normalizeSubtitleLanguage(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return 'und';
}
if (raw.length >= 3) {
return raw.slice(0, 3);
}
return raw;
}
function normalizeSubtitleLanguageOrder(rawOrder = []) {
const list = Array.isArray(rawOrder) ? rawOrder : [];
const seen = new Set();
const output = [];
for (const item of list) {
const language = normalizeSubtitleLanguage(item);
if (!language || seen.has(language)) {
continue;
}
seen.add(language);
output.push(language);
}
return output;
}
function normalizeSubtitleVariantSelectionMap(rawSelection) {
const map = {};
if (!rawSelection || typeof rawSelection !== 'object') {
return map;
}
for (const [language, value] of Object.entries(rawSelection)) {
const normalizedLanguage = normalizeSubtitleLanguage(language);
map[normalizedLanguage] = {
full: Boolean(value?.full),
forced: Boolean(value?.forced)
};
}
return map;
}
function normalizeSubtitleConfidenceValue(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'high' || raw === 'medium' || raw === 'low') {
return raw;
}
return 'low';
}
function subtitleConfidenceScore(value) {
const normalized = normalizeSubtitleConfidenceValue(value);
if (normalized === 'high') {
return 3;
}
if (normalized === 'medium') {
return 2;
}
return 1;
}
function resolveSubtitleTrackVariantFlags(track) {
const subtitleType = String(track?.subtitleType || '').trim().toLowerCase();
const isForcedOnly = Boolean(
track?.isForcedOnly
?? track?.subtitlePreviewForcedOnly
?? track?.forcedOnly
?? subtitleType === 'forced'
);
return Boolean(track?.forcedAvailable || sourceTrackIds.length > 0);
const fullHasForced = !isForcedOnly && Boolean(
track?.fullHasForced
?? track?.subtitleFullHasForced
?? track?.hasForcedVariant
);
return { isForcedOnly, fullHasForced };
}
function compareForcedSubtitleCandidate(a, b) {
const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
if (defaultDiff !== 0) {
return defaultDiff;
}
const confidenceDiff = subtitleConfidenceScore(b?.confidence) - subtitleConfidenceScore(a?.confidence);
if (confidenceDiff !== 0) {
return confidenceDiff;
}
if (a.id !== b.id) {
return a.id - b.id;
}
return a.originalIndex - b.originalIndex;
}
function compareFullSubtitleCandidate(a, b) {
const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
if (defaultDiff !== 0) {
return defaultDiff;
}
if (a.id !== b.id) {
return a.id - b.id;
}
return a.originalIndex - b.originalIndex;
}
function resolveDeterministicSubtitleCliSelectionForPreview({
subtitleTracks,
subtitleVariantSelection,
subtitleLanguageOrder
}) {
const tracks = (Array.isArray(subtitleTracks) ? subtitleTracks : [])
.map((track, index) => {
const id = normalizeTrackId(track?.id ?? track?.sourceTrackId);
if (id === null) {
return null;
}
if (isBurnedSubtitleTrack(track) || isDuplicateSubtitleTrack(track)) {
return null;
}
const flags = resolveSubtitleTrackVariantFlags(track);
return {
id,
language: normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und'),
isForcedOnly: flags.isForcedOnly,
fullHasForced: flags.fullHasForced,
defaultFlag: Boolean(track?.defaultFlag ?? track?.subtitlePreviewDefaultTrack ?? track?.defaultTrack),
confidence: normalizeSubtitleConfidenceValue(track?.sourceConfidence || track?.confidence),
selectedForEncode: Boolean(track?.selectedForEncode),
originalIndex: index
};
})
.filter(Boolean);
const grouped = new Map();
for (const track of tracks) {
if (!grouped.has(track.language)) {
grouped.set(track.language, []);
}
grouped.get(track.language).push(track);
}
const normalizedVariantSelection = normalizeSubtitleVariantSelectionMap(subtitleVariantSelection || {});
const hasExplicitVariantSelection = Object.keys(normalizedVariantSelection).length > 0;
const normalizedLanguageOrder = normalizeSubtitleLanguageOrder(subtitleLanguageOrder || []);
const sortedLanguages = Array.from(grouped.entries())
.map(([language, languageTracks]) => ({
language,
minId: languageTracks.reduce((minValue, track) => Math.min(minValue, track.id), Number.MAX_SAFE_INTEGER)
}))
.sort((a, b) => a.minId - b.minId || a.language.localeCompare(b.language))
.map((item) => item.language);
const order = [];
const seen = new Set();
const pushLanguage = (language) => {
if (!grouped.has(language) || seen.has(language)) {
return;
}
seen.add(language);
order.push(language);
};
normalizedLanguageOrder.forEach(pushLanguage);
sortedLanguages.forEach(pushLanguage);
const subtitleTrackIds = [];
const subtitleForcedTrackIndexes = [];
for (const language of order) {
const languageTracks = grouped.get(language) || [];
const forcedOnly = languageTracks.filter((track) => track.isForcedOnly).sort(compareForcedSubtitleCandidate)[0] || null;
const fullTracks = languageTracks.filter((track) => !track.isForcedOnly).sort(compareFullSubtitleCandidate);
const bestFull = fullTracks[0] || null;
const bestFullHasForced = fullTracks.filter((track) => track.fullHasForced).sort(compareFullSubtitleCandidate)[0] || null;
const explicit = normalizedVariantSelection[language] || null;
const fallbackFull = languageTracks.some((track) => track.selectedForEncode && !track.isForcedOnly) || Boolean(bestFull);
const fallbackForced = languageTracks.some((track) => track.selectedForEncode && track.isForcedOnly) || Boolean(forcedOnly);
const requestedFull = explicit
? Boolean(explicit.full)
: (hasExplicitVariantSelection ? false : fallbackFull);
const requestedForced = explicit
? Boolean(explicit.forced)
: (hasExplicitVariantSelection ? false : fallbackForced);
if (!requestedFull && !requestedForced) {
continue;
}
if (forcedOnly) {
subtitleTrackIds.push(forcedOnly.id);
if (requestedFull && bestFull) {
subtitleTrackIds.push(bestFull.id);
}
continue;
}
if (bestFullHasForced) {
if (requestedFull && requestedForced) {
subtitleTrackIds.push(bestFullHasForced.id);
subtitleTrackIds.push(bestFullHasForced.id);
subtitleForcedTrackIndexes.push(subtitleTrackIds.length);
} else if (requestedForced) {
subtitleTrackIds.push(bestFullHasForced.id);
subtitleForcedTrackIndexes.push(subtitleTrackIds.length);
} else if (requestedFull) {
subtitleTrackIds.push(bestFullHasForced.id);
}
continue;
}
if (requestedFull && bestFull) {
subtitleTrackIds.push(bestFull.id);
}
}
return {
subtitleTrackIds,
subtitleForcedTrackIndexes
};
}
function splitArgs(input) {
@@ -191,6 +416,8 @@ function buildHandBrakeCommandPreview({
title,
selectedAudioTrackIds,
selectedSubtitleTrackIds,
selectedSubtitleVariantSelection = {},
selectedSubtitleLanguageOrder = [],
commandOutputPath = null,
presetOverride = null
}) {
@@ -211,7 +438,19 @@ function buildHandBrakeCommandPreview({
? Math.trunc(rawMappedTitleId)
: null;
const selectedSubtitleSet = new Set(normalizeTrackIdList(selectedSubtitleTrackIds).map((id) => String(id)));
const resolvedSubtitleSelection = resolveDeterministicSubtitleCliSelectionForPreview({
subtitleTracks: Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [],
subtitleVariantSelection: selectedSubtitleVariantSelection,
subtitleLanguageOrder: selectedSubtitleLanguageOrder
});
const normalizedSelectedVariantSelection = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {});
const hasExplicitSelectedVariantSelection = Object.keys(normalizedSelectedVariantSelection).length > 0;
const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIds.length > 0
? resolvedSubtitleSelection.subtitleTrackIds
: (hasExplicitSelectedVariantSelection
? []
: normalizeTrackIdSequence(selectedSubtitleTrackIds, { dedupe: false }));
const selectedSubtitleSet = new Set(normalizeTrackIdList(subtitleTrackIds).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));
@@ -223,10 +462,6 @@ function buildHandBrakeCommandPreview({
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',
@@ -248,7 +483,7 @@ function buildHandBrakeCommandPreview({
'-a',
normalizeTrackIdList(selectedAudioTrackIds).join(',') || 'none',
'-s',
normalizeTrackIdList(selectedSubtitleTrackIds).join(',') || 'none'
subtitleTrackIds.length > 0 ? subtitleTrackIds.join(',') : 'none'
];
if (subtitleBurnTrackId !== null) {
@@ -257,10 +492,8 @@ function buildHandBrakeCommandPreview({
if (subtitleDefaultTrackId !== null) {
overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`);
}
if (subtitleForcedTrackId !== null) {
overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`);
} else if (subtitleForcedOnly) {
overrideArgs.push('--subtitle-forced');
if (resolvedSubtitleSelection.subtitleForcedTrackIndexes.length > 0) {
overrideArgs.push(`--subtitle-forced=${resolvedSubtitleSelection.subtitleForcedTrackIndexes.join(',')}`);
}
const finalArgs = [...baseArgs, ...filteredExtra, ...overrideArgs];
@@ -637,11 +870,191 @@ function TrackList({
type = 'generic',
allowSelection = false,
selectedTrackIds = [],
selectedSubtitleVariantSelection = {},
selectedSubtitleLanguageOrder = [],
onToggleTrack = null,
onToggleSubtitleVariant = null,
audioSelector = null
}) {
const orderedTracks = (Array.isArray(tracks) ? [...tracks] : [])
.sort((a, b) => {
const aId = normalizeTrackId(a?.id) ?? Number.MAX_SAFE_INTEGER;
const bId = normalizeTrackId(b?.id) ?? Number.MAX_SAFE_INTEGER;
if (aId !== bId) {
return aId - bId;
}
return String(a?.language || '').localeCompare(String(b?.language || ''));
});
if (type === 'subtitle') {
const selectedVariants = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {});
const sortedSubtitleTracks = orderedTracks
.filter((track) => !isDuplicateSubtitleTrack(track));
const renderVariantRow = ({
key,
checked,
disabled,
onChange,
text,
confidence = null,
indent = false
}) => {
const confidenceColor = confidence === 'high'
? '#2e7d32'
: confidence === 'medium'
? '#f57c00'
: '#c62828';
return (
<div key={key} className="media-track-item" style={indent ? { paddingLeft: '1.4rem' } : null}>
<label className="readonly-check-row">
<input
type="checkbox"
checked={checked}
onChange={onChange}
readOnly={disabled}
disabled={disabled}
/>
<span>
{text}
{confidence ? <span style={{ color: confidenceColor, marginLeft: '0.35rem' }}></span> : null}
</span>
</label>
</div>
);
};
const resolveSubtitleActionInfo = (track, selected) => {
const base = String(track?.subtitlePreviewSummary || track?.subtitleActionSummary || '').trim();
if (allowSelection) {
return selected ? 'Übernehmen' : 'Nicht übernommen';
}
return base || (selected ? 'Übernehmen' : 'Nicht übernommen');
};
return (
<div>
<h4>{title}</h4>
{!tracks || tracks.length === 0 ? (
<p>Keine Einträge.</p>
) : (
<div className="media-track-list">
{sortedSubtitleTracks.map((track) => {
const burned = isBurnedSubtitleTrack(track);
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
const displayLanguage = toLang2(track?.language || track?.languageLabel || 'und');
const displayCodec = simplifyCodec('subtitle', track?.format, track?.description || track?.title);
const flags = resolveSubtitleTrackVariantFlags(track);
const confidenceRaw = String(track?.sourceConfidence || '').trim().toLowerCase();
const confidence = confidenceRaw === 'high' || confidenceRaw === 'medium' || confidenceRaw === 'low'
? confidenceRaw
: 'low';
const languageSelection = selectedVariants[language] || { full: false, forced: false };
if (flags.isForcedOnly) {
const checked = allowSelection ? Boolean(languageSelection.forced) : Boolean(track?.selectedForEncode);
const disabled = !allowSelection || burned;
const actionInfo = resolveSubtitleActionInfo(track, checked);
return (
<div key={`${title}-${track.id}-forced-only-group`} className="media-track-item">
{renderVariantRow({
key: `${title}-${track.id}-forced-only`,
checked,
disabled,
onChange: (event) => {
if (disabled || typeof onToggleSubtitleVariant !== 'function') {
return;
}
onToggleSubtitleVariant(language, 'forced', event.target.checked);
},
text: `#${track.id} | ${displayLanguage} | Automatische Übersetzungen (${displayCodec})`,
confidence
})}
<small className="track-action-note">Untertitel: {actionInfo}</small>
</div>
);
}
if (flags.fullHasForced) {
const checkedFull = allowSelection ? Boolean(languageSelection.full) : Boolean(track?.selectedForEncode);
const checkedForced = allowSelection ? Boolean(languageSelection.forced) : false;
const disabled = !allowSelection || burned;
const actionInfo = resolveSubtitleActionInfo(track, checkedFull || checkedForced);
return (
<div key={`${title}-${track.id}-combo`} className="media-track-item">
<div className="media-track-item">
<small className="track-action-note">{`#${track.id} | ${displayLanguage}`}</small>
</div>
{renderVariantRow({
key: `${title}-${track.id}-combo-full`,
checked: checkedFull,
disabled,
onChange: (event) => {
if (disabled || typeof onToggleSubtitleVariant !== 'function') {
return;
}
onToggleSubtitleVariant(language, 'full', event.target.checked);
},
text: `Komplette Untertitel (${displayCodec})`,
indent: true
})}
{renderVariantRow({
key: `${title}-${track.id}-combo-forced`,
checked: checkedForced,
disabled,
onChange: (event) => {
if (disabled || typeof onToggleSubtitleVariant !== 'function') {
return;
}
onToggleSubtitleVariant(language, 'forced', event.target.checked);
},
text: `Automatische Übersetzungen (${displayCodec})`,
confidence,
indent: true
})}
<small className="track-action-note">Untertitel: {actionInfo}</small>
</div>
);
}
const checked = allowSelection ? Boolean(languageSelection.full) : Boolean(track?.selectedForEncode);
const disabled = !allowSelection || burned;
const actionInfo = resolveSubtitleActionInfo(track, checked);
return (
<div key={`${title}-${track.id}-full-group`} className="media-track-item">
{renderVariantRow({
key: `${title}-${track.id}-full`,
checked,
disabled,
onChange: (event) => {
if (disabled || typeof onToggleSubtitleVariant !== 'function') {
return;
}
onToggleSubtitleVariant(language, 'full', event.target.checked);
},
text: `#${track.id} | ${displayLanguage} | Komplette Untertitel (${displayCodec})`
})}
<small className="track-action-note">Untertitel: {actionInfo}</small>
</div>
);
})}
</div>
)}
<div className="subtitle-footnote-list">
<small className="track-action-note">
Automatische Übersetzungen werden nur eingeblendet, wenn im Film eine andere Sprache gesprochen wird. Komplette Untertitel zeigen alle Dialoge an. Blu-ray Untertitel liegen meist als PGS (Bildformat) vor.
</small>
<small className="track-action-note">
Confidence-Index (nur für Automatische Übersetzungen): <span style={{ color: '#2e7d32' }}></span> high = explizites Track-Signal erkannt, <span style={{ color: '#f57c00' }}></span> medium = "forced" im Titel erkannt, <span style={{ color: '#c62828' }}></span> low = heuristische Einschätzung.
</small>
</div>
</div>
);
}
const selectedIds = normalizeTrackIdList(selectedTrackIds);
const checkedTrackOrder = (Array.isArray(tracks) ? tracks : [])
const checkedTrackOrder = orderedTracks
.map((track) => normalizeTrackId(track?.id))
.filter((trackId, index) => {
if (trackId === null) {
@@ -650,7 +1063,7 @@ function TrackList({
if (allowSelection) {
return selectedIds.includes(trackId);
}
const track = tracks[index];
const track = orderedTracks[index];
return Boolean(track?.selectedForEncode);
});
@@ -661,11 +1074,10 @@ function TrackList({
<p>Keine Einträge.</p>
) : (
<div className="media-track-list">
{tracks.map((track) => {
{orderedTracks.map((track) => {
const trackId = normalizeTrackId(track.id);
const burned = type === 'subtitle' ? isBurnedSubtitleTrack(track) : false;
const checked = allowSelection
? (trackId !== null && selectedIds.includes(trackId) && !(type === 'subtitle' && burned))
? (trackId !== null && selectedIds.includes(trackId))
: Boolean(track.selectedForEncode);
const selectedIndex = trackId !== null
? checkedTrackOrder.indexOf(trackId)
@@ -686,23 +1098,14 @@ function TrackList({
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;
: null;
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 disabled = !allowSelection || (type === 'subtitle' && burned);
const forcedOnlyTrack = type === 'subtitle' ? isForcedOnlySubtitleTrack(track) : false;
const forcedAvailable = type === 'subtitle' ? hasForcedSubtitleAvailable(track) : false;
const disabled = !allowSelection;
let displayText = `#${track.id} | ${displayLanguage} | ${displayCodec}`;
if (type === 'audio') {
@@ -716,14 +1119,6 @@ function TrackList({
displayText += ` | ${audioVariant}`;
}
}
if (type === 'subtitle' && burned) {
displayText += ' | burned';
} else if (type === 'subtitle' && forcedOnlyTrack) {
displayText += ' | forced-only';
} else if (type === 'subtitle' && forcedAvailable) {
displayText += ' | forced verfügbar';
}
return (
<div key={`${title}-${track.id}`} className="media-track-item">
<label className="readonly-check-row">
@@ -739,7 +1134,9 @@ function TrackList({
readOnly={disabled}
disabled={disabled}
/>
<span>{displayText}</span>
<span>
{displayText}
</span>
</label>
{actionInfo ? <small className="track-action-note">Encode: {actionInfo}</small> : null}
</div>
@@ -804,6 +1201,7 @@ export default function MediaInfoReviewPanel({
allowTrackSelection = false,
trackSelectionByTitle = {},
onTrackSelectionChange = null,
onSubtitleVariantSelectionChange = null,
availableScripts = [],
availableChains = [],
preEncodeItems = [],
@@ -934,7 +1332,7 @@ export default function MediaInfoReviewPanel({
<div><strong>Audio Copy-Mask:</strong> {(effectiveAudioSelector?.copyMask || []).join(', ') || '-'}</div>
<div><strong>Audio Fallback:</strong> {effectiveAudioSelector?.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><strong>Subtitle Flags:</strong> {review.selectors?.subtitle?.burnBehavior === 'first' ? 'burned(first)' : '-'}</div>
</div>
{review.partial ? (
@@ -1213,18 +1611,44 @@ export default function MediaInfoReviewPanel({
const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
const selectableSubtitleTrackIds = subtitleTracks
.filter((track) => !isBurnedSubtitleTrack(track))
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const selectableSubtitleTrackIdSet = new Set(selectableSubtitleTrackIds.map((id) => String(id)));
const sortedSelectableSubtitleTracks = subtitleTracks
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.slice()
.sort((a, b) => (normalizeTrackId(a?.id) || Number.MAX_SAFE_INTEGER) - (normalizeTrackId(b?.id) || Number.MAX_SAFE_INTEGER));
const defaultAudioTrackIds = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
.filter((track) => Boolean(track?.selectedByRule))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const defaultSubtitleTrackIds = subtitleTracks
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track))
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const defaultSubtitleVariantSelection = {};
const defaultSubtitleLanguageOrder = [];
const subtitleLanguageOrderSeen = new Set();
for (const track of sortedSelectableSubtitleTracks) {
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
if (!subtitleLanguageOrderSeen.has(language)) {
subtitleLanguageOrderSeen.add(language);
defaultSubtitleLanguageOrder.push(language);
}
if (!Boolean(track?.selectedByRule)) {
continue;
}
if (!defaultSubtitleVariantSelection[language]) {
defaultSubtitleVariantSelection[language] = { full: false, forced: false };
}
const flags = resolveSubtitleTrackVariantFlags(track);
if (flags.isForcedOnly) {
defaultSubtitleVariantSelection[language].forced = true;
} else {
defaultSubtitleVariantSelection[language].full = true;
}
}
const selectedAudioTrackIds = normalizeTrackIdList(
Array.isArray(titleSelectionEntry?.audioTrackIds)
? titleSelectionEntry.audioTrackIds
@@ -1235,6 +1659,14 @@ export default function MediaInfoReviewPanel({
? titleSelectionEntry.subtitleTrackIds
: defaultSubtitleTrackIds
).filter((id) => selectableSubtitleTrackIdSet.has(String(id)));
const selectedSubtitleVariantSelection = normalizeSubtitleVariantSelectionMap(
titleSelectionEntry?.subtitleVariantSelection || defaultSubtitleVariantSelection
);
const selectedSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(
Array.isArray(titleSelectionEntry?.subtitleLanguageOrder)
? titleSelectionEntry.subtitleLanguageOrder
: defaultSubtitleLanguageOrder
);
const allowTrackSelectionForTitle = Boolean(
allowTrackSelection
&& allowTitleSelection
@@ -1302,15 +1734,19 @@ export default function MediaInfoReviewPanel({
/>
<TrackList
title={`Subtitles (Titel #${title.id})`}
tracks={allowTrackSelectionForTitle ? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track)) : subtitleTracks}
tracks={allowTrackSelectionForTitle
? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track))
: subtitleTracks}
type="subtitle"
allowSelection={allowTrackSelectionForTitle}
selectedTrackIds={selectedSubtitleTrackIds}
onToggleTrack={(trackId, checked) => {
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
onToggleSubtitleVariant={(language, variant, checked) => {
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
return;
}
onTrackSelectionChange(title.id, 'subtitle', trackId, checked);
onSubtitleVariantSelectionChange(title.id, language, variant, checked);
}}
/>
</div>
@@ -1320,6 +1756,8 @@ export default function MediaInfoReviewPanel({
title,
selectedAudioTrackIds,
selectedSubtitleTrackIds,
selectedSubtitleVariantSelection,
selectedSubtitleLanguageOrder,
commandOutputPath,
presetOverride: effectivePresetOverride
});
+255 -16
View File
@@ -15,6 +15,14 @@ function normalizeTitleId(value) {
return Math.trunc(parsed);
}
function normalizeJobId(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) {
@@ -155,10 +163,119 @@ function isBurnedSubtitleTrack(track) {
);
}
function isDuplicateSubtitleTrack(track) {
return Boolean(track?.duplicate);
}
function normalizeSubtitleLanguage(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return 'und';
}
if (raw.length >= 3) {
return raw.slice(0, 3);
}
return raw;
}
function normalizeSubtitleVariantSelectionMap(rawSelection) {
const map = {};
if (!rawSelection || typeof rawSelection !== 'object') {
return map;
}
for (const [language, value] of Object.entries(rawSelection)) {
const normalizedLanguage = normalizeSubtitleLanguage(language);
const full = Boolean(value?.full);
const forced = Boolean(value?.forced);
map[normalizedLanguage] = { full, forced };
}
return map;
}
function normalizeSubtitleLanguageOrder(rawOrder = []) {
const list = Array.isArray(rawOrder) ? rawOrder : [];
const seen = new Set();
const output = [];
for (const value of list) {
const language = normalizeSubtitleLanguage(value);
if (!language || seen.has(language)) {
continue;
}
seen.add(language);
output.push(language);
}
return output;
}
function resolveSubtitleTrackVariantFlags(track) {
const subtitleType = String(track?.subtitleType || '').trim().toLowerCase();
const isForcedOnly = Boolean(
track?.isForcedOnly
?? track?.subtitlePreviewForcedOnly
?? track?.forcedOnly
?? subtitleType === 'forced'
);
const fullHasForced = !isForcedOnly && Boolean(
track?.fullHasForced
?? track?.subtitleFullHasForced
?? track?.hasForcedVariant
);
return { isForcedOnly, fullHasForced };
}
function buildDefaultSubtitleVariantSelection(subtitleTracks, selectedTracks) {
const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : [];
const selected = Array.isArray(selectedTracks) ? selectedTracks : [];
const orderedTracks = [...tracks].sort((a, b) => {
const aId = normalizeTrackId(a?.id) ?? Number.MAX_SAFE_INTEGER;
const bId = normalizeTrackId(b?.id) ?? Number.MAX_SAFE_INTEGER;
if (aId !== bId) {
return aId - bId;
}
return String(a?.language || a?.languageLabel || '').localeCompare(String(b?.language || b?.languageLabel || ''));
});
const order = [];
const orderSeen = new Set();
for (const track of orderedTracks) {
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
if (!orderSeen.has(language)) {
orderSeen.add(language);
order.push(language);
}
}
const selectionMap = {};
for (const track of selected) {
if (isBurnedSubtitleTrack(track) || isDuplicateSubtitleTrack(track)) {
continue;
}
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
if (!selectionMap[language]) {
selectionMap[language] = { full: false, forced: false };
}
const flags = resolveSubtitleTrackVariantFlags(track);
if (flags.isForcedOnly) {
selectionMap[language].forced = true;
} else {
selectionMap[language].full = true;
}
}
return {
subtitleVariantSelection: selectionMap,
subtitleLanguageOrder: order
};
}
function buildDefaultTrackSelection(review) {
const titles = Array.isArray(review?.titles) ? review.titles : [];
const selection = {};
const reviewEncodeInputTitleId = normalizeTitleId(review?.encodeInputTitleId);
const manualSelection = review?.manualTrackSelection && typeof review.manualTrackSelection === 'object'
? review.manualTrackSelection
: null;
const manualTitleId = normalizeTitleId(manualSelection?.titleId);
for (const title of titles) {
const titleId = normalizeTitleId(title?.id);
@@ -179,16 +296,49 @@ function buildDefaultTrackSelection(review) {
const subtitleSelectionSource = isEncodeInputTitle
? subtitleTracks.filter((track) => Boolean(track?.selectedForEncode))
: subtitleTracks.filter((track) => Boolean(track?.selectedByRule));
const defaultSubtitleVariantSelection = buildDefaultSubtitleVariantSelection(
subtitleTracks,
subtitleSelectionSource
);
const manualSelectionMatchesTitle = Boolean(
isEncodeInputTitle
&& manualSelection
&& (!manualTitleId || manualTitleId === titleId)
);
const manualAudioTrackIds = manualSelectionMatchesTitle
? normalizeTrackIdList(manualSelection?.audioTrackIds || [])
: [];
const manualSubtitleTrackIds = manualSelectionMatchesTitle
? normalizeTrackIdList(
Array.isArray(manualSelection?.subtitleTrackIdsOrdered)
? manualSelection.subtitleTrackIdsOrdered
: manualSelection?.subtitleTrackIds || []
)
: [];
const manualSubtitleVariantSelection = manualSelectionMatchesTitle
? normalizeSubtitleVariantSelectionMap(manualSelection?.subtitleVariantSelection || {})
: {};
const manualSubtitleLanguageOrder = manualSelectionMatchesTitle
? normalizeSubtitleLanguageOrder(manualSelection?.subtitleLanguageOrder || [])
: [];
selection[titleId] = {
audioTrackIds: normalizeTrackIdList(
audioSelectionSource.map((track) => track?.id)
),
subtitleTrackIds: normalizeTrackIdList(
subtitleSelectionSource
.filter((track) => !isBurnedSubtitleTrack(track))
.map((track) => track?.id)
)
audioTrackIds: manualAudioTrackIds.length > 0
? manualAudioTrackIds
: normalizeTrackIdList(audioSelectionSource.map((track) => track?.id)),
subtitleTrackIds: manualSubtitleTrackIds.length > 0
? manualSubtitleTrackIds
: normalizeTrackIdList(
subtitleSelectionSource
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => track?.id)
),
subtitleVariantSelection: Object.keys(manualSubtitleVariantSelection).length > 0
? manualSubtitleVariantSelection
: defaultSubtitleVariantSelection.subtitleVariantSelection,
subtitleLanguageOrder: manualSubtitleLanguageOrder.length > 0
? manualSubtitleLanguageOrder
: defaultSubtitleVariantSelection.subtitleLanguageOrder
};
}
@@ -197,7 +347,12 @@ function buildDefaultTrackSelection(review) {
function defaultTrackSelectionForTitle(review, titleId) {
const defaults = buildDefaultTrackSelection(review);
return defaults[titleId] || defaults[String(titleId)] || { audioTrackIds: [], subtitleTrackIds: [] };
return defaults[titleId] || defaults[String(titleId)] || {
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
};
}
function buildSettingsMap(categories) {
@@ -299,6 +454,7 @@ function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId
}
export default function PipelineStatusCard({
jobId = null,
pipeline,
onAnalyze,
onReanalyze,
@@ -321,7 +477,9 @@ export default function PipelineStatusCard({
const stateLabel = getStatusLabel(state);
const progress = Number(pipeline?.progress || 0);
const running = state === 'ANALYZING' || state === 'RIPPING' || state === 'ENCODING' || state === 'MEDIAINFO_CHECK';
const retryJobId = pipeline?.context?.jobId;
const explicitJobId = normalizeJobId(jobId);
const contextJobId = normalizeJobId(pipeline?.context?.jobId);
const retryJobId = explicitJobId || contextJobId;
const queueLocked = Boolean(isQueued && retryJobId);
const selectedMetadata = pipeline?.context?.selectedMetadata || null;
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
@@ -435,7 +593,12 @@ export default function PipelineStatusCard({
}
const defaults = buildDefaultTrackSelection(mediaInfoReview);
const fallback = defaults[currentTitleId] || { audioTrackIds: [], subtitleTrackIds: [] };
const fallback = defaults[currentTitleId] || {
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
};
return {
...prev,
[currentTitleId]: fallback
@@ -639,7 +802,12 @@ export default function PipelineStatusCard({
: null;
const fallbackSelection = encodeTitleId
? defaultTrackSelectionForTitle(mediaInfoReview, encodeTitleId)
: { audioTrackIds: [], subtitleTrackIds: [] };
: {
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
};
const effectiveSelection = selectionEntry || fallbackSelection;
const encodeTitle = encodeTitleId
? (Array.isArray(mediaInfoReview?.titles)
@@ -648,17 +816,39 @@ export default function PipelineStatusCard({
: null;
const blockedSubtitleTrackIds = new Set(
(Array.isArray(encodeTitle?.subtitleTracks) ? encodeTitle.subtitleTracks : [])
.filter((track) => isBurnedSubtitleTrack(track))
.filter((track) => isBurnedSubtitleTrack(track) || isDuplicateSubtitleTrack(track))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const availableSubtitleLanguages = new Set(
(Array.isArray(encodeTitle?.subtitleTracks) ? encodeTitle.subtitleTracks : [])
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und'))
);
const normalizedVariantSelection = normalizeSubtitleVariantSelectionMap(effectiveSelection?.subtitleVariantSelection || {});
const filteredVariantSelection = {};
for (const [language, value] of Object.entries(normalizedVariantSelection)) {
if (!availableSubtitleLanguages.has(language)) {
continue;
}
filteredVariantSelection[language] = {
full: Boolean(value?.full),
forced: Boolean(value?.forced)
};
}
const normalizedLanguageOrder = normalizeSubtitleLanguageOrder(effectiveSelection?.subtitleLanguageOrder || [])
.filter((language) => availableSubtitleLanguages.has(language));
const hasExplicitVariantSelection = Object.keys(filteredVariantSelection).length > 0;
const filteredSubtitleTrackIds = normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
.filter((id) => !blockedSubtitleTrackIds.has(String(id)));
const selectedTrackSelection = encodeTitleId
? {
[encodeTitleId]: {
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
subtitleTrackIds: normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
.filter((id) => !blockedSubtitleTrackIds.has(String(id)))
subtitleTrackIds: hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds,
subtitleVariantSelection: filteredVariantSelection,
subtitleLanguageOrder: normalizedLanguageOrder
}
}
: null;
@@ -1032,7 +1222,9 @@ export default function PipelineStatusCard({
setTrackSelectionByTitle((prev) => {
const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || {
audioTrackIds: [],
subtitleTrackIds: []
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
};
const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds';
const existing = normalizeTrackIdList(current?.[key] || []);
@@ -1049,6 +1241,53 @@ export default function PipelineStatusCard({
};
});
}}
onSubtitleVariantSelectionChange={(titleId, language, variant, checked) => {
const normalizedTitleId = normalizeTitleId(titleId);
const normalizedLanguage = normalizeSubtitleLanguage(language);
const normalizedVariant = String(variant || '').trim().toLowerCase() === 'forced' ? 'forced' : 'full';
if (!normalizedTitleId || !normalizedLanguage) {
return;
}
setTrackSelectionByTitle((prev) => {
const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || {
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
};
const currentMap = normalizeSubtitleVariantSelectionMap(current?.subtitleVariantSelection || {});
const nextMap = { ...currentMap };
const currentEntry = nextMap[normalizedLanguage] || { full: false, forced: false };
nextMap[normalizedLanguage] = {
...currentEntry,
[normalizedVariant]: Boolean(checked)
};
const existingOrder = normalizeSubtitleLanguageOrder(current?.subtitleLanguageOrder || []);
const fallbackOrder = normalizeSubtitleLanguageOrder(
defaultTrackSelectionForTitle(mediaInfoReview, normalizedTitleId)?.subtitleLanguageOrder || []
);
let nextOrder = existingOrder.length > 0
? existingOrder
: fallbackOrder;
if (nextOrder.length === 0) {
nextOrder = [normalizedLanguage];
} else if (!nextOrder.includes(normalizedLanguage) && fallbackOrder.includes(normalizedLanguage)) {
nextOrder = fallbackOrder;
} else if (!nextOrder.includes(normalizedLanguage)) {
nextOrder = [...nextOrder, normalizedLanguage];
}
return {
...prev,
[normalizedTitleId]: {
...current,
subtitleVariantSelection: nextMap,
subtitleLanguageOrder: nextOrder
}
};
});
}}
availableScripts={scriptCatalog}
availableChains={chainCatalog}
preEncodeItems={preEncodeItems}
+413 -113
View File
@@ -1,30 +1,51 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { Card } from 'primereact/card';
import { Toast } from 'primereact/toast';
import { Divider } from 'primereact/divider';
import { Badge } from 'primereact/badge';
import { Divider } from 'primereact/divider';
import { Dialog } from 'primereact/dialog';
import { Dropdown } from 'primereact/dropdown';
import { RadioButton } from 'primereact/radiobutton';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import ConverterFileExplorer from '../components/ConverterFileExplorer';
import ConverterUploadPanel from '../components/ConverterUploadPanel';
import ConverterJobCard from '../components/ConverterJobCard';
import JobDetailDialog from '../components/JobDetailDialog';
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']);
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.wmv', '.ts', '.m2ts', '.iso', '.m4v', '.flv', '.webm']);
function isAudioEntry(e) {
if (e.detectedMediaType === 'audio') return true;
const p = (e.relPath || '').toLowerCase();
const dot = p.lastIndexOf('.');
if (dot === -1) return false;
return AUDIO_EXTS.has(p.slice(dot));
}
function isVideoEntry(e) {
if (e.detectedMediaType === 'video' || e.detectedMediaType === 'iso') return true;
const p = (e.relPath || '').toLowerCase();
const dot = p.lastIndexOf('.');
if (dot === -1) return false;
return VIDEO_EXTS.has(p.slice(dot));
}
function normalizeJobId(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
}
function parseConverterPlan(job) {
try {
return JSON.parse(job?.encode_plan_json || '{}');
} catch (_err) {
return {};
}
}
export default function ConverterPage() {
const toastRef = useRef(null);
const [jobs, setJobs] = useState([]);
@@ -35,7 +56,20 @@ export default function ConverterPage() {
const [selectedEntries, setSelectedEntries] = useState([]);
const [jobModeVisible, setJobModeVisible] = useState(false);
const [audioMode, setAudioMode] = useState('individual');
const [jobModalAction, setJobModalAction] = useState('create');
const [assignTargetJobId, setAssignTargetJobId] = useState(null);
const [creatingJobs, setCreatingJobs] = useState(false);
const [jobEntries, setJobEntries] = useState([]);
const [detailVisible, setDetailVisible] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [selectedJob, setSelectedJob] = useState(null);
const [logLoadingMode, setLogLoadingMode] = useState(null);
const [deleteEntryBusy, setDeleteEntryBusy] = useState(false);
const [cancelDetailBusy, setCancelDetailBusy] = useState(false);
const [expandedJobId, setExpandedJobId] = useState(undefined);
// Entries werden beim Öffnen des Modals gesichert, damit ein außerhalb-Click
// die Selection nicht löscht bevor die Jobs erstellt werden
const jobEntriesRef = useRef([]);
const loadJobs = useCallback(async () => {
setLoadingJobs(true);
@@ -55,53 +89,91 @@ export default function ConverterPage() {
return () => clearInterval(interval);
}, [loadJobs]);
useWebSocket((message) => {
if (!message?.type || !message?.payload) return;
useWebSocket({
onMessage: (message) => {
if (!message?.type || !message?.payload) return;
if (message.type === 'PIPELINE_PROGRESS') {
const payload = message.payload;
const jobId = normalizeJobId(payload?.activeJobId);
if (jobId) {
setJobProgress((prev) => ({
...prev,
[jobId]: {
progress: payload?.progress ?? null,
eta: payload?.eta ?? null,
statusText: payload?.statusText ?? null,
state: payload?.state ?? null
}
}));
if (message.type === 'PIPELINE_PROGRESS') {
const payload = message.payload;
const jobId = normalizeJobId(payload?.activeJobId);
if (jobId) {
setJobProgress((prev) => ({
...prev,
[jobId]: {
progress: payload?.progress ?? null,
eta: payload?.eta ?? null,
statusText: payload?.statusText ?? null,
state: payload?.state ?? null
}
}));
}
}
}
if (message.type === 'PIPELINE_UPDATE' || message.type === 'CONVERTER_SCAN_UPDATE') {
loadJobs();
if (message.type === 'CONVERTER_SCAN_UPDATE') {
setExplorerRefreshToken((t) => t + 1);
if (
message.type === 'PIPELINE_UPDATE' ||
message.type === 'PIPELINE_STATE_CHANGED' ||
message.type === 'CONVERTER_SCAN_UPDATE'
) {
loadJobs();
if (message.type === 'CONVERTER_SCAN_UPDATE' || message.type === 'PIPELINE_STATE_CHANGED') {
setExplorerRefreshToken((t) => t + 1);
}
}
}
});
const assignableJobs = useMemo(() => (
jobs
.map((job) => ({ job, plan: parseConverterPlan(job) }))
.filter(({ job, plan }) => {
const status = String(job?.status || '').trim().toUpperCase();
const mediaType = String(plan?.converterMediaType || '').trim().toLowerCase();
return status === 'READY_TO_START' && mediaType === 'audio' && !Boolean(plan?.isFolder);
})
.map(({ job, plan }) => {
const rawTitle = String(job?.title || '').trim();
const title = rawTitle ? `#${job.id} | ${rawTitle}` : `#${job.id}`;
const fileCount = Array.isArray(plan?.inputPaths) && plan.inputPaths.length > 0
? plan.inputPaths.length
: (plan?.inputPath ? 1 : 0);
return {
label: `${title}${fileCount > 0 ? ` (${fileCount} Datei${fileCount !== 1 ? 'en' : ''})` : ''}`,
value: job.id
};
})
), [jobs]);
const handleSelectionChange = (entries) => setSelectedEntries(entries || []);
const handleOpenJobModal = () => {
const hasAudio = selectedEntries.some(isAudioEntry);
if (!hasAudio) {
// Keine Audio-Dateien (nur Videos/Ordner) kein Modal, direkt Einzeljobs
// Explorer expandiert Ordner bereits zu Einzel-Dateien
const entries = [...selectedEntries];
jobEntriesRef.current = entries;
setJobEntries(entries);
const hasAudio = entries.some(isAudioEntry);
const hasAssignableJobs = assignableJobs.length > 0;
if (!hasAudio && !hasAssignableJobs) {
// Nur Videos oder sonstige Dateien direkt individual erstellen
handleCreateJobsFromSelection('individual');
return;
}
setAudioMode('individual');
setJobModalAction('create');
setAssignTargetJobId(assignableJobs[0]?.value || null);
setJobModeVisible(true);
};
const handleCreateJobsFromSelection = async (explicitMode) => {
if (selectedEntries.length === 0) return;
const handleCreateJobsFromSelection = async (explicitAudioMode) => {
const entries = jobEntriesRef.current;
if (entries.length === 0) return;
const resolvedAudioMode = typeof explicitAudioMode === 'string' ? explicitAudioMode : audioMode;
setCreatingJobs(true);
setJobModeVisible(false);
try {
const relPaths = selectedEntries.map((e) => e.relPath).filter(Boolean);
const result = await api.converterCreateJobsFromSelection(relPaths, explicitMode ?? audioMode);
const relPaths = entries.map((e) => e.relPath).filter(Boolean);
const result = await api.converterCreateJobsFromSelection(relPaths, resolvedAudioMode);
const newJobs = result?.jobs || [];
toastRef.current?.show({
severity: 'success',
@@ -109,6 +181,8 @@ export default function ConverterPage() {
detail: `${newJobs.length} Converter-Job${newJobs.length !== 1 ? 's' : ''} erstellt.`,
life: 3500
});
jobEntriesRef.current = [];
setJobEntries([]);
setSelectedEntries([]);
setExplorerRefreshToken((t) => t + 1);
await loadJobs();
@@ -124,10 +198,54 @@ export default function ConverterPage() {
}
};
const handleAssignSelectionToJob = async (targetJobId) => {
const entries = jobEntriesRef.current;
if (entries.length === 0) return;
const normalizedJobId = Number(targetJobId);
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) return;
setCreatingJobs(true);
setJobModeVisible(false);
try {
const relPaths = entries.map((e) => e.relPath).filter(Boolean);
const result = await api.converterAssignFilesToJob(normalizedJobId, relPaths);
const addedCount = Array.isArray(result?.addedRelPaths) ? result.addedRelPaths.length : 0;
toastRef.current?.show({
severity: 'success',
summary: 'Dateien zugewiesen',
detail: addedCount > 0
? `${addedCount} Datei${addedCount !== 1 ? 'en' : ''} zu Job #${normalizedJobId} hinzugefügt.`
: `Keine neuen Dateien zu Job #${normalizedJobId} hinzugefügt.`,
life: 3600
});
jobEntriesRef.current = [];
setJobEntries([]);
setSelectedEntries([]);
setExplorerRefreshToken((t) => t + 1);
await loadJobs();
} catch (err) {
toastRef.current?.show({
severity: 'error',
summary: 'Fehler',
detail: err.message || 'Dateien konnten dem Job nicht zugewiesen werden.',
life: 4600
});
} finally {
setCreatingJobs(false);
}
};
const handleJobModeConfirm = async ({ action, audioMode: selectedAudioMode, jobId }) => {
if (action === 'assign') {
await handleAssignSelectionToJob(jobId || assignTargetJobId);
return;
}
await handleCreateJobsFromSelection(selectedAudioMode || audioMode);
};
const handleUploaded = (folders) => {
setExplorerRefreshToken((t) => t + 1);
if (folders?.length > 0) {
// Explorer in den ersten hochgeladenen Ordner navigieren
setExplorerNavigateTo({ path: folders[0].folderRelPath, ts: Date.now() });
toastRef.current?.show({
severity: 'success',
@@ -148,6 +266,135 @@ export default function ConverterPage() {
});
};
const handleOpenJobDetails = async (row) => {
const jobId = Number(row?.id || 0);
if (!jobId) {
return;
}
setSelectedJob({
...row,
logs: [],
log: '',
logMeta: {
loaded: false,
total: Number(row?.log_count || 0),
returned: 0,
truncated: false
}
});
setDetailVisible(true);
setDetailLoading(true);
try {
const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true });
setSelectedJob(response.job);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Details konnten nicht geladen werden',
detail: error.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setDetailLoading(false);
}
};
const handleLoadDetailLog = async (job, mode = 'tail') => {
const jobId = Number(job?.id || selectedJob?.id || 0);
if (!jobId) {
return;
}
setLogLoadingMode(mode);
try {
const response = await api.getJob(jobId, {
includeLogs: true,
includeAllLogs: mode === 'all',
logTailLines: mode === 'all' ? null : 800
});
setSelectedJob(response.job);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Log konnte nicht geladen werden',
detail: error.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setLogLoadingMode(null);
}
};
const handleDeleteDetailEntry = async (job) => {
const jobId = Number(job?.id || 0);
if (!jobId) {
return;
}
const confirmed = window.confirm(`Historieneintrag Job #${jobId} löschen?`);
if (!confirmed) {
return;
}
setDeleteEntryBusy(true);
try {
await api.deleteConverterJob(jobId);
handleJobDeleted(jobId);
setDetailVisible(false);
setSelectedJob(null);
toastRef.current?.show({
severity: 'success',
summary: 'Eintrag gelöscht',
detail: `Job #${jobId} wurde entfernt.`,
life: 3200
});
await loadJobs();
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Löschen fehlgeschlagen',
detail: error.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setDeleteEntryBusy(false);
}
};
const handleCancelDetailJob = async (job) => {
const jobId = Number(job?.id || selectedJob?.id || 0);
if (!jobId) {
return;
}
const confirmed = window.confirm(`Job #${jobId} abbrechen?`);
if (!confirmed) {
return;
}
setCancelDetailBusy(true);
try {
await api.cancelConverterJob(jobId);
toastRef.current?.show({
severity: 'success',
summary: 'Abbruch gesendet',
detail: `Job #${jobId} wird abgebrochen.`,
life: 2800
});
await loadJobs();
if (detailVisible) {
const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true });
setSelectedJob(response.job);
}
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Abbruch fehlgeschlagen',
detail: error.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setCancelDetailBusy(false);
}
};
const handleJobDeleted = (jobId) => {
setJobs((prev) => prev.filter((j) => j.id !== jobId));
setJobProgress((prev) => {
@@ -155,12 +402,32 @@ export default function ConverterPage() {
delete next[jobId];
return next;
});
setExplorerRefreshToken((t) => t + 1);
if (Number(selectedJob?.id || 0) === Number(jobId || 0)) {
setDetailVisible(false);
setSelectedJob(null);
setDetailLoading(false);
setLogLoadingMode(null);
}
};
const handleJobCancelled = () => setTimeout(loadJobs, 1000);
const handleJobInputsChanged = async (_jobId, _payload) => {
setExplorerRefreshToken((t) => t + 1);
await loadJobs();
};
const activeJobs = jobs.filter((j) => !['DONE', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
const finishedJobs = jobs.filter((j) => ['DONE', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
const activeJobs = jobs.filter((j) => !['DONE', 'FINISHED', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
// Auto-expand: ersten Job aufklappen, wenn keiner expanded ist (außer user hat explizit zugeklappt)
useEffect(() => {
const normalizedExpanded = Number(expandedJobId) || null;
const hasExpanded = activeJobs.some((j) => Number(j.id) === normalizedExpanded);
if (hasExpanded) return;
if (expandedJobId === null) return; // explizit vom User zugeklappt
if (activeJobs.length === 0) return;
setExpandedJobId(Number(activeJobs[0].id));
}, [activeJobs, expandedJobId]);
const jobsCardHeader = (
<div className="converter-card-header">
@@ -192,6 +459,7 @@ export default function ConverterPage() {
onSelectionChange={handleSelectionChange}
refreshToken={explorerRefreshToken}
navigateToPath={explorerNavigateTo}
onAssignmentChanged={loadJobs}
/>
{selectedEntries.length > 0 && (
<div className="converter-selection-bar">
@@ -210,46 +478,28 @@ export default function ConverterPage() {
{/* Jobs */}
<Card header={jobsCardHeader}>
{jobs.length === 0 ? (
{activeJobs.length === 0 ? (
<p className="converter-jobs-empty-hint">
<small>Keine Jobs vorhanden. Dateien im Import-Ordner auswählen oder per Upload hochladen.</small>
<small>Keine aktiven Converter-Jobs vorhanden. Abgeschlossene Jobs findest du in der Historie.</small>
</p>
) : (
<>
{activeJobs.length > 0 && (
<>
<p className="converter-jobs-group-label">Aktiv</p>
<div className="converter-jobs-list">
{activeJobs.map((job) => (
<ConverterJobCard
key={job.id}
job={job}
jobProgress={jobProgress[job.id] || null}
onStarted={handleJobStarted}
onDeleted={handleJobDeleted}
onCancelled={handleJobCancelled}
/>
))}
</div>
</>
)}
{activeJobs.length > 0 && finishedJobs.length > 0 && <Divider />}
{finishedJobs.length > 0 && (
<>
<p className="converter-jobs-group-label">Abgeschlossen</p>
<div className="converter-jobs-list">
{finishedJobs.slice(0, 20).map((job) => (
<ConverterJobCard
key={job.id}
job={job}
jobProgress={null}
onDeleted={handleJobDeleted}
/>
))}
</div>
</>
)}
</>
<div className="dashboard-job-list converter-jobs-list">
{activeJobs.map((job) => (
<ConverterJobCard
key={job.id}
job={job}
jobProgress={jobProgress[job.id] || null}
isExpanded={Number(job.id) === Number(expandedJobId)}
onExpand={() => setExpandedJobId(Number(job.id))}
onCollapse={() => setExpandedJobId(null)}
onStarted={handleJobStarted}
onDeleted={handleJobDeleted}
onCancelled={handleJobCancelled}
onOpenDetails={handleOpenJobDetails}
onInputsChanged={handleJobInputsChanged}
/>
))}
</div>
)}
</Card>
@@ -260,39 +510,83 @@ export default function ConverterPage() {
<JobModeDialog
visible={jobModeVisible}
entries={selectedEntries}
entries={jobEntries}
audioMode={audioMode}
onAudioModeChange={setAudioMode}
action={jobModalAction}
onActionChange={setJobModalAction}
assignableJobs={assignableJobs}
assignJobId={assignTargetJobId}
onAssignJobIdChange={setAssignTargetJobId}
busy={creatingJobs}
onConfirm={handleCreateJobsFromSelection}
onConfirm={handleJobModeConfirm}
onHide={() => setJobModeVisible(false)}
/>
<JobDetailDialog
visible={detailVisible}
job={selectedJob}
detailLoading={detailLoading}
onLoadLog={handleLoadDetailLog}
logLoadingMode={logLoadingMode}
onCancel={handleCancelDetailJob}
cancelBusy={cancelDetailBusy}
onDeleteEntry={handleDeleteDetailEntry}
deleteEntryBusy={deleteEntryBusy}
onHide={() => {
setDetailVisible(false);
setSelectedJob(null);
setDetailLoading(false);
setLogLoadingMode(null);
setDeleteEntryBusy(false);
setCancelDetailBusy(false);
}}
/>
</div>
);
}
// Job-Modus-Dialog
function JobModeDialog({ visible, entries, audioMode, onAudioModeChange, busy, onConfirm, onHide }) {
function JobModeDialog({
visible,
entries,
audioMode,
onAudioModeChange,
action,
onActionChange,
assignableJobs,
assignJobId,
onAssignJobIdChange,
busy,
onConfirm,
onHide
}) {
const audioEntries = entries.filter(isAudioEntry);
const nonAudioEntries = entries.filter((e) => !isAudioEntry(e));
const hasAudio = audioEntries.length > 0;
const hasNonAudio = nonAudioEntries.length > 0;
const onlyAudio = hasAudio && !hasNonAudio;
const mixed = hasAudio && hasNonAudio;
const videoEntries = entries.filter(isVideoEntry);
const otherEntries = entries.filter((e) => !isAudioEntry(e) && !isVideoEntry(e));
// Anzahl der Jobs die entstehen werden
const hasAudio = audioEntries.length > 0;
const hasVideo = videoEntries.length > 0;
const hasOther = otherEntries.length > 0;
const hasAssignableJobs = Array.isArray(assignableJobs) && assignableJobs.length > 0;
// Videos immer individual, audio per Modus
const audioJobCount = audioMode === 'shared' ? 1 : audioEntries.length;
const totalJobs = nonAudioEntries.length + audioJobCount;
const totalJobs = videoEntries.length + otherEntries.length + audioJobCount;
const isAssignMode = action === 'assign';
const confirmLabel = isAssignMode
? 'Dateien zuweisen'
: `${totalJobs} Job${totalJobs !== 1 ? 's' : ''} anlegen`;
const footer = (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={onHide} disabled={busy} />
<Button
label={`${totalJobs} Job${totalJobs !== 1 ? 's' : ''} anlegen`}
label={confirmLabel}
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-plus'}
disabled={busy}
onClick={onConfirm}
disabled={busy || (isAssignMode && !assignJobId)}
onClick={() => onConfirm({ action, audioMode, jobId: assignJobId })}
/>
</div>
);
@@ -306,26 +600,27 @@ function JobModeDialog({ visible, entries, audioMode, onAudioModeChange, busy, o
style={{ width: '440px' }}
modal
>
{/* Nur Videos / Ordner gewählt */}
{!hasAudio && (
<p style={{ margin: 0, lineHeight: 1.6 }}>
Für jede ausgewählte Datei / jeden Ordner wird ein eigener Job angelegt.
<br />
<strong>{nonAudioEntries.length} Job{nonAudioEntries.length !== 1 ? 's' : ''}</strong> werden erstellt.
{/* Videos / Sonstige immer individual */}
{(hasVideo || hasOther) && (
<p style={{ marginTop: 0, marginBottom: hasAudio ? 8 : 0, lineHeight: 1.6 }}>
<strong>Videos / Sonstige ({videoEntries.length + otherEntries.length}):</strong> Je eine Datei ein eigener Job.
</p>
)}
{/* Nur Audio gewählt */}
{onlyAudio && (
{hasAudio && (hasVideo || hasOther) && <Divider style={{ margin: '8px 0' }} />}
{/* Audio: Abfrage */}
{hasAudio && (
<div>
<p style={{ marginTop: 0, marginBottom: 12 }}>
Wie sollen die <strong>{audioEntries.length} Audiodatei{audioEntries.length !== 1 ? 'en' : ''}</strong> verarbeitet werden?
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, opacity: isAssignMode ? 0.6 : 1 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<RadioButton
value="individual"
checked={audioMode === 'individual'}
disabled={isAssignMode}
onChange={() => onAudioModeChange('individual')}
/>
<span>Für jede Datei ein eigener Job <small style={{ color: 'var(--rip-muted)' }}>({audioEntries.length} Jobs)</small></span>
@@ -334,6 +629,7 @@ function JobModeDialog({ visible, entries, audioMode, onAudioModeChange, busy, o
<RadioButton
value="shared"
checked={audioMode === 'shared'}
disabled={isAssignMode}
onChange={() => onAudioModeChange('shared')}
/>
<span>Ein gemeinsamer Job für alle Dateien <small style={{ color: 'var(--rip-muted)' }}>(1 Job)</small></span>
@@ -342,35 +638,39 @@ function JobModeDialog({ visible, entries, audioMode, onAudioModeChange, busy, o
</div>
)}
{/* Gemischte Auswahl */}
{mixed && (
<div>
<p style={{ marginTop: 0, marginBottom: 8 }}>
<strong>Videos/Ordner ({nonAudioEntries.length}):</strong> Für jede Datei ein eigener Job.
</p>
<Divider style={{ margin: '8px 0' }} />
<p style={{ marginBottom: 10 }}>
<strong>Audiodateien ({audioEntries.length}):</strong> Wie verarbeiten?
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
{hasAssignableJobs && (
<>
<Divider style={{ margin: '12px 0' }} />
<div>
<p style={{ marginTop: 0, marginBottom: 10 }}>
Optional: Dateien einem <strong>nicht gestarteten Job</strong> direkt zuweisen.
</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 8 }}>
<RadioButton
value="individual"
checked={audioMode === 'individual'}
onChange={() => onAudioModeChange('individual')}
value="create"
checked={!isAssignMode}
onChange={() => onActionChange('create')}
/>
<span>Für jede Audio-Datei ein eigener Job <small style={{ color: 'var(--rip-muted)' }}>({audioEntries.length} Jobs)</small></span>
<span>Neue Jobs anlegen</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 8 }}>
<RadioButton
value="shared"
checked={audioMode === 'shared'}
onChange={() => onAudioModeChange('shared')}
value="assign"
checked={isAssignMode}
onChange={() => onActionChange('assign')}
/>
<span>Ein gemeinsamer Job für alle Audio-Dateien <small style={{ color: 'var(--rip-muted)' }}>(1 Job)</small></span>
<span>Zu bestehendem Job zuweisen</span>
</label>
<Dropdown
value={assignJobId}
options={assignableJobs}
onChange={(e) => onAssignJobIdChange(e.value)}
placeholder="Job auswählen …"
disabled={!isAssignMode}
style={{ width: '100%' }}
/>
</div>
</div>
</>
)}
</Dialog>
);
+42 -15
View File
@@ -832,22 +832,25 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const existingContext = currentPipeline?.context && typeof currentPipeline.context === 'object'
? currentPipeline.context
: {};
const mergedCurrentContext = {
...computedContext,
...existingContext,
rawPath: existingContext.rawPath || computedContext.rawPath,
outputPath: existingContext.outputPath || computedContext.outputPath,
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
? existingContext.tracks
: computedContext.tracks,
selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata,
canRestartEncodeFromLastSettings:
existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
canRestartReviewFromRaw:
existingContext.canRestartReviewFromRaw ?? computedContext.canRestartReviewFromRaw
};
// The card always represents `job.id`; never let stale live context override it.
mergedCurrentContext.jobId = jobId;
return {
...currentPipeline,
context: {
...computedContext,
...existingContext,
rawPath: existingContext.rawPath || computedContext.rawPath,
outputPath: existingContext.outputPath || computedContext.outputPath,
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
? existingContext.tracks
: computedContext.tracks,
selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata,
canRestartEncodeFromLastSettings:
existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
canRestartReviewFromRaw:
existingContext.canRestartReviewFromRaw ?? computedContext.canRestartReviewFromRaw
}
context: mergedCurrentContext
};
}
@@ -878,6 +881,8 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig
}
: computedContext;
// The card always represents `job.id`; never let stale live context override it.
mergedContext.jobId = jobId;
return {
state: ignoreStaleLiveProgress ? jobStatus : (liveJobProgress?.state || jobStatus),
@@ -1073,6 +1078,21 @@ export default function DashboardPage({
setQueueState(normalizeQueue(queueResponse.value?.queue));
}
const shouldDisplayOnDashboard = (job) => {
const rawMediaType = String(job?.media_type || '').trim().toLowerCase();
const resolvedMediaType = String(job?.mediaType || '').trim().toLowerCase();
const planMediaProfile = String(job?.encodePlan?.mediaProfile || '').trim().toLowerCase();
const converterPlanType = String(job?.encodePlan?.converterMediaType || '').trim().toLowerCase();
const isConverterJob = (
rawMediaType === 'converter'
|| resolvedMediaType === 'converter'
|| planMediaProfile === 'converter'
|| converterPlanType === 'audio'
|| converterPlanType === 'video'
|| converterPlanType === 'iso'
);
if (isConverterJob) {
return false;
}
const normalizedStatus = String(job?.status || '').trim().toUpperCase();
if (!dashboardStatuses.has(normalizedStatus)) {
return false;
@@ -1980,7 +2000,7 @@ export default function DashboardPage({
setJobBusy(normalizedJobId, true);
try {
const response = await api.restartReviewFromRaw(normalizedJobId);
const response = await api.restartReviewFromRaw(normalizedJobId, { reuseCurrentJob: true });
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
@@ -2935,6 +2955,7 @@ export default function DashboardPage({
})()}
{!isCdJob && !isAudiobookJob ? (
<PipelineStatusCard
jobId={jobId}
pipeline={pipelineForJob}
onAnalyze={handleAnalyze}
onReanalyze={handleReanalyze}
@@ -3041,6 +3062,9 @@ export default function DashboardPage({
const hasScriptSummary = hasQueueScriptSummary(item);
const detailKey = buildRunningQueueScriptKey(item?.jobId);
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
const jobProgressEntry = pipeline?.jobProgress?.[String(item.jobId)] || null;
const rawQueueProgress = Number(jobProgressEntry?.progress ?? 0);
const clampedQueueProgress = Number.isFinite(rawQueueProgress) ? Math.max(0, Math.min(100, rawQueueProgress)) : 0;
return (
<div key={`running-${item.jobId}`} className="pipeline-queue-entry-wrap">
<div className="pipeline-queue-item running queue-job-item">
@@ -3051,6 +3075,9 @@ export default function DashboardPage({
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
</strong>
<small>{getStatusLabel(item.status)}</small>
{clampedQueueProgress > 0 && (
<ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
)}
</div>
{hasScriptSummary ? (
<button
+41 -3
View File
@@ -412,6 +412,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
const [pendingRestartRow, setPendingRestartRow] = useState(null);
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
@@ -589,6 +590,25 @@ export default function HistoryPage({ refreshToken = 0 }) {
setSelectedJob(response.job);
};
const refreshDetailAfterReplacement = async (sourceJobId, replacementJobId = null) => {
if (!detailVisible) {
return;
}
const sourceId = Number(sourceJobId || 0);
const replacementId = Number(replacementJobId || 0);
const selectedId = Number(selectedJob?.id || 0);
const hasReplacement = replacementId > 0 && replacementId !== sourceId;
if (hasReplacement && selectedId === sourceId) {
const response = await api.getJob(replacementId, { includeLogs: false, forceRefresh: true });
setSelectedJob(response.job);
return;
}
await refreshDetailIfOpen(hasReplacement ? replacementId : sourceId);
};
// Conflict Modal Helpers
const mergeOutputFoldersForJob = (job, folders = []) => {
@@ -681,13 +701,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
try {
const response = await api.restartEncodeWithLastSettings(job.id, options);
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId);
if (result.queued) {
toastRef.current?.show({ severity: 'info', summary: 'Encode-Neustart in Queue', detail: result.queuePosition > 0 ? `Position ${result.queuePosition}` : 'In der Warteschlange eingeplant.', life: 3500 });
} else {
toastRef.current?.show({ severity: 'success', summary: 'Encode-Neustart gestartet', detail: 'Letzte bestätigte Einstellungen werden verwendet.', life: 3500 });
}
await load();
await refreshDetailIfOpen(job.id);
await refreshDetailAfterReplacement(job.id, replacementJobId);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Encode-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
@@ -703,7 +724,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
setActionBusy(true);
try {
await api.restartReviewFromRaw(job.id, options);
const response = await api.restartReviewFromRaw(job.id, options);
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId);
toastRef.current?.show({
severity: 'success',
summary: 'Review-Neustart',
@@ -711,7 +734,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
life: 3500
});
await load();
await refreshDetailIfOpen(job.id);
await refreshDetailAfterReplacement(job.id, replacementJobId);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
@@ -938,6 +961,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
};
const handleReencode = async (row) => {
const mediaType = resolveMediaType(row);
if (mediaType === 'bluray' || mediaType === 'dvd') {
setPendingRestartRow(row);
handleAssignOmdb(row);
return;
}
if (await maybeOpenOutputConflictModal(row, 'reencode')) {
return;
}
@@ -1044,6 +1073,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
setMetadataDialogContext(null);
await load();
await refreshDetailIfOpen(payload.jobId);
const rowToRestart = pendingRestartRow;
if (rowToRestart && Number(rowToRestart.id) === Number(payload.jobId)) {
setPendingRestartRow(null);
if (!(await maybeOpenOutputConflictModal(rowToRestart, 'reencode'))) {
await executeHistoryAction(rowToRestart, 'reencode');
}
}
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Zuweisung fehlgeschlagen', detail: error.message });
} finally {
@@ -1769,6 +1806,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
onHide={() => {
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
setPendingRestartRow(null);
}}
onSubmit={handleOmdbSubmit}
onSearch={handleOmdbSearch}
+140 -18
View File
@@ -2575,6 +2575,16 @@ body {
align-items: center;
}
@media (max-width: 1150px) and (min-width: 901px) {
.history-dv-toolbar {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) auto auto;
}
.history-dv-toolbar > *:first-child {
grid-column: 1 / -1;
}
}
.history-dv-layout-toggle {
display: flex;
justify-content: flex-end;
@@ -3475,6 +3485,23 @@ body {
gap: 0.15rem;
}
.subtitle-footnote-list {
display: grid;
gap: 0.2rem;
margin-top: 0.2rem;
width: 100%;
font-size: 0.72rem;
}
.subtitle-footnote-list .track-action-note {
margin-left: 0;
display: block;
width: 100%;
max-width: none;
line-height: 1.3;
font-size: inherit;
}
.track-action-note {
margin-left: 1.7rem;
color: var(--rip-muted);
@@ -4929,7 +4956,7 @@ body {
.explorer {
display: grid;
grid-template-columns: 260px 1fr;
grid-template-columns: 320px 1fr;
grid-template-rows: 1fr auto;
gap: 0;
border: 1px solid var(--rip-border, #d9bc8d);
@@ -5070,7 +5097,7 @@ body {
.explorer-row {
display: grid;
grid-template-columns: 28px 2fr repeat(2, minmax(80px, 1fr));
grid-template-columns: 28px 2fr minmax(90px, 0.8fr) minmax(90px, 0.8fr) minmax(170px, 1.4fr);
gap: 10px;
align-items: center;
padding: 8px 12px;
@@ -5086,6 +5113,14 @@ body {
border-left: 3px solid var(--primary-color);
padding-left: 9px;
}
.explorer-row.row-active {
background: color-mix(in srgb, var(--primary-color) 18%, transparent);
outline: 1px solid color-mix(in srgb, var(--primary-color) 40%, transparent);
outline-offset: -1px;
}
.explorer-row.row-active.selected {
background: color-mix(in srgb, var(--primary-color) 22%, transparent);
}
.explorer-row.header {
font-size: 11px;
text-transform: uppercase;
@@ -5135,6 +5170,39 @@ body {
}
.explorer-row.header .row-checkbox { pointer-events: none; }
.row-job {
min-width: 0;
text-align: left;
}
.row-job-assignment {
display: inline-block;
max-width: 100%;
font-size: 0.74rem;
color: var(--primary-color, #8e4d2d);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-job-empty {
font-size: 0.74rem;
color: var(--rip-muted, #6a4d38);
}
.explorer-row.row-locked {
opacity: 0.7;
cursor: default;
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 6px,
rgba(0,0,0,0.025) 6px,
rgba(0,0,0,0.025) 12px
) !important;
}
.footer-count {
font-weight: 600;
color: var(--rip-ink, #2f180f);
@@ -5216,6 +5284,20 @@ body {
}
.tree-caret.disabled { width: 18px; flex: 0 0 18px; }
.tree-checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 18px;
}
.tree-checkbox input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--primary-color);
border-radius: 3px;
}
.tree-icon.folder { color: var(--rip-brown-700, #6f3922); }
.tree-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -5293,9 +5375,7 @@ body {
/* ===== Job Card ===== */
.converter-jobs-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 0.6rem;
}
.converter-jobs-empty {
@@ -5305,15 +5385,16 @@ body {
.converter-job-card {
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 8px;
padding: 0.75rem 1rem;
background: var(--rip-panel, #fffaf1);
border-radius: 0.6rem;
padding: 0.6rem 0.7rem;
background: var(--rip-panel-soft, #fdf5e7);
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.6rem;
}
.converter-job-card.status-done {
.converter-job-card.status-done,
.converter-job-card.status-finished {
border-color: var(--green-300, #74c69d);
background: #f6fff9;
}
@@ -5330,7 +5411,7 @@ body {
.converter-job-card-header {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 0.3rem;
}
.converter-job-card-title-row {
@@ -5342,7 +5423,7 @@ body {
.converter-job-card-title {
font-weight: 600;
font-size: 0.9rem;
font-size: 0.92rem;
flex: 1;
min-width: 0;
overflow: hidden;
@@ -5351,7 +5432,7 @@ body {
}
.converter-job-card-meta {
font-size: 0.78rem;
font-size: 0.8rem;
color: var(--rip-muted, #6a4d38);
}
@@ -5364,8 +5445,10 @@ body {
.converter-job-card-progress-meta {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
font-size: 0.78rem;
color: var(--rip-muted, #6a4d38);
gap: 0.5rem;
flex-wrap: wrap;
}
.converter-job-card-error {
@@ -5377,7 +5460,7 @@ body {
}
.converter-job-card-output {
font-size: 0.78rem;
font-size: 0.8rem;
color: var(--rip-muted, #6a4d38);
}
@@ -5385,13 +5468,13 @@ body {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.25rem;
margin-top: 0.1rem;
}
/* Inline-Konfiguration für READY_TO_START Jobs */
.converter-job-card.is-ready {
border-color: var(--amber-400, #f59e0b);
background: var(--surface-50, #fafaf9);
border-color: #d9b26d;
background: #fff7e8;
}
.cjc-config {
@@ -5438,3 +5521,42 @@ body {
justify-content: flex-end;
padding-top: 0.25rem;
}
.cjc-section-label {
margin: 0 0 0.5rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--rip-muted, #6a4d38);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.converter-mb-results {
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 6px;
overflow-y: auto;
max-height: 12rem;
margin-bottom: 0.75rem;
}
.converter-mb-result-row {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--rip-border, #d9bc8d);
transition: background 0.12s;
}
.converter-mb-result-row:last-child {
border-bottom: none;
}
.converter-mb-result-row:hover {
background: var(--surface-100, #f5f0eb);
}
.converter-mb-result-row.selected {
background: rgba(142, 77, 45, 0.1);
}