1.0.0-rc2 RC2
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user