0.14.0 BluRay Series Flow
This commit is contained in:
@@ -685,8 +685,11 @@ function getSeriesRawPathCandidates(settings = {}) {
|
||||
for (const candidate of getConfiguredMediaPathList(source, 'series_raw_dir')) {
|
||||
pushPath(candidate);
|
||||
}
|
||||
pushPath(source.raw_dir_bluray_series);
|
||||
pushPath(source.raw_dir_dvd_series);
|
||||
|
||||
const blurayEffective = settingsService.resolveEffectiveToolSettings(source, 'bluray') || {};
|
||||
pushPath(blurayEffective.series_raw_dir);
|
||||
const dvdEffective = settingsService.resolveEffectiveToolSettings(source, 'dvd') || {};
|
||||
pushPath(dvdEffective.series_raw_dir);
|
||||
|
||||
@@ -705,7 +708,7 @@ function isLikelySeriesRawPath(rawPath, settings = {}) {
|
||||
}
|
||||
|
||||
// Fallback for common default layouts when series raw dir is not explicitly configured.
|
||||
return /(^|\/)raw\/dvd\/series(\/|$)/i.test(normalizedRawPath);
|
||||
return /(^|\/)raw\/(?:dvd|bluray)\/series(\/|$)/i.test(normalizedRawPath);
|
||||
}
|
||||
|
||||
function getOrphanRawScanPathList(settings = {}) {
|
||||
@@ -732,6 +735,7 @@ function getOrphanRawScanPathList(settings = {}) {
|
||||
for (const candidate of getConfiguredMediaPathList(source, 'series_raw_dir')) {
|
||||
pushPath(candidate);
|
||||
}
|
||||
pushPath(source.raw_dir_bluray_series);
|
||||
pushPath(source.raw_dir_dvd_series);
|
||||
pushPath(source.converter_raw_dir);
|
||||
|
||||
@@ -739,7 +743,7 @@ function getOrphanRawScanPathList(settings = {}) {
|
||||
for (const profile of ['bluray', 'dvd', 'cd', 'audiobook']) {
|
||||
const effective = settingsService.resolveEffectiveToolSettings(source, profile) || {};
|
||||
pushPath(effective.raw_dir);
|
||||
if (profile === 'dvd') {
|
||||
if (profile === 'dvd' || profile === 'bluray') {
|
||||
pushPath(effective.series_raw_dir);
|
||||
}
|
||||
}
|
||||
@@ -791,7 +795,7 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
|
||||
|| Number(selectedMetadata?.episodeCount || 0) > 0
|
||||
|| ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind)
|
||||
);
|
||||
const isSeriesDvd = mediaType === 'dvd' && (
|
||||
const isSeriesDisc = (mediaType === 'dvd' || mediaType === 'bluray') && (
|
||||
seriesSignals
|
||||
|| hasSeriesMetadataHint
|
||||
|| hasSeriesJobKind
|
||||
@@ -825,8 +829,8 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
|
||||
const seriesRawDir = String(effectiveSettings?.series_raw_dir || '').trim();
|
||||
const seriesMovieDir = String(effectiveSettings?.series_dir || '').trim();
|
||||
const defaultRawDir = String(effectiveSettings?.raw_dir || '').trim();
|
||||
let rawDir = isSeriesDvd ? (seriesRawDir || defaultRawDir) : defaultRawDir;
|
||||
if (isSeriesDvd) {
|
||||
let rawDir = isSeriesDisc ? (seriesRawDir || defaultRawDir) : defaultRawDir;
|
||||
if (isSeriesDisc) {
|
||||
const normalizedStoredRawPath = normalizeComparablePath(job?.raw_path);
|
||||
if (normalizedStoredRawPath) {
|
||||
const seriesRawCandidates = getSeriesRawPathCandidates(settings || {});
|
||||
@@ -836,11 +840,11 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
|
||||
}
|
||||
}
|
||||
}
|
||||
const configuredMovieDir = isSeriesDvd
|
||||
const configuredMovieDir = isSeriesDisc
|
||||
? (seriesMovieDir || String(effectiveSettings?.movie_dir || '').trim())
|
||||
: String(effectiveSettings?.movie_dir || '').trim();
|
||||
const movieDir = configuredMovieDir || rawDir;
|
||||
const rawLookupDirsBase = isSeriesDvd
|
||||
const rawLookupDirsBase = isSeriesDisc
|
||||
? getSeriesRawPathCandidates(settings || {})
|
||||
: getConfiguredMediaPathList(settings || {}, 'raw_dir');
|
||||
const rawLookupDirs = rawLookupDirsBase
|
||||
@@ -848,7 +852,7 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
|
||||
const effectiveRawPath = job?.raw_path
|
||||
? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs)
|
||||
: (job?.raw_path || null);
|
||||
const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path && !isSeriesDvd)
|
||||
const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path && !isSeriesDisc)
|
||||
? resolveEffectiveOutputPath(job.output_path, configuredMovieDir)
|
||||
: (job?.output_path || null);
|
||||
|
||||
@@ -1062,6 +1066,97 @@ function inferSiblingOutputFolders(outputPath) {
|
||||
return candidates.map((item) => item.path);
|
||||
}
|
||||
|
||||
function resolveExistingOutputPathVariant(outputPath) {
|
||||
const normalizedOutputPath = normalizeComparablePath(outputPath);
|
||||
if (!normalizedOutputPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(normalizedOutputPath)) {
|
||||
return normalizedOutputPath;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore and continue with fallback probing.
|
||||
}
|
||||
|
||||
const parsed = path.parse(normalizedOutputPath);
|
||||
const parentDir = normalizeComparablePath(parsed.dir);
|
||||
if (!parentDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidatePaths = [];
|
||||
const candidateSet = new Set();
|
||||
const pushCandidate = (candidatePath) => {
|
||||
const normalized = normalizeComparablePath(candidatePath);
|
||||
if (!normalized || candidateSet.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
candidateSet.add(normalized);
|
||||
candidatePaths.push(normalized);
|
||||
};
|
||||
|
||||
// If parent directory is numbered (e.g. "Staffel 3_2"), prefer base folder first.
|
||||
const parentName = path.basename(parentDir);
|
||||
const parsedParentName = parseNumberedFolderName(parentName);
|
||||
const parentRoot = path.dirname(parentDir);
|
||||
const siblingBaseName = parsedParentName.baseName || parentName;
|
||||
if (parsedParentName.numbered && parsedParentName.baseName) {
|
||||
pushCandidate(path.join(parentRoot, parsedParentName.baseName, parsed.base));
|
||||
}
|
||||
|
||||
// If filename is numbered (e.g. "Episode_2.mkv"), prefer base filename first.
|
||||
const parsedFileName = parseNumberedFolderName(parsed.name);
|
||||
if (parsedFileName.numbered && parsedFileName.baseName) {
|
||||
pushCandidate(path.join(parentDir, `${parsedFileName.baseName}${parsed.ext}`));
|
||||
}
|
||||
|
||||
// Probe sibling directories with same base name (base, _2, _3, ...).
|
||||
if (parentRoot && siblingBaseName) {
|
||||
try {
|
||||
const siblingRegex = new RegExp(`^${escapeRegExp(siblingBaseName)}(?:_(\\d+))?$`);
|
||||
const siblingDirs = fs.readdirSync(parentRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry?.isDirectory?.())
|
||||
.map((entry) => {
|
||||
const name = String(entry?.name || '').trim();
|
||||
const match = name.match(siblingRegex);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const suffixNumber = match[1] ? Number(match[1]) : 0;
|
||||
return {
|
||||
dirPath: path.join(parentRoot, name),
|
||||
suffixNumber: Number.isFinite(suffixNumber) && suffixNumber > 0 ? Math.trunc(suffixNumber) : 0
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => {
|
||||
if (left.suffixNumber !== right.suffixNumber) {
|
||||
return left.suffixNumber - right.suffixNumber;
|
||||
}
|
||||
return String(left.dirPath || '').localeCompare(String(right.dirPath || ''), 'de-DE');
|
||||
});
|
||||
for (const sibling of siblingDirs) {
|
||||
pushCandidate(path.join(sibling.dirPath, parsed.base));
|
||||
}
|
||||
} catch (_error) {
|
||||
// Best effort.
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidatePath of candidatePaths) {
|
||||
try {
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
return candidatePath;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore and continue.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function compareOutputFolderPaths(leftPath, rightPath) {
|
||||
const leftNormalized = normalizeComparablePath(leftPath);
|
||||
const rightNormalized = normalizeComparablePath(rightPath);
|
||||
@@ -3025,6 +3120,7 @@ class HistoryService {
|
||||
const mediaProfile = normalizeMediaTypeValue(options.mediaProfile) || 'other';
|
||||
const importedAt = String(options.importedAt || new Date().toISOString()).trim() || new Date().toISOString();
|
||||
const rawPath = String(options.rawPath || '').trim() || null;
|
||||
const stripRecoveryMetadata = Boolean(options.stripRecoveryMetadata);
|
||||
const existingInfo = options.existingInfo && typeof options.existingInfo === 'object'
|
||||
? options.existingInfo
|
||||
: {};
|
||||
@@ -3109,7 +3205,7 @@ class HistoryService {
|
||||
if (Array.isArray(recovery.tracks) && recovery.tracks.length > 0) {
|
||||
nextInfo.tracks = recovery.tracks;
|
||||
}
|
||||
if (recovery.selectedMetadata && typeof recovery.selectedMetadata === 'object') {
|
||||
if (!stripRecoveryMetadata && recovery.selectedMetadata && typeof recovery.selectedMetadata === 'object') {
|
||||
const recoverySelectedMetadata = compactMetadataObject(recovery.selectedMetadata);
|
||||
nextInfo.selectedMetadata = {
|
||||
...(nextInfo.selectedMetadata && typeof nextInfo.selectedMetadata === 'object'
|
||||
@@ -4895,19 +4991,7 @@ class HistoryService {
|
||||
|
||||
const folderName = path.basename(absRawPath);
|
||||
const metadata = parseRawFolderMetadata(folderName);
|
||||
let omdbById = null;
|
||||
if (metadata.imdbId) {
|
||||
try {
|
||||
omdbById = await omdbService.fetchByImdbId(metadata.imdbId);
|
||||
} catch (error) {
|
||||
logger.warn('job:import-orphan-raw:omdb-fetch-failed', {
|
||||
rawPath: absRawPath,
|
||||
imdbId: metadata.imdbId,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
const effectiveTitle = omdbById?.title || metadata.title || folderName;
|
||||
const effectiveTitle = metadata.title || folderName;
|
||||
const importedAt = new Date().toISOString();
|
||||
const created = await this.createJob({
|
||||
discDevice: null,
|
||||
@@ -4984,7 +5068,6 @@ class HistoryService {
|
||||
const initialSourceSelectedMetadata = initialSourceJobContext?.selectedMetadata && typeof initialSourceJobContext.selectedMetadata === 'object'
|
||||
? initialSourceJobContext.selectedMetadata
|
||||
: {};
|
||||
const orphanPosterUrl = omdbById?.poster || null;
|
||||
const cdRecovery = effectiveDetectedMediaType === 'cd'
|
||||
? recoverCdJobArtifactsForImport({
|
||||
currentRawPath: finalRawPath,
|
||||
@@ -5011,12 +5094,6 @@ class HistoryService {
|
||||
],
|
||||
mediaProfile: effectiveDetectedMediaType
|
||||
}) || initialSourceJobContext;
|
||||
const sourceJob = sourceJobContext?.job || null;
|
||||
const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object'
|
||||
? sourceJobContext.selectedMetadata
|
||||
: {};
|
||||
const sourcePosterCandidate = this.getPreferredPosterCandidateForImport(sourceJobContext, null);
|
||||
const recoveredCdEncodePlan = cdRecovery ? this.buildRecoveredCdEncodePlan(cdRecovery) : null;
|
||||
const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({
|
||||
importedAt,
|
||||
rawPath: finalRawPath,
|
||||
@@ -5025,27 +5102,12 @@ class HistoryService {
|
||||
mediaProfile: effectiveDetectedMediaType,
|
||||
// RAW-Import soll wie "Disk analysieren" starten:
|
||||
// keine geerbte Serien-/Metadatenzuordnung vor dem eigentlichen Analyse-Scan.
|
||||
existingInfo: cdRecovery ? (sourceJobContext?.makemkvInfo || null) : null,
|
||||
existingInfo: null,
|
||||
recovery: cdRecovery,
|
||||
selectedMetadata: null,
|
||||
analyzeContextPatch: null
|
||||
analyzeContextPatch: null,
|
||||
stripRecoveryMetadata: true
|
||||
});
|
||||
const recoveredPosterUrl = orphanPosterUrl
|
||||
|| (thumbnailService.isLocalUrl(sourcePosterCandidate) ? null : sourcePosterCandidate)
|
||||
|| null;
|
||||
const recoveredExternalId = String(
|
||||
omdbById?.imdbId
|
||||
|| metadata.imdbId
|
||||
|| sourceSelectedMetadata?.mbId
|
||||
|| sourceSelectedMetadata?.musicBrainzId
|
||||
|| sourceSelectedMetadata?.musicbrainzId
|
||||
|| sourceSelectedMetadata?.musicbrainz_id
|
||||
|| sourceSelectedMetadata?.music_brainz_id
|
||||
|| sourceSelectedMetadata?.musicbrainz
|
||||
|| sourceSelectedMetadata?.mbid
|
||||
|| sourceJob?.imdb_id
|
||||
|| ''
|
||||
).trim() || null;
|
||||
await this.updateJob(created.id, {
|
||||
...(effectiveDetectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}),
|
||||
...(effectiveDetectedMediaType === 'cd' ? { job_kind: 'cd', media_type: 'cd' } : {}),
|
||||
@@ -5053,32 +5115,19 @@ class HistoryService {
|
||||
...(effectiveDetectedMediaType === 'bluray' ? { job_kind: 'bluray', media_type: 'bluray' } : {}),
|
||||
status: 'FINISHED',
|
||||
last_state: 'FINISHED',
|
||||
title: omdbById?.title
|
||||
|| sourceSelectedMetadata?.title
|
||||
|| sourceSelectedMetadata?.album
|
||||
|| sourceJob?.title
|
||||
|| metadata.title
|
||||
|| cdRecovery?.selectedMetadata?.title
|
||||
|| null,
|
||||
year: Number.isFinite(Number(omdbById?.year))
|
||||
? Number(omdbById.year)
|
||||
: (
|
||||
sourceSelectedMetadata?.year
|
||||
|| sourceJob?.year
|
||||
|| metadata.year
|
||||
|| cdRecovery?.selectedMetadata?.year
|
||||
|| null
|
||||
),
|
||||
imdb_id: recoveredExternalId,
|
||||
poster_url: recoveredPosterUrl,
|
||||
omdb_json: omdbById?.raw ? JSON.stringify(omdbById.raw) : (sourceJob?.omdb_json || null),
|
||||
selected_from_omdb: omdbById ? 1 : Number(sourceJob?.selected_from_omdb || 0),
|
||||
// /database-Import startet bewusst metadata-clean: keine Übernahme aus früheren Läufen.
|
||||
title: null,
|
||||
year: null,
|
||||
imdb_id: null,
|
||||
poster_url: null,
|
||||
omdb_json: null,
|
||||
selected_from_omdb: 0,
|
||||
rip_successful: 1,
|
||||
raw_path: finalRawPath,
|
||||
output_path: cdRecovery?.outputPath || null,
|
||||
output_path: null,
|
||||
handbrake_info_json: null,
|
||||
mediainfo_info_json: null,
|
||||
encode_plan_json: recoveredCdEncodePlan ? JSON.stringify(recoveredCdEncodePlan) : null,
|
||||
encode_plan_json: null,
|
||||
encode_input_path: null,
|
||||
encode_review_confirmed: 0,
|
||||
error_message: null,
|
||||
@@ -5092,11 +5141,6 @@ class HistoryService {
|
||||
});
|
||||
}
|
||||
|
||||
// Bild direkt persistieren (kein Rip-Prozess, daher kein Cache-Zwischenschritt)
|
||||
if (orphanPosterUrl || sourcePosterCandidate) {
|
||||
this.restoreImportedPoster(created.id, sourceJobContext, orphanPosterUrl || sourcePosterCandidate).catch(() => {});
|
||||
}
|
||||
|
||||
if (!cdRecovery?.logSources?.length) {
|
||||
await this.appendLog(
|
||||
created.id,
|
||||
@@ -5112,22 +5156,11 @@ class HistoryService {
|
||||
? `Historieneintrag aus RAW erstellt (Medientyp: ${effectiveDetectedMediaType}). Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}`
|
||||
: `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath} (Medientyp: ${effectiveDetectedMediaType})`
|
||||
);
|
||||
if (metadata.imdbId) {
|
||||
await this.appendLog(
|
||||
created.id,
|
||||
'SYSTEM',
|
||||
omdbById
|
||||
? `OMDb-Zuordnung via IMDb-ID übernommen: ${omdbById.imdbId} (${omdbById.title || '-'})`
|
||||
: `OMDb-Zuordnung via IMDb-ID fehlgeschlagen: ${metadata.imdbId}`
|
||||
);
|
||||
}
|
||||
if (sourceJob) {
|
||||
await this.appendLog(
|
||||
created.id,
|
||||
'SYSTEM',
|
||||
`Metadaten aus vorhandenem Job #${sourceJob.id} wiederhergestellt.`
|
||||
);
|
||||
}
|
||||
await this.appendLog(
|
||||
created.id,
|
||||
'SYSTEM',
|
||||
'Metadaten aus früheren Läufen wurden beim /database-Import verworfen (metadata-clean Start).'
|
||||
);
|
||||
|
||||
logger.info('job:import-orphan-raw', {
|
||||
jobId: created.id,
|
||||
@@ -5268,8 +5301,9 @@ class HistoryService {
|
||||
job?.encode_plan_json,
|
||||
job?.handbrake_info_json
|
||||
);
|
||||
if (resolvedJobMediaType === 'dvd' && discNumber === null) {
|
||||
const error = new Error('Serien-DVD erkannt: Disk-Nummer ist Pflicht (Start bei 1).');
|
||||
if ((resolvedJobMediaType === 'dvd' || resolvedJobMediaType === 'bluray') && discNumber === null) {
|
||||
const seriesDiscLabel = resolvedJobMediaType === 'bluray' ? 'Blu-ray' : 'DVD';
|
||||
const error = new Error(`Serien-${seriesDiscLabel} erkannt: Disk-Nummer ist Pflicht (Start bei 1).`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
@@ -6363,7 +6397,7 @@ class HistoryService {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteJobFiles(jobId, target = 'both') {
|
||||
async deleteJobFiles(jobId, target = 'both', options = {}) {
|
||||
const allowedTargets = new Set(['raw', 'movie', 'both']);
|
||||
if (!allowedTargets.has(target)) {
|
||||
const error = new Error(`Ungültiges target '${target}'. Erlaubt: raw, movie, both.`);
|
||||
@@ -6378,6 +6412,81 @@ class HistoryService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const includeRelated = Boolean(options?.includeRelated);
|
||||
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
|
||||
? options.selectedMoviePaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
|
||||
if (includeRelated || (selectedMoviePaths && selectedMoviePaths.length > 0)) {
|
||||
const normalizedJobId = normalizeJobIdValue(jobId);
|
||||
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated });
|
||||
const summary = this._deletePathsFromPreview(preview, target, { selectedMoviePaths });
|
||||
const relatedJobIds = Array.from(new Set(
|
||||
(Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [])
|
||||
.map((row) => normalizeJobIdValue(row?.id))
|
||||
.filter(Boolean)
|
||||
));
|
||||
|
||||
if (summary.movie?.deleted) {
|
||||
const movieDeleteEntries = (Array.isArray(summary?.deletedPaths) ? summary.deletedPaths : [])
|
||||
.filter((entry) => String(entry?.target || '').trim().toLowerCase() === 'movie')
|
||||
.map((entry) => ({
|
||||
path: normalizeComparablePath(entry?.path),
|
||||
type: String(entry?.type || '').trim().toLowerCase()
|
||||
}))
|
||||
.filter((entry) => Boolean(entry.path));
|
||||
const previewMoviePaths = (Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [])
|
||||
.map((candidate) => normalizeComparablePath(candidate?.path))
|
||||
.filter(Boolean);
|
||||
const cleanupPaths = new Set();
|
||||
|
||||
for (const deletedEntry of movieDeleteEntries) {
|
||||
cleanupPaths.add(deletedEntry.path);
|
||||
for (const candidatePath of previewMoviePaths) {
|
||||
if (deletedEntry.type === 'directory') {
|
||||
if (isPathInside(deletedEntry.path, candidatePath)) {
|
||||
cleanupPaths.add(candidatePath);
|
||||
}
|
||||
} else if (candidatePath === deletedEntry.path) {
|
||||
cleanupPaths.add(candidatePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const deletedPath of cleanupPaths) {
|
||||
await this.removeJobOutputFolderFromJobs(
|
||||
relatedJobIds.length > 0 ? relatedJobIds : [normalizedJobId],
|
||||
deletedPath
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.appendLog(
|
||||
normalizedJobId,
|
||||
'USER_ACTION',
|
||||
`Dateien gelöscht (${target}) - includeRelated=${includeRelated} - raw=${JSON.stringify(summary.raw)} movie=${JSON.stringify(summary.movie)}`
|
||||
);
|
||||
logger.info('job:delete-files', {
|
||||
jobId: normalizedJobId,
|
||||
includeRelated,
|
||||
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0,
|
||||
summary
|
||||
});
|
||||
|
||||
const [updated, enrichSettings] = await Promise.all([
|
||||
this.getJobById(normalizedJobId),
|
||||
settingsService.getSettingsMap()
|
||||
]);
|
||||
return {
|
||||
summary,
|
||||
includeRelated,
|
||||
relatedJobIds,
|
||||
job: enrichJobRow(updated, enrichSettings)
|
||||
};
|
||||
}
|
||||
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
|
||||
const effectiveRawPath = resolvedPaths.effectiveRawPath;
|
||||
@@ -6717,20 +6826,36 @@ class HistoryService {
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
const addFolder = (rawFolder, defaults = {}) => {
|
||||
const outputPath = String(rawFolder?.output_path || rawFolder?.outputPath || '').trim();
|
||||
if (!outputPath) {
|
||||
const outputPathRaw = String(rawFolder?.output_path || rawFolder?.outputPath || '').trim();
|
||||
if (!outputPathRaw) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeComparablePath(outputPath);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
const normalizedRawPath = normalizeComparablePath(outputPathRaw);
|
||||
if (!normalizedRawPath) {
|
||||
return;
|
||||
}
|
||||
let exists = false;
|
||||
let resolvedOutputPath = normalizedRawPath;
|
||||
try {
|
||||
exists = fs.existsSync(normalized);
|
||||
exists = fs.existsSync(normalizedRawPath);
|
||||
} catch (_error) {
|
||||
exists = false;
|
||||
}
|
||||
if (!exists) {
|
||||
const repairedPath = resolveExistingOutputPathVariant(normalizedRawPath);
|
||||
if (repairedPath) {
|
||||
resolvedOutputPath = repairedPath;
|
||||
try {
|
||||
exists = fs.existsSync(repairedPath);
|
||||
} catch (_error) {
|
||||
exists = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
const normalized = normalizeComparablePath(resolvedOutputPath);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
if (!includeMissing && !exists) {
|
||||
return;
|
||||
}
|
||||
@@ -6738,7 +6863,7 @@ class HistoryService {
|
||||
merged.push({
|
||||
id: normalizeJobIdValue(rawFolder?.id),
|
||||
job_id: normalizeJobIdValue(rawFolder?.job_id ?? rawFolder?.jobId ?? defaults.job_id ?? normalizedJobId),
|
||||
output_path: outputPath,
|
||||
output_path: resolvedOutputPath,
|
||||
label: String(rawFolder?.label || defaults.label || '').trim() || null,
|
||||
created_at: String(rawFolder?.created_at || rawFolder?.createdAt || '').trim() || null,
|
||||
exists
|
||||
@@ -6940,6 +7065,104 @@ class HistoryService {
|
||||
}
|
||||
return normalizedRequested;
|
||||
};
|
||||
const collectIncompleteMoviePathsFromPreview = (preview) => {
|
||||
const rows = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [];
|
||||
return rows
|
||||
.filter((row) => Boolean(row?.exists))
|
||||
.map((row) => String(row?.path || '').trim())
|
||||
.filter((candidatePath) => Boolean(candidatePath))
|
||||
.filter((candidatePath) => /(^|[\\/])incomplete_job-\d+([\\/]|$)/i.test(candidatePath));
|
||||
};
|
||||
const cleanupIncompleteMovieFoldersForJobs = async (jobRows = [], ownerJobIds = []) => {
|
||||
const normalizedOwnerJobIds = Array.from(new Set(
|
||||
(Array.isArray(ownerJobIds) ? ownerJobIds : [])
|
||||
.map((value) => normalizeJobIdValue(value))
|
||||
.filter(Boolean)
|
||||
));
|
||||
const rows = Array.isArray(jobRows) ? jobRows : [];
|
||||
if (rows.length === 0) {
|
||||
return { attempted: 0, deleted: 0, failed: 0, paths: [] };
|
||||
}
|
||||
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const candidatePaths = new Set();
|
||||
const fallbackIncompletePattern = /^incomplete_job-(\d+)\s*$/i;
|
||||
|
||||
for (const row of rows) {
|
||||
const rowId = normalizeJobIdValue(row?.id);
|
||||
if (!rowId) {
|
||||
continue;
|
||||
}
|
||||
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, row);
|
||||
const movieRoot = normalizeComparablePath(resolvedPaths?.movieDir);
|
||||
if (!movieRoot || isFilesystemRootPath(movieRoot)) {
|
||||
continue;
|
||||
}
|
||||
const canonicalPath = normalizeComparablePath(path.join(movieRoot, `Incomplete_job-${rowId}`));
|
||||
if (canonicalPath && isPathInside(movieRoot, canonicalPath)) {
|
||||
candidatePaths.add(canonicalPath);
|
||||
}
|
||||
try {
|
||||
if (!fs.existsSync(movieRoot) || !fs.lstatSync(movieRoot).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const entries = fs.readdirSync(movieRoot, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry?.isDirectory?.()) {
|
||||
continue;
|
||||
}
|
||||
const match = String(entry?.name || '').match(fallbackIncompletePattern);
|
||||
if (normalizeJobIdValue(match?.[1]) !== rowId) {
|
||||
continue;
|
||||
}
|
||||
const scannedPath = normalizeComparablePath(path.join(movieRoot, entry.name));
|
||||
if (scannedPath && isPathInside(movieRoot, scannedPath)) {
|
||||
candidatePaths.add(scannedPath);
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// best-effort scan
|
||||
}
|
||||
}
|
||||
|
||||
if (candidatePaths.size === 0) {
|
||||
return { attempted: 0, deleted: 0, failed: 0, paths: [] };
|
||||
}
|
||||
|
||||
const deletedPaths = [];
|
||||
let failedCount = 0;
|
||||
for (const candidatePath of candidatePaths) {
|
||||
try {
|
||||
const inspection = inspectDeletionPath(candidatePath);
|
||||
if (!inspection.exists) {
|
||||
await this.removeJobOutputFolderFromJobs(normalizedOwnerJobIds, candidatePath);
|
||||
deletedPaths.push(candidatePath);
|
||||
continue;
|
||||
}
|
||||
if (inspection.isDirectory) {
|
||||
deleteFilesRecursively(inspection.path, false);
|
||||
} else if (inspection.isFile) {
|
||||
fs.unlinkSync(inspection.path);
|
||||
}
|
||||
await this.removeJobOutputFolderFromJobs(normalizedOwnerJobIds, candidatePath);
|
||||
deletedPaths.push(candidatePath);
|
||||
} catch (_error) {
|
||||
failedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
attempted: candidatePaths.size,
|
||||
deleted: deletedPaths.length,
|
||||
failed: failedCount,
|
||||
paths: deletedPaths
|
||||
};
|
||||
};
|
||||
const normalizedSelectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
|
||||
? options.selectedMoviePaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
|
||||
const includeRelated = Boolean(options?.includeRelated);
|
||||
if (!includeRelated) {
|
||||
@@ -6956,13 +7179,37 @@ class HistoryService {
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
if (isSeriesContainerRow(existing)) {
|
||||
const containerChildren = await this.listChildJobs(normalizedJobId);
|
||||
if (Array.isArray(containerChildren) && containerChildren.length > 0) {
|
||||
const error = new Error(
|
||||
'Serien-Container kann nicht einzeln gelöscht werden, solange noch Child-Jobs vorhanden sind.'
|
||||
);
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
|
||||
let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
|
||||
let selectedMoviePathsForDelete = normalizedSelectedMoviePaths;
|
||||
let preview = null;
|
||||
let fileSummary = null;
|
||||
if (effectiveFileTarget === 'none') {
|
||||
preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
|
||||
const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview);
|
||||
if (autoIncompleteMoviePaths.length > 0) {
|
||||
effectiveFileTarget = 'movie';
|
||||
if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) {
|
||||
selectedMoviePathsForDelete = autoIncompleteMoviePaths;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (effectiveFileTarget !== 'none') {
|
||||
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
|
||||
if (!preview) {
|
||||
preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
|
||||
}
|
||||
fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
|
||||
selectedMoviePaths: options?.selectedMoviePaths
|
||||
selectedMoviePaths: selectedMoviePathsForDelete
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7011,6 +7258,12 @@ class HistoryService {
|
||||
}
|
||||
|
||||
await db.run('DELETE FROM jobs WHERE id = ?', [normalizedJobId]);
|
||||
const stillExists = await db.get('SELECT id FROM jobs WHERE id = ?', [normalizedJobId]);
|
||||
if (stillExists) {
|
||||
const error = new Error(`Job #${normalizedJobId} konnte nicht aus der Datenbank entfernt werden.`);
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
await db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK');
|
||||
@@ -7020,6 +7273,11 @@ class HistoryService {
|
||||
await this.closeProcessLog(normalizedJobId);
|
||||
this._deleteProcessLogFile(normalizedJobId);
|
||||
thumbnailService.deleteThumbnail(normalizedJobId);
|
||||
const includeMovieCleanup = effectiveFileTarget === 'movie' || effectiveFileTarget === 'both';
|
||||
let incompleteCleanupSummary = null;
|
||||
if (includeMovieCleanup) {
|
||||
incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs([existing], [normalizedJobId]);
|
||||
}
|
||||
|
||||
logger.warn('job:deleted', {
|
||||
jobId: normalizedJobId,
|
||||
@@ -7027,6 +7285,7 @@ class HistoryService {
|
||||
requestedFileTarget: fileTarget,
|
||||
includeRelated: false,
|
||||
pipelineStateReset: isActivePipelineJob,
|
||||
incompleteCleanup: incompleteCleanupSummary,
|
||||
filesDeleted: fileSummary
|
||||
? {
|
||||
raw: fileSummary.raw?.filesDeleted ?? 0,
|
||||
@@ -7042,7 +7301,8 @@ class HistoryService {
|
||||
requestedFileTarget: fileTarget,
|
||||
includeRelated: false,
|
||||
deletedJobIds: [Number(normalizedJobId)],
|
||||
fileSummary
|
||||
fileSummary,
|
||||
incompleteCleanup: incompleteCleanupSummary
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7050,11 +7310,49 @@ class HistoryService {
|
||||
const existing = resolved?.job || null;
|
||||
const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId);
|
||||
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true });
|
||||
const deleteJobIds = Array.isArray(preview?.relatedJobs)
|
||||
let deleteJobIds = Array.isArray(preview?.relatedJobs)
|
||||
? preview.relatedJobs
|
||||
.map((row) => normalizeJobIdValue(row?.id))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
let rowById = new Map();
|
||||
if (normalizedJobId && !deleteJobIds.includes(normalizedJobId)) {
|
||||
deleteJobIds = [...deleteJobIds, normalizedJobId];
|
||||
}
|
||||
if (deleteJobIds.length > 0) {
|
||||
const db = await getDb();
|
||||
const placeholders = deleteJobIds.map(() => '?').join(', ');
|
||||
const rows = await db.all(
|
||||
`SELECT * FROM jobs WHERE id IN (${placeholders})`,
|
||||
deleteJobIds
|
||||
);
|
||||
rowById = new Map(
|
||||
(Array.isArray(rows) ? rows : [])
|
||||
.map((row) => [normalizeJobIdValue(row?.id), row])
|
||||
.filter(([id]) => Boolean(id))
|
||||
);
|
||||
const deleteSet = new Set(deleteJobIds);
|
||||
// Safety net: never delete a series container while it would still have
|
||||
// other children afterwards.
|
||||
for (const candidateId of [...deleteJobIds]) {
|
||||
const row = rowById.get(candidateId);
|
||||
if (!row || !isSeriesContainerRow(row)) {
|
||||
continue;
|
||||
}
|
||||
const childRows = await db.all(
|
||||
`SELECT id FROM jobs WHERE parent_job_id = ?`,
|
||||
[candidateId]
|
||||
);
|
||||
const hasRemainingChildOutsideDeleteSet = (Array.isArray(childRows) ? childRows : []).some((childRow) => {
|
||||
const childId = normalizeJobIdValue(childRow?.id);
|
||||
return Boolean(childId && !deleteSet.has(childId));
|
||||
});
|
||||
if (hasRemainingChildOutsideDeleteSet) {
|
||||
deleteSet.delete(candidateId);
|
||||
}
|
||||
}
|
||||
deleteJobIds = Array.from(deleteSet);
|
||||
}
|
||||
if (deleteJobIds.length === 0) {
|
||||
const error = new Error('Keine löschbaren Historien-Einträge gefunden.');
|
||||
error.statusCode = 404;
|
||||
@@ -7072,11 +7370,21 @@ class HistoryService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
|
||||
let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
|
||||
let selectedMoviePathsForDelete = normalizedSelectedMoviePaths;
|
||||
if (effectiveFileTarget === 'none') {
|
||||
const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview);
|
||||
if (autoIncompleteMoviePaths.length > 0) {
|
||||
effectiveFileTarget = 'movie';
|
||||
if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) {
|
||||
selectedMoviePathsForDelete = autoIncompleteMoviePaths;
|
||||
}
|
||||
}
|
||||
}
|
||||
let fileSummary = null;
|
||||
if (effectiveFileTarget !== 'none') {
|
||||
fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
|
||||
selectedMoviePaths: options?.selectedMoviePaths
|
||||
selectedMoviePaths: selectedMoviePathsForDelete
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7113,6 +7421,20 @@ class HistoryService {
|
||||
|
||||
const deletePlaceholders = deleteJobIds.map(() => '?').join(', ');
|
||||
await db.run(`DELETE FROM jobs WHERE id IN (${deletePlaceholders})`, deleteJobIds);
|
||||
const deletedVerificationRows = await db.all(
|
||||
`SELECT id FROM jobs WHERE id IN (${deletePlaceholders})`,
|
||||
deleteJobIds
|
||||
);
|
||||
if (Array.isArray(deletedVerificationRows) && deletedVerificationRows.length > 0) {
|
||||
const remainingIds = deletedVerificationRows
|
||||
.map((row) => normalizeJobIdValue(row?.id))
|
||||
.filter(Boolean);
|
||||
const error = new Error(
|
||||
`Folgende Jobs konnten nicht gelöscht werden: ${remainingIds.join(', ')}`
|
||||
);
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
await db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK');
|
||||
@@ -7124,6 +7446,19 @@ class HistoryService {
|
||||
this._deleteProcessLogFile(deletedJobId);
|
||||
thumbnailService.deleteThumbnail(deletedJobId);
|
||||
}
|
||||
const includeMovieCleanup = effectiveFileTarget === 'movie' || effectiveFileTarget === 'both';
|
||||
let incompleteCleanupSummary = null;
|
||||
if (includeMovieCleanup) {
|
||||
const deletedJobRows = deleteJobIds
|
||||
.map((id) => rowById.get(id))
|
||||
.filter(Boolean);
|
||||
incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs(deletedJobRows, deleteJobIds);
|
||||
}
|
||||
|
||||
const deletedJobIdSet = new Set(deleteJobIds);
|
||||
const deletedJobs = Array.isArray(preview?.relatedJobs)
|
||||
? preview.relatedJobs.filter((row) => deletedJobIdSet.has(normalizeJobIdValue(row?.id)))
|
||||
: [];
|
||||
|
||||
logger.warn('job:deleted', {
|
||||
jobId: normalizedJobId,
|
||||
@@ -7133,6 +7468,7 @@ class HistoryService {
|
||||
deletedJobIds: deleteJobIds,
|
||||
deletedJobCount: deleteJobIds.length,
|
||||
pipelineStateReset: activeJobIncluded,
|
||||
incompleteCleanup: incompleteCleanupSummary,
|
||||
filesDeleted: fileSummary
|
||||
? {
|
||||
raw: fileSummary.raw?.filesDeleted ?? 0,
|
||||
@@ -7148,8 +7484,9 @@ class HistoryService {
|
||||
requestedFileTarget: fileTarget,
|
||||
includeRelated: true,
|
||||
deletedJobIds: deleteJobIds,
|
||||
deletedJobs: preview.relatedJobs,
|
||||
fileSummary
|
||||
deletedJobs,
|
||||
fileSummary,
|
||||
incompleteCleanup: incompleteCleanupSummary
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user