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", "name": "ripster-backend",
"version": "0.13.1-12", "version": "0.14.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.13.1-12", "version": "0.14.0",
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.13.1-12", "version": "0.14.0",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "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.', description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
validation_json: '{}' 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', key: 'output_template_dvd_series_episode',
required: 1, required: 1,
@@ -859,6 +871,8 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
const SETTINGS_CATEGORY_MOVES = [ const SETTINGS_CATEGORY_MOVES = [
{ key: 'cd_output_template', category: 'Pfade' }, { key: 'cd_output_template', category: 'Pfade' },
{ key: 'output_template_bluray', 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_dvd', category: 'Pfade' },
{ key: 'output_template_audiobook', category: 'Pfade' }, { key: 'output_template_audiobook', category: 'Pfade' },
{ key: 'output_chapter_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_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( 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 ('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)` 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_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( 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 ('series_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113)` 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_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( 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 ('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)` 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' pluginFile: 'DVDSeriesPlugin.js'
}); });
const fallbackSeriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([
{
value: ctx.extra?.detectedTitle || '',
source: 'detected_title'
}
]);
const scanText = String(ctx.extra?.scanText || '').trim(); const scanText = String(ctx.extra?.scanText || '').trim();
if (!scanText) { if (!scanText) {
return { return {
parsedScan: null, parsedScan: null,
seriesAnalysis: null, seriesAnalysis: null,
seriesLookupHint: fallbackSeriesLookupHint,
providerConfigured: await tmdbService.isConfigured() providerConfigured: await tmdbService.isConfigured()
}; };
} }
@@ -50,7 +58,7 @@ class DVDSeriesPlugin extends SourcePlugin {
value: ctx.extra?.detectedTitle || '', value: ctx.extra?.detectedTitle || '',
source: 'detected_title' source: 'detected_title'
} }
]); ]) || fallbackSeriesLookupHint;
return { return {
parsedScan: result.parsed, parsedScan: result.parsed,
seriesAnalysis: result.analysis, seriesAnalysis: result.analysis,
+31 -7
View File
@@ -58,11 +58,23 @@ router.post(
logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath }); logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath });
const importedJob = await historyService.importOrphanRawFolder(rawPath); const importedJob = await historyService.importOrphanRawFolder(rawPath);
const importedJobId = Number(importedJob?.id || 0); const importedJobId = Number(importedJob?.id || 0);
const activation = (Number.isFinite(importedJobId) && importedJobId > 0) let activation = null;
? await pipelineService.analyzeRawImportJob(importedJobId, { let activationError = null;
if (Number.isFinite(importedJobId) && importedJobId > 0) {
try {
activation = await pipelineService.analyzeRawImportJob(importedJobId, {
rawPath: importedJob?.raw_path || rawPath rawPath: importedJob?.raw_path || rawPath
}) });
: null; } 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 const refreshedJob = importedJobId > 0
? await historyService.getJobById(importedJobId) ? await historyService.getJobById(importedJobId)
@@ -70,7 +82,8 @@ router.post(
res.json({ res.json({
job: refreshedJob || importedJob, job: refreshedJob || importedJob,
activation activation,
...(activationError ? { activationError } : {})
}); });
}) })
); );
@@ -139,14 +152,25 @@ router.post(
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const target = String(req.body?.target || 'both'); 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', { logger.warn('post:delete-files', {
reqId: req.reqId, reqId: req.reqId,
id, 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); res.json(result);
}) })
); );
+33 -3
View File
@@ -69,6 +69,8 @@ function normalizeLanguageCode(rawCode, fallbackLabel = null) {
function normalizeSeriesLookupTitle(rawValue) { function normalizeSeriesLookupTitle(rawValue) {
return String(rawValue || '') return String(rawValue || '')
.replace(/[_./]+/g, ' ') .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(?:disc|dvd|cd)\s*\d+\b/gi, ' ')
.replace(/\b(?:s(?:taffel|eason)?|season)\s*\d+\b/gi, ' ') .replace(/\b(?:s(?:taffel|eason)?|season)\s*\d+\b/gi, ' ')
.replace(/\b\d{1,2}x\b/gi, ' ') .replace(/\b\d{1,2}x\b/gi, ' ')
@@ -99,7 +101,12 @@ function deriveSeriesLookupHint(inputs = []) {
continue; 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); || compactValue.match(/(?:^|\s)(\d{1,2})x(?=\s|$)/i);
if (!seasonMatch) { if (!seasonMatch) {
continue; continue;
@@ -110,9 +117,11 @@ function deriveSeriesLookupHint(inputs = []) {
continue; 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 const discNumber = discMatch
? Number(discMatch[1] || 0) || null ? Number(compactDiscToken || discMatch[1] || 0) || null
: null; : null;
const baseTitle = normalizeSeriesLookupTitle(compactValue); const baseTitle = normalizeSeriesLookupTitle(compactValue);
@@ -385,12 +394,22 @@ function parseHandBrakeScanText(rawOutput) {
parsed.titleCount = Number(match[1] || 0); parsed.titleCount = Number(match[1] || 0);
continue; 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); match = line.match(/scan:\s+scanning title\s+(\d+)/i);
if (match) { if (match) {
ensureCurrentTitle(match[1]); ensureCurrentTitle(match[1]);
continue; 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); match = line.match(/scan:\s+duration is\s+(\d{1,2}:\d{2}:\d{2})/i);
if (match && currentTitle) { if (match && currentTitle) {
@@ -398,6 +417,12 @@ function parseHandBrakeScanText(rawOutput) {
currentTitle.durationSeconds = parseDurationToSeconds(match[1]); currentTitle.durationSeconds = parseDurationToSeconds(match[1]);
continue; 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); match = line.match(/scan:\s+title\s+(\d+)\s+has\s+(\d+)\s+chapters/i);
if (match) { if (match) {
@@ -407,6 +432,11 @@ function parseHandBrakeScanText(rawOutput) {
} }
continue; 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); match = line.match(/scan:\s+chap\s+\d+,\s+(\d+)\s+ms/i);
if (match && currentTitle) { if (match && currentTitle) {
+441 -104
View File
@@ -685,8 +685,11 @@ function getSeriesRawPathCandidates(settings = {}) {
for (const candidate of getConfiguredMediaPathList(source, 'series_raw_dir')) { for (const candidate of getConfiguredMediaPathList(source, 'series_raw_dir')) {
pushPath(candidate); pushPath(candidate);
} }
pushPath(source.raw_dir_bluray_series);
pushPath(source.raw_dir_dvd_series); pushPath(source.raw_dir_dvd_series);
const blurayEffective = settingsService.resolveEffectiveToolSettings(source, 'bluray') || {};
pushPath(blurayEffective.series_raw_dir);
const dvdEffective = settingsService.resolveEffectiveToolSettings(source, 'dvd') || {}; const dvdEffective = settingsService.resolveEffectiveToolSettings(source, 'dvd') || {};
pushPath(dvdEffective.series_raw_dir); 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. // 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 = {}) { function getOrphanRawScanPathList(settings = {}) {
@@ -732,6 +735,7 @@ function getOrphanRawScanPathList(settings = {}) {
for (const candidate of getConfiguredMediaPathList(source, 'series_raw_dir')) { for (const candidate of getConfiguredMediaPathList(source, 'series_raw_dir')) {
pushPath(candidate); pushPath(candidate);
} }
pushPath(source.raw_dir_bluray_series);
pushPath(source.raw_dir_dvd_series); pushPath(source.raw_dir_dvd_series);
pushPath(source.converter_raw_dir); pushPath(source.converter_raw_dir);
@@ -739,7 +743,7 @@ function getOrphanRawScanPathList(settings = {}) {
for (const profile of ['bluray', 'dvd', 'cd', 'audiobook']) { for (const profile of ['bluray', 'dvd', 'cd', 'audiobook']) {
const effective = settingsService.resolveEffectiveToolSettings(source, profile) || {}; const effective = settingsService.resolveEffectiveToolSettings(source, profile) || {};
pushPath(effective.raw_dir); pushPath(effective.raw_dir);
if (profile === 'dvd') { if (profile === 'dvd' || profile === 'bluray') {
pushPath(effective.series_raw_dir); pushPath(effective.series_raw_dir);
} }
} }
@@ -791,7 +795,7 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
|| Number(selectedMetadata?.episodeCount || 0) > 0 || Number(selectedMetadata?.episodeCount || 0) > 0
|| ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind) || ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind)
); );
const isSeriesDvd = mediaType === 'dvd' && ( const isSeriesDisc = (mediaType === 'dvd' || mediaType === 'bluray') && (
seriesSignals seriesSignals
|| hasSeriesMetadataHint || hasSeriesMetadataHint
|| hasSeriesJobKind || hasSeriesJobKind
@@ -825,8 +829,8 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
const seriesRawDir = String(effectiveSettings?.series_raw_dir || '').trim(); const seriesRawDir = String(effectiveSettings?.series_raw_dir || '').trim();
const seriesMovieDir = String(effectiveSettings?.series_dir || '').trim(); const seriesMovieDir = String(effectiveSettings?.series_dir || '').trim();
const defaultRawDir = String(effectiveSettings?.raw_dir || '').trim(); const defaultRawDir = String(effectiveSettings?.raw_dir || '').trim();
let rawDir = isSeriesDvd ? (seriesRawDir || defaultRawDir) : defaultRawDir; let rawDir = isSeriesDisc ? (seriesRawDir || defaultRawDir) : defaultRawDir;
if (isSeriesDvd) { if (isSeriesDisc) {
const normalizedStoredRawPath = normalizeComparablePath(job?.raw_path); const normalizedStoredRawPath = normalizeComparablePath(job?.raw_path);
if (normalizedStoredRawPath) { if (normalizedStoredRawPath) {
const seriesRawCandidates = getSeriesRawPathCandidates(settings || {}); 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()) ? (seriesMovieDir || String(effectiveSettings?.movie_dir || '').trim())
: String(effectiveSettings?.movie_dir || '').trim(); : String(effectiveSettings?.movie_dir || '').trim();
const movieDir = configuredMovieDir || rawDir; const movieDir = configuredMovieDir || rawDir;
const rawLookupDirsBase = isSeriesDvd const rawLookupDirsBase = isSeriesDisc
? getSeriesRawPathCandidates(settings || {}) ? getSeriesRawPathCandidates(settings || {})
: getConfiguredMediaPathList(settings || {}, 'raw_dir'); : getConfiguredMediaPathList(settings || {}, 'raw_dir');
const rawLookupDirs = rawLookupDirsBase const rawLookupDirs = rawLookupDirsBase
@@ -848,7 +852,7 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
const effectiveRawPath = job?.raw_path const effectiveRawPath = job?.raw_path
? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs) ? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs)
: (job?.raw_path || null); : (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) ? resolveEffectiveOutputPath(job.output_path, configuredMovieDir)
: (job?.output_path || null); : (job?.output_path || null);
@@ -1062,6 +1066,97 @@ function inferSiblingOutputFolders(outputPath) {
return candidates.map((item) => item.path); 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) { function compareOutputFolderPaths(leftPath, rightPath) {
const leftNormalized = normalizeComparablePath(leftPath); const leftNormalized = normalizeComparablePath(leftPath);
const rightNormalized = normalizeComparablePath(rightPath); const rightNormalized = normalizeComparablePath(rightPath);
@@ -3025,6 +3120,7 @@ class HistoryService {
const mediaProfile = normalizeMediaTypeValue(options.mediaProfile) || 'other'; const mediaProfile = normalizeMediaTypeValue(options.mediaProfile) || 'other';
const importedAt = String(options.importedAt || new Date().toISOString()).trim() || new Date().toISOString(); const importedAt = String(options.importedAt || new Date().toISOString()).trim() || new Date().toISOString();
const rawPath = String(options.rawPath || '').trim() || null; const rawPath = String(options.rawPath || '').trim() || null;
const stripRecoveryMetadata = Boolean(options.stripRecoveryMetadata);
const existingInfo = options.existingInfo && typeof options.existingInfo === 'object' const existingInfo = options.existingInfo && typeof options.existingInfo === 'object'
? options.existingInfo ? options.existingInfo
: {}; : {};
@@ -3109,7 +3205,7 @@ class HistoryService {
if (Array.isArray(recovery.tracks) && recovery.tracks.length > 0) { if (Array.isArray(recovery.tracks) && recovery.tracks.length > 0) {
nextInfo.tracks = recovery.tracks; nextInfo.tracks = recovery.tracks;
} }
if (recovery.selectedMetadata && typeof recovery.selectedMetadata === 'object') { if (!stripRecoveryMetadata && recovery.selectedMetadata && typeof recovery.selectedMetadata === 'object') {
const recoverySelectedMetadata = compactMetadataObject(recovery.selectedMetadata); const recoverySelectedMetadata = compactMetadataObject(recovery.selectedMetadata);
nextInfo.selectedMetadata = { nextInfo.selectedMetadata = {
...(nextInfo.selectedMetadata && typeof nextInfo.selectedMetadata === 'object' ...(nextInfo.selectedMetadata && typeof nextInfo.selectedMetadata === 'object'
@@ -4895,19 +4991,7 @@ class HistoryService {
const folderName = path.basename(absRawPath); const folderName = path.basename(absRawPath);
const metadata = parseRawFolderMetadata(folderName); const metadata = parseRawFolderMetadata(folderName);
let omdbById = null; const effectiveTitle = metadata.title || folderName;
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 importedAt = new Date().toISOString(); const importedAt = new Date().toISOString();
const created = await this.createJob({ const created = await this.createJob({
discDevice: null, discDevice: null,
@@ -4984,7 +5068,6 @@ class HistoryService {
const initialSourceSelectedMetadata = initialSourceJobContext?.selectedMetadata && typeof initialSourceJobContext.selectedMetadata === 'object' const initialSourceSelectedMetadata = initialSourceJobContext?.selectedMetadata && typeof initialSourceJobContext.selectedMetadata === 'object'
? initialSourceJobContext.selectedMetadata ? initialSourceJobContext.selectedMetadata
: {}; : {};
const orphanPosterUrl = omdbById?.poster || null;
const cdRecovery = effectiveDetectedMediaType === 'cd' const cdRecovery = effectiveDetectedMediaType === 'cd'
? recoverCdJobArtifactsForImport({ ? recoverCdJobArtifactsForImport({
currentRawPath: finalRawPath, currentRawPath: finalRawPath,
@@ -5011,12 +5094,6 @@ class HistoryService {
], ],
mediaProfile: effectiveDetectedMediaType mediaProfile: effectiveDetectedMediaType
}) || initialSourceJobContext; }) || 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({ const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({
importedAt, importedAt,
rawPath: finalRawPath, rawPath: finalRawPath,
@@ -5025,27 +5102,12 @@ class HistoryService {
mediaProfile: effectiveDetectedMediaType, mediaProfile: effectiveDetectedMediaType,
// RAW-Import soll wie "Disk analysieren" starten: // RAW-Import soll wie "Disk analysieren" starten:
// keine geerbte Serien-/Metadatenzuordnung vor dem eigentlichen Analyse-Scan. // keine geerbte Serien-/Metadatenzuordnung vor dem eigentlichen Analyse-Scan.
existingInfo: cdRecovery ? (sourceJobContext?.makemkvInfo || null) : null, existingInfo: null,
recovery: cdRecovery, recovery: cdRecovery,
selectedMetadata: null, 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, { await this.updateJob(created.id, {
...(effectiveDetectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}), ...(effectiveDetectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}),
...(effectiveDetectedMediaType === 'cd' ? { job_kind: 'cd', media_type: 'cd' } : {}), ...(effectiveDetectedMediaType === 'cd' ? { job_kind: 'cd', media_type: 'cd' } : {}),
@@ -5053,32 +5115,19 @@ class HistoryService {
...(effectiveDetectedMediaType === 'bluray' ? { job_kind: 'bluray', media_type: 'bluray' } : {}), ...(effectiveDetectedMediaType === 'bluray' ? { job_kind: 'bluray', media_type: 'bluray' } : {}),
status: 'FINISHED', status: 'FINISHED',
last_state: 'FINISHED', last_state: 'FINISHED',
title: omdbById?.title // /database-Import startet bewusst metadata-clean: keine Übernahme aus früheren Läufen.
|| sourceSelectedMetadata?.title title: null,
|| sourceSelectedMetadata?.album year: null,
|| sourceJob?.title imdb_id: null,
|| metadata.title poster_url: null,
|| cdRecovery?.selectedMetadata?.title omdb_json: null,
|| null, selected_from_omdb: 0,
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),
rip_successful: 1, rip_successful: 1,
raw_path: finalRawPath, raw_path: finalRawPath,
output_path: cdRecovery?.outputPath || null, output_path: null,
handbrake_info_json: null, handbrake_info_json: null,
mediainfo_info_json: null, mediainfo_info_json: null,
encode_plan_json: recoveredCdEncodePlan ? JSON.stringify(recoveredCdEncodePlan) : null, encode_plan_json: null,
encode_input_path: null, encode_input_path: null,
encode_review_confirmed: 0, encode_review_confirmed: 0,
error_message: null, 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) { if (!cdRecovery?.logSources?.length) {
await this.appendLog( await this.appendLog(
created.id, 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 RAW erstellt (Medientyp: ${effectiveDetectedMediaType}). Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}`
: `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath} (Medientyp: ${effectiveDetectedMediaType})` : `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath} (Medientyp: ${effectiveDetectedMediaType})`
); );
if (metadata.imdbId) {
await this.appendLog( await this.appendLog(
created.id, created.id,
'SYSTEM', 'SYSTEM',
omdbById 'Metadaten aus früheren Läufen wurden beim /database-Import verworfen (metadata-clean Start).'
? `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.`
);
}
logger.info('job:import-orphan-raw', { logger.info('job:import-orphan-raw', {
jobId: created.id, jobId: created.id,
@@ -5268,8 +5301,9 @@ class HistoryService {
job?.encode_plan_json, job?.encode_plan_json,
job?.handbrake_info_json job?.handbrake_info_json
); );
if (resolvedJobMediaType === 'dvd' && discNumber === null) { if ((resolvedJobMediaType === 'dvd' || resolvedJobMediaType === 'bluray') && discNumber === null) {
const error = new Error('Serien-DVD erkannt: Disk-Nummer ist Pflicht (Start bei 1).'); const seriesDiscLabel = resolvedJobMediaType === 'bluray' ? 'Blu-ray' : 'DVD';
const error = new Error(`Serien-${seriesDiscLabel} erkannt: Disk-Nummer ist Pflicht (Start bei 1).`);
error.statusCode = 400; error.statusCode = 400;
throw error; 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']); const allowedTargets = new Set(['raw', 'movie', 'both']);
if (!allowedTargets.has(target)) { if (!allowedTargets.has(target)) {
const error = new Error(`Ungültiges target '${target}'. Erlaubt: raw, movie, both.`); const error = new Error(`Ungültiges target '${target}'. Erlaubt: raw, movie, both.`);
@@ -6378,6 +6412,81 @@ class HistoryService {
throw error; 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 settings = await settingsService.getSettingsMap();
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
const effectiveRawPath = resolvedPaths.effectiveRawPath; const effectiveRawPath = resolvedPaths.effectiveRawPath;
@@ -6717,20 +6826,36 @@ class HistoryService {
const merged = []; const merged = [];
const seen = new Set(); const seen = new Set();
const addFolder = (rawFolder, defaults = {}) => { const addFolder = (rawFolder, defaults = {}) => {
const outputPath = String(rawFolder?.output_path || rawFolder?.outputPath || '').trim(); const outputPathRaw = String(rawFolder?.output_path || rawFolder?.outputPath || '').trim();
if (!outputPath) { if (!outputPathRaw) {
return; return;
} }
const normalized = normalizeComparablePath(outputPath); const normalizedRawPath = normalizeComparablePath(outputPathRaw);
if (!normalized || seen.has(normalized)) { if (!normalizedRawPath) {
return; return;
} }
let exists = false; let exists = false;
let resolvedOutputPath = normalizedRawPath;
try { try {
exists = fs.existsSync(normalized); exists = fs.existsSync(normalizedRawPath);
} catch (_error) { } catch (_error) {
exists = false; 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) { if (!includeMissing && !exists) {
return; return;
} }
@@ -6738,7 +6863,7 @@ class HistoryService {
merged.push({ merged.push({
id: normalizeJobIdValue(rawFolder?.id), id: normalizeJobIdValue(rawFolder?.id),
job_id: normalizeJobIdValue(rawFolder?.job_id ?? rawFolder?.jobId ?? defaults.job_id ?? normalizedJobId), 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, label: String(rawFolder?.label || defaults.label || '').trim() || null,
created_at: String(rawFolder?.created_at || rawFolder?.createdAt || '').trim() || null, created_at: String(rawFolder?.created_at || rawFolder?.createdAt || '').trim() || null,
exists exists
@@ -6940,6 +7065,104 @@ class HistoryService {
} }
return normalizedRequested; 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); const includeRelated = Boolean(options?.includeRelated);
if (!includeRelated) { if (!includeRelated) {
@@ -6956,13 +7179,37 @@ class HistoryService {
error.statusCode = 400; error.statusCode = 400;
throw error; 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; 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') { 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, { 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]); 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'); await db.exec('COMMIT');
} catch (error) { } catch (error) {
await db.exec('ROLLBACK'); await db.exec('ROLLBACK');
@@ -7020,6 +7273,11 @@ class HistoryService {
await this.closeProcessLog(normalizedJobId); await this.closeProcessLog(normalizedJobId);
this._deleteProcessLogFile(normalizedJobId); this._deleteProcessLogFile(normalizedJobId);
thumbnailService.deleteThumbnail(normalizedJobId); thumbnailService.deleteThumbnail(normalizedJobId);
const includeMovieCleanup = effectiveFileTarget === 'movie' || effectiveFileTarget === 'both';
let incompleteCleanupSummary = null;
if (includeMovieCleanup) {
incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs([existing], [normalizedJobId]);
}
logger.warn('job:deleted', { logger.warn('job:deleted', {
jobId: normalizedJobId, jobId: normalizedJobId,
@@ -7027,6 +7285,7 @@ class HistoryService {
requestedFileTarget: fileTarget, requestedFileTarget: fileTarget,
includeRelated: false, includeRelated: false,
pipelineStateReset: isActivePipelineJob, pipelineStateReset: isActivePipelineJob,
incompleteCleanup: incompleteCleanupSummary,
filesDeleted: fileSummary filesDeleted: fileSummary
? { ? {
raw: fileSummary.raw?.filesDeleted ?? 0, raw: fileSummary.raw?.filesDeleted ?? 0,
@@ -7042,7 +7301,8 @@ class HistoryService {
requestedFileTarget: fileTarget, requestedFileTarget: fileTarget,
includeRelated: false, includeRelated: false,
deletedJobIds: [Number(normalizedJobId)], deletedJobIds: [Number(normalizedJobId)],
fileSummary fileSummary,
incompleteCleanup: incompleteCleanupSummary
}; };
} }
@@ -7050,11 +7310,49 @@ class HistoryService {
const existing = resolved?.job || null; const existing = resolved?.job || null;
const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId); const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId);
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true }); const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true });
const deleteJobIds = Array.isArray(preview?.relatedJobs) let deleteJobIds = Array.isArray(preview?.relatedJobs)
? preview.relatedJobs ? preview.relatedJobs
.map((row) => normalizeJobIdValue(row?.id)) .map((row) => normalizeJobIdValue(row?.id))
.filter(Boolean) .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) { if (deleteJobIds.length === 0) {
const error = new Error('Keine löschbaren Historien-Einträge gefunden.'); const error = new Error('Keine löschbaren Historien-Einträge gefunden.');
error.statusCode = 404; error.statusCode = 404;
@@ -7072,11 +7370,21 @@ class HistoryService {
throw error; 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; let fileSummary = null;
if (effectiveFileTarget !== 'none') { if (effectiveFileTarget !== 'none') {
fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, { fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
selectedMoviePaths: options?.selectedMoviePaths selectedMoviePaths: selectedMoviePathsForDelete
}); });
} }
@@ -7113,6 +7421,20 @@ class HistoryService {
const deletePlaceholders = deleteJobIds.map(() => '?').join(', '); const deletePlaceholders = deleteJobIds.map(() => '?').join(', ');
await db.run(`DELETE FROM jobs WHERE id IN (${deletePlaceholders})`, deleteJobIds); 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'); await db.exec('COMMIT');
} catch (error) { } catch (error) {
await db.exec('ROLLBACK'); await db.exec('ROLLBACK');
@@ -7124,6 +7446,19 @@ class HistoryService {
this._deleteProcessLogFile(deletedJobId); this._deleteProcessLogFile(deletedJobId);
thumbnailService.deleteThumbnail(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', { logger.warn('job:deleted', {
jobId: normalizedJobId, jobId: normalizedJobId,
@@ -7133,6 +7468,7 @@ class HistoryService {
deletedJobIds: deleteJobIds, deletedJobIds: deleteJobIds,
deletedJobCount: deleteJobIds.length, deletedJobCount: deleteJobIds.length,
pipelineStateReset: activeJobIncluded, pipelineStateReset: activeJobIncluded,
incompleteCleanup: incompleteCleanupSummary,
filesDeleted: fileSummary filesDeleted: fileSummary
? { ? {
raw: fileSummary.raw?.filesDeleted ?? 0, raw: fileSummary.raw?.filesDeleted ?? 0,
@@ -7148,8 +7484,9 @@ class HistoryService {
requestedFileTarget: fileTarget, requestedFileTarget: fileTarget,
includeRelated: true, includeRelated: true,
deletedJobIds: deleteJobIds, deletedJobIds: deleteJobIds,
deletedJobs: preview.relatedJobs, deletedJobs,
fileSummary 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' audiobook: 'raw_dir_audiobook_owner'
}, },
series_raw_dir: { series_raw_dir: {
bluray: 'raw_dir_bluray_series',
dvd: 'raw_dir_dvd_series' dvd: 'raw_dir_dvd_series'
}, },
series_raw_dir_owner: { series_raw_dir_owner: {
bluray: 'raw_dir_bluray_series_owner',
dvd: 'raw_dir_dvd_series_owner' dvd: 'raw_dir_dvd_series_owner'
}, },
movie_dir: { movie_dir: {
@@ -77,6 +79,7 @@ const PROFILED_SETTINGS = {
audiobook: 'movie_dir_audiobook' audiobook: 'movie_dir_audiobook'
}, },
series_dir: { series_dir: {
bluray: 'series_dir_bluray',
dvd: 'series_dir_dvd' dvd: 'series_dir_dvd'
}, },
movie_dir_owner: { movie_dir_owner: {
@@ -86,6 +89,7 @@ const PROFILED_SETTINGS = {
audiobook: 'movie_dir_audiobook_owner' audiobook: 'movie_dir_audiobook_owner'
}, },
series_dir_owner: { series_dir_owner: {
bluray: 'series_dir_bluray_owner',
dvd: 'series_dir_dvd_owner' dvd: 'series_dir_dvd_owner'
}, },
mediainfo_extra_args: { mediainfo_extra_args: {
@@ -877,7 +881,12 @@ class SettingsService {
const cd = this.resolveEffectiveToolSettings(map, 'cd'); const cd = this.resolveEffectiveToolSettings(map, 'cd');
const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook'); const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook');
return { 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 }, 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 }, cd: { raw: cd.raw_dir, movies: cd.movie_dir },
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir }, audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
@@ -1086,16 +1095,17 @@ class SettingsService {
); );
const cmd = map.makemkv_command; const cmd = map.makemkv_command;
const extraArgs = splitArgs(map.makemkv_analyze_extra_args); 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 hasExplicitMinLength = extraArgs.some((arg) => /^--minlength(?:=|$)/i.test(String(arg || '').trim()));
const minLengthMinutes = Number(map.makemkv_min_length_minutes || 0); const minLengthMinutes = Number(map.makemkv_min_length_minutes || 0);
const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0 const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0
? Math.round(minLengthMinutes * 60) ? Math.round(minLengthMinutes * 60)
: 0; : 0;
const minLengthArgs = (!hasExplicitMinLength && minLengthSeconds > 0) const minLengthArgs = (!disableMinLengthFilter && !hasExplicitMinLength && minLengthSeconds > 0)
? [`--minlength=${minLengthSeconds}`] ? [`--minlength=${minLengthSeconds}`]
: []; : [];
const args = ['-r', ...minLengthArgs, ...extraArgs, 'info', this.resolveSourceArg(map, deviceInfo)]; 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 }; return { cmd, args };
} }
@@ -1105,12 +1115,13 @@ class SettingsService {
const cmd = map.makemkv_command; const cmd = map.makemkv_command;
const sourceArg = `file:${sourcePath}`; const sourceArg = `file:${sourcePath}`;
const extraArgs = splitArgs(map.makemkv_analyze_extra_args); 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 hasExplicitMinLength = extraArgs.some((arg) => /^--minlength(?:=|$)/i.test(String(arg || '').trim()));
const minLengthMinutes = Number(map.makemkv_min_length_minutes || 0); const minLengthMinutes = Number(map.makemkv_min_length_minutes || 0);
const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0 const minLengthSeconds = Number.isFinite(minLengthMinutes) && minLengthMinutes > 0
? Math.round(minLengthMinutes * 60) ? Math.round(minLengthMinutes * 60)
: 0; : 0;
const minLengthArgs = (!hasExplicitMinLength && minLengthSeconds > 0) const minLengthArgs = (!disableMinLengthFilter && !hasExplicitMinLength && minLengthSeconds > 0)
? [`--minlength=${minLengthSeconds}`] ? [`--minlength=${minLengthSeconds}`]
: []; : [];
const args = ['-r', ...minLengthArgs, ...extraArgs, 'info', sourceArg]; const args = ['-r', ...minLengthArgs, ...extraArgs, 'info', sourceArg];
@@ -1120,6 +1131,7 @@ class SettingsService {
cmd, cmd,
args, args,
sourcePath, sourcePath,
disableMinLengthFilter,
requestedTitleId: Number.isFinite(titleIdRaw) && titleIdRaw >= 0 ? Math.trunc(titleIdRaw) : null requestedTitleId: Number.isFinite(titleIdRaw) && titleIdRaw >= 0 ? Math.trunc(titleIdRaw) : null
}); });
return { cmd, args, sourceArg }; return { cmd, args, sourceArg };
+24
View File
@@ -262,6 +262,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
VALUES ('raw_dir_bluray_owner', 'Pfade', 'Eigentümer Raw-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1015); VALUES ('raw_dir_bluray_owner', 'Pfade', 'Eigentümer Raw-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1015);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_owner', NULL); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_owner', NULL);
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);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series_owner', NULL);
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 ('raw_dir_dvd_owner', 'Pfade', 'Eigentümer Raw-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1025); VALUES ('raw_dir_dvd_owner', 'Pfade', 'Eigentümer Raw-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1025);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_owner', NULL); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_owner', NULL);
@@ -274,6 +278,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
VALUES ('movie_dir_bluray_owner', 'Pfade', 'Eigentümer Film-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1115); VALUES ('movie_dir_bluray_owner', 'Pfade', 'Eigentümer Film-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1115);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray_owner', NULL); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray_owner', NULL);
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);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray_owner', NULL);
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 ('movie_dir_dvd_owner', 'Pfade', 'Eigentümer Film-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1125); VALUES ('movie_dir_dvd_owner', 'Pfade', 'Eigentümer Film-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1125);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd_owner', NULL); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd_owner', NULL);
@@ -308,6 +316,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
VALUES ('raw_dir_bluray', 'Pfade', 'Raw-Ordner (Blu-ray)', 'path', 0, 'RAW-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 101); VALUES ('raw_dir_bluray', 'Pfade', 'Raw-Ordner (Blu-ray)', 'path', 0, 'RAW-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 101);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray', NULL); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray', NULL);
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);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_bluray_series', NULL);
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 ('raw_dir_dvd', 'Pfade', 'Raw-Ordner (DVD)', 'path', 0, 'RAW-Zielpfad für DVD. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 102); VALUES ('raw_dir_dvd', 'Pfade', 'Raw-Ordner (DVD)', 'path', 0, 'RAW-Zielpfad für DVD. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 102);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd', NULL); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd', NULL);
@@ -320,6 +332,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
VALUES ('movie_dir_bluray', 'Pfade', 'Film-Ordner (Blu-ray)', 'path', 0, 'Encode-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 111); VALUES ('movie_dir_bluray', 'Pfade', 'Film-Ordner (Blu-ray)', 'path', 0, 'Encode-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 111);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray', NULL); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray', NULL);
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);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_bluray', NULL);
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 ('movie_dir_dvd', 'Pfade', 'Film-Ordner (DVD)', 'path', 0, 'Encode-Zielpfad für DVD. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 112); VALUES ('movie_dir_dvd', 'Pfade', 'Film-Ordner (DVD)', 'path', 0, 'Encode-Zielpfad für DVD. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 112);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd', NULL); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd', NULL);
@@ -428,6 +444,14 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
VALUES ('output_template_bluray', 'Pfade', 'Output Template (Blu-ray)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 335); VALUES ('output_template_bluray', 'Pfade', 'Output Template (Blu-ray)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 335);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray', '${title} (${year})/${title} (${year})'); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray', '${title} (${year})/${title} (${year})');
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);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_bluray_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}');
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);
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})');
-- Tools DVD -- Tools DVD
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 ('mediainfo_extra_args_dvd', 'Tools', 'Mediainfo Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für mediainfo (DVD).', NULL, '[]', '{}', 500); VALUES ('mediainfo_extra_args_dvd', 'Tools', 'Mediainfo Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für mediainfo (DVD).', NULL, '[]', '{}', 500);
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.1-12", "version": "0.14.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.1-12", "version": "0.14.0",
"dependencies": { "dependencies": {
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.2", "primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.1-12", "version": "0.14.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+12 -2
View File
@@ -806,10 +806,20 @@ export const api = {
afterMutationInvalidate(['/history']); afterMutationInvalidate(['/history']);
return result; return result;
}, },
async deleteJobFiles(jobId, target = 'both') { async deleteJobFiles(jobId, target = 'both', options = {}) {
const includeRelated = Boolean(options?.includeRelated);
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
? options.selectedMoviePaths
.map((item) => String(item || '').trim())
.filter(Boolean)
: null;
const result = await request(`/history/${jobId}/delete-files`, { const result = await request(`/history/${jobId}/delete-files`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ target }) body: JSON.stringify({
target,
includeRelated,
...(selectedMoviePaths ? { selectedMoviePaths } : {})
})
}); });
afterMutationInvalidate(['/history']); afterMutationInvalidate(['/history']);
return result; return result;
@@ -501,7 +501,15 @@ function buildToolSections(settings) {
} }
// Path keys per medium _owner keys are rendered inline // Path keys per medium _owner keys are rendered inline
const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray']; const BLURAY_PATH_KEYS = [
'raw_dir_bluray',
'raw_dir_bluray_series',
'movie_dir_bluray',
'series_dir_bluray',
'output_template_bluray',
'output_template_bluray_series_episode',
'output_template_bluray_series_multi_episode'
];
const DVD_PATH_KEYS = [ const DVD_PATH_KEYS = [
'raw_dir_dvd', 'raw_dir_dvd',
'raw_dir_dvd_series', 'raw_dir_dvd_series',
@@ -816,7 +824,9 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
const ep = effectivePaths || {}; const ep = effectivePaths || {};
const blurayRaw = ep.bluray?.raw || defaultRaw; const blurayRaw = ep.bluray?.raw || defaultRaw;
const bluraySeriesRaw = ep.bluray?.seriesRaw || blurayRaw;
const blurayMovies = ep.bluray?.movies || defaultMovies; const blurayMovies = ep.bluray?.movies || defaultMovies;
const bluraySeries = ep.bluray?.series || defaultSeries;
const dvdRaw = ep.dvd?.raw || defaultRaw; const dvdRaw = ep.dvd?.raw || defaultRaw;
const dvdSeriesRaw = ep.dvd?.seriesRaw || dvdRaw; const dvdSeriesRaw = ep.dvd?.seriesRaw || dvdRaw;
const dvdMovies = ep.dvd?.movies || defaultMovies; const dvdMovies = ep.dvd?.movies || defaultMovies;
@@ -860,6 +870,17 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
{isDefault(blurayMovies, defaultMovies) && <span className="path-default-badge">Standard</span>} {isDefault(blurayMovies, defaultMovies) && <span className="path-default-badge">Standard</span>}
</td> </td>
</tr> </tr>
<tr>
<td><strong>Blu-ray Serie</strong></td>
<td>
<code>{bluraySeriesRaw}</code>
{isDefault(bluraySeriesRaw, blurayRaw) && <span className="path-default-badge">wie Blu-ray RAW</span>}
</td>
<td>
<code>{bluraySeries}</code>
{isDefault(bluraySeries, defaultSeries) && <span className="path-default-badge">Standard</span>}
</td>
</tr>
<tr> <tr>
<td><strong>DVD</strong></td> <td><strong>DVD</strong></td>
<td> <td>
+7 -6
View File
@@ -6,7 +6,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 { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation'; import { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation';
import { isSeriesDvdJob } from '../utils/jobTaxonomy'; import { isSeriesVideoJob } from '../utils/jobTaxonomy';
const CD_FORMAT_LABELS = { const CD_FORMAT_LABELS = {
flac: 'FLAC', flac: 'FLAC',
@@ -136,6 +136,7 @@ function shellQuote(value) {
function trackLang(value) { function trackLang(value) {
const raw = String(value || '').trim().toLowerCase(); const raw = String(value || '').trim().toLowerCase();
if (!raw) return 'und'; if (!raw) return 'und';
if (raw === 'und' || raw === 'unknown' || raw === 'un') return 'und';
const map = { en: 'en', eng: 'en', de: 'de', deu: 'de', ger: 'de', tr: 'tr', tur: 'tr', fr: 'fr', fra: 'fr', fre: 'fr', es: 'es', spa: 'es', it: 'it', ita: 'it' }; const map = { en: 'en', eng: 'en', de: 'de', deu: 'de', ger: 'de', tr: 'tr', tur: 'tr', fr: 'fr', fra: 'fr', fre: 'fr', es: 'es', spa: 'es', it: 'it', ita: 'it' };
if (map[raw]) return map[raw]; if (map[raw]) return map[raw];
return raw.length >= 2 ? raw.slice(0, 2) : raw; return raw.length >= 2 ? raw.slice(0, 2) : raw;
@@ -964,7 +965,7 @@ export default function JobDetailDialog({
const showCancelAction = (running || softCancelable) && typeof onCancel === 'function'; const showCancelAction = (running || softCancelable) && typeof onCancel === 'function';
const showFinalLog = !running; const showFinalLog = !running;
const mediaType = resolveMediaType(job); const mediaType = resolveMediaType(job);
const isDvdSeries = mediaType === 'dvd' && isSeriesDvdJob(job); const isDvdSeries = (mediaType === 'dvd' || mediaType === 'bluray') && isSeriesVideoJob(job);
const seriesBatchEpisodes = isDvdSeries ? mergeSeriesBatchEpisodes(job) : []; const seriesBatchEpisodes = isDvdSeries ? mergeSeriesBatchEpisodes(job) : [];
const seriesEpisodeAssignments = isDvdSeries && job?.encodePlan?.episodeAssignments && typeof job.encodePlan.episodeAssignments === 'object' const seriesEpisodeAssignments = isDvdSeries && job?.encodePlan?.episodeAssignments && typeof job.encodePlan.episodeAssignments === 'object'
? job.encodePlan.episodeAssignments ? job.encodePlan.episodeAssignments
@@ -1151,7 +1152,7 @@ export default function JobDetailDialog({
const audiobookDetails = isAudiobook ? resolveAudiobookDetails(job) : null; const audiobookDetails = isAudiobook ? resolveAudiobookDetails(job) : null;
const canRetry = isCd && !running && typeof onRetry === 'function'; const canRetry = isCd && !running && typeof onRetry === 'function';
const mediaTypeLabel = mediaType === 'bluray' const mediaTypeLabel = mediaType === 'bluray'
? 'Blu-ray' ? (isDvdSeries ? 'Blu-ray Serie' : 'Blu-ray')
: mediaType === 'dvd' : mediaType === 'dvd'
? (isDvdSeries ? 'DVD Serie' : 'DVD') ? (isDvdSeries ? 'DVD Serie' : 'DVD')
: isCd : isCd
@@ -2323,7 +2324,7 @@ export default function JobDetailDialog({
<div className="actions-group-label"><i className="pi pi-trash" /> Dateien löschen</div> <div className="actions-group-label"><i className="pi pi-trash" /> Dateien löschen</div>
<div className="action-item"> <div className="action-item">
<Button <Button
label="RAW löschen" label="RAW löschen (Eintrag bleibt)"
icon="pi pi-trash" icon="pi pi-trash"
severity="warning" severity="warning"
outlined outlined
@@ -2336,7 +2337,7 @@ export default function JobDetailDialog({
</div> </div>
<div className="action-item"> <div className="action-item">
<Button <Button
label={isCd ? 'Audio löschen' : (isAudiobook ? 'Ausgabe löschen' : (isConverter ? 'Output löschen' : 'Movie löschen'))} label={isCd ? 'Audio löschen (Eintrag bleibt)' : (isAudiobook ? 'Ausgabe löschen (Eintrag bleibt)' : (isConverter ? 'Output löschen (Eintrag bleibt)' : 'Movie löschen (Eintrag bleibt)'))}
icon="pi pi-trash" icon="pi pi-trash"
severity="warning" severity="warning"
outlined outlined
@@ -2349,7 +2350,7 @@ export default function JobDetailDialog({
</div> </div>
<div className="action-item"> <div className="action-item">
<Button <Button
label="Beides löschen" label="Beides löschen (Eintrag bleibt)"
icon="pi pi-times" icon="pi pi-times"
severity="danger" severity="danger"
size="small" size="small"
@@ -685,6 +685,9 @@ function toLang2(value) {
if (!raw) { if (!raw) {
return 'und'; return 'und';
} }
if (raw === 'und' || raw === 'unknown' || raw === 'un') {
return 'und';
}
const map = { const map = {
en: 'en', en: 'en',
eng: 'en', eng: 'en',
@@ -2185,10 +2188,9 @@ export default function MediaInfoReviewPanel({
disabled={!allowTitleSelection} disabled={!allowTitleSelection}
style={{ width: '100%' }} style={{ width: '100%' }}
filter filter
showClear
/> />
<small> <small>
Startet bei der gewählten Episode und befüllt alle ausgewählten DVD-Titel der Reihe nach. Startet bei der gewählten Episode und befüllt alle ausgewählten Episoden-Titel der Reihe nach.
Multi-Episoden werden als Bereich (z.B. E16-17 | Teil 1+2) angezeigt. Multi-Episoden werden als Bereich (z.B. E16-17 | Teil 1+2) angezeigt.
</small> </small>
</div> </div>
+221 -42
View File
@@ -486,6 +486,34 @@ function normalizePositiveInt(value) {
return Math.trunc(parsed); return Math.trunc(parsed);
} }
function hasSeriesMetadataShape(source = null) {
const metadata = source && typeof source === 'object' ? source : {};
const seasonNumber = normalizePositiveInt(metadata?.seasonNumber ?? metadata?.season ?? null);
const episodes = Array.isArray(metadata?.episodes) ? metadata.episodes : [];
const metadataProvider = String(metadata?.metadataProvider || '').trim().toLowerCase();
const workflowKind = String(metadata?.workflowKind || metadata?.kind || '').trim().toLowerCase();
return Boolean(
seasonNumber
|| episodes.length > 0
|| metadataProvider === 'tmdb'
|| metadataProvider === 'themoviedb'
|| workflowKind === 'series'
|| workflowKind === 'season'
);
}
function hasSeriesEpisodeAssignmentHints(titles = []) {
const reviewTitles = Array.isArray(titles) ? titles : [];
return reviewTitles.some((title) => {
const seasonNumber = normalizePositiveInt(title?.seasonNumber ?? title?.season ?? null);
const episodeNumber = normalizePositiveInt(title?.episodeNumber ?? title?.number ?? null);
const episodeId = normalizePositiveInt(title?.episodeId ?? null);
const episodeTitle = String(title?.episodeTitle || '').trim();
const episodeTitleStart = String(title?.episodeTitleStart || '').trim();
return Boolean(seasonNumber || episodeNumber || episodeId || episodeTitle || episodeTitleStart);
});
}
function normalizeEpisodeReference(entry = {}) { function normalizeEpisodeReference(entry = {}) {
const source = entry && typeof entry === 'object' ? entry : {}; const source = entry && typeof entry === 'object' ? entry : {};
const episodeId = normalizePositiveInt(source?.episodeId ?? source?.id ?? null); const episodeId = normalizePositiveInt(source?.episodeId ?? source?.id ?? null);
@@ -605,6 +633,20 @@ function normalizeSeriesLanguage(value) {
return raw; return raw;
} }
function normalizeSeriesTitleForOutputPath(value, fallback = 'series') {
const raw = String(value || '').trim();
if (!raw) {
return String(fallback || 'series').trim() || 'series';
}
const stripped = raw
.replace(/\s*-\s*S\d{1,2}E\d{1,3}(?:-\d{1,3})?\b.*$/i, '')
.trim();
if (stripped) {
return stripped;
}
return raw;
}
function normalizeSeriesBatchStatus(value) { function normalizeSeriesBatchStatus(value) {
return String(value || '').trim().toUpperCase() || 'UNKNOWN'; return String(value || '').trim().toUpperCase() || 'UNKNOWN';
} }
@@ -1061,6 +1103,7 @@ function buildFallbackEpisodeTitleForOutput(episodeRange = null, template = '')
function buildDvdSeriesEpisodeOutputPathPreview( function buildDvdSeriesEpisodeOutputPathPreview(
settings, settings,
mediaProfile,
metadata, metadata,
title, title,
assignment = null, assignment = null,
@@ -1069,9 +1112,13 @@ function buildDvdSeriesEpisodeOutputPathPreview(
assignmentByTitle = {}, assignmentByTitle = {},
selectedTitleIds = [] selectedTitleIds = []
) { ) {
const normalizedSeriesMediaProfile = String(mediaProfile || '').trim().toLowerCase() === 'bluray'
? 'bluray'
: 'dvd';
const seriesRootDir = String( const seriesRootDir = String(
settings?.series_dir_dvd (normalizedSeriesMediaProfile === 'bluray'
|| settings?.movie_dir_dvd ? (settings?.series_dir_bluray || settings?.series_dir_dvd || settings?.movie_dir_bluray || settings?.movie_dir_dvd)
: (settings?.series_dir_dvd || settings?.series_dir_bluray || settings?.movie_dir_dvd || settings?.movie_dir_bluray))
|| settings?.movie_dir || settings?.movie_dir
|| '' || ''
).trim(); ).trim();
@@ -1105,21 +1152,29 @@ function buildDvdSeriesEpisodeOutputPathPreview(
const episodeRangeToken = buildEpisodeRangeToken(effectiveEpisodeRange.start, effectiveEpisodeRange.end); const episodeRangeToken = buildEpisodeRangeToken(effectiveEpisodeRange.start, effectiveEpisodeRange.end);
const partsToken = buildEpisodePartsToken(effectiveEpisodeRange); const partsToken = buildEpisodePartsToken(effectiveEpisodeRange);
const discNumber = normalizePositiveInt(metadata?.discNumber ?? assignment?.discNumber ?? null); const discNumber = normalizePositiveInt(metadata?.discNumber ?? assignment?.discNumber ?? null);
const seriesTitleRaw = String( const seriesTitle = normalizeSeriesTitleForOutputPath(
metadata?.title metadata?.seriesTitle
|| metadata?.seriesTitle || metadata?.title
|| (fallbackJobId ? `job-${fallbackJobId}` : 'series') || (fallbackJobId ? `job-${fallbackJobId}` : 'series'),
).trim(); fallbackJobId ? `job-${fallbackJobId}` : 'series'
const seriesTitle = seriesTitleRaw || 'series'; );
const year = String(metadata?.year || new Date().getFullYear()).trim(); const year = String(metadata?.year || new Date().getFullYear()).trim();
const language = normalizeSeriesLanguage(settings?.dvd_series_language || metadata?.language || null); const language = normalizeSeriesLanguage(settings?.dvd_series_language || metadata?.language || null);
const singleTemplateRaw = String( const singleTemplateRaw = String(
settings?.output_template_dvd_series_episode (normalizedSeriesMediaProfile === 'bluray'
? settings?.output_template_bluray_series_episode
: settings?.output_template_dvd_series_episode)
|| settings?.output_template_dvd_series_episode
|| settings?.output_template_bluray_series_episode
|| '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}' || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'
).trim(); ).trim();
const multiTemplateRaw = String( const multiTemplateRaw = String(
settings?.output_template_dvd_series_multi_episode (normalizedSeriesMediaProfile === 'bluray'
? settings?.output_template_bluray_series_multi_episode
: settings?.output_template_dvd_series_multi_episode)
|| settings?.output_template_dvd_series_multi_episode
|| settings?.output_template_bluray_series_multi_episode
|| '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})' || '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'
).trim(); ).trim();
const singleTemplate = singleTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'; const singleTemplate = singleTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}';
@@ -1160,7 +1215,15 @@ function buildDvdSeriesEpisodeOutputPathPreview(
.filter(Boolean); .filter(Boolean);
const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled'; const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled';
const folderParts = segments.slice(0, -1); const folderParts = segments.slice(0, -1);
const ext = String(settings?.output_extension_dvd || settings?.output_extension || 'mkv').trim() || 'mkv'; const ext = String(
(normalizedSeriesMediaProfile === 'bluray'
? settings?.output_extension_bluray
: settings?.output_extension_dvd)
|| settings?.output_extension_dvd
|| settings?.output_extension_bluray
|| settings?.output_extension
|| 'mkv'
).trim() || 'mkv';
const root = seriesRootDir.replace(/\/+$/g, ''); const root = seriesRootDir.replace(/\/+$/g, '');
if (folderParts.length > 0) { if (folderParts.length > 0) {
return `${root}/${folderParts.join('/')}/${baseName}.${ext}`; return `${root}/${folderParts.join('/')}/${baseName}.${ext}`;
@@ -1304,8 +1367,9 @@ export default function PipelineStatusCard({
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase(); const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip); const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview); const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
const isDvdTitleSelectionRequired = Boolean( const discTypeLabel = jobMediaProfile === 'bluray' ? 'Blu-ray' : 'DVD';
jobMediaProfile === 'dvd' const isDiscTitleSelectionRequired = Boolean(
(jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray')
&& (mediaInfoReview?.titleSelectionRequired || mediaInfoReview?.handBrakeTitleDecisionRequired) && (mediaInfoReview?.titleSelectionRequired || mediaInfoReview?.handBrakeTitleDecisionRequired)
); );
const [liveJobLog, setLiveJobLog] = useState(''); const [liveJobLog, setLiveJobLog] = useState('');
@@ -1467,8 +1531,21 @@ export default function PipelineStatusCard({
...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id), ...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id),
...(fromReview ? [fromReview] : []) ...(fromReview ? [fromReview] : [])
]); ]);
const isSeriesVideoByMetadata = Boolean(
(jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray')
&& (
hasSeriesMetadataShape(selectedMetadata)
|| hasSeriesMetadataShape(mediaInfoReview?.selectedMetadata)
|| hasSeriesEpisodeAssignmentHints(reviewTitles)
|| Boolean(mediaInfoReview?.seriesBatchParent)
)
);
const defaultAllTitles = Boolean( const defaultAllTitles = Boolean(
(mediaInfoReview?.titleSelectionRequired || mediaInfoReview?.handBrakeTitleDecisionRequired) (
mediaInfoReview?.titleSelectionRequired
|| mediaInfoReview?.handBrakeTitleDecisionRequired
|| (isSeriesVideoByMetadata && reviewTitles.length > 1)
)
&& reviewTitles.length > 0 && reviewTitles.length > 0
); );
const effectiveSelected = defaultAllTitles const effectiveSelected = defaultAllTitles
@@ -1537,6 +1614,10 @@ export default function PipelineStatusCard({
mediaInfoReview?.outputFormat, mediaInfoReview?.outputFormat,
mediaInfoReview?.userPreset?.id, mediaInfoReview?.userPreset?.id,
mediaInfoReview?.userPreset?.handbrakePreset, mediaInfoReview?.userPreset?.handbrakePreset,
selectedMetadata?.seasonNumber,
selectedMetadata?.episodes,
selectedMetadata?.metadataProvider,
jobMediaProfile,
retryJobId retryJobId
]); ]);
@@ -1877,16 +1958,6 @@ export default function PipelineStatusCard({
if (!Array.isArray(selectedMetadataEpisodes) || selectedMetadataEpisodes.length === 0) { if (!Array.isArray(selectedMetadataEpisodes) || selectedMetadataEpisodes.length === 0) {
return; return;
} }
const hasAnyEpisodeAssignment = Object.values(episodeAssignmentsByTitle || {}).some((assignment) => {
const episodeId = Number(assignment?.episodeId || 0);
const episodeNumber = Number(assignment?.episodeNumber || 0);
const seasonNumber = Number(assignment?.seasonNumber || 0);
const episodeTitle = String(assignment?.episodeTitle || '').trim();
return Boolean(episodeTitle || episodeId > 0 || episodeNumber > 0 || seasonNumber > 0);
});
if (hasAnyEpisodeAssignment) {
return;
}
if (!Array.isArray(episodeFillOptions) || episodeFillOptions.length === 0) { if (!Array.isArray(episodeFillOptions) || episodeFillOptions.length === 0) {
return; return;
} }
@@ -1903,11 +1974,64 @@ export default function PipelineStatusCard({
} }
if (!hasValidSelection) { if (!hasValidSelection) {
setSelectedEpisodeFillStart(String(defaultStartRef)); setSelectedEpisodeFillStart(String(defaultStartRef));
// Keep dropdown + episodentitel in sync when options change (e.g. Disc 2
// after already used episodes were loaded from the container).
applyEpisodeFillFrom(defaultStartRef);
return;
}
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
const fallbackSelectedIds = normalizeTitleIdList([
...selectedEncodeTitleIds,
selectedEncodeTitleId,
mediaInfoReview?.encodeInputTitleId,
...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
]);
const selectedTitleIdsInReviewOrder = reviewTitles
.map((title) => normalizeTitleId(title?.id))
.filter((id) => id !== null && fallbackSelectedIds.includes(id));
const effectiveSelectedTitleIds = selectedTitleIdsInReviewOrder.length > 0
? selectedTitleIdsInReviewOrder
: fallbackSelectedIds;
const hasEpisodeAssignmentPayload = (assignment) => {
if (!assignment || typeof assignment !== 'object') {
return false;
}
const episodeId = Number(assignment?.episodeId || assignment?.episodeIdStart || 0);
const episodeNumber = Number(assignment?.episodeNumber || assignment?.episodeNumberStart || 0);
const seasonNumber = Number(assignment?.seasonNumber || 0);
const episodeTitle = String(assignment?.episodeTitle || '').trim();
return Boolean(episodeTitle || episodeId > 0 || episodeNumber > 0 || seasonNumber > 0);
};
const hasAnyEpisodeAssignment = effectiveSelectedTitleIds.some((titleId) => {
const assignment = episodeAssignmentsByTitle?.[titleId]
|| episodeAssignmentsByTitle?.[String(titleId)]
|| null;
return hasEpisodeAssignmentPayload(assignment);
});
const hasCompleteEpisodeAssignments = effectiveSelectedTitleIds.length > 0
&& effectiveSelectedTitleIds.every((titleId) => {
const assignment = episodeAssignmentsByTitle?.[titleId]
|| episodeAssignmentsByTitle?.[String(titleId)]
|| null;
return hasEpisodeAssignmentPayload(assignment);
});
if (hasCompleteEpisodeAssignments) {
return;
}
if (hasAnyEpisodeAssignment && normalizedSelection) {
return;
} }
applyEpisodeFillFrom(defaultStartRef); applyEpisodeFillFrom(defaultStartRef);
}, [ }, [
selectedMetadataEpisodes, selectedMetadataEpisodes,
selectedEncodeTitleIds, selectedEncodeTitleIds,
selectedEncodeTitleId,
mediaInfoReview?.titles,
mediaInfoReview?.encodeInputTitleId,
mediaInfoReview?.generatedAt, mediaInfoReview?.generatedAt,
episodeAssignmentsByTitle, episodeAssignmentsByTitle,
episodeFillOptions, episodeFillOptions,
@@ -2247,6 +2371,16 @@ export default function PipelineStatusCard({
pipeline?.context?.selectedPlaylist pipeline?.context?.selectedPlaylist
]); ]);
const seriesBatchRunningChildJobIdHint = useMemo(() => {
const seriesBatch = pipeline?.context?.seriesBatch;
if (!seriesBatch || typeof seriesBatch !== 'object') {
return null;
}
const children = Array.isArray(seriesBatch?.children) ? seriesBatch.children : [];
const runningChild = children.find((child) => normalizeSeriesBatchStatus(child?.status) === 'RUNNING') || null;
return normalizeJobId(runningChild?.jobId);
}, [pipeline?.context?.seriesBatch]);
useEffect(() => { useEffect(() => {
if (!running || !retryJobId) { if (!running || !retryJobId) {
setLiveJobLog(''); setLiveJobLog('');
@@ -2257,7 +2391,38 @@ export default function PipelineStatusCard({
try { try {
const response = await api.getJob(retryJobId, { includeLiveLog: true, lite: true }); const response = await api.getJob(retryJobId, { includeLiveLog: true, lite: true });
if (!cancelled) { if (!cancelled) {
setLiveJobLog(response?.job?.log || ''); const liveJob = response?.job && typeof response.job === 'object'
? response.job
: null;
const childRows = Array.isArray(liveJob?.children) ? liveJob.children : [];
const liveHintChildId = normalizeJobId(seriesBatchRunningChildJobIdHint);
const isSeriesBatchParentRun = Boolean(
liveHintChildId
|| liveJob?.encodePlan?.seriesBatchParent
|| String(liveJob?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'series_batch'
);
if (isSeriesBatchParentRun) {
const activeChildStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'RUNNING']);
let activeChild = liveHintChildId
? (childRows.find((child) => normalizeJobId(child?.id) === liveHintChildId) || null)
: null;
if (activeChild) {
const hintChildStatus = String(activeChild?.status || activeChild?.last_state || '').trim().toUpperCase();
if (!activeChildStates.has(hintChildStatus)) {
activeChild = null;
}
}
if (!activeChild) {
activeChild = childRows.find((child) => (
activeChildStates.has(String(child?.status || child?.last_state || '').trim().toUpperCase())
)) || null;
}
setLiveJobLog(String(activeChild?.log || '').trim());
return;
}
setLiveJobLog(String(liveJob?.log || '').trim());
} }
} catch (_err) { } catch (_err) {
// ignore transient polling errors // ignore transient polling errors
@@ -2269,22 +2434,34 @@ export default function PipelineStatusCard({
cancelled = true; cancelled = true;
clearInterval(interval); clearInterval(interval);
}; };
}, [running, retryJobId]); }, [running, retryJobId, seriesBatchRunningChildJobIdHint]);
const manualDecisionState = pipeline?.context?.manualDecisionState || null; const manualDecisionState = pipeline?.context?.manualDecisionState || null;
const rawDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && manualDecisionState === 'waiting_for_raw_decision'; const rawDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && manualDecisionState === 'waiting_for_raw_decision';
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && !handBrakeTitleDecisionRequired && !rawDecisionRequiredBeforeStart; const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && !handBrakeTitleDecisionRequired && !rawDecisionRequiredBeforeStart;
const isSeriesDvdReview = useMemo(() => { const isSeriesDvdReview = useMemo(() => {
if (jobMediaProfile !== 'dvd') { if (jobMediaProfile !== 'dvd' && jobMediaProfile !== 'bluray') {
return false; return false;
} }
const seasonNumberRaw = selectedMetadata?.seasonNumber ?? null; return Boolean(
const seasonNumber = Number(String(seasonNumberRaw ?? '').replace(',', '.')); hasSeriesMetadataShape(selectedMetadata)
const hasSeasonNumber = Number.isFinite(seasonNumber) && seasonNumber > 0; || hasSeriesMetadataShape(mediaInfoReview?.selectedMetadata)
const hasEpisodeList = Array.isArray(selectedMetadata?.episodes) && selectedMetadata.episodes.length > 0; || hasSeriesEpisodeAssignmentHints(mediaInfoReview?.titles)
const metadataProvider = String(selectedMetadata?.metadataProvider || '').trim().toLowerCase(); || Boolean(mediaInfoReview?.seriesBatchParent)
return hasSeasonNumber || hasEpisodeList || metadataProvider === 'tmdb' || metadataProvider === 'themoviedb'; );
}, [jobMediaProfile, selectedMetadata?.seasonNumber, selectedMetadata?.episodes, selectedMetadata?.metadataProvider]); }, [
jobMediaProfile,
selectedMetadata?.seasonNumber,
selectedMetadata?.episodes,
selectedMetadata?.metadataProvider,
selectedMetadata?.workflowKind,
mediaInfoReview?.selectedMetadata?.seasonNumber,
mediaInfoReview?.selectedMetadata?.episodes,
mediaInfoReview?.selectedMetadata?.metadataProvider,
mediaInfoReview?.selectedMetadata?.workflowKind,
mediaInfoReview?.titles,
mediaInfoReview?.seriesBatchParent
]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -2515,6 +2692,7 @@ export default function PipelineStatusCard({
|| null; || null;
const path = buildDvdSeriesEpisodeOutputPathPreview( const path = buildDvdSeriesEpisodeOutputPathPreview(
settingsMap, settingsMap,
jobMediaProfile,
selectedMetadata || {}, selectedMetadata || {},
title, title,
assignment, assignment,
@@ -2533,6 +2711,7 @@ export default function PipelineStatusCard({
mediaInfoReview?.titles, mediaInfoReview?.titles,
episodeAssignmentsByTitle, episodeAssignmentsByTitle,
settingsMap, settingsMap,
jobMediaProfile,
selectedMetadata, selectedMetadata,
retryJobId, retryJobId,
selectedEncodeTitleIds selectedEncodeTitleIds
@@ -3091,7 +3270,7 @@ export default function PipelineStatusCard({
{handBrakeTitleDecisionRequired && retryJobId && ( {handBrakeTitleDecisionRequired && retryJobId && (
<Button <Button
label={isSeriesPlayAllDecisionMode ? 'PlayAll/Doppelfolge bestätigen' : 'DVD Titel bestätigen'} label={isSeriesPlayAllDecisionMode ? 'PlayAll/Doppelfolge bestätigen' : `${discTypeLabel} Titel bestätigen`}
icon="pi pi-check" icon="pi pi-check"
severity="warning" severity="warning"
outlined outlined
@@ -3353,11 +3532,11 @@ export default function PipelineStatusCard({
{handBrakeTitleDecisionRequired && !queueLocked ? ( {handBrakeTitleDecisionRequired && !queueLocked ? (
<div className="playlist-decision-block"> <div className="playlist-decision-block">
<h3>{isSeriesPlayAllDecisionMode ? 'Serien-Grenzfall: PlayAll oder Doppelfolge?' : 'DVD Titel-Auswahl erforderlich'}</h3> <h3>{isSeriesPlayAllDecisionMode ? 'Serien-Grenzfall: PlayAll oder Doppelfolge?' : `${discTypeLabel} Titelauswahl erforderlich`}</h3>
<small> <small>
{isSeriesPlayAllDecisionMode {isSeriesPlayAllDecisionMode
? 'Min. ein Titel passt zeitlich zur Summe der kürzeren Episoden. Bitte durch Auswahl entscheiden, ob PlayAll Multi-Episode (nicht anhaken) oder Doppelfolge (anhaken).' ? 'Min. ein Titel passt zeitlich zur Summe der kürzeren Episoden. Bitte durch Auswahl entscheiden, ob PlayAll Multi-Episode (nicht anhaken) oder Doppelfolge (anhaken).'
: 'Mehrere DVD-Titel gefunden, die die Mindestlänge erfüllen. Bitte einen oder mehrere Episoden-Titel auswählen.'} : `Mehrere ${discTypeLabel}-Titel gefunden, die die Mindestlänge erfüllen. Bitte einen oder mehrere Episodentitel auswählen.`}
</small> </small>
{visibleWaitingHandBrakeTitleRows.length > 0 ? ( {visibleWaitingHandBrakeTitleRows.length > 0 ? (
<div className="playlist-decision-list"> <div className="playlist-decision-list">
@@ -3415,7 +3594,7 @@ export default function PipelineStatusCard({
</div> </div>
) : null} ) : null}
{selectedMetadata && !isDvdTitleSelectionRequired ? ( {selectedMetadata && !isDiscTitleSelectionRequired ? (
<div className="pipeline-meta-inline"> <div className="pipeline-meta-inline">
{selectedMetadata.poster ? ( {selectedMetadata.poster ? (
<img <img
@@ -3445,8 +3624,8 @@ export default function PipelineStatusCard({
{(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? ( {(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? (
<div className="mediainfo-review-block"> <div className="mediainfo-review-block">
{!isDvdTitleSelectionRequired ? <h3>Titel-/Spurprüfung</h3> : null} {!isDiscTitleSelectionRequired ? <h3>Titel-/Spurprüfung</h3> : null}
{state === 'READY_TO_ENCODE' && !queueLocked && !isDvdTitleSelectionRequired ? ( {state === 'READY_TO_ENCODE' && !queueLocked && !isDiscTitleSelectionRequired ? (
<small> <small>
{isPreRipReview {isPreRipReview
? 'Spurauswahl kann direkt übernommen werden. Beim Klick auf "Backup + Encoding starten" wird automatisch bestätigt und gestartet.' ? 'Spurauswahl kann direkt übernommen werden. Beim Klick auf "Backup + Encoding starten" wird automatisch bestätigt und gestartet.'
@@ -3543,7 +3722,7 @@ export default function PipelineStatusCard({
<div className="playlist-decision-block"> <div className="playlist-decision-block">
<h3>Hinweis Episoden-Zuordnung</h3> <h3>Hinweis Episoden-Zuordnung</h3>
<small> <small>
Im Job wurden Hinweise zur Episoden-Zuordnung erkannt. Bitte Episodentitel und Episodenbereich pruefen, bevor das Encoding startet. Im Job wurden Hinweise zur Episoden-Zuordnung erkannt. Bitte Episodentitel und Episodenbereich prüfen, bevor das Encoding startet.
</small> </small>
{episodeAssignmentWarningLines.map((line, index) => ( {episodeAssignmentWarningLines.map((line, index) => (
<small key={`episode-assignment-warning-${index}`}>{line}</small> <small key={`episode-assignment-warning-${index}`}>{line}</small>
@@ -3558,7 +3737,7 @@ export default function PipelineStatusCard({
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)} selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)} selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
allowTitleSelection={(state === 'READY_TO_ENCODE' || state === 'WAITING_FOR_USER_DECISION') && !queueLocked} allowTitleSelection={(state === 'READY_TO_ENCODE' || state === 'WAITING_FOR_USER_DECISION') && !queueLocked}
compactTitleSelection={isDvdTitleSelectionRequired} compactTitleSelection={isDiscTitleSelectionRequired}
onSelectEncodeTitle={(titleId) => { onSelectEncodeTitle={(titleId) => {
const normalizedTitleId = normalizeTitleId(titleId); const normalizedTitleId = normalizeTitleId(titleId);
setSelectedEncodeTitleId(normalizedTitleId); setSelectedEncodeTitleId(normalizedTitleId);
+43 -3
View File
@@ -32,17 +32,31 @@ export default function DatabasePage() {
const [orphanLoading, setOrphanLoading] = useState(false); const [orphanLoading, setOrphanLoading] = useState(false);
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null); const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
const toastRef = useRef(null); const toastRef = useRef(null);
const orphanLoadRequestRef = useRef(0);
const loadOrphans = async () => { const loadOrphans = async (options = {}) => {
const requestId = orphanLoadRequestRef.current + 1;
orphanLoadRequestRef.current = requestId;
const silent = Boolean(options?.silent);
if (!silent) {
setOrphanLoading(true); setOrphanLoading(true);
}
try { try {
const response = await api.getOrphanRawFolders(); const response = await api.getOrphanRawFolders();
setOrphanRows(response.rows || []); if (orphanLoadRequestRef.current !== requestId) {
return;
}
setOrphanRows(Array.isArray(response?.rows) ? response.rows : []);
} catch (error) { } catch (error) {
if (orphanLoadRequestRef.current !== requestId) {
return;
}
toastRef.current?.show({ severity: 'error', summary: 'RAW-Prüfung fehlgeschlagen', detail: error.message }); toastRef.current?.show({ severity: 'error', summary: 'RAW-Prüfung fehlgeschlagen', detail: error.message });
} finally { } finally {
if (orphanLoadRequestRef.current === requestId && !silent) {
setOrphanLoading(false); setOrphanLoading(false);
} }
}
}; };
useEffect(() => { useEffect(() => {
@@ -66,6 +80,14 @@ export default function DatabasePage() {
const response = await api.importOrphanRawFolder(row.rawPath); const response = await api.importOrphanRawFolder(row.rawPath);
const newJobId = response?.job?.id; const newJobId = response?.job?.id;
const activationStarted = Boolean(response?.activation?.started); const activationStarted = Boolean(response?.activation?.started);
const activationError = String(response?.activationError || '').trim();
const importedRawPath = String(row?.rawPath || '').trim();
if (importedRawPath) {
// Immediate UX feedback: imported orphan should disappear without a full page reload.
setOrphanRows((previousRows) => (
(Array.isArray(previousRows) ? previousRows : []).filter((entry) => String(entry?.rawPath || '').trim() !== importedRawPath)
));
}
toastRef.current?.show({ toastRef.current?.show({
severity: 'success', severity: 'success',
summary: activationStarted ? 'Analyse gestartet' : 'Job angelegt', summary: activationStarted ? 'Analyse gestartet' : 'Job angelegt',
@@ -76,9 +98,27 @@ export default function DatabasePage() {
: (newJobId ? `Historieneintrag #${newJobId} erstellt.` : 'Historieneintrag wurde erstellt.'), : (newJobId ? `Historieneintrag #${newJobId} erstellt.` : 'Historieneintrag wurde erstellt.'),
life: 3500 life: 3500
}); });
if (activationError) {
toastRef.current?.show({
severity: 'warn',
summary: 'Analyse-Start fehlgeschlagen',
detail: activationError,
life: 5200
});
}
// Some workflows update job linkage milliseconds later (e.g. follow-up analyze start).
// Recheck a few times in background to converge UI without manual refresh.
const refreshDelaysMs = [0, 600, 1800];
for (const delayMs of refreshDelaysMs) {
if (delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
await loadOrphans({ silent: true });
}
// Jump to the Ripper view immediately after creating a job from /database. // Jump to the Ripper view immediately after creating a job from /database.
navigate('/ripper'); navigate('/ripper');
await loadOrphans();
} catch (error) { } catch (error) {
toastRef.current?.show({ toastRef.current?.show({
severity: 'error', severity: 'error',
+28 -13
View File
@@ -23,11 +23,12 @@ import {
STATUS_FILTER_OPTIONS STATUS_FILTER_OPTIONS
} from '../utils/statusPresentation'; } from '../utils/statusPresentation';
import { confirmModal } from '../utils/confirmModal'; import { confirmModal } from '../utils/confirmModal';
import { isSeriesDvdJob } from '../utils/jobTaxonomy'; import { isSeriesVideoJob } from '../utils/jobTaxonomy';
const MEDIA_FILTER_OPTIONS = [ const MEDIA_FILTER_OPTIONS = [
{ label: 'Alle Medien', value: '' }, { label: 'Alle Medien', value: '' },
{ label: 'Blu-ray', value: 'bluray' }, { label: 'Blu-ray', value: 'bluray' },
{ label: 'Blu-ray Serie', value: 'bluray_series' },
{ label: 'DVD', value: 'dvd' }, { label: 'DVD', value: 'dvd' },
{ label: 'DVD Serie', value: 'dvd_series' }, { label: 'DVD Serie', value: 'dvd_series' },
{ label: 'Audio CD', value: 'cd' }, { label: 'Audio CD', value: 'cd' },
@@ -125,13 +126,13 @@ function resolveMediaType(row) {
function resolveMediaTypeMeta(row) { function resolveMediaTypeMeta(row) {
const mediaType = resolveMediaType(row); const mediaType = resolveMediaType(row);
const isSeriesDvd = mediaType === 'dvd' && isSeriesDvdJob(row); const isSeriesDvd = (mediaType === 'dvd' || mediaType === 'bluray') && isSeriesVideoJob(row);
if (mediaType === 'bluray') { if (mediaType === 'bluray') {
return { return {
mediaType, mediaType,
icon: blurayIndicatorIcon, icon: blurayIndicatorIcon,
label: 'Blu-ray', label: isSeriesDvd ? 'Blu-ray Serie' : 'Blu-ray',
alt: 'Blu-ray' alt: isSeriesDvd ? 'Blu-ray Serie' : 'Blu-ray'
}; };
} }
if (mediaType === 'dvd') { if (mediaType === 'dvd') {
@@ -495,8 +496,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
const visibleJobs = useMemo( const visibleJobs = useMemo(
() => (mediumFilter () => (mediumFilter
? preparedJobs.filter((job) => { ? preparedJobs.filter((job) => {
if (mediumFilter === 'bluray_series') {
return job.sortMediaType === 'bluray' && isSeriesVideoJob(job);
}
if (mediumFilter === 'dvd_series') { if (mediumFilter === 'dvd_series') {
return job.sortMediaType === 'dvd' && isSeriesDvdJob(job); return job.sortMediaType === 'dvd' && isSeriesVideoJob(job);
} }
return job.sortMediaType === mediumFilter; return job.sortMediaType === mediumFilter;
}) })
@@ -895,6 +899,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
// File deletion // File deletion
const handleDeleteFiles = async (row, target) => { const handleDeleteFiles = async (row, target) => {
const includeRelated = isSeriesVideoJob(row);
if (target === 'movie') { if (target === 'movie') {
const outputFolders = await loadOutputFoldersForJob(row); const outputFolders = await loadOutputFoldersForJob(row);
if (outputFolders.length > 1) { if (outputFolders.length > 1) {
@@ -907,9 +913,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
const outputShortLabel = getOutputShortLabelForRow(row); const outputShortLabel = getOutputShortLabelForRow(row);
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 = includeRelated
? '\nScope: kompletter Serien-Container (alle zugehörigen RAWs/Outputs).'
: '';
const confirmed = await confirmModal({ const confirmed = await confirmModal({
header: 'Dateien löschen', header: 'Dateien löschen',
message: `${label} für "${title}" wirklich löschen?`, message: `${label} für "${title}" wirklich löschen?${scopeSuffix}`,
acceptLabel: 'Löschen', acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen', rejectLabel: 'Abbrechen',
danger: true danger: true
@@ -920,7 +929,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
setActionBusy(true); setActionBusy(true);
try { try {
const response = await api.deleteJobFiles(row.id, target); const response = await api.deleteJobFiles(row.id, target, { includeRelated });
const summary = response.summary || {}; const summary = response.summary || {};
const rawSummary = summary.raw || {}; const rawSummary = summary.raw || {};
const movieSummary = summary.movie || {}; const movieSummary = summary.movie || {};
@@ -1120,7 +1129,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
const workflowKind = ['film', 'movie', 'feature'].includes(workflowKindRaw) const workflowKind = ['film', 'movie', 'feature'].includes(workflowKindRaw)
? 'film' ? 'film'
: (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null); : (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null);
const isSeriesDvd = resolveMediaType(row) === 'dvd' && isSeriesDvdJob(row); const rowMediaType = resolveMediaType(row);
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
const metadataProvider = workflowKind === 'series' const metadataProvider = workflowKind === 'series'
? 'tmdb' ? 'tmdb'
: (workflowKind === 'film' : (workflowKind === 'film'
@@ -1670,7 +1680,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
const selectedMetadata = row?.makemkvInfo?.selectedMetadata && typeof row.makemkvInfo.selectedMetadata === 'object' const selectedMetadata = row?.makemkvInfo?.selectedMetadata && typeof row.makemkvInfo.selectedMetadata === 'object'
? row.makemkvInfo.selectedMetadata ? row.makemkvInfo.selectedMetadata
: {}; : {};
const isSeriesDvd = resolveMediaType(row) === 'dvd' && isSeriesDvdJob(row); const rowMediaType = resolveMediaType(row);
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
const seasonLabel = isSeriesDvd && selectedMetadata?.seasonNumber const seasonLabel = isSeriesDvd && selectedMetadata?.seasonNumber
? `Staffel ${selectedMetadata.seasonNumber}` ? `Staffel ${selectedMetadata.seasonNumber}`
: null; : null;
@@ -1751,7 +1762,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
const selectedMetadata = row?.makemkvInfo?.selectedMetadata && typeof row.makemkvInfo.selectedMetadata === 'object' const selectedMetadata = row?.makemkvInfo?.selectedMetadata && typeof row.makemkvInfo.selectedMetadata === 'object'
? row.makemkvInfo.selectedMetadata ? row.makemkvInfo.selectedMetadata
: {}; : {};
const isSeriesDvd = resolveMediaType(row) === 'dvd' && isSeriesDvdJob(row); const rowMediaType = resolveMediaType(row);
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
const seasonLabel = isSeriesDvd && selectedMetadata?.seasonNumber const seasonLabel = isSeriesDvd && selectedMetadata?.seasonNumber
? `Staffel ${selectedMetadata.seasonNumber}` ? `Staffel ${selectedMetadata.seasonNumber}`
: null; : null;
@@ -1962,6 +1974,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
<p> <p>
{`Es werden ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge entfernt.`} {`Es werden ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge entfernt.`}
</p> </p>
<small className="history-dv-subtle">
Hinweis: In diesem Dialog löschen alle ersten drei Buttons immer den Historien-Eintrag plus ausgewählte Dateien.
</small>
{deleteEntryDialogRow ? ( {deleteEntryDialogRow ? (
<small className="muted-inline"> <small className="muted-inline">
@@ -2048,7 +2063,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
<div className="dialog-actions"> <div className="dialog-actions">
<Button <Button
label="Nur RAW löschen" label="Eintrag + nur RAW-Dateien"
icon="pi pi-trash" icon="pi pi-trash"
severity="warning" severity="warning"
outlined outlined
@@ -2057,7 +2072,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
disabled={deleteTargetActionsDisabled} disabled={deleteTargetActionsDisabled}
/> />
<Button <Button
label={`Nur ${deleteEntryOutputShortLabel} löschen`} label={`Eintrag + nur ${deleteEntryOutputShortLabel}`}
icon="pi pi-trash" icon="pi pi-trash"
severity="warning" severity="warning"
outlined outlined
@@ -2066,7 +2081,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired} disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
/> />
<Button <Button
label="Beides löschen" label={`Eintrag + RAW & ${deleteEntryOutputShortLabel}`}
icon="pi pi-times" icon="pi pi-times"
severity="danger" severity="danger"
onClick={() => confirmDeleteEntry('both')} onClick={() => confirmDeleteEntry('both')}
+84 -17
View File
@@ -21,7 +21,7 @@ import otherIndicatorIcon from '../assets/media-other.svg';
import audiobookIndicatorIcon from '../assets/media-audiobook.svg'; import audiobookIndicatorIcon from '../assets/media-audiobook.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, isSeriesDvdJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy'; import { isConverterJob, isSeriesVideoJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy';
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']; const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING']; const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
@@ -78,6 +78,32 @@ function isIncompleteRawPath(rawPath) {
return /^incomplete_/i.test(baseName); return /^incomplete_/i.test(baseName);
} }
function normalizePathForMatch(value) {
return String(value || '').trim().replace(/[\\]+/g, '/');
}
function isIncompleteOutputPath(outputPath) {
const normalized = normalizePathForMatch(outputPath);
if (!normalized) {
return false;
}
return /(^|\/)incomplete_job-\d+(\/|$)/i.test(normalized);
}
function getIncompleteOutputSelectionPaths(outputPath) {
const normalized = normalizePathForMatch(outputPath);
if (!normalized) {
return [];
}
const segments = normalized.split('/').filter(Boolean);
const incompleteIndex = segments.findIndex((segment) => /^incomplete_job-\d+$/i.test(segment));
if (incompleteIndex < 0) {
return [];
}
const folderPath = `/${segments.slice(0, incompleteIndex + 1).join('/')}`;
return Array.from(new Set([normalized, folderPath]));
}
function formatPercent(value, digits = 1) { function formatPercent(value, digits = 1) {
const parsed = Number(value); const parsed = Number(value);
if (!Number.isFinite(parsed)) { if (!Number.isFinite(parsed)) {
@@ -557,17 +583,25 @@ function resolveMediaType(job) {
function mediaIndicatorMeta(job) { function mediaIndicatorMeta(job) {
const mediaType = resolveMediaType(job); const mediaType = resolveMediaType(job);
const isSeriesDvd = mediaType === 'dvd' && isSeriesDvdJob(job); const isSeriesVideo = (mediaType === 'dvd' || mediaType === 'bluray') && isSeriesVideoJob(job);
if (mediaType === 'bluray') { if (mediaType === 'bluray') {
return { mediaType, src: blurayIndicatorIcon, alt: 'Blu-ray', title: 'Blu-ray' }; return {
mediaType,
src: blurayIndicatorIcon,
alt: isSeriesVideo ? 'Blu-ray Serie' : 'Blu-ray',
title: isSeriesVideo ? 'Blu-ray Serie' : 'Blu-ray',
isSeriesDvd: isSeriesVideo,
isSeriesVideo
};
} }
if (mediaType === 'dvd') { if (mediaType === 'dvd') {
return { return {
mediaType, mediaType,
src: discIndicatorIcon, src: discIndicatorIcon,
alt: isSeriesDvd ? 'DVD Serie' : 'DVD', alt: isSeriesVideo ? 'DVD Serie' : 'DVD',
title: isSeriesDvd ? 'DVD Serie' : 'DVD', title: isSeriesVideo ? 'DVD Serie' : 'DVD',
isSeriesDvd isSeriesDvd: isSeriesVideo,
isSeriesVideo
}; };
} }
if (mediaType === 'cd') { if (mediaType === 'cd') {
@@ -1669,6 +1703,9 @@ export default function RipperPage({
const jobId = normalizeJobId(cancelCleanupDialog?.jobId); const jobId = normalizeJobId(cancelCleanupDialog?.jobId);
const target = String(cancelCleanupDialog?.target || '').trim().toLowerCase(); const target = String(cancelCleanupDialog?.target || '').trim().toLowerCase();
const effectiveTarget = target === 'raw' ? 'raw' : 'movie'; const effectiveTarget = target === 'raw' ? 'raw' : 'movie';
const jobRow = (Array.isArray(ripperJobs) ? ripperJobs : [])
.find((row) => normalizeJobId(row?.id) === jobId) || null;
const includeRelated = isSeriesVideoJob(jobRow);
if (!jobId) { if (!jobId) {
setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null }); setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null });
return; return;
@@ -1676,7 +1713,7 @@ export default function RipperPage({
setCancelCleanupBusy(true); setCancelCleanupBusy(true);
try { try {
const response = await api.deleteJobFiles(jobId, effectiveTarget); const response = await api.deleteJobFiles(jobId, effectiveTarget, { includeRelated });
const summary = response?.summary || {}; const summary = response?.summary || {};
const targetSummary = effectiveTarget === 'raw' const targetSummary = effectiveTarget === 'raw'
? (summary.raw || {}) ? (summary.raw || {})
@@ -1956,20 +1993,48 @@ export default function RipperPage({
const isOrphanRawImportJob = orphanImportSource === 'orphan_raw_import'; const isOrphanRawImportJob = orphanImportSource === 'orphan_raw_import';
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);
const deleteTarget = deleteIncompleteRaw ? 'raw' : 'none'; let incompleteMovieSelectionPaths = [];
try {
const deletePreview = await api.getJobDeletePreview(normalizedJobId, { includeRelated: true });
const movieCandidates = Array.isArray(deletePreview?.preview?.pathCandidates?.movie)
? deletePreview.preview.pathCandidates.movie
: [];
incompleteMovieSelectionPaths = Array.from(new Set(
movieCandidates
.filter((candidate) => Boolean(candidate?.exists) && isIncompleteOutputPath(candidate?.path))
.map((candidate) => String(candidate?.path || '').trim())
.filter(Boolean)
));
} catch (_error) {
incompleteMovieSelectionPaths = [];
}
if (incompleteMovieSelectionPaths.length === 0) {
incompleteMovieSelectionPaths = getIncompleteOutputSelectionPaths(job?.output_path);
}
const deleteIncompleteMovie = incompleteMovieSelectionPaths.length > 0;
const deleteTarget = deleteIncompleteRaw && deleteIncompleteMovie
? 'both'
: (deleteIncompleteRaw
? 'raw'
: (deleteIncompleteMovie ? 'movie' : 'none'));
const resetDriveStateOnDelete = !isOrphanRawImportJob; const resetDriveStateOnDelete = !isOrphanRawImportJob;
const keepDetectedDeviceOnDelete = isOrphanRawImportJob; const keepDetectedDeviceOnDelete = isOrphanRawImportJob;
const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim(); const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim();
const deleteHint = deleteIncompleteRaw && deleteIncompleteMovie
? 'Hinweis: Unvollständiges RAW und temporärer Incomplete-Output werden gelöscht.'
: (deleteIncompleteRaw
? 'Hinweis: Unvollständiges RAW wird gelöscht. Für einen neuen Versuch bitte das Laufwerk erneut analysieren.'
: (deleteIncompleteMovie
? 'Hinweis: Temporärer Incomplete-Output wird beim Löschen mit entfernt.'
: (isOrphanRawImportJob
? 'Hinweis: Dieser /database-Job löscht beim Entfernen aus dem Ripper keine RAW-Dateien.'
: 'Hinweis: Dateien bleiben erhalten. Vollständiges RAW ist anschließend über /database wiederherstellbar.')));
const confirmed = await confirmModal({ const confirmed = await confirmModal({
header: 'Job löschen', header: 'Job löschen',
message: message:
`Job #${normalizedJobId} wirklich löschen?\n` + `Job #${normalizedJobId} wirklich löschen?\n` +
`${title}\n\n` + `${title}\n\n` +
(deleteIncompleteRaw deleteHint +
? 'Hinweis: Unvollständiges RAW wird gelöscht. Für einen neuen Versuch bitte das Laufwerk erneut analysieren.'
: (isOrphanRawImportJob
? 'Hinweis: Dieser /database-Job löscht beim Entfernen aus dem Ripper keine RAW-Dateien.'
: 'Hinweis: Dateien bleiben erhalten. Vollständiges RAW ist anschließend über /database wiederherstellbar.')) +
'\nZugehörige Einträge (z.B. Serien-Container oder Folgejobs) werden ebenfalls entfernt.' + '\nZugehörige Einträge (z.B. Serien-Container oder Folgejobs) werden ebenfalls entfernt.' +
(isOrphanRawImportJob (isOrphanRawImportJob
? '\nOrphan-Import-Job: Das zugeordnete Laufwerk bleibt unverändert.' ? '\nOrphan-Import-Job: Das zugeordnete Laufwerk bleibt unverändert.'
@@ -1999,6 +2064,7 @@ export default function RipperPage({
} }
await api.deleteJobEntry(normalizedJobId, deleteTarget, { await api.deleteJobEntry(normalizedJobId, deleteTarget, {
includeRelated: true, includeRelated: true,
...(deleteIncompleteMovie ? { selectedMoviePaths: incompleteMovieSelectionPaths } : {}),
resetDriveState: resetDriveStateOnDelete, resetDriveState: resetDriveStateOnDelete,
keepDetectedDevice: keepDetectedDeviceOnDelete, keepDetectedDevice: keepDetectedDeviceOnDelete,
preserveRawForImportJobs: true preserveRawForImportJobs: true
@@ -2291,14 +2357,14 @@ export default function RipperPage({
const handleMetadataSubmit = async (payload) => { const handleMetadataSubmit = async (payload) => {
const payloadProvider = String(payload?.metadataProvider || '').trim().toLowerCase(); const payloadProvider = String(payload?.metadataProvider || '').trim().toLowerCase();
const currentJobMediaProfile = String(effectiveMetadataDialogContext?.mediaProfile || '').trim().toLowerCase(); const currentJobMediaProfile = String(effectiveMetadataDialogContext?.mediaProfile || '').trim().toLowerCase();
const isSeriesDvdPayload = payloadProvider === 'tmdb' && currentJobMediaProfile === 'dvd'; const isSeriesDvdPayload = payloadProvider === 'tmdb' && (currentJobMediaProfile === 'dvd' || currentJobMediaProfile === 'bluray');
if (metadataDialogReassignMode) { if (metadataDialogReassignMode) {
await doSelectMetadata(payload); await doSelectMetadata(payload);
return; return;
} }
// Serien-DVDs laufen ohne Film-Duplikatdialog weiter; die Disk-Pruefung // Serien-Discs laufen ohne Film-Duplikatdialog weiter; die Disk-Pruefung
// erfolgt serverseitig gegen den vorhandenen Staffel-Container. // erfolgt serverseitig gegen den vorhandenen Staffel-Container.
if (isSeriesDvdPayload) { if (isSeriesDvdPayload) {
await doSelectMetadata(payload, { suppressErrorToast: true }); await doSelectMetadata(payload, { suppressErrorToast: true });
@@ -2921,6 +2987,7 @@ export default function RipperPage({
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline; const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`; const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
const mediaIndicator = mediaIndicatorMeta(job); const mediaIndicator = mediaIndicatorMeta(job);
const seriesTagLabel = mediaIndicator.mediaType === 'bluray' ? 'Blu-ray Serie' : 'DVD Serie';
const isResumable = ( const isResumable = (
normalizedStatus === 'READY_TO_ENCODE' normalizedStatus === 'READY_TO_ENCODE'
|| (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START') || (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START')
@@ -3041,7 +3108,7 @@ export default function RipperPage({
) : null} ) : null}
<div className="ripper-job-badges"> <div className="ripper-job-badges">
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} /> <Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{mediaIndicator.isSeriesDvd ? <Tag value="DVD Serie" severity="secondary" /> : null} {mediaIndicator.isSeriesDvd ? <Tag value={seriesTagLabel} 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'
@@ -3185,7 +3252,7 @@ export default function RipperPage({
</div> </div>
<div className="ripper-job-badges"> <div className="ripper-job-badges">
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} /> <Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{mediaIndicator.isSeriesDvd ? <Tag value="DVD Serie" severity="secondary" /> : null} {mediaIndicator.isSeriesDvd ? <Tag value={seriesTagLabel} 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'
+7 -2
View File
@@ -204,8 +204,9 @@ export function resolveMediaType(job) {
return 'other'; return 'other';
} }
export function isSeriesDvdJob(job) { export function isSeriesVideoJob(job) {
if (resolveMediaType(job) !== 'dvd') { const mediaType = resolveMediaType(job);
if (mediaType !== 'dvd' && mediaType !== 'bluray') {
return false; return false;
} }
@@ -265,6 +266,10 @@ export function isSeriesDvdJob(job) {
return (metadataProvider === 'tmdb' || metadataProvider === 'themoviedb') && hasTmdbId; return (metadataProvider === 'tmdb' || metadataProvider === 'themoviedb') && hasTmdbId;
} }
export function isSeriesDvdJob(job) {
return isSeriesVideoJob(job);
}
export function isConverterJob(job) { export function isConverterJob(job) {
return resolveMediaType(job) === 'converter'; return resolveMediaType(job) === 'converter';
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster", "name": "ripster",
"version": "0.13.1-12", "version": "0.14.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster", "name": "ripster",
"version": "0.13.1-12", "version": "0.14.0",
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ripster", "name": "ripster",
"private": true, "private": true,
"version": "0.13.1-12", "version": "0.14.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",