1.0.0-rc2 RC2
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-05-21 10:19:00 +02:00
parent 9b38d78042
commit eeef1fdb73
31 changed files with 4046 additions and 1449 deletions
+340 -11
View File
@@ -4688,7 +4688,9 @@ class HistoryService {
}
}
}
if (existingCountFromSeriesBatch > existingCount) {
// For History UI with FS checks enabled, never override real filesystem
// counts with stale series-batch metadata counters.
if (!includeFsChecks && existingCountFromSeriesBatch > existingCount) {
existingCount = existingCountFromSeriesBatch;
}
if (!encodeSuccessAny && existingCount > 0) {
@@ -5202,7 +5204,7 @@ class HistoryService {
`
SELECT *
FROM jobs
WHERE status IN ('ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING')
WHERE status IN ('ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING')
AND COALESCE(job_kind, '') != 'dvd_series_container'
ORDER BY updated_at ASC, id ASC
`
@@ -8354,6 +8356,195 @@ class HistoryService {
return { deleted, failed };
}
async _cleanupEmptyDirectoriesForDeletedJobs(jobRows = [], options = {}) {
const rows = Array.isArray(jobRows)
? jobRows.filter((row) => normalizeJobIdValue(row?.id))
: [];
if (rows.length === 0) {
return {
attemptedCandidates: { raw: 0, movie: 0 },
removed: { raw: 0, movie: 0, total: 0 },
removedPaths: [],
failures: []
};
}
const jobIds = Array.from(new Set(
rows
.map((row) => normalizeJobIdValue(row?.id))
.filter(Boolean)
));
const settings = options?.settings && typeof options.settings === 'object'
? options.settings
: await settingsService.getSettingsMap();
const lineageArtifactsByJobId = options?.lineageArtifactsByJobId instanceof Map
? options.lineageArtifactsByJobId
: await this.listJobLineageArtifactsByJobIds(jobIds).catch(() => new Map());
const outputFoldersByJobId = options?.outputFoldersByJobId instanceof Map
? options.outputFoldersByJobId
: await this.listJobOutputFoldersByJobIds(jobIds).catch(() => new Map());
const candidateSets = {
raw: new Set(),
movie: new Set()
};
const protectedRootSets = {
raw: new Set(),
movie: new Set()
};
for (const row of rows) {
const rowId = normalizeJobIdValue(row?.id);
if (!rowId) {
continue;
}
const lineageArtifacts = lineageArtifactsByJobId.get(rowId) || [];
const trackedOutputPaths = (outputFoldersByJobId.get(rowId) || [])
.map((folder) => String(folder?.output_path || '').trim())
.filter(Boolean);
const collected = this._collectDeleteCandidatesForJob(row, settings, {
lineageArtifacts,
trackedOutputPaths
});
for (const rootPath of (collected?.rawRoots || [])) {
const normalizedRoot = normalizeComparablePath(rootPath);
if (normalizedRoot && !isFilesystemRootPath(normalizedRoot)) {
protectedRootSets.raw.add(normalizedRoot);
}
}
for (const rootPath of (collected?.movieRoots || [])) {
const normalizedRoot = normalizeComparablePath(rootPath);
if (normalizedRoot && !isFilesystemRootPath(normalizedRoot)) {
protectedRootSets.movie.add(normalizedRoot);
}
}
for (const candidate of (collected?.rawCandidates || [])) {
const normalizedPath = normalizeComparablePath(candidate?.path);
if (normalizedPath && !isFilesystemRootPath(normalizedPath)) {
candidateSets.raw.add(normalizedPath);
}
}
for (const candidate of (collected?.movieCandidates || [])) {
const normalizedPath = normalizeComparablePath(candidate?.path);
if (normalizedPath && !isFilesystemRootPath(normalizedPath)) {
candidateSets.movie.add(normalizedPath);
}
}
}
const removedPaths = [];
const removedPathSet = new Set();
const failures = [];
const removedCountByTarget = {
raw: 0,
movie: 0
};
const rememberRemovedPath = (target, candidatePath) => {
const normalizedPath = normalizeComparablePath(candidatePath);
if (!normalizedPath || removedPathSet.has(normalizedPath)) {
return;
}
removedPathSet.add(normalizedPath);
removedPaths.push(normalizedPath);
if (target === 'raw' || target === 'movie') {
removedCountByTarget[target] += 1;
}
};
const cleanupTarget = (target) => {
const protectedRoots = Array.from(protectedRootSets[target] || []);
const protectedRootSet = new Set(protectedRoots);
const candidates = Array.from(candidateSets[target] || [])
.filter(Boolean)
.sort((left, right) => String(right || '').length - String(left || '').length);
for (const candidatePathRaw of candidates) {
const candidatePath = normalizeComparablePath(candidatePathRaw);
if (!candidatePath || isFilesystemRootPath(candidatePath)) {
continue;
}
const inspection = inspectDeletionPath(candidatePath);
if (inspection.exists && inspection.isDirectory) {
if (protectedRootSet.has(inspection.path)) {
continue;
}
if (protectedRoots.length > 0 && !protectedRoots.some((rootPath) => isPathInside(rootPath, inspection.path))) {
continue;
}
try {
const entries = fs.readdirSync(inspection.path);
if (entries.length > 0) {
continue;
}
fs.rmdirSync(inspection.path);
rememberRemovedPath(target, inspection.path);
if (protectedRoots.length > 0) {
const removedParentDirs = removeEmptyParentDirectories(path.dirname(inspection.path), {
protectedRoots
});
for (const removedPath of removedParentDirs) {
rememberRemovedPath(target, removedPath);
}
}
} catch (error) {
if (error?.code !== 'ENOENT') {
failures.push({
target,
path: inspection.path,
error: error?.message || String(error)
});
}
}
continue;
}
const parentDir = normalizeComparablePath(path.dirname(candidatePath));
if (!parentDir || isFilesystemRootPath(parentDir)) {
continue;
}
if (protectedRoots.length === 0) {
continue;
}
if (!protectedRoots.some((rootPath) => isPathInside(rootPath, parentDir))) {
continue;
}
try {
const removedParentDirs = removeEmptyParentDirectories(parentDir, {
protectedRoots
});
for (const removedPath of removedParentDirs) {
rememberRemovedPath(target, removedPath);
}
} catch (error) {
failures.push({
target,
path: parentDir,
error: error?.message || String(error)
});
}
}
};
cleanupTarget('raw');
cleanupTarget('movie');
return {
attemptedCandidates: {
raw: (candidateSets.raw || new Set()).size,
movie: (candidateSets.movie || new Set()).size
},
removed: {
raw: removedCountByTarget.raw,
movie: removedCountByTarget.movie,
total: removedPaths.length
},
removedPaths: [...removedPaths].sort((left, right) => left.localeCompare(right, 'de')),
failures
};
}
async deleteJob(jobId, fileTarget = 'none', options = {}) {
const allowedTargets = new Set(['none', 'raw', 'movie', 'both']);
if (!allowedTargets.has(fileTarget)) {
@@ -8406,6 +8597,46 @@ class HistoryService {
.map((row) => String(row?.path || '').trim())
.filter(Boolean);
};
const collectIncompleteRawPathsFromPreview = (preview, selectedJobRows = []) => {
const rows = Array.isArray(selectedJobRows) ? selectedJobRows : [];
if (rows.length === 0) {
return [];
}
const incompleteCancelledJobIds = new Set(
rows
.filter((row) => {
if (!row || typeof row !== 'object') {
return false;
}
if (isOrphanRawImportJobRow(row)) {
return false;
}
const status = String(row?.status || '').trim().toUpperCase();
const lastState = String(row?.last_state || '').trim().toUpperCase();
const isCancelled = status === 'CANCELLED' || lastState === 'CANCELLED';
if (!isCancelled) {
return false;
}
return Number(row?.rip_successful || 0) !== 1;
})
.map((row) => normalizeJobIdValue(row?.id))
.filter(Boolean)
);
if (incompleteCancelledJobIds.size === 0) {
return [];
}
const rawRows = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : [];
return rawRows
.filter((row) => Boolean(row?.exists))
.filter((row) => {
const jobIds = Array.isArray(row?.jobIds) ? row.jobIds : [];
return jobIds.some((jobId) => incompleteCancelledJobIds.has(normalizeJobIdValue(jobId)));
})
.map((row) => String(row?.path || '').trim())
.filter(Boolean);
};
const cleanupIncompleteMovieFoldersForJobs = async (jobRows = [], ownerJobIds = []) => {
const normalizedOwnerJobIds = Array.from(new Set(
(Array.isArray(ownerJobIds) ? ownerJobIds : [])
@@ -8533,6 +8764,31 @@ class HistoryService {
}
}
const cleanupDeletedJobRows = [existing];
let emptyDirectoryCleanupContext = {
settings: null,
lineageArtifactsByJobId: new Map(),
outputFoldersByJobId: new Map()
};
try {
const [cleanupSettings, cleanupLineageArtifactsByJobId, cleanupOutputFoldersByJobId] = await Promise.all([
settingsService.getSettingsMap(),
this.listJobLineageArtifactsByJobIds([normalizedJobId]),
this.listJobOutputFoldersByJobIds([normalizedJobId])
]);
emptyDirectoryCleanupContext = {
settings: cleanupSettings,
lineageArtifactsByJobId: cleanupLineageArtifactsByJobId,
outputFoldersByJobId: cleanupOutputFoldersByJobId
};
} catch (error) {
logger.warn('job:delete:empty-dir-context-failed', {
jobId: normalizedJobId,
includeRelated: false,
error: error?.message || String(error)
});
}
let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
let selectedRawPathsForDelete = normalizedSelectedRawPaths;
let selectedMoviePathsForDelete = normalizedSelectedMoviePaths;
@@ -8541,8 +8797,15 @@ class HistoryService {
if (effectiveFileTarget === 'none') {
preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview);
const autoIncompleteRawPaths = collectIncompleteRawPathsFromPreview(preview, [existing]);
if (autoIncompleteRawPaths.length > 0) {
effectiveFileTarget = autoIncompleteMoviePaths.length > 0 ? 'both' : 'raw';
if (!selectedRawPathsForDelete || selectedRawPathsForDelete.length === 0) {
selectedRawPathsForDelete = autoIncompleteRawPaths;
}
}
if (autoIncompleteMoviePaths.length > 0) {
effectiveFileTarget = 'movie';
effectiveFileTarget = effectiveFileTarget === 'raw' ? 'both' : 'movie';
if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) {
selectedMoviePathsForDelete = autoIncompleteMoviePaths;
}
@@ -8567,7 +8830,7 @@ class HistoryService {
);
const isActivePipelineJob = Number(pipelineRow?.active_job_id || 0) === Number(normalizedJobId);
const runningStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']);
const runningStates = new Set(['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']);
if (isActivePipelineJob && runningStates.has(String(pipelineRow?.state || ''))) {
const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.');
@@ -8626,6 +8889,19 @@ class HistoryService {
if (includeMovieCleanup) {
incompleteCleanupSummary = await cleanupIncompleteMovieFoldersForJobs([existing], [normalizedJobId]);
}
let emptyDirectoryCleanup = null;
try {
emptyDirectoryCleanup = await this._cleanupEmptyDirectoriesForDeletedJobs(
cleanupDeletedJobRows,
emptyDirectoryCleanupContext
);
} catch (error) {
logger.warn('job:delete:empty-dir-cleanup-failed', {
jobId: normalizedJobId,
includeRelated: false,
error: error?.message || String(error)
});
}
logger.warn('job:deleted', {
jobId: normalizedJobId,
@@ -8634,6 +8910,7 @@ class HistoryService {
includeRelated: false,
pipelineStateReset: isActivePipelineJob,
incompleteCleanup: incompleteCleanupSummary,
emptyDirectoryCleanup,
filesDeleted: fileSummary
? {
raw: fileSummary.raw?.filesDeleted ?? 0,
@@ -8650,7 +8927,8 @@ class HistoryService {
includeRelated: false,
deletedJobIds: [Number(normalizedJobId)],
fileSummary,
incompleteCleanup: incompleteCleanupSummary
incompleteCleanup: incompleteCleanupSummary,
emptyDirectoryCleanup
};
}
@@ -8715,20 +8993,58 @@ class HistoryService {
const pipelineRow = await db.get('SELECT state, active_job_id FROM pipeline_state WHERE id = 1');
const activePipelineJobId = normalizeJobIdValue(pipelineRow?.active_job_id);
const activeJobIncluded = Boolean(activePipelineJobId && deleteJobIds.includes(activePipelineJobId));
const runningStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']);
const runningStates = new Set(['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']);
if (activeJobIncluded && runningStates.has(String(pipelineRow?.state || ''))) {
const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.');
error.statusCode = 409;
throw error;
}
const deletedJobRows = deleteJobIds
.map((id) => rowById.get(id))
.filter(Boolean);
let emptyDirectoryCleanupContext = {
settings: null,
lineageArtifactsByJobId: new Map(),
outputFoldersByJobId: new Map()
};
try {
const [cleanupSettings, cleanupLineageArtifactsByJobId, cleanupOutputFoldersByJobId] = await Promise.all([
settingsService.getSettingsMap(),
this.listJobLineageArtifactsByJobIds(deleteJobIds),
this.listJobOutputFoldersByJobIds(deleteJobIds)
]);
emptyDirectoryCleanupContext = {
settings: cleanupSettings,
lineageArtifactsByJobId: cleanupLineageArtifactsByJobId,
outputFoldersByJobId: cleanupOutputFoldersByJobId
};
} catch (error) {
logger.warn('job:delete:empty-dir-context-failed', {
jobId: normalizedJobId,
includeRelated: true,
deleteJobIds,
error: error?.message || String(error)
});
}
let effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
let selectedRawPathsForDelete = normalizedSelectedRawPaths;
let selectedMoviePathsForDelete = normalizedSelectedMoviePaths;
if (effectiveFileTarget === 'none') {
const autoIncompleteMoviePaths = collectIncompleteMoviePathsFromPreview(preview);
const selectedDeleteRows = deleteJobIds
.map((id) => rowById.get(id))
.filter(Boolean);
const autoIncompleteRawPaths = collectIncompleteRawPathsFromPreview(preview, selectedDeleteRows);
if (autoIncompleteRawPaths.length > 0) {
effectiveFileTarget = autoIncompleteMoviePaths.length > 0 ? 'both' : 'raw';
if (!selectedRawPathsForDelete || selectedRawPathsForDelete.length === 0) {
selectedRawPathsForDelete = autoIncompleteRawPaths;
}
}
if (autoIncompleteMoviePaths.length > 0) {
effectiveFileTarget = 'movie';
effectiveFileTarget = effectiveFileTarget === 'raw' ? 'both' : 'movie';
if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) {
selectedMoviePathsForDelete = autoIncompleteMoviePaths;
}
@@ -8806,11 +9122,22 @@ class HistoryService {
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);
}
let emptyDirectoryCleanup = null;
try {
emptyDirectoryCleanup = await this._cleanupEmptyDirectoriesForDeletedJobs(
deletedJobRows,
emptyDirectoryCleanupContext
);
} catch (error) {
logger.warn('job:delete:empty-dir-cleanup-failed', {
jobId: normalizedJobId,
includeRelated: true,
deleteJobIds,
error: error?.message || String(error)
});
}
const deletedJobIdSet = new Set(deleteJobIds);
const deletedJobs = Array.isArray(preview?.relatedJobs)
@@ -8826,6 +9153,7 @@ class HistoryService {
deletedJobCount: deleteJobIds.length,
pipelineStateReset: activeJobIncluded,
incompleteCleanup: incompleteCleanupSummary,
emptyDirectoryCleanup,
filesDeleted: fileSummary
? {
raw: fileSummary.raw?.filesDeleted ?? 0,
@@ -8843,7 +9171,8 @@ class HistoryService {
deletedJobIds: deleteJobIds,
deletedJobs,
fileSummary,
incompleteCleanup: incompleteCleanupSummary
incompleteCleanup: incompleteCleanupSummary,
emptyDirectoryCleanup
};
}
}