0.15.0 MovieMerge

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