0.15.0-1 ---
This commit is contained in:
@@ -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`);
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user