0.14.0 BluRay Series Flow

This commit is contained in:
2026-04-20 08:41:48 +00:00
parent 010c96461c
commit 9890971ff3
23 changed files with 2282 additions and 310 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "0.13.1-12",
"version": "0.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "0.13.1-12",
"version": "0.14.0",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.13.1-12",
"version": "0.14.0",
"private": true,
"type": "commonjs",
"scripts": {
+50
View File
@@ -841,6 +841,18 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
validation_json: '{}'
},
{
key: 'output_template_bluray_series_episode',
required: 1,
description: 'Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.',
validation_json: '{"minLength":1}'
},
{
key: 'output_template_bluray_series_multi_episode',
required: 1,
description: 'Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.',
validation_json: '{"minLength":1}'
},
{
key: 'output_template_dvd_series_episode',
required: 1,
@@ -859,6 +871,8 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
const SETTINGS_CATEGORY_MOVES = [
{ key: 'cd_output_template', category: 'Pfade' },
{ key: 'output_template_bluray', category: 'Pfade' },
{ key: 'output_template_bluray_series_episode', category: 'Pfade' },
{ key: 'output_template_bluray_series_multi_episode', category: 'Pfade' },
{ key: 'output_template_dvd', category: 'Pfade' },
{ key: 'output_template_audiobook', category: 'Pfade' },
{ key: 'output_chapter_template_audiobook', category: 'Pfade' },
@@ -1050,6 +1064,18 @@ async function migrateSettingsSchemaMetadata(db) {
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('raw_dir_bluray_series', 'Pfade', 'RAW-Ordner (Blu-ray Serie)', 'path', 0, 'RAW-Zielpfad für Blu-ray-Serien. Leer = gleicher Pfad wie RAW-Ordner (Blu-ray).', NULL, '[]', '{}', 1011)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('raw_dir_bluray_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (Blu-ray Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für Blu-ray-Serien-RAW. Leer = Eigentümer Raw-Ordner (Blu-ray).', NULL, '[]', '{}', 1012)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('raw_dir_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021)`
@@ -1062,6 +1088,18 @@ async function migrateSettingsSchemaMetadata(db) {
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('series_dir_bluray', 'Pfade', 'Serien-Ordner (Blu-ray)', 'path', 0, 'Zielordner für Blu-ray-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 110)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('series_dir_bluray_owner', 'Pfade', 'Eigentümer Serien-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Blu-ray-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1105)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('series_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113)`
@@ -1074,6 +1112,18 @@ async function migrateSettingsSchemaMetadata(db) {
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_template_bluray_series_episode', 'Pfade', 'Output Template (Blu-ray Serie, Episode)', 'string', 1, 'Template für einzelne Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 336)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_template_bluray_series_multi_episode', 'Pfade', 'Output Template (Blu-ray Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste Blu-ray-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 337)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)`
+9 -1
View File
@@ -31,11 +31,19 @@ class DVDSeriesPlugin extends SourcePlugin {
pluginFile: 'DVDSeriesPlugin.js'
});
const fallbackSeriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([
{
value: ctx.extra?.detectedTitle || '',
source: 'detected_title'
}
]);
const scanText = String(ctx.extra?.scanText || '').trim();
if (!scanText) {
return {
parsedScan: null,
seriesAnalysis: null,
seriesLookupHint: fallbackSeriesLookupHint,
providerConfigured: await tmdbService.isConfigured()
};
}
@@ -50,7 +58,7 @@ class DVDSeriesPlugin extends SourcePlugin {
value: ctx.extra?.detectedTitle || '',
source: 'detected_title'
}
]);
]) || fallbackSeriesLookupHint;
return {
parsedScan: result.parsed,
seriesAnalysis: result.analysis,
+32 -8
View File
@@ -58,11 +58,23 @@ router.post(
logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath });
const importedJob = await historyService.importOrphanRawFolder(rawPath);
const importedJobId = Number(importedJob?.id || 0);
const activation = (Number.isFinite(importedJobId) && importedJobId > 0)
? await pipelineService.analyzeRawImportJob(importedJobId, {
rawPath: importedJob?.raw_path || rawPath
})
: null;
let activation = null;
let activationError = null;
if (Number.isFinite(importedJobId) && importedJobId > 0) {
try {
activation = await pipelineService.analyzeRawImportJob(importedJobId, {
rawPath: importedJob?.raw_path || rawPath
});
} catch (error) {
activationError = error?.message || String(error);
logger.warn('post:orphan-raw:import:activation-failed', {
reqId: req.reqId,
jobId: importedJobId,
rawPath,
error: activationError
});
}
}
const refreshedJob = importedJobId > 0
? await historyService.getJobById(importedJobId)
@@ -70,7 +82,8 @@ router.post(
res.json({
job: refreshedJob || importedJob,
activation
activation,
...(activationError ? { activationError } : {})
});
})
);
@@ -139,14 +152,25 @@ router.post(
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const target = String(req.body?.target || 'both');
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
? req.body.selectedMoviePaths
.map((item) => String(item || '').trim())
.filter(Boolean)
: null;
logger.warn('post:delete-files', {
reqId: req.reqId,
id,
target
target,
includeRelated,
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
});
const result = await historyService.deleteJobFiles(id, target);
const result = await historyService.deleteJobFiles(id, target, {
includeRelated,
selectedMoviePaths
});
res.json(result);
})
);
+33 -3
View File
@@ -69,6 +69,8 @@ function normalizeLanguageCode(rawCode, fallbackLabel = null) {
function normalizeSeriesLookupTitle(rawValue) {
return String(rawValue || '')
.replace(/[_./]+/g, ' ')
.replace(/\bs\d{1,2}\s*d\d{1,2}\b/gi, ' ')
.replace(/\bs(?:taffel|eason)?\s*\d{1,2}\s*(?:disc|dvd|cd|d)\s*\d{1,2}\b/gi, ' ')
.replace(/\b(?:disc|dvd|cd)\s*\d+\b/gi, ' ')
.replace(/\b(?:s(?:taffel|eason)?|season)\s*\d+\b/gi, ' ')
.replace(/\b\d{1,2}x\b/gi, ' ')
@@ -99,7 +101,12 @@ function deriveSeriesLookupHint(inputs = []) {
continue;
}
const seasonMatch = compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\d{1,2})(?=\s|$)/i)
const compactSeasonDiscMatch = compactValue.match(
/(?:^|\s)s(?:taffel|eason)?\s*0?(\d{1,2})\s*(?:d|disc|dvd|cd)\s*0?(\d{1,2})(?=\s|$)/i
);
const seasonMatch = compactSeasonDiscMatch
|| compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\d{1,2})(?=\s|$)/i)
|| compactValue.match(/(?:^|\s)s\s*0?(\d{1,2})(?=\s|$)/i)
|| compactValue.match(/(?:^|\s)(\d{1,2})x(?=\s|$)/i);
if (!seasonMatch) {
continue;
@@ -110,9 +117,11 @@ function deriveSeriesLookupHint(inputs = []) {
continue;
}
const discMatch = compactValue.match(/(?:^|\s)(?:disc|dvd|cd)\s*(\d{1,2})(?=\s|$)/i);
const discMatch = compactSeasonDiscMatch
|| compactValue.match(/(?:^|\s)(?:disc|dvd|cd|d)\s*0?(\d{1,2})(?=\s|$)/i);
const compactDiscToken = compactSeasonDiscMatch ? compactSeasonDiscMatch[2] : null;
const discNumber = discMatch
? Number(discMatch[1] || 0) || null
? Number(compactDiscToken || discMatch[1] || 0) || null
: null;
const baseTitle = normalizeSeriesLookupTitle(compactValue);
@@ -385,12 +394,22 @@ function parseHandBrakeScanText(rawOutput) {
parsed.titleCount = Number(match[1] || 0);
continue;
}
match = line.match(/scan:\s+(?:BD|Blu-?ray)\s+has\s+(\d+)\s+title/i);
if (match) {
parsed.titleCount = Number(match[1] || 0);
continue;
}
match = line.match(/scan:\s+scanning title\s+(\d+)/i);
if (match) {
ensureCurrentTitle(match[1]);
continue;
}
match = line.match(/^\s*\+\s+title\s+(\d+):/i);
if (match) {
ensureCurrentTitle(match[1]);
continue;
}
match = line.match(/scan:\s+duration is\s+(\d{1,2}:\d{2}:\d{2})/i);
if (match && currentTitle) {
@@ -398,6 +417,12 @@ function parseHandBrakeScanText(rawOutput) {
currentTitle.durationSeconds = parseDurationToSeconds(match[1]);
continue;
}
match = line.match(/^\s*\+\s+duration:\s+(\d{1,2}:\d{2}:\d{2})/i);
if (match && currentTitle) {
currentTitle.durationLabel = match[1];
currentTitle.durationSeconds = parseDurationToSeconds(match[1]);
continue;
}
match = line.match(/scan:\s+title\s+(\d+)\s+has\s+(\d+)\s+chapters/i);
if (match) {
@@ -407,6 +432,11 @@ function parseHandBrakeScanText(rawOutput) {
}
continue;
}
match = line.match(/^\s*\+\s+chapters:\s+(\d+)/i);
if (match && currentTitle) {
currentTitle.chapterCount = Number(match[1] || 0);
continue;
}
match = line.match(/scan:\s+chap\s+\d+,\s+(\d+)\s+ms/i);
if (match && currentTitle) {
+445 -108
View File
@@ -685,8 +685,11 @@ function getSeriesRawPathCandidates(settings = {}) {
for (const candidate of getConfiguredMediaPathList(source, 'series_raw_dir')) {
pushPath(candidate);
}
pushPath(source.raw_dir_bluray_series);
pushPath(source.raw_dir_dvd_series);
const blurayEffective = settingsService.resolveEffectiveToolSettings(source, 'bluray') || {};
pushPath(blurayEffective.series_raw_dir);
const dvdEffective = settingsService.resolveEffectiveToolSettings(source, 'dvd') || {};
pushPath(dvdEffective.series_raw_dir);
@@ -705,7 +708,7 @@ function isLikelySeriesRawPath(rawPath, settings = {}) {
}
// Fallback for common default layouts when series raw dir is not explicitly configured.
return /(^|\/)raw\/dvd\/series(\/|$)/i.test(normalizedRawPath);
return /(^|\/)raw\/(?:dvd|bluray)\/series(\/|$)/i.test(normalizedRawPath);
}
function getOrphanRawScanPathList(settings = {}) {
@@ -732,6 +735,7 @@ function getOrphanRawScanPathList(settings = {}) {
for (const candidate of getConfiguredMediaPathList(source, 'series_raw_dir')) {
pushPath(candidate);
}
pushPath(source.raw_dir_bluray_series);
pushPath(source.raw_dir_dvd_series);
pushPath(source.converter_raw_dir);
@@ -739,7 +743,7 @@ function getOrphanRawScanPathList(settings = {}) {
for (const profile of ['bluray', 'dvd', 'cd', 'audiobook']) {
const effective = settingsService.resolveEffectiveToolSettings(source, profile) || {};
pushPath(effective.raw_dir);
if (profile === 'dvd') {
if (profile === 'dvd' || profile === 'bluray') {
pushPath(effective.series_raw_dir);
}
}
@@ -791,7 +795,7 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
|| Number(selectedMetadata?.episodeCount || 0) > 0
|| ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind)
);
const isSeriesDvd = mediaType === 'dvd' && (
const isSeriesDisc = (mediaType === 'dvd' || mediaType === 'bluray') && (
seriesSignals
|| hasSeriesMetadataHint
|| hasSeriesJobKind
@@ -825,8 +829,8 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
const seriesRawDir = String(effectiveSettings?.series_raw_dir || '').trim();
const seriesMovieDir = String(effectiveSettings?.series_dir || '').trim();
const defaultRawDir = String(effectiveSettings?.raw_dir || '').trim();
let rawDir = isSeriesDvd ? (seriesRawDir || defaultRawDir) : defaultRawDir;
if (isSeriesDvd) {
let rawDir = isSeriesDisc ? (seriesRawDir || defaultRawDir) : defaultRawDir;
if (isSeriesDisc) {
const normalizedStoredRawPath = normalizeComparablePath(job?.raw_path);
if (normalizedStoredRawPath) {
const seriesRawCandidates = getSeriesRawPathCandidates(settings || {});
@@ -836,11 +840,11 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
}
}
}
const configuredMovieDir = isSeriesDvd
const configuredMovieDir = isSeriesDisc
? (seriesMovieDir || String(effectiveSettings?.movie_dir || '').trim())
: String(effectiveSettings?.movie_dir || '').trim();
const movieDir = configuredMovieDir || rawDir;
const rawLookupDirsBase = isSeriesDvd
const rawLookupDirsBase = isSeriesDisc
? getSeriesRawPathCandidates(settings || {})
: getConfiguredMediaPathList(settings || {}, 'raw_dir');
const rawLookupDirs = rawLookupDirsBase
@@ -848,7 +852,7 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
const effectiveRawPath = job?.raw_path
? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs)
: (job?.raw_path || null);
const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path && !isSeriesDvd)
const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path && !isSeriesDisc)
? resolveEffectiveOutputPath(job.output_path, configuredMovieDir)
: (job?.output_path || null);
@@ -1062,6 +1066,97 @@ function inferSiblingOutputFolders(outputPath) {
return candidates.map((item) => item.path);
}
function resolveExistingOutputPathVariant(outputPath) {
const normalizedOutputPath = normalizeComparablePath(outputPath);
if (!normalizedOutputPath) {
return null;
}
try {
if (fs.existsSync(normalizedOutputPath)) {
return normalizedOutputPath;
}
} catch (_error) {
// Ignore and continue with fallback probing.
}
const parsed = path.parse(normalizedOutputPath);
const parentDir = normalizeComparablePath(parsed.dir);
if (!parentDir) {
return null;
}
const candidatePaths = [];
const candidateSet = new Set();
const pushCandidate = (candidatePath) => {
const normalized = normalizeComparablePath(candidatePath);
if (!normalized || candidateSet.has(normalized)) {
return;
}
candidateSet.add(normalized);
candidatePaths.push(normalized);
};
// If parent directory is numbered (e.g. "Staffel 3_2"), prefer base folder first.
const parentName = path.basename(parentDir);
const parsedParentName = parseNumberedFolderName(parentName);
const parentRoot = path.dirname(parentDir);
const siblingBaseName = parsedParentName.baseName || parentName;
if (parsedParentName.numbered && parsedParentName.baseName) {
pushCandidate(path.join(parentRoot, parsedParentName.baseName, parsed.base));
}
// If filename is numbered (e.g. "Episode_2.mkv"), prefer base filename first.
const parsedFileName = parseNumberedFolderName(parsed.name);
if (parsedFileName.numbered && parsedFileName.baseName) {
pushCandidate(path.join(parentDir, `${parsedFileName.baseName}${parsed.ext}`));
}
// Probe sibling directories with same base name (base, _2, _3, ...).
if (parentRoot && siblingBaseName) {
try {
const siblingRegex = new RegExp(`^${escapeRegExp(siblingBaseName)}(?:_(\\d+))?$`);
const siblingDirs = fs.readdirSync(parentRoot, { withFileTypes: true })
.filter((entry) => entry?.isDirectory?.())
.map((entry) => {
const name = String(entry?.name || '').trim();
const match = name.match(siblingRegex);
if (!match) {
return null;
}
const suffixNumber = match[1] ? Number(match[1]) : 0;
return {
dirPath: path.join(parentRoot, name),
suffixNumber: Number.isFinite(suffixNumber) && suffixNumber > 0 ? Math.trunc(suffixNumber) : 0
};
})
.filter(Boolean)
.sort((left, right) => {
if (left.suffixNumber !== right.suffixNumber) {
return left.suffixNumber - right.suffixNumber;
}
return String(left.dirPath || '').localeCompare(String(right.dirPath || ''), 'de-DE');
});
for (const sibling of siblingDirs) {
pushCandidate(path.join(sibling.dirPath, parsed.base));
}
} catch (_error) {
// Best effort.
}
}
for (const candidatePath of candidatePaths) {
try {
if (fs.existsSync(candidatePath)) {
return candidatePath;
}
} catch (_error) {
// Ignore and continue.
}
}
return null;
}
function compareOutputFolderPaths(leftPath, rightPath) {
const leftNormalized = normalizeComparablePath(leftPath);
const rightNormalized = normalizeComparablePath(rightPath);
@@ -3025,6 +3120,7 @@ class HistoryService {
const mediaProfile = normalizeMediaTypeValue(options.mediaProfile) || 'other';
const importedAt = String(options.importedAt || new Date().toISOString()).trim() || new Date().toISOString();
const rawPath = String(options.rawPath || '').trim() || null;
const stripRecoveryMetadata = Boolean(options.stripRecoveryMetadata);
const existingInfo = options.existingInfo && typeof options.existingInfo === 'object'
? options.existingInfo
: {};
@@ -3109,7 +3205,7 @@ class HistoryService {
if (Array.isArray(recovery.tracks) && recovery.tracks.length > 0) {
nextInfo.tracks = recovery.tracks;
}
if (recovery.selectedMetadata && typeof recovery.selectedMetadata === 'object') {
if (!stripRecoveryMetadata && recovery.selectedMetadata && typeof recovery.selectedMetadata === 'object') {
const recoverySelectedMetadata = compactMetadataObject(recovery.selectedMetadata);
nextInfo.selectedMetadata = {
...(nextInfo.selectedMetadata && typeof nextInfo.selectedMetadata === 'object'
@@ -4895,19 +4991,7 @@ class HistoryService {
const folderName = path.basename(absRawPath);
const metadata = parseRawFolderMetadata(folderName);
let omdbById = null;
if (metadata.imdbId) {
try {
omdbById = await omdbService.fetchByImdbId(metadata.imdbId);
} catch (error) {
logger.warn('job:import-orphan-raw:omdb-fetch-failed', {
rawPath: absRawPath,
imdbId: metadata.imdbId,
message: error.message
});
}
}
const effectiveTitle = omdbById?.title || metadata.title || folderName;
const effectiveTitle = metadata.title || folderName;
const importedAt = new Date().toISOString();
const created = await this.createJob({
discDevice: null,
@@ -4984,7 +5068,6 @@ class HistoryService {
const initialSourceSelectedMetadata = initialSourceJobContext?.selectedMetadata && typeof initialSourceJobContext.selectedMetadata === 'object'
? initialSourceJobContext.selectedMetadata
: {};
const orphanPosterUrl = omdbById?.poster || null;
const cdRecovery = effectiveDetectedMediaType === 'cd'
? recoverCdJobArtifactsForImport({
currentRawPath: finalRawPath,
@@ -5011,12 +5094,6 @@ class HistoryService {
],
mediaProfile: effectiveDetectedMediaType
}) || initialSourceJobContext;
const sourceJob = sourceJobContext?.job || null;
const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object'
? sourceJobContext.selectedMetadata
: {};
const sourcePosterCandidate = this.getPreferredPosterCandidateForImport(sourceJobContext, null);
const recoveredCdEncodePlan = cdRecovery ? this.buildRecoveredCdEncodePlan(cdRecovery) : null;
const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({
importedAt,
rawPath: finalRawPath,
@@ -5025,27 +5102,12 @@ class HistoryService {
mediaProfile: effectiveDetectedMediaType,
// RAW-Import soll wie "Disk analysieren" starten:
// keine geerbte Serien-/Metadatenzuordnung vor dem eigentlichen Analyse-Scan.
existingInfo: cdRecovery ? (sourceJobContext?.makemkvInfo || null) : null,
existingInfo: null,
recovery: cdRecovery,
selectedMetadata: null,
analyzeContextPatch: null
analyzeContextPatch: null,
stripRecoveryMetadata: true
});
const recoveredPosterUrl = orphanPosterUrl
|| (thumbnailService.isLocalUrl(sourcePosterCandidate) ? null : sourcePosterCandidate)
|| null;
const recoveredExternalId = String(
omdbById?.imdbId
|| metadata.imdbId
|| sourceSelectedMetadata?.mbId
|| sourceSelectedMetadata?.musicBrainzId
|| sourceSelectedMetadata?.musicbrainzId
|| sourceSelectedMetadata?.musicbrainz_id
|| sourceSelectedMetadata?.music_brainz_id
|| sourceSelectedMetadata?.musicbrainz
|| sourceSelectedMetadata?.mbid
|| sourceJob?.imdb_id
|| ''
).trim() || null;
await this.updateJob(created.id, {
...(effectiveDetectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}),
...(effectiveDetectedMediaType === 'cd' ? { job_kind: 'cd', media_type: 'cd' } : {}),
@@ -5053,32 +5115,19 @@ class HistoryService {
...(effectiveDetectedMediaType === 'bluray' ? { job_kind: 'bluray', media_type: 'bluray' } : {}),
status: 'FINISHED',
last_state: 'FINISHED',
title: omdbById?.title
|| sourceSelectedMetadata?.title
|| sourceSelectedMetadata?.album
|| sourceJob?.title
|| metadata.title
|| cdRecovery?.selectedMetadata?.title
|| null,
year: Number.isFinite(Number(omdbById?.year))
? Number(omdbById.year)
: (
sourceSelectedMetadata?.year
|| sourceJob?.year
|| metadata.year
|| cdRecovery?.selectedMetadata?.year
|| null
),
imdb_id: recoveredExternalId,
poster_url: recoveredPosterUrl,
omdb_json: omdbById?.raw ? JSON.stringify(omdbById.raw) : (sourceJob?.omdb_json || null),
selected_from_omdb: omdbById ? 1 : Number(sourceJob?.selected_from_omdb || 0),
// /database-Import startet bewusst metadata-clean: keine Übernahme aus früheren Läufen.
title: null,
year: null,
imdb_id: null,
poster_url: null,
omdb_json: null,
selected_from_omdb: 0,
rip_successful: 1,
raw_path: finalRawPath,
output_path: cdRecovery?.outputPath || null,
output_path: null,
handbrake_info_json: null,
mediainfo_info_json: null,
encode_plan_json: recoveredCdEncodePlan ? JSON.stringify(recoveredCdEncodePlan) : null,
encode_plan_json: null,
encode_input_path: null,
encode_review_confirmed: 0,
error_message: null,
@@ -5092,11 +5141,6 @@ class HistoryService {
});
}
// Bild direkt persistieren (kein Rip-Prozess, daher kein Cache-Zwischenschritt)
if (orphanPosterUrl || sourcePosterCandidate) {
this.restoreImportedPoster(created.id, sourceJobContext, orphanPosterUrl || sourcePosterCandidate).catch(() => {});
}
if (!cdRecovery?.logSources?.length) {
await this.appendLog(
created.id,
@@ -5112,22 +5156,11 @@ class HistoryService {
? `Historieneintrag aus RAW erstellt (Medientyp: ${effectiveDetectedMediaType}). Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}`
: `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath} (Medientyp: ${effectiveDetectedMediaType})`
);
if (metadata.imdbId) {
await this.appendLog(
created.id,
'SYSTEM',
omdbById
? `OMDb-Zuordnung via IMDb-ID übernommen: ${omdbById.imdbId} (${omdbById.title || '-'})`
: `OMDb-Zuordnung via IMDb-ID fehlgeschlagen: ${metadata.imdbId}`
);
}
if (sourceJob) {
await this.appendLog(
created.id,
'SYSTEM',
`Metadaten aus vorhandenem Job #${sourceJob.id} wiederhergestellt.`
);
}
await this.appendLog(
created.id,
'SYSTEM',
'Metadaten aus früheren Läufen wurden beim /database-Import verworfen (metadata-clean Start).'
);
logger.info('job:import-orphan-raw', {
jobId: created.id,
@@ -5268,8 +5301,9 @@ class HistoryService {
job?.encode_plan_json,
job?.handbrake_info_json
);
if (resolvedJobMediaType === 'dvd' && discNumber === null) {
const error = new Error('Serien-DVD erkannt: Disk-Nummer ist Pflicht (Start bei 1).');
if ((resolvedJobMediaType === 'dvd' || resolvedJobMediaType === 'bluray') && discNumber === null) {
const seriesDiscLabel = resolvedJobMediaType === 'bluray' ? 'Blu-ray' : 'DVD';
const error = new Error(`Serien-${seriesDiscLabel} erkannt: Disk-Nummer ist Pflicht (Start bei 1).`);
error.statusCode = 400;
throw error;
}
@@ -6363,7 +6397,7 @@ class HistoryService {
}
}
async deleteJobFiles(jobId, target = 'both') {
async deleteJobFiles(jobId, target = 'both', options = {}) {
const allowedTargets = new Set(['raw', 'movie', 'both']);
if (!allowedTargets.has(target)) {
const error = new Error(`Ungültiges target '${target}'. Erlaubt: raw, movie, both.`);
@@ -6378,6 +6412,81 @@ class HistoryService {
throw error;
}
const includeRelated = Boolean(options?.includeRelated);
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
? options.selectedMoviePaths
.map((item) => String(item || '').trim())
.filter(Boolean)
: null;
if (includeRelated || (selectedMoviePaths && selectedMoviePaths.length > 0)) {
const normalizedJobId = normalizeJobIdValue(jobId);
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated });
const summary = this._deletePathsFromPreview(preview, target, { selectedMoviePaths });
const relatedJobIds = Array.from(new Set(
(Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [])
.map((row) => normalizeJobIdValue(row?.id))
.filter(Boolean)
));
if (summary.movie?.deleted) {
const movieDeleteEntries = (Array.isArray(summary?.deletedPaths) ? summary.deletedPaths : [])
.filter((entry) => String(entry?.target || '').trim().toLowerCase() === 'movie')
.map((entry) => ({
path: normalizeComparablePath(entry?.path),
type: String(entry?.type || '').trim().toLowerCase()
}))
.filter((entry) => Boolean(entry.path));
const previewMoviePaths = (Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [])
.map((candidate) => normalizeComparablePath(candidate?.path))
.filter(Boolean);
const cleanupPaths = new Set();
for (const deletedEntry of movieDeleteEntries) {
cleanupPaths.add(deletedEntry.path);
for (const candidatePath of previewMoviePaths) {
if (deletedEntry.type === 'directory') {
if (isPathInside(deletedEntry.path, candidatePath)) {
cleanupPaths.add(candidatePath);
}
} else if (candidatePath === deletedEntry.path) {
cleanupPaths.add(candidatePath);
}
}
}
for (const deletedPath of cleanupPaths) {
await this.removeJobOutputFolderFromJobs(
relatedJobIds.length > 0 ? relatedJobIds : [normalizedJobId],
deletedPath
);
}
}
await this.appendLog(
normalizedJobId,
'USER_ACTION',
`Dateien gelöscht (${target}) - includeRelated=${includeRelated} - raw=${JSON.stringify(summary.raw)} movie=${JSON.stringify(summary.movie)}`
);
logger.info('job:delete-files', {
jobId: normalizedJobId,
includeRelated,
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0,
summary
});
const [updated, enrichSettings] = await Promise.all([
this.getJobById(normalizedJobId),
settingsService.getSettingsMap()
]);
return {
summary,
includeRelated,
relatedJobIds,
job: enrichJobRow(updated, enrichSettings)
};
}
const settings = await settingsService.getSettingsMap();
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
const effectiveRawPath = resolvedPaths.effectiveRawPath;
@@ -6717,20 +6826,36 @@ class HistoryService {
const merged = [];
const seen = new Set();
const addFolder = (rawFolder, defaults = {}) => {
const outputPath = String(rawFolder?.output_path || rawFolder?.outputPath || '').trim();
if (!outputPath) {
const outputPathRaw = String(rawFolder?.output_path || rawFolder?.outputPath || '').trim();
if (!outputPathRaw) {
return;
}
const normalized = normalizeComparablePath(outputPath);
if (!normalized || seen.has(normalized)) {
const normalizedRawPath = normalizeComparablePath(outputPathRaw);
if (!normalizedRawPath) {
return;
}
let exists = false;
let resolvedOutputPath = normalizedRawPath;
try {
exists = fs.existsSync(normalized);
exists = fs.existsSync(normalizedRawPath);
} catch (_error) {
exists = false;
}
if (!exists) {
const repairedPath = resolveExistingOutputPathVariant(normalizedRawPath);
if (repairedPath) {
resolvedOutputPath = repairedPath;
try {
exists = fs.existsSync(repairedPath);
} catch (_error) {
exists = false;
}
}
}
const normalized = normalizeComparablePath(resolvedOutputPath);
if (!normalized || seen.has(normalized)) {
return;
}
if (!includeMissing && !exists) {
return;
}
@@ -6738,7 +6863,7 @@ class HistoryService {
merged.push({
id: normalizeJobIdValue(rawFolder?.id),
job_id: normalizeJobIdValue(rawFolder?.job_id ?? rawFolder?.jobId ?? defaults.job_id ?? normalizedJobId),
output_path: outputPath,
output_path: resolvedOutputPath,
label: String(rawFolder?.label || defaults.label || '').trim() || null,
created_at: String(rawFolder?.created_at || rawFolder?.createdAt || '').trim() || null,
exists
@@ -6940,6 +7065,104 @@ class HistoryService {
}
return normalizedRequested;
};
const collectIncompleteMoviePathsFromPreview = (preview) => {
const rows = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [];
return rows
.filter((row) => Boolean(row?.exists))
.map((row) => String(row?.path || '').trim())
.filter((candidatePath) => Boolean(candidatePath))
.filter((candidatePath) => /(^|[\\/])incomplete_job-\d+([\\/]|$)/i.test(candidatePath));
};
const cleanupIncompleteMovieFoldersForJobs = async (jobRows = [], ownerJobIds = []) => {
const normalizedOwnerJobIds = Array.from(new Set(
(Array.isArray(ownerJobIds) ? ownerJobIds : [])
.map((value) => normalizeJobIdValue(value))
.filter(Boolean)
));
const rows = Array.isArray(jobRows) ? jobRows : [];
if (rows.length === 0) {
return { attempted: 0, deleted: 0, failed: 0, paths: [] };
}
const settings = await settingsService.getSettingsMap();
const candidatePaths = new Set();
const fallbackIncompletePattern = /^incomplete_job-(\d+)\s*$/i;
for (const row of rows) {
const rowId = normalizeJobIdValue(row?.id);
if (!rowId) {
continue;
}
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, row);
const movieRoot = normalizeComparablePath(resolvedPaths?.movieDir);
if (!movieRoot || isFilesystemRootPath(movieRoot)) {
continue;
}
const canonicalPath = normalizeComparablePath(path.join(movieRoot, `Incomplete_job-${rowId}`));
if (canonicalPath && isPathInside(movieRoot, canonicalPath)) {
candidatePaths.add(canonicalPath);
}
try {
if (!fs.existsSync(movieRoot) || !fs.lstatSync(movieRoot).isDirectory()) {
continue;
}
const entries = fs.readdirSync(movieRoot, { withFileTypes: true });
for (const entry of entries) {
if (!entry?.isDirectory?.()) {
continue;
}
const match = String(entry?.name || '').match(fallbackIncompletePattern);
if (normalizeJobIdValue(match?.[1]) !== rowId) {
continue;
}
const scannedPath = normalizeComparablePath(path.join(movieRoot, entry.name));
if (scannedPath && isPathInside(movieRoot, scannedPath)) {
candidatePaths.add(scannedPath);
}
}
} catch (_error) {
// best-effort scan
}
}
if (candidatePaths.size === 0) {
return { attempted: 0, deleted: 0, failed: 0, paths: [] };
}
const deletedPaths = [];
let failedCount = 0;
for (const candidatePath of candidatePaths) {
try {
const inspection = inspectDeletionPath(candidatePath);
if (!inspection.exists) {
await this.removeJobOutputFolderFromJobs(normalizedOwnerJobIds, candidatePath);
deletedPaths.push(candidatePath);
continue;
}
if (inspection.isDirectory) {
deleteFilesRecursively(inspection.path, false);
} else if (inspection.isFile) {
fs.unlinkSync(inspection.path);
}
await this.removeJobOutputFolderFromJobs(normalizedOwnerJobIds, candidatePath);
deletedPaths.push(candidatePath);
} catch (_error) {
failedCount += 1;
}
}
return {
attempted: candidatePaths.size,
deleted: deletedPaths.length,
failed: failedCount,
paths: deletedPaths
};
};
const normalizedSelectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
? options.selectedMoviePaths
.map((item) => String(item || '').trim())
.filter(Boolean)
: null;
const includeRelated = Boolean(options?.includeRelated);
if (!includeRelated) {
@@ -6956,13 +7179,37 @@ class HistoryService {
error.statusCode = 400;
throw error;
}
if (isSeriesContainerRow(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.'
);
error.statusCode = 409;
throw error;
}
}
const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
let selectedMoviePathsForDelete = normalizedSelectedMoviePaths;
let preview = null;
let fileSummary = null;
if (effectiveFileTarget === 'none') {
preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview);
if (autoIncompleteMoviePaths.length > 0) {
effectiveFileTarget = 'movie';
if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) {
selectedMoviePathsForDelete = autoIncompleteMoviePaths;
}
}
}
if (effectiveFileTarget !== 'none') {
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
if (!preview) {
preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
}
fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
selectedMoviePaths: options?.selectedMoviePaths
selectedMoviePaths: selectedMoviePathsForDelete
});
}
@@ -7011,6 +7258,12 @@ class HistoryService {
}
await db.run('DELETE FROM jobs WHERE id = ?', [normalizedJobId]);
const stillExists = await db.get('SELECT id FROM jobs WHERE id = ?', [normalizedJobId]);
if (stillExists) {
const error = new Error(`Job #${normalizedJobId} konnte nicht aus der Datenbank entfernt werden.`);
error.statusCode = 409;
throw error;
}
await db.exec('COMMIT');
} catch (error) {
await db.exec('ROLLBACK');
@@ -7020,6 +7273,11 @@ class HistoryService {
await this.closeProcessLog(normalizedJobId);
this._deleteProcessLogFile(normalizedJobId);
thumbnailService.deleteThumbnail(normalizedJobId);
const includeMovieCleanup = effectiveFileTarget === 'movie' || effectiveFileTarget === 'both';
let incompleteCleanupSummary = null;
if (includeMovieCleanup) {
incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs([existing], [normalizedJobId]);
}
logger.warn('job:deleted', {
jobId: normalizedJobId,
@@ -7027,6 +7285,7 @@ class HistoryService {
requestedFileTarget: fileTarget,
includeRelated: false,
pipelineStateReset: isActivePipelineJob,
incompleteCleanup: incompleteCleanupSummary,
filesDeleted: fileSummary
? {
raw: fileSummary.raw?.filesDeleted ?? 0,
@@ -7042,7 +7301,8 @@ class HistoryService {
requestedFileTarget: fileTarget,
includeRelated: false,
deletedJobIds: [Number(normalizedJobId)],
fileSummary
fileSummary,
incompleteCleanup: incompleteCleanupSummary
};
}
@@ -7050,11 +7310,49 @@ class HistoryService {
const existing = resolved?.job || null;
const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId);
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true });
const deleteJobIds = Array.isArray(preview?.relatedJobs)
let deleteJobIds = Array.isArray(preview?.relatedJobs)
? preview.relatedJobs
.map((row) => normalizeJobIdValue(row?.id))
.filter(Boolean)
: [];
let rowById = new Map();
if (normalizedJobId && !deleteJobIds.includes(normalizedJobId)) {
deleteJobIds = [...deleteJobIds, normalizedJobId];
}
if (deleteJobIds.length > 0) {
const db = await getDb();
const placeholders = deleteJobIds.map(() => '?').join(', ');
const rows = await db.all(
`SELECT * FROM jobs WHERE id IN (${placeholders})`,
deleteJobIds
);
rowById = new Map(
(Array.isArray(rows) ? rows : [])
.map((row) => [normalizeJobIdValue(row?.id), row])
.filter(([id]) => Boolean(id))
);
const deleteSet = new Set(deleteJobIds);
// Safety net: never delete a series container while it would still have
// other children afterwards.
for (const candidateId of [...deleteJobIds]) {
const row = rowById.get(candidateId);
if (!row || !isSeriesContainerRow(row)) {
continue;
}
const childRows = await db.all(
`SELECT id FROM jobs WHERE parent_job_id = ?`,
[candidateId]
);
const hasRemainingChildOutsideDeleteSet = (Array.isArray(childRows) ? childRows : []).some((childRow) => {
const childId = normalizeJobIdValue(childRow?.id);
return Boolean(childId && !deleteSet.has(childId));
});
if (hasRemainingChildOutsideDeleteSet) {
deleteSet.delete(candidateId);
}
}
deleteJobIds = Array.from(deleteSet);
}
if (deleteJobIds.length === 0) {
const error = new Error('Keine löschbaren Historien-Einträge gefunden.');
error.statusCode = 404;
@@ -7072,11 +7370,21 @@ class HistoryService {
throw error;
}
const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
let selectedMoviePathsForDelete = normalizedSelectedMoviePaths;
if (effectiveFileTarget === 'none') {
const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview);
if (autoIncompleteMoviePaths.length > 0) {
effectiveFileTarget = 'movie';
if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) {
selectedMoviePathsForDelete = autoIncompleteMoviePaths;
}
}
}
let fileSummary = null;
if (effectiveFileTarget !== 'none') {
fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
selectedMoviePaths: options?.selectedMoviePaths
selectedMoviePaths: selectedMoviePathsForDelete
});
}
@@ -7113,6 +7421,20 @@ class HistoryService {
const deletePlaceholders = deleteJobIds.map(() => '?').join(', ');
await db.run(`DELETE FROM jobs WHERE id IN (${deletePlaceholders})`, deleteJobIds);
const deletedVerificationRows = await db.all(
`SELECT id FROM jobs WHERE id IN (${deletePlaceholders})`,
deleteJobIds
);
if (Array.isArray(deletedVerificationRows) && deletedVerificationRows.length > 0) {
const remainingIds = deletedVerificationRows
.map((row) => normalizeJobIdValue(row?.id))
.filter(Boolean);
const error = new Error(
`Folgende Jobs konnten nicht gelöscht werden: ${remainingIds.join(', ')}`
);
error.statusCode = 409;
throw error;
}
await db.exec('COMMIT');
} catch (error) {
await db.exec('ROLLBACK');
@@ -7124,6 +7446,19 @@ class HistoryService {
this._deleteProcessLogFile(deletedJobId);
thumbnailService.deleteThumbnail(deletedJobId);
}
const includeMovieCleanup = effectiveFileTarget === 'movie' || effectiveFileTarget === 'both';
let incompleteCleanupSummary = null;
if (includeMovieCleanup) {
const deletedJobRows = deleteJobIds
.map((id) => rowById.get(id))
.filter(Boolean);
incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs(deletedJobRows, deleteJobIds);
}
const deletedJobIdSet = new Set(deleteJobIds);
const deletedJobs = Array.isArray(preview?.relatedJobs)
? preview.relatedJobs.filter((row) => deletedJobIdSet.has(normalizeJobIdValue(row?.id)))
: [];
logger.warn('job:deleted', {
jobId: normalizedJobId,
@@ -7133,6 +7468,7 @@ class HistoryService {
deletedJobIds: deleteJobIds,
deletedJobCount: deleteJobIds.length,
pipelineStateReset: activeJobIncluded,
incompleteCleanup: incompleteCleanupSummary,
filesDeleted: fileSummary
? {
raw: fileSummary.raw?.filesDeleted ?? 0,
@@ -7148,8 +7484,9 @@ class HistoryService {
requestedFileTarget: fileTarget,
includeRelated: true,
deletedJobIds: deleteJobIds,
deletedJobs: preview.relatedJobs,
fileSummary
deletedJobs,
fileSummary,
incompleteCleanup: incompleteCleanupSummary
};
}
}
File diff suppressed because it is too large Load Diff
+16 -4
View File
@@ -65,9 +65,11 @@ const PROFILED_SETTINGS = {
audiobook: 'raw_dir_audiobook_owner'
},
series_raw_dir: {
bluray: 'raw_dir_bluray_series',
dvd: 'raw_dir_dvd_series'
},
series_raw_dir_owner: {
bluray: 'raw_dir_bluray_series_owner',
dvd: 'raw_dir_dvd_series_owner'
},
movie_dir: {
@@ -77,6 +79,7 @@ const PROFILED_SETTINGS = {
audiobook: 'movie_dir_audiobook'
},
series_dir: {
bluray: 'series_dir_bluray',
dvd: 'series_dir_dvd'
},
movie_dir_owner: {
@@ -86,6 +89,7 @@ const PROFILED_SETTINGS = {
audiobook: 'movie_dir_audiobook_owner'
},
series_dir_owner: {
bluray: 'series_dir_bluray_owner',
dvd: 'series_dir_dvd_owner'
},
mediainfo_extra_args: {
@@ -877,7 +881,12 @@ class SettingsService {
const cd = this.resolveEffectiveToolSettings(map, 'cd');
const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook');
return {
bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir },
bluray: {
raw: bluray.raw_dir,
seriesRaw: bluray.series_raw_dir || bluray.raw_dir,
movies: bluray.movie_dir,
series: bluray.series_dir
},
dvd: { raw: dvd.raw_dir, seriesRaw: dvd.series_raw_dir || dvd.raw_dir, movies: dvd.movie_dir, series: dvd.series_dir },
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
@@ -1086,16 +1095,17 @@ class SettingsService {
);
const cmd = map.makemkv_command;
const extraArgs = splitArgs(map.makemkv_analyze_extra_args);
const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter);
const hasExplicitMinLength = extraArgs.some((arg) => /^--minlength(?:=|$)/i.test(String(arg || '').trim()));
const minLengthMinutes = Number(map.makemkv_min_length_minutes || 0);
const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0
? Math.round(minLengthMinutes * 60)
: 0;
const minLengthArgs = (!hasExplicitMinLength && minLengthSeconds > 0)
const minLengthArgs = (!disableMinLengthFilter && !hasExplicitMinLength && minLengthSeconds > 0)
? [`--minlength=${minLengthSeconds}`]
: [];
const args = ['-r', ...minLengthArgs, ...extraArgs, 'info', this.resolveSourceArg(map, deviceInfo)];
logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo });
logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo, disableMinLengthFilter });
return { cmd, args };
}
@@ -1105,12 +1115,13 @@ class SettingsService {
const cmd = map.makemkv_command;
const sourceArg = `file:${sourcePath}`;
const extraArgs = splitArgs(map.makemkv_analyze_extra_args);
const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter);
const hasExplicitMinLength = extraArgs.some((arg) => /^--minlength(?:=|$)/i.test(String(arg || '').trim()));
const minLengthMinutes = Number(map.makemkv_min_length_minutes || 0);
const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0
? Math.round(minLengthMinutes * 60)
: 0;
const minLengthArgs = (!hasExplicitMinLength && minLengthSeconds > 0)
const minLengthArgs = (!disableMinLengthFilter && !hasExplicitMinLength && minLengthSeconds > 0)
? [`--minlength=${minLengthSeconds}`]
: [];
const args = ['-r', ...minLengthArgs, ...extraArgs, 'info', sourceArg];
@@ -1120,6 +1131,7 @@ class SettingsService {
cmd,
args,
sourcePath,
disableMinLengthFilter,
requestedTitleId: Number.isFinite(titleIdRaw) && titleIdRaw >= 0 ? Math.trunc(titleIdRaw) : null
});
return { cmd, args, sourceArg };