0.15.0 MovieMerge

This commit is contained in:
2026-04-22 14:34:04 +00:00
parent ccd6c3ab2a
commit 06d5a45d9c
23 changed files with 3827 additions and 176 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "0.14.0-4",
"version": "0.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "0.14.0-4",
"version": "0.15.0",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.14.0-4",
"version": "0.15.0",
"private": true,
"type": "commonjs",
"scripts": {
+6
View File
@@ -1015,6 +1015,12 @@ async function migrateSettingsSchemaMetadata(db) {
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffmpeg_command', 'ffmpeg')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('mkvmerge_command', 'Tools', 'mkvmerge Kommando', 'string', 1, 'Pfad oder Befehl für mkvmerge. Wird für Multipart-Movie-Merges genutzt.', 'mkvmerge', '[]', '{"minLength":1}', 2325)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mkvmerge_command', 'mkvmerge')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('ffprobe_command', 'Tools', 'FFprobe Kommando', 'string', 1, 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.', 'ffprobe', '[]', '{"minLength":1}', 233)`
+56
View File
@@ -455,6 +455,62 @@ router.post(
})
);
router.post(
'/multipart-merge/:jobId/reorder',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const orderedSourceJobIds = Array.isArray(req.body?.orderedSourceJobIds)
? req.body.orderedSourceJobIds
: [];
logger.info('post:multipart-merge:reorder', {
reqId: req.reqId,
jobId,
orderedCount: orderedSourceJobIds.length
});
const job = await pipelineService.updateMultipartMergeSourceOrder(jobId, orderedSourceJobIds);
res.json({ job });
})
);
router.get(
'/multipart-merge/:jobId/preview',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
logger.info('get:multipart-merge:preview', {
reqId: req.reqId,
jobId
});
const preview = await pipelineService.getMultipartMergePreview(jobId);
res.json({ preview });
})
);
router.post(
'/multipart-merge/:containerJobId/restore',
asyncHandler(async (req, res) => {
const containerJobId = Number(req.params.containerJobId);
logger.info('post:multipart-merge:restore', {
reqId: req.reqId,
containerJobId
});
const job = await pipelineService.restoreMultipartMergeJobForContainer(containerJobId);
await pipelineService.emitQueueChanged().catch((error) => {
logger.warn('post:multipart-merge:restore:queue-emit-failed', {
reqId: req.reqId,
containerJobId,
error: error?.message || String(error)
});
});
res.json({
result: {
restored: true,
mergeJobId: Number(job?.id || 0) || null
},
job
});
})
);
router.post(
'/confirm-encode/:jobId',
asyncHandler(async (req, res) => {
+227 -21
View File
@@ -394,6 +394,7 @@ function normalizeJobKindValue(value) {
|| raw === 'dvd_series_child'
|| raw === 'multipart_movie_container'
|| raw === 'multipart_movie_child'
|| raw === 'multipart_movie_merge'
) {
return raw;
}
@@ -2474,10 +2475,37 @@ function isSeriesContainerRow(row) {
return String(row?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
}
function isMultipartContainerRow(row) {
return String(row?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container';
}
function isDiskContainerRow(row) {
return isSeriesContainerRow(row) || isMultipartContainerRow(row);
}
function isSeriesChildRow(row) {
return String(row?.job_kind || '').trim().toLowerCase() === 'dvd_series_child';
}
function isMultipartChildRow(row) {
return String(row?.job_kind || '').trim().toLowerCase() === 'multipart_movie_child';
}
function isMultipartMergeRow(row) {
const jobKind = String(row?.job_kind || '').trim().toLowerCase();
if (jobKind === 'multipart_movie_merge') {
return true;
}
const encodePlan = parseInfoFromValue(row?.encodePlan ?? row?.encode_plan_json, null);
const handbrakeInfo = parseInfoFromValue(row?.handbrakeInfo ?? row?.handbrake_info_json, null);
const planJobKind = String(encodePlan?.jobKind || '').trim().toLowerCase();
if (planJobKind === 'multipart_movie_merge') {
return true;
}
const mode = String(encodePlan?.mode || handbrakeInfo?.mode || '').trim().toLowerCase();
return mode === 'multipart_merge';
}
function normalizeArchiveTarget(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'raw') {
@@ -3866,7 +3894,7 @@ class HistoryService {
return job;
});
const containerJobs = adjustedJobs.filter((job) => String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container');
const containerJobs = adjustedJobs.filter((job) => isDiskContainerRow(job));
const containerIds = containerJobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
const seriesCandidates = adjustedJobs.filter((job) => {
const mkInfo = parseJsonSafe(job?.makemkv_info_json, {});
@@ -3983,13 +4011,20 @@ class HistoryService {
if (!containerId) {
continue;
}
const containerKind = String(container?.job_kind || '').trim().toLowerCase();
const isMultipartContainer = containerKind === 'multipart_movie_container';
const mkInfo = parseJsonSafe(container?.makemkv_info_json, {});
const selectedMetadata = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {};
const episodeCount = Number(selectedMetadata?.episodeCount || 0);
const episodesLength = Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes.length : 0;
const expectedTotal = episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0);
const containerChildRows = directDiskRowsByContainerId.get(containerId) || [];
const containerChildRowsRaw = directDiskRowsByContainerId.get(containerId) || [];
const containerChildRows = isMultipartContainer
? containerChildRowsRaw.filter((row) => !isMultipartMergeRow(row))
: containerChildRowsRaw;
const containerEpisodeRows = directEpisodeRowsByContainerId.get(containerId) || [];
const expectedTotal = isMultipartContainer
? containerChildRows.length
: (episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0));
const childIds = containerChildRows
.map((row) => normalizeJobIdValue(row?.id))
.filter(Boolean);
@@ -4127,9 +4162,90 @@ class HistoryService {
encodeSuccessCount = Math.min(1, totalChildren);
}
}
const expectedFinal = expectedFromPlans > 0
? expectedFromPlans
: (expectedTotal > 0 ? expectedTotal : existingCount);
let mergeSummary = null;
if (isMultipartContainer) {
const mergeRows = containerChildRowsRaw.filter((row) => isMultipartMergeRow(row));
let mergeJobId = null;
let mergeActive = false;
let mergeCompleted = false;
let mergeOutputExists = false;
for (const mergeRow of mergeRows) {
const mergeRowId = normalizeJobIdValue(mergeRow?.id);
if (mergeRowId && (!mergeJobId || mergeRowId > mergeJobId)) {
mergeJobId = mergeRowId;
}
const mergeStatus = String(mergeRow?.status || mergeRow?.last_state || '').trim().toUpperCase();
if (mergeStatus === 'ENCODING') {
mergeActive = true;
}
const outputCandidates = new Set();
const directOutputPath = String(mergeRow?.output_path || '').trim();
if (directOutputPath) {
outputCandidates.add(directOutputPath);
}
if (mergeRowId) {
const linkedOutputs = Array.from(childOutputMap.get(mergeRowId) || []);
for (const outputPath of linkedOutputs) {
if (outputPath) {
outputCandidates.add(outputPath);
}
}
}
let rowOutputExists = false;
for (const outputPath of outputCandidates) {
if (!outputPath) {
continue;
}
if (!includeFsChecks || fs.existsSync(outputPath)) {
rowOutputExists = true;
break;
}
}
if (rowOutputExists) {
mergeOutputExists = true;
}
const mergeHbInfo = parseJsonSafe(mergeRow?.handbrake_info_json, null);
const mergeHbStatus = String(mergeHbInfo?.status || '').trim().toUpperCase();
if (mergeStatus === 'FINISHED' || mergeHbStatus === 'SUCCESS' || rowOutputExists) {
mergeCompleted = true;
}
}
const mergeInputExpected = totalChildren;
const mergeInputReady = mergeInputExpected > 0
? Math.min(existingCount, mergeInputExpected)
: 0;
const mergeReady = mergeInputExpected >= 2 && mergeInputReady >= mergeInputExpected;
const mergeMissingInputs = mergeInputExpected > mergeInputReady
? (mergeInputExpected - mergeInputReady)
: 0;
let mergeState = 'missing';
if (mergeActive) {
mergeState = 'active';
} else if (mergeCompleted) {
mergeState = 'done';
} else if (mergeReady) {
mergeState = mergeRows.length > 0 ? 'ready' : 'restorable';
} else if (mergeRows.length > 0) {
mergeState = 'blocked';
}
mergeSummary = {
hasJob: mergeRows.length > 0,
jobId: mergeJobId,
active: mergeActive,
completed: mergeCompleted,
ready: mergeReady,
outputExists: mergeOutputExists,
inputReady: mergeInputReady,
inputExpected: mergeInputExpected,
missingInputs: mergeMissingInputs,
state: mergeState
};
}
const expectedFinal = isMultipartContainer
? (totalChildren > 0 ? totalChildren : existingCount)
: (expectedFromPlans > 0
? expectedFromPlans
: (expectedTotal > 0 ? expectedTotal : existingCount));
containerOutputSummary.set(containerId, {
existing: existingCount,
expected: expectedFinal
@@ -4139,7 +4255,8 @@ class HistoryService {
containerChildSummary.set(containerId, {
raw: { existing: rawExistsCount, expected: totalChildren },
backup: { existing: backupSuccessCount, expected: totalChildren },
encode: { existing: encodeSuccessCount, expected: totalChildren }
encode: { existing: encodeSuccessCount, expected: totalChildren },
...(mergeSummary ? { merge: mergeSummary } : {})
});
}
@@ -4586,6 +4703,8 @@ class HistoryService {
childRows.map((row) => this.repairImportedOrphanJobClassification(row, settings))
);
const isSeriesContainer = String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
const isMultipartContainer = String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container';
const isDiskContainer = isSeriesContainer || isMultipartContainer;
const detailChildJobs = isSeriesContainer
? childJobs.filter((child) => !isSeriesBatchEpisodeSubJobRow(child))
: childJobs;
@@ -4636,12 +4755,90 @@ class HistoryService {
);
let seriesChildSummary = null;
if (isSeriesContainer && enrichedChildren.length > 0) {
const total = enrichedChildren.length;
if (isDiskContainer && enrichedChildren.length > 0) {
const summaryChildren = isMultipartContainer
? enrichedChildren.filter((child) => !isMultipartMergeRow(child))
: enrichedChildren;
const mergeChildren = isMultipartContainer
? enrichedChildren.filter((child) => isMultipartMergeRow(child))
: [];
const total = summaryChildren.length;
const outputReadyCount = summaryChildren.reduce((count, child) => (
count + (child?.outputStatus?.exists ? 1 : 0)
), 0);
const buildMergeSummary = () => {
if (!isMultipartContainer) {
return null;
}
let mergeJobId = null;
let mergeActive = false;
let mergeCompleted = false;
let mergeOutputExists = false;
for (const child of mergeChildren) {
const mergeRowId = normalizeJobIdValue(child?.id);
if (mergeRowId && (!mergeJobId || mergeRowId > mergeJobId)) {
mergeJobId = mergeRowId;
}
const mergeStatus = String(child?.status || child?.last_state || '').trim().toUpperCase();
if (mergeStatus === 'ENCODING') {
mergeActive = true;
}
const hasOutput = Boolean(child?.outputStatus?.exists || String(child?.output_path || '').trim());
if (hasOutput) {
mergeOutputExists = true;
}
if (
mergeStatus === 'FINISHED'
|| child?.encodeSuccess
|| hasOutput
|| String(child?.handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'
) {
mergeCompleted = true;
}
}
const mergeInputExpected = total;
const mergeInputReady = mergeInputExpected > 0
? Math.min(outputReadyCount, mergeInputExpected)
: 0;
const mergeReady = mergeInputExpected >= 2 && mergeInputReady >= mergeInputExpected;
const mergeMissingInputs = mergeInputExpected > mergeInputReady
? (mergeInputExpected - mergeInputReady)
: 0;
let mergeState = 'missing';
if (mergeActive) {
mergeState = 'active';
} else if (mergeCompleted) {
mergeState = 'done';
} else if (mergeReady) {
mergeState = mergeChildren.length > 0 ? 'ready' : 'restorable';
} else if (mergeChildren.length > 0) {
mergeState = 'blocked';
}
return {
hasJob: mergeChildren.length > 0,
jobId: mergeJobId,
active: mergeActive,
completed: mergeCompleted,
ready: mergeReady,
outputExists: mergeOutputExists,
inputReady: mergeInputReady,
inputExpected: mergeInputExpected,
missingInputs: mergeMissingInputs,
state: mergeState
};
};
if (total <= 0) {
seriesChildSummary = {
raw: { existing: 0, expected: 0 },
backup: { existing: 0, expected: 0 },
encode: { existing: 0, expected: 0 },
...(isMultipartContainer ? { merge: buildMergeSummary() } : {})
};
}
let rawCount = 0;
let backupCount = 0;
let encodeCount = 0;
for (const child of enrichedChildren) {
for (const child of summaryChildren) {
if (child?.rawStatus?.exists) {
rawCount += 1;
}
@@ -4659,11 +4856,14 @@ class HistoryService {
encodeCount += 1;
}
}
seriesChildSummary = {
raw: { existing: rawCount, expected: total },
backup: { existing: backupCount, expected: total },
encode: { existing: encodeCount, expected: total }
};
if (total > 0) {
seriesChildSummary = {
raw: { existing: rawCount, expected: total },
backup: { existing: backupCount, expected: total },
encode: { existing: encodeCount, expected: total },
...(isMultipartContainer ? { merge: buildMergeSummary() } : {})
};
}
}
const outputFolders = await this.getJobOutputFoldersForLineage(jobId);
@@ -5856,7 +6056,13 @@ class HistoryService {
: null;
const isPrimarySeriesChild = isSeriesChildRow(primary)
|| (parentRow && isSeriesContainerRow(parentRow));
const isPrimaryMultipartChild = isMultipartChildRow(primary)
|| isMultipartMergeRow(primary)
|| (parentRow && isMultipartContainerRow(parentRow));
const isPrimarySeriesContainer = isSeriesContainerRow(primary);
const isPrimaryMultipartContainer = isMultipartContainerRow(primary);
const isPrimaryScopedChild = isPrimarySeriesChild || isPrimaryMultipartChild;
const isPrimaryDiskContainer = isPrimarySeriesContainer || isPrimaryMultipartContainer;
const enqueue = (value) => {
const id = normalizeJobIdValue(value);
if (!id || visited.has(id)) {
@@ -5877,7 +6083,7 @@ class HistoryService {
continue;
}
if (!(isPrimarySeriesChild || isPrimarySeriesContainer)) {
if (!(isPrimaryScopedChild || isPrimaryDiskContainer)) {
enqueue(row.parent_job_id);
enqueue(parseSourceJobIdFromPlan(row.encode_plan_json));
}
@@ -5889,7 +6095,7 @@ class HistoryService {
enqueue(childId);
}
if (includeLogLinks && !(isPrimarySeriesChild || isPrimarySeriesContainer)) {
if (includeLogLinks && !(isPrimaryScopedChild || isPrimaryDiskContainer)) {
try {
const processLog = await this.readProcessLogLines(currentId, { includeAll: true });
const linkedJobIds = parseRetryLinkedJobIdsFromLogLines(processLog.lines);
@@ -5902,7 +6108,7 @@ class HistoryService {
}
}
if (isPrimarySeriesChild && parentRow && isSeriesContainerRow(parentRow)) {
if (isPrimaryScopedChild && parentRow && isDiskContainerRow(parentRow)) {
const parentId = normalizeJobIdValue(parentRow?.id);
if (parentId) {
const remainingChildren = rows.filter((row) => {
@@ -7226,11 +7432,11 @@ class HistoryService {
error.statusCode = 400;
throw error;
}
if (isSeriesContainerRow(existing)) {
if (isDiskContainerRow(existing)) {
const containerChildren = await this.listChildJobs(normalizedJobId);
if (Array.isArray(containerChildren) && containerChildren.length > 0) {
const error = new Error(
'Serien-Container kann nicht einzeln gelöscht werden, solange noch Child-Jobs vorhanden sind.'
'Container kann nicht einzeln gelöscht werden, solange noch Child-Jobs vorhanden sind.'
);
error.statusCode = 409;
throw error;
@@ -7385,7 +7591,7 @@ class HistoryService {
// other children afterwards.
for (const candidateId of [...deleteJobIds]) {
const row = rowById.get(candidateId);
if (!row || !isSeriesContainerRow(row)) {
if (!row || !isDiskContainerRow(row)) {
continue;
}
const childRows = await db.all(
File diff suppressed because it is too large Load Diff
+30 -1
View File
@@ -43,6 +43,15 @@ const SENSITIVE_SETTING_KEYS = new Set([
'pushover_token',
'pushover_user'
]);
const IMMUTABLE_SETTING_KEYS = new Set([
'makemkv_command',
'mediainfo_command',
'handbrake_command',
'ffmpeg_command',
'ffprobe_command',
'cdparanoia_command',
'mkvmerge_command'
]);
const AUDIO_SELECTION_KEYS_WITH_VALUE = new Set(['-a', '--audio', '--audio-lang-list']);
const AUDIO_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-audio', '--first-audio']);
const SUBTITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-s', '--subtitle', '--subtitle-lang-list']);
@@ -159,6 +168,10 @@ function applyRuntimeLogDirSetting(rawValue) {
}
}
function isImmutableSettingKey(key) {
return IMMUTABLE_SETTING_KEYS.has(String(key || '').trim().toLowerCase());
}
function normalizeTrackIds(rawList) {
const list = Array.isArray(rawList) ? rawList : [];
const seen = new Set();
@@ -961,6 +974,12 @@ class SettingsService {
}
async setSettingValue(key, rawValue) {
if (isImmutableSettingKey(key)) {
const error = new Error(`Setting ${key} ist schreibgeschützt und kann nicht geändert werden.`);
error.statusCode = 403;
throw error;
}
const db = await getDb();
const schema = await db.get('SELECT * FROM settings_schema WHERE key = ?', [key]);
if (!schema) {
@@ -1022,6 +1041,14 @@ class SettingsService {
const validationErrors = [];
for (const [key, rawValue] of entries) {
if (isImmutableSettingKey(key)) {
validationErrors.push({
key,
message: 'Dieses Setting ist schreibgeschützt und kann nicht geändert werden.'
});
continue;
}
const schema = schemaByKey.get(key);
if (!schema) {
const error = new Error(`Setting ${key} existiert nicht.`);
@@ -1047,7 +1074,9 @@ class SettingsService {
if (validationErrors.length > 0) {
const error = new Error('Mindestens ein Setting ist ungültig.');
error.statusCode = 400;
error.statusCode = validationErrors.some((item) => String(item?.message || '').includes('schreibgeschützt'))
? 403
: 400;
error.details = validationErrors;
throw error;
}
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.14.0-4",
"version": "0.15.0",
"private": true,
"type": "module",
"scripts": {
+2
View File
@@ -455,6 +455,8 @@ function App() {
...(prev || {}),
queue: message.payload || null
}));
setRipperJobsRefreshToken((prev) => prev + 1);
setHistoryJobsRefreshToken((prev) => prev + 1);
}
if (message.type === 'DISC_DETECTED') {
+20
View File
@@ -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',
+14
View File
@@ -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

+33 -11
View File
@@ -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>
+213 -21
View File
@@ -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>
);
}
+245 -10
View File
@@ -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 : [];
+107 -13
View File
@@ -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}
+467 -30
View File
@@ -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'
+154
View File
@@ -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;
+2
View File
@@ -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;
}
+2 -1
View File
@@ -644,7 +644,8 @@ apt-get install -y \
mediainfo \
util-linux udev \
ca-certificates gnupg \
lsb-release
lsb-release \
mkvmerge
ok "Basispakete installiert"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster",
"version": "0.14.0-4",
"version": "0.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
"version": "0.14.0-4",
"version": "0.15.0",
"devDependencies": {
"concurrently": "^9.1.2"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
"version": "0.14.0-4",
"version": "0.15.0",
"scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend",