0.14.0-4 Merger
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled

This commit is contained in:
2026-04-21 14:27:07 +00:00
parent caf6a638f7
commit c27a81cdb1
127 changed files with 10643 additions and 335 deletions
+14
View File
@@ -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
});
+12 -3
View File
@@ -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 });
+58 -7
View File
@@ -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
});
}
+619 -54
View File
@@ -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) {