0.15.0-1 ---

This commit is contained in:
2026-04-23 19:10:33 +00:00
parent 120a9e6ad1
commit 3e5840f329
7 changed files with 886 additions and 255 deletions
+10
View File
@@ -650,6 +650,16 @@ export const api = {
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async updateMultipartMergeSettings(jobId, settings = {}) {
const result = await request(`/pipeline/multipart-merge/${jobId}/settings`, {
method: 'POST',
body: JSON.stringify({
deleteInputsAfterMerge: Boolean(settings?.deleteInputsAfterMerge)
})
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
getMultipartMergePreview(jobId) {
return request(`/pipeline/multipart-merge/${jobId}/preview`);
},
+34 -5
View File
@@ -5,6 +5,7 @@ import { Tag } from 'primereact/tag';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
import mergeIndicatorIcon from '../assets/media-merge.svg';
import { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation';
import { isSeriesVideoJob } from '../utils/jobTaxonomy';
@@ -1233,6 +1234,14 @@ export default function JobDetailDialog({
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container';
const isMultipartContainer = jobKindRaw === 'multipart_movie_container';
const isMultipartChild = jobKindRaw === 'multipart_movie_child'
|| (
Number(job?.is_multipart_movie || 0) === 1
&& Boolean(normalizePositiveInteger(job?.parent_job_id))
&& !isMultipartMergeJob
&& !isMultipartContainer
);
const isMultipartJob = Boolean(isMultipartContainer || isMultipartMergeJob || isMultipartChild);
const isDiskContainer = isSeriesContainer || isMultipartContainer;
const allChildJobs = Array.isArray(job?.children)
? [...job.children].sort(compareSeriesChildJobsByDisc)
@@ -1446,10 +1455,18 @@ export default function JobDetailDialog({
};
};
const useAudioPosterLayout = isCd || isAudiobook || (isConverter && converterMediaType === 'audio');
const dialogHeader = (
<span className="job-step-cell">
<span>{`Job #${job?.id || ''}`}</span>
{isMultipartJob ? (
<img src={mergeIndicatorIcon} alt="Multipart" title="Multipart" className="media-indicator-icon" />
) : null}
</span>
);
return (
<Dialog
header={`Job #${job?.id || ''}`}
header={dialogHeader}
visible={visible}
onHide={onHide}
style={{ width: '70rem', maxWidth: '96vw' }}
@@ -2301,6 +2318,18 @@ export default function JobDetailDialog({
const childDiscNumber = resolveSeriesDiscNumber(child);
const childDiscLabel = childDiscNumber ? `Disk ${childDiscNumber}` : 'Disk unbekannt';
const childActionState = resolveChildActionState(child);
const childOutputDeleteLabel = isMultipartContainer
? 'Movie löschen (Eintrag bleibt)'
: 'Folgen löschen (Eintrag bleibt)';
const childRawDeleteDescription = isMultipartContainer
? 'Löscht nur die RAW-Quelldatei dieser Disk (nicht die Movie-Subjobs).'
: 'Löscht nur die RAW-Quelldatei dieser Disk (nicht die Folgen-Subjobs).';
const childMovieDeleteDescription = isMultipartContainer
? 'Löscht den encodierten Movie-Output dieser Disk (inkl. Child-/Subjobs).'
: 'Löscht die encodierten Folgen dieser Disk (inkl. Child-/Subjobs).';
const childBothDeleteDescription = isMultipartContainer
? 'Löscht Disk-RAW plus Movie-Output dieser Disk gemeinsam.'
: 'Löscht Disk-RAW plus Folgen dieser Disk gemeinsam.';
const childCanShowGeneralEncodeActions = childActionState.childCanResumeReady
|| typeof onRestartEncode === 'function'
|| typeof onRestartReview === 'function'
@@ -2413,11 +2442,11 @@ export default function JobDetailDialog({
loading={actionBusy}
disabled={!childActionState.childHasRaw}
/>
<span className="action-desc">Löscht nur die RAW-Quelldatei dieser Disk (nicht die Folgen-Subjobs).</span>
<span className="action-desc">{childRawDeleteDescription}</span>
</div>
<div className="action-item">
<Button
label="Folgen löschen (Eintrag bleibt)"
label={childOutputDeleteLabel}
icon="pi pi-trash"
severity="warning"
outlined
@@ -2426,7 +2455,7 @@ export default function JobDetailDialog({
loading={actionBusy}
disabled={!childActionState.childHasAnyOutput}
/>
<span className="action-desc">Löscht die encodierten Folgen dieser Disk (inkl. Child-/Subjobs).</span>
<span className="action-desc">{childMovieDeleteDescription}</span>
</div>
<div className="action-item">
<Button
@@ -2438,7 +2467,7 @@ export default function JobDetailDialog({
loading={actionBusy}
disabled={!childActionState.childHasRaw && !childActionState.childHasAnyOutput}
/>
<span className="action-desc">Löscht Disk-RAW plus Folgen dieser Disk gemeinsam.</span>
<span className="action-desc">{childBothDeleteDescription}</span>
</div>
</div>
) : null}
+274 -81
View File
@@ -16,6 +16,7 @@ import ReencodeConflictModal from '../components/ReencodeConflictModal';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
import mergeIndicatorIcon from '../assets/media-merge.svg';
import {
getStatusLabel,
getStatusSeverity,
@@ -213,6 +214,23 @@ function isMultipartMergeHistoryRow(row) {
return kind === 'multipart_movie_merge';
}
function isMultipartChildHistoryRow(row) {
const kind = String(
row?.job_kind
|| row?.jobKind
|| row?.encodePlan?.jobKind
|| ''
).trim().toLowerCase();
return kind === 'multipart_movie_child';
}
function isMultipartHistoryRow(row) {
if (isMultipartContainerHistoryRow(row) || isMultipartMergeHistoryRow(row) || isMultipartChildHistoryRow(row)) {
return true;
}
return Number(row?.is_multipart_movie || 0) === 1;
}
function isSeriesChildHistoryRow(row) {
const kind = String(
row?.job_kind
@@ -361,6 +379,9 @@ function getOutputLabelForRow(row) {
if (isMultipartMergeHistoryRow(row)) {
return 'Merge-Datei(en)';
}
if (isMultipartContainerHistoryRow(row) || isMultipartChildHistoryRow(row)) {
return 'Movie-Datei(en)';
}
if (isSeriesVideoJob(row)) {
return 'Folgen-Datei(en)';
}
@@ -377,6 +398,9 @@ function getOutputShortLabelForRow(row) {
if (isMultipartMergeHistoryRow(row)) {
return 'Merge';
}
if (isMultipartContainerHistoryRow(row) || isMultipartChildHistoryRow(row)) {
return 'Movie';
}
if (isSeriesVideoJob(row)) {
return 'Folgen';
}
@@ -958,6 +982,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
const handleDeleteFiles = async (row, target) => {
const isContainerRow = isContainerHistoryRow(row);
const isMultipartContainerRow = isMultipartContainerHistoryRow(row);
const isMultipartChildRow = isMultipartChildHistoryRow(row);
const isSeriesChildRow = isSeriesChildHistoryRow(row);
const includeRelated = isContainerRow
|| (isSeriesChildRow && (target === 'movie' || target === 'both'));
@@ -981,7 +1006,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
: isSeriesChildRow && target === 'raw'
? '\nScope: nur RAW dieses Disk-Jobs.'
: isSeriesChildRow && (target === 'movie' || target === 'both')
? '\nScope: diese Disk inkl. zugehöriger Child-/Subjobs (Folgen).'
? (isMultipartChildRow
? '\nScope: diese Disk inkl. zugehöriger Child-/Subjobs (Movie).'
: '\nScope: diese Disk inkl. zugehöriger Child-/Subjobs (Folgen).')
: includeRelated
? '\nScope: verknüpfte Jobs werden einbezogen.'
: '';
@@ -1509,6 +1536,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) {
return;
}
const isMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow);
if (isMergeJob && (normalizedTarget === 'raw' || normalizedTarget === 'both')) {
return;
}
const jobId = Number(deleteEntryDialogRow?.id || 0);
if (!jobId) {
return;
@@ -1647,6 +1678,70 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const handleCancelJob = async (row) => {
const jobId = normalizeJobId(row?.id || row);
if (!jobId) {
return;
}
const runningStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']);
const initialStatus = String(row?.status || row?.last_state || '').trim().toUpperCase();
const fetchLatestJob = async () => {
try {
const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true });
return response?.job && typeof response.job === 'object' ? response.job : null;
} catch (_error) {
return null;
}
};
setActionBusy(true);
try {
const response = await api.cancelPipeline(jobId);
const result = response?.result && typeof response.result === 'object' ? response.result : {};
// Persisted job status can lag behind the cancel request for a short moment.
// Poll briefly so the history/detail view reflects cancellation without full page reload.
let latestJob = await fetchLatestJob();
if (runningStates.has(initialStatus)) {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
for (let attempt = 0; attempt < 8; attempt += 1) {
const latestStatus = String(latestJob?.status || latestJob?.last_state || '').trim().toUpperCase();
if (latestStatus && !runningStates.has(latestStatus)) {
break;
}
await wait(250);
latestJob = await fetchLatestJob();
}
}
await load();
if (detailVisible && Number(selectedJob?.id || 0) === Number(jobId) && latestJob) {
setSelectedJob(latestJob);
} else {
await refreshDetailIfOpen(jobId);
}
toastRef.current?.show({
severity: 'success',
summary: result?.queuedOnly ? 'Aus Queue entfernt' : 'Job abgebrochen',
detail: result?.queuedOnly
? `Job #${jobId} wurde aus der Warteschlange entfernt.`
: `Job #${jobId} wurde abgebrochen.`,
life: 3500
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Abbruch fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setActionBusy(false);
}
};
const handleRestoreMultipartMerge = async (row) => {
const containerJobId = normalizeJobId(row?.id || row);
if (!containerJobId) {
@@ -1685,10 +1780,25 @@ export default function HistoryPage({ refreshToken = 0 }) {
: null;
if (mergeSummary) {
const mergeState = String(mergeSummary?.state || '').trim().toLowerCase();
const mergeActive = Boolean(mergeSummary?.active) || mergeState === 'active';
const hasMergeJob = Boolean(mergeSummary?.hasJob);
const isCompleted = Boolean(mergeSummary?.completed);
const filesReady = Boolean(mergeSummary?.ready);
const mergeVisibleInRipper = hasMergeJob && !isCompleted;
if (mergeActive) {
return (
<div className="history-status-tag-wrap">
<Tag value="Merging" severity="warning" />
</div>
);
}
if (mergeState === 'done' || isCompleted) {
return (
<div className="history-status-tag-wrap">
<Tag value="Fertig" severity="success" />
</div>
);
}
if (mergeVisibleInRipper) {
return (
<div className="history-status-tag-wrap">
@@ -1711,13 +1821,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
</div>
);
}
if (mergeState === 'done') {
return (
<div className="history-status-tag-wrap">
<Tag value="Fertig" severity="success" />
</div>
);
}
}
}
if (isMultipartMergeHistoryRow(row) && normalizeStatus(row?.status) === 'ENCODING') {
@@ -1763,6 +1866,13 @@ export default function HistoryPage({ refreshToken = 0 }) {
);
};
const renderStateChip = (label, valueLabel, tone = 'tone-warn', iconClass = 'pi-spinner pi-spin') => (
<span className={`history-dv-chip ${tone}`}>
<i className={`pi ${iconClass}`} aria-hidden="true" />
<span>{label}: {valueLabel}</span>
</span>
);
const renderSeriesOutputChip = (row) => {
const summary = row?.seriesOutputSummary || null;
const label = isMultipartContainerHistoryRow(row) ? 'Movie' : 'Episoden';
@@ -1804,6 +1914,36 @@ export default function HistoryPage({ refreshToken = 0 }) {
return renderPresenceChip('RAW', true, 'tone-ok', suffix || ` (${safeExisting}/${safeExpected || safeExisting})`);
};
const renderMultipartMergeChip = (row) => {
if (!isMultipartHistoryRow(row)) {
return null;
}
const mergeSummary = row?.seriesChildSummary?.merge && typeof row.seriesChildSummary.merge === 'object'
? row.seriesChildSummary.merge
: null;
const mergeState = String(mergeSummary?.state || '').trim().toLowerCase();
const isMultipartContainer = isMultipartContainerHistoryRow(row);
const rowStatus = String(row?.status || row?.last_state || '').trim().toUpperCase();
const mergeRunning = mergeState === 'active' || rowStatus === 'ENCODING';
if (mergeRunning) {
return renderStateChip('Merge', 'Läuft', 'tone-warn', 'pi-spinner pi-spin');
}
const isCompleted = Boolean(
isMultipartContainer
? (mergeSummary?.completed || mergeSummary?.outputExists || mergeState === 'done')
: (
mergeSummary?.completed
|| mergeSummary?.outputExists
|| row?.outputStatus?.exists
|| rowStatus === 'FINISHED'
)
);
if (isCompleted) {
return renderPresenceChip('Merge', true, 'tone-ok');
}
return renderPresenceChip('Merge', false, 'tone-no');
};
const renderSupplementalInfo = (row) => {
if (resolveMediaType(row) === 'cd') {
const cdDetails = resolveCdDetails(row);
@@ -1901,6 +2041,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
const tmdbLabel = isSeriesDvd && selectedMetadata?.tmdbId
? `TMDb ${selectedMetadata.tmdbId}`
: null;
const multipartLabel = isMultipartHistoryRow(row)
? (isMultipartMergeHistoryRow(row) ? 'Merge' : 'Multipart')
: null;
const subtitle = isCdJob
? [
`#${row?.id || '-'}`,
@@ -1912,7 +2055,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
`#${row?.id || '-'}`,
row?.year || '-',
isSeriesDvd ? seasonLabel : (row?.imdb_id || '-'),
tmdbLabel
tmdbLabel,
multipartLabel
].filter(Boolean).join(' | ');
return (
@@ -1933,7 +2077,17 @@ export default function HistoryPage({ refreshToken = 0 }) {
<div className="history-dv-main">
<div className="history-dv-head">
<div className="history-dv-title-block">
<strong className="history-dv-title">{row?.title || row?.detected_title || '-'}</strong>
<strong className="history-dv-title">
{isMultipartHistoryRow(row) ? (
<img
src={mergeIndicatorIcon}
alt="Multipart"
title="Multipart"
className="media-indicator-icon"
/>
) : null}
<span>{row?.title || row?.detected_title || '-'}</span>
</strong>
<small className="history-dv-subtle">{subtitle}</small>
</div>
{renderStatusTag(row)}
@@ -1956,6 +2110,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
? renderSeriesOutputChip(row)
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
{renderMultipartMergeChip(row)}
</div>
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
@@ -1985,6 +2140,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
const tmdbLabel = isSeriesDvd && selectedMetadata?.tmdbId
? `TMDb ${selectedMetadata.tmdbId}`
: null;
const multipartLabel = isMultipartHistoryRow(row)
? (isMultipartMergeHistoryRow(row) ? 'Merge' : 'Multipart')
: null;
const subtitle = isCdJob
? [
`#${row?.id || '-'}`,
@@ -1996,7 +2154,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
`#${row?.id || '-'}`,
row?.year || '-',
isSeriesDvd ? seasonLabel : (row?.imdb_id || '-'),
tmdbLabel
tmdbLabel,
multipartLabel
].filter(Boolean).join(' | ');
return (
@@ -2020,7 +2179,17 @@ export default function HistoryPage({ refreshToken = 0 }) {
<div className="history-dv-grid-main">
<div className="history-dv-head">
<strong className="history-dv-title">{row?.title || row?.detected_title || '-'}</strong>
<strong className="history-dv-title">
{isMultipartHistoryRow(row) ? (
<img
src={mergeIndicatorIcon}
alt="Multipart"
title="Multipart"
className="media-indicator-icon"
/>
) : null}
<span>{row?.title || row?.detected_title || '-'}</span>
</strong>
</div>
<small className="history-dv-subtle">{subtitle}</small>
@@ -2042,6 +2211,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
? renderSeriesOutputChip(row)
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
{renderMultipartMergeChip(row)}
</div>
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
@@ -2061,9 +2231,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
const deleteEntryIsMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow);
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
const previewMovieExisting = previewMoviePaths.filter((item) => Boolean(item?.exists));
const rawDeleteSelectionEnabled = previewRawExisting.length > 2;
const rawDeleteSelectionEnabled = !deleteEntryIsMergeJob && previewRawExisting.length > 2;
const previewRawDisplay = rawDeleteSelectionEnabled
? previewRawPaths
: (previewRawExisting.length > 0 ? previewRawExisting : previewRawPaths.slice(0, 1));
@@ -2076,7 +2247,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
() => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)),
[deleteEntrySelectedMoviePaths]
);
const rawDeleteSelectionRequired = rawDeleteSelectionEnabled && deleteEntrySelectedRawPaths.length === 0;
const rawDeleteSelectionRequired = !deleteEntryIsMergeJob
&& rawDeleteSelectionEnabled
&& deleteEntrySelectedRawPaths.length === 0;
const movieDeleteSelectionRequired = previewMovieExisting.length > 0 && deleteEntrySelectedMoviePaths.length === 0;
const deleteTargetActionsDisabled = deleteEntryPreviewLoading || Boolean(deleteEntryTargetBusy) || !deleteEntryPreview;
const deleteEntryOutputLabel = getOutputLabelForRow(deleteEntryDialogRow);
@@ -2168,9 +2341,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
onDownloadArchive={handleDownloadArchive}
onDownloadOutputFolder={handleDownloadOutputFolder}
onRemoveFromQueue={handleRemoveFromQueue}
onCancel={handleCancelJob}
onRestoreMultipartMerge={handleRestoreMultipartMerge}
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
actionBusy={actionBusy}
cancelBusy={actionBusy}
restoreMergeBusy={actionBusy}
omdbAssignBusy={omdbAssignBusy}
cdMetadataAssignBusy={cdMetadataAssignBusy}
@@ -2200,7 +2375,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
{`Es werden ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge entfernt.`}
</p>
<small className="history-dv-subtle">
Hinweis: In diesem Dialog löschen alle ersten drei Buttons immer den Historien-Eintrag plus ausgewählte Dateien.
{deleteEntryIsMergeJob
? 'Hinweis: Die Aktion "Eintrag + Merge" löscht den Historien-Eintrag plus ausgewählte Merge-Datei(en).'
: 'Hinweis: In diesem Dialog löschen alle ersten drei Buttons immer den Historien-Eintrag plus ausgewählte Dateien.'}
</small>
{deleteEntryDialogRow ? (
@@ -2228,46 +2405,48 @@ export default function HistoryPage({ refreshToken = 0 }) {
)}
</div>
<div>
<h4>RAW</h4>
{previewRawPaths.length > 0 ? (() => {
return (
<ul className="history-delete-preview-list">
{previewRawDisplay.map((item) => (
<li key={`delete-raw-${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>
);
})() : (
<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>
{!deleteEntryIsMergeJob ? (
<div>
<h4>RAW</h4>
{previewRawPaths.length > 0 ? (() => {
return (
<ul className="history-delete-preview-list">
{previewRawDisplay.map((item) => (
<li key={`delete-raw-${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>
);
})() : (
<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>
) : null}
<div>
<h4>{deleteEntryOutputShortLabel}</h4>
@@ -2308,32 +2487,46 @@ export default function HistoryPage({ refreshToken = 0 }) {
)}
<div className="dialog-actions">
<Button
label="Eintrag + nur RAW-Dateien"
icon="pi pi-trash"
severity="warning"
outlined
onClick={() => confirmDeleteEntry('raw')}
loading={deleteEntryTargetBusy === 'raw'}
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired}
/>
<Button
label={`Eintrag + nur ${deleteEntryOutputShortLabel}`}
icon="pi pi-trash"
severity="warning"
outlined
onClick={() => confirmDeleteEntry('movie')}
loading={deleteEntryTargetBusy === 'movie'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
/>
<Button
label={`Eintrag + RAW & ${deleteEntryOutputShortLabel}`}
icon="pi pi-times"
severity="danger"
onClick={() => confirmDeleteEntry('both')}
loading={deleteEntryTargetBusy === 'both'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
/>
{deleteEntryIsMergeJob ? (
<Button
label="Eintrag + Merge"
icon="pi pi-trash"
severity="warning"
outlined
onClick={() => confirmDeleteEntry('movie')}
loading={deleteEntryTargetBusy === 'movie'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
/>
) : (
<>
<Button
label="Eintrag + nur RAW-Dateien"
icon="pi pi-trash"
severity="warning"
outlined
onClick={() => confirmDeleteEntry('raw')}
loading={deleteEntryTargetBusy === 'raw'}
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired}
/>
<Button
label={`Eintrag + nur ${deleteEntryOutputShortLabel}`}
icon="pi pi-trash"
severity="warning"
outlined
onClick={() => confirmDeleteEntry('movie')}
loading={deleteEntryTargetBusy === 'movie'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
/>
<Button
label={`Eintrag + RAW & ${deleteEntryOutputShortLabel}`}
icon="pi pi-times"
severity="danger"
onClick={() => confirmDeleteEntry('both')}
loading={deleteEntryTargetBusy === 'both'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
/>
</>
)}
<Button
label="Nur Eintrag löschen"
icon="pi pi-database"
+55 -3
View File
@@ -603,6 +603,11 @@ function extractMultipartMergeSources(job) {
return ordered;
}
function shouldDeleteMultipartMergeInputsAfterSuccess(job) {
const encodePlan = getEncodePlan(job) || {};
return Boolean(encodePlan?.deleteInputsAfterMerge);
}
function formatMergeTrackLanguage(language) {
const normalized = String(language || '').trim().toLowerCase();
if (!normalized || normalized === 'und') {
@@ -2112,6 +2117,24 @@ export default function RipperPage({
}
};
const handleUpdateMultipartMergeSettings = async (jobId, settings = {}) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setJobBusy(normalizedJobId, true);
try {
await api.updateMultipartMergeSettings(normalizedJobId, settings);
await refreshPipeline();
await loadRipperJobs();
setExpandedJobId(normalizedJobId);
} catch (error) {
showError(error);
} finally {
setJobBusy(normalizedJobId, false);
}
};
const handleSaveActivationBytes = async () => {
const { checksum, jobId } = activationBytesDialog;
const bytes = activationBytesInput.trim().toLowerCase();
@@ -3352,14 +3375,17 @@ export default function RipperPage({
|| null
)
: null;
const rawProgress = hasSeriesBatchProgress
const isCancelledState = normalizedStatus === 'CANCELLED' || jobState === 'CANCELLED';
const rawProgress = isCancelledState
? 0
: (hasSeriesBatchProgress
? seriesBatchAggregateProgress
: Number(pipelineForJob?.progress ?? 0);
: Number(pipelineForJob?.progress ?? 0));
const clampedProgress = Number.isFinite(rawProgress)
? Math.max(0, Math.min(100, rawProgress))
: 0;
const progressLabel = `${Math.round(clampedProgress)}%`;
const etaLabel = String(
const etaLabel = isCancelledState ? '' : String(
hasSeriesBatchProgress
? (runningSeriesChild?.eta || pipelineForJob?.eta || '')
: (pipelineForJob?.eta || '')
@@ -3382,6 +3408,12 @@ export default function RipperPage({
isMultipartMerge && multipartMergeLiveLogLoadingByJobId[jobId]
);
const isMultipartMergeRunning = isMultipartMerge && normalizedStatus === 'ENCODING';
const deleteInputsAfterMerge = isMultipartMerge
? shouldDeleteMultipartMergeInputsAfterSuccess(job)
: false;
const canEditMultipartMergeSettings = !busyJobIds.has(jobId)
&& !processingStates.includes(normalizedStatus)
&& !isQueued;
const multipartMergeCommandPreview = String(multipartMergePreview?.command?.preview || '').trim();
const multipartMergeChapterEntries = Array.isArray(multipartMergePreview?.chapters?.entries)
? multipartMergePreview.chapters.entries
@@ -3677,6 +3709,26 @@ export default function RipperPage({
</div>
</div>
)}
{!isMultipartMergeRunning ? (
<div className="multipart-merge-settings">
<label className="multipart-merge-setting-toggle">
<input
type="checkbox"
checked={deleteInputsAfterMerge}
onChange={(event) => {
void handleUpdateMultipartMergeSettings(jobId, {
deleteInputsAfterMerge: Boolean(event.target?.checked)
});
}}
disabled={!canEditMultipartMergeSettings}
/>
<span>Input-Dateien nach erfolgreichem Merge löschen</span>
</label>
<small className="muted-inline">
Bei Erfolg werden die Quell-Dateien der Discs aus dem Dateisystem entfernt.
</small>
</div>
) : null}
<div className="multipart-merge-actions">
{(normalizedStatus === 'READY_TO_START' || normalizedStatus === 'READY_TO_ENCODE') ? (
<Button