0.13.1-10 Misc fixes
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.13.1-9",
|
||||
"version": "0.13.1-10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-backend",
|
||||
"version": "0.13.1-9",
|
||||
"version": "0.13.1-10",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.13.1-9",
|
||||
"version": "0.13.1-10",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@@ -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})')`);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user