|
|
|
@@ -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) {
|
|
|
|
|