0.13.1-10 Misc fixes

This commit is contained in:
2026-04-17 08:04:52 +00:00
parent f25b6e1181
commit bebe9eac8c
19 changed files with 979 additions and 2155 deletions
+4 -4
View File
@@ -844,13 +844,13 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
{
key: 'output_template_dvd_series_episode',
required: 1,
description: '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}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.',
description: '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}.',
validation_json: '{"minLength":1}'
},
{
key: 'output_template_dvd_series_multi_episode',
required: 1,
description: 'Template für zusammengefasste DVD-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}. Alias: {seasonNo} entspricht {seasonNr}.',
description: 'Template für zusammengefasste DVD-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}'
}
];
@@ -1076,13 +1076,13 @@ async function migrateSettingsSchemaMetadata(db) {
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{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)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_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_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-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}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537)`
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-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}', 537)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})')`);
+40 -10
View File
@@ -174,10 +174,11 @@ router.post(
const id = Number(req.params.id);
const target = String(req.body?.target || 'none');
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
const resetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
const keepDetectedDevice = req.body?.keepDetectedDevice !== undefined
const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase());
const requestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined
? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase())
: !resetDriveState;
: !requestedResetDriveState;
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
? req.body.selectedMoviePaths
.map((item) => String(item || '').trim())
@@ -189,20 +190,41 @@ router.post(
id,
target,
includeRelated,
resetDriveState,
keepDetectedDevice,
requestedResetDriveState,
preserveRawForImportJobs,
requestedKeepDetectedDevice,
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
});
const preview = resetDriveState
? await historyService.getJobDeletePreview(id, { includeRelated })
: null;
const preview = await historyService.getJobDeletePreview(id, { includeRelated });
const containsOrphanRawImportJob = Boolean(
preview?.flags?.containsOrphanRawImportJob
|| (Array.isArray(preview?.relatedJobs) && preview.relatedJobs.some((row) => Boolean(row?.orphanRawImport)))
);
const resetDriveState = containsOrphanRawImportJob
? false
: requestedResetDriveState;
const keepDetectedDevice = containsOrphanRawImportJob
? true
: requestedKeepDetectedDevice;
if (containsOrphanRawImportJob && requestedResetDriveState) {
logger.info('post:delete-job:orphan-drive-reset-skipped', {
reqId: req.reqId,
id
});
}
const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
? preview.relatedJobs
.map((row) => String(row?.discDevice || '').trim())
.filter(Boolean)
: [];
const result = await historyService.deleteJob(id, target, { includeRelated, selectedMoviePaths });
const result = await historyService.deleteJob(id, target, {
includeRelated,
selectedMoviePaths,
preserveRawForImportJobs
});
await pipelineService.onJobsDeleted(result?.deletedJobIds || [id], {
resetDriveState,
devicePaths: relatedDevicePaths
@@ -212,7 +234,15 @@ router.post(
const uiReset = await pipelineService.resetFrontendState('history_delete', {
keepDetectedDevice
});
res.json({ ...result, uiReset });
res.json({
...result,
uiReset,
safeguards: {
containsOrphanRawImportJob,
resetDriveStateApplied: resetDriveState,
keepDetectedDeviceApplied: keepDetectedDevice
}
});
})
);
+82 -21
View File
@@ -133,6 +133,16 @@ function parseInfoFromValue(value, fallback = null) {
return parseJsonSafe(value, fallback);
}
function isOrphanRawImportMakemkvInfo(makemkvInfo = null) {
const info = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {};
const source = String(
info?.source
|| info?.importRecovery?.source
|| ''
).trim().toLowerCase();
return source === 'orphan_raw_import';
}
function hasBlurayStructure(rawPath) {
const basePath = String(rawPath || '').trim();
if (!basePath) {
@@ -5180,6 +5190,7 @@ class HistoryService {
? Math.trunc(manualEpisodeCountRaw)
: null;
const manualEpisodes = Array.isArray(payload.episodes) ? payload.episodes : null;
const hasExplicitManualEpisodes = Boolean(manualEpisodes && manualEpisodes.length > 0);
const manualMetadataKind = String(payload.metadataKind || '').trim().toLowerCase() || null;
const manualProviderId = String(payload.providerId || '').trim() || null;
@@ -5240,7 +5251,7 @@ class HistoryService {
let episodeCount = manualEpisodeCount !== null
? manualEpisodeCount
: (Number(existingSelectedMetadata?.episodeCount || 0) || 0);
let episodes = manualEpisodes && manualEpisodes.length > 0
let episodes = hasExplicitManualEpisodes
? manualEpisodes
: (Array.isArray(existingSelectedMetadata?.episodes) ? existingSelectedMetadata.episodes : []);
const discNumber = manualDiscNumber !== null
@@ -5280,6 +5291,13 @@ class HistoryService {
? `tmdb:${effectiveTmdbId}:season:${seasonNumber}`
: `tmdb:${effectiveTmdbId}`;
}
let tmdbLanguage = null;
try {
const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd');
tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null;
} catch (_settingsError) {
tmdbLanguage = null;
}
let tmdbDetails = existingSelectedMetadata?.tmdbDetails && typeof existingSelectedMetadata.tmdbDetails === 'object'
? { ...existingSelectedMetadata.tmdbDetails }
@@ -5287,6 +5305,7 @@ class HistoryService {
if (effectiveTmdbId !== null) {
try {
const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, {
language: tmdbLanguage,
appendToResponse: ['credits', 'external_ids']
});
if (seriesDetails) {
@@ -5325,12 +5344,16 @@ class HistoryService {
if (effectiveTmdbId !== null && seasonNumber !== null) {
try {
const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber);
const seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, seasonNumber);
const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber, {
language: tmdbLanguage
});
const seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, seasonNumber, {
language: tmdbLanguage
});
const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails);
if (seasonSummary) {
const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : [];
if (fetchedEpisodes.length > 0 && (!Array.isArray(episodes) || episodes.length === 0)) {
if (fetchedEpisodes.length > 0 && !hasExplicitManualEpisodes) {
episodes = fetchedEpisodes;
}
const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0;
@@ -6177,15 +6200,20 @@ class HistoryService {
lineageArtifactsByJobId,
outputFoldersByJobId
);
const relatedJobs = jobs.map((job) => ({
id: Number(job.id),
parentJobId: normalizeJobIdValue(job.parent_job_id),
title: buildJobDisplayTitle(job),
status: String(job.status || '').trim() || null,
discDevice: String(job.disc_device || '').trim() || null,
isPrimary: Number(job.id) === normalizedJobId,
createdAt: String(job.created_at || '').trim() || null
}));
const relatedJobs = jobs.map((job) => {
const mkInfo = parseJsonSafe(job?.makemkv_info_json, {});
return {
id: Number(job.id),
parentJobId: normalizeJobIdValue(job.parent_job_id),
title: buildJobDisplayTitle(job),
status: String(job.status || '').trim() || null,
discDevice: String(job.disc_device || '').trim() || null,
isPrimary: Number(job.id) === normalizedJobId,
createdAt: String(job.created_at || '').trim() || null,
orphanRawImport: isOrphanRawImportMakemkvInfo(mkInfo)
};
});
const orphanRawImportJobs = relatedJobs.filter((row) => Boolean(row?.orphanRawImport)).length;
const existingRawCandidates = preview.pathCandidates.raw.filter((row) => row.exists).length;
const existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length;
@@ -6195,8 +6223,12 @@ class HistoryService {
relatedJobs,
pathCandidates: preview.pathCandidates,
protectedRoots: preview.protectedRoots,
flags: {
containsOrphanRawImportJob: orphanRawImportJobs > 0
},
counts: {
relatedJobs: relatedJobs.length,
orphanRawImportJobs,
rawCandidates: preview.pathCandidates.raw.length,
movieCandidates: preview.pathCandidates.movie.length,
existingRawCandidates,
@@ -6887,6 +6919,28 @@ class HistoryService {
throw error;
}
const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs);
const isOrphanRawImportJobRow = (row) => {
if (!row || typeof row !== 'object') {
return false;
}
const mkInfo = parseJsonSafe(row?.makemkv_info_json, {});
return isOrphanRawImportMakemkvInfo(mkInfo);
};
const resolveEffectiveFileTarget = (requestedTarget, row) => {
if (!preserveRawForImportJobs || !isOrphanRawImportJobRow(row)) {
return requestedTarget;
}
const normalizedRequested = String(requestedTarget || 'none').trim().toLowerCase();
if (normalizedRequested === 'raw') {
return 'none';
}
if (normalizedRequested === 'both') {
return 'movie';
}
return normalizedRequested;
};
const includeRelated = Boolean(options?.includeRelated);
if (!includeRelated) {
const resolved = await this.getJobByIdOrReplacement(jobId);
@@ -6903,10 +6957,11 @@ class HistoryService {
throw error;
}
const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
let fileSummary = null;
if (fileTarget !== 'none') {
if (effectiveFileTarget !== 'none') {
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
fileSummary = this._deletePathsFromPreview(preview, fileTarget, {
fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
selectedMoviePaths: options?.selectedMoviePaths
});
}
@@ -6968,7 +7023,8 @@ class HistoryService {
logger.warn('job:deleted', {
jobId: normalizedJobId,
fileTarget,
fileTarget: effectiveFileTarget,
requestedFileTarget: fileTarget,
includeRelated: false,
pipelineStateReset: isActivePipelineJob,
filesDeleted: fileSummary
@@ -6982,7 +7038,8 @@ class HistoryService {
return {
deleted: true,
jobId: normalizedJobId,
fileTarget,
fileTarget: effectiveFileTarget,
requestedFileTarget: fileTarget,
includeRelated: false,
deletedJobIds: [Number(normalizedJobId)],
fileSummary
@@ -6990,6 +7047,7 @@ class HistoryService {
}
const resolved = await this.getJobByIdOrReplacement(jobId);
const existing = resolved?.job || null;
const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId);
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true });
const deleteJobIds = Array.isArray(preview?.relatedJobs)
@@ -7014,9 +7072,10 @@ class HistoryService {
throw error;
}
const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
let fileSummary = null;
if (fileTarget !== 'none') {
fileSummary = this._deletePathsFromPreview(preview, fileTarget, {
if (effectiveFileTarget !== 'none') {
fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
selectedMoviePaths: options?.selectedMoviePaths
});
}
@@ -7068,7 +7127,8 @@ class HistoryService {
logger.warn('job:deleted', {
jobId: normalizedJobId,
fileTarget,
fileTarget: effectiveFileTarget,
requestedFileTarget: fileTarget,
includeRelated: true,
deletedJobIds: deleteJobIds,
deletedJobCount: deleteJobIds.length,
@@ -7084,7 +7144,8 @@ class HistoryService {
return {
deleted: true,
jobId: normalizedJobId,
fileTarget,
fileTarget: effectiveFileTarget,
requestedFileTarget: fileTarget,
includeRelated: true,
deletedJobIds: deleteJobIds,
deletedJobs: preview.relatedJobs,
+492 -142
View File
@@ -94,11 +94,29 @@ const CONVERTER_SUPPORTED_SCAN_EXTENSIONS = Object.freeze([
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
]);
const CONVERTER_SUPPORTED_SCAN_EXTENSION_SET = new Set(CONVERTER_SUPPORTED_SCAN_EXTENSIONS);
const TMDB_SERIES_SEARCH_CACHE_TTL_MS = 2 * 60 * 1000;
const tmdbSeriesSearchCache = new Map();
function nowIso() {
return new Date().toISOString();
}
function cloneTmdbSeriesSearchResults(rows = []) {
if (!Array.isArray(rows)) {
return [];
}
return rows.map((row) => ({
...(row && typeof row === 'object' ? row : {}),
episodes: Array.isArray(row?.episodes)
? row.episodes.map((episode) => (
episode && typeof episode === 'object'
? { ...episode }
: episode
))
: []
}));
}
function normalizeCdTrackText(value) {
return String(value || '')
.normalize('NFC')
@@ -948,15 +966,16 @@ function formatTemplateTwoDigit(rawValue) {
return String(rawValue || '').trim() || String(numeric);
}
function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitle = null, fallbackEpisodeNumber = 1) {
function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitle = null, fallbackEpisodeNumber = 1, options = {}) {
const assignmentSource = assignment && typeof assignment === 'object' ? assignment : {};
const selectedTitleSource = selectedTitle && typeof selectedTitle === 'object' ? selectedTitle : {};
const allowSelectedTitleEpisodeNumber = options?.allowSelectedTitleEpisodeNumber !== false;
const episodeStart = normalizeTemplateNumberValue(
assignmentSource?.episodeNumberStart
?? assignmentSource?.episodeNoStart
?? assignmentSource?.episodeNumber
?? assignmentSource?.number
?? selectedTitleSource?.episodeNumber
?? (allowSelectedTitleEpisodeNumber ? selectedTitleSource?.episodeNumber : null)
?? null,
fallbackEpisodeNumber
);
@@ -987,6 +1006,167 @@ function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitl
};
}
function hasExplicitSeriesEpisodeAssignmentRange(assignment = null) {
const source = assignment && typeof assignment === 'object' ? assignment : null;
if (!source) {
return false;
}
const hasStart = normalizeEpisodeNumberValue(
source?.episodeNumberStart
?? source?.episodeNoStart
?? source?.episodeNumber
?? source?.number
?? null
) !== null;
const hasEnd = normalizeEpisodeNumberValue(
source?.episodeNumberEnd
?? source?.episodeNoEnd
?? null
) !== null;
const hasSpan = normalizePositiveInteger(
source?.episodeSpan
?? source?.episodeCount
?? null
) !== null;
const hasRangeToken = String(source?.episodeRange || '').trim().length > 0;
return Boolean(hasStart || hasEnd || hasSpan || hasRangeToken);
}
function resolveSeriesSelectedTitleIdOrder(encodePlan = null, titles = []) {
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
const explicitSelected = normalizeReviewTitleIdList(
Array.isArray(plan?.selectedTitleIds)
? plan.selectedTitleIds
: [plan?.encodeInputTitleId]
);
if (explicitSelected.length > 0) {
return explicitSelected;
}
return normalizeReviewTitleIdList(
(Array.isArray(titles) ? titles : [])
.filter((title) => Boolean(title?.selectedForEncode || title?.encodeInput))
.map((title) => title?.id)
);
}
function resolveSeriesEpisodeRangeForPlanTitle(encodePlan = null, titleId = null, fallbackEpisodeNumber = 1) {
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
const titles = Array.isArray(plan?.titles) ? plan.titles : [];
const titlesById = new Map();
for (const title of titles) {
const normalizedId = normalizeReviewTitleId(title?.id);
if (!normalizedId) {
continue;
}
titlesById.set(Number(normalizedId), title);
}
const normalizedTitleId = normalizeReviewTitleId(titleId)
|| normalizeReviewTitleId(plan?.encodeInputTitleId)
|| null;
let orderedTitleIds = resolveSeriesSelectedTitleIdOrder(plan, titles);
if (normalizedTitleId && !orderedTitleIds.some((id) => Number(id) === Number(normalizedTitleId))) {
orderedTitleIds = [...orderedTitleIds, Number(normalizedTitleId)];
}
if (orderedTitleIds.length === 0 && normalizedTitleId) {
orderedTitleIds = [Number(normalizedTitleId)];
}
const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object'
? plan.episodeAssignments
: {};
const fallbackStart = normalizePositiveInteger(fallbackEpisodeNumber) || 1;
let cursor = fallbackStart;
let targetRange = null;
let targetAssignment = null;
let targetTitle = null;
for (const rawId of orderedTitleIds) {
const currentId = normalizeReviewTitleId(rawId);
if (!currentId) {
continue;
}
const assignment = assignmentMap[String(currentId)] || assignmentMap[currentId] || null;
const selectedTitle = titlesById.get(Number(currentId)) || null;
const hasExplicitRange = hasExplicitSeriesEpisodeAssignmentRange(assignment);
const range = resolveSeriesEpisodeRangeFromAssignment(
assignment,
selectedTitle,
cursor,
{ allowSelectedTitleEpisodeNumber: hasExplicitRange }
);
const normalizedEnd = normalizeEpisodeNumberValue(range?.end);
if (normalizedEnd !== null) {
cursor = normalizedEnd + 1;
}
if (normalizedTitleId && Number(currentId) === Number(normalizedTitleId)) {
targetRange = range;
targetAssignment = assignment;
targetTitle = selectedTitle;
break;
}
}
if (!targetRange) {
const fallbackTitle = normalizedTitleId ? (titlesById.get(Number(normalizedTitleId)) || null) : null;
const fallbackAssignment = normalizedTitleId
? (assignmentMap[String(normalizedTitleId)] || assignmentMap[normalizedTitleId] || null)
: null;
const hasExplicitRange = hasExplicitSeriesEpisodeAssignmentRange(fallbackAssignment);
targetRange = resolveSeriesEpisodeRangeFromAssignment(
fallbackAssignment,
fallbackTitle,
cursor,
{ allowSelectedTitleEpisodeNumber: hasExplicitRange }
);
targetAssignment = fallbackAssignment;
targetTitle = fallbackTitle;
}
return {
range: targetRange,
assignment: targetAssignment,
title: targetTitle
};
}
function templateUsesLeadingZeroForToken(template, token) {
const source = String(template || '').trim();
const normalizedToken = String(token || '').trim();
if (!source || !normalizedToken) {
return false;
}
const pattern = new RegExp(`\\{\\s*0\\s*:\\s*${normalizedToken}\\s*\\}`, 'i');
return pattern.test(source);
}
function formatSeriesEpisodeNumberByTemplate(rawValue, template, token = 'episodeNr') {
const numeric = normalizeEpisodeNumberValue(rawValue);
if (numeric === null) {
return String(rawValue || '').trim() || '0';
}
if (Number.isInteger(numeric)) {
const normalized = String(Math.trunc(numeric));
if (templateUsesLeadingZeroForToken(template, token)) {
return normalized.padStart(2, '0');
}
return normalized;
}
return String(numeric);
}
function buildSeriesFallbackEpisodeTitle(episodeRangeInfo = null, template = '') {
const start = normalizeEpisodeNumberValue(episodeRangeInfo?.start) ?? 1;
const end = normalizeEpisodeNumberValue(episodeRangeInfo?.end) ?? start;
if (end > start) {
const startToken = formatSeriesEpisodeNumberByTemplate(start, template, 'episodeNr');
const endToken = formatSeriesEpisodeNumberByTemplate(end, template, 'episodeNr');
return `Folge ${startToken}-${endToken}`;
}
const token = formatSeriesEpisodeNumberByTemplate(start, template, 'episodeNr');
return `Folge ${token}`;
}
function buildSeriesEpisodeRangeToken(start, end) {
const startToken = formatTemplateTwoDigit(start);
const endToken = formatTemplateTwoDigit(end);
@@ -1178,19 +1358,39 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
const assignment = primaryTitleId
? (episodeAssignments[String(primaryTitleId)] || episodeAssignments[primaryTitleId] || null)
: null;
const selectedTitlePosition = primaryTitleId
? selectedTitleIds.findIndex((id) => Number(id) === Number(primaryTitleId))
: -1;
const fallbackEpisodeNumber = normalizePositiveInteger(
encodePlan?.seriesBatchChildIndex
?? null
) || (selectedTitlePosition >= 0 ? selectedTitlePosition + 1 : 1);
const episodeResolution = resolveSeriesEpisodeRangeForPlanTitle(
encodePlan,
primaryTitleId,
fallbackEpisodeNumber
);
const resolvedAssignment = episodeResolution?.assignment || assignment || null;
const resolvedTitle = episodeResolution?.title || selectedTitle || null;
const episodeRangeInfo = (
episodeResolution?.range
&& typeof episodeResolution.range === 'object'
)
? episodeResolution.range
: resolveSeriesEpisodeRangeFromAssignment(
resolvedAssignment,
resolvedTitle,
fallbackEpisodeNumber,
{ allowSelectedTitleEpisodeNumber: hasExplicitSeriesEpisodeAssignmentRange(resolvedAssignment) }
);
const seasonNumber = normalizePositiveInteger(
assignment?.seasonNumber
?? selectedTitle?.seasonNumber
resolvedAssignment?.seasonNumber
?? resolvedTitle?.seasonNumber
?? selectedMetadata?.seasonNumber
?? analyzeContext?.seriesLookupHint?.seasonNumber
?? null
) || 1;
const episodeRangeInfo = resolveSeriesEpisodeRangeFromAssignment(
assignment,
selectedTitle,
primaryTitleId || 1
);
const episodeTemplateValue = episodeRangeInfo.start;
const episodeRangeToken = buildSeriesEpisodeRangeToken(
episodeRangeInfo.start,
@@ -1199,7 +1399,7 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
const episodePartsToken = buildSeriesEpisodePartsToken(episodeRangeInfo);
const discNumber = normalizePositiveInteger(
selectedMetadata?.discNumber
?? assignment?.discNumber
?? resolvedAssignment?.discNumber
?? analyzeContext?.seriesLookupHint?.discNumber
?? null
);
@@ -1210,16 +1410,27 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
|| job?.detected_title
|| (fallbackJobId ? `job-${fallbackJobId}` : 'series')
) || (fallbackJobId ? `job-${fallbackJobId}` : 'series');
const singleTemplateRaw = String(
settings?.output_template_dvd_series_episode
|| DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE
).trim();
const multiTemplateRaw = String(
settings?.output_template_dvd_series_multi_episode
|| DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE
).trim();
const singleTemplate = singleTemplateRaw || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE;
const multiTemplate = multiTemplateRaw || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE;
const template = episodeRangeInfo.isMulti ? multiTemplate : singleTemplate;
const fallbackEpisodeTitle = buildSeriesFallbackEpisodeTitle(episodeRangeInfo, template);
const rawEpisodeTitle = normalizeCdTrackText(
assignment?.episodeTitle
|| selectedTitle?.episodeTitle
|| selectedTitle?.fileName
|| `Episode ${episodeRangeToken}`
) || `Episode ${episodeRangeToken}`;
resolvedAssignment?.episodeTitle
|| resolvedTitle?.episodeTitle
|| fallbackEpisodeTitle
) || fallbackEpisodeTitle;
const episodeTitle = normalizeSeriesEpisodeTitleForOutput(
rawEpisodeTitle,
episodeRangeInfo
);
) || fallbackEpisodeTitle;
const language = String(
settings?.dvd_series_language
|| selectedMetadata?.language
@@ -1227,21 +1438,10 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
).trim() || 'unknown';
const year = Number(selectedMetadata?.year || job?.year || new Date().getFullYear()) || new Date().getFullYear();
const singleTemplate = String(
settings?.output_template_dvd_series_episode
|| DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE
).trim() || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE;
const multiTemplate = String(
settings?.output_template_dvd_series_multi_episode
|| DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE
).trim() || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE;
const template = episodeRangeInfo.isMulti ? multiTemplate : singleTemplate;
const rendered = renderTemplate(template, {
seriesTitle,
seasonNr: seasonNumber,
seasonNo: String(seasonNumber),
episodeNr: episodeTemplateValue,
episodeNo: String(episodeTemplateValue),
episodeNoStart: episodeRangeInfo.start,
episodeNoEnd: episodeRangeInfo.end,
episodeNumberStart: episodeRangeInfo.start,
@@ -5283,15 +5483,16 @@ function resolveSeriesBatchChildDisplayTitle(parentJob, parentPlan, titleId, chi
const episodeRange = resolveSeriesEpisodeRangeFromAssignment(
assignment,
selectedTitle,
childIndex + 1
childIndex + 1,
{ allowSelectedTitleEpisodeNumber: hasExplicitSeriesEpisodeAssignmentRange(assignment) }
);
const fallbackEpisodeLabel = `Folge ${buildSeriesEpisodeRangeToken(episodeRange.start, episodeRange.end)}`;
const rawEpisodeLabel = String(
assignment?.episodeTitle
|| selectedTitle?.episodeTitle
|| selectedTitle?.fileName
|| ''
|| fallbackEpisodeLabel
).trim();
const episodeLabel = normalizeSeriesEpisodeTitleForOutput(rawEpisodeLabel, episodeRange);
const episodeLabel = normalizeSeriesEpisodeTitleForOutput(rawEpisodeLabel, episodeRange) || fallbackEpisodeLabel;
const seasonToken = seasonNumber !== null ? String(seasonNumber).padStart(2, '0') : null;
const episodeToken = buildSeriesEpisodeRangeToken(
@@ -5326,6 +5527,25 @@ function buildSeriesBatchChildPlan(parentPlan, titleId, parentJobId, childIndex,
? plan.episodeAssignments
: {};
const selectedAssignment = assignmentMap[String(titleId)] || assignmentMap[titleId] || null;
const inferredRangeForChild = resolveSeriesEpisodeRangeForPlanTitle(plan, titleId, childIndex + 1)?.range || null;
const selectedAssignmentHasRange = hasExplicitSeriesEpisodeAssignmentRange(selectedAssignment);
const resolvedChildAssignment = (
selectedAssignmentHasRange
|| !inferredRangeForChild
)
? selectedAssignment
: {
...(selectedAssignment && typeof selectedAssignment === 'object' ? selectedAssignment : {}),
titleId: Number(titleId),
episodeNumber: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumber) ?? inferredRangeForChild.start,
episodeNumberStart: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumberStart) ?? inferredRangeForChild.start,
episodeNumberEnd: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumberEnd) ?? inferredRangeForChild.end,
episodeSpan: normalizePositiveInteger(selectedAssignment?.episodeSpan)
|| Math.max(1, Math.trunc(Number(inferredRangeForChild.end) - Number(inferredRangeForChild.start) + 1)),
episodeRange: String(selectedAssignment?.episodeRange || '').trim()
|| buildSeriesEpisodeRangeToken(inferredRangeForChild.start, inferredRangeForChild.end),
seasonNumber: normalizePositiveInteger(selectedAssignment?.seasonNumber) ?? normalizePositiveInteger(selectedTitle?.seasonNumber) ?? null
};
return {
...plan,
@@ -5343,12 +5563,12 @@ function buildSeriesBatchChildPlan(parentPlan, titleId, parentJobId, childIndex,
handBrakeTitleIds: handBrakeTitleId ? [handBrakeTitleId] : [],
titleSelectionRequired: false,
titles: remappedTitles,
episodeAssignments: selectedAssignment
episodeAssignments: resolvedChildAssignment
? {
[String(titleId)]: {
...selectedAssignment,
...resolvedChildAssignment,
titleId: Number(titleId),
filePath: selectedTitle?.filePath || selectedAssignment?.filePath || null
filePath: selectedTitle?.filePath || resolvedChildAssignment?.filePath || null
}
}
: {},
@@ -8759,6 +8979,10 @@ class PipelineService extends EventEmitter {
if (this.isSeriesContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) {
continue;
}
const status = String(job?.status || job?.last_state || '').trim().toUpperCase();
if (status !== 'ENCODING' && status !== 'CD_ENCODING') {
continue;
}
const poolType = this.resolveQueuePoolTypeForJob(job);
if (poolType === 'audio') {
audioRunning += 1;
@@ -10759,49 +10983,52 @@ class PipelineService extends EventEmitter {
const filmRunning = runningUsage.filmRunning;
const cdRunning = runningUsage.audioRunning;
const totalRunning = runningUsage.totalRunning;
// Counts all actively processing jobs (excludes waiting states like READY_TO_ENCODE).
const anyActiveJobs = totalRunning;
// Find next startable entry
let entryIndex = -1;
for (let i = 0; i < this.queueEntries.length; i++) {
const candidate = this.queueEntries[i];
const isNonJob = candidate.type && candidate.type !== 'job';
if (isNonJob) {
// Non-job entries (script, chain, wait) only start when no jobs are actively running.
// anyActiveJobs covers all active states (ANALYZING, RIPPING, ENCODING, MEDIAINFO_CHECK etc.)
// activeProcesses provides a second safety net for race conditions during state transitions.
if (anyActiveJobs === 0 && this.activeProcesses.size === 0) {
entryIndex = i;
}
break; // FIFO: stop scanning regardless (non-job blocks everything behind it)
}
// Job entry: check hierarchical limits
if (totalRunning >= maxTotal) {
// Total limit reached nothing can start
break;
}
const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate);
if (candidatePoolType === 'audio') {
if (cdRunning < maxCd) {
entryIndex = i;
const independentEntryIndex = this.queueEntries.findIndex((candidate) => {
const type = String(candidate?.type || '').trim().toLowerCase();
return type === 'script' || type === 'chain';
});
if (independentEntryIndex >= 0) {
entryIndex = independentEntryIndex;
} else {
for (let i = 0; i < this.queueEntries.length; i++) {
const candidate = this.queueEntries[i];
const isWaitEntry = String(candidate?.type || '').trim().toLowerCase() === 'wait';
if (isWaitEntry) {
// Wait keeps queue order and blocks until no tracked job process is active.
if (this.activeProcesses.size === 0) {
entryIndex = i;
}
break;
}
// CD limit reached
if (!cdBypass) break; // Strict FIFO: stop scanning
continue; // Bypass mode: skip this blocked CD entry
} else {
// Film/video job entry
if (filmRunning < maxFilm) {
entryIndex = i;
// Job entry: check hierarchical limits (caps apply only to encode jobs).
if (totalRunning >= maxTotal) {
// Total encode cap reached nothing can start.
break;
}
// Film limit reached
if (!cdBypass) break; // Strict FIFO: stop scanning
continue; // Bypass mode: skip this blocked film entry
const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate);
if (candidatePoolType === 'audio') {
if (cdRunning < maxCd) {
entryIndex = i;
break;
}
// CD/audio encode cap reached
if (!cdBypass) break; // Strict FIFO: stop scanning
continue; // Bypass mode: skip this blocked CD entry
} else {
// Film/video encode job entry
if (filmRunning < maxFilm) {
entryIndex = i;
break;
}
// Film encode cap reached
if (!cdBypass) break; // Strict FIFO: stop scanning
continue; // Bypass mode: skip this blocked film entry
}
}
}
@@ -12493,7 +12720,36 @@ class PipelineService extends EventEmitter {
seasonNumber: seasonFilter || null
});
let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery);
let tmdbLanguage = null;
try {
const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd');
tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null;
} catch (_settingsError) {
tmdbLanguage = null;
}
const cacheKey = [
normalizedQuery.toLowerCase(),
seasonFilter.toLowerCase(),
String(tmdbLanguage || '').trim().toLowerCase()
].join('::');
const cacheEntry = tmdbSeriesSearchCache.get(cacheKey);
const nowTs = Date.now();
if (cacheEntry && cacheEntry.expiresAt > nowTs && Array.isArray(cacheEntry.results)) {
logger.info('tmdb:series-search:cache-hit', {
query: normalizedQuery,
seasonNumber: seasonFilter || null,
count: cacheEntry.results.length
});
return cloneTmdbSeriesSearchResults(cacheEntry.results);
}
if (cacheEntry && cacheEntry.expiresAt <= nowTs) {
tmdbSeriesSearchCache.delete(cacheKey);
}
let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, {
language: tmdbLanguage
});
if (!Array.isArray(normalizedResults)) {
normalizedResults = [];
}
@@ -12518,6 +12774,26 @@ class PipelineService extends EventEmitter {
throw error;
}
const cacheNowTs = Date.now();
if (tmdbSeriesSearchCache.size >= 200) {
for (const [key, entry] of tmdbSeriesSearchCache.entries()) {
if (!entry || entry.expiresAt <= cacheNowTs) {
tmdbSeriesSearchCache.delete(key);
}
}
if (tmdbSeriesSearchCache.size >= 200) {
const oldestKey = tmdbSeriesSearchCache.keys().next().value;
if (oldestKey) {
tmdbSeriesSearchCache.delete(oldestKey);
}
}
}
tmdbSeriesSearchCache.set(cacheKey, {
results: cloneTmdbSeriesSearchResults(normalizedResults),
expiresAt: cacheNowTs + TMDB_SERIES_SEARCH_CACHE_TTL_MS
});
return normalizedResults;
}
@@ -14174,12 +14450,22 @@ class PipelineService extends EventEmitter {
|| mkInfo?.analyzeContext?.selectedMetadata?.episodeCount
|| 0
) || 0;
let effectiveEpisodes = Array.isArray(episodes)
const hasExplicitEpisodePayload = Array.isArray(episodes) && episodes.length > 0;
let effectiveEpisodes = hasExplicitEpisodePayload
? episodes
: (Array.isArray(mkInfo?.analyzeContext?.selectedMetadata?.episodes)
? mkInfo.analyzeContext.selectedMetadata.episodes
: []);
let tmdbDetails = null;
let tmdbLanguage = null;
if (effectiveMetadataProvider === 'tmdb') {
try {
const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd');
tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null;
} catch (_settingsError) {
tmdbLanguage = null;
}
}
const isDvdTmdbMetadataSelection = mediaProfile === 'dvd' && effectiveWorkflowKind === 'series';
if (mediaProfile === 'dvd' && effectiveWorkflowKind === 'film') {
@@ -14218,6 +14504,7 @@ class PipelineService extends EventEmitter {
if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId) {
try {
const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, {
language: tmdbLanguage,
appendToResponse: ['credits', 'external_ids']
});
tmdbDetails = tmdbService.buildSeriesDetailsSummary(seriesDetails);
@@ -14248,15 +14535,17 @@ class PipelineService extends EventEmitter {
if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveSeasonNumber) {
try {
seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber);
seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber);
seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber, {
language: tmdbLanguage
});
seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber, {
language: tmdbLanguage
});
const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails);
if (seasonSummary) {
const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : [];
if (fetchedEpisodes.length > 0) {
if (!Array.isArray(effectiveEpisodes) || effectiveEpisodes.length === 0) {
effectiveEpisodes = fetchedEpisodes;
}
if (fetchedEpisodes.length > 0 && !hasExplicitEpisodePayload) {
effectiveEpisodes = fetchedEpisodes;
}
const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0;
if (fetchedEpisodeCount > 0) {
@@ -17981,6 +18270,98 @@ class PipelineService extends EventEmitter {
};
}
buildPostEncodeScriptsSummaryPlaceholder(encodePlan) {
const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []);
const chainIds = normalizeChainIdList(encodePlan?.postEncodeChainIds || []);
return {
configured: scriptIds.length + chainIds.length,
attempted: 0,
succeeded: 0,
failed: 0,
skipped: 0,
aborted: false,
abortReason: null,
failedScriptId: null,
failedScriptName: null,
pending: scriptIds.length + chainIds.length > 0,
results: []
};
}
async persistPostEncodeScriptsSummary(jobId, summary) {
try {
const job = await historyService.getJobById(jobId);
if (!job) {
return;
}
const handbrakeInfo = this.safeParseJson(job.handbrake_info_json);
const nextInfo = handbrakeInfo && typeof handbrakeInfo === 'object'
? handbrakeInfo
: {};
await historyService.updateJob(jobId, {
handbrake_info_json: JSON.stringify({
...nextInfo,
postEncodeScripts: {
...(summary && typeof summary === 'object' ? summary : {}),
pending: false
}
})
});
} catch (error) {
logger.warn('encode:post-script:persist-summary-failed', { jobId, error: errorToMeta(error) });
}
}
runPostEncodeScriptsDetached(jobId, encodePlan, context = {}, options = {}) {
const placeholderSummary = this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan);
if (placeholderSummary.configured <= 0) {
return { started: false, summary: placeholderSummary };
}
const phaseLabel = String(options?.phaseLabel || 'Post-Encode').trim() || 'Post-Encode';
void historyService.appendLog(
jobId,
'SYSTEM',
`${phaseLabel} Skripte/Ketten werden unabhängig im Hintergrund ausgeführt.`
);
void (async () => {
let finalSummary = {
...placeholderSummary,
pending: false
};
try {
finalSummary = {
...(await this.runPostEncodeScripts(jobId, encodePlan, context, null)),
pending: false
};
} catch (error) {
logger.warn('encode:post-script:detached-failed', { jobId, error: errorToMeta(error) });
finalSummary = {
...placeholderSummary,
attempted: placeholderSummary.configured,
failed: placeholderSummary.configured,
aborted: true,
abortReason: error?.message || 'unknown',
pending: false
};
}
await historyService.appendLog(
jobId,
'SYSTEM',
`${phaseLabel} Skripte/Ketten abgeschlossen: ${finalSummary.succeeded} erfolgreich, `
+ `${finalSummary.failed} fehlgeschlagen, ${finalSummary.skipped} übersprungen.`
);
await this.persistPostEncodeScriptsSummary(jobId, finalSummary);
})();
return {
started: true,
summary: placeholderSummary
};
}
async startEncodingFromPrepared(jobId, options = {}) {
this.ensureNotBusy('startEncodingFromPrepared', jobId);
logger.info('encode:start-from-prepared', { jobId });
@@ -18568,40 +18949,15 @@ class PipelineService extends EventEmitter {
historyService.addJobOutputFolder(jobId, finalizedOutputPath).catch((e) => {
logger.warn('encode:record-output-folder-failed', { jobId, error: e?.message });
});
let postEncodeScriptsSummary = {
configured: 0,
attempted: 0,
succeeded: 0,
failed: 0,
skipped: 0,
results: []
const postEncodeExecutionContext = {
mode,
jobTitle: job.title || job.detected_title || null,
inputPath,
outputPath: finalizedOutputPath,
rawPath: activeRawPath,
pipelineStage: 'ENCODING'
};
try {
postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, encodePlan, {
mode,
jobTitle: job.title || job.detected_title || null,
inputPath,
outputPath: finalizedOutputPath,
rawPath: activeRawPath
}, encodeScriptProgressTracker);
} catch (error) {
logger.warn('encode:post-script:summary-failed', {
jobId,
error: errorToMeta(error)
});
await historyService.appendLog(
jobId,
'SYSTEM',
`Post-Encode Skripte konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}`
);
}
if (postEncodeScriptsSummary.configured > 0) {
await historyService.appendLog(
jobId,
'SYSTEM',
`Post-Encode Skripte abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.${postEncodeScriptsSummary.aborted ? ' Kette wurde abgebrochen.' : ''}`
);
}
const postEncodeScriptsSummary = this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan);
let finalizedRawPath = activeRawPath || null;
if (activeRawPath) {
const currentRawPath = String(activeRawPath || '').trim();
@@ -18662,6 +19018,9 @@ class PipelineService extends EventEmitter {
output_path: finalizedOutputPath,
error_message: null
});
this.runPostEncodeScriptsDetached(jobId, encodePlan, postEncodeExecutionContext, {
phaseLabel: 'Post-Encode'
});
return {
outputPath: finalizedOutputPath,
handbrakeInfo: handbrakeInfoWithPostScripts,
@@ -18723,6 +19082,9 @@ class PipelineService extends EventEmitter {
logger.info('encoding:finished:background', { jobId, mode, activeJobId: this.snapshot.activeJobId });
void this.pumpQueue();
}
this.runPostEncodeScriptsDetached(jobId, encodePlan, postEncodeExecutionContext, {
phaseLabel: 'Post-Encode'
});
if (mode === 'reencode') {
void this.notifyPushover('reencode_finished', {
@@ -23532,8 +23894,6 @@ class PipelineService extends EventEmitter {
const normalizedEncodePlan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
const preScriptIds = normalizeScriptIdList(normalizedEncodePlan?.preEncodeScriptIds || []);
const preChainIds = normalizeChainIdList(normalizedEncodePlan?.preEncodeChainIds || []);
const postScriptIds = normalizeScriptIdList(normalizedEncodePlan?.postEncodeScriptIds || []);
const postChainIds = normalizeChainIdList(normalizedEncodePlan?.postEncodeChainIds || []);
let preEncodeScriptsSummary = {
configured: 0,
attempted: 0,
@@ -23755,35 +24115,17 @@ class PipelineService extends EventEmitter {
}
const { encodeResults: cdEncodeResults = [] } = cdRipResult || {};
settleLifecycle();
if (postScriptIds.length > 0 || postChainIds.length > 0) {
await historyService.appendLog(jobId, 'SYSTEM', 'Post-Rip Skripte/Ketten werden ausgeführt...');
try {
postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, normalizedEncodePlan, {
mode: 'cd_rip',
jobId,
jobTitle: selectedMeta?.title || `Job #${jobId}`,
inputPath: devicePath || null,
outputPath: outputDir || null,
rawPath: rawWavDir || null,
mediaProfile: 'cd',
pipelineStage: skipEncode ? 'CD_RIPPING' : 'CD_ENCODING'
});
} catch (error) {
logger.warn('cd:rip:post-script:failed', { jobId, error: errorToMeta(error) });
await historyService.appendLog(
jobId,
'SYSTEM',
`Post-Rip Skripte/Ketten konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}`
);
}
await historyService.appendLog(
jobId,
'SYSTEM',
`Post-Rip Skripte/Ketten abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, `
+ `${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.`
);
}
postEncodeScriptsSummary = this.buildPostEncodeScriptsSummaryPlaceholder(normalizedEncodePlan);
let postCdScriptContext = {
mode: 'cd_rip',
jobId,
jobTitle: selectedMeta?.title || `Job #${jobId}`,
inputPath: devicePath || null,
outputPath: outputDir || null,
rawPath: rawWavDir || null,
mediaProfile: 'cd',
pipelineStage: skipEncode ? 'CD_RIPPING' : 'CD_ENCODING'
};
// RAW-Verzeichnis von Incomplete_ → Zielzustand umbenennen
let activeRawDir = rawWavDir;
@@ -23800,6 +24142,11 @@ class PipelineService extends EventEmitter {
// ignore raw dir bleibt unter bestehendem Namen zugänglich
}
}
postCdScriptContext = {
...postCdScriptContext,
outputPath: outputDir || null,
rawPath: activeRawDir || rawWavDir || null
};
const directReencodeReadyAt = normalizedEncodePlan?.directReencodeReadyAt || nowIso();
const persistedEncodePlan = {
@@ -23963,6 +24310,9 @@ class PipelineService extends EventEmitter {
void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath));
}
}
this.runPostEncodeScriptsDetached(jobId, normalizedEncodePlan, postCdScriptContext, {
phaseLabel: 'Post-Rip'
});
} catch (error) {
settleLifecycle();
const failedCdLive = buildLiveContext(currentTrackPosition || null);