0.15.0 MovieMerge
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.14.0-4",
|
||||
"version": "0.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.14.0-4",
|
||||
"version": "0.15.0",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.14.0-4",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -455,6 +455,8 @@ function App() {
|
||||
...(prev || {}),
|
||||
queue: message.payload || null
|
||||
}));
|
||||
setRipperJobsRefreshToken((prev) => prev + 1);
|
||||
setHistoryJobsRefreshToken((prev) => prev + 1);
|
||||
}
|
||||
|
||||
if (message.type === 'DISC_DETECTED') {
|
||||
|
||||
@@ -640,6 +640,26 @@ export const api = {
|
||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||
return result;
|
||||
},
|
||||
async reorderMultipartMergeSources(jobId, orderedSourceJobIds = []) {
|
||||
const result = await request(`/pipeline/multipart-merge/${jobId}/reorder`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
orderedSourceJobIds: Array.isArray(orderedSourceJobIds) ? orderedSourceJobIds : []
|
||||
})
|
||||
});
|
||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||
return result;
|
||||
},
|
||||
getMultipartMergePreview(jobId) {
|
||||
return request(`/pipeline/multipart-merge/${jobId}/preview`);
|
||||
},
|
||||
async restoreMultipartMergeJob(containerJobId) {
|
||||
const result = await request(`/pipeline/multipart-merge/${containerJobId}/restore`, {
|
||||
method: 'POST'
|
||||
});
|
||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||
return result;
|
||||
},
|
||||
async confirmEncodeReview(jobId, payload = {}) {
|
||||
const result = await request(`/pipeline/confirm-encode/${jobId}`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Merge job">
|
||||
<defs>
|
||||
<linearGradient id="mergebg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#eaf6ff"/>
|
||||
<stop offset="100%" stop-color="#86b6d6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="30" fill="url(#mergebg)"/>
|
||||
<path d="M14 18h15l7 8h10" fill="none" stroke="#1f2f3f" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 46h15l7-8h10" fill="none" stroke="#1f2f3f" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M45 24l7 2-7 2" fill="none" stroke="#1f2f3f" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M45 38l7-2-7-2" fill="none" stroke="#1f2f3f" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="34" cy="32" r="3" fill="#1f2f3f"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 898 B |
@@ -25,6 +25,7 @@ const GENERAL_TOOL_KEYS = new Set([
|
||||
'mediainfo_command',
|
||||
'handbrake_command',
|
||||
'ffmpeg_command',
|
||||
'mkvmerge_command',
|
||||
'ffprobe_command',
|
||||
'handbrake_restart_delete_incomplete_output',
|
||||
'script_test_timeout_ms'
|
||||
@@ -530,6 +531,15 @@ const CONVERTER_SCAN_EXTENSION_OPTIONS = [
|
||||
'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov',
|
||||
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
|
||||
];
|
||||
const READONLY_CLI_SETTING_KEYS = new Set([
|
||||
'makemkv_command',
|
||||
'mediainfo_command',
|
||||
'handbrake_command',
|
||||
'ffmpeg_command',
|
||||
'ffprobe_command',
|
||||
'cdparanoia_command',
|
||||
'mkvmerge_command'
|
||||
]);
|
||||
|
||||
function parseConverterScanExtensions(value) {
|
||||
const tokens = String(value || '')
|
||||
@@ -630,6 +640,10 @@ function isNotificationEventToggleSetting(setting) {
|
||||
}
|
||||
|
||||
function isReadonlySetting(setting) {
|
||||
const key = normalizeSettingKey(setting?.key);
|
||||
if (READONLY_CLI_SETTING_KEYS.has(key)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
// API gibt validation als parsed object zurück, nicht validation_json als String
|
||||
const v = setting?.validation;
|
||||
@@ -656,6 +670,8 @@ function SettingField({
|
||||
const ownerKey = ownerSetting?.key;
|
||||
const pathHasValue = Boolean(String(value ?? '').trim());
|
||||
const isNotificationToggleBox = variant === 'notification-toggle' && setting?.type === 'boolean';
|
||||
const readonlySetting = isReadonlySetting(setting);
|
||||
const isDisabled = Boolean(disabled || readonlySetting);
|
||||
|
||||
return (
|
||||
<div className={`setting-row${isNotificationToggleBox ? ' notification-toggle-box' : ''}`}>
|
||||
@@ -668,15 +684,17 @@ function SettingField({
|
||||
<InputSwitch
|
||||
id={setting.key}
|
||||
checked={Boolean(value)}
|
||||
disabled={disabled}
|
||||
onChange={disabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
||||
disabled={isDisabled}
|
||||
onChange={isDisabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<label htmlFor={setting.key} className={disabled ? 'setting-label--readonly' : undefined}>
|
||||
<label htmlFor={setting.key} className={isDisabled ? 'setting-label--readonly' : undefined}>
|
||||
{setting.label}
|
||||
{setting.required && <span className="required">*</span>}
|
||||
{disabled ? <span className="setting-badge--coming-soon"> (bald)</span> : null}
|
||||
{isDisabled ? (
|
||||
<span className="setting-badge--coming-soon">{readonlySetting ? ' (gesperrt)' : ' (bald)'}</span>
|
||||
) : null}
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -687,7 +705,9 @@ function SettingField({
|
||||
<InputText
|
||||
id={setting.key}
|
||||
value={value ?? ''}
|
||||
onChange={(event) => onChange?.(setting.key, event.target.value)}
|
||||
readOnly={isDisabled}
|
||||
disabled={isDisabled}
|
||||
onChange={isDisabled ? undefined : (event) => onChange?.(setting.key, event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -719,7 +739,8 @@ function SettingField({
|
||||
<InputNumber
|
||||
id={setting.key}
|
||||
value={value ?? 0}
|
||||
onValueChange={(event) => onChange?.(setting.key, event.value)}
|
||||
disabled={isDisabled}
|
||||
onValueChange={isDisabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
||||
mode="decimal"
|
||||
useGrouping={false}
|
||||
/>
|
||||
@@ -729,8 +750,8 @@ function SettingField({
|
||||
<InputSwitch
|
||||
id={setting.key}
|
||||
checked={Boolean(value)}
|
||||
disabled={disabled}
|
||||
onChange={disabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
||||
disabled={isDisabled}
|
||||
onChange={isDisabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -742,7 +763,8 @@ function SettingField({
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
disabled={isDisabled}
|
||||
onChange={isDisabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -778,8 +800,8 @@ function SettingField({
|
||||
id={ownerKey}
|
||||
value={ownerValue ?? ''}
|
||||
placeholder="z.B. michael:ripster"
|
||||
disabled={!pathHasValue}
|
||||
onChange={(event) => onChange?.(ownerKey, event.target.value)}
|
||||
disabled={isDisabled || !pathHasValue}
|
||||
onChange={isDisabled ? undefined : (event) => onChange?.(ownerKey, event.target.value)}
|
||||
/>
|
||||
{ownerError ? (
|
||||
<small className="error-text">{ownerError}</small>
|
||||
|
||||
@@ -736,6 +736,7 @@ function resolveSeriesDiscNumber(job) {
|
||||
metadataContext?.selectedMetadata?.discNumber
|
||||
?? metadataContext?.analyzeContext?.seriesLookupHint?.discNumber
|
||||
?? job?.encodePlan?.discNumber
|
||||
?? job?.disc_number
|
||||
?? null
|
||||
);
|
||||
}
|
||||
@@ -757,9 +758,27 @@ function isSeriesBatchEpisodeChildJob(job) {
|
||||
return hasParent && hasEpisodeMarker;
|
||||
}
|
||||
|
||||
function isMultipartMergeChildJob(job) {
|
||||
const jobKind = String(job?.job_kind || '').trim().toLowerCase();
|
||||
if (jobKind === 'multipart_movie_merge') {
|
||||
return true;
|
||||
}
|
||||
const plan = job?.encodePlan && typeof job.encodePlan === 'object'
|
||||
? job.encodePlan
|
||||
: null;
|
||||
if (String(plan?.jobKind || '').trim().toLowerCase() === 'multipart_movie_merge') {
|
||||
return true;
|
||||
}
|
||||
const mode = String(plan?.mode || job?.handbrakeInfo?.mode || '').trim().toLowerCase();
|
||||
return mode === 'multipart_merge';
|
||||
}
|
||||
|
||||
function buildSeriesContainerDiskChildren(children = []) {
|
||||
const rows = Array.isArray(children) ? children : [];
|
||||
const diskCandidates = rows.filter((child) => !isSeriesBatchEpisodeChildJob(child));
|
||||
const diskCandidates = rows.filter((child) => (
|
||||
!isSeriesBatchEpisodeChildJob(child)
|
||||
&& !isMultipartMergeChildJob(child)
|
||||
));
|
||||
const byDisc = new Map();
|
||||
const withoutDisc = [];
|
||||
|
||||
@@ -946,6 +965,7 @@ export default function JobDetailDialog({
|
||||
onDownloadArchive,
|
||||
onDownloadOutputFolder,
|
||||
onRemoveFromQueue,
|
||||
onRestoreMultipartMerge,
|
||||
onCancel,
|
||||
isQueued = false,
|
||||
omdbAssignBusy = false,
|
||||
@@ -955,6 +975,7 @@ export default function JobDetailDialog({
|
||||
cancelBusy = false,
|
||||
reencodeBusy = false,
|
||||
deleteEntryBusy = false,
|
||||
restoreMergeBusy = false,
|
||||
downloadBusyTarget = null,
|
||||
downloadFolderBusyPath = null
|
||||
}) {
|
||||
@@ -1170,17 +1191,121 @@ export default function JobDetailDialog({
|
||||
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
|
||||
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
|
||||
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
|
||||
const isSeriesContainer = isDvdSeries && String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
|
||||
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
|
||||
const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container';
|
||||
const isMultipartContainer = jobKindRaw === 'multipart_movie_container';
|
||||
const isMultipartMergeJob = isMultipartMergeChildJob(job);
|
||||
const isDiskContainer = isSeriesContainer || isMultipartContainer;
|
||||
const allChildJobs = Array.isArray(job?.children)
|
||||
? [...job.children].sort(compareSeriesChildJobsByDisc)
|
||||
: [];
|
||||
const childJobs = Array.isArray(job?.children)
|
||||
? (
|
||||
isSeriesContainer
|
||||
? buildSeriesContainerDiskChildren(job.children)
|
||||
: [...job.children].sort(compareSeriesChildJobsByDisc)
|
||||
isDiskContainer
|
||||
? buildSeriesContainerDiskChildren(allChildJobs)
|
||||
: allChildJobs
|
||||
)
|
||||
: [];
|
||||
const mergeChildJobs = isMultipartContainer
|
||||
? allChildJobs.filter((child) => isMultipartMergeChildJob(child))
|
||||
: [];
|
||||
const seriesChildSummary = job?.seriesChildSummary || null;
|
||||
const seriesBackupSummary = isSeriesContainer ? seriesChildSummary?.backup : null;
|
||||
const seriesEncodeSummary = isSeriesContainer ? seriesChildSummary?.encode : null;
|
||||
const seriesBackupSummary = isDiskContainer ? seriesChildSummary?.backup : null;
|
||||
const seriesEncodeSummary = isDiskContainer ? seriesChildSummary?.encode : null;
|
||||
const seriesMergeSummary = isMultipartContainer && seriesChildSummary?.merge && typeof seriesChildSummary.merge === 'object'
|
||||
? seriesChildSummary.merge
|
||||
: null;
|
||||
const multipartMergeSummary = isMultipartContainer
|
||||
? (
|
||||
seriesMergeSummary
|
||||
|| (() => {
|
||||
const inputExpected = childJobs.length;
|
||||
const inputReady = childJobs.reduce((count, child) => (
|
||||
count + (
|
||||
child?.outputStatus?.exists
|
||||
|| Boolean(String(child?.output_path || '').trim())
|
||||
|| (Array.isArray(child?.outputFolders) && child.outputFolders.length > 0)
|
||||
? 1
|
||||
: 0
|
||||
)
|
||||
), 0);
|
||||
const hasJob = mergeChildJobs.length > 0;
|
||||
const active = mergeChildJobs.some((child) => String(child?.status || child?.last_state || '').trim().toUpperCase() === 'ENCODING');
|
||||
const completed = mergeChildJobs.some((child) => (
|
||||
String(child?.status || child?.last_state || '').trim().toUpperCase() === 'FINISHED'
|
||||
|| child?.encodeSuccess
|
||||
|| child?.outputStatus?.exists
|
||||
|| Boolean(String(child?.output_path || '').trim())
|
||||
|| String(child?.handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'
|
||||
));
|
||||
const ready = inputExpected >= 2 && inputReady >= inputExpected;
|
||||
let state = 'missing';
|
||||
if (active) {
|
||||
state = 'active';
|
||||
} else if (completed) {
|
||||
state = 'done';
|
||||
} else if (ready) {
|
||||
state = hasJob ? 'ready' : 'restorable';
|
||||
} else if (hasJob) {
|
||||
state = 'blocked';
|
||||
}
|
||||
return {
|
||||
hasJob,
|
||||
jobId: hasJob ? Number(mergeChildJobs[0]?.id || 0) || null : null,
|
||||
active,
|
||||
completed,
|
||||
ready,
|
||||
inputReady,
|
||||
inputExpected,
|
||||
missingInputs: Math.max(0, inputExpected - inputReady),
|
||||
state
|
||||
};
|
||||
})()
|
||||
)
|
||||
: null;
|
||||
const mergeStatusMeta = (() => {
|
||||
if (isMultipartContainer) {
|
||||
const state = String(multipartMergeSummary?.state || '').trim().toLowerCase();
|
||||
const inputReady = Number(multipartMergeSummary?.inputReady || 0);
|
||||
const inputExpected = Number(multipartMergeSummary?.inputExpected || 0);
|
||||
const countSuffix = inputExpected > 0 ? ` (${inputReady}/${inputExpected})` : '';
|
||||
if (state === 'active') {
|
||||
return { tone: 'info', icon: 'pi-spinner pi-spin', label: `Aktiv${countSuffix}` };
|
||||
}
|
||||
if (state === 'done') {
|
||||
return { tone: 'success', icon: 'pi-check-circle', label: 'Ja' };
|
||||
}
|
||||
if (state === 'ready' || state === 'restorable') {
|
||||
return { tone: 'warning', icon: 'pi-play-circle', label: `Bereit${countSuffix}` };
|
||||
}
|
||||
return { tone: 'danger', icon: 'pi-times-circle', label: `Nein${countSuffix}` };
|
||||
}
|
||||
if (isMultipartMergeJob) {
|
||||
const mergeStatus = String(job?.status || job?.last_state || '').trim().toUpperCase();
|
||||
if (mergeStatus === 'ENCODING') {
|
||||
return { tone: 'info', icon: 'pi-spinner pi-spin', label: 'Aktiv' };
|
||||
}
|
||||
if (
|
||||
mergeStatus === 'FINISHED'
|
||||
|| job?.encodeSuccess
|
||||
|| job?.outputStatus?.exists
|
||||
|| Boolean(String(job?.output_path || '').trim())
|
||||
) {
|
||||
return { tone: 'success', icon: 'pi-check-circle', label: 'Ja' };
|
||||
}
|
||||
if (mergeStatus === 'READY_TO_START' || mergeStatus === 'READY_TO_ENCODE') {
|
||||
return { tone: 'warning', icon: 'pi-play-circle', label: 'Bereit' };
|
||||
}
|
||||
return { tone: 'danger', icon: 'pi-times-circle', label: 'Nein' };
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const canRestoreMultipartMerge = Boolean(
|
||||
isMultipartContainer
|
||||
&& mergeChildJobs.length === 0
|
||||
&& Number(multipartMergeSummary?.inputExpected || 0) >= 2
|
||||
&& Number(multipartMergeSummary?.inputReady || 0) >= Number(multipartMergeSummary?.inputExpected || 0)
|
||||
);
|
||||
const displayedImdbId = metadataDetails?.imdbId || job?.imdb_id || null;
|
||||
const metadataJsonTitle = metadataDetails.provider === 'tmdb' ? 'TMDb Info' : 'OMDb Info';
|
||||
const metadataJsonValue = metadataDetails.provider === 'tmdb'
|
||||
@@ -1216,7 +1341,7 @@ export default function JobDetailDialog({
|
||||
|| typeof onRestartEncode === 'function'
|
||||
|| typeof onRestartReview === 'function'
|
||||
|| typeof onReencode === 'function';
|
||||
const isContainerWithDiskActions = isSeriesContainer && childJobs.length > 0;
|
||||
const isContainerWithDiskActions = isDiskContainer && childJobs.length > 0;
|
||||
const resolveChildActionState = (child) => {
|
||||
const childStatusUpper = String(child?.status || '').trim().toUpperCase();
|
||||
const childLastStateUpper = String(child?.last_state || '').trim().toUpperCase();
|
||||
@@ -1552,17 +1677,32 @@ export default function JobDetailDialog({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span><strong>{isAudiobook || isConverter ? 'Import:' : 'Backup:'}</strong> {isSeriesContainer && seriesBackupSummary ? (
|
||||
<span><strong>{isAudiobook || isConverter ? 'Import:' : 'Backup:'}</strong> {isDiskContainer && seriesBackupSummary ? (
|
||||
<TriState existing={seriesBackupSummary.existing} expected={seriesBackupSummary.expected} />
|
||||
) : (
|
||||
<BoolState value={job?.backupSuccess} />
|
||||
)}</span>
|
||||
<span className="job-infos-sep">|</span>
|
||||
<span><strong>Encode:</strong> {isSeriesContainer && seriesEncodeSummary ? (
|
||||
<span><strong>Encode:</strong> {isDiskContainer && seriesEncodeSummary ? (
|
||||
<TriState existing={seriesEncodeSummary.existing} expected={seriesEncodeSummary.expected} />
|
||||
) : (
|
||||
<BoolState value={job?.encodeSuccess} />
|
||||
)}</span>
|
||||
{mergeStatusMeta ? (
|
||||
<>
|
||||
<span className="job-infos-sep">|</span>
|
||||
<span>
|
||||
<strong>Merge:</strong>{' '}
|
||||
<span
|
||||
className={`job-status-icon tone-${mergeStatusMeta.tone}`}
|
||||
title={mergeStatusMeta.label}
|
||||
aria-label={mergeStatusMeta.label}
|
||||
>
|
||||
<i className={`pi ${mergeStatusMeta.icon}`} aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1572,14 +1712,16 @@ export default function JobDetailDialog({
|
||||
<div><strong>Start:</strong> {job.start_time || '-'}</div>
|
||||
<div><strong>Ende:</strong> {job.end_time || '-'}</div>
|
||||
{/* Zeile 3+4: Pfade */}
|
||||
<PathField
|
||||
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
|
||||
value={isSeriesContainer ? null : (isCd ? (job.raw_path || job.output_path) : resolvedRawPath)}
|
||||
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
|
||||
downloadDisabled={!canDownloadRaw}
|
||||
downloadLoading={downloadBusyTarget === 'raw'}
|
||||
/>
|
||||
{isSeriesContainer && childJobs.length > 0 ? (
|
||||
{!isDiskContainer ? (
|
||||
<PathField
|
||||
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
|
||||
value={isCd ? (job.raw_path || job.output_path) : resolvedRawPath}
|
||||
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
|
||||
downloadDisabled={!canDownloadRaw}
|
||||
downloadLoading={downloadBusyTarget === 'raw'}
|
||||
/>
|
||||
) : null}
|
||||
{isDiskContainer && childJobs.length > 0 ? (
|
||||
childJobs.map((child) => {
|
||||
const childDiscNumber = resolveSeriesDiscNumber(child);
|
||||
const childLabel = childDiscNumber ? `Disk ${childDiscNumber} RAW:` : 'Disk RAW:';
|
||||
@@ -2088,8 +2230,8 @@ export default function JobDetailDialog({
|
||||
) : null}
|
||||
|
||||
<section className="job-meta-block job-meta-block-full">
|
||||
<h4>{isSeriesContainer ? 'Disks' : 'Logs'}</h4>
|
||||
{isSeriesContainer && childJobs.length > 0 ? (
|
||||
<h4>{isDiskContainer ? 'Disks' : 'Logs'}</h4>
|
||||
{isDiskContainer && (childJobs.length > 0 || mergeChildJobs.length > 0) ? (
|
||||
<>
|
||||
<div className="job-json-grid">
|
||||
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : null}
|
||||
@@ -2262,7 +2404,49 @@ export default function JobDetailDialog({
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
{isMultipartContainer ? (
|
||||
<>
|
||||
<h4>Merge</h4>
|
||||
{mergeChildJobs.length > 0 ? (
|
||||
mergeChildJobs.map((child) => (
|
||||
<details key={`merge-log-${child.id}`} className="episode-track-accordion">
|
||||
<summary className="series-batch-episode-head">
|
||||
<span className="series-batch-episode-title">{`Merge${child?.id ? ` #${child.id}` : ''}`}</span>
|
||||
</summary>
|
||||
<p>Für Merge-Jobs wird nur das Merge-Tool-Log angezeigt.</p>
|
||||
</details>
|
||||
))
|
||||
) : (
|
||||
<details className="episode-track-accordion" open>
|
||||
<summary className="series-batch-episode-head">
|
||||
<span className="series-batch-episode-title">Merge-Job fehlt</span>
|
||||
</summary>
|
||||
<div className="actions-section">
|
||||
<div className="action-item">
|
||||
<Button
|
||||
label="Merge-Job wiederherstellen"
|
||||
icon="pi pi-refresh"
|
||||
severity="info"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onRestoreMultipartMerge?.(job)}
|
||||
loading={restoreMergeBusy}
|
||||
disabled={!canRestoreMultipartMerge || typeof onRestoreMultipartMerge !== 'function'}
|
||||
/>
|
||||
<span className="action-desc">
|
||||
{canRestoreMultipartMerge
|
||||
? 'Erstellt den Merge-Job neu auf Basis der vorhandenen Disc-Outputs.'
|
||||
: `Nur möglich, wenn alle Disc-Outputs vorhanden sind (${Number(multipartMergeSummary?.inputReady || 0)}/${Number(multipartMergeSummary?.inputExpected || 0)}).`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : isMultipartMergeJob ? (
|
||||
<p>Für Merge-Jobs wird nur das Merge-Tool-Log angezeigt.</p>
|
||||
) : isDvdSeries ? (
|
||||
<details className="episode-track-accordion">
|
||||
<summary className="series-batch-episode-head">
|
||||
@@ -2320,7 +2504,7 @@ export default function JobDetailDialog({
|
||||
{logTruncated ? <small>(gekürzt auf letzte 800 Zeilen)</small> : null}
|
||||
</div>
|
||||
{logLoaded ? (
|
||||
isSeriesContainer && childJobs.length > 0 ? (
|
||||
isDiskContainer && (childJobs.length > 0 || mergeChildJobs.length > 0) ? (
|
||||
<div className="log-box">
|
||||
{childJobs.map((child) => {
|
||||
const childDiscNumber = resolveSeriesDiscNumber(child);
|
||||
@@ -2334,6 +2518,14 @@ export default function JobDetailDialog({
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
{mergeChildJobs.map((child) => (
|
||||
<details key={`merge-log-text-${child.id}`} className="episode-track-accordion">
|
||||
<summary className="series-batch-episode-head">
|
||||
<span className="series-batch-episode-title">{`Merge${child?.id ? ` #${child.id}` : ''}`}</span>
|
||||
</summary>
|
||||
<pre>{child.log || ''}</pre>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="log-box">{job.log || ''}</pre>
|
||||
|
||||
@@ -1411,7 +1411,8 @@ export default function MediaInfoReviewPanel({
|
||||
userPresets = [],
|
||||
selectedUserPresetId = null,
|
||||
onUserPresetChange = null,
|
||||
selectedHandBrakePreset = ''
|
||||
selectedHandBrakePreset = '',
|
||||
comparisonReviewItems = []
|
||||
}) {
|
||||
if (!review) {
|
||||
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
||||
@@ -1453,6 +1454,23 @@ export default function MediaInfoReviewPanel({
|
||||
|| selectedTitleIds[0]
|
||||
|| null;
|
||||
const selectedTitleIdSet = new Set(selectedTitleIds.map((id) => String(id)));
|
||||
const normalizedComparisonReviews = (Array.isArray(comparisonReviewItems) ? comparisonReviewItems : [])
|
||||
.map((item) => {
|
||||
const reviewCandidate = item?.review && typeof item.review === 'object'
|
||||
? item.review
|
||||
: null;
|
||||
if (!reviewCandidate || !Array.isArray(reviewCandidate?.titles) || reviewCandidate.titles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const jobId = normalizeTitleId(item?.jobId);
|
||||
const discNumber = normalizePositiveInt(item?.discNumber);
|
||||
return {
|
||||
jobId,
|
||||
discNumber,
|
||||
review: reviewCandidate
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
const encodeInputTitle = displayTitles.find((item) => item.id === currentSelectedId) || null;
|
||||
const normalizedEpisodeRows = (Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : [])
|
||||
.map((episode) => {
|
||||
@@ -1631,6 +1649,18 @@ export default function MediaInfoReviewPanel({
|
||||
const playlistRecommendation = review.playlistRecommendation || null;
|
||||
const rawPreset = String(review.selectors?.preset || '').trim();
|
||||
const presetLabel = String(presetDisplayValue || rawPreset).trim() || '(kein Preset)';
|
||||
const multipartSettingsLock = review?.multipartSettingsLock && typeof review.multipartSettingsLock === 'object'
|
||||
? review.multipartSettingsLock
|
||||
: null;
|
||||
const multipartSettingsLocked = Boolean(multipartSettingsLock?.enabled);
|
||||
const multipartLockSourceJobId = Number.isFinite(Number(multipartSettingsLock?.sourceJobId))
|
||||
&& Number(multipartSettingsLock.sourceJobId) > 0
|
||||
? Math.trunc(Number(multipartSettingsLock.sourceJobId))
|
||||
: null;
|
||||
const multipartLockSourceDiscNumber = Number.isFinite(Number(multipartSettingsLock?.sourceDiscNumber))
|
||||
&& Number(multipartSettingsLock.sourceDiscNumber) > 0
|
||||
? Math.trunc(Number(multipartSettingsLock.sourceDiscNumber))
|
||||
: null;
|
||||
|
||||
// User preset resolution
|
||||
const normalizedUserPresets = Array.isArray(userPresets) ? userPresets : [];
|
||||
@@ -1930,6 +1960,15 @@ export default function MediaInfoReviewPanel({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!compactTitleSelection && multipartSettingsLocked ? (
|
||||
<div className="media-review-notes">
|
||||
<small>
|
||||
Multipart-Lock aktiv: Auswahl wurde von der Referenz-Disc übernommen und ist in diesem Review nicht editierbar.
|
||||
{multipartLockSourceJobId ? ` Quelle Job #${multipartLockSourceJobId}.` : ''}
|
||||
{multipartLockSourceDiscNumber ? ` Referenz-Disc ${multipartLockSourceDiscNumber}.` : ''}
|
||||
</small>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Pre-Encode Items (scripts + chains unified) */}
|
||||
{!compactTitleSelection && (allowEncodeItemSelection || preEncodeItems.length > 0) ? (
|
||||
@@ -2541,6 +2580,111 @@ export default function MediaInfoReviewPanel({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!compactTitleSelection && normalizedComparisonReviews.length > 0 ? (
|
||||
<div className="media-review-notes" style={{ marginTop: '0.85rem' }}>
|
||||
<small><strong>Vergleich Andere Discs (read-only)</strong></small>
|
||||
{normalizedComparisonReviews.map((comparisonItem, comparisonIndex) => {
|
||||
const comparisonReview = comparisonItem?.review || {};
|
||||
const comparisonTitles = Array.isArray(comparisonReview?.titles) ? comparisonReview.titles : [];
|
||||
const comparisonSelectedTitleIds = normalizeTrackIdList([
|
||||
...normalizeTrackIdList(comparisonReview?.selectedTitleIds || []),
|
||||
...comparisonTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id),
|
||||
comparisonReview?.encodeInputTitleId
|
||||
]);
|
||||
const comparisonSelectedTitleSet = new Set(comparisonSelectedTitleIds.map((id) => String(id)));
|
||||
const comparisonPrimaryTitleId = normalizeTitleId(comparisonReview?.encodeInputTitleId);
|
||||
const comparisonLabelParts = [];
|
||||
if (comparisonItem?.discNumber) {
|
||||
comparisonLabelParts.push(`Disc ${comparisonItem.discNumber}`);
|
||||
}
|
||||
if (comparisonItem?.jobId) {
|
||||
comparisonLabelParts.push(`Job #${comparisonItem.jobId}`);
|
||||
}
|
||||
const comparisonLabel = comparisonLabelParts.join(' | ') || `Referenz ${comparisonIndex + 1}`;
|
||||
|
||||
return (
|
||||
<div key={`comparison-review-${comparisonItem?.jobId || comparisonIndex}`} className="media-title-block">
|
||||
<small><strong>{comparisonLabel}</strong></small>
|
||||
{comparisonTitles.length === 0 ? (
|
||||
<small>Keine Titel in der Referenz-Disc gefunden.</small>
|
||||
) : comparisonTitles.map((title) => {
|
||||
const normalizedTitleId = normalizeTitleId(title?.id);
|
||||
const titleChecked = normalizedTitleId !== null
|
||||
&& comparisonSelectedTitleSet.has(String(normalizedTitleId));
|
||||
const titleIsPrimary = normalizedTitleId !== null
|
||||
&& normalizedTitleId === comparisonPrimaryTitleId;
|
||||
const subtitleTracks = Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [];
|
||||
const audioCount = Number.isFinite(Number(title?.audioTrackCount))
|
||||
? Number(title.audioTrackCount)
|
||||
: (Array.isArray(title?.audioTracks) ? title.audioTracks.length : 0);
|
||||
const subtitleCount = Number.isFinite(Number(title?.subtitleTrackCount))
|
||||
? Number(title.subtitleTrackCount)
|
||||
: subtitleTracks.length;
|
||||
|
||||
return (
|
||||
<div key={`comparison-title-${comparisonItem?.jobId || comparisonIndex}-${title?.id}`} className="media-title-block" style={{ marginTop: '0.65rem' }}>
|
||||
<label className="readonly-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={titleChecked}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
<span>
|
||||
#{title.id} | {title.fileName} | {formatDuration(title.durationMinutes)}
|
||||
{audioCount > 0 ? ` | Audio: ${audioCount}` : ''}
|
||||
{subtitleCount > 0 ? ` | Untertitel: ${subtitleCount}` : ''}
|
||||
{title.encodeInput ? ' | Encode-Input' : ''}
|
||||
{titleIsPrimary ? ' | Primär' : ''}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{title.playlistFile || title.playlistEvaluationLabel || title.playlistSegmentCommand ? (
|
||||
<div className="playlist-info-box">
|
||||
<small>
|
||||
<strong>Playlist:</strong> {title.playlistFile || '-'}
|
||||
{title.playlistRecommended ? ' | empfohlen' : ''}
|
||||
</small>
|
||||
{title.playlistEvaluationLabel ? (
|
||||
<small><strong>Bewertung:</strong> {title.playlistEvaluationLabel}</small>
|
||||
) : null}
|
||||
{title.playlistSegmentCommand ? (
|
||||
<small><strong>Analyse-Command:</strong> {title.playlistSegmentCommand}</small>
|
||||
) : null}
|
||||
{Array.isArray(title.playlistSegmentFiles) && title.playlistSegmentFiles.length > 0 ? (
|
||||
<details className="playlist-segment-toggle">
|
||||
<summary>Segment-Dateien anzeigen ({title.playlistSegmentFiles.length})</summary>
|
||||
<pre className="playlist-segment-output">{title.playlistSegmentFiles.join('\n')}</pre>
|
||||
</details>
|
||||
) : (
|
||||
<small>Segment-Ausgabe: keine m2ts-Einträge gefunden.</small>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="media-track-grid">
|
||||
<TrackList
|
||||
title={`Tonspuren (Titel #${title.id})`}
|
||||
tracks={title.audioTracks || []}
|
||||
type="audio"
|
||||
allowSelection={false}
|
||||
/>
|
||||
<TrackList
|
||||
title={`Subtitles (Titel #${title.id})`}
|
||||
tracks={subtitleTracks}
|
||||
type="subtitle"
|
||||
allowSelection={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1250,7 +1250,69 @@ function resolveProfiledSetting(settings, key, mediaProfile) {
|
||||
return settings?.[key] ?? null;
|
||||
}
|
||||
|
||||
function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId = null) {
|
||||
function resolveMultipartDiscSuffixForPreview({
|
||||
jobRow = null,
|
||||
pipeline = null,
|
||||
mediaInfoReview = null,
|
||||
selectedMetadata = null
|
||||
} = {}) {
|
||||
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
||||
const review = mediaInfoReview && typeof mediaInfoReview === 'object' ? mediaInfoReview : {};
|
||||
const reviewSelectedMetadata = review?.selectedMetadata && typeof review.selectedMetadata === 'object'
|
||||
? review.selectedMetadata
|
||||
: {};
|
||||
const contextSelectedMetadata = context?.selectedMetadata && typeof context.selectedMetadata === 'object'
|
||||
? context.selectedMetadata
|
||||
: {};
|
||||
const jobMkInfo = jobRow?.makemkvInfo && typeof jobRow.makemkvInfo === 'object'
|
||||
? jobRow.makemkvInfo
|
||||
: {};
|
||||
const jobAnalyzeContext = jobMkInfo?.analyzeContext && typeof jobMkInfo.analyzeContext === 'object'
|
||||
? jobMkInfo.analyzeContext
|
||||
: {};
|
||||
const jobAnalyzeSelectedMetadata = jobAnalyzeContext?.selectedMetadata && typeof jobAnalyzeContext.selectedMetadata === 'object'
|
||||
? jobAnalyzeContext.selectedMetadata
|
||||
: {};
|
||||
|
||||
const jobKindCandidates = [
|
||||
jobRow?.job_kind,
|
||||
jobRow?.jobKind,
|
||||
context?.jobKind,
|
||||
review?.jobKind,
|
||||
review?.encodePlan?.jobKind
|
||||
]
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const isMultipartMerge = jobKindCandidates.includes('multipart_movie_merge');
|
||||
if (isMultipartMerge) {
|
||||
return '';
|
||||
}
|
||||
const isMultipartChild = jobKindCandidates.includes('multipart_movie_child')
|
||||
|| Number(jobRow?.is_multipart_movie || jobRow?.isMultipartMovie || 0) === 1
|
||||
|| Number(context?.isMultipartMovie || 0) === 1
|
||||
|| Number(review?.isMultipartMovie || 0) === 1;
|
||||
if (!isMultipartChild) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const discNumber = normalizePositiveInt(
|
||||
jobRow?.disc_number
|
||||
?? jobRow?.discNumber
|
||||
?? selectedMetadata?.discNumber
|
||||
?? contextSelectedMetadata?.discNumber
|
||||
?? reviewSelectedMetadata?.discNumber
|
||||
?? jobAnalyzeSelectedMetadata?.discNumber
|
||||
?? context?.discNumber
|
||||
?? null
|
||||
);
|
||||
if (!discNumber) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `_Disc${discNumber}`;
|
||||
}
|
||||
|
||||
function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId = null, options = {}) {
|
||||
const movieDir = String(resolveProfiledSetting(settings, 'movie_dir', mediaProfile) || '').trim();
|
||||
if (!movieDir) {
|
||||
return null;
|
||||
@@ -1272,7 +1334,9 @@ function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId
|
||||
.split('/')
|
||||
.map((seg) => sanitizeFileName(seg))
|
||||
.filter(Boolean);
|
||||
const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled';
|
||||
const rawBaseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled';
|
||||
const outputSuffix = String(options?.outputSuffix || '').trim();
|
||||
const baseName = outputSuffix ? `${rawBaseName}${outputSuffix}` : rawBaseName;
|
||||
const folderParts = segments.slice(0, -1);
|
||||
const rawExt = resolveProfiledSetting(settings, 'output_extension', mediaProfile);
|
||||
const ext = String(rawExt || 'mkv').trim() || 'mkv';
|
||||
@@ -1382,6 +1446,7 @@ export default function PipelineStatusCard({
|
||||
const [selectedEpisodeFillStart, setSelectedEpisodeFillStart] = useState(null);
|
||||
const [containerUsedEpisodeKeys, setContainerUsedEpisodeKeys] = useState([]);
|
||||
const [containerSeasonEpisodes, setContainerSeasonEpisodes] = useState([]);
|
||||
const [multipartComparisonReviews, setMultipartComparisonReviews] = useState([]);
|
||||
const [settingsMap, setSettingsMap] = useState({});
|
||||
const [presetDisplayMap, setPresetDisplayMap] = useState({});
|
||||
const [scriptCatalog, setScriptCatalog] = useState([]);
|
||||
@@ -2046,6 +2111,26 @@ export default function PipelineStatusCard({
|
||||
const converterMediaType = String(mediaInfoReview?.converterMediaType || 'video').trim().toLowerCase() || 'video';
|
||||
const showConverterConfig = jobMediaProfile === 'converter' && converterMediaType !== 'audio';
|
||||
const requiresPresetSelection = showConverterConfig;
|
||||
const multipartSettingsLock = mediaInfoReview?.multipartSettingsLock
|
||||
&& typeof mediaInfoReview.multipartSettingsLock === 'object'
|
||||
? mediaInfoReview.multipartSettingsLock
|
||||
: null;
|
||||
const multipartSettingsLocked = Boolean(
|
||||
multipartSettingsLock?.enabled
|
||||
&& state === 'READY_TO_ENCODE'
|
||||
);
|
||||
const multipartLockSourceJobId = normalizeJobId(multipartSettingsLock?.sourceJobId);
|
||||
const multipartLockSourceDiscNumber = Number.isFinite(Number(multipartSettingsLock?.sourceDiscNumber))
|
||||
&& Number(multipartSettingsLock.sourceDiscNumber) > 0
|
||||
? Math.trunc(Number(multipartSettingsLock.sourceDiscNumber))
|
||||
: null;
|
||||
const allowReviewSelectionEdit = !queueLocked && !multipartSettingsLocked;
|
||||
const allowTitleSelectionInReview = (
|
||||
(state === 'READY_TO_ENCODE' || state === 'WAITING_FOR_USER_DECISION')
|
||||
&& allowReviewSelectionEdit
|
||||
);
|
||||
const allowTrackSelectionInReview = state === 'READY_TO_ENCODE' && allowReviewSelectionEdit;
|
||||
const allowEncodeItemSelectionInReview = state === 'READY_TO_ENCODE' && allowReviewSelectionEdit;
|
||||
|
||||
// Filter user presets by job media profile ('all' presets always shown)
|
||||
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
|
||||
@@ -2079,7 +2164,7 @@ export default function PipelineStatusCard({
|
||||
&& !running
|
||||
&& (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath)
|
||||
);
|
||||
const allowConverterConfigEdit = showConverterConfig && !queueLocked && state === 'READY_TO_ENCODE';
|
||||
const allowConverterConfigEdit = showConverterConfig && allowReviewSelectionEdit && state === 'READY_TO_ENCODE';
|
||||
|
||||
const converterConfigPayload = useMemo(() => {
|
||||
if (!showConverterConfig) {
|
||||
@@ -2588,6 +2673,128 @@ export default function PipelineStatusCard({
|
||||
selectedMetadata?.tmdbId,
|
||||
selectedMetadata?.seasonNumber
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const parseEncodePlanCandidate = (value) => {
|
||||
if (value && typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMultipartComparisonReviews = async () => {
|
||||
if (!retryJobId) {
|
||||
if (!cancelled) {
|
||||
setMultipartComparisonReviews([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentResponse = await api.getJob(retryJobId, { lite: true, forceRefresh: true });
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const currentJob = currentResponse?.job && typeof currentResponse.job === 'object'
|
||||
? currentResponse.job
|
||||
: null;
|
||||
const currentJobId = normalizeJobId(currentJob?.id || retryJobId);
|
||||
const normalizedCurrentKind = String(currentJob?.job_kind || '').trim().toLowerCase();
|
||||
const currentIsMultipart = normalizedCurrentKind === 'multipart_movie_container'
|
||||
|| normalizedCurrentKind === 'multipart_movie_child'
|
||||
|| Number(currentJob?.is_multipart_movie || currentJob?.isMultipartMovie || 0) === 1;
|
||||
if (!currentIsMultipart) {
|
||||
if (!cancelled) {
|
||||
setMultipartComparisonReviews([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const containerJobId = normalizedCurrentKind === 'multipart_movie_container'
|
||||
? normalizeJobId(currentJob?.id)
|
||||
: normalizeJobId(currentJob?.parent_job_id);
|
||||
|
||||
if (!containerJobId) {
|
||||
if (!cancelled) {
|
||||
setMultipartComparisonReviews([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const containerResponse = await api.getJob(containerJobId, { lite: true, forceRefresh: true });
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const containerJob = containerResponse?.job && typeof containerResponse.job === 'object'
|
||||
? containerResponse.job
|
||||
: null;
|
||||
const children = Array.isArray(containerJob?.children) ? containerJob.children : [];
|
||||
const comparisonRows = children
|
||||
.map((child) => {
|
||||
const childId = normalizeJobId(child?.id);
|
||||
if (!childId || (currentJobId && currentJobId === childId)) {
|
||||
return null;
|
||||
}
|
||||
const childKind = String(child?.job_kind || '').trim().toLowerCase();
|
||||
const isMultipartChild = childKind === 'multipart_movie_child'
|
||||
|| Number(child?.is_multipart_movie || child?.isMultipartMovie || 0) === 1;
|
||||
if (!isMultipartChild) {
|
||||
return null;
|
||||
}
|
||||
const encodePlan = parseEncodePlanCandidate(child?.encodePlan || child?.encode_plan_json);
|
||||
if (!encodePlan || !Array.isArray(encodePlan?.titles) || encodePlan.titles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const discNumberRaw = Number(
|
||||
child?.disc_number
|
||||
?? child?.discNumber
|
||||
?? encodePlan?.discNumber
|
||||
?? 0
|
||||
);
|
||||
const discNumber = Number.isFinite(discNumberRaw) && discNumberRaw > 0
|
||||
? Math.trunc(discNumberRaw)
|
||||
: null;
|
||||
return {
|
||||
jobId: childId,
|
||||
discNumber,
|
||||
review: encodePlan
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => {
|
||||
const leftDisc = Number.isFinite(left?.discNumber) ? left.discNumber : Number.MAX_SAFE_INTEGER;
|
||||
const rightDisc = Number.isFinite(right?.discNumber) ? right.discNumber : Number.MAX_SAFE_INTEGER;
|
||||
if (leftDisc !== rightDisc) {
|
||||
return leftDisc - rightDisc;
|
||||
}
|
||||
return Number(left?.jobId || 0) - Number(right?.jobId || 0);
|
||||
});
|
||||
|
||||
if (!cancelled) {
|
||||
setMultipartComparisonReviews(comparisonRows);
|
||||
}
|
||||
} catch (_error) {
|
||||
if (!cancelled) {
|
||||
setMultipartComparisonReviews([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadMultipartComparisonReviews();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [retryJobId, mediaInfoReview?.generatedAt, mediaInfoReview?.reviewConfirmedAt]);
|
||||
|
||||
const seriesBatchContext = useMemo(() => {
|
||||
const raw = pipeline?.context?.seriesBatch;
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
@@ -2664,6 +2871,12 @@ export default function PipelineStatusCard({
|
||||
|| pipeline?.context?.mediaInfoReview?.seriesBatchParent
|
||||
|| hasSeriesBatchProgress
|
||||
);
|
||||
const multipartDiscSuffixForCommandPreview = useMemo(() => resolveMultipartDiscSuffixForPreview({
|
||||
jobRow,
|
||||
pipeline,
|
||||
mediaInfoReview,
|
||||
selectedMetadata
|
||||
}), [jobRow, pipeline, mediaInfoReview, selectedMetadata]);
|
||||
const commandOutputPath = useMemo(() => {
|
||||
if (jobMediaProfile === 'converter') {
|
||||
const reviewWithOutput = mediaInfoReview
|
||||
@@ -2674,8 +2887,19 @@ export default function PipelineStatusCard({
|
||||
if (isSeriesDvdReview) {
|
||||
return null;
|
||||
}
|
||||
return buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId);
|
||||
}, [settingsMap, jobMediaProfile, mediaInfoReview, selectedMetadata, retryJobId, selectedOutputFormat, isSeriesDvdReview]);
|
||||
return buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId, {
|
||||
outputSuffix: multipartDiscSuffixForCommandPreview
|
||||
});
|
||||
}, [
|
||||
settingsMap,
|
||||
jobMediaProfile,
|
||||
mediaInfoReview,
|
||||
selectedMetadata,
|
||||
retryJobId,
|
||||
selectedOutputFormat,
|
||||
isSeriesDvdReview,
|
||||
multipartDiscSuffixForCommandPreview
|
||||
]);
|
||||
const commandOutputPathByTitle = useMemo(() => {
|
||||
if (!isSeriesDvdReview) {
|
||||
return {};
|
||||
@@ -3633,6 +3857,16 @@ export default function PipelineStatusCard({
|
||||
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
|
||||
</small>
|
||||
) : null}
|
||||
{multipartSettingsLocked ? (
|
||||
<div className="playlist-decision-block">
|
||||
<h3>Multipart-Lock aktiv</h3>
|
||||
<small>
|
||||
Die Encode-Einstellungen wurden automatisch von der bereits vorhandenen Disc übernommen und sind gesperrt.
|
||||
{multipartLockSourceJobId ? ` Quelle: Job #${multipartLockSourceJobId}.` : ''}
|
||||
{multipartLockSourceDiscNumber ? ` Referenz-Disc: ${multipartLockSourceDiscNumber}.` : ''}
|
||||
</small>
|
||||
</div>
|
||||
) : null}
|
||||
{showConverterConfig ? (
|
||||
<div style={{ marginTop: '0.75rem', marginBottom: '0.75rem', padding: '0.75rem', border: '1px solid var(--surface-border, #e0e0e0)', borderRadius: '6px', background: 'var(--surface-ground, #f8f8f8)' }}>
|
||||
<div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
|
||||
@@ -3736,7 +3970,7 @@ export default function PipelineStatusCard({
|
||||
commandOutputPathByTitle={commandOutputPathByTitle}
|
||||
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
||||
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
|
||||
allowTitleSelection={(state === 'READY_TO_ENCODE' || state === 'WAITING_FOR_USER_DECISION') && !queueLocked}
|
||||
allowTitleSelection={allowTitleSelectionInReview}
|
||||
compactTitleSelection={isDiscTitleSelectionRequired}
|
||||
onSelectEncodeTitle={(titleId) => {
|
||||
const normalizedTitleId = normalizeTitleId(titleId);
|
||||
@@ -3769,7 +4003,7 @@ export default function PipelineStatusCard({
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
allowTrackSelection={state === 'READY_TO_ENCODE' && !queueLocked}
|
||||
allowTrackSelection={allowTrackSelectionInReview}
|
||||
trackSelectionByTitle={trackSelectionByTitle}
|
||||
onTrackSelectionChange={(titleId, trackType, trackId, checked) => {
|
||||
const normalizedTitleId = normalizeTitleId(titleId);
|
||||
@@ -3857,7 +4091,7 @@ export default function PipelineStatusCard({
|
||||
}
|
||||
}}
|
||||
episodeAssignmentsByTitle={episodeAssignmentsByTitle}
|
||||
allowEpisodeAssignments={isSeriesDvdReview}
|
||||
allowEpisodeAssignments={isSeriesDvdReview && !multipartSettingsLocked}
|
||||
onEpisodeAssignmentChange={(titleId, patch = {}) => {
|
||||
const normalizedTitleId = normalizeTitleId(titleId);
|
||||
if (!normalizedTitleId || !patch || typeof patch !== 'object') {
|
||||
@@ -3924,9 +4158,10 @@ export default function PipelineStatusCard({
|
||||
postEncodeItems={postEncodeItems}
|
||||
userPresets={effectiveUserPresets}
|
||||
selectedUserPresetId={selectedUserPresetId}
|
||||
onUserPresetChange={showConverterConfig ? null : (presetId) => setSelectedUserPresetId(presetId)}
|
||||
onUserPresetChange={showConverterConfig || multipartSettingsLocked ? null : (presetId) => setSelectedUserPresetId(presetId)}
|
||||
selectedHandBrakePreset={selectedHandBrakePreset}
|
||||
allowEncodeItemSelection={state === 'READY_TO_ENCODE' && !queueLocked}
|
||||
allowEncodeItemSelection={allowEncodeItemSelectionInReview}
|
||||
comparisonReviewItems={multipartComparisonReviews}
|
||||
onAddPreEncodeItem={(itemType) => {
|
||||
setPreEncodeItems((prev) => {
|
||||
const current = Array.isArray(prev) ? prev : [];
|
||||
|
||||
@@ -193,6 +193,16 @@ function isContainerHistoryRow(row) {
|
||||
return kind === 'dvd_series_container' || kind === 'multipart_movie_container';
|
||||
}
|
||||
|
||||
function isMultipartContainerHistoryRow(row) {
|
||||
const kind = String(
|
||||
row?.job_kind
|
||||
|| row?.jobKind
|
||||
|| row?.encodePlan?.jobKind
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
return kind === 'multipart_movie_container';
|
||||
}
|
||||
|
||||
function isSeriesChildHistoryRow(row) {
|
||||
const kind = String(
|
||||
row?.job_kind
|
||||
@@ -931,6 +941,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
|
||||
const handleDeleteFiles = async (row, target) => {
|
||||
const isContainerRow = isContainerHistoryRow(row);
|
||||
const isMultipartContainerRow = isMultipartContainerHistoryRow(row);
|
||||
const isSeriesChildRow = isSeriesChildHistoryRow(row);
|
||||
const includeRelated = isContainerRow
|
||||
|| (isSeriesChildRow && (target === 'movie' || target === 'both'));
|
||||
@@ -948,7 +959,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
|
||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||
const scopeSuffix = isContainerRow
|
||||
? '\nScope: kompletter Serien-Container (alle zugehörigen RAWs/Outputs).'
|
||||
? (isMultipartContainerRow
|
||||
? '\nScope: kompletter Multipart-Container (alle zugehörigen RAWs/Outputs).'
|
||||
: '\nScope: kompletter Serien-Container (alle zugehörigen RAWs/Outputs).')
|
||||
: isSeriesChildRow && target === 'raw'
|
||||
? '\nScope: nur RAW dieses Disk-Jobs.'
|
||||
: isSeriesChildRow && (target === 'movie' || target === 'both')
|
||||
@@ -1615,15 +1628,89 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreMultipartMerge = async (row) => {
|
||||
const containerJobId = normalizeJobId(row?.id || row);
|
||||
if (!containerJobId) {
|
||||
return;
|
||||
}
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const response = await api.restoreMultipartMergeJob(containerJobId);
|
||||
const mergeJobId = normalizeJobId(response?.job?.id || response?.result?.mergeJobId);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Merge-Job wiederhergestellt',
|
||||
detail: mergeJobId
|
||||
? `Merge-Job #${mergeJobId} wurde neu erstellt.`
|
||||
: 'Merge-Job wurde neu erstellt.',
|
||||
life: 4200
|
||||
});
|
||||
void load();
|
||||
void refreshDetailIfOpen(containerJobId);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Merge-Wiederherstellung fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 5500
|
||||
});
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStatusTag = (row) => {
|
||||
if (isMultipartContainerHistoryRow(row)) {
|
||||
const mergeSummary = row?.seriesChildSummary?.merge && typeof row.seriesChildSummary.merge === 'object'
|
||||
? row.seriesChildSummary.merge
|
||||
: null;
|
||||
if (mergeSummary) {
|
||||
const mergeState = String(mergeSummary?.state || '').trim().toLowerCase();
|
||||
const hasMergeJob = Boolean(mergeSummary?.hasJob);
|
||||
const isCompleted = Boolean(mergeSummary?.completed);
|
||||
const filesReady = Boolean(mergeSummary?.ready);
|
||||
const mergeVisibleInRipper = hasMergeJob && !isCompleted;
|
||||
if (mergeVisibleInRipper) {
|
||||
return (
|
||||
<div className="history-status-tag-wrap">
|
||||
<Tag value="Merge" severity="info" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!hasMergeJob && filesReady) {
|
||||
return (
|
||||
<div className="history-status-tag-wrap">
|
||||
<Tag value="Merge bereit" severity="info" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!filesReady) {
|
||||
return (
|
||||
<div className="history-status-tag-wrap">
|
||||
<Tag value="Merge" severity="danger" />
|
||||
<small className="history-status-tag-subtitle">Dateien nicht vollständig</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mergeState === 'done') {
|
||||
return (
|
||||
<div className="history-status-tag-wrap">
|
||||
<Tag value="Merge fertig" severity="success" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const normalizedStatus = normalizeStatus(row?.status);
|
||||
const rowId = normalizeJobId(row?.id);
|
||||
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
|
||||
return (
|
||||
<Tag
|
||||
value={getStatusLabel(row?.status, { queued: isQueued })}
|
||||
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
|
||||
/>
|
||||
<div className="history-status-tag-wrap">
|
||||
<Tag
|
||||
value={getStatusLabel(row?.status, { queued: isQueued })}
|
||||
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1652,8 +1739,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
|
||||
const renderSeriesOutputChip = (row) => {
|
||||
const summary = row?.seriesOutputSummary || null;
|
||||
const label = isMultipartContainerHistoryRow(row) ? 'Movie' : 'Episoden';
|
||||
if (!summary) {
|
||||
return renderPresenceChip('Episoden', Boolean(row?.outputStatus?.exists));
|
||||
return renderPresenceChip(label, Boolean(row?.outputStatus?.exists));
|
||||
}
|
||||
const expected = Number(summary.expected || 0);
|
||||
const existing = Number(summary.existing || 0);
|
||||
@@ -1662,12 +1750,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const safeExisting = Number.isFinite(existing) && existing >= 0 ? existing : 0;
|
||||
const suffix = hasExpected ? ` (${safeExisting}/${safeExpected})` : '';
|
||||
if (safeExisting <= 0) {
|
||||
return renderPresenceChip('Episoden', false, 'tone-no', suffix || ' (0/0)');
|
||||
return renderPresenceChip(label, false, 'tone-no', suffix || ' (0/0)');
|
||||
}
|
||||
if (hasExpected && safeExisting < safeExpected) {
|
||||
return renderPresenceChip('Episoden', true, 'tone-warn', suffix);
|
||||
return renderPresenceChip(label, true, 'tone-warn', suffix);
|
||||
}
|
||||
return renderPresenceChip('Episoden', true, 'tone-ok', suffix || ` (${safeExisting}/${safeExpected || safeExisting})`);
|
||||
return renderPresenceChip(label, true, 'tone-ok', suffix || ` (${safeExisting}/${safeExpected || safeExisting})`);
|
||||
};
|
||||
|
||||
const renderSeriesRawChip = (row) => {
|
||||
@@ -1779,6 +1867,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
: {};
|
||||
const rowMediaType = resolveMediaType(row);
|
||||
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
|
||||
const isContainerRow = isContainerHistoryRow(row);
|
||||
const useContainerSummaryChips = isSeriesDvd || isContainerRow;
|
||||
const seasonLabel = isSeriesDvd && selectedMetadata?.seasonNumber
|
||||
? `Staffel ${selectedMetadata.seasonNumber}`
|
||||
: null;
|
||||
@@ -1833,10 +1923,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
</div>
|
||||
|
||||
<div className="history-dv-flags-row">
|
||||
{isSeriesDvd
|
||||
{useContainerSummaryChips
|
||||
? renderSeriesRawChip(row)
|
||||
: renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{isSeriesDvd
|
||||
{useContainerSummaryChips
|
||||
? renderSeriesOutputChip(row)
|
||||
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
@@ -1861,6 +1951,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
: {};
|
||||
const rowMediaType = resolveMediaType(row);
|
||||
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
|
||||
const isContainerRow = isContainerHistoryRow(row);
|
||||
const useContainerSummaryChips = isSeriesDvd || isContainerRow;
|
||||
const seasonLabel = isSeriesDvd && selectedMetadata?.seasonNumber
|
||||
? `Staffel ${selectedMetadata.seasonNumber}`
|
||||
: null;
|
||||
@@ -1917,10 +2009,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
</div>
|
||||
|
||||
<div className="history-dv-flags-row">
|
||||
{isSeriesDvd
|
||||
{useContainerSummaryChips
|
||||
? renderSeriesRawChip(row)
|
||||
: renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{isSeriesDvd
|
||||
{useContainerSummaryChips
|
||||
? renderSeriesOutputChip(row)
|
||||
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
@@ -2050,8 +2142,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
onDownloadArchive={handleDownloadArchive}
|
||||
onDownloadOutputFolder={handleDownloadOutputFolder}
|
||||
onRemoveFromQueue={handleRemoveFromQueue}
|
||||
onRestoreMultipartMerge={handleRestoreMultipartMerge}
|
||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||
actionBusy={actionBusy}
|
||||
restoreMergeBusy={actionBusy}
|
||||
omdbAssignBusy={omdbAssignBusy}
|
||||
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
||||
acknowledgeErrorBusy={acknowledgeErrorBusy}
|
||||
|
||||
@@ -19,6 +19,7 @@ import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
|
||||
import mergeIndicatorIcon from '../assets/media-merge.svg';
|
||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||
import { confirmModal } from '../utils/confirmModal';
|
||||
import { isConverterJob, isSeriesVideoJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy';
|
||||
@@ -517,6 +518,125 @@ function getAnalyzeContext(job) {
|
||||
: {};
|
||||
}
|
||||
|
||||
function getEncodePlan(job) {
|
||||
if (job?.encodePlan && typeof job.encodePlan === 'object') {
|
||||
return job.encodePlan;
|
||||
}
|
||||
if (job?.encode_plan_json && typeof job.encode_plan_json === 'object') {
|
||||
return job.encode_plan_json;
|
||||
}
|
||||
const rawPlan = String(job?.encode_plan_json || '').trim();
|
||||
if (!rawPlan) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(rawPlan);
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveJobKindRaw(job) {
|
||||
return String(job?.job_kind || job?.jobKind || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isMultipartMergeJob(job) {
|
||||
return resolveJobKindRaw(job) === 'multipart_movie_merge';
|
||||
}
|
||||
|
||||
function extractMultipartMergeSources(job) {
|
||||
const encodePlan = getEncodePlan(job) || {};
|
||||
const sourceItems = Array.isArray(encodePlan?.sourceItems) ? encodePlan.sourceItems : [];
|
||||
const normalized = sourceItems
|
||||
.map((item) => {
|
||||
const sourceJobId = normalizeJobId(item?.jobId || item?.sourceJobId);
|
||||
const discNumberRaw = Number(item?.discNumber);
|
||||
const discNumber = Number.isFinite(discNumberRaw) && discNumberRaw > 0
|
||||
? Math.trunc(discNumberRaw)
|
||||
: null;
|
||||
const outputPath = String(item?.outputPath || item?.path || '').trim();
|
||||
if (!sourceJobId || !outputPath) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sourceJobId,
|
||||
discNumber,
|
||||
outputPath,
|
||||
fileName: getPathBaseName(outputPath)
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (normalized.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const orderedIds = Array.isArray(encodePlan?.orderedSourceJobIds)
|
||||
? encodePlan.orderedSourceJobIds.map((value) => normalizeJobId(value)).filter(Boolean)
|
||||
: [];
|
||||
const byId = new Map(normalized.map((item) => [item.sourceJobId, item]));
|
||||
const ordered = [];
|
||||
const seen = new Set();
|
||||
for (const sourceJobId of orderedIds) {
|
||||
const item = byId.get(sourceJobId);
|
||||
if (!item || seen.has(sourceJobId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(sourceJobId);
|
||||
ordered.push(item);
|
||||
}
|
||||
for (const item of normalized) {
|
||||
if (seen.has(item.sourceJobId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(item.sourceJobId);
|
||||
ordered.push(item);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function formatMergeTrackLanguage(language) {
|
||||
const normalized = String(language || '').trim().toLowerCase();
|
||||
if (!normalized || normalized === 'und') {
|
||||
return 'und';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatMergeTrackSummary(track = {}, fallbackLabel = 'Track') {
|
||||
const source = track && typeof track === 'object' ? track : {};
|
||||
const orderRaw = Number(source.order);
|
||||
const order = Number.isFinite(orderRaw) && orderRaw > 0 ? Math.trunc(orderRaw) : null;
|
||||
const language = formatMergeTrackLanguage(source.language);
|
||||
const codec = String(source.codec || '?').trim() || '?';
|
||||
const title = String(source.title || '').trim();
|
||||
const channelsRaw = Number(source.channels);
|
||||
const channelInfo = Number.isFinite(channelsRaw) && channelsRaw > 0
|
||||
? `${Math.trunc(channelsRaw)}ch`
|
||||
: null;
|
||||
const flags = [];
|
||||
if (source.default === true) {
|
||||
flags.push('default');
|
||||
}
|
||||
if (source.forced === true) {
|
||||
flags.push('forced');
|
||||
}
|
||||
const parts = [
|
||||
order ? `#${order}` : fallbackLabel,
|
||||
language,
|
||||
codec
|
||||
];
|
||||
if (channelInfo) {
|
||||
parts.push(channelInfo);
|
||||
}
|
||||
if (title) {
|
||||
parts.push(title);
|
||||
}
|
||||
if (flags.length > 0) {
|
||||
parts.push(`[${flags.join(', ')}]`);
|
||||
}
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
function resolveMediaType(job) {
|
||||
const centralMediaType = resolveCentralMediaType(job);
|
||||
if (centralMediaType && centralMediaType !== 'other') {
|
||||
@@ -581,6 +701,14 @@ function resolveMediaType(job) {
|
||||
}
|
||||
|
||||
function mediaIndicatorMeta(job) {
|
||||
if (isMultipartMergeJob(job)) {
|
||||
return {
|
||||
mediaType: 'multipart_merge',
|
||||
src: mergeIndicatorIcon,
|
||||
alt: 'Merge Job',
|
||||
title: 'Multipart Merge Job'
|
||||
};
|
||||
}
|
||||
const mediaType = resolveMediaType(job);
|
||||
const isSeriesVideo = (mediaType === 'dvd' || mediaType === 'bluray') && isSeriesVideoJob(job);
|
||||
if (mediaType === 'bluray') {
|
||||
@@ -1020,6 +1148,7 @@ export default function RipperPage({
|
||||
const [queueState, setQueueState] = useState(() => normalizeQueue(pipeline?.queue));
|
||||
const [queueReorderBusy, setQueueReorderBusy] = useState(false);
|
||||
const [draggingQueueEntryId, setDraggingQueueEntryId] = useState(null);
|
||||
const [draggingMergeSource, setDraggingMergeSource] = useState(null);
|
||||
const [insertQueueDialog, setInsertQueueDialog] = useState({ visible: false, afterEntryId: null });
|
||||
const [runtimeActivities, setRuntimeActivities] = useState(() => normalizeRuntimeActivitiesPayload(null));
|
||||
const [runtimeLoading, setRuntimeLoading] = useState(false);
|
||||
@@ -1027,6 +1156,8 @@ export default function RipperPage({
|
||||
const [runtimeRecentClearing, setRuntimeRecentClearing] = useState(false);
|
||||
const [jobsLoading, setJobsLoading] = useState(false);
|
||||
const [ripperJobs, setRipperJobs] = useState([]);
|
||||
const [multipartMergePreviewByJobId, setMultipartMergePreviewByJobId] = useState({});
|
||||
const [multipartMergePreviewLoadingByJobId, setMultipartMergePreviewLoadingByJobId] = useState({});
|
||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||
const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null });
|
||||
const [activationBytesInput, setActivationBytesInput] = useState('');
|
||||
@@ -1363,6 +1494,18 @@ export default function RipperPage({
|
||||
setExpandedJobId(normalizeJobId(ripperJobs[0]?.id));
|
||||
}, [ripperJobs, expandedJobId, currentPipelineJobId]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedExpanded = normalizeJobId(expandedJobId);
|
||||
if (!normalizedExpanded) {
|
||||
return;
|
||||
}
|
||||
const expandedJob = ripperJobs.find((job) => normalizeJobId(job?.id) === normalizedExpanded) || null;
|
||||
if (!expandedJob || !isMultipartMergeJob(expandedJob)) {
|
||||
return;
|
||||
}
|
||||
void loadMultipartMergePreview(normalizedExpanded, { force: false, silent: true });
|
||||
}, [expandedJobId, ripperJobs]);
|
||||
|
||||
const pipelineByJobId = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const job of ripperJobs) {
|
||||
@@ -1463,6 +1606,49 @@ export default function RipperPage({
|
||||
});
|
||||
};
|
||||
|
||||
const loadMultipartMergePreview = async (jobId, options = {}) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return null;
|
||||
}
|
||||
const force = options?.force === true;
|
||||
const silent = options?.silent !== false;
|
||||
if (!force) {
|
||||
const existingPreview = multipartMergePreviewByJobId[normalizedJobId];
|
||||
if (existingPreview) {
|
||||
return existingPreview;
|
||||
}
|
||||
}
|
||||
if (!force && multipartMergePreviewLoadingByJobId[normalizedJobId]) {
|
||||
return null;
|
||||
}
|
||||
setMultipartMergePreviewLoadingByJobId((prev) => ({
|
||||
...prev,
|
||||
[normalizedJobId]: true
|
||||
}));
|
||||
try {
|
||||
const response = await api.getMultipartMergePreview(normalizedJobId);
|
||||
const preview = response?.preview && typeof response.preview === 'object'
|
||||
? response.preview
|
||||
: null;
|
||||
setMultipartMergePreviewByJobId((prev) => ({
|
||||
...prev,
|
||||
[normalizedJobId]: preview
|
||||
}));
|
||||
return preview;
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
showError(error);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
setMultipartMergePreviewLoadingByJobId((prev) => ({
|
||||
...prev,
|
||||
[normalizedJobId]: false
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMetadataDialog = (jobId = null) => {
|
||||
const context = jobId ? buildMetadataContextForJob(jobId) : defaultMetadataDialogContext;
|
||||
if (!context?.jobId) {
|
||||
@@ -1805,6 +1991,31 @@ export default function RipperPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleReorderMultipartMergeSources = async (jobId, orderedSourceJobIds = []) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
const normalizedOrderedIds = Array.isArray(orderedSourceJobIds)
|
||||
? orderedSourceJobIds.map((value) => normalizeJobId(value)).filter(Boolean)
|
||||
: [];
|
||||
if (normalizedOrderedIds.length < 2) {
|
||||
return;
|
||||
}
|
||||
setJobBusy(normalizedJobId, true);
|
||||
try {
|
||||
await api.reorderMultipartMergeSources(normalizedJobId, normalizedOrderedIds);
|
||||
await refreshPipeline();
|
||||
await loadRipperJobs();
|
||||
await loadMultipartMergePreview(normalizedJobId, { force: true, silent: true });
|
||||
setExpandedJobId(normalizedJobId);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setJobBusy(normalizedJobId, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveActivationBytes = async () => {
|
||||
const { checksum, jobId } = activationBytesDialog;
|
||||
const bytes = activationBytesInput.trim().toLowerCase();
|
||||
@@ -1992,11 +2203,16 @@ export default function RipperPage({
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
const isOrphanRawImportJob = orphanImportSource === 'orphan_raw_import';
|
||||
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
|
||||
const isMultipartChildJob = jobKindRaw === 'multipart_movie_child'
|
||||
|| jobKindRaw === 'multipart_movie_merge'
|
||||
|| (Number(job?.is_multipart_movie || 0) === 1 && Boolean(normalizeJobId(job?.parent_job_id)));
|
||||
const includeRelatedOnDelete = !isMultipartChildJob;
|
||||
const ripSuccessful = Number(job?.rip_successful || 0) === 1;
|
||||
const deleteIncompleteRaw = !isOrphanRawImportJob && !ripSuccessful && isIncompleteRawPath(job?.raw_path);
|
||||
let incompleteMovieSelectionPaths = [];
|
||||
try {
|
||||
const deletePreview = await api.getJobDeletePreview(normalizedJobId, { includeRelated: true });
|
||||
const deletePreview = await api.getJobDeletePreview(normalizedJobId, { includeRelated: includeRelatedOnDelete });
|
||||
const movieCandidates = Array.isArray(deletePreview?.preview?.pathCandidates?.movie)
|
||||
? deletePreview.preview.pathCandidates.movie
|
||||
: [];
|
||||
@@ -2036,7 +2252,9 @@ export default function RipperPage({
|
||||
`Job #${normalizedJobId} wirklich löschen?\n` +
|
||||
`${title}\n\n` +
|
||||
deleteHint +
|
||||
'\nZugehörige Einträge (z.B. Serien-Container oder Folgejobs) werden ebenfalls entfernt.' +
|
||||
(includeRelatedOnDelete
|
||||
? '\nZugehörige Einträge (z.B. Serien-Container oder Folgejobs) werden ebenfalls entfernt.'
|
||||
: '\nEs wird nur dieser Job entfernt; andere zugehörige Disks bleiben erhalten.') +
|
||||
(isOrphanRawImportJob
|
||||
? '\nOrphan-Import-Job: Das zugeordnete Laufwerk bleibt unverändert.'
|
||||
: '\nDas zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.'),
|
||||
@@ -2064,7 +2282,7 @@ export default function RipperPage({
|
||||
}
|
||||
}
|
||||
await api.deleteJobEntry(normalizedJobId, deleteTarget, {
|
||||
includeRelated: true,
|
||||
includeRelated: includeRelatedOnDelete,
|
||||
...(deleteIncompleteMovie ? { selectedMoviePaths: incompleteMovieSelectionPaths } : {}),
|
||||
resetDriveState: resetDriveStateOnDelete,
|
||||
keepDetectedDevice: keepDetectedDeviceOnDelete,
|
||||
@@ -2988,12 +3206,14 @@ export default function RipperPage({
|
||||
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';
|
||||
|| jobKindRaw === 'multipart_movie_container'
|
||||
|| jobKindRaw === 'multipart_movie_merge';
|
||||
const isMultipartMerge = isMultipartMergeJob(job);
|
||||
const multipartDiscNumber = Number(job?.disc_number || selectedMetadata?.discNumber || 0) || null;
|
||||
const multipartContainerId = normalizeJobId(job?.parent_job_id);
|
||||
const multipartSubtitle = isMultipartMovie
|
||||
? (
|
||||
`${multipartDiscNumber ? ` Disc ${multipartDiscNumber}` : ''}`
|
||||
`${isMultipartMerge ? ' Merge' : `${multipartDiscNumber ? ` Disc ${multipartDiscNumber}` : ''}`}`
|
||||
+ `${multipartContainerId ? ` | Container #${multipartContainerId}` : ''}`
|
||||
).trim()
|
||||
: '';
|
||||
@@ -3053,6 +3273,28 @@ export default function RipperPage({
|
||||
? pipelineForJob.context.selectedMetadata
|
||||
: {};
|
||||
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
|
||||
const multipartMergeSources = isMultipartMerge ? extractMultipartMergeSources(job) : [];
|
||||
const hasMultipartMergeSources = multipartMergeSources.length > 0;
|
||||
const multipartMergePreview = isMultipartMerge
|
||||
? (multipartMergePreviewByJobId[jobId] || null)
|
||||
: null;
|
||||
const multipartMergePreviewLoading = Boolean(
|
||||
isMultipartMerge && multipartMergePreviewLoadingByJobId[jobId]
|
||||
);
|
||||
const multipartMergeCommandPreview = String(multipartMergePreview?.command?.preview || '').trim();
|
||||
const multipartMergeChapterEntries = Array.isArray(multipartMergePreview?.chapters?.entries)
|
||||
? multipartMergePreview.chapters.entries
|
||||
: [];
|
||||
const multipartMergeAudioTracks = Array.isArray(multipartMergePreview?.media?.audioTracks)
|
||||
? multipartMergePreview.media.audioTracks
|
||||
: [];
|
||||
const multipartMergeSubtitleTracks = Array.isArray(multipartMergePreview?.media?.subtitleTracks)
|
||||
? multipartMergePreview.media.subtitleTracks
|
||||
: [];
|
||||
const multipartMergeMediaAligned = Boolean(multipartMergePreview?.media?.allSourcesAligned);
|
||||
const multipartMergeMediaMismatchCount = Array.isArray(multipartMergePreview?.media?.mismatches)
|
||||
? multipartMergePreview.media.mismatches.length
|
||||
: 0;
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div key={jobId} className="ripper-job-expanded">
|
||||
@@ -3081,7 +3323,8 @@ 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}
|
||||
{isMultipartMerge ? <Tag value="Merge Job" severity="warning" /> : null}
|
||||
{!isMultipartMerge && 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'
|
||||
@@ -3151,28 +3394,221 @@ export default function RipperPage({
|
||||
return null;
|
||||
})()}
|
||||
{!isCdJob && !isAudiobookJob ? (
|
||||
<PipelineStatusCard
|
||||
jobId={jobId}
|
||||
jobRow={job}
|
||||
pipeline={pipelineForJob}
|
||||
onAnalyze={handleAnalyze}
|
||||
onReanalyze={handleReanalyze}
|
||||
onOpenMetadata={handleOpenMetadataDialog}
|
||||
onReassignOmdb={handleOpenReassignOmdbDialog}
|
||||
onStart={handleStartJob}
|
||||
onRestartEncode={handleRestartEncodeWithLastSettings}
|
||||
onRestartReview={handleRestartReviewFromRaw}
|
||||
onConfirmReview={handleConfirmReview}
|
||||
onSelectPlaylist={handleSelectPlaylist}
|
||||
onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
|
||||
onSubmitRawDecision={handleSubmitRawDecision}
|
||||
onCancel={handleCancel}
|
||||
onRetry={handleRetry}
|
||||
onDeleteJob={handleDeleteRipperJob}
|
||||
onRemoveFromQueue={handleRemoveQueuedJob}
|
||||
isQueued={isQueued}
|
||||
busy={busyJobIds.has(jobId)}
|
||||
/>
|
||||
isMultipartMerge ? (
|
||||
<div className="multipart-merge-panel">
|
||||
<div className="multipart-merge-head">
|
||||
<strong>Merge-Quellen</strong>
|
||||
<small>Drag-and-Drop ändert die Reihenfolge der Disc-Dateien.</small>
|
||||
</div>
|
||||
{hasMultipartMergeSources ? (
|
||||
<div className="multipart-merge-list">
|
||||
{multipartMergeSources.map((source, sourceIndex) => {
|
||||
const sourceKey = `${jobId}-${source.sourceJobId}-${sourceIndex}`;
|
||||
const canReorder = !busyJobIds.has(jobId)
|
||||
&& !processingStates.includes(normalizedStatus)
|
||||
&& multipartMergeSources.length > 1;
|
||||
return (
|
||||
<div
|
||||
key={sourceKey}
|
||||
className="multipart-merge-item"
|
||||
draggable={canReorder}
|
||||
onDragStart={() => {
|
||||
if (!canReorder) return;
|
||||
setDraggingMergeSource({ jobId, sourceJobId: source.sourceJobId });
|
||||
}}
|
||||
onDragEnd={() => setDraggingMergeSource(null)}
|
||||
onDragOver={(event) => {
|
||||
if (!canReorder) return;
|
||||
if (!draggingMergeSource || draggingMergeSource.jobId !== jobId) return;
|
||||
event.preventDefault();
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!canReorder) return;
|
||||
if (!draggingMergeSource || draggingMergeSource.jobId !== jobId) return;
|
||||
event.preventDefault();
|
||||
const draggedId = normalizeJobId(draggingMergeSource.sourceJobId);
|
||||
const targetId = normalizeJobId(source.sourceJobId);
|
||||
if (!draggedId || !targetId || draggedId === targetId) {
|
||||
setDraggingMergeSource(null);
|
||||
return;
|
||||
}
|
||||
const currentOrder = multipartMergeSources
|
||||
.map((item) => normalizeJobId(item.sourceJobId))
|
||||
.filter(Boolean);
|
||||
const fromIndex = currentOrder.findIndex((id) => id === draggedId);
|
||||
const toIndex = currentOrder.findIndex((id) => id === targetId);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
||||
setDraggingMergeSource(null);
|
||||
return;
|
||||
}
|
||||
const nextOrder = [...currentOrder];
|
||||
const [moved] = nextOrder.splice(fromIndex, 1);
|
||||
nextOrder.splice(toIndex, 0, moved);
|
||||
setDraggingMergeSource(null);
|
||||
handleReorderMultipartMergeSources(jobId, nextOrder);
|
||||
}}
|
||||
>
|
||||
<div className="multipart-merge-item-meta">
|
||||
<Tag value={`#${sourceIndex + 1}`} severity="contrast" />
|
||||
<Tag value={source.discNumber ? `Disc ${source.discNumber}` : 'Disc ?'} severity="info" />
|
||||
<Tag value={`Job #${source.sourceJobId}`} severity="secondary" />
|
||||
</div>
|
||||
<div className="multipart-merge-item-text">
|
||||
<strong>{source.fileName || source.outputPath}</strong>
|
||||
<small>{source.outputPath}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted-inline">Noch keine Merge-Quellen gefunden.</p>
|
||||
)}
|
||||
<div className="multipart-merge-preview">
|
||||
<div className="multipart-merge-preview-head">
|
||||
<strong>Merge Vorschau</strong>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
text
|
||||
severity="secondary"
|
||||
aria-label="Merge-Vorschau aktualisieren"
|
||||
onClick={() => loadMultipartMergePreview(jobId, { force: true, silent: false })}
|
||||
disabled={busyJobIds.has(jobId) || multipartMergePreviewLoading}
|
||||
/>
|
||||
</div>
|
||||
{multipartMergePreviewLoading ? (
|
||||
<small className="muted-inline">Vorschau wird aktualisiert ...</small>
|
||||
) : null}
|
||||
<div className="multipart-merge-preview-section">
|
||||
<strong>1. CLI-Befehl</strong>
|
||||
{multipartMergeCommandPreview ? (
|
||||
<code className="multipart-merge-command-preview">{multipartMergeCommandPreview}</code>
|
||||
) : (
|
||||
<small className="muted-inline">Noch keine Vorschau verfügbar.</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="multipart-merge-preview-section">
|
||||
<strong>2. Kapitel (gesamt)</strong>
|
||||
{multipartMergeChapterEntries.length > 0 ? (
|
||||
<div className="multipart-merge-chapter-list">
|
||||
{multipartMergeChapterEntries.map((entry) => (
|
||||
<div key={`merge-chapter-${jobId}-${entry.index}`} className="multipart-merge-chapter-item">
|
||||
<small>#{entry.index}</small>
|
||||
<small>{entry.startClock || entry.start || '-'}</small>
|
||||
<span>{entry.title || '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<small className="muted-inline">Keine berechnete Kapitel-Gesamtliste verfügbar.</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="multipart-merge-preview-section">
|
||||
<strong>3. Erwartete Medieninfos (nach Merge)</strong>
|
||||
<div className="multipart-merge-track-columns">
|
||||
<div className="multipart-merge-track-column">
|
||||
<small className="multipart-merge-track-heading">Audio</small>
|
||||
{multipartMergeAudioTracks.length > 0 ? (
|
||||
<div className="multipart-merge-track-list">
|
||||
{multipartMergeAudioTracks.map((track) => (
|
||||
<small key={`merge-audio-${jobId}-${track.order || track.streamIndex}`}>
|
||||
{formatMergeTrackSummary(track, 'Audio')}
|
||||
</small>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<small className="muted-inline">Keine Audio-Infos.</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="multipart-merge-track-column">
|
||||
<small className="multipart-merge-track-heading">Untertitel</small>
|
||||
{multipartMergeSubtitleTracks.length > 0 ? (
|
||||
<div className="multipart-merge-track-list">
|
||||
{multipartMergeSubtitleTracks.map((track) => (
|
||||
<small key={`merge-sub-${jobId}-${track.order || track.streamIndex}`}>
|
||||
{formatMergeTrackSummary(track, 'UT')}
|
||||
</small>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<small className="muted-inline">Keine Untertitel-Infos.</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{multipartMergePreview?.media?.available ? (
|
||||
<small className={multipartMergeMediaAligned ? 'muted-inline' : 'error-text'}>
|
||||
{multipartMergeMediaAligned
|
||||
? 'Track-Layout über alle Quellen konsistent.'
|
||||
: `Achtung: Track-Layout unterscheidet sich zwischen Quellen (${multipartMergeMediaMismatchCount} Abweichung(en)).`}
|
||||
</small>
|
||||
) : (
|
||||
<small className="muted-inline">Stream-Vergleich noch nicht verfügbar.</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="multipart-merge-actions">
|
||||
{(normalizedStatus === 'READY_TO_START' || normalizedStatus === 'READY_TO_ENCODE') ? (
|
||||
<Button
|
||||
label={isQueued ? 'In Queue' : 'Merge starten'}
|
||||
icon="pi pi-play"
|
||||
onClick={() => handleStartJob(jobId)}
|
||||
disabled={busyJobIds.has(jobId) || isQueued}
|
||||
/>
|
||||
) : null}
|
||||
{processingStates.includes(normalizedStatus) ? (
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
icon="pi pi-stop"
|
||||
severity="warning"
|
||||
outlined
|
||||
onClick={() => handleCancel(jobId, jobState)}
|
||||
disabled={busyJobIds.has(jobId)}
|
||||
/>
|
||||
) : null}
|
||||
{(normalizedStatus === 'ERROR' || normalizedStatus === 'CANCELLED') ? (
|
||||
<Button
|
||||
label="Retry"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => handleRetry(jobId)}
|
||||
disabled={busyJobIds.has(jobId)}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
label="Job löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => handleDeleteRipperJob(jobId)}
|
||||
disabled={busyJobIds.has(jobId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PipelineStatusCard
|
||||
jobId={jobId}
|
||||
jobRow={job}
|
||||
pipeline={pipelineForJob}
|
||||
onAnalyze={handleAnalyze}
|
||||
onReanalyze={handleReanalyze}
|
||||
onOpenMetadata={handleOpenMetadataDialog}
|
||||
onReassignOmdb={handleOpenReassignOmdbDialog}
|
||||
onStart={handleStartJob}
|
||||
onRestartEncode={handleRestartEncodeWithLastSettings}
|
||||
onRestartReview={handleRestartReviewFromRaw}
|
||||
onConfirmReview={handleConfirmReview}
|
||||
onSelectPlaylist={handleSelectPlaylist}
|
||||
onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
|
||||
onSubmitRawDecision={handleSubmitRawDecision}
|
||||
onCancel={handleCancel}
|
||||
onRetry={handleRetry}
|
||||
onDeleteJob={handleDeleteRipperJob}
|
||||
onRemoveFromQueue={handleRemoveQueuedJob}
|
||||
isQueued={isQueued}
|
||||
busy={busyJobIds.has(jobId)}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -3219,7 +3655,7 @@ export default function RipperPage({
|
||||
+ (mediaIndicator.isSeriesDvd ? seriesContainerLabel : '')
|
||||
+ (mediaIndicator.isSeriesDvd ? seriesTmdbLabel : '')
|
||||
+ (!mediaIndicator.isSeriesDvd && isMultipartMovie
|
||||
? `${multipartDiscNumber ? ` | Disc ${multipartDiscNumber}` : ''}${multipartContainerId ? ` | Container #${multipartContainerId}` : ''}`
|
||||
? `${isMultipartMerge ? ' | Merge' : ''}${multipartDiscNumber ? ` | Disc ${multipartDiscNumber}` : ''}${multipartContainerId ? ` | Container #${multipartContainerId}` : ''}`
|
||||
: '')
|
||||
+ (mediaIndicator.isSeriesDvd ? '' : `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`)
|
||||
))
|
||||
@@ -3229,7 +3665,8 @@ 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}
|
||||
{isMultipartMerge ? <Tag value="Merge Job" severity="warning" /> : null}
|
||||
{!isMultipartMerge && 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'
|
||||
|
||||
@@ -1566,6 +1566,137 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.multipart-merge-panel {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.5rem;
|
||||
background: #fff;
|
||||
padding: 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.multipart-merge-head {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.multipart-merge-head small {
|
||||
color: var(--rip-muted);
|
||||
}
|
||||
|
||||
.multipart-merge-list {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.multipart-merge-item {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.5rem 0.55rem;
|
||||
background: var(--rip-panel-soft);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.multipart-merge-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.multipart-merge-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.multipart-merge-item-text {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.multipart-merge-item-text strong,
|
||||
.multipart-merge-item-text small {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.multipart-merge-item-text small {
|
||||
color: var(--rip-muted);
|
||||
}
|
||||
|
||||
.multipart-merge-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.multipart-merge-preview {
|
||||
border: 1px dashed var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
background: #fffdf8;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.multipart-merge-preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.multipart-merge-preview-section {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.multipart-merge-command-preview {
|
||||
display: block;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-radius: 0.35rem;
|
||||
border: 1px solid var(--rip-border);
|
||||
background: #fff;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.multipart-merge-chapter-list {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.multipart-merge-chapter-item {
|
||||
display: grid;
|
||||
grid-template-columns: 3rem 5.5rem minmax(0, 1fr);
|
||||
gap: 0.45rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.multipart-merge-track-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.multipart-merge-track-column {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.multipart-merge-track-heading {
|
||||
color: var(--rip-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.multipart-merge-track-list {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.playlist-waiting-box {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
@@ -2893,6 +3024,12 @@ body {
|
||||
background: #fff1db;
|
||||
}
|
||||
|
||||
.history-dv-chip.tone-info {
|
||||
color: #0d5a86;
|
||||
border-color: #93c7e8;
|
||||
background: #e3f3ff;
|
||||
}
|
||||
|
||||
.history-dv-rating-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2988,6 +3125,23 @@ body {
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.history-status-tag-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.history-dv-grid-status-overlay .history-status-tag-wrap {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.history-status-tag-subtitle {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.1;
|
||||
color: #8b2c2c;
|
||||
}
|
||||
|
||||
.history-dv-grid-main {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
|
||||
@@ -65,6 +65,7 @@ function normalizeJobKind(value) {
|
||||
'dvd_series_child',
|
||||
'multipart_movie_container',
|
||||
'multipart_movie_child',
|
||||
'multipart_movie_merge',
|
||||
'converter_audio',
|
||||
'converter_video',
|
||||
'converter_iso'
|
||||
@@ -111,6 +112,7 @@ function mediaTypeFromJobKind(jobKind) {
|
||||
|| normalized === 'dvd_series_child'
|
||||
|| normalized === 'multipart_movie_container'
|
||||
|| normalized === 'multipart_movie_child'
|
||||
|| normalized === 'multipart_movie_merge'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user