0.14.0-4 Merger
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled

This commit is contained in:
2026-04-21 14:27:07 +00:00
parent caf6a638f7
commit c27a81cdb1
127 changed files with 10643 additions and 335 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.14.0-3",
"version": "0.14.0-4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.14.0-3",
"version": "0.14.0-4",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.14.0-3",
"version": "0.14.0-4",
"private": true,
"type": "module",
"scripts": {
+12
View File
@@ -808,6 +808,11 @@ export const api = {
},
async deleteJobFiles(jobId, target = 'both', options = {}) {
const includeRelated = Boolean(options?.includeRelated);
const selectedRawPaths = Array.isArray(options?.selectedRawPaths)
? options.selectedRawPaths
.map((item) => String(item || '').trim())
.filter(Boolean)
: null;
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
? options.selectedMoviePaths
.map((item) => String(item || '').trim())
@@ -818,6 +823,7 @@ export const api = {
body: JSON.stringify({
target,
includeRelated,
...(selectedRawPaths ? { selectedRawPaths } : {}),
...(selectedMoviePaths ? { selectedMoviePaths } : {})
})
});
@@ -834,6 +840,11 @@ export const api = {
const includeRelated = Boolean(options?.includeRelated);
const resetDriveState = Boolean(options?.resetDriveState);
const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs);
const selectedRawPaths = Array.isArray(options?.selectedRawPaths)
? options.selectedRawPaths
.map((item) => String(item || '').trim())
.filter(Boolean)
: null;
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
? options.selectedMoviePaths
.map((item) => String(item || '').trim())
@@ -851,6 +862,7 @@ export const api = {
resetDriveState,
preserveRawForImportJobs,
...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}),
...(selectedRawPaths ? { selectedRawPaths } : {}),
...(selectedMoviePaths ? { selectedMoviePaths } : {})
})
});
+223 -19
View File
@@ -1216,6 +1216,55 @@ export default function JobDetailDialog({
|| typeof onRestartEncode === 'function'
|| typeof onRestartReview === 'function'
|| typeof onReencode === 'function';
const isContainerWithDiskActions = isSeriesContainer && childJobs.length > 0;
const resolveChildActionState = (child) => {
const childStatusUpper = String(child?.status || '').trim().toUpperCase();
const childLastStateUpper = String(child?.last_state || '').trim().toUpperCase();
const childRunning = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(childStatusUpper);
const childCanResumeReady = Boolean(
(childStatusUpper === 'READY_TO_ENCODE' || childLastStateUpper === 'READY_TO_ENCODE')
&& !childRunning
&& typeof onResumeReady === 'function'
);
const childMediaType = resolveMediaType(child);
const childMkDone = Boolean(child?.ripSuccessful) || !child?.makemkvInfo || child?.makemkvInfo?.status === 'SUCCESS';
const childHasReencodeRawInput = childMediaType === 'cd'
? Boolean(child?.rawStatus?.exists || child?.raw_path || child?.output_path)
: Boolean(child?.rawStatus?.exists && child?.rawStatus?.isEmpty !== true);
const childCanReencode = !!(childHasReencodeRawInput && !childRunning && (childMediaType === 'cd' || childMkDone));
const childHasConfirmedPlan = Boolean(
child?.encodePlan
&& Array.isArray(child?.encodePlan?.titles)
&& child?.encodePlan?.titles.length > 0
&& Number(child?.encode_review_confirmed || 0) === 1
);
const childHasRestartInput = Boolean(child?.encode_input_path || child?.raw_path || child?.encodePlan?.encodeInputPath);
const childHasRaw = Boolean(child?.rawStatus?.exists || child?.raw_path || child?.encode_input_path);
const childCanRestartEncode = Boolean(childHasConfirmedPlan && childHasRestartInput && !childRunning && childHasRaw);
const childCanRestartReview = Boolean(
child?.rawStatus?.exists
&& child?.rawStatus?.isEmpty !== true
&& !childRunning
&& childMediaType !== 'audiobook'
&& typeof onRestartReview === 'function'
);
const childCanAssignMetadata = !childRunning;
const childOutputFolders = Array.isArray(child?.outputFolders) ? child.outputFolders : [];
const childSeriesOutputExisting = Number(child?.seriesOutputSummary?.existing || 0);
const childHasAnyOutput = childOutputFolders.length > 0
|| childSeriesOutputExisting > 0
|| Boolean(child?.outputStatus?.exists || child?.output_path);
return {
childRunning,
childCanResumeReady,
childCanReencode,
childCanRestartEncode,
childCanRestartReview,
childCanAssignMetadata,
childHasRaw,
childHasAnyOutput
};
};
const useAudioPosterLayout = isCd || isAudiobook || (isConverter && converterMediaType === 'audio');
return (
@@ -2039,7 +2088,7 @@ export default function JobDetailDialog({
) : null}
<section className="job-meta-block job-meta-block-full">
<h4>Logs</h4>
<h4>{isSeriesContainer ? 'Disks' : 'Logs'}</h4>
{isSeriesContainer && childJobs.length > 0 ? (
<>
<div className="job-json-grid">
@@ -2048,6 +2097,11 @@ export default function JobDetailDialog({
{childJobs.map((child) => {
const childDiscNumber = resolveSeriesDiscNumber(child);
const childDiscLabel = childDiscNumber ? `Disk ${childDiscNumber}` : 'Disk unbekannt';
const childActionState = resolveChildActionState(child);
const childCanShowGeneralEncodeActions = childActionState.childCanResumeReady
|| typeof onRestartEncode === 'function'
|| typeof onRestartReview === 'function'
|| typeof onReencode === 'function';
return (
<details key={`child-log-${child.id}`} className="episode-track-accordion">
<summary className="series-batch-episode-head">
@@ -2059,6 +2113,152 @@ export default function JobDetailDialog({
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={child.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={child.handbrakeInfo} />
</div>
<div className="actions-section">
{childCanShowGeneralEncodeActions ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Kodierung</div>
{childActionState.childCanResumeReady ? (
<div className="action-item">
<Button
label="Im Ripper öffnen"
icon="pi pi-window-maximize"
severity="info"
outlined
size="small"
onClick={() => onResumeReady?.(child)}
loading={actionBusy}
/>
<span className="action-desc">Öffnet den wartenden Job im Ripper 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?.(child)}
loading={actionBusy}
disabled={!childActionState.childCanRestartEncode}
/>
<span className="action-desc">Startet HandBrake mit den zuletzt gespeicherten Einstellungen direkt neu ohne Review.</span>
</div>
) : null}
{typeof onRestartReview === 'function' ? (
<div className="action-item">
<Button
label="Spur-Auswahl öffnen"
icon="pi pi-list"
severity="info"
outlined
size="small"
onClick={() => onRestartReview?.(child)}
loading={actionBusy}
disabled={!childActionState.childCanRestartReview}
/>
<span className="action-desc">Ö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={() => onReencode?.(child)}
loading={reencodeBusy}
disabled={!childActionState.childCanReencode}
/>
<span className="action-desc">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>
) : null}
{!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">
<Button
label={metadataAssignButtonLabel}
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onAssignOmdb?.(child)}
loading={omdbAssignBusy}
disabled={!childActionState.childCanAssignMetadata}
/>
<span className="action-desc">{metadataAssignActionDescription}</span>
</div>
</div>
) : null}
{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 (Eintrag bleibt)"
icon="pi pi-trash"
severity="warning"
outlined
size="small"
onClick={() => onDeleteFiles?.(child, 'raw')}
loading={actionBusy}
disabled={!childActionState.childHasRaw}
/>
<span className="action-desc">Löscht nur die RAW-Quelldatei dieser Disk (nicht die Folgen-Subjobs).</span>
</div>
<div className="action-item">
<Button
label="Folgen löschen (Eintrag bleibt)"
icon="pi pi-trash"
severity="warning"
outlined
size="small"
onClick={() => onDeleteFiles?.(child, 'movie')}
loading={actionBusy}
disabled={!childActionState.childHasAnyOutput}
/>
<span className="action-desc">Löscht die encodierten Folgen dieser Disk (inkl. Child-/Subjobs).</span>
</div>
<div className="action-item">
<Button
label="Beides löschen (Eintrag bleibt)"
icon="pi pi-times"
severity="danger"
size="small"
onClick={() => onDeleteFiles?.(child, 'both')}
loading={actionBusy}
disabled={!childActionState.childHasRaw && !childActionState.childHasAnyOutput}
/>
<span className="action-desc">Löscht Disk-RAW plus Folgen dieser Disk gemeinsam.</span>
</div>
</div>
) : null}
{typeof onDeleteEntry === 'function' ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-database" /> Historie</div>
<div className="action-item">
<Button
label="Historieneintrag löschen"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
onClick={() => onDeleteEntry?.(child, { includeRelated: true })}
loading={deleteEntryBusy}
disabled={childActionState.childRunning}
/>
<span className="action-desc">Öffnet die Löschauswahl für diese Disk (Child-Job inkl. Subjobs). Wenn dies die letzte Disk ist, wird auch der Container entfernt.</span>
</div>
</div>
) : null}
</div>
</details>
);
})}
@@ -2146,22 +2346,24 @@ export default function JobDetailDialog({
<p>Live-Log wird nur im Ripper während laufender Analyse/Rip/Encode angezeigt.</p>
)}
<h4>Aktionen</h4>
{queueLocked ? (
<div className="actions-row">
<Button
label="Aus Queue löschen"
icon="pi pi-times"
severity="danger"
outlined
size="small"
onClick={() => onRemoveFromQueue?.(job)}
loading={actionBusy}
disabled={typeof onRemoveFromQueue !== 'function'}
/>
</div>
) : (
<div className="actions-section">
{queueLocked || !isContainerWithDiskActions ? (
<>
<h4>Aktionen</h4>
{queueLocked ? (
<div className="actions-row">
<Button
label="Aus Queue löschen"
icon="pi pi-times"
severity="danger"
outlined
size="small"
onClick={() => onRemoveFromQueue?.(job)}
loading={actionBusy}
disabled={typeof onRemoveFromQueue !== 'function'}
/>
</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>
@@ -2363,8 +2565,10 @@ export default function JobDetailDialog({
</div>
) : null}
</div>
)}
</div>
)}
</>
) : null}
{!queueLocked ? (
<div className="action-delete-entry">
@@ -49,6 +49,10 @@ export default function MetadataSelectionDialog({
const [searchBusy, setSearchBusy] = useState(false);
const [searchError, setSearchError] = useState('');
const [submitError, setSubmitError] = useState('');
const [conflictState, setConflictState] = useState(null);
const [conflictExistingDiscNumber, setConflictExistingDiscNumber] = useState('');
const [conflictNewDiscNumber, setConflictNewDiscNumber] = useState('');
const [conflictError, setConflictError] = useState('');
const [seasonFilter, setSeasonFilter] = useState('');
const [discValidationError, setDiscValidationError] = useState('');
const activeSearchRequestRef = useRef(0);
@@ -108,6 +112,10 @@ export default function MetadataSelectionDialog({
setSearchBusy(false);
setSearchError('');
setSubmitError('');
setConflictState(null);
setConflictExistingDiscNumber('');
setConflictNewDiscNumber('');
setConflictError('');
setSeasonFilter('');
setDiscValidationError('');
setSelectedMetadataProvider(contextMetadataProvider);
@@ -127,7 +135,7 @@ export default function MetadataSelectionDialog({
if (submitError) {
setSubmitError('');
}
}, [manualDiscNumber, seasonFilter, selected, metadataProvider]);
}, [manualDiscNumber, seasonFilter, selected, metadataProvider, conflictExistingDiscNumber, conflictNewDiscNumber]);
const rows = useMemo(() => {
const base = Array.isArray(context?.metadataCandidates)
@@ -222,9 +230,49 @@ export default function MetadataSelectionDialog({
}
};
const handleSubmitError = (error, pendingPayload = null) => {
const details = Array.isArray(error?.details) ? error.details : [];
const duplicateDetail = details.find(
(detail) => String(detail?.code || '').trim().toUpperCase() === 'METADATA_DUPLICATE_FOUND'
);
if (duplicateDetail) {
const existingJob = duplicateDetail?.existingJob && typeof duplicateDetail.existingJob === 'object'
? duplicateDetail.existingJob
: null;
const nextPendingPayload = pendingPayload && typeof pendingPayload === 'object'
? pendingPayload
: (conflictState?.pendingPayload && typeof conflictState.pendingPayload === 'object'
? conflictState.pendingPayload
: null);
const pendingDiscNumber = parseDiscNumber(nextPendingPayload?.discNumber);
const existingDiscNumber = parseDiscNumber(existingJob?.discNumber);
setConflictState({
existingJob,
pendingPayload: nextPendingPayload
});
setConflictExistingDiscNumber(existingDiscNumber ? String(existingDiscNumber) : '');
setConflictNewDiscNumber(pendingDiscNumber ? String(pendingDiscNumber) : '');
setConflictError('');
setSubmitError('');
return;
}
const hasDuplicateDiscError = details.some(
(detail) => String(detail?.code || '').trim().toUpperCase() === 'SERIES_DISC_ALREADY_EXISTS'
);
const message = String(error?.message || 'Metadaten konnten nicht uebernommen werden.').trim()
|| 'Metadaten konnten nicht uebernommen werden.';
if (hasDuplicateDiscError) {
setSubmitError(message);
return;
}
setSubmitError(message);
};
const handleSubmit = async () => {
setDiscValidationError('');
setSubmitError('');
setConflictError('');
if (requiresDiscNumber && !hasValidDiscNumber) {
setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.');
return;
@@ -265,14 +313,65 @@ export default function MetadataSelectionDialog({
try {
await onSubmit(payload);
} catch (error) {
const details = Array.isArray(error?.details) ? error.details : [];
const hasDuplicateDiscError = details.some((detail) => String(detail?.code || '').trim().toUpperCase() === 'SERIES_DISC_ALREADY_EXISTS');
const message = String(error?.message || 'Metadaten konnten nicht uebernommen werden.').trim() || 'Metadaten konnten nicht uebernommen werden.';
if (hasDuplicateDiscError) {
setSubmitError(message);
return;
}
setSubmitError(message);
handleSubmitError(error, payload);
}
};
const handleForceDuplicateSubmit = async () => {
const pendingPayload = conflictState?.pendingPayload && typeof conflictState.pendingPayload === 'object'
? conflictState.pendingPayload
: null;
if (!pendingPayload) {
setConflictError('Konflikt-Zustand ist unvollständig. Bitte erneut suchen.');
return;
}
setConflictError('');
setSubmitError('');
try {
await onSubmit({
...pendingPayload,
duplicateAction: 'allow_new'
});
} catch (error) {
handleSubmitError(error, pendingPayload);
}
};
const handleSubmitMultipartMovie = async () => {
const pendingPayload = conflictState?.pendingPayload && typeof conflictState.pendingPayload === 'object'
? conflictState.pendingPayload
: null;
const existingJob = conflictState?.existingJob && typeof conflictState.existingJob === 'object'
? conflictState.existingJob
: null;
if (!pendingPayload || !existingJob?.id) {
setConflictError('Konflikt-Zustand ist unvollständig. Bitte erneut suchen.');
return;
}
const existingDisc = parseDiscNumber(conflictExistingDiscNumber);
const newDisc = parseDiscNumber(conflictNewDiscNumber);
if (!existingDisc || !newDisc) {
setConflictError('Für Multipart movie müssen beide Disc-Nummern gesetzt werden (>= 1).');
return;
}
if (existingDisc === newDisc) {
setConflictError('Disc-Nummern müssen unterschiedlich sein.');
return;
}
setConflictError('');
setSubmitError('');
try {
await onSubmit({
...pendingPayload,
duplicateAction: 'multipart_movie',
existingJobId: existingJob.id,
discNumber: newDisc,
existingDiscNumber: existingDisc
});
} catch (error) {
handleSubmitError(error, pendingPayload);
}
};
@@ -280,6 +379,80 @@ export default function MetadataSelectionDialog({
onHide?.();
};
if (conflictState) {
const existingJob = conflictState?.existingJob && typeof conflictState.existingJob === 'object'
? conflictState.existingJob
: null;
return (
<Dialog
header="Metadaten bereits vorhanden"
visible={visible}
onHide={handleHideRequest}
style={{ width: '38rem', maxWidth: '96vw' }}
className="metadata-selection-dialog"
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
modal
>
<p>
Für diese Film-Metadaten existiert bereits ein Job in der Historie.
</p>
<p>
Bestehender Job: <strong>#{existingJob?.id || '-'}</strong>
{' | '}
<strong>{existingJob?.title || '-'}</strong>
{existingJob?.year ? ` (${existingJob.year})` : ''}
</p>
<p>
Status: {existingJob?.status || '-'}
{existingJob?.discNumber ? ` | Disc ${existingJob.discNumber}` : ''}
</p>
<div className="metadata-grid">
<InputText
value={conflictExistingDiscNumber}
onChange={(event) => setConflictExistingDiscNumber(event.target.value)}
placeholder="Disc-Nr bestehender Job"
inputMode="numeric"
disabled={busy}
/>
<InputText
value={conflictNewDiscNumber}
onChange={(event) => setConflictNewDiscNumber(event.target.value)}
placeholder="Disc-Nr neuer Job"
inputMode="numeric"
disabled={busy}
/>
</div>
{conflictError ? <small className="error-text">{conflictError}</small> : null}
{submitError ? <small className="error-text">{submitError}</small> : null}
<div className="dialog-actions">
<Button
label="Zurück zur Auswahl"
severity="secondary"
text
onClick={() => {
setConflictState(null);
setConflictError('');
}}
disabled={busy}
/>
<Button
label="Übernahme erzwingen"
severity="secondary"
outlined
onClick={handleForceDuplicateSubmit}
loading={busy}
/>
<Button
label="Multipart movie"
icon="pi pi-sitemap"
onClick={handleSubmitMultipartMovie}
loading={busy}
/>
</div>
</Dialog>
);
}
return (
<Dialog
header="Metadaten auswählen"
+142 -16
View File
@@ -183,6 +183,29 @@ function isSeriesBatchChildHistoryRow(row) {
return Boolean(encodePlan?.seriesBatchChild || encodePlan?.seriesBatchVirtualEpisode);
}
function isContainerHistoryRow(row) {
const kind = String(
row?.job_kind
|| row?.jobKind
|| row?.encodePlan?.jobKind
|| ''
).trim().toLowerCase();
return kind === 'dvd_series_container' || kind === 'multipart_movie_container';
}
function isSeriesChildHistoryRow(row) {
const kind = String(
row?.job_kind
|| row?.jobKind
|| row?.encodePlan?.jobKind
|| ''
).trim().toLowerCase();
if (kind === 'dvd_series_child' || kind === 'multipart_movie_child') {
return true;
}
return Boolean(normalizeJobId(row?.parent_job_id)) && isSeriesVideoJob(row);
}
function formatDurationSeconds(totalSeconds) {
const parsed = Number(totalSeconds);
if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -315,6 +338,9 @@ function hasAudioOutput(row) {
}
function getOutputLabelForRow(row) {
if (isSeriesVideoJob(row)) {
return 'Folgen-Datei(en)';
}
if (resolveMediaType(row) === 'audiobook') {
return 'Audiobook-Datei(en)';
}
@@ -325,6 +351,9 @@ function getOutputLabelForRow(row) {
}
function getOutputShortLabelForRow(row) {
if (isSeriesVideoJob(row)) {
return 'Folgen';
}
if (resolveMediaType(row) === 'audiobook') {
return 'Audiobook';
}
@@ -449,6 +478,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [deleteEntryPreview, setDeleteEntryPreview] = useState(null);
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
const [deleteEntryIncludeRelated, setDeleteEntryIncludeRelated] = useState(true);
const [deleteEntrySelectedRawPaths, setDeleteEntrySelectedRawPaths] = useState([]);
const [deleteEntrySelectedMoviePaths, setDeleteEntrySelectedMoviePaths] = useState([]);
const [downloadBusyTarget, setDownloadBusyTarget] = useState(null);
const [downloadFolderBusyPath, setDownloadFolderBusyPath] = useState(null);
@@ -899,9 +930,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
// ── File deletion ──────────────────────────────────────────────────────────
const handleDeleteFiles = async (row, target) => {
const includeRelated = isSeriesVideoJob(row);
const isContainerRow = isContainerHistoryRow(row);
const isSeriesChildRow = isSeriesChildHistoryRow(row);
const includeRelated = isContainerRow
|| (isSeriesChildRow && (target === 'movie' || target === 'both'));
if (target === 'movie') {
if (target === 'movie' && !isSeriesChildRow) {
const outputFolders = await loadOutputFoldersForJob(row);
if (outputFolders.length > 1) {
await openConflictModal(row, 'delete_output', 'delete', outputFolders);
@@ -913,9 +947,15 @@ export default function HistoryPage({ refreshToken = 0 }) {
const outputShortLabel = getOutputShortLabelForRow(row);
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
const title = row.title || row.detected_title || `Job #${row.id}`;
const scopeSuffix = includeRelated
const scopeSuffix = isContainerRow
? '\nScope: kompletter Serien-Container (alle zugehörigen RAWs/Outputs).'
: '';
: isSeriesChildRow && target === 'raw'
? '\nScope: nur RAW dieses Disk-Jobs.'
: isSeriesChildRow && (target === 'movie' || target === 'both')
? '\nScope: diese Disk inkl. zugehöriger Child-/Subjobs (Folgen).'
: includeRelated
? '\nScope: verknüpfte Jobs werden einbezogen.'
: '';
const confirmed = await confirmModal({
header: 'Dateien löschen',
message: `${label} für "${title}" wirklich löschen?${scopeSuffix}`,
@@ -929,8 +969,20 @@ export default function HistoryPage({ refreshToken = 0 }) {
setActionBusy(true);
try {
const response = await api.deleteJobFiles(row.id, target, { includeRelated });
const summary = response.summary || {};
let summary = {};
if (target === 'both' && isSeriesChildRow) {
// Disk-RAW nur am Disk-Job löschen, Folgen jedoch diskweit inkl. Subjobs.
const rawResponse = await api.deleteJobFiles(row.id, 'raw', { includeRelated: false });
const movieResponse = await api.deleteJobFiles(row.id, 'movie', { includeRelated: true });
summary = {
target: 'both',
raw: rawResponse?.summary?.raw || {},
movie: movieResponse?.summary?.movie || {}
};
} else {
const response = await api.deleteJobFiles(row.id, target, { includeRelated });
summary = response?.summary || {};
}
const rawSummary = summary.raw || {};
const movieSummary = summary.movie || {};
const deletedSomething = target === 'raw'
@@ -1367,24 +1419,35 @@ export default function HistoryPage({ refreshToken = 0 }) {
setDeleteEntryPreview(null);
setDeleteEntryPreviewLoading(false);
setDeleteEntryTargetBusy(null);
setDeleteEntryIncludeRelated(true);
setDeleteEntrySelectedRawPaths([]);
setDeleteEntrySelectedMoviePaths([]);
};
const handleDeleteEntry = async (row) => {
const handleDeleteEntry = async (row, options = {}) => {
const jobId = Number(row?.id || 0);
if (!jobId) {
return;
}
const includeRelated = options?.includeRelated !== false;
setDeleteEntryDialogRow(row);
setDeleteEntryPreview(null);
setDeleteEntryIncludeRelated(includeRelated);
setDeleteEntrySelectedRawPaths([]);
setDeleteEntrySelectedMoviePaths([]);
setDeleteEntryDialogVisible(true);
setDeleteEntryPreviewLoading(true);
setDeleteEntryBusy(true);
try {
const response = await api.getJobDeletePreview(jobId, { includeRelated: true });
const response = await api.getJobDeletePreview(jobId, { includeRelated });
const preview = response?.preview || null;
setDeleteEntryPreview(preview);
const rawCandidates = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : [];
const defaultSelectedRawPaths = rawCandidates
.filter((item) => Boolean(item?.exists))
.map((item) => String(item?.path || '').trim())
.filter(Boolean);
setDeleteEntrySelectedRawPaths(defaultSelectedRawPaths);
const movieCandidates = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [];
const defaultSelectedMoviePaths = movieCandidates
.filter((item) => Boolean(item?.exists))
@@ -1401,6 +1464,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
setDeleteEntryDialogVisible(false);
setDeleteEntryDialogRow(null);
setDeleteEntryPreview(null);
setDeleteEntryIncludeRelated(true);
setDeleteEntrySelectedRawPaths([]);
} finally {
setDeleteEntryPreviewLoading(false);
setDeleteEntryBusy(false);
@@ -1416,6 +1481,19 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!jobId) {
return;
}
if (
(normalizedTarget === 'raw' || normalizedTarget === 'both')
&& rawDeleteSelectionEnabled
&& deleteEntrySelectedRawPaths.length === 0
) {
toastRef.current?.show({
severity: 'warn',
summary: 'RAW-Auswahl erforderlich',
detail: 'Bitte mindestens einen RAW-Pfad zum Löschen auswählen.',
life: 3500
});
return;
}
if (
(normalizedTarget === 'movie' || normalizedTarget === 'both')
&& previewMovieExisting.length > 0
@@ -1434,7 +1512,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
setDeleteEntryTargetBusy(normalizedTarget);
try {
const response = await api.deleteJobEntry(jobId, normalizedTarget, {
includeRelated: true,
includeRelated: deleteEntryIncludeRelated,
selectedRawPaths: deleteEntrySelectedRawPaths,
selectedMoviePaths: deleteEntrySelectedMoviePaths
});
const deletedJobIds = Array.isArray(response?.deletedJobIds) ? response.deletedJobIds : [];
@@ -1489,6 +1568,24 @@ export default function HistoryPage({ refreshToken = 0 }) {
});
};
const toggleDeleteRawPathSelection = (rawPath, checked) => {
const normalizedPath = String(rawPath || '').trim();
if (!normalizedPath) {
return;
}
setDeleteEntrySelectedRawPaths((previous) => {
const nextSet = new Set((Array.isArray(previous) ? previous : []).map((item) => String(item || '').trim()).filter(Boolean));
if (checked) {
nextSet.add(normalizedPath);
} else {
nextSet.delete(normalizedPath);
}
return previewRawPaths
.map((item) => String(item?.path || '').trim())
.filter((itemPath) => itemPath && nextSet.has(itemPath));
});
};
const handleRemoveFromQueue = async (row) => {
const jobId = normalizeJobId(row?.id || row);
if (!jobId) {
@@ -1848,12 +1945,20 @@ export default function HistoryPage({ refreshToken = 0 }) {
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
const previewMovieExisting = previewMoviePaths.filter((item) => Boolean(item?.exists));
const previewRawDisplay = previewRawExisting.length > 0 ? previewRawExisting : previewRawPaths.slice(0, 1);
const rawDeleteSelectionEnabled = previewRawExisting.length > 2;
const previewRawDisplay = rawDeleteSelectionEnabled
? previewRawPaths
: (previewRawExisting.length > 0 ? previewRawExisting : previewRawPaths.slice(0, 1));
const previewMovieDisplay = previewMovieExisting.length > 0 ? previewMovieExisting : previewMoviePaths.slice(0, 1);
const selectedDeleteRawPathSet = useMemo(
() => new Set(deleteEntrySelectedRawPaths.map((item) => String(item || '').trim()).filter(Boolean)),
[deleteEntrySelectedRawPaths]
);
const selectedDeleteMoviePathSet = useMemo(
() => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)),
[deleteEntrySelectedMoviePaths]
);
const rawDeleteSelectionRequired = rawDeleteSelectionEnabled && deleteEntrySelectedRawPaths.length === 0;
const movieDeleteSelectionRequired = previewMovieExisting.length > 0 && deleteEntrySelectedMoviePaths.length === 0;
const deleteTargetActionsDisabled = deleteEntryPreviewLoading || Boolean(deleteEntryTargetBusy) || !deleteEntryPreview;
const deleteEntryOutputLabel = getOutputLabelForRow(deleteEntryDialogRow);
@@ -2010,10 +2115,26 @@ export default function HistoryPage({ refreshToken = 0 }) {
<ul className="history-delete-preview-list">
{previewRawDisplay.map((item) => (
<li key={`delete-raw-${item.path}`}>
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
{item.exists ? 'vorhanden' : 'nicht gefunden'}
</span>
{' '}| {item.path}
{rawDeleteSelectionEnabled && item.exists ? (
<label className="history-delete-preview-checkbox-row">
<input
type="checkbox"
checked={selectedDeleteRawPathSet.has(String(item.path || '').trim())}
onChange={(event) => toggleDeleteRawPathSelection(item.path, Boolean(event.target.checked))}
/>
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
{item.exists ? 'vorhanden' : 'nicht gefunden'}
</span>
<span>| {item.path}</span>
</label>
) : (
<>
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
{item.exists ? 'vorhanden' : 'nicht gefunden'}
</span>
{' '}| {item.path}
</>
)}
</li>
))}
</ul>
@@ -2021,6 +2142,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
})() : (
<small className="history-dv-subtle">Keine RAW-Pfade.</small>
)}
{rawDeleteSelectionEnabled ? (
<small className="history-dv-subtle">
RAW-Auswahl aktiv: {deleteEntrySelectedRawPaths.length}/{previewRawExisting.length} ausgewählt.
</small>
) : null}
</div>
<div>
@@ -2069,7 +2195,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined
onClick={() => confirmDeleteEntry('raw')}
loading={deleteEntryTargetBusy === 'raw'}
disabled={deleteTargetActionsDisabled}
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired}
/>
<Button
label={`Eintrag + nur ${deleteEntryOutputShortLabel}`}
@@ -2086,7 +2212,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
severity="danger"
onClick={() => confirmDeleteEntry('both')}
loading={deleteEntryTargetBusy === 'both'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
/>
<Button
label="Nur Eintrag löschen"
+40 -126
View File
@@ -24,7 +24,6 @@ import { confirmModal } from '../utils/confirmModal';
import { isConverterJob, isSeriesVideoJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy';
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
const activeCdDriveStates = ['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING'];
const ripperStatuses = new Set([
'ANALYZING',
@@ -1008,7 +1007,6 @@ export default function RipperPage({
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [metadataDialogReassignMode, setMetadataDialogReassignMode] = useState(false);
const [duplicateJobDialog, setDuplicateJobDialog] = useState({ visible: false, existingJob: null, pendingPayload: null });
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
const [cdRipPanelJobId, setCdRipPanelJobId] = useState(null);
@@ -1131,7 +1129,10 @@ export default function RipperPage({
if (isConverterJob(job)) {
return false;
}
if (String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container') {
if (
String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'
|| String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container'
) {
return false;
}
const normalizedStatus = String(job?.status || '').trim().toUpperCase();
@@ -2302,6 +2303,7 @@ export default function RipperPage({
setBusy(true);
try {
if (metadataDialogReassignMode) {
const duplicateAction = String(payload?.duplicateAction || '').trim().toLowerCase();
const providerRaw = String(payload?.metadataProvider || '').trim().toLowerCase();
const nextWorkflowKind = String(
payload?.workflowKind
@@ -2309,6 +2311,12 @@ export default function RipperPage({
|| ''
).trim().toLowerCase();
const reassignJob = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(payload?.jobId)) || null;
const reassignMediaProfile = String(
reassignJob?.media_type
|| reassignJob?.mediaType
|| effectiveMetadataDialogContext?.mediaProfile
|| ''
).trim().toLowerCase();
const currentWorkflowKind = String(
reassignJob?.makemkvInfo?.analyzeContext?.selectedMetadata?.workflowKind
|| reassignJob?.makemkvInfo?.analyzeContext?.workflowKind
@@ -2331,7 +2339,8 @@ export default function RipperPage({
&& providerRaw
&& currentProvider !== providerRaw
);
if (shouldRestartReview) {
const forceFilmMetadataPath = ['dvd', 'bluray'].includes(reassignMediaProfile) && nextWorkflowKind === 'film';
if (duplicateAction || shouldRestartReview || forceFilmMetadataPath) {
await api.selectMetadata(payload);
} else {
await api.assignJobOmdb(payload.jobId, payload);
@@ -2355,58 +2364,7 @@ export default function RipperPage({
};
const handleMetadataSubmit = async (payload) => {
const payloadProvider = String(payload?.metadataProvider || '').trim().toLowerCase();
const currentJobMediaProfile = String(effectiveMetadataDialogContext?.mediaProfile || '').trim().toLowerCase();
const isSeriesDvdPayload = payloadProvider === 'tmdb' && (currentJobMediaProfile === 'dvd' || currentJobMediaProfile === 'bluray');
if (metadataDialogReassignMode) {
await doSelectMetadata(payload);
return;
}
// Serien-Discs laufen ohne Film-Duplikatdialog weiter; die Disk-Pruefung
// erfolgt serverseitig gegen den vorhandenen Staffel-Container.
if (isSeriesDvdPayload) {
await doSelectMetadata(payload, { suppressErrorToast: true });
return;
}
// Duplikatprüfung: nur bei OMDB-Auswahl mit imdbId sinnvoll
const searchTitle = payload.title || '';
const searchImdbId = payload.imdbId || null;
if (searchTitle) {
try {
const historyResponse = await api.getJobs({ search: searchTitle, limit: 50, lite: true });
const historyJobs = Array.isArray(historyResponse?.jobs) ? historyResponse.jobs : [];
const duplicate = historyJobs.find((job) => {
if (normalizeJobId(job.id) === normalizeJobId(payload.jobId)) {
return false; // aktueller Job selbst
}
// Gleicher Titel / imdbId?
const titleMatch = searchImdbId
? (job.imdb_id && job.imdb_id === searchImdbId)
: (String(job.title || '').toLowerCase() === searchTitle.toLowerCase());
if (!titleMatch) {
return false;
}
// Gleiches Medium? Verschiedene Medien (DVD vs. Bluray) → kein Duplikat
if (!currentJobMediaProfile || currentJobMediaProfile === 'other') {
return false;
}
const jobMediaType = resolveMediaType(job);
return jobMediaType === currentJobMediaProfile;
});
if (duplicate) {
setDuplicateJobDialog({ visible: true, existingJob: duplicate, pendingPayload: payload });
return;
}
} catch (_error) {
// Bei Fehler einfach fortfahren
}
}
await doSelectMetadata(payload);
await doSelectMetadata(payload, { suppressErrorToast: true });
};
const handleMusicBrainzSearch = async (query) => {
@@ -2527,8 +2485,7 @@ export default function RipperPage({
.filter((drv) => !String(drv.path || '').startsWith('__virtual__'))
.sort((a, b) => (a.path || '').localeCompare(b.path || ''));
}, [knownDrives, pipeline?.cdDrives, pipeline?.detectedDiscs, device, state, lastNonCdDiscEvent]);
const isDriveActive = driveActiveStates.includes(state);
const canRescan = !isDriveActive;
const canRescan = !busy;
const canOpenMetadataModal = Boolean(defaultMetadataDialogContext?.jobId);
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
const queueIdleJobs = Array.isArray(queueState?.idleJobs) ? queueState.idleJobs : [];
@@ -2813,7 +2770,7 @@ export default function RipperPage({
severity="secondary"
className="drive-list-action-btn"
loading={isRescanBusy}
disabled={isRescanBusy || isDriveActive}
disabled={isRescanBusy}
onClick={() => handleRescanDrive(drivePath)}
title="Laufwerk neu lesen"
aria-label="Laufwerk neu lesen"
@@ -3028,6 +2985,18 @@ export default function RipperPage({
const seriesTitleSuffix = mediaIndicator.isSeriesDvd
? `${seriesSeasonLabel}${seriesDiscLabel}${seriesContainerLabel}`
: '';
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
const isMultipartMovie = Number(job?.is_multipart_movie || 0) === 1
|| jobKindRaw === 'multipart_movie_child'
|| jobKindRaw === 'multipart_movie_container';
const multipartDiscNumber = Number(job?.disc_number || selectedMetadata?.discNumber || 0) || null;
const multipartContainerId = normalizeJobId(job?.parent_job_id);
const multipartSubtitle = isMultipartMovie
? (
`${multipartDiscNumber ? ` Disc ${multipartDiscNumber}` : ''}`
+ `${multipartContainerId ? ` | Container #${multipartContainerId}` : ''}`
).trim()
: '';
const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
@@ -3106,9 +3075,13 @@ export default function RipperPage({
{mediaIndicator.isSeriesDvd ? (
<small className="ripper-job-subtitle">{seriesTitleSuffix || '-'}</small>
) : null}
{!mediaIndicator.isSeriesDvd && isMultipartMovie ? (
<small className="ripper-job-subtitle">{multipartSubtitle || 'Multipart Movie'}</small>
) : null}
<div className="ripper-job-badges">
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{mediaIndicator.isSeriesDvd ? <Tag value={seriesTagLabel} severity="secondary" /> : null}
{isMultipartMovie ? <Tag value="Multipart Movie" severity="secondary" /> : null}
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
{normalizedStatus === 'READY_TO_ENCODE'
@@ -3245,6 +3218,9 @@ export default function RipperPage({
+ (mediaIndicator.isSeriesDvd ? seriesDiscLabel : '')
+ (mediaIndicator.isSeriesDvd ? seriesContainerLabel : '')
+ (mediaIndicator.isSeriesDvd ? seriesTmdbLabel : '')
+ (!mediaIndicator.isSeriesDvd && isMultipartMovie
? `${multipartDiscNumber ? ` | Disc ${multipartDiscNumber}` : ''}${multipartContainerId ? ` | Container #${multipartContainerId}` : ''}`
: '')
+ (mediaIndicator.isSeriesDvd ? '' : `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`)
))
)}
@@ -3253,6 +3229,7 @@ export default function RipperPage({
<div className="ripper-job-badges">
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{mediaIndicator.isSeriesDvd ? <Tag value={seriesTagLabel} severity="secondary" /> : null}
{isMultipartMovie ? <Tag value="Multipart Movie" severity="secondary" /> : null}
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
{normalizedStatus === 'READY_TO_ENCODE'
@@ -3334,12 +3311,12 @@ export default function RipperPage({
<div className="pipeline-queue-item running queue-job-item">
<div className="pipeline-queue-item-main">
<strong>
#{item.jobId} | {item.title || `Job #${item.jobId}`}
{item.title || `Job #${item.jobId}`}
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
</strong>
{item.subtitle ? <small>{item.subtitle}</small> : null}
<small>{getStatusLabel(item.status)}</small>
<small>#{item.jobId} | {getStatusLabel(item.status)}</small>
{clampedQueueProgress > 0 && (
<ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
)}
@@ -3451,12 +3428,12 @@ export default function RipperPage({
) : (
<>
<strong>
{item.position || '-'} | #{item.jobId} | {item.title || `Job #${item.jobId}`}
{item.title || `Job #${item.jobId}`}
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
</strong>
{item.subtitle ? <small>{item.subtitle}</small> : null}
<small>{item.actionLabel || item.action || '-'} | {getStatusLabel(item.status)}</small>
<small>{item.position || '-'} | #{item.jobId} | {item.actionLabel || item.action || '-'} | {getStatusLabel(item.status)}</small>
</>
)}
</div>
@@ -3848,69 +3825,6 @@ export default function RipperPage({
busy={busy}
/>
<Dialog
header="Titel bereits in der Historie"
visible={duplicateJobDialog.visible}
onHide={() => setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null })}
style={{ width: '30rem', maxWidth: '96vw' }}
modal
>
{(() => {
const pendingPayload = duplicateJobDialog.pendingPayload || {};
const metadataProvider = String(pendingPayload?.metadataProvider || '').trim().toLowerCase();
const seasonNumber = Number(pendingPayload?.seasonNumber || 0) || 0;
const tmdbId = Number(pendingPayload?.tmdbId || 0) || 0;
const isSeriesContainerAssign = metadataProvider === 'tmdb' && seasonNumber > 0 && tmdbId > 0;
return (
<>
<p>
<strong>{duplicateJobDialog.existingJob?.title || duplicateJobDialog.pendingPayload?.title}</strong> ist bereits als Job #{duplicateJobDialog.existingJob?.id} in der Historie vorhanden.
</p>
<p>
{isSeriesContainerAssign
? 'Dieser Job kann dem vorhandenen Serien-Container automatisch zugewiesen werden.'
: 'Neuen Job anlegen oder mit dem vorhandenen Eintrag weiterarbeiten?'}
</p>
<div className="dialog-actions">
<Button
label={isSeriesContainerAssign ? 'Vorhandenem Container zuweisen' : 'Vorhandenen Job öffnen'}
icon={isSeriesContainerAssign ? 'pi pi-sitemap' : 'pi pi-history'}
onClick={async () => {
const payload = duplicateJobDialog.pendingPayload;
const jobId = duplicateJobDialog.existingJob?.id;
setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null });
if (isSeriesContainerAssign) {
await doSelectMetadata(payload);
return;
}
navigate(`/history?open=${jobId}`);
}}
/>
{isSeriesContainerAssign ? (
<Button
label="Abbrechen"
severity="secondary"
outlined
onClick={() => setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null })}
/>
) : (
<Button
label="Neuen Job anlegen"
severity="secondary"
outlined
onClick={async () => {
const payload = duplicateJobDialog.pendingPayload;
setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null });
await doSelectMetadata(payload);
}}
/>
)}
</div>
</>
);
})()}
</Dialog>
<Dialog
header={cancelCleanupDialog?.target === 'raw' ? 'Rip abgebrochen' : 'Encode abgebrochen'}
visible={Boolean(cancelCleanupDialog.visible)}
+23 -1
View File
@@ -55,7 +55,21 @@ function normalizeJobKind(value) {
if (!raw) {
return null;
}
if (['audiobook', 'cd', 'dvd', 'bluray', 'converter_audio', 'converter_video', 'converter_iso'].includes(raw)) {
if (
[
'audiobook',
'cd',
'dvd',
'bluray',
'dvd_series_container',
'dvd_series_child',
'multipart_movie_container',
'multipart_movie_child',
'converter_audio',
'converter_video',
'converter_iso'
].includes(raw)
) {
return raw;
}
if (raw === 'converter') {
@@ -92,6 +106,14 @@ function mediaTypeFromJobKind(jobKind) {
if (normalized.startsWith('converter_')) {
return 'converter';
}
if (
normalized === 'dvd_series_container'
|| normalized === 'dvd_series_child'
|| normalized === 'multipart_movie_container'
|| normalized === 'multipart_movie_child'
) {
return null;
}
return normalized;
}