0.14.0-4 Merger
This commit is contained in:
1 parent
caf6a638f7
commit
c27a81cdb1
127 files changed
+10643
-335
No files matched your search
@@ -153,6 +153,11 @@ router.post(
|
||||
const id = Number(req.params.id);
|
||||
const target = String(req.body?.target || 'both');
|
||||
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
||||
const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths)
|
||||
? req.body.selectedRawPaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
|
||||
? req.body.selectedMoviePaths
|
||||
.map((item) => String(item || '').trim())
|
||||
@@ -164,11 +169,13 @@ router.post(
|
||||
id,
|
||||
target,
|
||||
includeRelated,
|
||||
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
|
||||
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
||||
});
|
||||
|
||||
const result = await historyService.deleteJobFiles(id, target, {
|
||||
includeRelated,
|
||||
selectedRawPaths,
|
||||
selectedMoviePaths
|
||||
});
|
||||
res.json(result);
|
||||
@@ -203,6 +210,11 @@ router.post(
|
||||
const requestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined
|
||||
? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase())
|
||||
: !requestedResetDriveState;
|
||||
const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths)
|
||||
? req.body.selectedRawPaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
|
||||
? req.body.selectedMoviePaths
|
||||
.map((item) => String(item || '').trim())
|
||||
@@ -217,6 +229,7 @@ router.post(
|
||||
requestedResetDriveState,
|
||||
preserveRawForImportJobs,
|
||||
requestedKeepDetectedDevice,
|
||||
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
|
||||
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
||||
});
|
||||
|
||||
@@ -246,6 +259,7 @@ router.post(
|
||||
: [];
|
||||
const result = await historyService.deleteJob(id, target, {
|
||||
includeRelated,
|
||||
selectedRawPaths,
|
||||
selectedMoviePaths,
|
||||
preserveRawForImportJobs
|
||||
});
|
||||
|
||||
@@ -371,7 +371,10 @@ router.post(
|
||||
seasonName,
|
||||
episodeCount,
|
||||
episodes,
|
||||
discNumber
|
||||
discNumber,
|
||||
duplicateAction,
|
||||
existingJobId,
|
||||
existingDiscNumber
|
||||
} = req.body;
|
||||
|
||||
if (!jobId) {
|
||||
@@ -396,7 +399,10 @@ router.post(
|
||||
tmdbId,
|
||||
workflowKind,
|
||||
seasonNumber,
|
||||
discNumber
|
||||
discNumber,
|
||||
duplicateAction,
|
||||
existingJobId,
|
||||
existingDiscNumber
|
||||
});
|
||||
|
||||
const job = await pipelineService.selectMetadata({
|
||||
@@ -418,7 +424,10 @@ router.post(
|
||||
seasonName,
|
||||
episodeCount,
|
||||
episodes,
|
||||
discNumber
|
||||
discNumber,
|
||||
duplicateAction,
|
||||
existingJobId,
|
||||
existingDiscNumber
|
||||
});
|
||||
|
||||
res.json({ job });
|
||||
|
||||
@@ -385,7 +385,16 @@ function normalizeJobKindValue(value) {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (raw === 'audiobook' || raw === 'cd' || raw === 'dvd' || raw === 'bluray') {
|
||||
if (
|
||||
raw === 'audiobook'
|
||||
|| raw === 'cd'
|
||||
|| raw === 'dvd'
|
||||
|| raw === 'bluray'
|
||||
|| raw === 'dvd_series_container'
|
||||
|| raw === 'dvd_series_child'
|
||||
|| raw === 'multipart_movie_container'
|
||||
|| raw === 'multipart_movie_child'
|
||||
) {
|
||||
return raw;
|
||||
}
|
||||
if (raw === 'converter_audio' || raw === 'converter_video' || raw === 'converter_iso') {
|
||||
@@ -4636,7 +4645,14 @@ class HistoryService {
|
||||
if (child?.rawStatus?.exists) {
|
||||
rawCount += 1;
|
||||
}
|
||||
if (child?.backupSuccess) {
|
||||
// Backup should be considered present when rip-success markers are set
|
||||
// or when RAW is physically available for that child.
|
||||
const backupPresent = Boolean(
|
||||
child?.backupSuccess
|
||||
|| child?.rawStatus?.exists
|
||||
|| String(child?.makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS'
|
||||
);
|
||||
if (backupPresent) {
|
||||
backupCount += 1;
|
||||
}
|
||||
if (child?.encodeSuccess) {
|
||||
@@ -6275,6 +6291,12 @@ class HistoryService {
|
||||
const normalizedTarget = String(target || 'both').trim().toLowerCase();
|
||||
const includesRaw = normalizedTarget === 'raw' || normalizedTarget === 'both';
|
||||
const includesMovie = normalizedTarget === 'movie' || normalizedTarget === 'both';
|
||||
const selectedRawPathFilter = new Set(
|
||||
(Array.isArray(options?.selectedRawPaths) ? options.selectedRawPaths : [])
|
||||
.map((rawPath) => String(rawPath || '').trim())
|
||||
.filter(Boolean)
|
||||
.map((rawPath) => normalizeComparablePath(rawPath))
|
||||
);
|
||||
const selectedMoviePathFilter = new Set(
|
||||
(Array.isArray(options?.selectedMoviePaths) ? options.selectedMoviePaths : [])
|
||||
.map((moviePath) => String(moviePath || '').trim())
|
||||
@@ -6286,6 +6308,7 @@ class HistoryService {
|
||||
target: normalizedTarget,
|
||||
raw: { attempted: includesRaw, deleted: false, filesDeleted: 0, dirsRemoved: 0, pathsDeleted: 0, reason: null },
|
||||
movie: { attempted: includesMovie, deleted: false, filesDeleted: 0, dirsRemoved: 0, pathsDeleted: 0, reason: null },
|
||||
selectedRawPaths: Array.from(selectedRawPathFilter),
|
||||
selectedMoviePaths: Array.from(selectedMoviePathFilter),
|
||||
deletedPaths: []
|
||||
};
|
||||
@@ -6293,6 +6316,15 @@ class HistoryService {
|
||||
const applyTarget = (targetKey) => {
|
||||
let candidates = (Array.isArray(preview?.pathCandidates?.[targetKey]) ? preview.pathCandidates[targetKey] : [])
|
||||
.filter((item) => Boolean(item?.exists) && (Boolean(item?.isDirectory) || Boolean(item?.isFile)));
|
||||
if (targetKey === 'raw' && selectedRawPathFilter.size > 0) {
|
||||
candidates = candidates.filter((item) => {
|
||||
const candidatePath = String(item?.path || '').trim();
|
||||
if (!candidatePath) {
|
||||
return false;
|
||||
}
|
||||
return selectedRawPathFilter.has(normalizeComparablePath(candidatePath));
|
||||
});
|
||||
}
|
||||
if (targetKey === 'movie' && selectedMoviePathFilter.size > 0) {
|
||||
candidates = candidates.filter((item) => {
|
||||
const candidatePath = String(item?.path || '').trim();
|
||||
@@ -6303,9 +6335,13 @@ class HistoryService {
|
||||
});
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
summary[targetKey].reason = targetKey === 'movie' && selectedMoviePathFilter.size > 0
|
||||
? 'Keine ausgewählten Audio/Video-Ordner gefunden.'
|
||||
: 'Keine passenden Dateien/Ordner gefunden.';
|
||||
if (targetKey === 'raw' && selectedRawPathFilter.size > 0) {
|
||||
summary[targetKey].reason = 'Keine ausgewählten RAW-Dateien/Ordner gefunden.';
|
||||
} else if (targetKey === 'movie' && selectedMoviePathFilter.size > 0) {
|
||||
summary[targetKey].reason = 'Keine ausgewählten Audio/Video-Ordner gefunden.';
|
||||
} else {
|
||||
summary[targetKey].reason = 'Keine passenden Dateien/Ordner gefunden.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6413,16 +6449,21 @@ class HistoryService {
|
||||
}
|
||||
|
||||
const includeRelated = Boolean(options?.includeRelated);
|
||||
const selectedRawPaths = Array.isArray(options?.selectedRawPaths)
|
||||
? options.selectedRawPaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
|
||||
? options.selectedMoviePaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
|
||||
if (includeRelated || (selectedMoviePaths && selectedMoviePaths.length > 0)) {
|
||||
if (includeRelated || (selectedRawPaths && selectedRawPaths.length > 0) || (selectedMoviePaths && selectedMoviePaths.length > 0)) {
|
||||
const normalizedJobId = normalizeJobIdValue(jobId);
|
||||
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated });
|
||||
const summary = this._deletePathsFromPreview(preview, target, { selectedMoviePaths });
|
||||
const summary = this._deletePathsFromPreview(preview, target, { selectedRawPaths, selectedMoviePaths });
|
||||
const relatedJobIds = Array.from(new Set(
|
||||
(Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [])
|
||||
.map((row) => normalizeJobIdValue(row?.id))
|
||||
@@ -6471,6 +6512,7 @@ class HistoryService {
|
||||
logger.info('job:delete-files', {
|
||||
jobId: normalizedJobId,
|
||||
includeRelated,
|
||||
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
|
||||
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0,
|
||||
summary
|
||||
});
|
||||
@@ -7158,6 +7200,11 @@ class HistoryService {
|
||||
paths: deletedPaths
|
||||
};
|
||||
};
|
||||
const normalizedSelectedRawPaths = Array.isArray(options?.selectedRawPaths)
|
||||
? options.selectedRawPaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
const normalizedSelectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
|
||||
? options.selectedMoviePaths
|
||||
.map((item) => String(item || '').trim())
|
||||
@@ -7191,6 +7238,7 @@ class HistoryService {
|
||||
}
|
||||
|
||||
let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
|
||||
let selectedRawPathsForDelete = normalizedSelectedRawPaths;
|
||||
let selectedMoviePathsForDelete = normalizedSelectedMoviePaths;
|
||||
let preview = null;
|
||||
let fileSummary = null;
|
||||
@@ -7209,6 +7257,7 @@ class HistoryService {
|
||||
preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
|
||||
}
|
||||
fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
|
||||
selectedRawPaths: selectedRawPathsForDelete,
|
||||
selectedMoviePaths: selectedMoviePathsForDelete
|
||||
});
|
||||
}
|
||||
@@ -7371,6 +7420,7 @@ class HistoryService {
|
||||
}
|
||||
|
||||
let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
|
||||
let selectedRawPathsForDelete = normalizedSelectedRawPaths;
|
||||
let selectedMoviePathsForDelete = normalizedSelectedMoviePaths;
|
||||
if (effectiveFileTarget === 'none') {
|
||||
const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview);
|
||||
@@ -7384,6 +7434,7 @@ class HistoryService {
|
||||
let fileSummary = null;
|
||||
if (effectiveFileTarget !== 'none') {
|
||||
fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
|
||||
selectedRawPaths: selectedRawPathsForDelete,
|
||||
selectedMoviePaths: selectedMoviePathsForDelete
|
||||
});
|
||||
}
|
||||
|
||||
@@ -166,6 +166,81 @@ function resolveSeriesDiscLabel(mediaProfile) {
|
||||
return 'Disc';
|
||||
}
|
||||
|
||||
function normalizeMovieFingerprintLabel(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function buildMovieMetadataFingerprint(mediaProfile, metadata = {}) {
|
||||
const normalizedMediaProfile = normalizeMediaProfile(mediaProfile);
|
||||
if (normalizedMediaProfile !== 'dvd' && normalizedMediaProfile !== 'bluray') {
|
||||
return null;
|
||||
}
|
||||
const imdbId = String(metadata?.imdbId || '').trim().toLowerCase();
|
||||
if (imdbId) {
|
||||
return `${normalizedMediaProfile}|imdb|${imdbId}`;
|
||||
}
|
||||
const title = normalizeMovieFingerprintLabel(metadata?.title || '');
|
||||
const year = normalizePositiveInteger(metadata?.year);
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
if (year) {
|
||||
return `${normalizedMediaProfile}|title_year|${title}|${year}`;
|
||||
}
|
||||
return `${normalizedMediaProfile}|title|${title}`;
|
||||
}
|
||||
|
||||
function extractSelectedMetadataFromMakemkvInfo(makemkvInfo = null) {
|
||||
const mkInfo = makemkvInfo && typeof makemkvInfo === 'object'
|
||||
? makemkvInfo
|
||||
: {};
|
||||
const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object'
|
||||
? mkInfo.analyzeContext
|
||||
: {};
|
||||
const analyzeSelected = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
||||
? analyzeContext.selectedMetadata
|
||||
: {};
|
||||
const topLevelSelected = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object'
|
||||
? mkInfo.selectedMetadata
|
||||
: {};
|
||||
return {
|
||||
...topLevelSelected,
|
||||
...analyzeSelected
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDiscNumberFromJobLike(job = null) {
|
||||
const direct = normalizePositiveInteger(job?.disc_number);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
let parsedMakemkvInfo = {};
|
||||
if (typeof job?.makemkv_info_json === 'string') {
|
||||
try {
|
||||
parsedMakemkvInfo = JSON.parse(job.makemkv_info_json) || {};
|
||||
} catch (_error) {
|
||||
parsedMakemkvInfo = {};
|
||||
}
|
||||
} else if (job?.makemkv_info_json && typeof job.makemkv_info_json === 'object') {
|
||||
parsedMakemkvInfo = job.makemkv_info_json;
|
||||
} else if (job?.makemkvInfo && typeof job.makemkvInfo === 'object') {
|
||||
parsedMakemkvInfo = job.makemkvInfo;
|
||||
}
|
||||
const mkInfo = parsedMakemkvInfo;
|
||||
const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(mkInfo);
|
||||
return normalizePositiveInteger(
|
||||
selectedMetadata?.discNumber
|
||||
?? mkInfo?.analyzeContext?.seriesLookupHint?.discNumber
|
||||
?? null
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSelectedMetadataForJob(job = null, analyzeContext = null, activeContext = null) {
|
||||
const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
||||
? analyzeContext.selectedMetadata
|
||||
@@ -605,7 +680,16 @@ function normalizeJobKind(value) {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (raw === 'audiobook' || raw === 'cd' || raw === 'dvd' || raw === 'bluray') {
|
||||
if (
|
||||
raw === 'audiobook'
|
||||
|| raw === 'cd'
|
||||
|| raw === 'dvd'
|
||||
|| raw === 'bluray'
|
||||
|| raw === 'dvd_series_container'
|
||||
|| raw === 'dvd_series_child'
|
||||
|| raw === 'multipart_movie_container'
|
||||
|| raw === 'multipart_movie_child'
|
||||
) {
|
||||
return raw;
|
||||
}
|
||||
if (raw === 'converter_audio' || raw === 'converter_video' || raw === 'converter_iso') {
|
||||
@@ -8868,6 +8952,13 @@ class PipelineService extends EventEmitter {
|
||||
error: errorToMeta(driveLockRecoveryError)
|
||||
});
|
||||
}
|
||||
try {
|
||||
await this._forceUnlockStaleDriveLocks();
|
||||
} catch (driveLockCleanupError) {
|
||||
logger.warn('init:drive-lock-cleanup-failed', {
|
||||
error: errorToMeta(driveLockCleanupError)
|
||||
});
|
||||
}
|
||||
|
||||
// Always start with a clean ripper/session snapshot after server restart.
|
||||
const hasContextKeys = this.snapshot.context
|
||||
@@ -8887,7 +8978,7 @@ class PipelineService extends EventEmitter {
|
||||
const staleRows = await db.all(`
|
||||
SELECT id, status, last_state
|
||||
FROM jobs
|
||||
WHERE COALESCE(job_kind, '') != 'dvd_series_container'
|
||||
WHERE COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container')
|
||||
AND status IN ('ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING',
|
||||
'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING')
|
||||
ORDER BY updated_at ASC, id ASC
|
||||
@@ -9466,7 +9557,7 @@ class PipelineService extends EventEmitter {
|
||||
FROM jobs
|
||||
WHERE disc_device IS NOT NULL
|
||||
AND TRIM(disc_device) <> ''
|
||||
AND COALESCE(job_kind, '') != 'dvd_series_container'
|
||||
AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container')
|
||||
AND status IN ('RIPPING', 'CD_RIPPING', 'ERROR', 'CANCELLED')
|
||||
`
|
||||
);
|
||||
@@ -9812,19 +9903,28 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
|
||||
async _forceUnlockStaleDriveLocks(devicePaths = []) {
|
||||
const activeLocks = Array.isArray(diskDetectionService.getActiveLocks?.())
|
||||
? diskDetectionService.getActiveLocks()
|
||||
: [];
|
||||
const normalizedPaths = Array.from(new Set(
|
||||
(Array.isArray(devicePaths) ? devicePaths : [])
|
||||
.map((value) => this.normalizeDrivePath(value))
|
||||
.filter((value) => Boolean(value) && !value.startsWith('__virtual__'))
|
||||
));
|
||||
if (normalizedPaths.length === 0 || typeof diskDetectionService.forceUnlockDevice !== 'function') {
|
||||
const pathsToCheck = normalizedPaths.length > 0
|
||||
? normalizedPaths
|
||||
: Array.from(new Set(
|
||||
activeLocks
|
||||
.map((entry) => this.normalizeDrivePath(entry?.path))
|
||||
.filter((value) => Boolean(value) && !value.startsWith('__virtual__'))
|
||||
));
|
||||
if (pathsToCheck.length === 0 || typeof diskDetectionService.forceUnlockDevice !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const activeLocks = diskDetectionService.getActiveLocks();
|
||||
const locksToCheck = normalizedPaths
|
||||
const locksToCheck = pathsToCheck
|
||||
.map((devicePath) => {
|
||||
const lock = activeLocks.find((entry) => this.normalizeDrivePath(entry?.path) === devicePath) || null;
|
||||
const lock = activeLocks.find((entry) => this._isSameDrivePath(entry?.path, devicePath)) || null;
|
||||
return lock ? { devicePath, lock } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
@@ -9832,36 +9932,6 @@ class PipelineService extends EventEmitter {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ownerJobIds = new Set();
|
||||
for (const entry of locksToCheck) {
|
||||
const owners = Array.isArray(entry.lock?.owners) ? entry.lock.owners : [];
|
||||
for (const owner of owners) {
|
||||
const jobId = Number(owner?.jobId);
|
||||
if (Number.isFinite(jobId) && jobId > 0) {
|
||||
ownerJobIds.add(Math.trunc(jobId));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ownerJobIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let existingJobIds = new Set();
|
||||
try {
|
||||
const db = await getDb();
|
||||
const placeholders = Array.from(ownerJobIds).map(() => '?').join(', ');
|
||||
const rows = await db.all(`SELECT id FROM jobs WHERE id IN (${placeholders})`, Array.from(ownerJobIds));
|
||||
existingJobIds = new Set(
|
||||
(Array.isArray(rows) ? rows : [])
|
||||
.map((row) => Number(row?.id))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.trunc(value))
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('drive-lock:stale-check:failed', { error: errorToMeta(error) });
|
||||
return [];
|
||||
}
|
||||
|
||||
const cleared = [];
|
||||
for (const entry of locksToCheck) {
|
||||
const owners = Array.isArray(entry.lock?.owners) ? entry.lock.owners : [];
|
||||
@@ -9870,22 +9940,33 @@ class PipelineService extends EventEmitter {
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return false;
|
||||
}
|
||||
return existingJobIds.has(Math.trunc(jobId));
|
||||
const normalizedJobId = Math.trunc(jobId);
|
||||
return this._isActiveProcessForJob(normalizedJobId, { cleanupStale: true });
|
||||
});
|
||||
if (hasActiveOwner) {
|
||||
continue;
|
||||
}
|
||||
const clearedJobIds = [];
|
||||
for (const [jobId, lock] of this.driveLocksByJob.entries()) {
|
||||
if (!this._isSameDrivePath(lock?.devicePath, entry.devicePath)) {
|
||||
continue;
|
||||
}
|
||||
this.driveLocksByJob.delete(jobId);
|
||||
clearedJobIds.push(Number(jobId));
|
||||
}
|
||||
|
||||
const clearedCount = Number(diskDetectionService.forceUnlockDevice(entry.devicePath, {
|
||||
reason: 'stale_lock',
|
||||
deletedJobIds: []
|
||||
deletedJobIds: clearedJobIds
|
||||
}) || 0);
|
||||
if (clearedCount > 0) {
|
||||
if (clearedCount > 0 || clearedJobIds.length > 0) {
|
||||
cleared.push(entry.devicePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (cleared.length > 0) {
|
||||
logger.warn('drive-lock:stale-unlocked', { devicePaths: cleared });
|
||||
this._broadcastPipelineStateChanged();
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
@@ -10082,7 +10163,7 @@ class PipelineService extends EventEmitter {
|
||||
let filmRunning = 0;
|
||||
let audioRunning = 0;
|
||||
for (const job of rows) {
|
||||
if (this.isSeriesContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) {
|
||||
if (this.isContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) {
|
||||
continue;
|
||||
}
|
||||
const status = String(job?.status || job?.last_state || '').trim().toUpperCase();
|
||||
@@ -10113,6 +10194,15 @@ class PipelineService extends EventEmitter {
|
||||
return jobKind === 'dvd_series_container';
|
||||
}
|
||||
|
||||
isMultipartMovieContainerHistoryJob(jobLike = null) {
|
||||
const jobKind = String(jobLike?.job_kind || jobLike?.jobKind || '').trim().toLowerCase();
|
||||
return jobKind === 'multipart_movie_container';
|
||||
}
|
||||
|
||||
isContainerHistoryJob(jobLike = null) {
|
||||
return this.isSeriesContainerHistoryJob(jobLike) || this.isMultipartMovieContainerHistoryJob(jobLike);
|
||||
}
|
||||
|
||||
_deriveSeriesContainerStateFromChildren(childJobs = []) {
|
||||
const rows = Array.isArray(childJobs) ? childJobs : [];
|
||||
if (rows.length === 0) {
|
||||
@@ -10575,6 +10665,37 @@ class PipelineService extends EventEmitter {
|
||||
const finalState = errorCount > 0
|
||||
? 'ERROR'
|
||||
: (cancelledCount > 0 ? 'CANCELLED' : 'FINISHED');
|
||||
let finalizedParentRawPath = String(parentJob?.raw_path || '').trim() || null;
|
||||
if (finalState === 'FINISHED' && finalizedParentRawPath) {
|
||||
const completedRawPath = buildCompletedRawPath(finalizedParentRawPath);
|
||||
if (completedRawPath && completedRawPath !== finalizedParentRawPath) {
|
||||
if (fs.existsSync(completedRawPath)) {
|
||||
logger.warn('series-batch:raw-dir-finalize:target-exists', {
|
||||
parentJobId: normalizedParentJobId,
|
||||
sourceRawPath: finalizedParentRawPath,
|
||||
targetRawPath: completedRawPath
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
fs.renameSync(finalizedParentRawPath, completedRawPath);
|
||||
await historyService.updateRawPathByOldPath(finalizedParentRawPath, completedRawPath);
|
||||
await historyService.appendLog(
|
||||
normalizedParentJobId,
|
||||
'SYSTEM',
|
||||
`RAW-Ordner nach Abschluss aller Serien-Encodes finalisiert (Prefix entfernt): ${finalizedParentRawPath} -> ${completedRawPath}`
|
||||
);
|
||||
finalizedParentRawPath = completedRawPath;
|
||||
} catch (rawFinalizeError) {
|
||||
logger.warn('series-batch:raw-dir-finalize:rename-failed', {
|
||||
parentJobId: normalizedParentJobId,
|
||||
sourceRawPath: finalizedParentRawPath,
|
||||
targetRawPath: completedRawPath,
|
||||
error: errorToMeta(rawFinalizeError)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const finalErrorMessage = finalState === 'FINISHED'
|
||||
? null
|
||||
: (
|
||||
@@ -10588,7 +10709,8 @@ class PipelineService extends EventEmitter {
|
||||
status: finalState,
|
||||
last_state: finalState,
|
||||
end_time: nowIso(),
|
||||
error_message: finalErrorMessage
|
||||
error_message: finalErrorMessage,
|
||||
...(finalizedParentRawPath ? { raw_path: finalizedParentRawPath } : {})
|
||||
});
|
||||
// Remove stale queue entries that still target this parent batch.
|
||||
this.queueEntries = this.queueEntries.filter((entry) => {
|
||||
@@ -10645,12 +10767,13 @@ class PipelineService extends EventEmitter {
|
||||
const hasPendingChildren = terminalCount < totalCount;
|
||||
const hasErrors = errorCount > 0;
|
||||
const hasCancellations = cancelledCount > 0;
|
||||
const nextState = hasErrors
|
||||
? 'ERROR'
|
||||
: (hasCancellations ? 'CANCELLED' : (hasPendingChildren ? 'ENCODING' : 'READY_TO_ENCODE'));
|
||||
const nextLastState = hasPendingChildren && nextState === 'ENCODING'
|
||||
// Keep parent job in ENCODING while any child is still pending/running.
|
||||
// Partial errors/cancellations are reflected via summary/error counters,
|
||||
// but must not flip the parent state to terminal before all children end.
|
||||
const nextState = hasPendingChildren
|
||||
? 'ENCODING'
|
||||
: nextState;
|
||||
: (hasErrors ? 'ERROR' : (hasCancellations ? 'CANCELLED' : 'READY_TO_ENCODE'));
|
||||
const nextLastState = hasPendingChildren ? 'ENCODING' : nextState;
|
||||
const nextErrorMessage = hasErrors
|
||||
? `Serien-Batch enthält ${errorCount} Fehler-Episode(n).`
|
||||
: (
|
||||
@@ -11506,7 +11629,7 @@ class PipelineService extends EventEmitter {
|
||||
historyService.getQueueIdleJobs()
|
||||
]);
|
||||
const visibleRunningJobs = (Array.isArray(runningJobs) ? runningJobs : []).filter((job) => (
|
||||
!this.isSeriesContainerHistoryJob(job)
|
||||
!this.isContainerHistoryJob(job)
|
||||
&& !this.isSeriesBatchParentQueueAnchor(job)
|
||||
));
|
||||
const runningPoolUsage = this.buildRunningPoolUsage(runningJobs);
|
||||
@@ -11523,7 +11646,7 @@ class PipelineService extends EventEmitter {
|
||||
.filter((id) => Number.isFinite(id) && id > 0)
|
||||
);
|
||||
const idleJobs = (Array.isArray(idleJobsRaw) ? idleJobsRaw : []).filter((job) => {
|
||||
if (this.isSeriesContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) {
|
||||
if (this.isContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) {
|
||||
return false;
|
||||
}
|
||||
const jobId = Number(job?.id);
|
||||
@@ -13074,6 +13197,21 @@ class PipelineService extends EventEmitter {
|
||||
jobId: normalizedJobId,
|
||||
rawPath: options?.rawPath || null
|
||||
});
|
||||
const activeLockPaths = Array.isArray(diskDetectionService.getActiveLocks?.())
|
||||
? diskDetectionService.getActiveLocks()
|
||||
.map((entry) => this.normalizeDrivePath(entry?.path))
|
||||
.filter((value) => Boolean(value) && !value.startsWith('__virtual__'))
|
||||
: [];
|
||||
if (activeLockPaths.length > 0) {
|
||||
try {
|
||||
await this._forceUnlockStaleDriveLocks(activeLockPaths);
|
||||
} catch (error) {
|
||||
logger.warn('analyze:raw-import:stale-lock-cleanup-failed', {
|
||||
jobId: normalizedJobId,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sourceJob = await historyService.getJobById(normalizedJobId);
|
||||
if (!sourceJob) {
|
||||
@@ -13594,6 +13732,15 @@ class PipelineService extends EventEmitter {
|
||||
detectedTitle,
|
||||
jobKind: resolveJobKindForMediaProfile(mediaProfile)
|
||||
});
|
||||
let analyzeDriveLockHeld = false;
|
||||
if (effectiveAnalyzeDevicePath && !String(effectiveAnalyzeDevicePath).startsWith('__virtual__')) {
|
||||
analyzeDriveLockHeld = this._acquireDriveLockForJob(effectiveAnalyzeDevicePath, job.id, {
|
||||
stage: 'ANALYZING',
|
||||
source: 'DRIVE_ANALYZE',
|
||||
mediaProfile,
|
||||
reason: 'drive_analyze_running'
|
||||
});
|
||||
}
|
||||
|
||||
// Surface the newly created analyze job immediately in the Ripper UI,
|
||||
// even if metadata provider lookups (TMDb/OMDb) take longer.
|
||||
@@ -13885,6 +14032,10 @@ class PipelineService extends EventEmitter {
|
||||
logger.error('metadata:prepare:failed', { jobId: job.id, error: errorToMeta(error) });
|
||||
await this.failJob(job.id, 'METADATA_SELECTION', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (analyzeDriveLockHeld) {
|
||||
this._releaseDriveLockForJob(job.id, { reason: 'drive_analyze_finished' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15736,7 +15887,10 @@ class PipelineService extends EventEmitter {
|
||||
seasonName = null,
|
||||
episodeCount = null,
|
||||
episodes = null,
|
||||
discNumber = null
|
||||
discNumber = null,
|
||||
duplicateAction = null,
|
||||
existingJobId = null,
|
||||
existingDiscNumber = null
|
||||
}) {
|
||||
this.ensureNotBusy('selectMetadata', jobId);
|
||||
logger.info('metadata:selected', {
|
||||
@@ -15754,7 +15908,10 @@ class PipelineService extends EventEmitter {
|
||||
tmdbId,
|
||||
workflowKind,
|
||||
seasonNumber,
|
||||
discNumber
|
||||
discNumber,
|
||||
duplicateAction,
|
||||
existingJobId,
|
||||
existingDiscNumber
|
||||
});
|
||||
|
||||
const job = await historyService.getJobById(jobId);
|
||||
@@ -16084,6 +16241,9 @@ class PipelineService extends EventEmitter {
|
||||
? 'series'
|
||||
: (effectiveMetadataProvider === 'omdb' ? 'film' : null);
|
||||
const effectiveWorkflowKind = requestedWorkflowKind || providerWorkflowKind || existingWorkflowKind;
|
||||
const normalizedDuplicateAction = String(duplicateAction || '').trim().toLowerCase();
|
||||
const requestedExistingJobId = normalizePositiveInteger(existingJobId);
|
||||
const requestedExistingDiscNumber = normalizePositiveInteger(existingDiscNumber);
|
||||
if (isSeriesDiscMediaProfile(mediaProfile)) {
|
||||
if (effectiveWorkflowKind === 'series') {
|
||||
effectiveMetadataProvider = 'tmdb';
|
||||
@@ -16144,7 +16304,6 @@ class PipelineService extends EventEmitter {
|
||||
effectiveProviderId = null;
|
||||
effectiveTmdbId = null;
|
||||
effectiveMetadataKind = 'movie';
|
||||
effectiveDiscNumber = null;
|
||||
effectiveSeasonNumber = null;
|
||||
effectiveSeasonName = null;
|
||||
effectiveEpisodeCount = 0;
|
||||
@@ -16271,6 +16430,364 @@ class PipelineService extends EventEmitter {
|
||||
...(tmdbDetails && typeof tmdbDetails === 'object' ? { tmdbDetails } : {})
|
||||
};
|
||||
|
||||
const isDiscFilmMetadataSelection = isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'film';
|
||||
const filmMetadataFingerprint = isDiscFilmMetadataSelection
|
||||
? buildMovieMetadataFingerprint(mediaProfile, {
|
||||
imdbId: effectiveImdbId,
|
||||
title: effectiveTitle,
|
||||
year: effectiveYear
|
||||
})
|
||||
: null;
|
||||
let multipartContainerJobId = null;
|
||||
let multipartExistingJobId = null;
|
||||
let multipartCurrentDiscNumber = null;
|
||||
let multipartExistingDiscNumber = null;
|
||||
let shouldDetachMultipartContainer = false;
|
||||
|
||||
if (isDiscFilmMetadataSelection) {
|
||||
const currentParentJobId = normalizePositiveInteger(job.parent_job_id || null);
|
||||
if (currentParentJobId) {
|
||||
const currentParentJob = await historyService.getJobById(currentParentJobId).catch(() => null);
|
||||
if (String(currentParentJob?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container') {
|
||||
shouldDetachMultipartContainer = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (filmMetadataFingerprint) {
|
||||
const db = await getDb();
|
||||
let duplicateRows = await db.all(
|
||||
`
|
||||
SELECT *
|
||||
FROM jobs
|
||||
WHERE id != ?
|
||||
AND media_type = ?
|
||||
AND metadata_fingerprint = ?
|
||||
AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container')
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 25
|
||||
`,
|
||||
[Number(jobId), mediaProfile, filmMetadataFingerprint]
|
||||
);
|
||||
if ((!Array.isArray(duplicateRows) || duplicateRows.length === 0) && effectiveImdbId) {
|
||||
duplicateRows = await db.all(
|
||||
`
|
||||
SELECT *
|
||||
FROM jobs
|
||||
WHERE id != ?
|
||||
AND media_type = ?
|
||||
AND LOWER(TRIM(COALESCE(imdb_id, ''))) = LOWER(TRIM(?))
|
||||
AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container')
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 25
|
||||
`,
|
||||
[Number(jobId), mediaProfile, String(effectiveImdbId || '').trim()]
|
||||
);
|
||||
}
|
||||
if ((!Array.isArray(duplicateRows) || duplicateRows.length === 0) && effectiveTitle) {
|
||||
if (effectiveYear) {
|
||||
duplicateRows = await db.all(
|
||||
`
|
||||
SELECT *
|
||||
FROM jobs
|
||||
WHERE id != ?
|
||||
AND media_type = ?
|
||||
AND LOWER(TRIM(COALESCE(title, ''))) = LOWER(TRIM(?))
|
||||
AND CAST(COALESCE(year, 0) AS INTEGER) = ?
|
||||
AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container')
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 25
|
||||
`,
|
||||
[Number(jobId), mediaProfile, String(effectiveTitle || '').trim(), Number(effectiveYear)]
|
||||
);
|
||||
} else {
|
||||
duplicateRows = await db.all(
|
||||
`
|
||||
SELECT *
|
||||
FROM jobs
|
||||
WHERE id != ?
|
||||
AND media_type = ?
|
||||
AND LOWER(TRIM(COALESCE(title, ''))) = LOWER(TRIM(?))
|
||||
AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container')
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 25
|
||||
`,
|
||||
[Number(jobId), mediaProfile, String(effectiveTitle || '').trim()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const duplicateCandidates = Array.isArray(duplicateRows) ? duplicateRows : [];
|
||||
const firstDuplicate = duplicateCandidates[0] || null;
|
||||
const isMultipartAction = normalizedDuplicateAction === 'multipart_movie';
|
||||
const allowDuplicate = normalizedDuplicateAction === 'allow_new';
|
||||
|
||||
let resolvedExistingJob = null;
|
||||
if (requestedExistingJobId && requestedExistingJobId !== Number(jobId)) {
|
||||
resolvedExistingJob = await historyService.getJobById(requestedExistingJobId).catch(() => null);
|
||||
} else if (firstDuplicate) {
|
||||
resolvedExistingJob = await historyService.getJobById(firstDuplicate.id).catch(() => null);
|
||||
}
|
||||
|
||||
const throwDuplicateConflict = (existingJob) => {
|
||||
const existingDisc = resolveDiscNumberFromJobLike(existingJob);
|
||||
const error = new Error('Metadaten bereits in der Historie gefunden. Bitte Auswahl übernehmen oder Multipart movie wählen.');
|
||||
error.statusCode = 409;
|
||||
error.details = [
|
||||
{
|
||||
code: 'METADATA_DUPLICATE_FOUND',
|
||||
fingerprint: filmMetadataFingerprint,
|
||||
mediaProfile,
|
||||
existingJob: existingJob
|
||||
? {
|
||||
id: Number(existingJob.id || 0) || null,
|
||||
title: String(existingJob.title || existingJob.detected_title || '').trim() || null,
|
||||
year: Number(existingJob.year || 0) || null,
|
||||
status: String(existingJob.status || '').trim() || null,
|
||||
lastState: String(existingJob.last_state || '').trim() || null,
|
||||
mediaType: String(existingJob.media_type || existingJob.mediaType || '').trim().toLowerCase() || null,
|
||||
jobKind: String(existingJob.job_kind || existingJob.jobKind || '').trim().toLowerCase() || null,
|
||||
discNumber: existingDisc,
|
||||
isMultipartMovie: Number(existingJob.is_multipart_movie || 0) === 1
|
||||
}
|
||||
: null
|
||||
}
|
||||
];
|
||||
throw error;
|
||||
};
|
||||
|
||||
if (resolvedExistingJob && !allowDuplicate && !isMultipartAction) {
|
||||
throwDuplicateConflict(resolvedExistingJob);
|
||||
}
|
||||
|
||||
if (isMultipartAction) {
|
||||
if (!resolvedExistingJob || Number(resolvedExistingJob?.id || 0) === Number(jobId)) {
|
||||
throwDuplicateConflict(resolvedExistingJob || firstDuplicate);
|
||||
}
|
||||
|
||||
const existingMediaProfile = normalizeMediaProfile(resolvedExistingJob?.media_type || resolvedExistingJob?.mediaType || null);
|
||||
if (existingMediaProfile !== normalizeMediaProfile(mediaProfile)) {
|
||||
const error = new Error('Multipart movie benötigt denselben Medientyp (DVD oder Blu-ray).');
|
||||
error.statusCode = 409;
|
||||
error.details = [{ code: 'MULTIPART_MEDIA_MISMATCH' }];
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingMkInfo = this.safeParseJson(resolvedExistingJob?.makemkv_info_json)
|
||||
|| (resolvedExistingJob?.makemkvInfo && typeof resolvedExistingJob.makemkvInfo === 'object'
|
||||
? resolvedExistingJob.makemkvInfo
|
||||
: {});
|
||||
const existingAnalyzeContext = existingMkInfo?.analyzeContext && typeof existingMkInfo.analyzeContext === 'object'
|
||||
? existingMkInfo.analyzeContext
|
||||
: {};
|
||||
const existingSelectedMetadata = resolveSelectedMetadataForJob(
|
||||
resolvedExistingJob,
|
||||
existingAnalyzeContext,
|
||||
null
|
||||
);
|
||||
const existingIsSeries = isSeriesDvdMetadataSelection(existingMediaProfile, existingSelectedMetadata, existingAnalyzeContext);
|
||||
if (existingIsSeries) {
|
||||
const error = new Error('Multipart movie ist nur für Film-Jobs erlaubt.');
|
||||
error.statusCode = 409;
|
||||
error.details = [{ code: 'MULTIPART_SERIES_NOT_ALLOWED' }];
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingFingerprint = buildMovieMetadataFingerprint(existingMediaProfile, {
|
||||
imdbId: existingSelectedMetadata?.imdbId || resolvedExistingJob?.imdb_id || null,
|
||||
title: existingSelectedMetadata?.title || resolvedExistingJob?.title || resolvedExistingJob?.detected_title || null,
|
||||
year: existingSelectedMetadata?.year || resolvedExistingJob?.year || null
|
||||
});
|
||||
if (existingFingerprint && existingFingerprint !== filmMetadataFingerprint) {
|
||||
const error = new Error('Ausgewählter bestehender Job passt nicht zu den aktuellen Film-Metadaten.');
|
||||
error.statusCode = 409;
|
||||
error.details = [{ code: 'MULTIPART_METADATA_MISMATCH' }];
|
||||
throw error;
|
||||
}
|
||||
|
||||
multipartCurrentDiscNumber = normalizePositiveInteger(effectiveDiscNumber);
|
||||
multipartExistingDiscNumber = requestedExistingDiscNumber || resolveDiscNumberFromJobLike(resolvedExistingJob);
|
||||
if (!multipartCurrentDiscNumber || !multipartExistingDiscNumber) {
|
||||
const error = new Error('Für Multipart movie sind Disc-Nummern für beide Jobs erforderlich (>= 1).');
|
||||
error.statusCode = 400;
|
||||
error.details = [{ code: 'MULTIPART_DISC_REQUIRED' }];
|
||||
throw error;
|
||||
}
|
||||
if (multipartCurrentDiscNumber === multipartExistingDiscNumber) {
|
||||
const error = new Error('Disc-Nummern im Multipart-Container müssen eindeutig sein.');
|
||||
error.statusCode = 409;
|
||||
error.details = [{ code: 'MULTIPART_DISC_ALREADY_EXISTS', discNumber: multipartCurrentDiscNumber }];
|
||||
throw error;
|
||||
}
|
||||
|
||||
const loadMultipartContainerById = async (candidateId) => {
|
||||
const normalizedCandidateId = normalizePositiveInteger(candidateId);
|
||||
if (!normalizedCandidateId) {
|
||||
return null;
|
||||
}
|
||||
const row = await historyService.getJobById(normalizedCandidateId).catch(() => null);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return String(row?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container'
|
||||
? row
|
||||
: null;
|
||||
};
|
||||
|
||||
let multipartContainerJob = null;
|
||||
multipartContainerJob = await loadMultipartContainerById(job.parent_job_id);
|
||||
if (!multipartContainerJob) {
|
||||
multipartContainerJob = await loadMultipartContainerById(resolvedExistingJob?.parent_job_id);
|
||||
}
|
||||
if (!multipartContainerJob) {
|
||||
const existingContainerByFingerprint = await db.get(
|
||||
`
|
||||
SELECT *
|
||||
FROM jobs
|
||||
WHERE job_kind = 'multipart_movie_container'
|
||||
AND media_type = ?
|
||||
AND metadata_fingerprint = ?
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[mediaProfile, filmMetadataFingerprint]
|
||||
);
|
||||
if (existingContainerByFingerprint?.id) {
|
||||
multipartContainerJob = await historyService.getJobById(existingContainerByFingerprint.id).catch(() => null);
|
||||
}
|
||||
}
|
||||
if (!multipartContainerJob) {
|
||||
multipartContainerJob = await historyService.createJob({
|
||||
discDevice: job.disc_device || resolvedExistingJob?.disc_device || null,
|
||||
status: job.status || 'METADATA_SELECTION',
|
||||
detectedTitle: effectiveTitle || job.detected_title || null,
|
||||
mediaType: mediaProfile,
|
||||
jobKind: 'multipart_movie_container'
|
||||
});
|
||||
}
|
||||
|
||||
multipartContainerJobId = normalizePositiveInteger(multipartContainerJob?.id);
|
||||
if (!multipartContainerJobId) {
|
||||
const error = new Error('Multipart-Container konnte nicht angelegt werden.');
|
||||
error.statusCode = 500;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const containerChildren = await historyService.listChildJobs(multipartContainerJobId);
|
||||
const conflictingDiscChild = (Array.isArray(containerChildren) ? containerChildren : []).find((child) => {
|
||||
const childId = normalizePositiveInteger(child?.id);
|
||||
if (!childId || childId === Number(jobId) || childId === Number(resolvedExistingJob?.id || 0)) {
|
||||
return false;
|
||||
}
|
||||
const childDisc = resolveDiscNumberFromJobLike(child);
|
||||
return childDisc === multipartCurrentDiscNumber || childDisc === multipartExistingDiscNumber;
|
||||
});
|
||||
if (conflictingDiscChild) {
|
||||
const conflictDisc = resolveDiscNumberFromJobLike(conflictingDiscChild);
|
||||
const error = new Error(`Disc ${conflictDisc || '?'} ist im Multipart-Container bereits vergeben.`);
|
||||
error.statusCode = 409;
|
||||
error.details = [
|
||||
{
|
||||
code: 'MULTIPART_DISC_ALREADY_EXISTS',
|
||||
containerJobId: multipartContainerJobId,
|
||||
existingJobId: Number(conflictingDiscChild?.id || 0) || null,
|
||||
discNumber: conflictDisc || null
|
||||
}
|
||||
];
|
||||
throw error;
|
||||
}
|
||||
|
||||
multipartExistingJobId = normalizePositiveInteger(resolvedExistingJob?.id);
|
||||
const patchedExistingSelectedMetadata = {
|
||||
...existingSelectedMetadata,
|
||||
workflowKind: 'film',
|
||||
metadataProvider: 'omdb',
|
||||
metadataKind: 'movie',
|
||||
providerId: null,
|
||||
tmdbId: null,
|
||||
seasonNumber: null,
|
||||
seasonName: null,
|
||||
episodeCount: 0,
|
||||
episodes: [],
|
||||
discNumber: multipartExistingDiscNumber
|
||||
};
|
||||
const existingAnalyzeSelectedMetadata = existingAnalyzeContext?.selectedMetadata && typeof existingAnalyzeContext.selectedMetadata === 'object'
|
||||
? existingAnalyzeContext.selectedMetadata
|
||||
: {};
|
||||
const existingTopLevelSelectedMetadata = existingMkInfo?.selectedMetadata && typeof existingMkInfo.selectedMetadata === 'object'
|
||||
? existingMkInfo.selectedMetadata
|
||||
: {};
|
||||
const patchedExistingMkInfo = this.withAnalyzeContextMediaProfile({
|
||||
...(existingMkInfo && typeof existingMkInfo === 'object' ? existingMkInfo : {}),
|
||||
selectedMetadata: {
|
||||
...existingTopLevelSelectedMetadata,
|
||||
...patchedExistingSelectedMetadata
|
||||
},
|
||||
analyzeContext: {
|
||||
...existingAnalyzeContext,
|
||||
metadataProvider: 'omdb',
|
||||
workflowKind: 'film',
|
||||
selectedMetadata: {
|
||||
...existingAnalyzeSelectedMetadata,
|
||||
...patchedExistingSelectedMetadata
|
||||
}
|
||||
}
|
||||
}, mediaProfile);
|
||||
|
||||
if (multipartExistingJobId) {
|
||||
await historyService.updateJob(multipartExistingJobId, {
|
||||
parent_job_id: multipartContainerJobId,
|
||||
media_type: mediaProfile,
|
||||
job_kind: 'multipart_movie_child',
|
||||
is_multipart_movie: 1,
|
||||
disc_number: multipartExistingDiscNumber,
|
||||
metadata_fingerprint: filmMetadataFingerprint,
|
||||
makemkv_info_json: JSON.stringify(patchedExistingMkInfo)
|
||||
});
|
||||
}
|
||||
|
||||
const containerSelectedMetadataPatch = {
|
||||
...selectedMetadata,
|
||||
workflowKind: 'film',
|
||||
metadataProvider: 'omdb',
|
||||
metadataKind: 'movie',
|
||||
providerId: null,
|
||||
tmdbId: null,
|
||||
seasonNumber: null,
|
||||
seasonName: null,
|
||||
episodeCount: 0,
|
||||
episodes: [],
|
||||
discNumber: null
|
||||
};
|
||||
const containerMkInfo = this.withAnalyzeContextMediaProfile({
|
||||
analyzeContext: {
|
||||
metadataProvider: 'omdb',
|
||||
workflowKind: 'film',
|
||||
selectedMetadata: containerSelectedMetadataPatch
|
||||
},
|
||||
selectedMetadata: containerSelectedMetadataPatch
|
||||
}, mediaProfile);
|
||||
await historyService.updateJob(multipartContainerJobId, {
|
||||
title: effectiveTitle,
|
||||
year: effectiveYear,
|
||||
imdb_id: effectiveImdbId,
|
||||
poster_url: posterValue,
|
||||
selected_from_omdb: selectedFromOmdb,
|
||||
omdb_json: omdbJsonValue,
|
||||
status: job.status || 'METADATA_SELECTION',
|
||||
last_state: job.status || 'METADATA_SELECTION',
|
||||
media_type: mediaProfile,
|
||||
job_kind: 'multipart_movie_container',
|
||||
is_multipart_movie: 1,
|
||||
disc_number: null,
|
||||
metadata_fingerprint: filmMetadataFingerprint,
|
||||
makemkv_info_json: JSON.stringify(containerMkInfo)
|
||||
});
|
||||
|
||||
shouldDetachMultipartContainer = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shouldDetachSeriesContainer = isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'film';
|
||||
let parentContainerJobId = shouldDetachSeriesContainer
|
||||
? null
|
||||
@@ -16574,13 +17091,42 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
}, mediaProfile);
|
||||
|
||||
const seriesDetachPatch = shouldDetachSeriesContainer
|
||||
const seriesDetachPatch = (shouldDetachSeriesContainer && !multipartContainerJobId)
|
||||
? {
|
||||
parent_job_id: null,
|
||||
job_kind: resolveJobKindForMediaProfile(mediaProfile),
|
||||
media_type: mediaProfile
|
||||
}
|
||||
: {};
|
||||
const multipartAttachPatch = multipartContainerJobId
|
||||
? {
|
||||
parent_job_id: multipartContainerJobId,
|
||||
job_kind: 'multipart_movie_child',
|
||||
media_type: mediaProfile,
|
||||
is_multipart_movie: 1,
|
||||
disc_number: multipartCurrentDiscNumber || normalizePositiveInteger(effectiveDiscNumber),
|
||||
metadata_fingerprint: filmMetadataFingerprint || null
|
||||
}
|
||||
: {};
|
||||
const multipartDetachPatch = (shouldDetachMultipartContainer && !multipartContainerJobId)
|
||||
? {
|
||||
parent_job_id: null,
|
||||
job_kind: resolveJobKindForMediaProfile(mediaProfile),
|
||||
media_type: mediaProfile,
|
||||
is_multipart_movie: 0
|
||||
}
|
||||
: {};
|
||||
const filmMetadataPatch = isDiscFilmMetadataSelection
|
||||
? {
|
||||
metadata_fingerprint: filmMetadataFingerprint || null,
|
||||
disc_number: multipartContainerJobId
|
||||
? (multipartCurrentDiscNumber || normalizePositiveInteger(effectiveDiscNumber))
|
||||
: (normalizePositiveInteger(effectiveDiscNumber) || null),
|
||||
...(multipartContainerJobId
|
||||
? { is_multipart_movie: 1 }
|
||||
: (shouldDetachMultipartContainer ? { is_multipart_movie: 0 } : {}))
|
||||
}
|
||||
: {};
|
||||
await historyService.updateJob(jobId, {
|
||||
title: effectiveTitle,
|
||||
year: effectiveYear,
|
||||
@@ -16593,9 +17139,27 @@ class PipelineService extends EventEmitter {
|
||||
raw_path: updatedRawPath,
|
||||
makemkv_info_json: JSON.stringify(updatedMakemkvInfo),
|
||||
...(parentContainerJobId ? { parent_job_id: parentContainerJobId, job_kind: 'dvd_series_child', media_type: mediaProfile } : {}),
|
||||
...multipartAttachPatch,
|
||||
...multipartDetachPatch,
|
||||
...filmMetadataPatch,
|
||||
...seriesDetachPatch
|
||||
});
|
||||
|
||||
if (multipartContainerJobId) {
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Multipart movie aktiviert: Container #${multipartContainerJobId}, Disc ${multipartCurrentDiscNumber || '-'}`
|
||||
);
|
||||
if (multipartExistingJobId && Number(multipartExistingJobId) !== Number(jobId)) {
|
||||
await historyService.appendLog(
|
||||
multipartExistingJobId,
|
||||
'SYSTEM',
|
||||
`Multipart movie aktiviert: Container #${multipartContainerJobId}, Disc ${multipartExistingDiscNumber || '-'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Bild in Cache laden (async, blockiert nicht)
|
||||
if (posterValue && !thumbnailService.isLocalUrl(posterValue)) {
|
||||
historyService.queuePosterCache(jobId, posterValue, {
|
||||
@@ -20746,7 +21310,8 @@ class PipelineService extends EventEmitter {
|
||||
};
|
||||
const postEncodeScriptsSummary = this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan);
|
||||
let finalizedRawPath = activeRawPath || null;
|
||||
if (activeRawPath) {
|
||||
const deferRawFinalizeUntilSeriesBatchDone = isSeriesBatchEpisodeRun || Boolean(seriesBatchParentJobId);
|
||||
if (activeRawPath && !deferRawFinalizeUntilSeriesBatchDone) {
|
||||
const currentRawPath = String(activeRawPath || '').trim();
|
||||
const completedRawPath = buildCompletedRawPath(currentRawPath);
|
||||
if (completedRawPath && completedRawPath !== currentRawPath) {
|
||||
|
||||
Reference in new issue
Block a user