0.12.0-16 misc fixes
This commit is contained in:
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
<strong>
|
||||
{jobUnassignTarget?.jobTitle || (jobUnassignTarget?.jobId ? `#${jobUnassignTarget.jobId}` : '-')}
|
||||
</strong>
|
||||
entfernt werden?
|
||||
</p>
|
||||
</Dialog>
|
||||
|
||||
{/* Neuer Ordner */}
|
||||
<Dialog
|
||||
header="Neuen Ordner erstellen"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user