0.15.0 MovieMerge
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.14.0-4",
|
"version": "0.15.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.14.0-4",
|
"version": "0.15.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.14.0-4",
|
"version": "0.15.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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_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(
|
await db.run(
|
||||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
`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)`
|
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)`
|
||||||
|
|||||||
@@ -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(
|
router.post(
|
||||||
'/confirm-encode/:jobId',
|
'/confirm-encode/:jobId',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ function normalizeJobKindValue(value) {
|
|||||||
|| raw === 'dvd_series_child'
|
|| raw === 'dvd_series_child'
|
||||||
|| raw === 'multipart_movie_container'
|
|| raw === 'multipart_movie_container'
|
||||||
|| raw === 'multipart_movie_child'
|
|| raw === 'multipart_movie_child'
|
||||||
|
|| raw === 'multipart_movie_merge'
|
||||||
) {
|
) {
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
@@ -2474,10 +2475,37 @@ function isSeriesContainerRow(row) {
|
|||||||
return String(row?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
|
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) {
|
function isSeriesChildRow(row) {
|
||||||
return String(row?.job_kind || '').trim().toLowerCase() === 'dvd_series_child';
|
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) {
|
function normalizeArchiveTarget(value) {
|
||||||
const raw = String(value || '').trim().toLowerCase();
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
if (raw === 'raw') {
|
if (raw === 'raw') {
|
||||||
@@ -3866,7 +3894,7 @@ class HistoryService {
|
|||||||
return job;
|
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 containerIds = containerJobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
|
||||||
const seriesCandidates = adjustedJobs.filter((job) => {
|
const seriesCandidates = adjustedJobs.filter((job) => {
|
||||||
const mkInfo = parseJsonSafe(job?.makemkv_info_json, {});
|
const mkInfo = parseJsonSafe(job?.makemkv_info_json, {});
|
||||||
@@ -3983,13 +4011,20 @@ class HistoryService {
|
|||||||
if (!containerId) {
|
if (!containerId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const containerKind = String(container?.job_kind || '').trim().toLowerCase();
|
||||||
|
const isMultipartContainer = containerKind === 'multipart_movie_container';
|
||||||
const mkInfo = parseJsonSafe(container?.makemkv_info_json, {});
|
const mkInfo = parseJsonSafe(container?.makemkv_info_json, {});
|
||||||
const selectedMetadata = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {};
|
const selectedMetadata = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {};
|
||||||
const episodeCount = Number(selectedMetadata?.episodeCount || 0);
|
const episodeCount = Number(selectedMetadata?.episodeCount || 0);
|
||||||
const episodesLength = Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes.length : 0;
|
const episodesLength = Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes.length : 0;
|
||||||
const expectedTotal = episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0);
|
const containerChildRowsRaw = directDiskRowsByContainerId.get(containerId) || [];
|
||||||
const containerChildRows = directDiskRowsByContainerId.get(containerId) || [];
|
const containerChildRows = isMultipartContainer
|
||||||
|
? containerChildRowsRaw.filter((row) => !isMultipartMergeRow(row))
|
||||||
|
: containerChildRowsRaw;
|
||||||
const containerEpisodeRows = directEpisodeRowsByContainerId.get(containerId) || [];
|
const containerEpisodeRows = directEpisodeRowsByContainerId.get(containerId) || [];
|
||||||
|
const expectedTotal = isMultipartContainer
|
||||||
|
? containerChildRows.length
|
||||||
|
: (episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0));
|
||||||
const childIds = containerChildRows
|
const childIds = containerChildRows
|
||||||
.map((row) => normalizeJobIdValue(row?.id))
|
.map((row) => normalizeJobIdValue(row?.id))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -4127,9 +4162,90 @@ class HistoryService {
|
|||||||
encodeSuccessCount = Math.min(1, totalChildren);
|
encodeSuccessCount = Math.min(1, totalChildren);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const expectedFinal = expectedFromPlans > 0
|
let mergeSummary = null;
|
||||||
? expectedFromPlans
|
if (isMultipartContainer) {
|
||||||
: (expectedTotal > 0 ? expectedTotal : existingCount);
|
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, {
|
containerOutputSummary.set(containerId, {
|
||||||
existing: existingCount,
|
existing: existingCount,
|
||||||
expected: expectedFinal
|
expected: expectedFinal
|
||||||
@@ -4139,7 +4255,8 @@ class HistoryService {
|
|||||||
containerChildSummary.set(containerId, {
|
containerChildSummary.set(containerId, {
|
||||||
raw: { existing: rawExistsCount, expected: totalChildren },
|
raw: { existing: rawExistsCount, expected: totalChildren },
|
||||||
backup: { existing: backupSuccessCount, 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))
|
childRows.map((row) => this.repairImportedOrphanJobClassification(row, settings))
|
||||||
);
|
);
|
||||||
const isSeriesContainer = String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
|
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
|
const detailChildJobs = isSeriesContainer
|
||||||
? childJobs.filter((child) => !isSeriesBatchEpisodeSubJobRow(child))
|
? childJobs.filter((child) => !isSeriesBatchEpisodeSubJobRow(child))
|
||||||
: childJobs;
|
: childJobs;
|
||||||
@@ -4636,12 +4755,90 @@ class HistoryService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let seriesChildSummary = null;
|
let seriesChildSummary = null;
|
||||||
if (isSeriesContainer && enrichedChildren.length > 0) {
|
if (isDiskContainer && enrichedChildren.length > 0) {
|
||||||
const total = enrichedChildren.length;
|
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 rawCount = 0;
|
||||||
let backupCount = 0;
|
let backupCount = 0;
|
||||||
let encodeCount = 0;
|
let encodeCount = 0;
|
||||||
for (const child of enrichedChildren) {
|
for (const child of summaryChildren) {
|
||||||
if (child?.rawStatus?.exists) {
|
if (child?.rawStatus?.exists) {
|
||||||
rawCount += 1;
|
rawCount += 1;
|
||||||
}
|
}
|
||||||
@@ -4659,11 +4856,14 @@ class HistoryService {
|
|||||||
encodeCount += 1;
|
encodeCount += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
seriesChildSummary = {
|
if (total > 0) {
|
||||||
raw: { existing: rawCount, expected: total },
|
seriesChildSummary = {
|
||||||
backup: { existing: backupCount, expected: total },
|
raw: { existing: rawCount, expected: total },
|
||||||
encode: { existing: encodeCount, expected: total }
|
backup: { existing: backupCount, expected: total },
|
||||||
};
|
encode: { existing: encodeCount, expected: total },
|
||||||
|
...(isMultipartContainer ? { merge: buildMergeSummary() } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputFolders = await this.getJobOutputFoldersForLineage(jobId);
|
const outputFolders = await this.getJobOutputFoldersForLineage(jobId);
|
||||||
@@ -5856,7 +6056,13 @@ class HistoryService {
|
|||||||
: null;
|
: null;
|
||||||
const isPrimarySeriesChild = isSeriesChildRow(primary)
|
const isPrimarySeriesChild = isSeriesChildRow(primary)
|
||||||
|| (parentRow && isSeriesContainerRow(parentRow));
|
|| (parentRow && isSeriesContainerRow(parentRow));
|
||||||
|
const isPrimaryMultipartChild = isMultipartChildRow(primary)
|
||||||
|
|| isMultipartMergeRow(primary)
|
||||||
|
|| (parentRow && isMultipartContainerRow(parentRow));
|
||||||
const isPrimarySeriesContainer = isSeriesContainerRow(primary);
|
const isPrimarySeriesContainer = isSeriesContainerRow(primary);
|
||||||
|
const isPrimaryMultipartContainer = isMultipartContainerRow(primary);
|
||||||
|
const isPrimaryScopedChild = isPrimarySeriesChild || isPrimaryMultipartChild;
|
||||||
|
const isPrimaryDiskContainer = isPrimarySeriesContainer || isPrimaryMultipartContainer;
|
||||||
const enqueue = (value) => {
|
const enqueue = (value) => {
|
||||||
const id = normalizeJobIdValue(value);
|
const id = normalizeJobIdValue(value);
|
||||||
if (!id || visited.has(id)) {
|
if (!id || visited.has(id)) {
|
||||||
@@ -5877,7 +6083,7 @@ class HistoryService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(isPrimarySeriesChild || isPrimarySeriesContainer)) {
|
if (!(isPrimaryScopedChild || isPrimaryDiskContainer)) {
|
||||||
enqueue(row.parent_job_id);
|
enqueue(row.parent_job_id);
|
||||||
enqueue(parseSourceJobIdFromPlan(row.encode_plan_json));
|
enqueue(parseSourceJobIdFromPlan(row.encode_plan_json));
|
||||||
}
|
}
|
||||||
@@ -5889,7 +6095,7 @@ class HistoryService {
|
|||||||
enqueue(childId);
|
enqueue(childId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeLogLinks && !(isPrimarySeriesChild || isPrimarySeriesContainer)) {
|
if (includeLogLinks && !(isPrimaryScopedChild || isPrimaryDiskContainer)) {
|
||||||
try {
|
try {
|
||||||
const processLog = await this.readProcessLogLines(currentId, { includeAll: true });
|
const processLog = await this.readProcessLogLines(currentId, { includeAll: true });
|
||||||
const linkedJobIds = parseRetryLinkedJobIdsFromLogLines(processLog.lines);
|
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);
|
const parentId = normalizeJobIdValue(parentRow?.id);
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
const remainingChildren = rows.filter((row) => {
|
const remainingChildren = rows.filter((row) => {
|
||||||
@@ -7226,11 +7432,11 @@ class HistoryService {
|
|||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (isSeriesContainerRow(existing)) {
|
if (isDiskContainerRow(existing)) {
|
||||||
const containerChildren = await this.listChildJobs(normalizedJobId);
|
const containerChildren = await this.listChildJobs(normalizedJobId);
|
||||||
if (Array.isArray(containerChildren) && containerChildren.length > 0) {
|
if (Array.isArray(containerChildren) && containerChildren.length > 0) {
|
||||||
const error = new Error(
|
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;
|
error.statusCode = 409;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -7385,7 +7591,7 @@ class HistoryService {
|
|||||||
// other children afterwards.
|
// other children afterwards.
|
||||||
for (const candidateId of [...deleteJobIds]) {
|
for (const candidateId of [...deleteJobIds]) {
|
||||||
const row = rowById.get(candidateId);
|
const row = rowById.get(candidateId);
|
||||||
if (!row || !isSeriesContainerRow(row)) {
|
if (!row || !isDiskContainerRow(row)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const childRows = await db.all(
|
const childRows = await db.all(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,15 @@ const SENSITIVE_SETTING_KEYS = new Set([
|
|||||||
'pushover_token',
|
'pushover_token',
|
||||||
'pushover_user'
|
'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_WITH_VALUE = new Set(['-a', '--audio', '--audio-lang-list']);
|
||||||
const AUDIO_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-audio', '--first-audio']);
|
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']);
|
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) {
|
function normalizeTrackIds(rawList) {
|
||||||
const list = Array.isArray(rawList) ? rawList : [];
|
const list = Array.isArray(rawList) ? rawList : [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -961,6 +974,12 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setSettingValue(key, rawValue) {
|
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 db = await getDb();
|
||||||
const schema = await db.get('SELECT * FROM settings_schema WHERE key = ?', [key]);
|
const schema = await db.get('SELECT * FROM settings_schema WHERE key = ?', [key]);
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
@@ -1022,6 +1041,14 @@ class SettingsService {
|
|||||||
const validationErrors = [];
|
const validationErrors = [];
|
||||||
|
|
||||||
for (const [key, rawValue] of entries) {
|
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);
|
const schema = schemaByKey.get(key);
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
const error = new Error(`Setting ${key} existiert nicht.`);
|
const error = new Error(`Setting ${key} existiert nicht.`);
|
||||||
@@ -1047,7 +1074,9 @@ class SettingsService {
|
|||||||
|
|
||||||
if (validationErrors.length > 0) {
|
if (validationErrors.length > 0) {
|
||||||
const error = new Error('Mindestens ein Setting ist ungültig.');
|
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;
|
error.details = validationErrors;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.14.0-4",
|
"version": "0.15.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.14.0-4",
|
"version": "0.15.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.14.0-4",
|
"version": "0.15.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -455,6 +455,8 @@ function App() {
|
|||||||
...(prev || {}),
|
...(prev || {}),
|
||||||
queue: message.payload || null
|
queue: message.payload || null
|
||||||
}));
|
}));
|
||||||
|
setRipperJobsRefreshToken((prev) => prev + 1);
|
||||||
|
setHistoryJobsRefreshToken((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'DISC_DETECTED') {
|
if (message.type === 'DISC_DETECTED') {
|
||||||
|
|||||||
@@ -640,6 +640,26 @@ export const api = {
|
|||||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
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 = {}) {
|
async confirmEncodeReview(jobId, payload = {}) {
|
||||||
const result = await request(`/pipeline/confirm-encode/${jobId}`, {
|
const result = await request(`/pipeline/confirm-encode/${jobId}`, {
|
||||||
method: 'POST',
|
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',
|
'mediainfo_command',
|
||||||
'handbrake_command',
|
'handbrake_command',
|
||||||
'ffmpeg_command',
|
'ffmpeg_command',
|
||||||
|
'mkvmerge_command',
|
||||||
'ffprobe_command',
|
'ffprobe_command',
|
||||||
'handbrake_restart_delete_incomplete_output',
|
'handbrake_restart_delete_incomplete_output',
|
||||||
'script_test_timeout_ms'
|
'script_test_timeout_ms'
|
||||||
@@ -530,6 +531,15 @@ const CONVERTER_SCAN_EXTENSION_OPTIONS = [
|
|||||||
'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov',
|
'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov',
|
||||||
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
|
'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) {
|
function parseConverterScanExtensions(value) {
|
||||||
const tokens = String(value || '')
|
const tokens = String(value || '')
|
||||||
@@ -630,6 +640,10 @@ function isNotificationEventToggleSetting(setting) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isReadonlySetting(setting) {
|
function isReadonlySetting(setting) {
|
||||||
|
const key = normalizeSettingKey(setting?.key);
|
||||||
|
if (READONLY_CLI_SETTING_KEYS.has(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// API gibt validation als parsed object zurück, nicht validation_json als String
|
// API gibt validation als parsed object zurück, nicht validation_json als String
|
||||||
const v = setting?.validation;
|
const v = setting?.validation;
|
||||||
@@ -656,6 +670,8 @@ function SettingField({
|
|||||||
const ownerKey = ownerSetting?.key;
|
const ownerKey = ownerSetting?.key;
|
||||||
const pathHasValue = Boolean(String(value ?? '').trim());
|
const pathHasValue = Boolean(String(value ?? '').trim());
|
||||||
const isNotificationToggleBox = variant === 'notification-toggle' && setting?.type === 'boolean';
|
const isNotificationToggleBox = variant === 'notification-toggle' && setting?.type === 'boolean';
|
||||||
|
const readonlySetting = isReadonlySetting(setting);
|
||||||
|
const isDisabled = Boolean(disabled || readonlySetting);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`setting-row${isNotificationToggleBox ? ' notification-toggle-box' : ''}`}>
|
<div className={`setting-row${isNotificationToggleBox ? ' notification-toggle-box' : ''}`}>
|
||||||
@@ -668,15 +684,17 @@ function SettingField({
|
|||||||
<InputSwitch
|
<InputSwitch
|
||||||
id={setting.key}
|
id={setting.key}
|
||||||
checked={Boolean(value)}
|
checked={Boolean(value)}
|
||||||
disabled={disabled}
|
disabled={isDisabled}
|
||||||
onChange={disabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
onChange={isDisabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<label htmlFor={setting.key} className={disabled ? 'setting-label--readonly' : undefined}>
|
<label htmlFor={setting.key} className={isDisabled ? 'setting-label--readonly' : undefined}>
|
||||||
{setting.label}
|
{setting.label}
|
||||||
{setting.required && <span className="required">*</span>}
|
{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>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -687,7 +705,9 @@ function SettingField({
|
|||||||
<InputText
|
<InputText
|
||||||
id={setting.key}
|
id={setting.key}
|
||||||
value={value ?? ''}
|
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}
|
) : null}
|
||||||
|
|
||||||
@@ -719,7 +739,8 @@ function SettingField({
|
|||||||
<InputNumber
|
<InputNumber
|
||||||
id={setting.key}
|
id={setting.key}
|
||||||
value={value ?? 0}
|
value={value ?? 0}
|
||||||
onValueChange={(event) => onChange?.(setting.key, event.value)}
|
disabled={isDisabled}
|
||||||
|
onValueChange={isDisabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
||||||
mode="decimal"
|
mode="decimal"
|
||||||
useGrouping={false}
|
useGrouping={false}
|
||||||
/>
|
/>
|
||||||
@@ -729,8 +750,8 @@ function SettingField({
|
|||||||
<InputSwitch
|
<InputSwitch
|
||||||
id={setting.key}
|
id={setting.key}
|
||||||
checked={Boolean(value)}
|
checked={Boolean(value)}
|
||||||
disabled={disabled}
|
disabled={isDisabled}
|
||||||
onChange={disabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
onChange={isDisabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -742,7 +763,8 @@ function SettingField({
|
|||||||
optionLabel="label"
|
optionLabel="label"
|
||||||
optionValue="value"
|
optionValue="value"
|
||||||
optionDisabled="disabled"
|
optionDisabled="disabled"
|
||||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
disabled={isDisabled}
|
||||||
|
onChange={isDisabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -778,8 +800,8 @@ function SettingField({
|
|||||||
id={ownerKey}
|
id={ownerKey}
|
||||||
value={ownerValue ?? ''}
|
value={ownerValue ?? ''}
|
||||||
placeholder="z.B. michael:ripster"
|
placeholder="z.B. michael:ripster"
|
||||||
disabled={!pathHasValue}
|
disabled={isDisabled || !pathHasValue}
|
||||||
onChange={(event) => onChange?.(ownerKey, event.target.value)}
|
onChange={isDisabled ? undefined : (event) => onChange?.(ownerKey, event.target.value)}
|
||||||
/>
|
/>
|
||||||
{ownerError ? (
|
{ownerError ? (
|
||||||
<small className="error-text">{ownerError}</small>
|
<small className="error-text">{ownerError}</small>
|
||||||
|
|||||||
@@ -736,6 +736,7 @@ function resolveSeriesDiscNumber(job) {
|
|||||||
metadataContext?.selectedMetadata?.discNumber
|
metadataContext?.selectedMetadata?.discNumber
|
||||||
?? metadataContext?.analyzeContext?.seriesLookupHint?.discNumber
|
?? metadataContext?.analyzeContext?.seriesLookupHint?.discNumber
|
||||||
?? job?.encodePlan?.discNumber
|
?? job?.encodePlan?.discNumber
|
||||||
|
?? job?.disc_number
|
||||||
?? null
|
?? null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -757,9 +758,27 @@ function isSeriesBatchEpisodeChildJob(job) {
|
|||||||
return hasParent && hasEpisodeMarker;
|
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 = []) {
|
function buildSeriesContainerDiskChildren(children = []) {
|
||||||
const rows = Array.isArray(children) ? 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 byDisc = new Map();
|
||||||
const withoutDisc = [];
|
const withoutDisc = [];
|
||||||
|
|
||||||
@@ -946,6 +965,7 @@ export default function JobDetailDialog({
|
|||||||
onDownloadArchive,
|
onDownloadArchive,
|
||||||
onDownloadOutputFolder,
|
onDownloadOutputFolder,
|
||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
|
onRestoreMultipartMerge,
|
||||||
onCancel,
|
onCancel,
|
||||||
isQueued = false,
|
isQueued = false,
|
||||||
omdbAssignBusy = false,
|
omdbAssignBusy = false,
|
||||||
@@ -955,6 +975,7 @@ export default function JobDetailDialog({
|
|||||||
cancelBusy = false,
|
cancelBusy = false,
|
||||||
reencodeBusy = false,
|
reencodeBusy = false,
|
||||||
deleteEntryBusy = false,
|
deleteEntryBusy = false,
|
||||||
|
restoreMergeBusy = false,
|
||||||
downloadBusyTarget = null,
|
downloadBusyTarget = null,
|
||||||
downloadFolderBusyPath = null
|
downloadFolderBusyPath = null
|
||||||
}) {
|
}) {
|
||||||
@@ -1170,17 +1191,121 @@ export default function JobDetailDialog({
|
|||||||
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
|
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
|
||||||
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
|
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
|
||||||
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
|
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)
|
const childJobs = Array.isArray(job?.children)
|
||||||
? (
|
? (
|
||||||
isSeriesContainer
|
isDiskContainer
|
||||||
? buildSeriesContainerDiskChildren(job.children)
|
? buildSeriesContainerDiskChildren(allChildJobs)
|
||||||
: [...job.children].sort(compareSeriesChildJobsByDisc)
|
: allChildJobs
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
const mergeChildJobs = isMultipartContainer
|
||||||
|
? allChildJobs.filter((child) => isMultipartMergeChildJob(child))
|
||||||
|
: [];
|
||||||
const seriesChildSummary = job?.seriesChildSummary || null;
|
const seriesChildSummary = job?.seriesChildSummary || null;
|
||||||
const seriesBackupSummary = isSeriesContainer ? seriesChildSummary?.backup : null;
|
const seriesBackupSummary = isDiskContainer ? seriesChildSummary?.backup : null;
|
||||||
const seriesEncodeSummary = isSeriesContainer ? seriesChildSummary?.encode : 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 displayedImdbId = metadataDetails?.imdbId || job?.imdb_id || null;
|
||||||
const metadataJsonTitle = metadataDetails.provider === 'tmdb' ? 'TMDb Info' : 'OMDb Info';
|
const metadataJsonTitle = metadataDetails.provider === 'tmdb' ? 'TMDb Info' : 'OMDb Info';
|
||||||
const metadataJsonValue = metadataDetails.provider === 'tmdb'
|
const metadataJsonValue = metadataDetails.provider === 'tmdb'
|
||||||
@@ -1216,7 +1341,7 @@ export default function JobDetailDialog({
|
|||||||
|| typeof onRestartEncode === 'function'
|
|| typeof onRestartEncode === 'function'
|
||||||
|| typeof onRestartReview === 'function'
|
|| typeof onRestartReview === 'function'
|
||||||
|| typeof onReencode === 'function';
|
|| typeof onReencode === 'function';
|
||||||
const isContainerWithDiskActions = isSeriesContainer && childJobs.length > 0;
|
const isContainerWithDiskActions = isDiskContainer && childJobs.length > 0;
|
||||||
const resolveChildActionState = (child) => {
|
const resolveChildActionState = (child) => {
|
||||||
const childStatusUpper = String(child?.status || '').trim().toUpperCase();
|
const childStatusUpper = String(child?.status || '').trim().toUpperCase();
|
||||||
const childLastStateUpper = String(child?.last_state || '').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} />
|
<TriState existing={seriesBackupSummary.existing} expected={seriesBackupSummary.expected} />
|
||||||
) : (
|
) : (
|
||||||
<BoolState value={job?.backupSuccess} />
|
<BoolState value={job?.backupSuccess} />
|
||||||
)}</span>
|
)}</span>
|
||||||
<span className="job-infos-sep">|</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} />
|
<TriState existing={seriesEncodeSummary.existing} expected={seriesEncodeSummary.expected} />
|
||||||
) : (
|
) : (
|
||||||
<BoolState value={job?.encodeSuccess} />
|
<BoolState value={job?.encodeSuccess} />
|
||||||
)}</span>
|
)}</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>
|
</div>
|
||||||
@@ -1572,14 +1712,16 @@ export default function JobDetailDialog({
|
|||||||
<div><strong>Start:</strong> {job.start_time || '-'}</div>
|
<div><strong>Start:</strong> {job.start_time || '-'}</div>
|
||||||
<div><strong>Ende:</strong> {job.end_time || '-'}</div>
|
<div><strong>Ende:</strong> {job.end_time || '-'}</div>
|
||||||
{/* Zeile 3+4: Pfade */}
|
{/* Zeile 3+4: Pfade */}
|
||||||
<PathField
|
{!isDiskContainer ? (
|
||||||
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
|
<PathField
|
||||||
value={isSeriesContainer ? null : (isCd ? (job.raw_path || job.output_path) : resolvedRawPath)}
|
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
|
||||||
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
|
value={isCd ? (job.raw_path || job.output_path) : resolvedRawPath}
|
||||||
downloadDisabled={!canDownloadRaw}
|
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
|
||||||
downloadLoading={downloadBusyTarget === 'raw'}
|
downloadDisabled={!canDownloadRaw}
|
||||||
/>
|
downloadLoading={downloadBusyTarget === 'raw'}
|
||||||
{isSeriesContainer && childJobs.length > 0 ? (
|
/>
|
||||||
|
) : null}
|
||||||
|
{isDiskContainer && childJobs.length > 0 ? (
|
||||||
childJobs.map((child) => {
|
childJobs.map((child) => {
|
||||||
const childDiscNumber = resolveSeriesDiscNumber(child);
|
const childDiscNumber = resolveSeriesDiscNumber(child);
|
||||||
const childLabel = childDiscNumber ? `Disk ${childDiscNumber} RAW:` : 'Disk RAW:';
|
const childLabel = childDiscNumber ? `Disk ${childDiscNumber} RAW:` : 'Disk RAW:';
|
||||||
@@ -2088,8 +2230,8 @@ export default function JobDetailDialog({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="job-meta-block job-meta-block-full">
|
<section className="job-meta-block job-meta-block-full">
|
||||||
<h4>{isSeriesContainer ? 'Disks' : 'Logs'}</h4>
|
<h4>{isDiskContainer ? 'Disks' : 'Logs'}</h4>
|
||||||
{isSeriesContainer && childJobs.length > 0 ? (
|
{isDiskContainer && (childJobs.length > 0 || mergeChildJobs.length > 0) ? (
|
||||||
<>
|
<>
|
||||||
<div className="job-json-grid">
|
<div className="job-json-grid">
|
||||||
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : null}
|
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : null}
|
||||||
@@ -2262,7 +2404,49 @@ export default function JobDetailDialog({
|
|||||||
</details>
|
</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 ? (
|
) : isDvdSeries ? (
|
||||||
<details className="episode-track-accordion">
|
<details className="episode-track-accordion">
|
||||||
<summary className="series-batch-episode-head">
|
<summary className="series-batch-episode-head">
|
||||||
@@ -2320,7 +2504,7 @@ export default function JobDetailDialog({
|
|||||||
{logTruncated ? <small>(gekürzt auf letzte 800 Zeilen)</small> : null}
|
{logTruncated ? <small>(gekürzt auf letzte 800 Zeilen)</small> : null}
|
||||||
</div>
|
</div>
|
||||||
{logLoaded ? (
|
{logLoaded ? (
|
||||||
isSeriesContainer && childJobs.length > 0 ? (
|
isDiskContainer && (childJobs.length > 0 || mergeChildJobs.length > 0) ? (
|
||||||
<div className="log-box">
|
<div className="log-box">
|
||||||
{childJobs.map((child) => {
|
{childJobs.map((child) => {
|
||||||
const childDiscNumber = resolveSeriesDiscNumber(child);
|
const childDiscNumber = resolveSeriesDiscNumber(child);
|
||||||
@@ -2334,6 +2518,14 @@ export default function JobDetailDialog({
|
|||||||
</details>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<pre className="log-box">{job.log || ''}</pre>
|
<pre className="log-box">{job.log || ''}</pre>
|
||||||
|
|||||||
@@ -1411,7 +1411,8 @@ export default function MediaInfoReviewPanel({
|
|||||||
userPresets = [],
|
userPresets = [],
|
||||||
selectedUserPresetId = null,
|
selectedUserPresetId = null,
|
||||||
onUserPresetChange = null,
|
onUserPresetChange = null,
|
||||||
selectedHandBrakePreset = ''
|
selectedHandBrakePreset = '',
|
||||||
|
comparisonReviewItems = []
|
||||||
}) {
|
}) {
|
||||||
if (!review) {
|
if (!review) {
|
||||||
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
||||||
@@ -1453,6 +1454,23 @@ export default function MediaInfoReviewPanel({
|
|||||||
|| selectedTitleIds[0]
|
|| selectedTitleIds[0]
|
||||||
|| null;
|
|| null;
|
||||||
const selectedTitleIdSet = new Set(selectedTitleIds.map((id) => String(id)));
|
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 encodeInputTitle = displayTitles.find((item) => item.id === currentSelectedId) || null;
|
||||||
const normalizedEpisodeRows = (Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : [])
|
const normalizedEpisodeRows = (Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : [])
|
||||||
.map((episode) => {
|
.map((episode) => {
|
||||||
@@ -1631,6 +1649,18 @@ export default function MediaInfoReviewPanel({
|
|||||||
const playlistRecommendation = review.playlistRecommendation || null;
|
const playlistRecommendation = review.playlistRecommendation || null;
|
||||||
const rawPreset = String(review.selectors?.preset || '').trim();
|
const rawPreset = String(review.selectors?.preset || '').trim();
|
||||||
const presetLabel = String(presetDisplayValue || rawPreset).trim() || '(kein Preset)';
|
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
|
// User preset resolution
|
||||||
const normalizedUserPresets = Array.isArray(userPresets) ? userPresets : [];
|
const normalizedUserPresets = Array.isArray(userPresets) ? userPresets : [];
|
||||||
@@ -1930,6 +1960,15 @@ export default function MediaInfoReviewPanel({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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) */}
|
{/* Pre-Encode Items (scripts + chains unified) */}
|
||||||
{!compactTitleSelection && (allowEncodeItemSelection || preEncodeItems.length > 0) ? (
|
{!compactTitleSelection && (allowEncodeItemSelection || preEncodeItems.length > 0) ? (
|
||||||
@@ -2541,6 +2580,111 @@ export default function MediaInfoReviewPanel({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1250,7 +1250,69 @@ function resolveProfiledSetting(settings, key, mediaProfile) {
|
|||||||
return settings?.[key] ?? null;
|
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();
|
const movieDir = String(resolveProfiledSetting(settings, 'movie_dir', mediaProfile) || '').trim();
|
||||||
if (!movieDir) {
|
if (!movieDir) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1272,7 +1334,9 @@ function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId
|
|||||||
.split('/')
|
.split('/')
|
||||||
.map((seg) => sanitizeFileName(seg))
|
.map((seg) => sanitizeFileName(seg))
|
||||||
.filter(Boolean);
|
.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 folderParts = segments.slice(0, -1);
|
||||||
const rawExt = resolveProfiledSetting(settings, 'output_extension', mediaProfile);
|
const rawExt = resolveProfiledSetting(settings, 'output_extension', mediaProfile);
|
||||||
const ext = String(rawExt || 'mkv').trim() || 'mkv';
|
const ext = String(rawExt || 'mkv').trim() || 'mkv';
|
||||||
@@ -1382,6 +1446,7 @@ export default function PipelineStatusCard({
|
|||||||
const [selectedEpisodeFillStart, setSelectedEpisodeFillStart] = useState(null);
|
const [selectedEpisodeFillStart, setSelectedEpisodeFillStart] = useState(null);
|
||||||
const [containerUsedEpisodeKeys, setContainerUsedEpisodeKeys] = useState([]);
|
const [containerUsedEpisodeKeys, setContainerUsedEpisodeKeys] = useState([]);
|
||||||
const [containerSeasonEpisodes, setContainerSeasonEpisodes] = useState([]);
|
const [containerSeasonEpisodes, setContainerSeasonEpisodes] = useState([]);
|
||||||
|
const [multipartComparisonReviews, setMultipartComparisonReviews] = useState([]);
|
||||||
const [settingsMap, setSettingsMap] = useState({});
|
const [settingsMap, setSettingsMap] = useState({});
|
||||||
const [presetDisplayMap, setPresetDisplayMap] = useState({});
|
const [presetDisplayMap, setPresetDisplayMap] = useState({});
|
||||||
const [scriptCatalog, setScriptCatalog] = useState([]);
|
const [scriptCatalog, setScriptCatalog] = useState([]);
|
||||||
@@ -2046,6 +2111,26 @@ export default function PipelineStatusCard({
|
|||||||
const converterMediaType = String(mediaInfoReview?.converterMediaType || 'video').trim().toLowerCase() || 'video';
|
const converterMediaType = String(mediaInfoReview?.converterMediaType || 'video').trim().toLowerCase() || 'video';
|
||||||
const showConverterConfig = jobMediaProfile === 'converter' && converterMediaType !== 'audio';
|
const showConverterConfig = jobMediaProfile === 'converter' && converterMediaType !== 'audio';
|
||||||
const requiresPresetSelection = showConverterConfig;
|
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)
|
// Filter user presets by job media profile ('all' presets always shown)
|
||||||
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
|
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
|
||||||
@@ -2079,7 +2164,7 @@ export default function PipelineStatusCard({
|
|||||||
&& !running
|
&& !running
|
||||||
&& (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath)
|
&& (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(() => {
|
const converterConfigPayload = useMemo(() => {
|
||||||
if (!showConverterConfig) {
|
if (!showConverterConfig) {
|
||||||
@@ -2588,6 +2673,128 @@ export default function PipelineStatusCard({
|
|||||||
selectedMetadata?.tmdbId,
|
selectedMetadata?.tmdbId,
|
||||||
selectedMetadata?.seasonNumber
|
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 seriesBatchContext = useMemo(() => {
|
||||||
const raw = pipeline?.context?.seriesBatch;
|
const raw = pipeline?.context?.seriesBatch;
|
||||||
if (!raw || typeof raw !== 'object') {
|
if (!raw || typeof raw !== 'object') {
|
||||||
@@ -2664,6 +2871,12 @@ export default function PipelineStatusCard({
|
|||||||
|| pipeline?.context?.mediaInfoReview?.seriesBatchParent
|
|| pipeline?.context?.mediaInfoReview?.seriesBatchParent
|
||||||
|| hasSeriesBatchProgress
|
|| hasSeriesBatchProgress
|
||||||
);
|
);
|
||||||
|
const multipartDiscSuffixForCommandPreview = useMemo(() => resolveMultipartDiscSuffixForPreview({
|
||||||
|
jobRow,
|
||||||
|
pipeline,
|
||||||
|
mediaInfoReview,
|
||||||
|
selectedMetadata
|
||||||
|
}), [jobRow, pipeline, mediaInfoReview, selectedMetadata]);
|
||||||
const commandOutputPath = useMemo(() => {
|
const commandOutputPath = useMemo(() => {
|
||||||
if (jobMediaProfile === 'converter') {
|
if (jobMediaProfile === 'converter') {
|
||||||
const reviewWithOutput = mediaInfoReview
|
const reviewWithOutput = mediaInfoReview
|
||||||
@@ -2674,8 +2887,19 @@ export default function PipelineStatusCard({
|
|||||||
if (isSeriesDvdReview) {
|
if (isSeriesDvdReview) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId);
|
return buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId, {
|
||||||
}, [settingsMap, jobMediaProfile, mediaInfoReview, selectedMetadata, retryJobId, selectedOutputFormat, isSeriesDvdReview]);
|
outputSuffix: multipartDiscSuffixForCommandPreview
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
settingsMap,
|
||||||
|
jobMediaProfile,
|
||||||
|
mediaInfoReview,
|
||||||
|
selectedMetadata,
|
||||||
|
retryJobId,
|
||||||
|
selectedOutputFormat,
|
||||||
|
isSeriesDvdReview,
|
||||||
|
multipartDiscSuffixForCommandPreview
|
||||||
|
]);
|
||||||
const commandOutputPathByTitle = useMemo(() => {
|
const commandOutputPathByTitle = useMemo(() => {
|
||||||
if (!isSeriesDvdReview) {
|
if (!isSeriesDvdReview) {
|
||||||
return {};
|
return {};
|
||||||
@@ -3633,6 +3857,16 @@ export default function PipelineStatusCard({
|
|||||||
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
|
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
|
||||||
</small>
|
</small>
|
||||||
) : null}
|
) : 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 ? (
|
{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={{ 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))' }}>
|
<div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
|
||||||
@@ -3736,7 +3970,7 @@ export default function PipelineStatusCard({
|
|||||||
commandOutputPathByTitle={commandOutputPathByTitle}
|
commandOutputPathByTitle={commandOutputPathByTitle}
|
||||||
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
||||||
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
|
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
|
||||||
allowTitleSelection={(state === 'READY_TO_ENCODE' || state === 'WAITING_FOR_USER_DECISION') && !queueLocked}
|
allowTitleSelection={allowTitleSelectionInReview}
|
||||||
compactTitleSelection={isDiscTitleSelectionRequired}
|
compactTitleSelection={isDiscTitleSelectionRequired}
|
||||||
onSelectEncodeTitle={(titleId) => {
|
onSelectEncodeTitle={(titleId) => {
|
||||||
const normalizedTitleId = normalizeTitleId(titleId);
|
const normalizedTitleId = normalizeTitleId(titleId);
|
||||||
@@ -3769,7 +4003,7 @@ export default function PipelineStatusCard({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
allowTrackSelection={state === 'READY_TO_ENCODE' && !queueLocked}
|
allowTrackSelection={allowTrackSelectionInReview}
|
||||||
trackSelectionByTitle={trackSelectionByTitle}
|
trackSelectionByTitle={trackSelectionByTitle}
|
||||||
onTrackSelectionChange={(titleId, trackType, trackId, checked) => {
|
onTrackSelectionChange={(titleId, trackType, trackId, checked) => {
|
||||||
const normalizedTitleId = normalizeTitleId(titleId);
|
const normalizedTitleId = normalizeTitleId(titleId);
|
||||||
@@ -3857,7 +4091,7 @@ export default function PipelineStatusCard({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
episodeAssignmentsByTitle={episodeAssignmentsByTitle}
|
episodeAssignmentsByTitle={episodeAssignmentsByTitle}
|
||||||
allowEpisodeAssignments={isSeriesDvdReview}
|
allowEpisodeAssignments={isSeriesDvdReview && !multipartSettingsLocked}
|
||||||
onEpisodeAssignmentChange={(titleId, patch = {}) => {
|
onEpisodeAssignmentChange={(titleId, patch = {}) => {
|
||||||
const normalizedTitleId = normalizeTitleId(titleId);
|
const normalizedTitleId = normalizeTitleId(titleId);
|
||||||
if (!normalizedTitleId || !patch || typeof patch !== 'object') {
|
if (!normalizedTitleId || !patch || typeof patch !== 'object') {
|
||||||
@@ -3924,9 +4158,10 @@ export default function PipelineStatusCard({
|
|||||||
postEncodeItems={postEncodeItems}
|
postEncodeItems={postEncodeItems}
|
||||||
userPresets={effectiveUserPresets}
|
userPresets={effectiveUserPresets}
|
||||||
selectedUserPresetId={selectedUserPresetId}
|
selectedUserPresetId={selectedUserPresetId}
|
||||||
onUserPresetChange={showConverterConfig ? null : (presetId) => setSelectedUserPresetId(presetId)}
|
onUserPresetChange={showConverterConfig || multipartSettingsLocked ? null : (presetId) => setSelectedUserPresetId(presetId)}
|
||||||
selectedHandBrakePreset={selectedHandBrakePreset}
|
selectedHandBrakePreset={selectedHandBrakePreset}
|
||||||
allowEncodeItemSelection={state === 'READY_TO_ENCODE' && !queueLocked}
|
allowEncodeItemSelection={allowEncodeItemSelectionInReview}
|
||||||
|
comparisonReviewItems={multipartComparisonReviews}
|
||||||
onAddPreEncodeItem={(itemType) => {
|
onAddPreEncodeItem={(itemType) => {
|
||||||
setPreEncodeItems((prev) => {
|
setPreEncodeItems((prev) => {
|
||||||
const current = Array.isArray(prev) ? prev : [];
|
const current = Array.isArray(prev) ? prev : [];
|
||||||
|
|||||||
@@ -193,6 +193,16 @@ function isContainerHistoryRow(row) {
|
|||||||
return kind === 'dvd_series_container' || kind === 'multipart_movie_container';
|
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) {
|
function isSeriesChildHistoryRow(row) {
|
||||||
const kind = String(
|
const kind = String(
|
||||||
row?.job_kind
|
row?.job_kind
|
||||||
@@ -931,6 +941,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
|
|
||||||
const handleDeleteFiles = async (row, target) => {
|
const handleDeleteFiles = async (row, target) => {
|
||||||
const isContainerRow = isContainerHistoryRow(row);
|
const isContainerRow = isContainerHistoryRow(row);
|
||||||
|
const isMultipartContainerRow = isMultipartContainerHistoryRow(row);
|
||||||
const isSeriesChildRow = isSeriesChildHistoryRow(row);
|
const isSeriesChildRow = isSeriesChildHistoryRow(row);
|
||||||
const includeRelated = isContainerRow
|
const includeRelated = isContainerRow
|
||||||
|| (isSeriesChildRow && (target === 'movie' || target === 'both'));
|
|| (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 label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
|
||||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||||
const scopeSuffix = isContainerRow
|
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'
|
: isSeriesChildRow && target === 'raw'
|
||||||
? '\nScope: nur RAW dieses Disk-Jobs.'
|
? '\nScope: nur RAW dieses Disk-Jobs.'
|
||||||
: isSeriesChildRow && (target === 'movie' || target === 'both')
|
: 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) => {
|
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 normalizedStatus = normalizeStatus(row?.status);
|
||||||
const rowId = normalizeJobId(row?.id);
|
const rowId = normalizeJobId(row?.id);
|
||||||
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
|
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
|
||||||
return (
|
return (
|
||||||
<Tag
|
<div className="history-status-tag-wrap">
|
||||||
value={getStatusLabel(row?.status, { queued: isQueued })}
|
<Tag
|
||||||
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
|
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 renderSeriesOutputChip = (row) => {
|
||||||
const summary = row?.seriesOutputSummary || null;
|
const summary = row?.seriesOutputSummary || null;
|
||||||
|
const label = isMultipartContainerHistoryRow(row) ? 'Movie' : 'Episoden';
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
return renderPresenceChip('Episoden', Boolean(row?.outputStatus?.exists));
|
return renderPresenceChip(label, Boolean(row?.outputStatus?.exists));
|
||||||
}
|
}
|
||||||
const expected = Number(summary.expected || 0);
|
const expected = Number(summary.expected || 0);
|
||||||
const existing = Number(summary.existing || 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 safeExisting = Number.isFinite(existing) && existing >= 0 ? existing : 0;
|
||||||
const suffix = hasExpected ? ` (${safeExisting}/${safeExpected})` : '';
|
const suffix = hasExpected ? ` (${safeExisting}/${safeExpected})` : '';
|
||||||
if (safeExisting <= 0) {
|
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) {
|
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) => {
|
const renderSeriesRawChip = (row) => {
|
||||||
@@ -1779,6 +1867,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
: {};
|
: {};
|
||||||
const rowMediaType = resolveMediaType(row);
|
const rowMediaType = resolveMediaType(row);
|
||||||
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
|
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
|
||||||
|
const isContainerRow = isContainerHistoryRow(row);
|
||||||
|
const useContainerSummaryChips = isSeriesDvd || isContainerRow;
|
||||||
const seasonLabel = isSeriesDvd && selectedMetadata?.seasonNumber
|
const seasonLabel = isSeriesDvd && selectedMetadata?.seasonNumber
|
||||||
? `Staffel ${selectedMetadata.seasonNumber}`
|
? `Staffel ${selectedMetadata.seasonNumber}`
|
||||||
: null;
|
: null;
|
||||||
@@ -1833,10 +1923,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-flags-row">
|
<div className="history-dv-flags-row">
|
||||||
{isSeriesDvd
|
{useContainerSummaryChips
|
||||||
? renderSeriesRawChip(row)
|
? renderSeriesRawChip(row)
|
||||||
: renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
: renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||||
{isSeriesDvd
|
{useContainerSummaryChips
|
||||||
? renderSeriesOutputChip(row)
|
? renderSeriesOutputChip(row)
|
||||||
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||||
@@ -1861,6 +1951,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
: {};
|
: {};
|
||||||
const rowMediaType = resolveMediaType(row);
|
const rowMediaType = resolveMediaType(row);
|
||||||
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
|
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
|
||||||
|
const isContainerRow = isContainerHistoryRow(row);
|
||||||
|
const useContainerSummaryChips = isSeriesDvd || isContainerRow;
|
||||||
const seasonLabel = isSeriesDvd && selectedMetadata?.seasonNumber
|
const seasonLabel = isSeriesDvd && selectedMetadata?.seasonNumber
|
||||||
? `Staffel ${selectedMetadata.seasonNumber}`
|
? `Staffel ${selectedMetadata.seasonNumber}`
|
||||||
: null;
|
: null;
|
||||||
@@ -1917,10 +2009,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-flags-row">
|
<div className="history-dv-flags-row">
|
||||||
{isSeriesDvd
|
{useContainerSummaryChips
|
||||||
? renderSeriesRawChip(row)
|
? renderSeriesRawChip(row)
|
||||||
: renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
: renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||||
{isSeriesDvd
|
{useContainerSummaryChips
|
||||||
? renderSeriesOutputChip(row)
|
? renderSeriesOutputChip(row)
|
||||||
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||||
@@ -2050,8 +2142,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
onDownloadArchive={handleDownloadArchive}
|
onDownloadArchive={handleDownloadArchive}
|
||||||
onDownloadOutputFolder={handleDownloadOutputFolder}
|
onDownloadOutputFolder={handleDownloadOutputFolder}
|
||||||
onRemoveFromQueue={handleRemoveFromQueue}
|
onRemoveFromQueue={handleRemoveFromQueue}
|
||||||
|
onRestoreMultipartMerge={handleRestoreMultipartMerge}
|
||||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||||
actionBusy={actionBusy}
|
actionBusy={actionBusy}
|
||||||
|
restoreMergeBusy={actionBusy}
|
||||||
omdbAssignBusy={omdbAssignBusy}
|
omdbAssignBusy={omdbAssignBusy}
|
||||||
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
||||||
acknowledgeErrorBusy={acknowledgeErrorBusy}
|
acknowledgeErrorBusy={acknowledgeErrorBusy}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
|||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
|
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
|
||||||
|
import mergeIndicatorIcon from '../assets/media-merge.svg';
|
||||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||||
import { confirmModal } from '../utils/confirmModal';
|
import { confirmModal } from '../utils/confirmModal';
|
||||||
import { isConverterJob, isSeriesVideoJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy';
|
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) {
|
function resolveMediaType(job) {
|
||||||
const centralMediaType = resolveCentralMediaType(job);
|
const centralMediaType = resolveCentralMediaType(job);
|
||||||
if (centralMediaType && centralMediaType !== 'other') {
|
if (centralMediaType && centralMediaType !== 'other') {
|
||||||
@@ -581,6 +701,14 @@ function resolveMediaType(job) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mediaIndicatorMeta(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 mediaType = resolveMediaType(job);
|
||||||
const isSeriesVideo = (mediaType === 'dvd' || mediaType === 'bluray') && isSeriesVideoJob(job);
|
const isSeriesVideo = (mediaType === 'dvd' || mediaType === 'bluray') && isSeriesVideoJob(job);
|
||||||
if (mediaType === 'bluray') {
|
if (mediaType === 'bluray') {
|
||||||
@@ -1020,6 +1148,7 @@ export default function RipperPage({
|
|||||||
const [queueState, setQueueState] = useState(() => normalizeQueue(pipeline?.queue));
|
const [queueState, setQueueState] = useState(() => normalizeQueue(pipeline?.queue));
|
||||||
const [queueReorderBusy, setQueueReorderBusy] = useState(false);
|
const [queueReorderBusy, setQueueReorderBusy] = useState(false);
|
||||||
const [draggingQueueEntryId, setDraggingQueueEntryId] = useState(null);
|
const [draggingQueueEntryId, setDraggingQueueEntryId] = useState(null);
|
||||||
|
const [draggingMergeSource, setDraggingMergeSource] = useState(null);
|
||||||
const [insertQueueDialog, setInsertQueueDialog] = useState({ visible: false, afterEntryId: null });
|
const [insertQueueDialog, setInsertQueueDialog] = useState({ visible: false, afterEntryId: null });
|
||||||
const [runtimeActivities, setRuntimeActivities] = useState(() => normalizeRuntimeActivitiesPayload(null));
|
const [runtimeActivities, setRuntimeActivities] = useState(() => normalizeRuntimeActivitiesPayload(null));
|
||||||
const [runtimeLoading, setRuntimeLoading] = useState(false);
|
const [runtimeLoading, setRuntimeLoading] = useState(false);
|
||||||
@@ -1027,6 +1156,8 @@ export default function RipperPage({
|
|||||||
const [runtimeRecentClearing, setRuntimeRecentClearing] = useState(false);
|
const [runtimeRecentClearing, setRuntimeRecentClearing] = useState(false);
|
||||||
const [jobsLoading, setJobsLoading] = useState(false);
|
const [jobsLoading, setJobsLoading] = useState(false);
|
||||||
const [ripperJobs, setRipperJobs] = useState([]);
|
const [ripperJobs, setRipperJobs] = useState([]);
|
||||||
|
const [multipartMergePreviewByJobId, setMultipartMergePreviewByJobId] = useState({});
|
||||||
|
const [multipartMergePreviewLoadingByJobId, setMultipartMergePreviewLoadingByJobId] = useState({});
|
||||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||||
const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null });
|
const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null });
|
||||||
const [activationBytesInput, setActivationBytesInput] = useState('');
|
const [activationBytesInput, setActivationBytesInput] = useState('');
|
||||||
@@ -1363,6 +1494,18 @@ export default function RipperPage({
|
|||||||
setExpandedJobId(normalizeJobId(ripperJobs[0]?.id));
|
setExpandedJobId(normalizeJobId(ripperJobs[0]?.id));
|
||||||
}, [ripperJobs, expandedJobId, currentPipelineJobId]);
|
}, [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 pipelineByJobId = useMemo(() => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const job of ripperJobs) {
|
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 handleOpenMetadataDialog = (jobId = null) => {
|
||||||
const context = jobId ? buildMetadataContextForJob(jobId) : defaultMetadataDialogContext;
|
const context = jobId ? buildMetadataContextForJob(jobId) : defaultMetadataDialogContext;
|
||||||
if (!context?.jobId) {
|
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 handleSaveActivationBytes = async () => {
|
||||||
const { checksum, jobId } = activationBytesDialog;
|
const { checksum, jobId } = activationBytesDialog;
|
||||||
const bytes = activationBytesInput.trim().toLowerCase();
|
const bytes = activationBytesInput.trim().toLowerCase();
|
||||||
@@ -1992,11 +2203,16 @@ export default function RipperPage({
|
|||||||
|| ''
|
|| ''
|
||||||
).trim().toLowerCase();
|
).trim().toLowerCase();
|
||||||
const isOrphanRawImportJob = orphanImportSource === 'orphan_raw_import';
|
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 ripSuccessful = Number(job?.rip_successful || 0) === 1;
|
||||||
const deleteIncompleteRaw = !isOrphanRawImportJob && !ripSuccessful && isIncompleteRawPath(job?.raw_path);
|
const deleteIncompleteRaw = !isOrphanRawImportJob && !ripSuccessful && isIncompleteRawPath(job?.raw_path);
|
||||||
let incompleteMovieSelectionPaths = [];
|
let incompleteMovieSelectionPaths = [];
|
||||||
try {
|
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)
|
const movieCandidates = Array.isArray(deletePreview?.preview?.pathCandidates?.movie)
|
||||||
? deletePreview.preview.pathCandidates.movie
|
? deletePreview.preview.pathCandidates.movie
|
||||||
: [];
|
: [];
|
||||||
@@ -2036,7 +2252,9 @@ export default function RipperPage({
|
|||||||
`Job #${normalizedJobId} wirklich löschen?\n` +
|
`Job #${normalizedJobId} wirklich löschen?\n` +
|
||||||
`${title}\n\n` +
|
`${title}\n\n` +
|
||||||
deleteHint +
|
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
|
(isOrphanRawImportJob
|
||||||
? '\nOrphan-Import-Job: Das zugeordnete Laufwerk bleibt unverändert.'
|
? '\nOrphan-Import-Job: Das zugeordnete Laufwerk bleibt unverändert.'
|
||||||
: '\nDas zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.'),
|
: '\nDas zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.'),
|
||||||
@@ -2064,7 +2282,7 @@ export default function RipperPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await api.deleteJobEntry(normalizedJobId, deleteTarget, {
|
await api.deleteJobEntry(normalizedJobId, deleteTarget, {
|
||||||
includeRelated: true,
|
includeRelated: includeRelatedOnDelete,
|
||||||
...(deleteIncompleteMovie ? { selectedMoviePaths: incompleteMovieSelectionPaths } : {}),
|
...(deleteIncompleteMovie ? { selectedMoviePaths: incompleteMovieSelectionPaths } : {}),
|
||||||
resetDriveState: resetDriveStateOnDelete,
|
resetDriveState: resetDriveStateOnDelete,
|
||||||
keepDetectedDevice: keepDetectedDeviceOnDelete,
|
keepDetectedDevice: keepDetectedDeviceOnDelete,
|
||||||
@@ -2988,12 +3206,14 @@ export default function RipperPage({
|
|||||||
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
|
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
|
||||||
const isMultipartMovie = Number(job?.is_multipart_movie || 0) === 1
|
const isMultipartMovie = Number(job?.is_multipart_movie || 0) === 1
|
||||||
|| jobKindRaw === 'multipart_movie_child'
|
|| 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 multipartDiscNumber = Number(job?.disc_number || selectedMetadata?.discNumber || 0) || null;
|
||||||
const multipartContainerId = normalizeJobId(job?.parent_job_id);
|
const multipartContainerId = normalizeJobId(job?.parent_job_id);
|
||||||
const multipartSubtitle = isMultipartMovie
|
const multipartSubtitle = isMultipartMovie
|
||||||
? (
|
? (
|
||||||
`${multipartDiscNumber ? ` Disc ${multipartDiscNumber}` : ''}`
|
`${isMultipartMerge ? ' Merge' : `${multipartDiscNumber ? ` Disc ${multipartDiscNumber}` : ''}`}`
|
||||||
+ `${multipartContainerId ? ` | Container #${multipartContainerId}` : ''}`
|
+ `${multipartContainerId ? ` | Container #${multipartContainerId}` : ''}`
|
||||||
).trim()
|
).trim()
|
||||||
: '';
|
: '';
|
||||||
@@ -3053,6 +3273,28 @@ export default function RipperPage({
|
|||||||
? pipelineForJob.context.selectedMetadata
|
? pipelineForJob.context.selectedMetadata
|
||||||
: {};
|
: {};
|
||||||
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
|
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) {
|
if (isExpanded) {
|
||||||
return (
|
return (
|
||||||
<div key={jobId} className="ripper-job-expanded">
|
<div key={jobId} className="ripper-job-expanded">
|
||||||
@@ -3081,7 +3323,8 @@ export default function RipperPage({
|
|||||||
<div className="ripper-job-badges">
|
<div className="ripper-job-badges">
|
||||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||||
{mediaIndicator.isSeriesDvd ? <Tag value={seriesTagLabel} severity="secondary" /> : null}
|
{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}
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||||
{normalizedStatus === 'READY_TO_ENCODE'
|
{normalizedStatus === 'READY_TO_ENCODE'
|
||||||
@@ -3151,28 +3394,221 @@ export default function RipperPage({
|
|||||||
return null;
|
return null;
|
||||||
})()}
|
})()}
|
||||||
{!isCdJob && !isAudiobookJob ? (
|
{!isCdJob && !isAudiobookJob ? (
|
||||||
<PipelineStatusCard
|
isMultipartMerge ? (
|
||||||
jobId={jobId}
|
<div className="multipart-merge-panel">
|
||||||
jobRow={job}
|
<div className="multipart-merge-head">
|
||||||
pipeline={pipelineForJob}
|
<strong>Merge-Quellen</strong>
|
||||||
onAnalyze={handleAnalyze}
|
<small>Drag-and-Drop ändert die Reihenfolge der Disc-Dateien.</small>
|
||||||
onReanalyze={handleReanalyze}
|
</div>
|
||||||
onOpenMetadata={handleOpenMetadataDialog}
|
{hasMultipartMergeSources ? (
|
||||||
onReassignOmdb={handleOpenReassignOmdbDialog}
|
<div className="multipart-merge-list">
|
||||||
onStart={handleStartJob}
|
{multipartMergeSources.map((source, sourceIndex) => {
|
||||||
onRestartEncode={handleRestartEncodeWithLastSettings}
|
const sourceKey = `${jobId}-${source.sourceJobId}-${sourceIndex}`;
|
||||||
onRestartReview={handleRestartReviewFromRaw}
|
const canReorder = !busyJobIds.has(jobId)
|
||||||
onConfirmReview={handleConfirmReview}
|
&& !processingStates.includes(normalizedStatus)
|
||||||
onSelectPlaylist={handleSelectPlaylist}
|
&& multipartMergeSources.length > 1;
|
||||||
onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
|
return (
|
||||||
onSubmitRawDecision={handleSubmitRawDecision}
|
<div
|
||||||
onCancel={handleCancel}
|
key={sourceKey}
|
||||||
onRetry={handleRetry}
|
className="multipart-merge-item"
|
||||||
onDeleteJob={handleDeleteRipperJob}
|
draggable={canReorder}
|
||||||
onRemoveFromQueue={handleRemoveQueuedJob}
|
onDragStart={() => {
|
||||||
isQueued={isQueued}
|
if (!canReorder) return;
|
||||||
busy={busyJobIds.has(jobId)}
|
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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -3219,7 +3655,7 @@ export default function RipperPage({
|
|||||||
+ (mediaIndicator.isSeriesDvd ? seriesContainerLabel : '')
|
+ (mediaIndicator.isSeriesDvd ? seriesContainerLabel : '')
|
||||||
+ (mediaIndicator.isSeriesDvd ? seriesTmdbLabel : '')
|
+ (mediaIndicator.isSeriesDvd ? seriesTmdbLabel : '')
|
||||||
+ (!mediaIndicator.isSeriesDvd && isMultipartMovie
|
+ (!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}` : ''}`)
|
+ (mediaIndicator.isSeriesDvd ? '' : `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`)
|
||||||
))
|
))
|
||||||
@@ -3229,7 +3665,8 @@ export default function RipperPage({
|
|||||||
<div className="ripper-job-badges">
|
<div className="ripper-job-badges">
|
||||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||||
{mediaIndicator.isSeriesDvd ? <Tag value={seriesTagLabel} severity="secondary" /> : null}
|
{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}
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||||
{normalizedStatus === 'READY_TO_ENCODE'
|
{normalizedStatus === 'READY_TO_ENCODE'
|
||||||
|
|||||||
@@ -1566,6 +1566,137 @@ body {
|
|||||||
min-width: 0;
|
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 {
|
.playlist-waiting-box {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
padding: 0.6rem 0.7rem;
|
padding: 0.6rem 0.7rem;
|
||||||
@@ -2893,6 +3024,12 @@ body {
|
|||||||
background: #fff1db;
|
background: #fff1db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-dv-chip.tone-info {
|
||||||
|
color: #0d5a86;
|
||||||
|
border-color: #93c7e8;
|
||||||
|
background: #e3f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
.history-dv-rating-chip {
|
.history-dv-rating-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2988,6 +3125,23 @@ body {
|
|||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
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 {
|
.history-dv-grid-main {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ function normalizeJobKind(value) {
|
|||||||
'dvd_series_child',
|
'dvd_series_child',
|
||||||
'multipart_movie_container',
|
'multipart_movie_container',
|
||||||
'multipart_movie_child',
|
'multipart_movie_child',
|
||||||
|
'multipart_movie_merge',
|
||||||
'converter_audio',
|
'converter_audio',
|
||||||
'converter_video',
|
'converter_video',
|
||||||
'converter_iso'
|
'converter_iso'
|
||||||
@@ -111,6 +112,7 @@ function mediaTypeFromJobKind(jobKind) {
|
|||||||
|| normalized === 'dvd_series_child'
|
|| normalized === 'dvd_series_child'
|
||||||
|| normalized === 'multipart_movie_container'
|
|| normalized === 'multipart_movie_container'
|
||||||
|| normalized === 'multipart_movie_child'
|
|| normalized === 'multipart_movie_child'
|
||||||
|
|| normalized === 'multipart_movie_merge'
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -644,7 +644,8 @@ apt-get install -y \
|
|||||||
mediainfo \
|
mediainfo \
|
||||||
util-linux udev \
|
util-linux udev \
|
||||||
ca-certificates gnupg \
|
ca-certificates gnupg \
|
||||||
lsb-release
|
lsb-release \
|
||||||
|
mkvmerge
|
||||||
|
|
||||||
ok "Basispakete installiert"
|
ok "Basispakete installiert"
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.14.0-4",
|
"version": "0.15.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.14.0-4",
|
"version": "0.15.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.14.0-4",
|
"version": "0.15.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user