0.14.0-4 Merger
This commit is contained in:
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.14.0-3",
|
||||
"version": "0.14.0-4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user