9181 lines
314 KiB
JavaScript
9181 lines
314 KiB
JavaScript
const { getDb } = require('../db/database');
|
|
const logger = require('./logger').child('HISTORY');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { defaultConverterRawDir } = require('../config');
|
|
const settingsService = require('./settingsService');
|
|
const tmdbService = require('./tmdbService');
|
|
const cdRipService = require('./cdRipService');
|
|
const { getJobLogDir } = require('./logPathService');
|
|
const thumbnailService = require('./thumbnailService');
|
|
|
|
function parseJsonSafe(raw, fallback = null) {
|
|
if (!raw) {
|
|
return fallback;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch (error) {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024;
|
|
const processLogStreams = new Map();
|
|
const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'cd', 'audiobook', 'converter', 'other'];
|
|
const RAW_INCOMPLETE_PREFIX = 'Incomplete_';
|
|
const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_';
|
|
const PROCESS_LOG_FILE_PATTERN = /^job-(\d+)\.process\.log$/i;
|
|
const CD_OUTPUT_AUDIO_EXTENSIONS = new Set(['.flac', '.wav', '.mp3', '.opus', '.ogg']);
|
|
const CD_RAW_TRACK_FILE_PATTERN = /^track(\d{1,3})\.cdda\.wav$/i;
|
|
const INCOMPLETE_OUTPUT_PATH_PATTERN = /(^|[\\/])incomplete_job-\d+([\\/]|$)/i;
|
|
const INCOMPLETE_MERGE_OUTPUT_PATH_PATTERN = /(^|[\\/])incomplete_merge_[^\\/]+_job_\d+([\\/]|$)/i;
|
|
|
|
function inspectDirectory(dirPath) {
|
|
if (!dirPath) {
|
|
return {
|
|
path: null,
|
|
exists: false,
|
|
isDirectory: false,
|
|
isEmpty: null,
|
|
entryCount: null
|
|
};
|
|
}
|
|
|
|
try {
|
|
const stat = fs.statSync(dirPath);
|
|
if (!stat.isDirectory()) {
|
|
return {
|
|
path: dirPath,
|
|
exists: true,
|
|
isDirectory: false,
|
|
isEmpty: null,
|
|
entryCount: null
|
|
};
|
|
}
|
|
|
|
// Fast path: only determine whether directory is empty, avoid loading all entries.
|
|
let firstEntry = null;
|
|
let openError = null;
|
|
try {
|
|
const dir = fs.opendirSync(dirPath);
|
|
try {
|
|
firstEntry = dir.readSync();
|
|
} finally {
|
|
dir.closeSync();
|
|
}
|
|
} catch (error) {
|
|
openError = error;
|
|
}
|
|
if (openError) {
|
|
const entries = fs.readdirSync(dirPath);
|
|
return {
|
|
path: dirPath,
|
|
exists: true,
|
|
isDirectory: true,
|
|
isEmpty: entries.length === 0,
|
|
entryCount: entries.length
|
|
};
|
|
}
|
|
return {
|
|
path: dirPath,
|
|
exists: true,
|
|
isDirectory: true,
|
|
isEmpty: !firstEntry,
|
|
entryCount: firstEntry ? null : 0
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
path: dirPath,
|
|
exists: false,
|
|
isDirectory: false,
|
|
isEmpty: null,
|
|
entryCount: null
|
|
};
|
|
}
|
|
}
|
|
|
|
function inspectOutputFile(filePath) {
|
|
if (!filePath) {
|
|
return {
|
|
path: null,
|
|
exists: false,
|
|
isFile: false,
|
|
sizeBytes: null
|
|
};
|
|
}
|
|
|
|
try {
|
|
const stat = fs.statSync(filePath);
|
|
return {
|
|
path: filePath,
|
|
exists: true,
|
|
isFile: stat.isFile(),
|
|
sizeBytes: stat.size
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
path: filePath,
|
|
exists: false,
|
|
isFile: false,
|
|
sizeBytes: null
|
|
};
|
|
}
|
|
}
|
|
|
|
function isIncompleteRawStoragePath(rawPath) {
|
|
const normalized = String(rawPath || '').trim();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
const baseName = path.basename(normalized);
|
|
return /^incomplete_/i.test(baseName);
|
|
}
|
|
|
|
function isIncompleteOutputStoragePath(outputPath) {
|
|
const normalized = String(outputPath || '').trim();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
return INCOMPLETE_OUTPUT_PATH_PATTERN.test(normalized)
|
|
|| INCOMPLETE_MERGE_OUTPUT_PATH_PATTERN.test(normalized);
|
|
}
|
|
|
|
function parseBooleanValue(value, fallback = false) {
|
|
if (value === null || value === undefined) {
|
|
return fallback;
|
|
}
|
|
if (typeof value === 'boolean') {
|
|
return value;
|
|
}
|
|
const normalized = String(value || '').trim().toLowerCase();
|
|
if (!normalized) {
|
|
return fallback;
|
|
}
|
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
|
return true;
|
|
}
|
|
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
|
return false;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function resolveNfoPathFromOutputPath(outputPath) {
|
|
const normalizedOutputPath = String(outputPath || '').trim();
|
|
if (!normalizedOutputPath) {
|
|
return null;
|
|
}
|
|
const parsed = path.parse(normalizedOutputPath);
|
|
const extension = String(parsed.ext || '').trim();
|
|
const baseName = String(parsed.name || '').trim();
|
|
if (!extension || !baseName) {
|
|
return null;
|
|
}
|
|
return path.join(parsed.dir, `${baseName}.nfo`);
|
|
}
|
|
|
|
function inspectNfoFileStatus(nfoPath, options = {}) {
|
|
const includeFsChecks = options?.includeFsChecks !== false;
|
|
if (!nfoPath) {
|
|
return {
|
|
path: null,
|
|
exists: false,
|
|
isFile: false,
|
|
sizeBytes: null
|
|
};
|
|
}
|
|
if (!includeFsChecks) {
|
|
return {
|
|
path: nfoPath,
|
|
exists: null,
|
|
isFile: null,
|
|
sizeBytes: null
|
|
};
|
|
}
|
|
try {
|
|
const stat = fs.statSync(nfoPath);
|
|
return {
|
|
path: nfoPath,
|
|
exists: true,
|
|
isFile: stat.isFile(),
|
|
sizeBytes: stat.size
|
|
};
|
|
} catch (_error) {
|
|
return {
|
|
path: nfoPath,
|
|
exists: false,
|
|
isFile: false,
|
|
sizeBytes: null
|
|
};
|
|
}
|
|
}
|
|
|
|
function escapeXml(value) {
|
|
return String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function resolveNfoMetadataFromJob(job = {}) {
|
|
const mkInfo = parseInfoFromValue(job?.makemkvInfo ?? job?.makemkv_info_json, {});
|
|
const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(mkInfo);
|
|
const encodePlan = parseInfoFromValue(job?.encodePlan ?? job?.encode_plan_json, {});
|
|
const planMetadata = encodePlan?.metadata && typeof encodePlan.metadata === 'object'
|
|
? encodePlan.metadata
|
|
: {};
|
|
|
|
const title = String(
|
|
job?.title
|
|
|| selectedMetadata?.title
|
|
|| selectedMetadata?.seriesTitle
|
|
|| planMetadata?.title
|
|
|| job?.detected_title
|
|
|| ''
|
|
).trim();
|
|
const rawYear = Number(
|
|
job?.year
|
|
?? selectedMetadata?.year
|
|
?? planMetadata?.year
|
|
?? null
|
|
);
|
|
const year = Number.isFinite(rawYear) && rawYear > 0
|
|
? Math.trunc(rawYear)
|
|
: null;
|
|
const imdbId = String(
|
|
job?.imdb_id
|
|
|| selectedMetadata?.imdbId
|
|
|| planMetadata?.imdbId
|
|
|| ''
|
|
).trim();
|
|
|
|
return {
|
|
title,
|
|
year,
|
|
imdbId
|
|
};
|
|
}
|
|
|
|
function buildNfoXml(metadata = {}) {
|
|
const title = escapeXml(metadata?.title || '');
|
|
const year = metadata?.year !== null && metadata?.year !== undefined
|
|
? escapeXml(String(metadata.year))
|
|
: '';
|
|
const imdbId = escapeXml(metadata?.imdbId || '');
|
|
return [
|
|
'<movie>',
|
|
` <title>${title}</title>`,
|
|
` <year>${year}</year>`,
|
|
` <imdbid>${imdbId}</imdbid>`,
|
|
'</movie>',
|
|
''
|
|
].join('\n');
|
|
}
|
|
|
|
function resolveConverterMediaTypeForNfo(job = {}, encodePlan = null) {
|
|
const plan = encodePlan && typeof encodePlan === 'object'
|
|
? encodePlan
|
|
: parseInfoFromValue(job?.encodePlan ?? job?.encode_plan_json, {});
|
|
const mkInfo = parseInfoFromValue(job?.makemkvInfo ?? job?.makemkv_info_json, {});
|
|
const miInfo = parseInfoFromValue(job?.mediainfoInfo ?? job?.mediainfo_info_json, {});
|
|
const candidates = [
|
|
plan?.converterMediaType,
|
|
job?.converterMediaType,
|
|
mkInfo?.converterMediaType,
|
|
miInfo?.converterMediaType
|
|
];
|
|
for (const candidate of candidates) {
|
|
const raw = String(candidate || '').trim().toLowerCase();
|
|
if (raw === 'audio' || raw === 'video' || raw === 'iso') {
|
|
return raw;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveNfoSupportForJob(job = {}, settings = {}, options = {}) {
|
|
const mkInfo = parseInfoFromValue(job?.makemkvInfo ?? job?.makemkv_info_json, {});
|
|
const miInfo = parseInfoFromValue(job?.mediainfoInfo ?? job?.mediainfo_info_json, {});
|
|
const plan = parseInfoFromValue(job?.encodePlan ?? job?.encode_plan_json, {});
|
|
const hbInfo = parseInfoFromValue(job?.handbrakeInfo ?? job?.handbrake_info_json, {});
|
|
const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(mkInfo);
|
|
const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object'
|
|
? mkInfo.analyzeContext
|
|
: {};
|
|
const mediaType = normalizeMediaTypeValue(options?.mediaType)
|
|
|| normalizeMediaTypeValue(job?.mediaType)
|
|
|| normalizeMediaTypeValue(job?.media_type)
|
|
|| inferMediaType(job, mkInfo, miInfo, plan, hbInfo);
|
|
const normalizedJobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase();
|
|
const hasSeriesJobKind = normalizedJobKind === 'dvd_series_child' || normalizedJobKind === 'dvd_series_container';
|
|
const hasSeriesMetadata = hasSeriesMetadataSignals(selectedMetadata, analyzeContext);
|
|
const hasSeriesRawPathHint = isLikelySeriesRawPath(job?.raw_path, settings || {});
|
|
const seriesBatchMode = String(hbInfo?.mode || '').trim().toLowerCase() === 'series_batch'
|
|
|| Boolean(plan?.seriesBatchChild)
|
|
|| Boolean(plan?.seriesBatchVirtualEpisode);
|
|
const isSeriesMedia = (mediaType === 'dvd' || mediaType === 'bluray') && (
|
|
hasSeriesJobKind
|
|
|| hasSeriesMetadata
|
|
|| hasSeriesRawPathHint
|
|
|| seriesBatchMode
|
|
);
|
|
|
|
const outputPath = String(options?.outputPath || '').trim();
|
|
const outputExt = String(path.extname(outputPath || '') || '').trim().toLowerCase();
|
|
const audioOutputExtensions = new Set([
|
|
'.flac', '.wav', '.mp3', '.opus', '.ogg', '.m4a', '.m4b', '.aac', '.alac', '.aif', '.aiff', '.wma'
|
|
]);
|
|
const converterMediaType = resolveConverterMediaTypeForNfo(job, plan);
|
|
|
|
let supported = false;
|
|
if (mediaType === 'bluray' || mediaType === 'dvd') {
|
|
supported = !isSeriesMedia;
|
|
} else if (mediaType === 'converter') {
|
|
if (converterMediaType === 'audio') {
|
|
supported = false;
|
|
} else if (converterMediaType === 'video' || converterMediaType === 'iso') {
|
|
supported = true;
|
|
} else {
|
|
supported = !audioOutputExtensions.has(outputExt);
|
|
}
|
|
}
|
|
|
|
return {
|
|
supported,
|
|
mediaType: mediaType || null,
|
|
converterMediaType,
|
|
isSeriesMedia
|
|
};
|
|
}
|
|
|
|
function buildNfoStatusForJob({
|
|
job = {},
|
|
outputPath = null,
|
|
outputStatus = null,
|
|
includeFsChecks = true,
|
|
settings = {},
|
|
mediaType = null
|
|
} = {}) {
|
|
const normalizedOutputPath = String(outputPath || '').trim() || null;
|
|
const nfoPath = resolveNfoPathFromOutputPath(normalizedOutputPath);
|
|
const nfoStatus = inspectNfoFileStatus(nfoPath, { includeFsChecks });
|
|
const nfoSettingEnabled = parseBooleanValue(
|
|
settings?.generate_nfo_after_encode,
|
|
false
|
|
);
|
|
const supportInfo = resolveNfoSupportForJob(job, settings, {
|
|
mediaType,
|
|
outputPath: normalizedOutputPath
|
|
});
|
|
const outputExists = Boolean(outputStatus?.exists);
|
|
const outputIsFile = Boolean(outputStatus?.isFile);
|
|
const canGenerateManual = Boolean(supportInfo?.supported)
|
|
&& !nfoSettingEnabled
|
|
&& Boolean(nfoPath)
|
|
&& outputExists
|
|
&& outputIsFile
|
|
&& !Boolean(nfoStatus?.exists);
|
|
|
|
let reason = null;
|
|
if (!supportInfo?.supported) {
|
|
reason = 'unsupported_media';
|
|
} else if (nfoSettingEnabled) {
|
|
reason = 'setting_enabled';
|
|
} else if (!nfoPath) {
|
|
reason = 'output_not_file';
|
|
} else if (!outputExists || !outputIsFile) {
|
|
reason = 'output_missing';
|
|
} else if (Boolean(nfoStatus?.exists)) {
|
|
reason = 'nfo_exists';
|
|
} else {
|
|
reason = 'eligible';
|
|
}
|
|
|
|
return {
|
|
settingEnabled: nfoSettingEnabled,
|
|
supported: Boolean(supportInfo?.supported),
|
|
mediaType: supportInfo?.mediaType || null,
|
|
converterMediaType: supportInfo?.converterMediaType || null,
|
|
isSeriesMedia: Boolean(supportInfo?.isSeriesMedia),
|
|
outputPath: normalizedOutputPath,
|
|
nfoPath,
|
|
outputExists: includeFsChecks ? outputExists : null,
|
|
outputIsFile: includeFsChecks ? outputIsFile : null,
|
|
nfoExists: includeFsChecks ? Boolean(nfoStatus?.exists) : null,
|
|
canGenerateManual,
|
|
reason
|
|
};
|
|
}
|
|
|
|
function parseInfoFromValue(value, fallback = null) {
|
|
if (!value) {
|
|
return fallback;
|
|
}
|
|
if (typeof value === 'object') {
|
|
return value;
|
|
}
|
|
return parseJsonSafe(value, fallback);
|
|
}
|
|
|
|
function isOrphanRawImportMakemkvInfo(makemkvInfo = null) {
|
|
const info = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {};
|
|
const source = String(
|
|
info?.source
|
|
|| info?.importRecovery?.source
|
|
|| ''
|
|
).trim().toLowerCase();
|
|
return source === 'orphan_raw_import';
|
|
}
|
|
|
|
function hasBlurayStructure(rawPath) {
|
|
const basePath = String(rawPath || '').trim();
|
|
if (!basePath) {
|
|
return false;
|
|
}
|
|
|
|
const bdmvPath = path.join(basePath, 'BDMV');
|
|
const streamPath = path.join(bdmvPath, 'STREAM');
|
|
|
|
try {
|
|
if (fs.existsSync(streamPath)) {
|
|
const streamStat = fs.statSync(streamPath);
|
|
if (streamStat.isDirectory()) {
|
|
return true;
|
|
}
|
|
}
|
|
} catch (_error) {
|
|
// ignore fs errors and continue with fallback checks
|
|
}
|
|
|
|
try {
|
|
if (fs.existsSync(bdmvPath)) {
|
|
const bdmvStat = fs.statSync(bdmvPath);
|
|
if (bdmvStat.isDirectory()) {
|
|
return true;
|
|
}
|
|
}
|
|
} catch (_error) {
|
|
// ignore fs errors
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function hasCdStructure(rawPath) {
|
|
const basePath = String(rawPath || '').trim();
|
|
if (!basePath) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
if (!fs.existsSync(basePath)) {
|
|
return false;
|
|
}
|
|
const stat = fs.statSync(basePath);
|
|
if (!stat.isDirectory()) {
|
|
return false;
|
|
}
|
|
const entries = fs.readdirSync(basePath);
|
|
const audioExtensions = new Set(['.flac', '.wav', '.mp3', '.opus', '.ogg', '.aiff', '.aif']);
|
|
return entries.some((entry) => audioExtensions.has(path.extname(entry).toLowerCase()));
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function hasAudiobookStructure(rawPath) {
|
|
const basePath = String(rawPath || '').trim();
|
|
if (!basePath) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
if (!fs.existsSync(basePath)) {
|
|
return false;
|
|
}
|
|
const stat = fs.statSync(basePath);
|
|
if (stat.isFile()) {
|
|
return path.extname(basePath).toLowerCase() === '.aax';
|
|
}
|
|
if (!stat.isDirectory()) {
|
|
return false;
|
|
}
|
|
const entries = fs.readdirSync(basePath);
|
|
return entries.some((entry) => path.extname(entry).toLowerCase() === '.aax');
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function hasNestedMediaStructure(rawPath, detector, maxDepth = 2) {
|
|
const startPath = String(rawPath || '').trim();
|
|
if (!startPath || typeof detector !== 'function') {
|
|
return false;
|
|
}
|
|
|
|
const normalizedMaxDepth = Number.isFinite(Number(maxDepth))
|
|
? Math.max(0, Math.trunc(Number(maxDepth)))
|
|
: 0;
|
|
const queue = [{ currentPath: startPath, depth: 0 }];
|
|
const visited = new Set();
|
|
|
|
while (queue.length > 0) {
|
|
const next = queue.shift();
|
|
const currentPath = String(next?.currentPath || '').trim();
|
|
const depth = Number(next?.depth || 0);
|
|
if (!currentPath) {
|
|
continue;
|
|
}
|
|
const normalizedPath = normalizeComparablePath(currentPath);
|
|
if (!normalizedPath || visited.has(normalizedPath)) {
|
|
continue;
|
|
}
|
|
visited.add(normalizedPath);
|
|
|
|
if (detector(currentPath)) {
|
|
return true;
|
|
}
|
|
if (depth >= normalizedMaxDepth) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry?.isDirectory?.() || isHiddenDirectoryName(entry.name)) {
|
|
continue;
|
|
}
|
|
queue.push({
|
|
currentPath: path.join(currentPath, entry.name),
|
|
depth: depth + 1
|
|
});
|
|
}
|
|
} catch (_error) {
|
|
// ignore fs errors while traversing nested structures
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function detectOrphanMediaType(rawPath, options = {}) {
|
|
if (hasBlurayStructure(rawPath)) {
|
|
return 'bluray';
|
|
}
|
|
if (hasDvdStructure(rawPath)) {
|
|
return 'dvd';
|
|
}
|
|
// Some RAW folders contain one or two nested disc directories (e.g. ".../Rip_Complete_XYZ/.../VIDEO_TS").
|
|
// Inspect nested folders as fallback so these imports are not classified as "other".
|
|
if (hasNestedMediaStructure(rawPath, hasBlurayStructure, 2)) {
|
|
return 'bluray';
|
|
}
|
|
if (hasNestedMediaStructure(rawPath, hasDvdStructure, 2)) {
|
|
return 'dvd';
|
|
}
|
|
if (hasCdStructure(rawPath)) {
|
|
return 'cd';
|
|
}
|
|
if (hasAudiobookStructure(rawPath)) {
|
|
return 'audiobook';
|
|
}
|
|
if (options?.seriesRawPathHint) {
|
|
return 'dvd';
|
|
}
|
|
return 'other';
|
|
}
|
|
|
|
function hasDvdStructure(rawPath) {
|
|
const basePath = String(rawPath || '').trim();
|
|
if (!basePath) {
|
|
return false;
|
|
}
|
|
|
|
const videoTsPath = path.join(basePath, 'VIDEO_TS');
|
|
try {
|
|
if (fs.existsSync(videoTsPath)) {
|
|
const stat = fs.statSync(videoTsPath);
|
|
if (stat.isDirectory()) {
|
|
return true;
|
|
}
|
|
}
|
|
} catch (_error) {
|
|
// ignore fs errors
|
|
}
|
|
|
|
try {
|
|
if (fs.existsSync(basePath)) {
|
|
const stat = fs.statSync(basePath);
|
|
if (stat.isDirectory()) {
|
|
const entries = fs.readdirSync(basePath);
|
|
if (entries.some((entry) => /^vts_\d{2}_\d\.(ifo|vob|bup)$/i.test(entry) || /^video_ts\.(ifo|vob|bup)$/i.test(entry))) {
|
|
return true;
|
|
}
|
|
} else if (stat.isFile()) {
|
|
return /(^|\/)video_ts\/.+\.(ifo|vob|bup)$/i.test(basePath) || /\.(ifo|vob|bup)$/i.test(basePath);
|
|
}
|
|
}
|
|
} catch (_error) {
|
|
// ignore fs errors and fallback to path checks
|
|
}
|
|
|
|
if (/(^|\/)video_ts(\/|$)/i.test(basePath)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function normalizeMediaTypeValue(value) {
|
|
const raw = String(value || '').trim().toLowerCase();
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
if (
|
|
raw === 'bluray'
|
|
|| raw === 'blu-ray'
|
|
|| raw === 'blu_ray'
|
|
|| raw === 'bd'
|
|
|| raw === 'bdmv'
|
|
|| raw === 'bdrom'
|
|
|| raw === 'bd-rom'
|
|
|| raw === 'bd-r'
|
|
|| raw === 'bd-re'
|
|
) {
|
|
return 'bluray';
|
|
}
|
|
if (
|
|
raw === 'dvd'
|
|
|| raw === 'dvdvideo'
|
|
|| raw === 'dvd-video'
|
|
|| raw === 'dvdrom'
|
|
|| raw === 'dvd-rom'
|
|
|| raw === 'video_ts'
|
|
|| raw === 'iso9660'
|
|
) {
|
|
return 'dvd';
|
|
}
|
|
if (raw === 'cd' || raw === 'audio_cd') {
|
|
return 'cd';
|
|
}
|
|
if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') {
|
|
return 'audiobook';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeJobKindValue(value) {
|
|
const raw = String(value || '').trim().toLowerCase();
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
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'
|
|
|| raw === 'multipart_movie_merge'
|
|
) {
|
|
return raw;
|
|
}
|
|
if (raw === 'converter_audio' || raw === 'converter_video' || raw === 'converter_iso') {
|
|
return raw;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function inferMediaTypeFromJobKind(value) {
|
|
const normalized = normalizeJobKindValue(value);
|
|
if (normalized === 'converter_audio' || normalized === 'converter_video' || normalized === 'converter_iso') {
|
|
return 'converter';
|
|
}
|
|
if (normalized === 'audiobook' || normalized === 'cd' || normalized === 'dvd' || normalized === 'bluray') {
|
|
return normalized;
|
|
}
|
|
const legacy = String(value || '').trim().toLowerCase();
|
|
if (legacy === 'converter') {
|
|
return 'converter';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeInfo = null) {
|
|
const mkInfo = parseInfoFromValue(makemkvInfo, null);
|
|
const miInfo = parseInfoFromValue(mediainfoInfo, null);
|
|
const plan = parseInfoFromValue(encodePlan, null);
|
|
const hbInfo = parseInfoFromValue(handbrakeInfo, null);
|
|
const rawPath = String(job?.raw_path || '').trim();
|
|
const encodeInputPath = String(job?.encode_input_path || plan?.encodeInputPath || '').trim();
|
|
const profileHint = normalizeMediaTypeValue(
|
|
plan?.mediaProfile
|
|
|| mkInfo?.analyzeContext?.mediaProfile
|
|
|| mkInfo?.mediaProfile
|
|
|| miInfo?.mediaProfile
|
|
|| job?.media_type
|
|
|| job?.mediaType
|
|
);
|
|
|
|
if (profileHint === 'bluray' || profileHint === 'dvd' || profileHint === 'cd' || profileHint === 'audiobook') {
|
|
return profileHint;
|
|
}
|
|
|
|
const profileFromJobKind = inferMediaTypeFromJobKind(
|
|
job?.job_kind
|
|
|| plan?.jobKind
|
|
|| mkInfo?.jobKind
|
|
|| mkInfo?.analyzeContext?.jobKind
|
|
|| miInfo?.jobKind
|
|
|| hbInfo?.jobKind
|
|
);
|
|
if (profileFromJobKind) {
|
|
return profileFromJobKind;
|
|
}
|
|
|
|
// Converter jobs: detected via media_type field (plan.mediaProfile = 'converter')
|
|
const rawMediaType = normalizeMediaTypeValue(job?.media_type);
|
|
const rawMediaTypeLegacy = String(job?.media_type || '').trim().toLowerCase();
|
|
const rawPlanProfile = String(plan?.mediaProfile || '').trim().toLowerCase();
|
|
const converterMediaTypeHint = String(plan?.converterMediaType || '').trim().toLowerCase();
|
|
const hasConverterPathHint = (value) => {
|
|
const normalized = String(value || '')
|
|
.trim()
|
|
.replace(/\\/g, '/')
|
|
.toLowerCase();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
return /(^|\/)converter(\/|$)/.test(normalized);
|
|
};
|
|
if (
|
|
rawPlanProfile === 'converter'
|
|
|| rawMediaType === 'converter'
|
|
|| rawMediaTypeLegacy === 'converter'
|
|
|| converterMediaTypeHint === 'video'
|
|
|| converterMediaTypeHint === 'audio'
|
|
|| converterMediaTypeHint === 'iso'
|
|
|| hasConverterPathHint(rawPath)
|
|
|| hasConverterPathHint(encodeInputPath)
|
|
|| hasConverterPathHint(plan?.inputPath)
|
|
) {
|
|
return 'converter';
|
|
}
|
|
|
|
const statusCandidates = [
|
|
job?.status,
|
|
job?.last_state,
|
|
mkInfo?.lastState
|
|
];
|
|
if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) {
|
|
return 'cd';
|
|
}
|
|
|
|
const planFormat = String(plan?.format || '').trim().toLowerCase();
|
|
const hasCdTracksInPlan = Array.isArray(plan?.selectedTracks) && plan.selectedTracks.length > 0;
|
|
if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
|
|
return 'cd';
|
|
}
|
|
if (String(hbInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
|
|
return 'cd';
|
|
}
|
|
if (Array.isArray(mkInfo?.tracks) && mkInfo.tracks.length > 0) {
|
|
return 'cd';
|
|
}
|
|
if (hasAudiobookStructure(rawPath) || hasAudiobookStructure(encodeInputPath)) {
|
|
return 'audiobook';
|
|
}
|
|
if (['audiobook_encode', 'audiobook_encode_split'].includes(String(hbInfo?.mode || '').trim().toLowerCase())) {
|
|
return 'audiobook';
|
|
}
|
|
if (String(plan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
|
return 'audiobook';
|
|
}
|
|
|
|
if (hasBlurayStructure(rawPath)) {
|
|
return 'bluray';
|
|
}
|
|
if (hasDvdStructure(rawPath)) {
|
|
return 'dvd';
|
|
}
|
|
|
|
const mkSource = String(mkInfo?.source || '').trim().toLowerCase();
|
|
const mkRipMode = String(mkInfo?.ripMode || mkInfo?.rip_mode || '').trim().toLowerCase();
|
|
if (Boolean(mkInfo?.analyzeContext?.playlistAnalysis)) {
|
|
return 'bluray';
|
|
}
|
|
if (mkRipMode === 'backup' || mkSource.includes('backup') || mkSource.includes('raw_backup')) {
|
|
if (hasDvdStructure(rawPath) || hasDvdStructure(encodeInputPath)) {
|
|
return 'dvd';
|
|
}
|
|
if (hasBlurayStructure(rawPath) || hasBlurayStructure(encodeInputPath)) {
|
|
return 'bluray';
|
|
}
|
|
}
|
|
|
|
const planMode = String(plan?.mode || '').trim().toLowerCase();
|
|
if (planMode === 'pre_rip' || Boolean(plan?.preRip)) {
|
|
return 'bluray';
|
|
}
|
|
|
|
const mediainfoSource = String(miInfo?.source || '').trim().toLowerCase();
|
|
if (Number(miInfo?.handbrakeTitleId) > 0) {
|
|
return 'bluray';
|
|
}
|
|
if (mediainfoSource.includes('raw_backup')) {
|
|
if (hasDvdStructure(rawPath) || hasDvdStructure(encodeInputPath)) {
|
|
return 'dvd';
|
|
}
|
|
if (hasBlurayStructure(rawPath) || hasBlurayStructure(encodeInputPath)) {
|
|
return 'bluray';
|
|
}
|
|
}
|
|
|
|
if (
|
|
/(^|\/)bdmv(\/|$)/i.test(rawPath)
|
|
|| /(^|\/)bdmv(\/|$)/i.test(encodeInputPath)
|
|
|| /\.m2ts(\.|$)/i.test(encodeInputPath)
|
|
) {
|
|
return 'bluray';
|
|
}
|
|
if (
|
|
/(^|\/)video_ts(\/|$)/i.test(rawPath)
|
|
|| /(^|\/)video_ts(\/|$)/i.test(encodeInputPath)
|
|
|| /\.(ifo|vob|bup)(\.|$)/i.test(encodeInputPath)
|
|
) {
|
|
return 'dvd';
|
|
}
|
|
// Fallback for jobs without complete metadata/structure hints (e.g. multipart history rows).
|
|
// Prefer explicit profile folder segments when present.
|
|
const normalizedRawPathForHint = rawPath.replace(/\\/g, '/').toLowerCase();
|
|
const normalizedEncodeInputPathForHint = encodeInputPath.replace(/\\/g, '/').toLowerCase();
|
|
if (
|
|
/(^|\/)raw\/bluray(\/|$)/.test(normalizedRawPathForHint)
|
|
|| /(^|\/)raw\/bluray(\/|$)/.test(normalizedEncodeInputPathForHint)
|
|
) {
|
|
return 'bluray';
|
|
}
|
|
if (
|
|
/(^|\/)raw\/dvd(\/|$)/.test(normalizedRawPathForHint)
|
|
|| /(^|\/)raw\/dvd(\/|$)/.test(normalizedEncodeInputPathForHint)
|
|
) {
|
|
return 'dvd';
|
|
}
|
|
|
|
return profileHint || 'other';
|
|
}
|
|
|
|
function toProcessLogPath(jobId) {
|
|
const normalizedId = Number(jobId);
|
|
if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
|
|
return null;
|
|
}
|
|
return path.join(getJobLogDir(), `job-${Math.trunc(normalizedId)}.process.log`);
|
|
}
|
|
|
|
function buildArchivedProcessLogPath(sourceJobId, options = {}) {
|
|
const normalizedId = Number(sourceJobId);
|
|
if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
|
|
return null;
|
|
}
|
|
const targetJobId = Number(options?.replacementJobId);
|
|
const safeReason = String(options?.reason || 'retired')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_-]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
|| 'retired';
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const archiveDir = path.join(getJobLogDir(), 'archived');
|
|
const targetSuffix = Number.isFinite(targetJobId) && targetJobId > 0
|
|
? `.to-${Math.trunc(targetJobId)}`
|
|
: '';
|
|
return {
|
|
archiveDir,
|
|
archivePath: path.join(
|
|
archiveDir,
|
|
`job-${Math.trunc(normalizedId)}.process.${safeReason}${targetSuffix}.${timestamp}.log`
|
|
)
|
|
};
|
|
}
|
|
|
|
function hasProcessLogFile(jobId) {
|
|
const filePath = toProcessLogPath(jobId);
|
|
return Boolean(filePath && fs.existsSync(filePath));
|
|
}
|
|
|
|
function toProcessLogStreamKey(jobId) {
|
|
const normalizedId = Number(jobId);
|
|
if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
|
|
return null;
|
|
}
|
|
return String(Math.trunc(normalizedId));
|
|
}
|
|
|
|
function resolveEffectiveRawPath(storedPath, rawDir, extraDirs = []) {
|
|
const stored = String(storedPath || '').trim();
|
|
if (!stored) return stored;
|
|
const baseFolderName = path.basename(stored);
|
|
if (!baseFolderName) return stored;
|
|
const folderNames = [baseFolderName];
|
|
const stripped = stripRawFolderStatePrefix(baseFolderName);
|
|
if (stripped) {
|
|
folderNames.push(applyRawFolderPrefix(stripped, RAW_RIP_COMPLETE_PREFIX));
|
|
folderNames.push(applyRawFolderPrefix(stripped, RAW_INCOMPLETE_PREFIX));
|
|
}
|
|
|
|
const candidates = [];
|
|
const seen = new Set();
|
|
const pushCandidate = (candidatePath) => {
|
|
const normalized = String(candidatePath || '').trim();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
const comparable = normalizeComparablePath(normalized);
|
|
if (!comparable || seen.has(comparable)) {
|
|
return;
|
|
}
|
|
seen.add(comparable);
|
|
candidates.push(normalized);
|
|
};
|
|
|
|
pushCandidate(stored);
|
|
const storedParent = path.dirname(stored);
|
|
for (const name of folderNames) {
|
|
if (!name) continue;
|
|
if (storedParent && storedParent !== '.') {
|
|
pushCandidate(path.join(storedParent, name));
|
|
}
|
|
if (rawDir) {
|
|
pushCandidate(path.join(String(rawDir).trim(), name));
|
|
}
|
|
for (const extraDir of Array.isArray(extraDirs) ? extraDirs : []) {
|
|
pushCandidate(path.join(String(extraDir || '').trim(), name));
|
|
}
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
try {
|
|
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
|
return candidate;
|
|
}
|
|
} catch (_error) {
|
|
// ignore fs errors and continue with fallbacks
|
|
}
|
|
}
|
|
|
|
return rawDir ? path.join(String(rawDir).trim(), baseFolderName) : stored;
|
|
}
|
|
|
|
function resolveEffectiveOutputPath(storedPath, movieDir) {
|
|
const stored = String(storedPath || '').trim();
|
|
if (!stored || !movieDir) return stored;
|
|
// output_path structure: {movie_dir}/{folderName}/{fileName}
|
|
const fileName = path.basename(stored);
|
|
const folderName = path.basename(path.dirname(stored));
|
|
if (!fileName || !folderName || folderName === '.') return stored;
|
|
return path.join(String(movieDir).trim(), folderName, fileName);
|
|
}
|
|
|
|
function getConfiguredMediaPathList(settings = {}, baseKey) {
|
|
const source = settings && typeof settings === 'object' ? settings : {};
|
|
const candidates = [source[baseKey], ...PROFILE_PATH_SUFFIXES.map((suffix) => source[`${baseKey}_${suffix}`])];
|
|
const unique = [];
|
|
const seen = new Set();
|
|
|
|
for (const candidate of candidates) {
|
|
const rawPath = String(candidate || '').trim();
|
|
if (!rawPath) {
|
|
continue;
|
|
}
|
|
const normalized = normalizeComparablePath(rawPath);
|
|
if (!normalized || seen.has(normalized)) {
|
|
continue;
|
|
}
|
|
seen.add(normalized);
|
|
unique.push(normalized);
|
|
}
|
|
|
|
return unique;
|
|
}
|
|
|
|
function getSeriesRawPathCandidates(settings = {}) {
|
|
const source = settings && typeof settings === 'object' ? settings : {};
|
|
const unique = [];
|
|
const seen = new Set();
|
|
const pushPath = (candidate) => {
|
|
const rawPath = String(candidate || '').trim();
|
|
if (!rawPath) {
|
|
return;
|
|
}
|
|
const normalized = normalizeComparablePath(rawPath);
|
|
if (!normalized || seen.has(normalized)) {
|
|
return;
|
|
}
|
|
seen.add(normalized);
|
|
unique.push(normalized);
|
|
};
|
|
|
|
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);
|
|
|
|
return unique;
|
|
}
|
|
|
|
function resolveRawRootForPath(settings = {}, currentRawDir = null, rawPath = null) {
|
|
const normalizedRawPath = normalizeComparablePath(rawPath);
|
|
if (!normalizedRawPath) {
|
|
return String(currentRawDir || '').trim() || null;
|
|
}
|
|
|
|
const unique = [];
|
|
const seen = new Set();
|
|
const pushPath = (candidate) => {
|
|
const normalized = normalizeComparablePath(candidate);
|
|
if (!normalized || seen.has(normalized)) {
|
|
return;
|
|
}
|
|
seen.add(normalized);
|
|
unique.push(normalized);
|
|
};
|
|
|
|
pushPath(currentRawDir);
|
|
for (const candidate of getConfiguredMediaPathList(settings || {}, 'raw_dir')) {
|
|
pushPath(candidate);
|
|
}
|
|
for (const candidate of getSeriesRawPathCandidates(settings || {})) {
|
|
pushPath(candidate);
|
|
}
|
|
|
|
const matchingRoots = unique
|
|
.filter((candidateRoot) => isPathInside(candidateRoot, normalizedRawPath))
|
|
.sort((left, right) => right.length - left.length);
|
|
if (matchingRoots.length > 0) {
|
|
return matchingRoots[0];
|
|
}
|
|
return String(currentRawDir || '').trim() || null;
|
|
}
|
|
|
|
function isLikelySeriesRawPath(rawPath, settings = {}) {
|
|
const normalizedRawPath = normalizeComparablePath(rawPath);
|
|
if (!normalizedRawPath) {
|
|
return false;
|
|
}
|
|
|
|
const seriesRoots = getSeriesRawPathCandidates(settings);
|
|
if (seriesRoots.some((candidate) => isPathInside(candidate, normalizedRawPath))) {
|
|
return true;
|
|
}
|
|
|
|
// Fallback for common default layouts when series raw dir is not explicitly configured.
|
|
return /(^|\/)raw\/(?:dvd|bluray)\/series(\/|$)/i.test(normalizedRawPath);
|
|
}
|
|
|
|
function getOrphanRawScanPathList(settings = {}) {
|
|
const source = settings && typeof settings === 'object' ? settings : {};
|
|
const unique = [];
|
|
const seen = new Set();
|
|
const pushPath = (candidate) => {
|
|
const rawPath = String(candidate || '').trim();
|
|
if (!rawPath) {
|
|
return;
|
|
}
|
|
const normalized = normalizeComparablePath(rawPath);
|
|
if (!normalized || seen.has(normalized)) {
|
|
return;
|
|
}
|
|
seen.add(normalized);
|
|
unique.push(normalized);
|
|
};
|
|
|
|
// Explicitly configured RAW roots (legacy + profiled keys)
|
|
for (const candidate of getConfiguredMediaPathList(source, 'raw_dir')) {
|
|
pushPath(candidate);
|
|
}
|
|
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);
|
|
|
|
// Effective settings roots per profile (covers fallback behavior in settings service)
|
|
for (const profile of ['bluray', 'dvd', 'cd', 'audiobook']) {
|
|
const effective = settingsService.resolveEffectiveToolSettings(source, profile) || {};
|
|
pushPath(effective.raw_dir);
|
|
if (profile === 'dvd' || profile === 'bluray') {
|
|
pushPath(effective.series_raw_dir);
|
|
}
|
|
}
|
|
|
|
// Hard defaults (even when no explicit setting exists yet)
|
|
pushPath(settingsService.DEFAULT_RAW_DIR);
|
|
pushPath(settingsService.DEFAULT_AUDIOBOOK_RAW_DIR);
|
|
pushPath(defaultConverterRawDir);
|
|
|
|
return unique;
|
|
}
|
|
|
|
function isDirectoryLikeOutput(mediaType, encodePlan = null, handbrakeInfo = null) {
|
|
if (mediaType === 'cd') {
|
|
return true;
|
|
}
|
|
if (mediaType !== 'audiobook') {
|
|
return false;
|
|
}
|
|
const hbMode = String(handbrakeInfo?.mode || '').trim().toLowerCase();
|
|
if (hbMode === 'audiobook_encode_split') {
|
|
return true;
|
|
}
|
|
const format = String(encodePlan?.format || '').trim().toLowerCase();
|
|
return Boolean(format && format !== 'm4b');
|
|
}
|
|
|
|
function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = {}) {
|
|
const mkInfo = parsed?.makemkvInfo || parseJsonSafe(job?.makemkv_info_json, null);
|
|
const miInfo = parsed?.mediainfoInfo || parseJsonSafe(job?.mediainfo_info_json, null);
|
|
const plan = parsed?.encodePlan || parseJsonSafe(job?.encode_plan_json, null);
|
|
const handbrakeInfo = parsed?.handbrakeInfo || parseJsonSafe(job?.handbrake_info_json, null);
|
|
const mediaType = inferMediaType(job, mkInfo, miInfo, plan, handbrakeInfo);
|
|
const directoryLikeOutput = isDirectoryLikeOutput(mediaType, plan, handbrakeInfo);
|
|
const selectedMetadata = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {};
|
|
const metadataKind = String(selectedMetadata?.metadataKind || '').trim().toLowerCase();
|
|
const normalizedJobKind = String(job?.job_kind || '').trim().toLowerCase();
|
|
const hasSeriesJobKind = normalizedJobKind === 'dvd_series_child' || normalizedJobKind === 'dvd_series_container';
|
|
const hasParentJob = normalizeJobIdValue(job?.parent_job_id) !== null;
|
|
const isMultipartJobKind = (
|
|
normalizedJobKind === 'multipart_movie_container'
|
|
|| normalizedJobKind === 'multipart_movie_child'
|
|
|| normalizedJobKind === 'multipart_movie_merge'
|
|
);
|
|
// Parent-ID alone is not a reliable series signal: multipart movie jobs also use parent_job_id.
|
|
const hasSeriesParent = hasParentJob && !isMultipartJobKind;
|
|
const hasSeriesRawPathHint = isLikelySeriesRawPath(job?.raw_path, settings || {});
|
|
const hasSeriesMetadataHint = hasSeriesMetadataSignals(
|
|
selectedMetadata,
|
|
mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' ? mkInfo.analyzeContext : {}
|
|
);
|
|
const seriesSignals = (
|
|
Number(selectedMetadata?.tmdbId || 0) > 0
|
|
|| Number(selectedMetadata?.seasonNumber || 0) > 0
|
|
|| (Array.isArray(selectedMetadata?.episodes) && selectedMetadata.episodes.length > 0)
|
|
|| Number(selectedMetadata?.episodeCount || 0) > 0
|
|
|| ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind)
|
|
);
|
|
const isSeriesDisc = (mediaType === 'dvd' || mediaType === 'bluray') && (
|
|
seriesSignals
|
|
|| hasSeriesMetadataHint
|
|
|| hasSeriesJobKind
|
|
|| hasSeriesParent
|
|
|| hasSeriesRawPathHint
|
|
);
|
|
|
|
// Converter jobs use their own storage dirs — do not remap output path
|
|
if (mediaType === 'converter') {
|
|
const s = settings || {};
|
|
const converterMediaType = String(plan?.converterMediaType || '').trim().toLowerCase();
|
|
const converterMovieDir = converterMediaType === 'audio'
|
|
? (String(s.converter_audio_dir || '').trim() || String(s.converter_movie_dir || '').trim())
|
|
: String(s.converter_movie_dir || '').trim();
|
|
const converterRawDir = String(s.converter_raw_dir || '').trim();
|
|
return {
|
|
mediaType: 'converter',
|
|
directoryLikeOutput: false,
|
|
rawDir: converterRawDir,
|
|
movieDir: converterMovieDir,
|
|
effectiveRawPath: job?.raw_path || null,
|
|
effectiveOutputPath: job?.output_path || null,
|
|
makemkvInfo: mkInfo,
|
|
mediainfoInfo: miInfo,
|
|
handbrakeInfo,
|
|
encodePlan: plan
|
|
};
|
|
}
|
|
|
|
const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType);
|
|
const seriesRawDir = String(effectiveSettings?.series_raw_dir || '').trim();
|
|
const seriesMovieDir = String(effectiveSettings?.series_dir || '').trim();
|
|
const defaultRawDir = String(effectiveSettings?.raw_dir || '').trim();
|
|
let rawDir = isSeriesDisc ? (seriesRawDir || defaultRawDir) : defaultRawDir;
|
|
if (isSeriesDisc) {
|
|
const normalizedStoredRawPath = normalizeComparablePath(job?.raw_path);
|
|
if (normalizedStoredRawPath) {
|
|
const seriesRawCandidates = getSeriesRawPathCandidates(settings || {});
|
|
const matchedSeriesRawDir = seriesRawCandidates.find((candidate) => isPathInside(candidate, normalizedStoredRawPath));
|
|
if (matchedSeriesRawDir) {
|
|
rawDir = matchedSeriesRawDir;
|
|
}
|
|
}
|
|
}
|
|
const configuredMovieDir = isSeriesDisc
|
|
? (seriesMovieDir || String(effectiveSettings?.movie_dir || '').trim())
|
|
: String(effectiveSettings?.movie_dir || '').trim();
|
|
const movieDir = configuredMovieDir || rawDir;
|
|
const rawLookupDirsBase = isSeriesDisc
|
|
? getSeriesRawPathCandidates(settings || {})
|
|
: getConfiguredMediaPathList(settings || {}, 'raw_dir');
|
|
const rawLookupDirs = rawLookupDirsBase
|
|
.filter((candidate) => normalizeComparablePath(candidate) !== normalizeComparablePath(rawDir));
|
|
const effectiveRawPath = job?.raw_path
|
|
? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs)
|
|
: (job?.raw_path || null);
|
|
rawDir = resolveRawRootForPath(settings || {}, rawDir, effectiveRawPath);
|
|
const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path && !isSeriesDisc)
|
|
? resolveEffectiveOutputPath(job.output_path, configuredMovieDir)
|
|
: (job?.output_path || null);
|
|
|
|
return {
|
|
mediaType,
|
|
directoryLikeOutput,
|
|
rawDir,
|
|
movieDir,
|
|
effectiveRawPath,
|
|
effectiveOutputPath,
|
|
makemkvInfo: mkInfo,
|
|
mediainfoInfo: miInfo,
|
|
handbrakeInfo,
|
|
encodePlan: plan
|
|
};
|
|
}
|
|
|
|
function buildUnknownDirectoryStatus(dirPath = null) {
|
|
return {
|
|
path: dirPath || null,
|
|
exists: null,
|
|
isDirectory: null,
|
|
isEmpty: null,
|
|
entryCount: null
|
|
};
|
|
}
|
|
|
|
function buildUnknownFileStatus(filePath = null) {
|
|
return {
|
|
path: filePath || null,
|
|
exists: null,
|
|
isFile: null,
|
|
sizeBytes: null
|
|
};
|
|
}
|
|
|
|
function enrichJobRow(job, settings = null, options = {}) {
|
|
const includeFsChecks = options?.includeFsChecks !== false;
|
|
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
|
|
const handbrakeInfo = resolvedPaths.handbrakeInfo;
|
|
const directoryLikeOutput = Boolean(resolvedPaths.directoryLikeOutput);
|
|
const outputStatus = includeFsChecks
|
|
? (directoryLikeOutput
|
|
? inspectDirectory(resolvedPaths.effectiveOutputPath)
|
|
: inspectOutputFile(resolvedPaths.effectiveOutputPath))
|
|
: (directoryLikeOutput
|
|
? buildUnknownDirectoryStatus(resolvedPaths.effectiveOutputPath)
|
|
: buildUnknownFileStatus(resolvedPaths.effectiveOutputPath));
|
|
const rawStatus = includeFsChecks
|
|
? inspectDirectory(resolvedPaths.effectiveRawPath)
|
|
: buildUnknownDirectoryStatus(resolvedPaths.effectiveRawPath);
|
|
const movieDirPath = resolvedPaths.effectiveOutputPath ? path.dirname(resolvedPaths.effectiveOutputPath) : null;
|
|
const movieDirStatus = includeFsChecks
|
|
? inspectDirectory(movieDirPath)
|
|
: buildUnknownDirectoryStatus(movieDirPath);
|
|
const makemkvInfo = resolvedPaths.makemkvInfo;
|
|
const mediainfoInfo = resolvedPaths.mediainfoInfo;
|
|
const encodePlan = resolvedPaths.encodePlan;
|
|
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
|
|
? makemkvInfo.analyzeContext
|
|
: {};
|
|
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
|
? analyzeContext.selectedMetadata
|
|
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
|
? makemkvInfo.selectedMetadata
|
|
: {});
|
|
const resolvedPosterUrl = String(
|
|
job?.poster_url
|
|
|| selectedMetadata?.poster
|
|
|| selectedMetadata?.coverUrl
|
|
|| selectedMetadata?.posterUrl
|
|
|| encodePlan?.metadata?.poster
|
|
|| encodePlan?.metadata?.coverUrl
|
|
|| ''
|
|
).trim() || null;
|
|
const mediaType = resolvedPaths.mediaType;
|
|
const normalizedJobStatus = String(job?.status || '').trim().toUpperCase();
|
|
const ripSuccessful = Number(job?.rip_successful || 0) === 1
|
|
|| String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
|
|
const backupSuccess = ripSuccessful;
|
|
const finishedWithOutput = normalizedJobStatus === 'FINISHED'
|
|
&& (includeFsChecks ? Boolean(outputStatus?.exists) : true);
|
|
const seriesBatchSummary = handbrakeInfo?.seriesBatch && typeof handbrakeInfo.seriesBatch === 'object'
|
|
? handbrakeInfo.seriesBatch
|
|
: null;
|
|
const seriesBatchMode = String(handbrakeInfo?.mode || '').trim().toLowerCase() === 'series_batch';
|
|
const seriesBatchTotal = Number(seriesBatchSummary?.totalCount || 0);
|
|
const seriesBatchFinished = Number(seriesBatchSummary?.finishedCount || 0);
|
|
const seriesBatchErrors = Number(seriesBatchSummary?.errorCount || 0);
|
|
const seriesBatchCancelled = Number(seriesBatchSummary?.cancelledCount || 0);
|
|
const seriesBatchEncodeSuccess = seriesBatchMode
|
|
&& seriesBatchTotal > 0
|
|
&& seriesBatchFinished >= seriesBatchTotal
|
|
&& seriesBatchErrors <= 0
|
|
&& seriesBatchCancelled <= 0;
|
|
const encodeSuccess = directoryLikeOutput
|
|
? finishedWithOutput
|
|
: (
|
|
seriesBatchEncodeSuccess
|
|
|| (mediaType === 'converter'
|
|
? finishedWithOutput
|
|
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS')
|
|
);
|
|
const nfoStatus = buildNfoStatusForJob({
|
|
job,
|
|
outputPath: resolvedPaths.effectiveOutputPath,
|
|
outputStatus,
|
|
includeFsChecks,
|
|
settings,
|
|
mediaType
|
|
});
|
|
|
|
return {
|
|
...job,
|
|
poster_url: resolvedPosterUrl,
|
|
raw_path: resolvedPaths.effectiveRawPath,
|
|
output_path: resolvedPaths.effectiveOutputPath,
|
|
makemkvInfo,
|
|
handbrakeInfo,
|
|
mediainfoInfo,
|
|
encodePlan,
|
|
mediaType,
|
|
ripSuccessful,
|
|
backupSuccess,
|
|
encodeSuccess,
|
|
rawStatus,
|
|
outputStatus,
|
|
movieDirStatus,
|
|
nfoStatus
|
|
};
|
|
}
|
|
|
|
function resolveSafe(inputPath) {
|
|
return path.resolve(String(inputPath || ''));
|
|
}
|
|
|
|
function isPathInside(basePath, candidatePath) {
|
|
if (!basePath || !candidatePath) {
|
|
return false;
|
|
}
|
|
|
|
const base = resolveSafe(basePath);
|
|
const candidate = resolveSafe(candidatePath);
|
|
return candidate === base || candidate.startsWith(`${base}${path.sep}`);
|
|
}
|
|
|
|
function normalizeComparablePath(inputPath) {
|
|
return resolveSafe(String(inputPath || '')).replace(/[\\/]+$/, '');
|
|
}
|
|
|
|
function escapeRegExp(input) {
|
|
return String(input || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function parseNumberedFolderName(folderName) {
|
|
const raw = String(folderName || '').trim();
|
|
if (!raw) {
|
|
return { baseName: '', suffixNumber: 0, numbered: false };
|
|
}
|
|
const match = raw.match(/^(.*)_([0-9]+)$/);
|
|
if (!match) {
|
|
return { baseName: raw, suffixNumber: 0, numbered: false };
|
|
}
|
|
const baseName = String(match[1] || '').trim() || raw;
|
|
const suffixNumber = Number(match[2] || 0);
|
|
return {
|
|
baseName,
|
|
suffixNumber: Number.isFinite(suffixNumber) && suffixNumber > 0 ? Math.trunc(suffixNumber) : 0,
|
|
numbered: true
|
|
};
|
|
}
|
|
|
|
function inferSiblingOutputFolders(outputPath) {
|
|
const normalizedOutputPath = normalizeComparablePath(outputPath);
|
|
if (!normalizedOutputPath) {
|
|
return [];
|
|
}
|
|
|
|
let outputStat = null;
|
|
try {
|
|
outputStat = fs.statSync(normalizedOutputPath);
|
|
} catch (_error) {
|
|
return [];
|
|
}
|
|
if (!outputStat?.isDirectory?.()) {
|
|
return [];
|
|
}
|
|
|
|
const parentDir = path.dirname(normalizedOutputPath);
|
|
const folderName = path.basename(normalizedOutputPath);
|
|
const parsedName = parseNumberedFolderName(folderName);
|
|
const baseName = parsedName.baseName || folderName;
|
|
if (!baseName) {
|
|
return [normalizedOutputPath];
|
|
}
|
|
|
|
const siblingRegex = new RegExp(`^${escapeRegExp(baseName)}(?:_(\\d+))?$`);
|
|
let entries = [];
|
|
try {
|
|
entries = fs.readdirSync(parentDir, { withFileTypes: true });
|
|
} catch (_error) {
|
|
return [normalizedOutputPath];
|
|
}
|
|
|
|
const candidates = [];
|
|
for (const entry of entries) {
|
|
if (!entry?.isDirectory?.()) {
|
|
continue;
|
|
}
|
|
const name = String(entry.name || '').trim();
|
|
if (!name) {
|
|
continue;
|
|
}
|
|
const match = name.match(siblingRegex);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
const suffixNumber = match[1] ? Number(match[1]) : 0;
|
|
const fullPath = normalizeComparablePath(path.join(parentDir, name));
|
|
candidates.push({
|
|
path: fullPath,
|
|
suffixNumber: Number.isFinite(suffixNumber) && suffixNumber > 0 ? Math.trunc(suffixNumber) : 0
|
|
});
|
|
}
|
|
|
|
if (candidates.length === 0) {
|
|
return [normalizedOutputPath];
|
|
}
|
|
|
|
candidates.sort((left, right) => {
|
|
if (left.suffixNumber !== right.suffixNumber) {
|
|
return left.suffixNumber - right.suffixNumber;
|
|
}
|
|
return String(left.path || '').localeCompare(String(right.path || ''), 'de-DE');
|
|
});
|
|
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);
|
|
const leftParent = normalizeComparablePath(path.dirname(leftNormalized));
|
|
const rightParent = normalizeComparablePath(path.dirname(rightNormalized));
|
|
const parentCompare = leftParent.localeCompare(rightParent, 'de-DE');
|
|
if (parentCompare !== 0) {
|
|
return parentCompare;
|
|
}
|
|
|
|
const leftName = String(path.basename(leftNormalized) || '').trim();
|
|
const rightName = String(path.basename(rightNormalized) || '').trim();
|
|
const leftParsed = parseNumberedFolderName(leftName);
|
|
const rightParsed = parseNumberedFolderName(rightName);
|
|
const baseCompare = String(leftParsed.baseName || '').localeCompare(String(rightParsed.baseName || ''), 'de-DE');
|
|
if (baseCompare !== 0) {
|
|
return baseCompare;
|
|
}
|
|
|
|
const leftSuffix = Number(leftParsed?.suffixNumber || 0);
|
|
const rightSuffix = Number(rightParsed?.suffixNumber || 0);
|
|
if (leftSuffix !== rightSuffix) {
|
|
return leftSuffix - rightSuffix;
|
|
}
|
|
|
|
return leftNormalized.localeCompare(rightNormalized, 'de-DE');
|
|
}
|
|
|
|
function stripRawFolderStatePrefix(folderName) {
|
|
const rawName = String(folderName || '').trim();
|
|
if (!rawName) {
|
|
return '';
|
|
}
|
|
return rawName
|
|
.replace(new RegExp(`^${RAW_INCOMPLETE_PREFIX}`, 'i'), '')
|
|
.replace(new RegExp(`^${RAW_RIP_COMPLETE_PREFIX}`, 'i'), '')
|
|
.trim();
|
|
}
|
|
|
|
function stripRawFolderJobSuffix(folderName) {
|
|
const rawName = String(folderName || '').trim();
|
|
if (!rawName) {
|
|
return '';
|
|
}
|
|
return rawName.replace(/(?:\s*-\s*RAW\s*-\s*job-\d+\s*)+$/i, '').trim();
|
|
}
|
|
|
|
function applyRawFolderPrefix(folderName, prefix = '') {
|
|
const normalized = stripRawFolderStatePrefix(folderName);
|
|
if (!normalized) {
|
|
return normalized;
|
|
}
|
|
const safePrefix = String(prefix || '').trim();
|
|
return safePrefix ? `${safePrefix}${normalized}` : normalized;
|
|
}
|
|
|
|
function parseRawFolderMetadata(folderName) {
|
|
const rawName = String(folderName || '').trim();
|
|
const normalizedRawName = stripRawFolderStatePrefix(rawName);
|
|
const folderJobIdMatches = Array.from(normalizedRawName.matchAll(/-\s*RAW\s*-\s*job-(\d+)/ig));
|
|
const folderJobId = folderJobIdMatches.length > 0
|
|
? Number(folderJobIdMatches[folderJobIdMatches.length - 1][1])
|
|
: null;
|
|
let working = stripRawFolderJobSuffix(normalizedRawName);
|
|
|
|
const imdbMatch = working.match(/\[(tt\d{6,12})\]/i);
|
|
const imdbId = imdbMatch ? String(imdbMatch[1] || '').toLowerCase() : null;
|
|
if (imdbMatch) {
|
|
working = working.replace(imdbMatch[0], '').trim();
|
|
}
|
|
|
|
const yearMatch = working.match(/\((19|20)\d{2}\)/);
|
|
const year = yearMatch ? Number(String(yearMatch[0]).replace(/[()]/g, '')) : null;
|
|
if (yearMatch) {
|
|
working = working.replace(yearMatch[0], '').trim();
|
|
}
|
|
|
|
const title = working.replace(/\s{2,}/g, ' ').trim() || null;
|
|
|
|
return {
|
|
title,
|
|
year: Number.isFinite(year) ? year : null,
|
|
imdbId,
|
|
folderJobId: Number.isFinite(folderJobId) ? Math.trunc(folderJobId) : null
|
|
};
|
|
}
|
|
|
|
function normalizePositiveNumberOrNull(value) {
|
|
const raw = String(value ?? '').trim();
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
const parsed = Number(raw.replace(',', '.'));
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return null;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function normalizePositiveIntegerOrNull(value) {
|
|
const parsed = normalizePositiveNumberOrNull(value);
|
|
if (parsed === null) {
|
|
return null;
|
|
}
|
|
return Math.trunc(parsed);
|
|
}
|
|
|
|
function normalizeDvdMetadataWorkflowKind(value) {
|
|
const raw = String(value || '').trim().toLowerCase();
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
if (['film', 'movie', 'feature'].includes(raw)) {
|
|
return 'film';
|
|
}
|
|
if (['series', 'tv', 'season', 'episode'].includes(raw)) {
|
|
return 'series';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveSeriesAssignmentEpisodeSpan(assignment = null) {
|
|
const source = assignment && typeof assignment === 'object' ? assignment : {};
|
|
const start = normalizePositiveIntegerOrNull(
|
|
source?.episodeNumberStart
|
|
?? source?.episodeNoStart
|
|
?? source?.episodeNumber
|
|
?? source?.number
|
|
?? null
|
|
);
|
|
const explicitEnd = normalizePositiveIntegerOrNull(
|
|
source?.episodeNumberEnd
|
|
?? source?.episodeNoEnd
|
|
?? null
|
|
);
|
|
const explicitSpan = normalizePositiveIntegerOrNull(
|
|
source?.episodeSpan
|
|
?? source?.episodeCount
|
|
?? null
|
|
);
|
|
if (start && explicitEnd && explicitEnd >= start) {
|
|
return Math.max(1, Math.trunc(explicitEnd - start + 1));
|
|
}
|
|
if (explicitSpan && explicitSpan > 0) {
|
|
return Math.max(1, Math.trunc(explicitSpan));
|
|
}
|
|
if (start) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function countSelectedEpisodeSlotsFromPlan(plan = null) {
|
|
const sourcePlan = plan && typeof plan === 'object' ? plan : {};
|
|
const titles = Array.isArray(sourcePlan?.titles) ? sourcePlan.titles : [];
|
|
const selectedFromFlags = titles
|
|
.filter((title) => Boolean(title?.selectedForEncode || title?.encodeInput))
|
|
.map((title) => normalizePositiveIntegerOrNull(title?.id))
|
|
.filter(Boolean);
|
|
const selectedFromPlan = Array.isArray(sourcePlan?.selectedTitleIds)
|
|
? sourcePlan.selectedTitleIds
|
|
.map((value) => normalizePositiveIntegerOrNull(value))
|
|
.filter(Boolean)
|
|
: [];
|
|
const selectedFromInput = normalizePositiveIntegerOrNull(sourcePlan?.encodeInputTitleId);
|
|
const selectedTitleIds = Array.from(new Set([
|
|
...selectedFromFlags,
|
|
...selectedFromPlan,
|
|
...(selectedFromInput ? [selectedFromInput] : [])
|
|
]));
|
|
if (selectedTitleIds.length === 0) {
|
|
return 0;
|
|
}
|
|
const assignments = sourcePlan?.episodeAssignments && typeof sourcePlan.episodeAssignments === 'object'
|
|
? sourcePlan.episodeAssignments
|
|
: {};
|
|
let total = 0;
|
|
for (const titleId of selectedTitleIds) {
|
|
const assignment = assignments[String(titleId)] || assignments[titleId] || null;
|
|
const span = resolveSeriesAssignmentEpisodeSpan(assignment);
|
|
total += span > 0 ? span : 1;
|
|
}
|
|
return Math.max(0, total);
|
|
}
|
|
|
|
function isSeriesBatchEpisodeSubJobPlan(encodePlan = null) {
|
|
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
|
|
if (Boolean(plan?.seriesBatchChild) || Boolean(plan?.seriesBatchVirtualEpisode)) {
|
|
return true;
|
|
}
|
|
const parentJobId = normalizePositiveIntegerOrNull(plan?.seriesBatchParentJobId);
|
|
const titleId = normalizePositiveIntegerOrNull(plan?.seriesBatchTitleId);
|
|
const childIndex = normalizePositiveIntegerOrNull(plan?.seriesBatchChildIndex);
|
|
const childCount = normalizePositiveIntegerOrNull(plan?.seriesBatchChildCount);
|
|
if (parentJobId && (titleId || childIndex || childCount)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isSeriesBatchEpisodeSubJobRow(row = null) {
|
|
const source = row && typeof row === 'object' ? row : {};
|
|
const plan = source?.encodePlan && typeof source.encodePlan === 'object'
|
|
? source.encodePlan
|
|
: parseJsonSafe(source?.encode_plan_json, null);
|
|
return isSeriesBatchEpisodeSubJobPlan(plan);
|
|
}
|
|
|
|
function extractSeasonNumberFromText(value) {
|
|
const text = String(value || '').trim();
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
|
|
const directPattern = text.match(/(?:staffel|season|serie|series|s)\s*[-_.:]?\s*(\d{1,3}(?:[.,]\d+)?)/i);
|
|
if (directPattern?.[1]) {
|
|
return normalizePositiveNumberOrNull(directPattern[1]);
|
|
}
|
|
|
|
const tokenPattern = text.match(/(?:^|[\s._-])s(\d{1,3}(?:[.,]\d+)?)(?:$|[\s._-])/i);
|
|
if (tokenPattern?.[1]) {
|
|
return normalizePositiveNumberOrNull(tokenPattern[1]);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function extractDiscNumberFromText(value) {
|
|
const text = String(value || '').trim();
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
|
|
const directPattern = text.match(/(?:disc|disk|dvd|cd)\s*[-_.:]?\s*(\d{1,2})/i);
|
|
if (directPattern?.[1]) {
|
|
return normalizePositiveIntegerOrNull(directPattern[1]);
|
|
}
|
|
|
|
const tokenPattern = text.match(/(?:^|[\s._-])d(?:isc)?[-_.:]?\s*(\d{1,2})(?:$|[\s._-])/i);
|
|
if (tokenPattern?.[1]) {
|
|
return normalizePositiveIntegerOrNull(tokenPattern[1]);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function extractSelectedMetadataFromMakemkvInfo(makemkvInfo = {}) {
|
|
const source = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {};
|
|
const analyzeContext = source.analyzeContext && typeof source.analyzeContext === 'object'
|
|
? source.analyzeContext
|
|
: {};
|
|
const analyzeSelected = analyzeContext.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
|
? analyzeContext.selectedMetadata
|
|
: {};
|
|
const topLevelSelected = source.selectedMetadata && typeof source.selectedMetadata === 'object'
|
|
? source.selectedMetadata
|
|
: {};
|
|
const merged = {
|
|
...analyzeSelected,
|
|
...topLevelSelected
|
|
};
|
|
|
|
if (!merged.metadataProvider && analyzeContext.metadataProvider) {
|
|
merged.metadataProvider = analyzeContext.metadataProvider;
|
|
}
|
|
if (!merged.metadataKind && analyzeContext.metadataKind) {
|
|
merged.metadataKind = analyzeContext.metadataKind;
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
function hasSeriesMetadataSignals(selectedMetadata = {}, analyzeContext = {}) {
|
|
const metadata = selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {};
|
|
const analyze = analyzeContext && typeof analyzeContext === 'object' ? analyzeContext : {};
|
|
const workflowKind = normalizeDvdMetadataWorkflowKind(
|
|
metadata.workflowKind
|
|
|| analyze.workflowKind
|
|
|| null
|
|
);
|
|
if (workflowKind === 'film') {
|
|
return false;
|
|
}
|
|
if (workflowKind === 'series') {
|
|
return true;
|
|
}
|
|
const metadataKind = String(metadata.metadataKind || analyze.metadataKind || '').trim().toLowerCase();
|
|
const seasonNumber = normalizePositiveNumberOrNull(
|
|
metadata.seasonNumber ?? analyze?.seriesLookupHint?.seasonNumber ?? null
|
|
);
|
|
const seasonName = String(metadata.seasonName || '').trim();
|
|
const episodeCount = Number(metadata.episodeCount || 0) || 0;
|
|
const hasEpisodeList = Array.isArray(metadata.episodes) && metadata.episodes.length > 0;
|
|
const tmdbId = normalizePositiveIntegerOrNull(
|
|
metadata.tmdbId ?? analyze.tmdbId ?? metadata.providerId ?? analyze.providerId ?? null
|
|
);
|
|
const provider = String(
|
|
metadata.metadataProvider
|
|
|| analyze.metadataProvider
|
|
|| ''
|
|
).trim().toLowerCase();
|
|
const providerIsTmdb = provider === 'tmdb' || provider === 'themoviedb';
|
|
const seriesKind = ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind);
|
|
|
|
if (seriesKind || seasonNumber !== null || Boolean(seasonName) || episodeCount > 0 || hasEpisodeList || tmdbId !== null) {
|
|
return true;
|
|
}
|
|
return providerIsTmdb && Boolean(String(analyze?.seriesLookupHint?.query || '').trim());
|
|
}
|
|
|
|
function buildSeriesImportHints({
|
|
folderName = '',
|
|
rawPath = '',
|
|
metadata = {},
|
|
sourceSelectedMetadata = {},
|
|
sourceAnalyzeContext = {}
|
|
} = {}) {
|
|
const safeMetadata = metadata && typeof metadata === 'object' ? metadata : {};
|
|
const selected = sourceSelectedMetadata && typeof sourceSelectedMetadata === 'object'
|
|
? sourceSelectedMetadata
|
|
: {};
|
|
const analyze = sourceAnalyzeContext && typeof sourceAnalyzeContext === 'object'
|
|
? sourceAnalyzeContext
|
|
: {};
|
|
const titleSource = String(
|
|
selected.title
|
|
|| selected.seriesTitle
|
|
|| safeMetadata.title
|
|
|| ''
|
|
).trim();
|
|
const combinedText = [folderName, path.basename(String(rawPath || '').trim())]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
const seasonNumber = normalizePositiveNumberOrNull(
|
|
selected.seasonNumber
|
|
?? analyze?.seriesLookupHint?.seasonNumber
|
|
?? extractSeasonNumberFromText(combinedText)
|
|
);
|
|
const discNumber = normalizePositiveIntegerOrNull(
|
|
selected.discNumber
|
|
?? analyze?.seriesLookupHint?.discNumber
|
|
?? extractDiscNumberFromText(combinedText)
|
|
);
|
|
|
|
const seriesLookupHint = {
|
|
query: titleSource || null,
|
|
seasonNumber,
|
|
discNumber
|
|
};
|
|
const metadataHasSeriesSignals = hasSeriesMetadataSignals(selected, analyze);
|
|
if (!metadataHasSeriesSignals && !seriesLookupHint.query && seriesLookupHint.seasonNumber === null && seriesLookupHint.discNumber === null) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
selectedMetadata: {
|
|
...selected,
|
|
metadataProvider: String(selected.metadataProvider || analyze.metadataProvider || 'tmdb').trim().toLowerCase() || 'tmdb',
|
|
metadataKind: String(selected.metadataKind || analyze.metadataKind || 'series').trim().toLowerCase() || 'series',
|
|
title: titleSource || null,
|
|
seasonNumber,
|
|
discNumber
|
|
},
|
|
analyzeContextPatch: {
|
|
metadataProvider: String(
|
|
analyze.metadataProvider
|
|
|| selected.metadataProvider
|
|
|| 'tmdb'
|
|
).trim().toLowerCase() || 'tmdb',
|
|
metadataKind: String(
|
|
analyze.metadataKind
|
|
|| selected.metadataKind
|
|
|| 'series'
|
|
).trim().toLowerCase() || 'series',
|
|
seriesLookupHint: {
|
|
...(analyze.seriesLookupHint && typeof analyze.seriesLookupHint === 'object'
|
|
? analyze.seriesLookupHint
|
|
: {}),
|
|
...seriesLookupHint
|
|
},
|
|
seriesAnalysis: {
|
|
...(analyze.seriesAnalysis && typeof analyze.seriesAnalysis === 'object'
|
|
? analyze.seriesAnalysis
|
|
: {}),
|
|
summary: {
|
|
...(analyze?.seriesAnalysis?.summary && typeof analyze.seriesAnalysis.summary === 'object'
|
|
? analyze.seriesAnalysis.summary
|
|
: {}),
|
|
seriesLike: true,
|
|
confidence: analyze?.seriesAnalysis?.summary?.confidence || 'medium'
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function buildRawPathForJobId(rawPath, jobId) {
|
|
const normalizedJobId = Number(jobId);
|
|
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
|
|
return rawPath;
|
|
}
|
|
|
|
const absRawPath = normalizeComparablePath(rawPath);
|
|
const folderName = path.basename(absRawPath);
|
|
const statePrefix = /^Rip_Complete_/i.test(folderName)
|
|
? RAW_RIP_COMPLETE_PREFIX
|
|
: /^Incomplete_/i.test(folderName)
|
|
? RAW_INCOMPLETE_PREFIX
|
|
: '';
|
|
const stripped = stripRawFolderJobSuffix(stripRawFolderStatePrefix(folderName));
|
|
if (!stripped) {
|
|
return absRawPath;
|
|
}
|
|
const withJobId = `${statePrefix}${stripped} - RAW - job-${Math.trunc(normalizedJobId)}`;
|
|
return path.join(path.dirname(absRawPath), withJobId);
|
|
}
|
|
|
|
function normalizeCdFormat(value) {
|
|
const raw = String(value || '').trim().toLowerCase();
|
|
return cdRipService.SUPPORTED_FORMATS.has(raw) ? raw : null;
|
|
}
|
|
|
|
function normalizeAudioPathToken(value) {
|
|
let raw = String(value || '').trim();
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
|
|
// Unwrap common wrappers like "(...)" or quotes around the full token.
|
|
let changed = true;
|
|
while (changed && raw.length > 0) {
|
|
changed = false;
|
|
if (
|
|
(raw.startsWith('"') && raw.endsWith('"'))
|
|
|| (raw.startsWith("'") && raw.endsWith("'"))
|
|
|| (raw.startsWith('`') && raw.endsWith('`'))
|
|
|| (raw.startsWith('(') && raw.endsWith(')'))
|
|
) {
|
|
raw = raw.slice(1, -1).trim();
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
// Strip leading quotes and trailing punctuation (but keep balanced ')').
|
|
raw = raw
|
|
.replace(/^["'`]+/, '')
|
|
.replace(/[,"'`;]+$/g, '')
|
|
.trim();
|
|
|
|
// Remove only dangling unmatched ')' at the end (e.g. ".../path)")
|
|
// while preserving valid directory names like "... (2007)".
|
|
let openCount = (raw.match(/\(/g) || []).length;
|
|
let closeCount = (raw.match(/\)/g) || []).length;
|
|
while (closeCount > openCount && raw.endsWith(')')) {
|
|
raw = raw.slice(0, -1).trimEnd();
|
|
closeCount -= 1;
|
|
}
|
|
|
|
if (!raw.startsWith('/')) {
|
|
return null;
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
function resolveExistingAudioPathCandidate(candidate) {
|
|
const normalized = normalizeAudioPathToken(candidate) || String(candidate || '').trim() || null;
|
|
if (!normalized || !normalized.startsWith('/')) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
if (fs.existsSync(normalized)) {
|
|
return normalized;
|
|
}
|
|
} catch (_error) {
|
|
// ignore fs errors while probing possible output paths
|
|
}
|
|
|
|
const openCount = (normalized.match(/\(/g) || []).length;
|
|
const closeCount = (normalized.match(/\)/g) || []).length;
|
|
if (openCount <= closeCount) {
|
|
return null;
|
|
}
|
|
|
|
let repaired = normalized;
|
|
for (let missing = closeCount; missing < openCount; missing += 1) {
|
|
repaired = `${repaired})`;
|
|
try {
|
|
if (fs.existsSync(repaired)) {
|
|
return repaired;
|
|
}
|
|
} catch (_error) {
|
|
// ignore fs errors while probing repaired variants
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function extractAbsolutePathTokens(text) {
|
|
const source = String(text || '');
|
|
const tokens = [];
|
|
const seen = new Set();
|
|
const pushToken = (value) => {
|
|
const normalized = normalizeAudioPathToken(value);
|
|
if (!normalized || seen.has(normalized)) {
|
|
return;
|
|
}
|
|
seen.add(normalized);
|
|
tokens.push(normalized);
|
|
};
|
|
|
|
let match;
|
|
const quotedPattern = /'([^']+)'|"([^"]+)"/g;
|
|
while ((match = quotedPattern.exec(source)) !== null) {
|
|
pushToken(match[1] || match[2] || '');
|
|
}
|
|
|
|
const sanitized = source.replace(/'[^']*'|"[^"]*"/g, ' ');
|
|
const barePattern = /(^|\s)(\/[^\s]+)/g;
|
|
while ((match = barePattern.exec(sanitized)) !== null) {
|
|
pushToken(match[2] || '');
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
function parseProcessLogTimestamp(line) {
|
|
const match = String(line || '').match(/^\[([^\]]+)\]/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const parsed = Date.parse(match[1]);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function readProcessLogFileLines(filePath) {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
return String(raw || '')
|
|
.split(/\r\n|\n|\r/)
|
|
.filter((line) => line.length > 0);
|
|
} catch (_error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function listJobProcessLogFiles() {
|
|
const logDir = getJobLogDir();
|
|
try {
|
|
if (!fs.existsSync(logDir) || !fs.statSync(logDir).isDirectory()) {
|
|
return [];
|
|
}
|
|
return fs.readdirSync(logDir, { withFileTypes: true })
|
|
.filter((entry) => entry.isFile() && PROCESS_LOG_FILE_PATTERN.test(entry.name))
|
|
.map((entry) => {
|
|
const match = entry.name.match(PROCESS_LOG_FILE_PATTERN);
|
|
const jobId = match ? Number(match[1]) : null;
|
|
return {
|
|
fileName: entry.name,
|
|
filePath: path.join(logDir, entry.name),
|
|
jobId: Number.isFinite(jobId) ? Math.trunc(jobId) : null
|
|
};
|
|
});
|
|
} catch (_error) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function pickPreferredExistingPath(candidates = []) {
|
|
const normalizedCandidates = [];
|
|
const seen = new Set();
|
|
|
|
for (const candidate of Array.isArray(candidates) ? candidates : []) {
|
|
const normalized = normalizeAudioPathToken(candidate) || String(candidate || '').trim() || null;
|
|
if (!normalized || seen.has(normalized)) {
|
|
continue;
|
|
}
|
|
seen.add(normalized);
|
|
normalizedCandidates.push(normalized);
|
|
}
|
|
|
|
for (let index = normalizedCandidates.length - 1; index >= 0; index -= 1) {
|
|
const candidate = normalizedCandidates[index];
|
|
const existingCandidate = resolveExistingAudioPathCandidate(candidate);
|
|
if (existingCandidate) {
|
|
return existingCandidate;
|
|
}
|
|
}
|
|
|
|
return normalizedCandidates[normalizedCandidates.length - 1] || null;
|
|
}
|
|
|
|
function collectAudioFilesRecursively(rootPath, options = {}) {
|
|
const maxDepth = Number.isFinite(Number(options.maxDepth)) ? Math.max(0, Math.trunc(Number(options.maxDepth))) : 6;
|
|
const maxFiles = Number.isFinite(Number(options.maxFiles)) ? Math.max(1, Math.trunc(Number(options.maxFiles))) : 2000;
|
|
const result = [];
|
|
const normalizedRoot = String(rootPath || '').trim();
|
|
if (!normalizedRoot || !fs.existsSync(normalizedRoot)) {
|
|
return result;
|
|
}
|
|
|
|
const pushFile = (filePath) => {
|
|
if (result.length >= maxFiles) {
|
|
return;
|
|
}
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
if (CD_OUTPUT_AUDIO_EXTENSIONS.has(ext)) {
|
|
result.push(filePath);
|
|
}
|
|
};
|
|
|
|
try {
|
|
const stat = fs.statSync(normalizedRoot);
|
|
if (stat.isFile()) {
|
|
pushFile(normalizedRoot);
|
|
return result;
|
|
}
|
|
} catch (_error) {
|
|
return result;
|
|
}
|
|
|
|
const queue = [{ dirPath: normalizedRoot, depth: 0 }];
|
|
while (queue.length > 0 && result.length < maxFiles) {
|
|
const current = queue.shift();
|
|
if (!current) {
|
|
continue;
|
|
}
|
|
let entries = [];
|
|
try {
|
|
entries = fs.readdirSync(current.dirPath, { withFileTypes: true });
|
|
} catch (_error) {
|
|
continue;
|
|
}
|
|
|
|
entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
|
|
for (const entry of entries) {
|
|
const absPath = path.join(current.dirPath, entry.name);
|
|
if (entry.isFile()) {
|
|
pushFile(absPath);
|
|
} else if (entry.isDirectory() && current.depth < maxDepth) {
|
|
queue.push({ dirPath: absPath, depth: current.depth + 1 });
|
|
}
|
|
if (result.length >= maxFiles) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function parseCdOutputTrackFromPath(filePath) {
|
|
const ext = path.extname(String(filePath || '')).toLowerCase();
|
|
if (!CD_OUTPUT_AUDIO_EXTENSIONS.has(ext)) {
|
|
return null;
|
|
}
|
|
|
|
const baseName = path.basename(filePath, ext).replace(/\s+/g, ' ').trim();
|
|
if (!baseName) {
|
|
return null;
|
|
}
|
|
|
|
const numberMatch = baseName.match(/^(\d{1,3})(.*)$/);
|
|
const parsedPosition = numberMatch ? Number(numberMatch[1]) : null;
|
|
const position = Number.isFinite(parsedPosition) && parsedPosition > 0 ? Math.trunc(parsedPosition) : null;
|
|
let remainder = numberMatch ? String(numberMatch[2] || '').replace(/^[-._\s]+/, '').trim() : baseName;
|
|
let artist = null;
|
|
let title = remainder || baseName;
|
|
|
|
const separatorIndex = remainder.indexOf(' - ');
|
|
if (separatorIndex > 0) {
|
|
artist = remainder.slice(0, separatorIndex).trim() || null;
|
|
title = remainder.slice(separatorIndex + 3).trim() || remainder;
|
|
}
|
|
|
|
return {
|
|
position,
|
|
artist,
|
|
title: title || baseName,
|
|
format: normalizeCdFormat(ext.replace(/^\./, '')),
|
|
filePath
|
|
};
|
|
}
|
|
|
|
function assignMissingTrackPositions(entries = []) {
|
|
const used = new Set(
|
|
(Array.isArray(entries) ? entries : [])
|
|
.map((entry) => Number(entry?.position))
|
|
.filter((value) => Number.isFinite(value) && value > 0)
|
|
.map((value) => Math.trunc(value))
|
|
);
|
|
|
|
let nextPosition = 1;
|
|
return (Array.isArray(entries) ? entries : []).map((entry) => {
|
|
const current = Number(entry?.position);
|
|
if (Number.isFinite(current) && current > 0) {
|
|
return {
|
|
...entry,
|
|
position: Math.trunc(current)
|
|
};
|
|
}
|
|
|
|
while (used.has(nextPosition)) {
|
|
nextPosition += 1;
|
|
}
|
|
const assigned = nextPosition;
|
|
used.add(assigned);
|
|
nextPosition += 1;
|
|
return {
|
|
...entry,
|
|
position: assigned
|
|
};
|
|
});
|
|
}
|
|
|
|
function findMostCommonString(values = []) {
|
|
const counts = new Map();
|
|
for (const value of Array.isArray(values) ? values : []) {
|
|
const normalized = String(value || '').trim();
|
|
if (!normalized) {
|
|
continue;
|
|
}
|
|
counts.set(normalized, (counts.get(normalized) || 0) + 1);
|
|
}
|
|
|
|
let bestValue = null;
|
|
let bestCount = 0;
|
|
for (const [value, count] of counts.entries()) {
|
|
if (count > bestCount) {
|
|
bestValue = value;
|
|
bestCount = count;
|
|
}
|
|
}
|
|
return bestValue;
|
|
}
|
|
|
|
function normalizeComparableLabel(value) {
|
|
return String(value || '')
|
|
.normalize('NFKD')
|
|
.replace(/[^\x00-\x7F]+/g, '')
|
|
.replace(/[^A-Za-z0-9]+/g, ' ')
|
|
.trim()
|
|
.toLowerCase();
|
|
}
|
|
|
|
function parseCdFolderMetadataFromOutputDir(outputDir) {
|
|
const normalized = String(outputDir || '').trim();
|
|
if (!normalized) {
|
|
return {
|
|
artist: null,
|
|
album: null,
|
|
year: null
|
|
};
|
|
}
|
|
|
|
let working = path.basename(normalized).replace(/\s+/g, ' ').trim();
|
|
if (!working || working === '.' || working === path.sep) {
|
|
return {
|
|
artist: null,
|
|
album: null,
|
|
year: null
|
|
};
|
|
}
|
|
|
|
const yearMatch = working.match(/\((19|20)\d{2}\)\s*$/);
|
|
const year = yearMatch ? Number(String(yearMatch[0]).replace(/[()]/g, '')) : null;
|
|
if (yearMatch) {
|
|
working = working.slice(0, working.length - yearMatch[0].length).trim();
|
|
}
|
|
|
|
const separatorIndex = working.indexOf(' - ');
|
|
const artist = separatorIndex > 0 ? working.slice(0, separatorIndex).trim() || null : null;
|
|
const album = separatorIndex > 0
|
|
? working.slice(separatorIndex + 3).trim() || null
|
|
: (working || null);
|
|
|
|
return {
|
|
artist,
|
|
album,
|
|
year: Number.isFinite(year) ? year : null
|
|
};
|
|
}
|
|
|
|
function directoryContainsAudioFiles(dirPath) {
|
|
try {
|
|
if (!dirPath || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
|
return false;
|
|
}
|
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const absPath = path.join(dirPath, entry.name);
|
|
if (entry.isFile()) {
|
|
if (CD_OUTPUT_AUDIO_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
|
return true;
|
|
}
|
|
} else if (entry.isDirectory()) {
|
|
const nestedEntries = fs.readdirSync(absPath, { withFileTypes: true });
|
|
if (nestedEntries.some((nested) => nested.isFile() && CD_OUTPUT_AUDIO_EXTENSIONS.has(path.extname(nested.name).toLowerCase()))) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function findCdOutputDirByMetadata(options = {}) {
|
|
const settings = options.settings && typeof options.settings === 'object' ? options.settings : {};
|
|
const baseMetadata = options.baseMetadata && typeof options.baseMetadata === 'object'
|
|
? options.baseMetadata
|
|
: {};
|
|
const outputTemplate = String(options.outputTemplate || settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE).trim()
|
|
|| cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
|
|
const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings, 'cd');
|
|
const outputBaseDir = String(effectiveSettings?.movie_dir || effectiveSettings?.raw_dir || '').trim() || null;
|
|
if (!outputBaseDir) {
|
|
return null;
|
|
}
|
|
|
|
const metadataVariants = [];
|
|
const pushMetadataVariant = (candidate) => {
|
|
if (!candidate || typeof candidate !== 'object') {
|
|
return;
|
|
}
|
|
const title = String(candidate.title || candidate.album || '').trim() || null;
|
|
const artist = String(candidate.artist || '').trim() || null;
|
|
const yearRaw = Number(candidate.year);
|
|
const year = Number.isFinite(yearRaw) && yearRaw > 0 ? Math.trunc(yearRaw) : null;
|
|
if (!title && !artist && !year) {
|
|
return;
|
|
}
|
|
const key = JSON.stringify({ title, artist, year });
|
|
if (metadataVariants.some((entry) => entry.key === key)) {
|
|
return;
|
|
}
|
|
metadataVariants.push({
|
|
key,
|
|
meta: {
|
|
title,
|
|
album: title,
|
|
artist,
|
|
year
|
|
}
|
|
});
|
|
};
|
|
|
|
pushMetadataVariant(baseMetadata);
|
|
pushMetadataVariant({
|
|
title: baseMetadata.album || baseMetadata.title || null,
|
|
artist: null,
|
|
year: baseMetadata.year || null
|
|
});
|
|
|
|
for (const variant of metadataVariants) {
|
|
try {
|
|
if (!variant.meta.title) {
|
|
continue;
|
|
}
|
|
const candidatePath = cdRipService.buildOutputDir(variant.meta, outputBaseDir, outputTemplate);
|
|
if (candidatePath && fs.existsSync(candidatePath) && directoryContainsAudioFiles(candidatePath)) {
|
|
return candidatePath;
|
|
}
|
|
} catch (_error) {
|
|
// ignore template/render errors and continue with directory scan fallback
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (!fs.existsSync(outputBaseDir) || !fs.statSync(outputBaseDir).isDirectory()) {
|
|
return null;
|
|
}
|
|
const targetAlbum = normalizeComparableLabel(baseMetadata.album || baseMetadata.title || '');
|
|
const targetArtist = normalizeComparableLabel(baseMetadata.artist || '');
|
|
const targetYearRaw = Number(baseMetadata.year);
|
|
const targetYear = Number.isFinite(targetYearRaw) && targetYearRaw > 0 ? Math.trunc(targetYearRaw) : null;
|
|
let best = null;
|
|
let bestScore = -1;
|
|
const entries = fs.readdirSync(outputBaseDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) {
|
|
continue;
|
|
}
|
|
const candidatePath = path.join(outputBaseDir, entry.name);
|
|
if (!directoryContainsAudioFiles(candidatePath)) {
|
|
continue;
|
|
}
|
|
const parsed = parseCdFolderMetadataFromOutputDir(candidatePath);
|
|
const candidateAlbum = normalizeComparableLabel(parsed.album || '');
|
|
const candidateArtist = normalizeComparableLabel(parsed.artist || '');
|
|
const candidateYearRaw = Number(parsed.year);
|
|
const candidateYear = Number.isFinite(candidateYearRaw) && candidateYearRaw > 0 ? Math.trunc(candidateYearRaw) : null;
|
|
|
|
let score = 0;
|
|
if (targetAlbum && candidateAlbum === targetAlbum) {
|
|
score += 10;
|
|
} else if (targetAlbum && candidateAlbum.includes(targetAlbum)) {
|
|
score += 6;
|
|
} else if (targetAlbum && targetAlbum.includes(candidateAlbum) && candidateAlbum) {
|
|
score += 4;
|
|
}
|
|
if (targetYear && candidateYear === targetYear) {
|
|
score += 5;
|
|
}
|
|
if (targetArtist && candidateArtist === targetArtist) {
|
|
score += 3;
|
|
}
|
|
if (score > bestScore) {
|
|
best = candidatePath;
|
|
bestScore = score;
|
|
}
|
|
}
|
|
return bestScore > 0 ? best : null;
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function extractCommandToken(commandLine) {
|
|
const raw = String(commandLine || '').trim();
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
const match = raw.match(/^'([^']+)'|"([^"]+)"|(\S+)/);
|
|
return String(match?.[1] || match?.[2] || match?.[3] || '').trim() || null;
|
|
}
|
|
|
|
function findRelatedJobLogSources(options = {}) {
|
|
const relatedJobIds = Array.isArray(options.relatedJobIds)
|
|
? options.relatedJobIds
|
|
.map((value) => normalizeJobIdValue(value))
|
|
.filter(Boolean)
|
|
: [];
|
|
const excludeJobIds = new Set(
|
|
(Array.isArray(options.excludeJobIds) ? options.excludeJobIds : [])
|
|
.map((value) => normalizeJobIdValue(value))
|
|
.filter(Boolean)
|
|
);
|
|
const rawPathCandidates = Array.isArray(options.rawPathCandidates)
|
|
? options.rawPathCandidates
|
|
.map((value) => String(value || '').trim())
|
|
.filter(Boolean)
|
|
: [];
|
|
const pathNeedles = Array.from(new Set(
|
|
rawPathCandidates
|
|
.map((value) => normalizeComparablePath(value))
|
|
.filter(Boolean)
|
|
));
|
|
|
|
const collected = new Map();
|
|
const addSource = (filePath, matchedBy) => {
|
|
const normalizedFilePath = String(filePath || '').trim();
|
|
if (!normalizedFilePath || collected.has(normalizedFilePath)) {
|
|
return;
|
|
}
|
|
const lines = readProcessLogFileLines(normalizedFilePath);
|
|
if (lines.length === 0) {
|
|
return;
|
|
}
|
|
const fileName = path.basename(normalizedFilePath);
|
|
const match = fileName.match(PROCESS_LOG_FILE_PATTERN);
|
|
const parsedJobId = match ? Number(match[1]) : null;
|
|
const jobId = Number.isFinite(parsedJobId) ? Math.trunc(parsedJobId) : null;
|
|
if (jobId && excludeJobIds.has(jobId)) {
|
|
return;
|
|
}
|
|
const firstTimestampMs = lines.reduce((found, line) => {
|
|
if (found !== null) {
|
|
return found;
|
|
}
|
|
return parseProcessLogTimestamp(line);
|
|
}, null);
|
|
collected.set(normalizedFilePath, {
|
|
filePath: normalizedFilePath,
|
|
fileName,
|
|
jobId,
|
|
matchedBy: matchedBy ? [matchedBy] : [],
|
|
lines,
|
|
firstTimestampMs
|
|
});
|
|
};
|
|
|
|
for (const relatedJobId of relatedJobIds) {
|
|
if (excludeJobIds.has(relatedJobId)) {
|
|
continue;
|
|
}
|
|
const directPath = toProcessLogPath(relatedJobId);
|
|
if (directPath && fs.existsSync(directPath)) {
|
|
addSource(directPath, `jobId:${relatedJobId}`);
|
|
}
|
|
}
|
|
|
|
if (pathNeedles.length > 0) {
|
|
for (const entry of listJobProcessLogFiles()) {
|
|
if (!entry?.filePath || collected.has(entry.filePath)) {
|
|
continue;
|
|
}
|
|
if (entry.jobId && excludeJobIds.has(entry.jobId)) {
|
|
continue;
|
|
}
|
|
|
|
let rawText = '';
|
|
try {
|
|
rawText = fs.readFileSync(entry.filePath, 'utf-8');
|
|
} catch (_error) {
|
|
continue;
|
|
}
|
|
|
|
const matchedNeedle = pathNeedles.find((needle) => rawText.includes(needle));
|
|
if (matchedNeedle) {
|
|
addSource(entry.filePath, `path:${matchedNeedle}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return Array.from(collected.values())
|
|
.sort((a, b) => {
|
|
if (a.firstTimestampMs !== null && b.firstTimestampMs !== null && a.firstTimestampMs !== b.firstTimestampMs) {
|
|
return a.firstTimestampMs - b.firstTimestampMs;
|
|
}
|
|
if (a.jobId && b.jobId && a.jobId !== b.jobId) {
|
|
return a.jobId - b.jobId;
|
|
}
|
|
return a.fileName.localeCompare(b.fileName, undefined, { numeric: true, sensitivity: 'base' });
|
|
});
|
|
}
|
|
|
|
function recoverCdJobArtifactsForImport(options = {}) {
|
|
const currentRawPath = String(options.currentRawPath || '').trim() || null;
|
|
const settings = options.settings && typeof options.settings === 'object' ? options.settings : {};
|
|
const baseMetadata = options.baseMetadata && typeof options.baseMetadata === 'object'
|
|
? options.baseMetadata
|
|
: {};
|
|
const rawPathCandidates = Array.isArray(options.rawPathCandidates)
|
|
? options.rawPathCandidates
|
|
: [];
|
|
const relatedJobIds = Array.isArray(options.relatedJobIds)
|
|
? options.relatedJobIds
|
|
: [];
|
|
const excludeJobIds = Array.isArray(options.excludeJobIds)
|
|
? options.excludeJobIds
|
|
: [];
|
|
const logSources = findRelatedJobLogSources({
|
|
rawPathCandidates: [currentRawPath, ...rawPathCandidates],
|
|
relatedJobIds,
|
|
excludeJobIds
|
|
});
|
|
const logLines = logSources.flatMap((source) => source.lines || []);
|
|
|
|
let formatFromLogs = null;
|
|
let selectedTracksFromLogs = [];
|
|
let selectedTracksLogSaysAll = false;
|
|
let cdparanoiaCmd = null;
|
|
const explicitOutputDirs = [];
|
|
const outputFilePathsFromLogs = [];
|
|
const seenOutputFilePath = new Set();
|
|
|
|
for (const line of logLines) {
|
|
const message = String(line || '').replace(/^\[[^\]]+\]\s+\[[^\]]+\]\s*/, '');
|
|
if (!message) {
|
|
continue;
|
|
}
|
|
|
|
const startMatch = message.match(/CD-Rip gestartet:\s*Format=([a-z0-9]+)\s*,\s*Tracks=(.+)$/i);
|
|
if (startMatch) {
|
|
formatFromLogs = normalizeCdFormat(startMatch[1]) || formatFromLogs;
|
|
const selectedTrackText = String(startMatch[2] || '').trim();
|
|
if (/^alle$/i.test(selectedTrackText)) {
|
|
selectedTracksLogSaysAll = true;
|
|
selectedTracksFromLogs = [];
|
|
} else {
|
|
selectedTracksFromLogs = selectedTrackText
|
|
.split(',')
|
|
.map((value) => Number(value))
|
|
.filter((value) => Number.isFinite(value) && value > 0)
|
|
.map((value) => Math.trunc(value));
|
|
}
|
|
}
|
|
|
|
const completionMatch = message.match(/CD-Rip abgeschlossen\.\s*Ausgabe:\s*(.+)$/i);
|
|
if (completionMatch) {
|
|
const outputDir = normalizeAudioPathToken(completionMatch[1]) || String(completionMatch[1] || '').trim();
|
|
if (outputDir) {
|
|
explicitOutputDirs.push(outputDir);
|
|
}
|
|
}
|
|
|
|
if (!cdparanoiaCmd) {
|
|
const ripMatch = message.match(/Promptkette \[Rip \d+\/\d+]:\s*(.+)$/i);
|
|
if (ripMatch) {
|
|
cdparanoiaCmd = extractCommandToken(ripMatch[1]);
|
|
}
|
|
}
|
|
|
|
for (const token of extractAbsolutePathTokens(message)) {
|
|
const ext = path.extname(token).toLowerCase();
|
|
if (!CD_OUTPUT_AUDIO_EXTENSIONS.has(ext)) {
|
|
continue;
|
|
}
|
|
if (CD_RAW_TRACK_FILE_PATTERN.test(path.basename(token))) {
|
|
continue;
|
|
}
|
|
if (!seenOutputFilePath.has(token)) {
|
|
seenOutputFilePath.add(token);
|
|
outputFilePathsFromLogs.push(token);
|
|
}
|
|
}
|
|
}
|
|
|
|
const outputPath = pickPreferredExistingPath([
|
|
...(options.fallbackOutputPath ? [options.fallbackOutputPath] : []),
|
|
...explicitOutputDirs,
|
|
...outputFilePathsFromLogs.map((filePath) => path.dirname(filePath))
|
|
]);
|
|
|
|
const outputAudioFiles = outputPath ? collectAudioFilesRecursively(outputPath) : [];
|
|
const parsedOutputTracks = assignMissingTrackPositions(
|
|
(outputAudioFiles.length > 0 ? outputAudioFiles : outputFilePathsFromLogs)
|
|
.map((filePath) => parseCdOutputTrackFromPath(filePath))
|
|
.filter(Boolean)
|
|
);
|
|
|
|
const rawTracks = [];
|
|
if (currentRawPath && fs.existsSync(currentRawPath)) {
|
|
try {
|
|
const rawEntries = fs.readdirSync(currentRawPath, { withFileTypes: true });
|
|
for (const entry of rawEntries) {
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
const match = entry.name.match(CD_RAW_TRACK_FILE_PATTERN);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
const parsedPosition = Number(match[1]);
|
|
if (!Number.isFinite(parsedPosition) || parsedPosition <= 0) {
|
|
continue;
|
|
}
|
|
rawTracks.push({
|
|
position: Math.trunc(parsedPosition),
|
|
title: `Track ${Math.trunc(parsedPosition)}`,
|
|
artist: null
|
|
});
|
|
}
|
|
} catch (_error) {
|
|
// ignore raw dir read errors during best-effort import recovery
|
|
}
|
|
}
|
|
|
|
const folderMetadata = parseRawFolderMetadata(path.basename(currentRawPath || rawPathCandidates[0] || ''));
|
|
const outputFolderMetadata = parseCdFolderMetadataFromOutputDir(outputPath);
|
|
const selectedTrackSet = new Set(
|
|
selectedTracksFromLogs
|
|
.map((value) => Number(value))
|
|
.filter((value) => Number.isFinite(value) && value > 0)
|
|
.map((value) => Math.trunc(value))
|
|
);
|
|
const trackPositionSet = new Set();
|
|
for (const track of rawTracks) {
|
|
trackPositionSet.add(track.position);
|
|
}
|
|
for (const track of parsedOutputTracks) {
|
|
if (Number.isFinite(Number(track?.position)) && Number(track.position) > 0) {
|
|
trackPositionSet.add(Math.trunc(Number(track.position)));
|
|
}
|
|
}
|
|
for (const position of selectedTrackSet) {
|
|
trackPositionSet.add(position);
|
|
}
|
|
|
|
const trackPositions = Array.from(trackPositionSet).sort((a, b) => a - b);
|
|
const outputTrackByPosition = new Map(
|
|
parsedOutputTracks
|
|
.filter((track) => Number.isFinite(Number(track?.position)) && Number(track.position) > 0)
|
|
.map((track) => [Math.trunc(Number(track.position)), track])
|
|
);
|
|
const rawTrackByPosition = new Map(
|
|
rawTracks
|
|
.filter((track) => Number.isFinite(Number(track?.position)) && Number(track.position) > 0)
|
|
.map((track) => [Math.trunc(Number(track.position)), track])
|
|
);
|
|
|
|
const tracks = trackPositions.map((position) => {
|
|
const outputTrack = outputTrackByPosition.get(position) || null;
|
|
const rawTrack = rawTrackByPosition.get(position) || null;
|
|
return {
|
|
position,
|
|
title: String(outputTrack?.title || rawTrack?.title || `Track ${position}`).trim() || `Track ${position}`,
|
|
artist: String(outputTrack?.artist || rawTrack?.artist || '').trim() || null,
|
|
selected: selectedTrackSet.size > 0 ? selectedTrackSet.has(position) : true
|
|
};
|
|
});
|
|
|
|
const selectedTracks = selectedTracksLogSaysAll || selectedTrackSet.size === 0
|
|
? trackPositions
|
|
: trackPositions.filter((position) => selectedTrackSet.has(position));
|
|
const artistHints = [
|
|
...parsedOutputTracks.map((track) => track.artist),
|
|
...tracks.map((track) => track.artist),
|
|
baseMetadata.artist,
|
|
outputFolderMetadata.artist
|
|
].filter(Boolean);
|
|
const formatHints = [
|
|
formatFromLogs,
|
|
...parsedOutputTracks.map((track) => track.format)
|
|
].filter(Boolean);
|
|
const inferredArtist = findMostCommonString(artistHints);
|
|
const inferredFormat = findMostCommonString(formatHints);
|
|
const normalizedYear = Number(baseMetadata.year);
|
|
const year = Number.isFinite(normalizedYear) && normalizedYear > 0
|
|
? Math.trunc(normalizedYear)
|
|
: (outputFolderMetadata.year || folderMetadata.year || null);
|
|
const title = String(
|
|
baseMetadata.title
|
|
|| baseMetadata.album
|
|
|| outputFolderMetadata.album
|
|
|| folderMetadata.title
|
|
|| ''
|
|
).trim() || null;
|
|
const artist = String(baseMetadata.artist || inferredArtist || '').trim() || null;
|
|
const selectedMetadata = {
|
|
title,
|
|
album: title,
|
|
artist,
|
|
year
|
|
};
|
|
const outputTemplate = String(settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE).trim()
|
|
|| cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
|
|
const outputPathFromMetadata = findCdOutputDirByMetadata({
|
|
settings,
|
|
baseMetadata: selectedMetadata,
|
|
outputTemplate
|
|
});
|
|
const resolvedOutputPath = outputPath || outputPathFromMetadata || null;
|
|
|
|
return {
|
|
logSources,
|
|
outputPath: resolvedOutputPath,
|
|
outputPathExists: Boolean(resolvedOutputPath && fs.existsSync(resolvedOutputPath)),
|
|
format: normalizeCdFormat(inferredFormat),
|
|
tracks,
|
|
selectedTracks,
|
|
selectedMetadata,
|
|
cdparanoiaCmd: String(cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia',
|
|
outputTemplate,
|
|
importedLogFiles: logSources.map((source) => ({
|
|
fileName: source.fileName,
|
|
jobId: source.jobId,
|
|
matchedBy: Array.isArray(source.matchedBy) ? source.matchedBy : [],
|
|
lineCount: Array.isArray(source.lines) ? source.lines.length : 0
|
|
}))
|
|
};
|
|
}
|
|
|
|
function deleteFilesRecursively(rootPath, keepRoot = true) {
|
|
const result = {
|
|
filesDeleted: 0,
|
|
dirsRemoved: 0
|
|
};
|
|
|
|
const visit = (current, isRoot = false) => {
|
|
if (!fs.existsSync(current)) {
|
|
return;
|
|
}
|
|
|
|
const stat = fs.lstatSync(current);
|
|
if (stat.isDirectory()) {
|
|
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const abs = path.join(current, entry.name);
|
|
if (entry.isDirectory()) {
|
|
visit(abs, false);
|
|
} else {
|
|
fs.unlinkSync(abs);
|
|
result.filesDeleted += 1;
|
|
}
|
|
}
|
|
|
|
const remaining = fs.readdirSync(current);
|
|
if (remaining.length === 0 && (!isRoot || !keepRoot)) {
|
|
fs.rmdirSync(current);
|
|
result.dirsRemoved += 1;
|
|
}
|
|
return;
|
|
}
|
|
|
|
fs.unlinkSync(current);
|
|
result.filesDeleted += 1;
|
|
};
|
|
|
|
visit(rootPath, true);
|
|
return result;
|
|
}
|
|
|
|
function collectFilesRecursively(rootPath) {
|
|
const normalizedRoot = normalizeComparablePath(rootPath);
|
|
if (!normalizedRoot) {
|
|
return [];
|
|
}
|
|
const result = [];
|
|
const visit = (currentPath) => {
|
|
let stat = null;
|
|
try {
|
|
stat = fs.lstatSync(currentPath);
|
|
} catch (_error) {
|
|
return;
|
|
}
|
|
if (stat.isDirectory()) {
|
|
let entries = [];
|
|
try {
|
|
entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
} catch (_error) {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
visit(path.join(currentPath, entry.name));
|
|
}
|
|
return;
|
|
}
|
|
result.push(normalizeComparablePath(currentPath));
|
|
};
|
|
visit(normalizedRoot);
|
|
return Array.from(new Set(result.filter(Boolean))).sort((left, right) => left.localeCompare(right, 'de'));
|
|
}
|
|
|
|
function removeEmptyParentDirectories(startPath, options = {}) {
|
|
const start = normalizeComparablePath(startPath);
|
|
if (!start) {
|
|
return [];
|
|
}
|
|
const protectedRoots = new Set(
|
|
(Array.isArray(options?.protectedRoots) ? options.protectedRoots : [])
|
|
.map((entry) => normalizeComparablePath(entry))
|
|
.filter(Boolean)
|
|
);
|
|
const allowedRoots = Array.from(protectedRoots);
|
|
const removed = [];
|
|
const removedSet = new Set();
|
|
|
|
let current = start;
|
|
while (current && !isFilesystemRootPath(current)) {
|
|
if (protectedRoots.has(current)) {
|
|
break;
|
|
}
|
|
if (allowedRoots.length > 0 && !allowedRoots.some((rootPath) => isPathInside(rootPath, current))) {
|
|
break;
|
|
}
|
|
let stat = null;
|
|
try {
|
|
stat = fs.lstatSync(current);
|
|
} catch (_error) {
|
|
break;
|
|
}
|
|
if (!stat.isDirectory()) {
|
|
break;
|
|
}
|
|
let entries = [];
|
|
try {
|
|
entries = fs.readdirSync(current);
|
|
} catch (_error) {
|
|
break;
|
|
}
|
|
if (entries.length > 0) {
|
|
break;
|
|
}
|
|
try {
|
|
fs.rmdirSync(current);
|
|
if (!removedSet.has(current)) {
|
|
removedSet.add(current);
|
|
removed.push(current);
|
|
}
|
|
} catch (_error) {
|
|
break;
|
|
}
|
|
const parentDir = normalizeComparablePath(path.dirname(current));
|
|
if (!parentDir || parentDir === current) {
|
|
break;
|
|
}
|
|
current = parentDir;
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
function normalizeJobIdValue(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return null;
|
|
}
|
|
return Math.trunc(parsed);
|
|
}
|
|
|
|
function isSeriesContainerRow(row) {
|
|
return String(row?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
|
|
}
|
|
|
|
function isMultipartContainerRow(row) {
|
|
return String(row?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container';
|
|
}
|
|
|
|
function isDiskContainerRow(row) {
|
|
return isSeriesContainerRow(row) || isMultipartContainerRow(row);
|
|
}
|
|
|
|
function isSeriesChildRow(row) {
|
|
return String(row?.job_kind || '').trim().toLowerCase() === 'dvd_series_child';
|
|
}
|
|
|
|
function isMultipartChildRow(row) {
|
|
return String(row?.job_kind || '').trim().toLowerCase() === 'multipart_movie_child';
|
|
}
|
|
|
|
function isMultipartMergeRow(row) {
|
|
const jobKind = String(row?.job_kind || '').trim().toLowerCase();
|
|
if (jobKind === 'multipart_movie_merge') {
|
|
return true;
|
|
}
|
|
const encodePlan = parseInfoFromValue(row?.encodePlan ?? row?.encode_plan_json, null);
|
|
const handbrakeInfo = parseInfoFromValue(row?.handbrakeInfo ?? row?.handbrake_info_json, null);
|
|
const planJobKind = String(encodePlan?.jobKind || '').trim().toLowerCase();
|
|
if (planJobKind === 'multipart_movie_merge') {
|
|
return true;
|
|
}
|
|
const mode = String(encodePlan?.mode || handbrakeInfo?.mode || '').trim().toLowerCase();
|
|
return mode === 'multipart_merge';
|
|
}
|
|
|
|
function normalizeArchiveTarget(value) {
|
|
const raw = String(value || '').trim().toLowerCase();
|
|
if (raw === 'raw') {
|
|
return 'raw';
|
|
}
|
|
if (raw === 'output' || raw === 'movie' || raw === 'encode') {
|
|
return 'output';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function sanitizeArchiveNamePart(value, fallback = 'job') {
|
|
const normalized = String(value || '')
|
|
.normalize('NFKD')
|
|
.replace(/[^\x00-\x7F]+/g, '');
|
|
const safe = normalized
|
|
.replace(/[^A-Za-z0-9._-]+/g, '_')
|
|
.replace(/_+/g, '_')
|
|
.replace(/^[_-]+|[_-]+$/g, '')
|
|
.slice(0, 80);
|
|
return safe || fallback;
|
|
}
|
|
|
|
function buildJobArchiveName(job, target) {
|
|
const jobId = normalizeJobIdValue(job?.id) || 'unknown';
|
|
const titlePart = sanitizeArchiveNamePart(job?.title || job?.detected_title || '', 'job');
|
|
const targetPart = target === 'raw' ? 'raw' : 'encode';
|
|
return `job-${jobId}-${titlePart}-${targetPart}.zip`;
|
|
}
|
|
|
|
function parseSourceJobIdFromPlan(encodePlanRaw) {
|
|
const plan = parseInfoFromValue(encodePlanRaw, null);
|
|
const sourceJobId = normalizeJobIdValue(plan?.sourceJobId);
|
|
return sourceJobId || null;
|
|
}
|
|
|
|
function extractDiscNumberFromDeleteContext(row) {
|
|
const values = [
|
|
row?.raw_path,
|
|
row?.title,
|
|
row?.detected_title
|
|
];
|
|
for (const value of values) {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) continue;
|
|
const match = raw.match(/(?:^|\W)D(?:ISC)?\s*[-_ ]?(\d+)(?:\W|$)/i)
|
|
|| raw.match(/-\s*D(\d+)\s*-\s*RAW/i);
|
|
const discNumber = normalizeJobIdValue(match?.[1]);
|
|
if (discNumber) {
|
|
return discNumber;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveDeletePreviewRole(row) {
|
|
if (!row || typeof row !== 'object') {
|
|
return { roleKey: 'job', roleLabel: 'Job' };
|
|
}
|
|
if (isSeriesContainerRow(row) || isMultipartContainerRow(row)) {
|
|
return { roleKey: 'container', roleLabel: 'Container' };
|
|
}
|
|
if (isMultipartMergeRow(row)) {
|
|
return { roleKey: 'merge', roleLabel: 'Merge' };
|
|
}
|
|
if (isSeriesChildRow(row) || isMultipartChildRow(row)) {
|
|
const discNumber = extractDiscNumberFromDeleteContext(row);
|
|
return {
|
|
roleKey: 'disc',
|
|
roleLabel: discNumber ? `Disk ${discNumber}` : 'Disk/Child'
|
|
};
|
|
}
|
|
if (normalizeJobIdValue(row?.parent_job_id)) {
|
|
return { roleKey: 'child', roleLabel: 'Child-Job' };
|
|
}
|
|
return { roleKey: 'job', roleLabel: 'Einzeljob' };
|
|
}
|
|
|
|
function parseRetryLinkedJobIdsFromLogLines(lines = []) {
|
|
const jobIds = new Set();
|
|
const list = Array.isArray(lines) ? lines : [];
|
|
for (const line of list) {
|
|
const text = String(line || '');
|
|
if (!text) {
|
|
continue;
|
|
}
|
|
if (!/retry/i.test(text)) {
|
|
continue;
|
|
}
|
|
const regex = /job\s*#(\d+)/ig;
|
|
let match = regex.exec(text);
|
|
while (match) {
|
|
const id = normalizeJobIdValue(match?.[1]);
|
|
if (id) {
|
|
jobIds.add(id);
|
|
}
|
|
match = regex.exec(text);
|
|
}
|
|
}
|
|
return Array.from(jobIds);
|
|
}
|
|
|
|
function normalizeLineageReason(value) {
|
|
const normalized = String(value || '').trim();
|
|
return normalized || null;
|
|
}
|
|
|
|
function inspectDeletionPath(targetPath) {
|
|
const normalized = normalizeComparablePath(targetPath);
|
|
if (!normalized) {
|
|
return {
|
|
path: null,
|
|
exists: false,
|
|
isDirectory: false,
|
|
isFile: false
|
|
};
|
|
}
|
|
try {
|
|
const stat = fs.lstatSync(normalized);
|
|
return {
|
|
path: normalized,
|
|
exists: true,
|
|
isDirectory: stat.isDirectory(),
|
|
isFile: stat.isFile()
|
|
};
|
|
} catch (_error) {
|
|
return {
|
|
path: normalized,
|
|
exists: false,
|
|
isDirectory: false,
|
|
isFile: false
|
|
};
|
|
}
|
|
}
|
|
|
|
function buildJobDisplayTitle(job = null) {
|
|
if (!job || typeof job !== 'object') {
|
|
return '-';
|
|
}
|
|
return String(job.title || job.detected_title || `Job #${job.id || '-'}`).trim() || '-';
|
|
}
|
|
|
|
function isHiddenDirectoryName(value) {
|
|
return String(value || '').trim().startsWith('.');
|
|
}
|
|
|
|
function isFilesystemRootPath(inputPath) {
|
|
const raw = String(inputPath || '').trim();
|
|
if (!raw) {
|
|
return false;
|
|
}
|
|
const resolved = normalizeComparablePath(raw);
|
|
const parsedRoot = path.parse(resolved).root;
|
|
return Boolean(parsedRoot && resolved === normalizeComparablePath(parsedRoot));
|
|
}
|
|
|
|
class HistoryService {
|
|
async createJob({
|
|
discDevice = null,
|
|
status = 'ANALYZING',
|
|
detectedTitle = null,
|
|
mediaType = null,
|
|
jobKind = null
|
|
}) {
|
|
const db = await getDb();
|
|
const startTime = new Date().toISOString();
|
|
const normalizedMediaType = (() => {
|
|
const normalized = normalizeMediaTypeValue(mediaType);
|
|
if (normalized) {
|
|
return normalized;
|
|
}
|
|
const legacy = String(mediaType || '').trim().toLowerCase();
|
|
return legacy === 'converter' ? 'converter' : null;
|
|
})();
|
|
const normalizedJobKind = normalizeJobKindValue(jobKind);
|
|
|
|
const result = await db.run(
|
|
`
|
|
INSERT INTO jobs (disc_device, status, start_time, detected_title, last_state, media_type, job_kind, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
`,
|
|
[discDevice, status, startTime, detectedTitle, status, normalizedMediaType, normalizedJobKind]
|
|
);
|
|
logger.info('job:created', {
|
|
jobId: result.lastID,
|
|
discDevice,
|
|
status,
|
|
detectedTitle,
|
|
mediaType: normalizedMediaType,
|
|
jobKind: normalizedJobKind
|
|
});
|
|
|
|
return this.getJobById(result.lastID);
|
|
}
|
|
|
|
async updateJob(jobId, patch) {
|
|
const db = await getDb();
|
|
const fields = [];
|
|
const values = [];
|
|
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
fields.push(`${key} = ?`);
|
|
values.push(value);
|
|
}
|
|
|
|
fields.push('updated_at = CURRENT_TIMESTAMP');
|
|
values.push(jobId);
|
|
|
|
await db.run(`UPDATE jobs SET ${fields.join(', ')} WHERE id = ?`, values);
|
|
logger.debug('job:updated', { jobId, patchKeys: Object.keys(patch) });
|
|
return this.getJobById(jobId, { skipRepair: true });
|
|
}
|
|
|
|
async updateJobStatus(jobId, status, extra = {}) {
|
|
return this.updateJob(jobId, {
|
|
status,
|
|
last_state: status,
|
|
...extra
|
|
});
|
|
}
|
|
|
|
async updateRawPathByOldPath(oldRawPath, newRawPath) {
|
|
const db = await getDb();
|
|
const result = await db.run(
|
|
'UPDATE jobs SET raw_path = ?, updated_at = CURRENT_TIMESTAMP WHERE raw_path = ?',
|
|
[newRawPath, oldRawPath]
|
|
);
|
|
logger.info('job:raw-path-bulk-updated', { oldRawPath, newRawPath, changes: result.changes });
|
|
return result.changes;
|
|
}
|
|
|
|
async listJobLineageArtifactsByJobIds(jobIds = []) {
|
|
const normalizedIds = Array.isArray(jobIds)
|
|
? jobIds
|
|
.map((value) => normalizeJobIdValue(value))
|
|
.filter(Boolean)
|
|
: [];
|
|
if (normalizedIds.length === 0) {
|
|
return new Map();
|
|
}
|
|
|
|
const db = await getDb();
|
|
const placeholders = normalizedIds.map(() => '?').join(', ');
|
|
const rows = await db.all(
|
|
`
|
|
SELECT id, job_id, source_job_id, media_type, raw_path, output_path, reason, note, created_at
|
|
FROM job_lineage_artifacts
|
|
WHERE job_id IN (${placeholders})
|
|
ORDER BY id ASC
|
|
`,
|
|
normalizedIds
|
|
);
|
|
|
|
const byJobId = new Map();
|
|
for (const row of rows) {
|
|
const ownerJobId = normalizeJobIdValue(row?.job_id);
|
|
if (!ownerJobId) {
|
|
continue;
|
|
}
|
|
if (!byJobId.has(ownerJobId)) {
|
|
byJobId.set(ownerJobId, []);
|
|
}
|
|
byJobId.get(ownerJobId).push({
|
|
id: normalizeJobIdValue(row?.id),
|
|
jobId: ownerJobId,
|
|
sourceJobId: normalizeJobIdValue(row?.source_job_id),
|
|
mediaType: normalizeMediaTypeValue(row?.media_type),
|
|
rawPath: String(row?.raw_path || '').trim() || null,
|
|
outputPath: String(row?.output_path || '').trim() || null,
|
|
reason: normalizeLineageReason(row?.reason),
|
|
note: String(row?.note || '').trim() || null,
|
|
createdAt: String(row?.created_at || '').trim() || null
|
|
});
|
|
}
|
|
|
|
return byJobId;
|
|
}
|
|
|
|
async transferJobLineageArtifacts(sourceJobId, replacementJobId, options = {}) {
|
|
const fromJobId = normalizeJobIdValue(sourceJobId);
|
|
const toJobId = normalizeJobIdValue(replacementJobId);
|
|
if (!fromJobId || !toJobId || fromJobId === toJobId) {
|
|
const error = new Error('Ungültige Job-IDs für Lineage-Transfer.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const reason = normalizeLineageReason(options?.reason) || 'job_replaced';
|
|
const note = String(options?.note || '').trim() || null;
|
|
const sourceJob = await this.getJobById(fromJobId);
|
|
if (!sourceJob) {
|
|
const error = new Error(`Quell-Job ${fromJobId} nicht gefunden.`);
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
const settings = await settingsService.getSettingsMap();
|
|
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, sourceJob);
|
|
const rawPath = String(resolvedPaths?.effectiveRawPath || sourceJob?.raw_path || '').trim() || null;
|
|
const outputPath = String(resolvedPaths?.effectiveOutputPath || sourceJob?.output_path || '').trim() || null;
|
|
const mediaType = normalizeMediaTypeValue(resolvedPaths?.mediaType) || 'other';
|
|
|
|
const db = await getDb();
|
|
await db.exec('BEGIN');
|
|
try {
|
|
await db.run(
|
|
`
|
|
INSERT INTO job_lineage_artifacts (
|
|
job_id, source_job_id, media_type, raw_path, output_path, reason, note, created_at
|
|
)
|
|
SELECT ?, source_job_id, media_type, raw_path, output_path, reason, note, created_at
|
|
FROM job_lineage_artifacts
|
|
WHERE job_id = ?
|
|
`,
|
|
[toJobId, fromJobId]
|
|
);
|
|
|
|
if (rawPath || outputPath) {
|
|
await db.run(
|
|
`
|
|
INSERT INTO job_lineage_artifacts (
|
|
job_id, source_job_id, media_type, raw_path, output_path, reason, note, created_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
`,
|
|
[toJobId, fromJobId, mediaType, rawPath, outputPath, reason, note]
|
|
);
|
|
}
|
|
|
|
// Preserve known output folders when replacing a job.
|
|
await db.run(
|
|
`
|
|
INSERT OR IGNORE INTO job_output_folders (job_id, output_path, label, created_at)
|
|
SELECT ?, output_path, label, created_at
|
|
FROM job_output_folders
|
|
WHERE job_id = ?
|
|
`,
|
|
[toJobId, fromJobId]
|
|
);
|
|
if (outputPath) {
|
|
await db.run(
|
|
'INSERT OR IGNORE INTO job_output_folders (job_id, output_path, label) VALUES (?, ?, ?)',
|
|
[toJobId, outputPath, 'Lineage-Output']
|
|
);
|
|
}
|
|
|
|
await db.exec('COMMIT');
|
|
} catch (error) {
|
|
await db.exec('ROLLBACK');
|
|
throw error;
|
|
}
|
|
|
|
logger.info('job:lineage:transferred', {
|
|
sourceJobId: fromJobId,
|
|
replacementJobId: toJobId,
|
|
mediaType,
|
|
reason,
|
|
hasRawPath: Boolean(rawPath),
|
|
hasOutputPath: Boolean(outputPath)
|
|
});
|
|
}
|
|
|
|
async retireJobInFavorOf(sourceJobId, replacementJobId, options = {}) {
|
|
const fromJobId = normalizeJobIdValue(sourceJobId);
|
|
const toJobId = normalizeJobIdValue(replacementJobId);
|
|
if (!fromJobId || !toJobId || fromJobId === toJobId) {
|
|
const error = new Error('Ungültige Job-IDs für Job-Ersatz.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const reason = normalizeLineageReason(options?.reason) || 'job_replaced';
|
|
const note = String(options?.note || '').trim() || null;
|
|
|
|
await this.transferJobLineageArtifacts(fromJobId, toJobId, { reason, note });
|
|
|
|
const db = await getDb();
|
|
const pipelineRow = await db.get('SELECT active_job_id FROM pipeline_state WHERE id = 1');
|
|
const activeJobId = normalizeJobIdValue(pipelineRow?.active_job_id);
|
|
const sourceIsActive = activeJobId === fromJobId;
|
|
|
|
await db.exec('BEGIN');
|
|
try {
|
|
if (sourceIsActive) {
|
|
await db.run(
|
|
`
|
|
UPDATE pipeline_state
|
|
SET active_job_id = ?, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = 1
|
|
`,
|
|
[toJobId]
|
|
);
|
|
} else {
|
|
await db.run(
|
|
`
|
|
UPDATE pipeline_state
|
|
SET active_job_id = NULL, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = 1 AND active_job_id = ?
|
|
`,
|
|
[fromJobId]
|
|
);
|
|
}
|
|
|
|
await db.run('DELETE FROM jobs WHERE id = ?', [fromJobId]);
|
|
await db.exec('COMMIT');
|
|
} catch (error) {
|
|
await db.exec('ROLLBACK');
|
|
throw error;
|
|
}
|
|
|
|
await this.closeProcessLog(fromJobId);
|
|
const archivedProcessLogPath = this._archiveProcessLogFile(fromJobId, {
|
|
replacementJobId: toJobId,
|
|
reason
|
|
});
|
|
if (archivedProcessLogPath) {
|
|
this.appendProcessLog(
|
|
toJobId,
|
|
'SYSTEM',
|
|
`Vorheriger Prozess-Log von Job #${fromJobId} archiviert: ${archivedProcessLogPath}`
|
|
);
|
|
}
|
|
|
|
logger.warn('job:retired', {
|
|
sourceJobId: fromJobId,
|
|
replacementJobId: toJobId,
|
|
reason,
|
|
sourceWasActive: sourceIsActive
|
|
});
|
|
|
|
return {
|
|
retired: true,
|
|
sourceJobId: fromJobId,
|
|
replacementJobId: toJobId,
|
|
reason,
|
|
archivedProcessLogPath: archivedProcessLogPath || null
|
|
};
|
|
}
|
|
|
|
async appendLog(jobId, source, message) {
|
|
this.appendProcessLog(jobId, source, message);
|
|
}
|
|
|
|
async cacheAndPromoteExternalPoster(jobId, posterUrl, options = {}) {
|
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
|
const normalizedPosterUrl = String(posterUrl || '').trim();
|
|
const logFailures = options?.logFailures !== false;
|
|
const sourceLabel = String(options?.source || 'Poster').trim() || 'Poster';
|
|
const logPrefix = `${sourceLabel} Link`;
|
|
|
|
if (!normalizedJobId) {
|
|
return { ok: false, reason: 'invalid_job_id', localUrl: null };
|
|
}
|
|
if (!normalizedPosterUrl || thumbnailService.isLocalUrl(normalizedPosterUrl)) {
|
|
return { ok: false, reason: 'no_external_url', localUrl: null };
|
|
}
|
|
|
|
const cacheResult = await thumbnailService.cacheJobThumbnailDetailed(normalizedJobId, normalizedPosterUrl);
|
|
if (!cacheResult?.ok) {
|
|
if (logFailures) {
|
|
await this.appendLog(
|
|
normalizedJobId,
|
|
'SYSTEM',
|
|
`${logPrefix} konnte nicht heruntergeladen werden: ${normalizedPosterUrl}${cacheResult?.error ? ` (${cacheResult.error})` : ''}`
|
|
);
|
|
}
|
|
return {
|
|
ok: false,
|
|
reason: 'cache_failed',
|
|
localUrl: null,
|
|
error: cacheResult?.error || null
|
|
};
|
|
}
|
|
|
|
const promotedUrl = thumbnailService.promoteJobThumbnail(normalizedJobId);
|
|
if (!promotedUrl) {
|
|
if (logFailures) {
|
|
await this.appendLog(
|
|
normalizedJobId,
|
|
'SYSTEM',
|
|
`${logPrefix} konnte nicht finalisiert werden: ${normalizedPosterUrl}`
|
|
);
|
|
}
|
|
return { ok: false, reason: 'promote_failed', localUrl: null };
|
|
}
|
|
|
|
try {
|
|
await this.updateJob(normalizedJobId, { poster_url: promotedUrl });
|
|
return { ok: true, reason: 'ok', localUrl: promotedUrl, sourceUrl: normalizedPosterUrl };
|
|
} catch (error) {
|
|
logger.warn('thumbnail:update-job-failed', {
|
|
jobId: normalizedJobId,
|
|
posterUrl: normalizedPosterUrl,
|
|
promotedUrl,
|
|
error: error?.message || String(error)
|
|
});
|
|
if (logFailures) {
|
|
await this.appendLog(
|
|
normalizedJobId,
|
|
'SYSTEM',
|
|
`${logPrefix} wurde heruntergeladen, konnte aber nicht am Job gespeichert werden: ${normalizedPosterUrl}`
|
|
);
|
|
}
|
|
return {
|
|
ok: false,
|
|
reason: 'update_failed',
|
|
localUrl: null,
|
|
error: error?.message || String(error)
|
|
};
|
|
}
|
|
}
|
|
|
|
queuePosterCache(jobId, posterUrl, options = {}) {
|
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
|
if (!normalizedJobId) {
|
|
return;
|
|
}
|
|
this.cacheAndPromoteExternalPoster(normalizedJobId, posterUrl, options).catch((error) => {
|
|
logger.warn('thumbnail:queue-cache-failed', {
|
|
jobId: normalizedJobId,
|
|
posterUrl: String(posterUrl || '').trim() || null,
|
|
error: error?.message || String(error)
|
|
});
|
|
});
|
|
}
|
|
|
|
appendProcessLog(jobId, source, message) {
|
|
const filePath = toProcessLogPath(jobId);
|
|
const streamKey = toProcessLogStreamKey(jobId);
|
|
if (!filePath || !streamKey) {
|
|
return;
|
|
}
|
|
try {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
let stream = processLogStreams.get(streamKey);
|
|
if (!stream) {
|
|
stream = fs.createWriteStream(filePath, {
|
|
flags: 'a',
|
|
encoding: 'utf-8'
|
|
});
|
|
stream.on('error', (error) => {
|
|
logger.warn('job:process-log:stream-error', {
|
|
jobId,
|
|
source,
|
|
error: error?.message || String(error)
|
|
});
|
|
});
|
|
processLogStreams.set(streamKey, stream);
|
|
}
|
|
const line = `[${new Date().toISOString()}] [${source}] ${String(message || '')}\n`;
|
|
stream.write(line);
|
|
} catch (error) {
|
|
logger.warn('job:process-log:append-failed', {
|
|
jobId,
|
|
source,
|
|
error: error?.message || String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
async closeProcessLog(jobId) {
|
|
const streamKey = toProcessLogStreamKey(jobId);
|
|
if (!streamKey) {
|
|
return;
|
|
}
|
|
const stream = processLogStreams.get(streamKey);
|
|
if (!stream) {
|
|
return;
|
|
}
|
|
processLogStreams.delete(streamKey);
|
|
await new Promise((resolve) => {
|
|
stream.end(resolve);
|
|
});
|
|
}
|
|
|
|
async resetProcessLog(jobId) {
|
|
await this.closeProcessLog(jobId);
|
|
const filePath = toProcessLogPath(jobId);
|
|
if (!filePath || !fs.existsSync(filePath)) {
|
|
return;
|
|
}
|
|
try {
|
|
fs.unlinkSync(filePath);
|
|
} catch (error) {
|
|
logger.warn('job:process-log:reset-failed', {
|
|
jobId,
|
|
path: filePath,
|
|
error: error?.message || String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
async readProcessLogLines(jobId, options = {}) {
|
|
const includeAll = Boolean(options.includeAll);
|
|
const parsedTail = Number(options.tailLines);
|
|
const tailLines = Number.isFinite(parsedTail) && parsedTail > 0
|
|
? Math.trunc(parsedTail)
|
|
: 800;
|
|
const filePath = toProcessLogPath(jobId);
|
|
if (!filePath || !fs.existsSync(filePath)) {
|
|
return {
|
|
exists: false,
|
|
lines: [],
|
|
returned: 0,
|
|
total: 0,
|
|
truncated: false
|
|
};
|
|
}
|
|
|
|
if (includeAll) {
|
|
const raw = await fs.promises.readFile(filePath, 'utf-8');
|
|
const lines = String(raw || '')
|
|
.split(/\r\n|\n|\r/)
|
|
.filter((line) => line.length > 0);
|
|
return {
|
|
exists: true,
|
|
lines,
|
|
returned: lines.length,
|
|
total: lines.length,
|
|
truncated: false
|
|
};
|
|
}
|
|
|
|
const stat = await fs.promises.stat(filePath);
|
|
if (!stat.isFile() || stat.size <= 0) {
|
|
return {
|
|
exists: true,
|
|
lines: [],
|
|
returned: 0,
|
|
total: 0,
|
|
truncated: false
|
|
};
|
|
}
|
|
|
|
const readBytes = Math.min(stat.size, PROCESS_LOG_TAIL_MAX_BYTES);
|
|
const start = Math.max(0, stat.size - readBytes);
|
|
const handle = await fs.promises.open(filePath, 'r');
|
|
let buffer = Buffer.alloc(0);
|
|
try {
|
|
buffer = Buffer.alloc(readBytes);
|
|
const { bytesRead } = await handle.read(buffer, 0, readBytes, start);
|
|
buffer = buffer.subarray(0, bytesRead);
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
|
|
let text = buffer.toString('utf-8');
|
|
if (start > 0) {
|
|
const parts = text.split(/\r\n|\n|\r/);
|
|
parts.shift();
|
|
text = parts.join('\n');
|
|
}
|
|
|
|
let lines = text.split(/\r\n|\n|\r/).filter((line) => line.length > 0);
|
|
let truncated = start > 0;
|
|
if (lines.length > tailLines) {
|
|
lines = lines.slice(-tailLines);
|
|
truncated = true;
|
|
}
|
|
|
|
return {
|
|
exists: true,
|
|
lines,
|
|
returned: lines.length,
|
|
total: lines.length,
|
|
truncated
|
|
};
|
|
}
|
|
|
|
buildRecoveredCdEncodePlan(recovery = {}) {
|
|
const tracks = Array.isArray(recovery?.tracks) ? recovery.tracks : [];
|
|
const selectedTracks = Array.isArray(recovery?.selectedTracks) ? recovery.selectedTracks : [];
|
|
const format = normalizeCdFormat(recovery?.format) || null;
|
|
const outputTemplate = String(recovery?.outputTemplate || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE).trim()
|
|
|| cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
|
|
|
|
if (tracks.length === 0 && selectedTracks.length === 0 && !format) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
format,
|
|
formatOptions: {},
|
|
selectedTracks: selectedTracks.length > 0
|
|
? selectedTracks
|
|
: tracks
|
|
.map((track) => Number(track?.position))
|
|
.filter((value) => Number.isFinite(value) && value > 0)
|
|
.map((value) => Math.trunc(value)),
|
|
tracks,
|
|
outputTemplate,
|
|
recoveredFrom: {
|
|
source: 'orphan_raw_import',
|
|
importedLogFiles: Array.isArray(recovery?.importedLogFiles) ? recovery.importedLogFiles : [],
|
|
outputPath: recovery?.outputPath || null,
|
|
outputPathExists: Boolean(recovery?.outputPathExists)
|
|
}
|
|
};
|
|
}
|
|
|
|
buildOrphanRawImportMakemkvInfo(options = {}) {
|
|
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
|
|
: {};
|
|
const recovery = options.recovery && typeof options.recovery === 'object'
|
|
? options.recovery
|
|
: null;
|
|
const analyzeContextPatch = options.analyzeContextPatch && typeof options.analyzeContextPatch === 'object'
|
|
? options.analyzeContextPatch
|
|
: {};
|
|
const providedSelectedMetadata = options.selectedMetadata && typeof options.selectedMetadata === 'object'
|
|
? options.selectedMetadata
|
|
: {};
|
|
const compactMetadataObject = (input) => Object.fromEntries(
|
|
Object.entries(input || {}).filter(([, value]) => {
|
|
if (value === null || value === undefined) {
|
|
return false;
|
|
}
|
|
if (typeof value === 'string') {
|
|
return value.trim().length > 0;
|
|
}
|
|
return true;
|
|
})
|
|
);
|
|
const existingSelectedMetadata = compactMetadataObject(extractSelectedMetadataFromMakemkvInfo(existingInfo));
|
|
const normalizedProvidedSelectedMetadata = compactMetadataObject(providedSelectedMetadata);
|
|
const mergedSelectedMetadata = {
|
|
...existingSelectedMetadata,
|
|
...normalizedProvidedSelectedMetadata
|
|
};
|
|
const existingAnalyzeContext = existingInfo.analyzeContext && typeof existingInfo.analyzeContext === 'object'
|
|
? existingInfo.analyzeContext
|
|
: {};
|
|
const previousImportContext = existingInfo.importContext && typeof existingInfo.importContext === 'object'
|
|
? existingInfo.importContext
|
|
: {};
|
|
const nextImportContext = {
|
|
...previousImportContext,
|
|
requestedRawPath: String(options.requestedRawPath || previousImportContext.requestedRawPath || rawPath || '').trim() || null,
|
|
sourceFolderJobId: normalizeJobIdValue(
|
|
options.sourceFolderJobId
|
|
?? previousImportContext.sourceFolderJobId
|
|
?? null
|
|
)
|
|
};
|
|
|
|
if (recovery?.outputPath) {
|
|
nextImportContext.recoveredOutputPath = recovery.outputPath;
|
|
}
|
|
if (Array.isArray(recovery?.importedLogFiles) && recovery.importedLogFiles.length > 0) {
|
|
nextImportContext.importedLogFiles = recovery.importedLogFiles;
|
|
}
|
|
|
|
const nextInfo = {
|
|
...existingInfo,
|
|
status: 'SUCCESS',
|
|
source: 'orphan_raw_import',
|
|
importedAt,
|
|
rawPath,
|
|
mediaProfile,
|
|
analyzeContext: {
|
|
...existingAnalyzeContext,
|
|
...analyzeContextPatch,
|
|
mediaProfile
|
|
},
|
|
importContext: nextImportContext
|
|
};
|
|
|
|
if (Object.keys(mergedSelectedMetadata).length > 0) {
|
|
nextInfo.selectedMetadata = mergedSelectedMetadata;
|
|
nextInfo.analyzeContext = {
|
|
...nextInfo.analyzeContext,
|
|
selectedMetadata: {
|
|
...(existingAnalyzeContext.selectedMetadata && typeof existingAnalyzeContext.selectedMetadata === 'object'
|
|
? existingAnalyzeContext.selectedMetadata
|
|
: {}),
|
|
...mergedSelectedMetadata
|
|
}
|
|
};
|
|
}
|
|
|
|
if (mediaProfile === 'cd' && recovery) {
|
|
if (Array.isArray(recovery.tracks) && recovery.tracks.length > 0) {
|
|
nextInfo.tracks = recovery.tracks;
|
|
}
|
|
if (!stripRecoveryMetadata && recovery.selectedMetadata && typeof recovery.selectedMetadata === 'object') {
|
|
const recoverySelectedMetadata = compactMetadataObject(recovery.selectedMetadata);
|
|
nextInfo.selectedMetadata = {
|
|
...(nextInfo.selectedMetadata && typeof nextInfo.selectedMetadata === 'object'
|
|
? nextInfo.selectedMetadata
|
|
: {}),
|
|
...recoverySelectedMetadata
|
|
};
|
|
nextInfo.analyzeContext = {
|
|
...nextInfo.analyzeContext,
|
|
selectedMetadata: {
|
|
...(nextInfo.analyzeContext?.selectedMetadata && typeof nextInfo.analyzeContext.selectedMetadata === 'object'
|
|
? nextInfo.analyzeContext.selectedMetadata
|
|
: {}),
|
|
...nextInfo.selectedMetadata
|
|
}
|
|
};
|
|
}
|
|
nextInfo.cdparanoiaCmd = String(
|
|
recovery.cdparanoiaCmd
|
|
|| existingInfo.cdparanoiaCmd
|
|
|| 'cdparanoia'
|
|
).trim() || 'cdparanoia';
|
|
nextInfo.importRecovery = {
|
|
source: 'orphan_raw_import',
|
|
importedAt,
|
|
logFiles: Array.isArray(recovery.importedLogFiles) ? recovery.importedLogFiles : [],
|
|
outputPath: recovery.outputPath || null,
|
|
outputPathExists: Boolean(recovery.outputPathExists),
|
|
recoveredFormat: normalizeCdFormat(recovery.format),
|
|
selectedTracks: Array.isArray(recovery.selectedTracks) ? recovery.selectedTracks : []
|
|
};
|
|
}
|
|
|
|
return nextInfo;
|
|
}
|
|
|
|
async restoreImportedProcessLog(jobId, logSources = [], options = {}) {
|
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
|
if (!normalizedJobId) {
|
|
return {
|
|
imported: false,
|
|
sourceCount: 0,
|
|
lineCount: 0
|
|
};
|
|
}
|
|
|
|
const normalizedSources = (Array.isArray(logSources) ? logSources : [])
|
|
.filter((source) => source && source.filePath && Array.isArray(source.lines) && source.lines.length > 0);
|
|
const importInfo = options.importInfo && typeof options.importInfo === 'object'
|
|
? options.importInfo
|
|
: null;
|
|
const shouldAppend = Boolean(options.append);
|
|
if (normalizedSources.length === 0 && !importInfo) {
|
|
return {
|
|
imported: false,
|
|
sourceCount: 0,
|
|
lineCount: 0
|
|
};
|
|
}
|
|
|
|
const filePath = toProcessLogPath(normalizedJobId);
|
|
if (!filePath) {
|
|
return {
|
|
imported: false,
|
|
sourceCount: 0,
|
|
lineCount: 0
|
|
};
|
|
}
|
|
|
|
await this.closeProcessLog(normalizedJobId);
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
if (!shouldAppend) {
|
|
try {
|
|
fs.unlinkSync(filePath);
|
|
} catch (_error) {
|
|
// ignore missing file on overwrite
|
|
}
|
|
}
|
|
|
|
const lines = [];
|
|
if (normalizedSources.length > 0) {
|
|
const fileSummary = normalizedSources
|
|
.map((source) => source.fileName || path.basename(source.filePath))
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
lines.push(
|
|
`[${new Date().toISOString()}] [SYSTEM] Importierte vorhandene Job-Logs: ${fileSummary}`
|
|
);
|
|
for (const source of normalizedSources) {
|
|
lines.push(...source.lines);
|
|
}
|
|
}
|
|
if (importInfo) {
|
|
lines.push(
|
|
`[${new Date().toISOString()}] [SYSTEM] Orphan-RAW-Import Zusatzinfo: ${JSON.stringify(importInfo)}`
|
|
);
|
|
}
|
|
|
|
if (lines.length === 0) {
|
|
return {
|
|
imported: false,
|
|
sourceCount: normalizedSources.length,
|
|
lineCount: 0
|
|
};
|
|
}
|
|
|
|
try {
|
|
const prefix = shouldAppend && fs.existsSync(filePath) && fs.statSync(filePath).size > 0 ? '\n' : '';
|
|
fs.writeFileSync(filePath, `${prefix}${lines.join('\n')}\n`, {
|
|
encoding: 'utf-8',
|
|
flag: shouldAppend ? 'a' : 'w'
|
|
});
|
|
} catch (error) {
|
|
logger.warn('job:process-log:restore-failed', {
|
|
jobId: normalizedJobId,
|
|
path: filePath,
|
|
error: error?.message || String(error)
|
|
});
|
|
return {
|
|
imported: false,
|
|
sourceCount: normalizedSources.length,
|
|
lineCount: 0
|
|
};
|
|
}
|
|
|
|
return {
|
|
imported: normalizedSources.length > 0,
|
|
sourceCount: normalizedSources.length,
|
|
lineCount: lines.length
|
|
};
|
|
}
|
|
|
|
async resolveOrphanImportSourceJob(options = {}) {
|
|
const candidateJobIds = Array.isArray(options.candidateJobIds)
|
|
? options.candidateJobIds
|
|
.map((value) => normalizeJobIdValue(value))
|
|
.filter(Boolean)
|
|
: [];
|
|
const desiredMediaProfile = normalizeMediaTypeValue(options.mediaProfile) || null;
|
|
if (candidateJobIds.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const uniqueCandidateIds = Array.from(new Set(candidateJobIds));
|
|
const db = await getDb();
|
|
const placeholders = uniqueCandidateIds.map(() => '?').join(', ');
|
|
const rows = await db.all(
|
|
`SELECT * FROM jobs WHERE id IN (${placeholders})`,
|
|
uniqueCandidateIds
|
|
);
|
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const byId = new Map(rows.map((row) => [normalizeJobIdValue(row?.id), row]));
|
|
let best = null;
|
|
let bestScore = -1;
|
|
|
|
for (const candidateId of uniqueCandidateIds) {
|
|
const row = byId.get(candidateId);
|
|
if (!row) {
|
|
continue;
|
|
}
|
|
|
|
const makemkvInfo = parseJsonSafe(row.makemkv_info_json, {});
|
|
const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(makemkvInfo);
|
|
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
|
|
? makemkvInfo.analyzeContext
|
|
: {};
|
|
const rowMediaProfile = normalizeMediaTypeValue(
|
|
makemkvInfo?.analyzeContext?.mediaProfile
|
|
|| makemkvInfo?.mediaProfile
|
|
|| inferMediaTypeFromJobKind(row?.job_kind)
|
|
|| row?.media_type
|
|
);
|
|
const posterCandidate = String(
|
|
row?.poster_url
|
|
|| selectedMetadata?.coverUrl
|
|
|| selectedMetadata?.poster
|
|
|| selectedMetadata?.posterUrl
|
|
|| ''
|
|
).trim() || null;
|
|
const hasTitle = Boolean(String(
|
|
selectedMetadata?.title
|
|
|| selectedMetadata?.album
|
|
|| row?.title
|
|
|| row?.detected_title
|
|
|| ''
|
|
).trim());
|
|
const hasArtist = Boolean(String(selectedMetadata?.artist || '').trim());
|
|
const hasPoster = Boolean(posterCandidate);
|
|
const hasExternalMetadata = Boolean(
|
|
row?.imdb_id
|
|
|| selectedMetadata?.tmdbId
|
|
|| analyzeContext?.tmdbId
|
|
|| selectedMetadata?.mbId
|
|
|| selectedMetadata?.musicBrainzId
|
|
|| selectedMetadata?.musicbrainzId
|
|
|| selectedMetadata?.musicbrainz_id
|
|
|| selectedMetadata?.music_brainz_id
|
|
|| selectedMetadata?.musicbrainz
|
|
|| selectedMetadata?.mbid
|
|
);
|
|
const isFinished = String(row?.status || row?.last_state || '').trim().toUpperCase() === 'FINISHED';
|
|
|
|
let score = 0;
|
|
if (desiredMediaProfile && rowMediaProfile === desiredMediaProfile) {
|
|
score += 10;
|
|
}
|
|
if (hasTitle) {
|
|
score += 6;
|
|
}
|
|
if (desiredMediaProfile === 'cd' && hasArtist) {
|
|
score += 4;
|
|
}
|
|
if (hasPoster) {
|
|
score += 3;
|
|
}
|
|
if (hasExternalMetadata) {
|
|
score += 2;
|
|
}
|
|
if (isFinished) {
|
|
score += 1;
|
|
}
|
|
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = {
|
|
job: row,
|
|
makemkvInfo,
|
|
selectedMetadata,
|
|
analyzeContext,
|
|
mediaProfile: rowMediaProfile,
|
|
posterCandidate
|
|
};
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
getPreferredPosterCandidateForImport(sourceContext = null, explicitPosterUrl = null) {
|
|
const explicit = String(explicitPosterUrl || '').trim() || null;
|
|
if (explicit) {
|
|
return explicit;
|
|
}
|
|
const sourceJob = sourceContext?.job && typeof sourceContext.job === 'object'
|
|
? sourceContext.job
|
|
: null;
|
|
const selectedMetadata = sourceContext?.selectedMetadata && typeof sourceContext.selectedMetadata === 'object'
|
|
? sourceContext.selectedMetadata
|
|
: {};
|
|
return String(
|
|
sourceJob?.poster_url
|
|
|| selectedMetadata?.coverUrl
|
|
|| selectedMetadata?.poster
|
|
|| selectedMetadata?.posterUrl
|
|
|| ''
|
|
).trim() || null;
|
|
}
|
|
|
|
async restoreImportedPoster(jobId, sourceContext = null, explicitPosterUrl = null) {
|
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
|
if (!normalizedJobId) {
|
|
return null;
|
|
}
|
|
|
|
const sourceJob = sourceContext?.job && typeof sourceContext.job === 'object'
|
|
? sourceContext.job
|
|
: null;
|
|
const sourceJobId = normalizeJobIdValue(sourceJob?.id);
|
|
const sourcePosterUrl = String(sourceJob?.poster_url || '').trim() || null;
|
|
if (sourceJobId && sourcePosterUrl && thumbnailService.isLocalUrl(sourcePosterUrl)) {
|
|
const copiedUrl = thumbnailService.copyThumbnail(sourceJobId, normalizedJobId);
|
|
if (copiedUrl) {
|
|
await this.updateJob(normalizedJobId, { poster_url: copiedUrl });
|
|
return copiedUrl;
|
|
}
|
|
}
|
|
|
|
const preferredPosterUrl = this.getPreferredPosterCandidateForImport(sourceContext, explicitPosterUrl);
|
|
if (!preferredPosterUrl || thumbnailService.isLocalUrl(preferredPosterUrl)) {
|
|
return null;
|
|
}
|
|
|
|
const result = await this.cacheAndPromoteExternalPoster(normalizedJobId, preferredPosterUrl, {
|
|
source: 'Coverart',
|
|
logFailures: true
|
|
});
|
|
if (!result?.ok || !result.localUrl) {
|
|
return null;
|
|
}
|
|
return result.localUrl;
|
|
}
|
|
|
|
async repairImportedCdJobArtifacts(job, settings = null) {
|
|
if (!job || typeof job !== 'object') {
|
|
return job;
|
|
}
|
|
|
|
const makemkvInfo = parseJsonSafe(job.makemkv_info_json, null);
|
|
const mediaProfile = normalizeMediaTypeValue(
|
|
makemkvInfo?.mediaProfile
|
|
|| makemkvInfo?.analyzeContext?.mediaProfile
|
|
|| inferMediaTypeFromJobKind(job?.job_kind)
|
|
|| job?.media_type
|
|
);
|
|
if (String(makemkvInfo?.source || '').trim().toLowerCase() !== 'orphan_raw_import' || mediaProfile !== 'cd') {
|
|
return job;
|
|
}
|
|
|
|
const hasTracks = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0;
|
|
const hasEncodePlan = Boolean(String(job?.encode_plan_json || '').trim());
|
|
const outputPathRaw = String(job?.output_path || '').trim();
|
|
const hasOutputPath = Boolean(outputPathRaw);
|
|
const outputPathExists = hasOutputPath && (() => {
|
|
try {
|
|
return fs.existsSync(outputPathRaw);
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
})();
|
|
const hasExistingLog = hasProcessLogFile(job.id);
|
|
if (hasTracks && hasEncodePlan && hasOutputPath && outputPathExists && hasExistingLog) {
|
|
return job;
|
|
}
|
|
|
|
const importContext = makemkvInfo?.importContext && typeof makemkvInfo.importContext === 'object'
|
|
? makemkvInfo.importContext
|
|
: {};
|
|
const rawPathCandidates = [
|
|
job?.raw_path,
|
|
makemkvInfo?.rawPath,
|
|
importContext?.requestedRawPath,
|
|
importContext?.originalRawPath
|
|
].filter(Boolean);
|
|
const currentRawPath = String(job?.raw_path || makemkvInfo?.rawPath || '').trim() || null;
|
|
const rawFolderMeta = parseRawFolderMetadata(path.basename(currentRawPath || rawPathCandidates[0] || ''));
|
|
const recovery = recoverCdJobArtifactsForImport({
|
|
currentRawPath,
|
|
rawPathCandidates,
|
|
relatedJobIds: [
|
|
importContext?.sourceFolderJobId,
|
|
rawFolderMeta.folderJobId
|
|
],
|
|
excludeJobIds: [],
|
|
settings: settings || {},
|
|
baseMetadata: {
|
|
title: job?.title || makemkvInfo?.selectedMetadata?.title || null,
|
|
album: makemkvInfo?.selectedMetadata?.album || job?.title || null,
|
|
artist: makemkvInfo?.selectedMetadata?.artist || null,
|
|
year: makemkvInfo?.selectedMetadata?.year || job?.year || null
|
|
},
|
|
fallbackOutputPath: job?.output_path || null
|
|
});
|
|
const sourceJobContext = await this.resolveOrphanImportSourceJob({
|
|
candidateJobIds: [
|
|
importContext?.sourceFolderJobId,
|
|
rawFolderMeta.folderJobId,
|
|
...recovery.logSources.map((source) => source?.jobId)
|
|
],
|
|
mediaProfile: 'cd'
|
|
});
|
|
const sourceJob = sourceJobContext?.job || null;
|
|
const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object'
|
|
? sourceJobContext.selectedMetadata
|
|
: {};
|
|
const sourcePosterCandidate = this.getPreferredPosterCandidateForImport(sourceJobContext, null);
|
|
|
|
const recoveryHasData = recovery.outputPath || recovery.logSources.length > 0 || recovery.tracks.length > 0;
|
|
if (!recoveryHasData && !sourceJob) {
|
|
return job;
|
|
}
|
|
|
|
const importedAt = String(makemkvInfo?.importedAt || job?.end_time || new Date().toISOString()).trim()
|
|
|| new Date().toISOString();
|
|
const nextMakemkvInfo = this.buildOrphanRawImportMakemkvInfo({
|
|
importedAt,
|
|
rawPath: currentRawPath,
|
|
requestedRawPath: importContext?.requestedRawPath || currentRawPath,
|
|
sourceFolderJobId: importContext?.sourceFolderJobId || rawFolderMeta.folderJobId || null,
|
|
mediaProfile: 'cd',
|
|
existingInfo: makemkvInfo,
|
|
recovery
|
|
});
|
|
const nextEncodePlan = this.buildRecoveredCdEncodePlan(recovery);
|
|
const patch = {};
|
|
const nextMakemkvInfoJson = JSON.stringify(nextMakemkvInfo);
|
|
|
|
if (nextMakemkvInfoJson !== String(job?.makemkv_info_json || '')) {
|
|
patch.makemkv_info_json = nextMakemkvInfoJson;
|
|
}
|
|
if (!hasEncodePlan && nextEncodePlan) {
|
|
patch.encode_plan_json = JSON.stringify(nextEncodePlan);
|
|
}
|
|
const recoveredOutputPath = String(recovery.outputPath || '').trim() || null;
|
|
const normalizedOutputPath = hasOutputPath ? normalizeComparablePath(outputPathRaw) : null;
|
|
const normalizedRecoveredOutputPath = recoveredOutputPath ? normalizeComparablePath(recoveredOutputPath) : null;
|
|
const recoveredOutputPathExists = Boolean(recovery.outputPathExists);
|
|
const shouldPatchOutputPath = Boolean(
|
|
recoveredOutputPath
|
|
&& (
|
|
!hasOutputPath
|
|
|| (
|
|
!outputPathExists
|
|
&& (
|
|
recoveredOutputPathExists
|
|
|| recovery.logSources.length > 0
|
|
)
|
|
)
|
|
)
|
|
&& normalizedRecoveredOutputPath !== normalizedOutputPath
|
|
);
|
|
if (shouldPatchOutputPath) {
|
|
patch.output_path = recoveredOutputPath;
|
|
}
|
|
if (!job?.title && nextMakemkvInfo?.selectedMetadata?.title) {
|
|
patch.title = nextMakemkvInfo.selectedMetadata.title;
|
|
} else if (!job?.title && (sourceSelectedMetadata?.title || sourceSelectedMetadata?.album || sourceJob?.title)) {
|
|
patch.title = sourceSelectedMetadata.title || sourceSelectedMetadata.album || sourceJob.title;
|
|
}
|
|
if (!job?.year && nextMakemkvInfo?.selectedMetadata?.year) {
|
|
patch.year = nextMakemkvInfo.selectedMetadata.year;
|
|
} else if (!job?.year && (sourceSelectedMetadata?.year || sourceJob?.year)) {
|
|
patch.year = sourceSelectedMetadata.year ?? sourceJob.year ?? null;
|
|
}
|
|
if (!job?.imdb_id) {
|
|
const recoveredMbId = String(
|
|
sourceSelectedMetadata?.mbId
|
|
|| sourceSelectedMetadata?.musicBrainzId
|
|
|| sourceSelectedMetadata?.musicbrainzId
|
|
|| sourceSelectedMetadata?.musicbrainz_id
|
|
|| sourceSelectedMetadata?.music_brainz_id
|
|
|| sourceSelectedMetadata?.musicbrainz
|
|
|| sourceSelectedMetadata?.mbid
|
|
|| sourceJob?.imdb_id
|
|
|| ''
|
|
).trim() || null;
|
|
if (recoveredMbId) {
|
|
patch.imdb_id = recoveredMbId;
|
|
}
|
|
}
|
|
if (!job?.poster_url && sourcePosterCandidate && !thumbnailService.isLocalUrl(sourcePosterCandidate)) {
|
|
patch.poster_url = sourcePosterCandidate;
|
|
}
|
|
|
|
if (!hasExistingLog && recovery.logSources.length > 0) {
|
|
await this.restoreImportedProcessLog(job.id, recovery.logSources, {
|
|
importInfo: nextMakemkvInfo
|
|
});
|
|
}
|
|
|
|
let updatedJob = job;
|
|
if (Object.keys(patch).length > 0) {
|
|
updatedJob = await this.updateJob(job.id, patch);
|
|
}
|
|
|
|
if (!job?.poster_url && sourcePosterCandidate) {
|
|
await this.restoreImportedPoster(job.id, sourceJobContext, sourcePosterCandidate);
|
|
updatedJob = await this.getJobById(job.id);
|
|
}
|
|
|
|
return updatedJob;
|
|
}
|
|
|
|
async repairImportedOrphanJobClassification(job, settings = null) {
|
|
if (!job || typeof job !== 'object') {
|
|
return job;
|
|
}
|
|
|
|
const makemkvInfo = parseJsonSafe(job.makemkv_info_json, null);
|
|
if (String(makemkvInfo?.source || '').trim().toLowerCase() !== 'orphan_raw_import') {
|
|
return job;
|
|
}
|
|
|
|
const settingsMap = settings && typeof settings === 'object'
|
|
? settings
|
|
: {};
|
|
const resolvedRawPath = String(
|
|
job?.raw_path
|
|
|| makemkvInfo?.rawPath
|
|
|| ''
|
|
).trim() || null;
|
|
const folderMeta = parseRawFolderMetadata(path.basename(String(resolvedRawPath || '').trim()));
|
|
const seriesRawPathHint = isLikelySeriesRawPath(resolvedRawPath, settingsMap);
|
|
const detectedMediaType = detectOrphanMediaType(resolvedRawPath, { seriesRawPathHint });
|
|
const mkMediaType = normalizeMediaTypeValue(
|
|
makemkvInfo?.analyzeContext?.mediaProfile
|
|
|| makemkvInfo?.mediaProfile
|
|
|| inferMediaTypeFromJobKind(job?.job_kind)
|
|
|| job?.media_type
|
|
);
|
|
const effectiveMediaType = detectedMediaType === 'other'
|
|
? (mkMediaType || (seriesRawPathHint ? 'dvd' : 'other'))
|
|
: detectedMediaType;
|
|
const patch = {};
|
|
|
|
if (['bluray', 'dvd', 'cd', 'audiobook'].includes(effectiveMediaType)) {
|
|
if (normalizeMediaTypeValue(job?.media_type) !== effectiveMediaType) {
|
|
patch.media_type = effectiveMediaType;
|
|
}
|
|
if (normalizeJobKindValue(job?.job_kind) !== effectiveMediaType) {
|
|
patch.job_kind = effectiveMediaType;
|
|
}
|
|
}
|
|
|
|
let nextMakemkvInfo = makemkvInfo && typeof makemkvInfo === 'object'
|
|
? makemkvInfo
|
|
: {};
|
|
const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(nextMakemkvInfo);
|
|
const analyzeContext = nextMakemkvInfo?.analyzeContext && typeof nextMakemkvInfo.analyzeContext === 'object'
|
|
? nextMakemkvInfo.analyzeContext
|
|
: {};
|
|
const hasSeriesSignals = hasSeriesMetadataSignals(selectedMetadata, analyzeContext);
|
|
|
|
if (effectiveMediaType === 'dvd' && seriesRawPathHint && !hasSeriesSignals) {
|
|
const seriesImportHints = buildSeriesImportHints({
|
|
folderName: path.basename(String(resolvedRawPath || '').trim()),
|
|
rawPath: resolvedRawPath,
|
|
metadata: folderMeta,
|
|
sourceSelectedMetadata: selectedMetadata,
|
|
sourceAnalyzeContext: analyzeContext
|
|
});
|
|
if (seriesImportHints) {
|
|
nextMakemkvInfo = this.buildOrphanRawImportMakemkvInfo({
|
|
importedAt: nextMakemkvInfo?.importedAt || job?.end_time || new Date().toISOString(),
|
|
rawPath: resolvedRawPath,
|
|
requestedRawPath: nextMakemkvInfo?.importContext?.requestedRawPath || resolvedRawPath,
|
|
sourceFolderJobId: nextMakemkvInfo?.importContext?.sourceFolderJobId || folderMeta.folderJobId || null,
|
|
mediaProfile: effectiveMediaType,
|
|
existingInfo: nextMakemkvInfo,
|
|
selectedMetadata: seriesImportHints.selectedMetadata,
|
|
analyzeContextPatch: seriesImportHints.analyzeContextPatch
|
|
});
|
|
}
|
|
} else if (
|
|
effectiveMediaType !== 'other'
|
|
&& normalizeMediaTypeValue(nextMakemkvInfo?.analyzeContext?.mediaProfile || nextMakemkvInfo?.mediaProfile) !== effectiveMediaType
|
|
) {
|
|
nextMakemkvInfo = this.buildOrphanRawImportMakemkvInfo({
|
|
importedAt: nextMakemkvInfo?.importedAt || job?.end_time || new Date().toISOString(),
|
|
rawPath: resolvedRawPath,
|
|
requestedRawPath: nextMakemkvInfo?.importContext?.requestedRawPath || resolvedRawPath,
|
|
sourceFolderJobId: nextMakemkvInfo?.importContext?.sourceFolderJobId || folderMeta.folderJobId || null,
|
|
mediaProfile: effectiveMediaType,
|
|
existingInfo: nextMakemkvInfo
|
|
});
|
|
}
|
|
|
|
const nextMakemkvInfoJson = JSON.stringify(nextMakemkvInfo || {});
|
|
if (nextMakemkvInfoJson !== String(job?.makemkv_info_json || '')) {
|
|
patch.makemkv_info_json = nextMakemkvInfoJson;
|
|
}
|
|
|
|
if (Object.keys(patch).length === 0) {
|
|
return job;
|
|
}
|
|
return this.updateJob(job.id, patch);
|
|
}
|
|
|
|
async getJobById(jobId, options = {}) {
|
|
const db = await getDb();
|
|
const row = await db.get('SELECT * FROM jobs WHERE id = ?', [jobId]);
|
|
if (!row || options?.skipRepair) {
|
|
return row;
|
|
}
|
|
const settings = options?.settings && typeof options.settings === 'object'
|
|
? options.settings
|
|
: await settingsService.getSettingsMap();
|
|
return this.repairImportedOrphanJobClassification(row, settings);
|
|
}
|
|
|
|
async getJobs(filters = {}) {
|
|
const db = await getDb();
|
|
const where = [];
|
|
const values = [];
|
|
const includeFsChecks = filters?.includeFsChecks !== false;
|
|
const includeChildren = filters?.includeChildren === true;
|
|
const rawStatuses = Array.isArray(filters?.statuses)
|
|
? filters.statuses
|
|
: (typeof filters?.statuses === 'string'
|
|
? String(filters.statuses).split(',')
|
|
: []);
|
|
const normalizedStatuses = rawStatuses
|
|
.map((value) => String(value || '').trim().toUpperCase())
|
|
.filter(Boolean);
|
|
const limitRaw = Number(filters?.limit);
|
|
const limit = Number.isFinite(limitRaw) && limitRaw > 0
|
|
? Math.min(Math.trunc(limitRaw), 500)
|
|
: 500;
|
|
|
|
if (normalizedStatuses.length > 0) {
|
|
const placeholders = normalizedStatuses.map(() => '?').join(', ');
|
|
where.push(`status IN (${placeholders})`);
|
|
values.push(...normalizedStatuses);
|
|
} else if (filters.status) {
|
|
where.push('status = ?');
|
|
values.push(filters.status);
|
|
}
|
|
|
|
if (filters.search) {
|
|
where.push('(title LIKE ? OR imdb_id LIKE ? OR detected_title LIKE ? OR makemkv_info_json LIKE ?)');
|
|
values.push(`%${filters.search}%`, `%${filters.search}%`, `%${filters.search}%`, `%${filters.search}%`);
|
|
}
|
|
|
|
if (!includeChildren) {
|
|
where.push(`parent_job_id IS NULL`);
|
|
}
|
|
|
|
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
|
|
|
const [jobs, settings] = await Promise.all([
|
|
db.all(
|
|
`
|
|
SELECT j.*
|
|
FROM jobs j
|
|
${whereClause}
|
|
ORDER BY COALESCE(j.updated_at, j.created_at) DESC, j.id DESC
|
|
LIMIT ${limit}
|
|
`,
|
|
values
|
|
),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
|
|
const repairedJobs = await Promise.all(
|
|
jobs.map((job) => this.repairImportedOrphanJobClassification(job, settings))
|
|
);
|
|
const derivedParentIds = new Set(
|
|
repairedJobs
|
|
.map((job) => normalizeJobIdValue(job?.parent_job_id))
|
|
.filter(Boolean)
|
|
);
|
|
const adjustedJobs = repairedJobs.map((job) => {
|
|
const normalizedId = normalizeJobIdValue(job?.id);
|
|
const hasParent = normalizeJobIdValue(job?.parent_job_id) !== null;
|
|
const jobKind = String(job?.job_kind || '').trim().toLowerCase();
|
|
if (hasParent && !jobKind) {
|
|
return { ...job, job_kind: 'dvd_series_child' };
|
|
}
|
|
if (!hasParent && normalizedId && derivedParentIds.has(normalizedId) && !jobKind) {
|
|
return { ...job, job_kind: 'dvd_series_container' };
|
|
}
|
|
return job;
|
|
});
|
|
|
|
const containerJobs = adjustedJobs.filter((job) => isDiskContainerRow(job));
|
|
const containerIds = containerJobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
|
|
const seriesCandidates = adjustedJobs.filter((job) => {
|
|
const mkInfo = parseJsonSafe(job?.makemkv_info_json, {});
|
|
const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object'
|
|
? mkInfo.analyzeContext
|
|
: {};
|
|
const selected = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {};
|
|
const tmdbId = Number(selected?.tmdbId || 0) || null;
|
|
const seasonNumber = Number(selected?.seasonNumber || 0) || null;
|
|
return (tmdbId && seasonNumber) || hasSeriesMetadataSignals(selected, analyzeContext);
|
|
});
|
|
const seriesCandidateIds = seriesCandidates.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
|
|
let childRows = [];
|
|
let nestedChildRows = [];
|
|
let outputFolders = [];
|
|
if (containerIds.length > 0) {
|
|
const placeholders = containerIds.map(() => '?').join(', ');
|
|
childRows = await db.all(
|
|
`SELECT id, parent_job_id, output_path, raw_path, handbrake_info_json, status, encode_plan_json, makemkv_info_json, rip_successful FROM jobs WHERE parent_job_id IN (${placeholders})`,
|
|
containerIds
|
|
);
|
|
const childIds = childRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean);
|
|
if (childIds.length > 0) {
|
|
const childPlaceholders = childIds.map(() => '?').join(', ');
|
|
nestedChildRows = await db.all(
|
|
`SELECT id, parent_job_id, output_path, raw_path, handbrake_info_json, status, encode_plan_json, makemkv_info_json, rip_successful FROM jobs WHERE parent_job_id IN (${childPlaceholders})`,
|
|
childIds
|
|
);
|
|
}
|
|
const summaryJobIds = Array.from(new Set([
|
|
...childRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean),
|
|
...nestedChildRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean)
|
|
]));
|
|
if (summaryJobIds.length > 0) {
|
|
const childPlaceholders = summaryJobIds.map(() => '?').join(', ');
|
|
outputFolders = await db.all(
|
|
`SELECT job_id, output_path FROM job_output_folders WHERE job_id IN (${childPlaceholders})`,
|
|
summaryJobIds
|
|
);
|
|
}
|
|
}
|
|
|
|
const allContainerChildRows = [...childRows, ...nestedChildRows];
|
|
const directDiskRowsByContainerId = new Map();
|
|
const directEpisodeRowsByContainerId = new Map();
|
|
const directChildRowById = new Map();
|
|
for (const row of childRows) {
|
|
const containerId = normalizeJobIdValue(row?.parent_job_id);
|
|
const isEpisodeSubJob = isSeriesBatchEpisodeSubJobRow(row);
|
|
if (containerId) {
|
|
const map = isEpisodeSubJob
|
|
? directEpisodeRowsByContainerId
|
|
: directDiskRowsByContainerId;
|
|
if (!map.has(containerId)) {
|
|
map.set(containerId, []);
|
|
}
|
|
map.get(containerId).push(row);
|
|
}
|
|
if (isEpisodeSubJob) {
|
|
continue;
|
|
}
|
|
const childId = normalizeJobIdValue(row?.id);
|
|
if (childId) {
|
|
directChildRowById.set(childId, row);
|
|
}
|
|
}
|
|
const nestedRowsByParentId = new Map();
|
|
for (const row of nestedChildRows) {
|
|
const parentId = normalizeJobIdValue(row?.parent_job_id);
|
|
if (!parentId) {
|
|
continue;
|
|
}
|
|
if (!nestedRowsByParentId.has(parentId)) {
|
|
nestedRowsByParentId.set(parentId, []);
|
|
}
|
|
nestedRowsByParentId.get(parentId).push(row);
|
|
}
|
|
|
|
const childOutputMap = new Map();
|
|
for (const child of allContainerChildRows) {
|
|
const childId = normalizeJobIdValue(child?.id);
|
|
if (!childId) {
|
|
continue;
|
|
}
|
|
if (!childOutputMap.has(childId)) {
|
|
childOutputMap.set(childId, new Set());
|
|
}
|
|
const childSet = childOutputMap.get(childId);
|
|
const directOutput = String(child?.output_path || '').trim();
|
|
if (directOutput) {
|
|
childSet.add(directOutput);
|
|
}
|
|
}
|
|
for (const folder of outputFolders) {
|
|
const childId = normalizeJobIdValue(folder?.job_id);
|
|
if (!childId) {
|
|
continue;
|
|
}
|
|
if (!childOutputMap.has(childId)) {
|
|
childOutputMap.set(childId, new Set());
|
|
}
|
|
const outputPath = String(folder?.output_path || '').trim();
|
|
if (outputPath) {
|
|
childOutputMap.get(childId).add(outputPath);
|
|
}
|
|
}
|
|
|
|
const containerOutputSummary = new Map();
|
|
const containerRawSummary = new Map();
|
|
const containerEncodeSummary = new Map();
|
|
const containerChildSummary = new Map();
|
|
for (const container of containerJobs) {
|
|
const containerId = normalizeJobIdValue(container?.id);
|
|
if (!containerId) {
|
|
continue;
|
|
}
|
|
const containerKind = String(container?.job_kind || '').trim().toLowerCase();
|
|
const isMultipartContainer = containerKind === 'multipart_movie_container';
|
|
const mkInfo = parseJsonSafe(container?.makemkv_info_json, {});
|
|
const selectedMetadata = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {};
|
|
const episodeCount = Number(selectedMetadata?.episodeCount || 0);
|
|
const episodesLength = Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes.length : 0;
|
|
const containerChildRowsRaw = directDiskRowsByContainerId.get(containerId) || [];
|
|
const containerChildRows = isMultipartContainer
|
|
? containerChildRowsRaw.filter((row) => !isMultipartMergeRow(row))
|
|
: containerChildRowsRaw;
|
|
const containerEpisodeRows = directEpisodeRowsByContainerId.get(containerId) || [];
|
|
const expectedTotal = isMultipartContainer
|
|
? containerChildRows.length
|
|
: (episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0));
|
|
const childIds = containerChildRows
|
|
.map((row) => normalizeJobIdValue(row?.id))
|
|
.filter(Boolean);
|
|
let rawExistsAny = false;
|
|
let encodeSuccessAny = false;
|
|
let rawExistsCount = 0;
|
|
let backupSuccessCount = 0;
|
|
let encodeSuccessCount = 0;
|
|
let expectedFromPlans = 0;
|
|
const seenOutputs = new Set();
|
|
let existingCount = 0;
|
|
let existingCountFromSeriesBatch = 0;
|
|
const totalChildren = childIds.length;
|
|
for (const childId of childIds) {
|
|
const childRow = directChildRowById.get(childId) || null;
|
|
const descendantRows = nestedRowsByParentId.get(childId) || [];
|
|
const relatedRows = [childRow, ...descendantRows].filter(Boolean);
|
|
if (childRow) {
|
|
const rawPath = String(childRow?.raw_path || '').trim();
|
|
if (rawPath) {
|
|
const resolvedChildPaths = resolveEffectiveStoragePathsForJob(settings, childRow);
|
|
const rawStatus = includeFsChecks
|
|
? inspectDirectory(resolvedChildPaths.effectiveRawPath)
|
|
: buildUnknownDirectoryStatus(resolvedChildPaths.effectiveRawPath);
|
|
if (rawStatus?.exists) {
|
|
rawExistsAny = true;
|
|
rawExistsCount += 1;
|
|
}
|
|
}
|
|
const mkInfo = parseJsonSafe(childRow?.makemkv_info_json, null);
|
|
const ripSuccessful = Number(childRow?.rip_successful || 0) === 1
|
|
|| String(mkInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
|
|
if (ripSuccessful) {
|
|
backupSuccessCount += 1;
|
|
}
|
|
let selectedCount = 0;
|
|
const childPlan = parseJsonSafe(childRow?.encode_plan_json, null);
|
|
if (childPlan && typeof childPlan === 'object') {
|
|
selectedCount = countSelectedEpisodeSlotsFromPlan(childPlan);
|
|
}
|
|
const handbrakeInfo = parseJsonSafe(childRow?.handbrake_info_json, null);
|
|
const hbStatus = String(handbrakeInfo?.status || '').trim().toUpperCase();
|
|
const seriesBatchSummary = handbrakeInfo?.seriesBatch && typeof handbrakeInfo.seriesBatch === 'object'
|
|
? handbrakeInfo.seriesBatch
|
|
: null;
|
|
const seriesBatchTotal = Number(seriesBatchSummary?.totalCount || 0);
|
|
const seriesBatchFinished = Number(seriesBatchSummary?.finishedCount || 0);
|
|
const seriesBatchErrors = Number(seriesBatchSummary?.errorCount || 0);
|
|
const seriesBatchCancelled = Number(seriesBatchSummary?.cancelledCount || 0);
|
|
const expectedFromNested = descendantRows.length > 0 ? descendantRows.length : 0;
|
|
const expectedFromChild = seriesBatchTotal > 0
|
|
? seriesBatchTotal
|
|
: (selectedCount > 0 ? selectedCount : expectedFromNested);
|
|
if (expectedFromChild > 0) {
|
|
expectedFromPlans += expectedFromChild;
|
|
}
|
|
if (seriesBatchFinished > 0) {
|
|
existingCountFromSeriesBatch += seriesBatchFinished;
|
|
}
|
|
|
|
let childEncodeSuccess = hbStatus === 'SUCCESS';
|
|
if (
|
|
!childEncodeSuccess
|
|
&& seriesBatchTotal > 0
|
|
&& seriesBatchFinished >= seriesBatchTotal
|
|
&& seriesBatchErrors <= 0
|
|
&& seriesBatchCancelled <= 0
|
|
) {
|
|
childEncodeSuccess = true;
|
|
}
|
|
if (!childEncodeSuccess && descendantRows.length > 0) {
|
|
let finishedDescendants = 0;
|
|
let errorDescendants = 0;
|
|
let cancelledDescendants = 0;
|
|
for (const row of descendantRows) {
|
|
const rowState = String(row?.status || row?.last_state || '').trim().toUpperCase();
|
|
if (rowState === 'FINISHED') {
|
|
finishedDescendants += 1;
|
|
} else if (rowState === 'ERROR') {
|
|
errorDescendants += 1;
|
|
} else if (rowState === 'CANCELLED') {
|
|
cancelledDescendants += 1;
|
|
}
|
|
}
|
|
childEncodeSuccess = finishedDescendants >= descendantRows.length
|
|
&& errorDescendants === 0
|
|
&& cancelledDescendants === 0;
|
|
}
|
|
if (childEncodeSuccess) {
|
|
encodeSuccessAny = true;
|
|
encodeSuccessCount += 1;
|
|
}
|
|
} else if (descendantRows.length > 0) {
|
|
expectedFromPlans += descendantRows.length;
|
|
}
|
|
for (const relatedRow of relatedRows) {
|
|
const relatedRowId = normalizeJobIdValue(relatedRow?.id);
|
|
if (!relatedRowId) {
|
|
continue;
|
|
}
|
|
const outputs = Array.from(childOutputMap.get(relatedRowId) || []);
|
|
for (const outputPath of outputs) {
|
|
if (!outputPath || seenOutputs.has(outputPath)) {
|
|
continue;
|
|
}
|
|
seenOutputs.add(outputPath);
|
|
if (!includeFsChecks || fs.existsSync(outputPath)) {
|
|
existingCount += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const episodeRow of containerEpisodeRows) {
|
|
const episodeRowId = normalizeJobIdValue(episodeRow?.id);
|
|
if (!episodeRowId) {
|
|
continue;
|
|
}
|
|
const outputs = Array.from(childOutputMap.get(episodeRowId) || []);
|
|
for (const outputPath of outputs) {
|
|
if (!outputPath || seenOutputs.has(outputPath)) {
|
|
continue;
|
|
}
|
|
seenOutputs.add(outputPath);
|
|
if (!includeFsChecks || fs.existsSync(outputPath)) {
|
|
existingCount += 1;
|
|
}
|
|
}
|
|
}
|
|
// 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) {
|
|
encodeSuccessAny = true;
|
|
if (encodeSuccessCount === 0 && totalChildren > 0) {
|
|
encodeSuccessCount = Math.min(1, totalChildren);
|
|
}
|
|
}
|
|
let mergeSummary = null;
|
|
if (isMultipartContainer) {
|
|
const mergeRows = containerChildRowsRaw.filter((row) => isMultipartMergeRow(row));
|
|
let mergeJobId = null;
|
|
let mergeActive = false;
|
|
let mergeCompleted = false;
|
|
let mergeOutputExists = false;
|
|
for (const mergeRow of mergeRows) {
|
|
const mergeRowId = normalizeJobIdValue(mergeRow?.id);
|
|
if (mergeRowId && (!mergeJobId || mergeRowId > mergeJobId)) {
|
|
mergeJobId = mergeRowId;
|
|
}
|
|
const mergeStatus = String(mergeRow?.status || mergeRow?.last_state || '').trim().toUpperCase();
|
|
if (mergeStatus === 'ENCODING') {
|
|
mergeActive = true;
|
|
}
|
|
const outputCandidates = new Set();
|
|
const directOutputPath = String(mergeRow?.output_path || '').trim();
|
|
if (directOutputPath) {
|
|
outputCandidates.add(directOutputPath);
|
|
}
|
|
if (mergeRowId) {
|
|
const linkedOutputs = Array.from(childOutputMap.get(mergeRowId) || []);
|
|
for (const outputPath of linkedOutputs) {
|
|
if (outputPath) {
|
|
outputCandidates.add(outputPath);
|
|
}
|
|
}
|
|
}
|
|
let rowOutputExists = false;
|
|
for (const outputPath of outputCandidates) {
|
|
if (!outputPath) {
|
|
continue;
|
|
}
|
|
if (!includeFsChecks || fs.existsSync(outputPath)) {
|
|
rowOutputExists = true;
|
|
break;
|
|
}
|
|
}
|
|
if (rowOutputExists) {
|
|
mergeOutputExists = true;
|
|
}
|
|
const mergeHbInfo = parseJsonSafe(mergeRow?.handbrake_info_json, null);
|
|
const mergeHbStatus = String(mergeHbInfo?.status || '').trim().toUpperCase();
|
|
if (mergeStatus === 'FINISHED' || mergeHbStatus === 'SUCCESS' || rowOutputExists) {
|
|
mergeCompleted = true;
|
|
}
|
|
}
|
|
const mergeInputExpected = totalChildren;
|
|
const mergeInputReady = mergeInputExpected > 0
|
|
? Math.min(existingCount, mergeInputExpected)
|
|
: 0;
|
|
const mergeReady = mergeInputExpected >= 2 && mergeInputReady >= mergeInputExpected;
|
|
const mergeMissingInputs = mergeInputExpected > mergeInputReady
|
|
? (mergeInputExpected - mergeInputReady)
|
|
: 0;
|
|
let mergeState = 'missing';
|
|
if (mergeActive) {
|
|
mergeState = 'active';
|
|
} else if (mergeCompleted) {
|
|
mergeState = 'done';
|
|
} else if (mergeReady) {
|
|
mergeState = mergeRows.length > 0 ? 'ready' : 'restorable';
|
|
} else if (mergeRows.length > 0) {
|
|
mergeState = 'blocked';
|
|
}
|
|
mergeSummary = {
|
|
hasJob: mergeRows.length > 0,
|
|
jobId: mergeJobId,
|
|
active: mergeActive,
|
|
completed: mergeCompleted,
|
|
ready: mergeReady,
|
|
outputExists: mergeOutputExists,
|
|
inputReady: mergeInputReady,
|
|
inputExpected: mergeInputExpected,
|
|
missingInputs: mergeMissingInputs,
|
|
state: mergeState
|
|
};
|
|
}
|
|
const expectedFinal = isMultipartContainer
|
|
? (totalChildren > 0 ? totalChildren : existingCount)
|
|
: (expectedFromPlans > 0
|
|
? expectedFromPlans
|
|
: (expectedTotal > 0 ? expectedTotal : existingCount));
|
|
containerOutputSummary.set(containerId, {
|
|
existing: existingCount,
|
|
expected: expectedFinal
|
|
});
|
|
containerRawSummary.set(containerId, { exists: rawExistsAny });
|
|
containerEncodeSummary.set(containerId, { success: encodeSuccessAny });
|
|
containerChildSummary.set(containerId, {
|
|
raw: { existing: rawExistsCount, expected: totalChildren },
|
|
backup: { existing: backupSuccessCount, expected: totalChildren },
|
|
encode: { existing: encodeSuccessCount, expected: totalChildren },
|
|
...(mergeSummary ? { merge: mergeSummary } : {})
|
|
});
|
|
}
|
|
|
|
const standaloneSeriesOutputSummary = new Map();
|
|
if (seriesCandidateIds.length > 0) {
|
|
const placeholders = seriesCandidateIds.map(() => '?').join(', ');
|
|
const seriesOutputRows = await db.all(
|
|
`SELECT job_id, output_path FROM job_output_folders WHERE job_id IN (${placeholders})`,
|
|
seriesCandidateIds
|
|
);
|
|
const outputsByJobId = new Map();
|
|
for (const row of seriesOutputRows) {
|
|
const jobId = normalizeJobIdValue(row?.job_id);
|
|
if (!jobId) continue;
|
|
if (!outputsByJobId.has(jobId)) outputsByJobId.set(jobId, new Set());
|
|
const pathValue = String(row?.output_path || '').trim();
|
|
if (pathValue) outputsByJobId.get(jobId).add(pathValue);
|
|
}
|
|
for (const job of seriesCandidates) {
|
|
const jobId = normalizeJobIdValue(job?.id);
|
|
if (!jobId || containerIds.includes(jobId)) {
|
|
continue;
|
|
}
|
|
const mkInfo = parseJsonSafe(job?.makemkv_info_json, {});
|
|
const selected = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {};
|
|
const episodeCount = Number(selected?.episodeCount || 0);
|
|
const episodesLength = Array.isArray(selected?.episodes) ? selected.episodes.length : 0;
|
|
const expectedFromMetadata = episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0);
|
|
const plan = parseJsonSafe(job?.encode_plan_json, null);
|
|
const expectedFromPlan = countSelectedEpisodeSlotsFromPlan(plan);
|
|
const expectedFinal = expectedFromPlan > 0 ? expectedFromPlan : expectedFromMetadata;
|
|
const outputSet = outputsByJobId.get(jobId) || new Set();
|
|
const directOutput = String(job?.output_path || '').trim();
|
|
if (directOutput) outputSet.add(directOutput);
|
|
let existingCount = 0;
|
|
for (const outputPath of outputSet) {
|
|
if (!outputPath) continue;
|
|
if (!includeFsChecks || fs.existsSync(outputPath)) {
|
|
existingCount += 1;
|
|
}
|
|
}
|
|
standaloneSeriesOutputSummary.set(jobId, {
|
|
existing: existingCount,
|
|
expected: expectedFinal > 0 ? expectedFinal : existingCount
|
|
});
|
|
}
|
|
}
|
|
|
|
return adjustedJobs.map((job) => {
|
|
const enriched = enrichJobRow(job, settings, { includeFsChecks });
|
|
const containerId = normalizeJobIdValue(job?.id);
|
|
const summary = containerId
|
|
? (containerOutputSummary.get(containerId) || standaloneSeriesOutputSummary.get(containerId) || null)
|
|
: (standaloneSeriesOutputSummary.get(containerId) || null);
|
|
const rawSummary = containerId ? containerRawSummary.get(containerId) : null;
|
|
const encodeSummary = containerId ? containerEncodeSummary.get(containerId) : null;
|
|
const childSummary = containerId ? containerChildSummary.get(containerId) : null;
|
|
const rawStatus = rawSummary
|
|
? {
|
|
...enriched.rawStatus,
|
|
exists: rawSummary.exists,
|
|
isDirectory: rawSummary.exists
|
|
}
|
|
: enriched.rawStatus;
|
|
const outputStatus = summary
|
|
? {
|
|
...enriched.outputStatus,
|
|
exists: summary.existing > 0,
|
|
isFile: summary.existing > 0
|
|
}
|
|
: enriched.outputStatus;
|
|
const encodeSuccess = encodeSummary ? Boolean(encodeSummary.success) : enriched.encodeSuccess;
|
|
return {
|
|
...enriched,
|
|
rawStatus,
|
|
outputStatus,
|
|
encodeSuccess,
|
|
...(childSummary ? { seriesChildSummary: childSummary } : {}),
|
|
...(summary ? { seriesOutputSummary: summary } : {}),
|
|
log_count: includeFsChecks ? (hasProcessLogFile(job.id) ? 1 : 0) : 0
|
|
};
|
|
});
|
|
}
|
|
|
|
async getTmdbMigrationPendingJobs(options = {}) {
|
|
const db = await getDb();
|
|
const limitRaw = Number(options?.limit);
|
|
const limit = Number.isFinite(limitRaw) && limitRaw > 0
|
|
? Math.min(Math.trunc(limitRaw), 2000)
|
|
: 1000;
|
|
const includeFsChecks = options?.includeFsChecks === true;
|
|
|
|
const [rows, settings] = await Promise.all([
|
|
db.all(
|
|
`
|
|
SELECT *
|
|
FROM jobs
|
|
WHERE COALESCE(migrate_tmdb, 0) = 0
|
|
AND LOWER(TRIM(COALESCE(media_type, ''))) NOT IN ('cd', 'audiobook')
|
|
AND LOWER(TRIM(COALESCE(job_kind, ''))) NOT IN ('cd', 'audiobook')
|
|
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
|
|
LIMIT ?
|
|
`,
|
|
[limit]
|
|
),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
|
|
const repairedRows = await Promise.all(
|
|
(Array.isArray(rows) ? rows : []).map((row) => this.repairImportedOrphanJobClassification(row, settings))
|
|
);
|
|
return repairedRows.map((row) => enrichJobRow(row, settings, { includeFsChecks }));
|
|
}
|
|
|
|
async setTmdbMigrationFlag(jobId, migrated = false) {
|
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
|
if (!normalizedJobId) {
|
|
const error = new Error('Ungültige Job-ID.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const existing = await this.getJobById(normalizedJobId);
|
|
if (!existing) {
|
|
const error = new Error('Job nicht gefunden.');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
await this.updateJob(normalizedJobId, {
|
|
migrate_tmdb: migrated ? 1 : 0
|
|
});
|
|
|
|
const [updated, settings] = await Promise.all([
|
|
this.getJobById(normalizedJobId),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
return enrichJobRow(updated, settings);
|
|
}
|
|
|
|
async getJobsByIds(jobIds = []) {
|
|
const ids = Array.isArray(jobIds)
|
|
? jobIds
|
|
.map((value) => Number(value))
|
|
.filter((value) => Number.isFinite(value) && value > 0)
|
|
.map((value) => Math.trunc(value))
|
|
: [];
|
|
if (ids.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const [rows, settings] = await Promise.all([
|
|
(async () => {
|
|
const db = await getDb();
|
|
const placeholders = ids.map(() => '?').join(', ');
|
|
return db.all(`SELECT * FROM jobs WHERE id IN (${placeholders})`, ids);
|
|
})(),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
const byId = new Map(rows.map((row) => [Number(row.id), row]));
|
|
const repairedRows = await Promise.all(
|
|
ids
|
|
.map((id) => byId.get(id))
|
|
.filter(Boolean)
|
|
.map((job) => this.repairImportedOrphanJobClassification(job, settings))
|
|
);
|
|
|
|
const repairedById = new Map(
|
|
repairedRows
|
|
.filter(Boolean)
|
|
.map((row) => [Number(row.id), row])
|
|
);
|
|
|
|
return ids
|
|
.map((id) => repairedById.get(id))
|
|
.filter(Boolean)
|
|
.map((job) => ({
|
|
...enrichJobRow(job, settings),
|
|
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
|
}));
|
|
}
|
|
|
|
async findSeriesContainerJob(tmdbId, seasonNumber) {
|
|
const normalizedTmdbId = Number(tmdbId || 0) || null;
|
|
const normalizedSeason = Number(seasonNumber || 0) || null;
|
|
if (!normalizedTmdbId || !normalizedSeason) {
|
|
return null;
|
|
}
|
|
const db = await getDb();
|
|
const row = await db.get(
|
|
`
|
|
SELECT *
|
|
FROM jobs
|
|
WHERE job_kind = 'dvd_series_container'
|
|
AND (
|
|
json_extract(makemkv_info_json, '$.analyzeContext.selectedMetadata.tmdbId') = ?
|
|
OR json_extract(makemkv_info_json, '$.selectedMetadata.tmdbId') = ?
|
|
)
|
|
AND (
|
|
json_extract(makemkv_info_json, '$.analyzeContext.selectedMetadata.seasonNumber') = ?
|
|
OR json_extract(makemkv_info_json, '$.selectedMetadata.seasonNumber') = ?
|
|
)
|
|
ORDER BY id DESC
|
|
LIMIT 1
|
|
`,
|
|
[normalizedTmdbId, normalizedTmdbId, normalizedSeason, normalizedSeason]
|
|
);
|
|
return row || null;
|
|
}
|
|
|
|
async findLikelySeriesContainerJob({ title = null, year = null } = {}) {
|
|
const normalizedTitle = normalizeComparableLabel(title || '');
|
|
const normalizedYear = Number.isFinite(Number(year)) && Number(year) > 0
|
|
? Math.trunc(Number(year))
|
|
: null;
|
|
if (!normalizedTitle) {
|
|
return null;
|
|
}
|
|
|
|
const db = await getDb();
|
|
const rows = await db.all(
|
|
`
|
|
SELECT *
|
|
FROM jobs
|
|
WHERE job_kind = 'dvd_series_container'
|
|
ORDER BY id DESC
|
|
`
|
|
);
|
|
const candidates = [];
|
|
for (const row of (Array.isArray(rows) ? rows : [])) {
|
|
const mkInfo = parseJsonSafe(row?.makemkv_info_json, {}) || {};
|
|
const selected = extractSelectedMetadataFromMakemkvInfo(mkInfo);
|
|
const containerTitle = String(
|
|
selected?.title
|
|
|| selected?.seriesTitle
|
|
|| row?.title
|
|
|| row?.detected_title
|
|
|| ''
|
|
).trim();
|
|
const containerNormalizedTitle = normalizeComparableLabel(containerTitle);
|
|
if (!containerNormalizedTitle) {
|
|
continue;
|
|
}
|
|
|
|
let score = 0;
|
|
if (containerNormalizedTitle === normalizedTitle) {
|
|
score += 10;
|
|
} else if (containerNormalizedTitle.includes(normalizedTitle) || normalizedTitle.includes(containerNormalizedTitle)) {
|
|
score += 6;
|
|
} else {
|
|
continue;
|
|
}
|
|
|
|
const containerYearRaw = Number(selected?.year || row?.year || 0);
|
|
const containerYear = Number.isFinite(containerYearRaw) && containerYearRaw > 0
|
|
? Math.trunc(containerYearRaw)
|
|
: null;
|
|
if (normalizedYear && containerYear === normalizedYear) {
|
|
score += 4;
|
|
} else if (normalizedYear && containerYear && Math.abs(containerYear - normalizedYear) <= 1) {
|
|
score += 2;
|
|
}
|
|
|
|
const tmdbId = normalizePositiveIntegerOrNull(selected?.tmdbId || selected?.providerId || null);
|
|
const seasonNumber = normalizePositiveIntegerOrNull(selected?.seasonNumber || null);
|
|
if (tmdbId && seasonNumber) {
|
|
score += 3;
|
|
}
|
|
|
|
candidates.push({
|
|
row,
|
|
score
|
|
});
|
|
}
|
|
|
|
if (candidates.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
candidates.sort((left, right) => {
|
|
if (right.score !== left.score) {
|
|
return right.score - left.score;
|
|
}
|
|
return Number(right.row?.id || 0) - Number(left.row?.id || 0);
|
|
});
|
|
|
|
const best = candidates[0];
|
|
const second = candidates[1] || null;
|
|
if (second && second.score === best.score) {
|
|
return null;
|
|
}
|
|
return best.row || null;
|
|
}
|
|
|
|
async listSeriesSiblingJobs(tmdbId, seasonNumber) {
|
|
const normalizedTmdbId = Number(tmdbId || 0) || null;
|
|
const normalizedSeason = Number(seasonNumber || 0) || null;
|
|
if (!normalizedTmdbId || !normalizedSeason) {
|
|
return [];
|
|
}
|
|
const db = await getDb();
|
|
const rows = await db.all(
|
|
`
|
|
SELECT *
|
|
FROM jobs
|
|
WHERE job_kind != 'dvd_series_container'
|
|
AND (
|
|
json_extract(makemkv_info_json, '$.analyzeContext.selectedMetadata.tmdbId') = ?
|
|
OR json_extract(makemkv_info_json, '$.selectedMetadata.tmdbId') = ?
|
|
)
|
|
AND (
|
|
json_extract(makemkv_info_json, '$.analyzeContext.selectedMetadata.seasonNumber') = ?
|
|
OR json_extract(makemkv_info_json, '$.selectedMetadata.seasonNumber') = ?
|
|
)
|
|
ORDER BY id ASC
|
|
`,
|
|
[normalizedTmdbId, normalizedTmdbId, normalizedSeason, normalizedSeason]
|
|
);
|
|
return Array.isArray(rows) ? rows : [];
|
|
}
|
|
|
|
async listChildJobs(parentJobId) {
|
|
const normalizedParentJobId = normalizeJobIdValue(parentJobId);
|
|
if (!normalizedParentJobId) {
|
|
return [];
|
|
}
|
|
const db = await getDb();
|
|
const rows = await db.all(
|
|
`SELECT * FROM jobs WHERE parent_job_id = ? ORDER BY id ASC`,
|
|
[normalizedParentJobId]
|
|
);
|
|
return Array.isArray(rows) ? rows : [];
|
|
}
|
|
|
|
async findLatestReplacementJobId(sourceJobId) {
|
|
const normalizedSourceJobId = normalizeJobIdValue(sourceJobId);
|
|
if (!normalizedSourceJobId) {
|
|
return null;
|
|
}
|
|
|
|
const db = await getDb();
|
|
const rows = await db.all(
|
|
`
|
|
SELECT a.job_id
|
|
FROM job_lineage_artifacts a
|
|
JOIN jobs j ON j.id = a.job_id
|
|
WHERE a.source_job_id = ?
|
|
ORDER BY a.id DESC
|
|
`,
|
|
[normalizedSourceJobId]
|
|
);
|
|
|
|
for (const row of (Array.isArray(rows) ? rows : [])) {
|
|
const candidateJobId = normalizeJobIdValue(row?.job_id);
|
|
if (!candidateJobId || candidateJobId === normalizedSourceJobId) {
|
|
continue;
|
|
}
|
|
return candidateJobId;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async getJobByIdOrReplacement(jobId) {
|
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
|
if (!normalizedJobId) {
|
|
return {
|
|
requestedJobId: null,
|
|
resolvedJobId: null,
|
|
replaced: false,
|
|
job: null
|
|
};
|
|
}
|
|
|
|
const directJob = await this.getJobById(normalizedJobId);
|
|
if (directJob) {
|
|
return {
|
|
requestedJobId: normalizedJobId,
|
|
resolvedJobId: normalizedJobId,
|
|
replaced: false,
|
|
job: directJob
|
|
};
|
|
}
|
|
|
|
const replacementJobId = await this.findLatestReplacementJobId(normalizedJobId);
|
|
if (!replacementJobId) {
|
|
return {
|
|
requestedJobId: normalizedJobId,
|
|
resolvedJobId: normalizedJobId,
|
|
replaced: false,
|
|
job: null
|
|
};
|
|
}
|
|
|
|
const replacementJob = await this.getJobById(replacementJobId);
|
|
return {
|
|
requestedJobId: normalizedJobId,
|
|
resolvedJobId: replacementJobId,
|
|
replaced: Boolean(replacementJob),
|
|
job: replacementJob || null
|
|
};
|
|
}
|
|
|
|
async getRunningJobs() {
|
|
const db = await getDb();
|
|
const [rows, settings] = await Promise.all([
|
|
db.all(
|
|
`
|
|
SELECT *
|
|
FROM jobs
|
|
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
|
|
`
|
|
),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
return rows.map((job) => ({
|
|
...enrichJobRow(job, settings),
|
|
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
|
}));
|
|
}
|
|
|
|
async getQueueIdleJobs() {
|
|
const db = await getDb();
|
|
const [rows, settings] = await Promise.all([
|
|
db.all(
|
|
`
|
|
SELECT *
|
|
FROM jobs
|
|
WHERE status IN (
|
|
'METADATA_SELECTION',
|
|
'WAITING_FOR_USER_DECISION',
|
|
'READY_TO_START',
|
|
'READY_TO_ENCODE',
|
|
'CD_METADATA_SELECTION',
|
|
'CD_READY_TO_RIP'
|
|
)
|
|
AND COALESCE(job_kind, '') != 'dvd_series_container'
|
|
ORDER BY updated_at ASC, id ASC
|
|
`
|
|
),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
return rows.map((job) => ({
|
|
...enrichJobRow(job, settings),
|
|
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
|
}));
|
|
}
|
|
|
|
async getRunningEncodeJobs() {
|
|
const db = await getDb();
|
|
const [rows, settings] = await Promise.all([
|
|
db.all(
|
|
`
|
|
SELECT *
|
|
FROM jobs
|
|
WHERE status IN ('ENCODING', 'CD_ENCODING')
|
|
ORDER BY updated_at ASC, id ASC
|
|
`
|
|
),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
return rows.map((job) => ({
|
|
...enrichJobRow(job, settings),
|
|
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
|
}));
|
|
}
|
|
|
|
async getRunningFilmEncodeJobs() {
|
|
const db = await getDb();
|
|
const rows = await db.all(
|
|
`SELECT id, status FROM jobs WHERE status = 'ENCODING' ORDER BY updated_at ASC, id ASC`
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
async getRunningCdEncodeJobs() {
|
|
const db = await getDb();
|
|
const rows = await db.all(
|
|
`SELECT id, status FROM jobs WHERE status IN ('CD_RIPPING', 'CD_ENCODING') ORDER BY updated_at ASC, id ASC`
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
async getJobWithLogs(jobId, options = {}) {
|
|
const db = await getDb();
|
|
const includeFsChecks = options?.includeFsChecks !== false;
|
|
const [loadedJob, settings] = await Promise.all([
|
|
db.get('SELECT * FROM jobs WHERE id = ?', [jobId]),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
if (!loadedJob) {
|
|
return null;
|
|
}
|
|
const classifiedJob = await this.repairImportedOrphanJobClassification(loadedJob, settings);
|
|
const job = await this.repairImportedCdJobArtifacts(classifiedJob, settings);
|
|
const childRows = await this.listChildJobs(jobId);
|
|
const childJobs = await Promise.all(
|
|
childRows.map((row) => this.repairImportedOrphanJobClassification(row, settings))
|
|
);
|
|
const isSeriesContainer = String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
|
|
const isMultipartContainer = String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container';
|
|
const isDiskContainer = isSeriesContainer || isMultipartContainer;
|
|
const detailChildJobs = isSeriesContainer
|
|
? childJobs.filter((child) => !isSeriesBatchEpisodeSubJobRow(child))
|
|
: childJobs;
|
|
const parsedTail = Number(options.logTailLines);
|
|
const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0
|
|
? Math.trunc(parsedTail)
|
|
: 800;
|
|
const includeLiveLog = Boolean(options.includeLiveLog);
|
|
const includeLogs = Boolean(options.includeLogs);
|
|
const includeAllLogs = Boolean(options.includeAllLogs);
|
|
const shouldLoadLogs = includeLiveLog || includeLogs;
|
|
const hasProcessLog = (!shouldLoadLogs && includeFsChecks) ? hasProcessLogFile(jobId) : false;
|
|
const baseLogCount = hasProcessLog ? 1 : 0;
|
|
const enrichedChildren = await Promise.all(
|
|
detailChildJobs.map(async (child) => {
|
|
const base = {
|
|
...enrichJobRow(child, settings, { includeFsChecks }),
|
|
log_count: includeFsChecks ? (hasProcessLogFile(child.id) ? 1 : 0) : 0
|
|
};
|
|
if (!shouldLoadLogs) {
|
|
return {
|
|
...base,
|
|
logs: [],
|
|
log: '',
|
|
logMeta: {
|
|
loaded: false,
|
|
total: base.log_count,
|
|
returned: 0,
|
|
truncated: false
|
|
}
|
|
};
|
|
}
|
|
const childLog = await this.readProcessLogLines(child.id, {
|
|
includeAll: includeAllLogs,
|
|
tailLines: logTailLines
|
|
});
|
|
return {
|
|
...base,
|
|
log: childLog.lines.join('\n'),
|
|
logMeta: {
|
|
loaded: true,
|
|
total: includeAllLogs ? childLog.total : childLog.returned,
|
|
returned: childLog.returned,
|
|
truncated: childLog.truncated
|
|
}
|
|
};
|
|
})
|
|
);
|
|
|
|
let seriesChildSummary = null;
|
|
if (isDiskContainer && enrichedChildren.length > 0) {
|
|
const summaryChildren = isMultipartContainer
|
|
? enrichedChildren.filter((child) => !isMultipartMergeRow(child))
|
|
: enrichedChildren;
|
|
const mergeChildren = isMultipartContainer
|
|
? enrichedChildren.filter((child) => isMultipartMergeRow(child))
|
|
: [];
|
|
const total = summaryChildren.length;
|
|
const outputReadyCount = summaryChildren.reduce((count, child) => (
|
|
count + (child?.outputStatus?.exists ? 1 : 0)
|
|
), 0);
|
|
const buildMergeSummary = () => {
|
|
if (!isMultipartContainer) {
|
|
return null;
|
|
}
|
|
let mergeJobId = null;
|
|
let mergeActive = false;
|
|
let mergeCompleted = false;
|
|
let mergeOutputExists = false;
|
|
for (const child of mergeChildren) {
|
|
const mergeRowId = normalizeJobIdValue(child?.id);
|
|
if (mergeRowId && (!mergeJobId || mergeRowId > mergeJobId)) {
|
|
mergeJobId = mergeRowId;
|
|
}
|
|
const mergeStatus = String(child?.status || child?.last_state || '').trim().toUpperCase();
|
|
if (mergeStatus === 'ENCODING') {
|
|
mergeActive = true;
|
|
}
|
|
const hasOutput = Boolean(child?.outputStatus?.exists || String(child?.output_path || '').trim());
|
|
if (hasOutput) {
|
|
mergeOutputExists = true;
|
|
}
|
|
if (
|
|
mergeStatus === 'FINISHED'
|
|
|| child?.encodeSuccess
|
|
|| hasOutput
|
|
|| String(child?.handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'
|
|
) {
|
|
mergeCompleted = true;
|
|
}
|
|
}
|
|
const mergeInputExpected = total;
|
|
const mergeInputReady = mergeInputExpected > 0
|
|
? Math.min(outputReadyCount, mergeInputExpected)
|
|
: 0;
|
|
const mergeReady = mergeInputExpected >= 2 && mergeInputReady >= mergeInputExpected;
|
|
const mergeMissingInputs = mergeInputExpected > mergeInputReady
|
|
? (mergeInputExpected - mergeInputReady)
|
|
: 0;
|
|
let mergeState = 'missing';
|
|
if (mergeActive) {
|
|
mergeState = 'active';
|
|
} else if (mergeCompleted) {
|
|
mergeState = 'done';
|
|
} else if (mergeReady) {
|
|
mergeState = mergeChildren.length > 0 ? 'ready' : 'restorable';
|
|
} else if (mergeChildren.length > 0) {
|
|
mergeState = 'blocked';
|
|
}
|
|
return {
|
|
hasJob: mergeChildren.length > 0,
|
|
jobId: mergeJobId,
|
|
active: mergeActive,
|
|
completed: mergeCompleted,
|
|
ready: mergeReady,
|
|
outputExists: mergeOutputExists,
|
|
inputReady: mergeInputReady,
|
|
inputExpected: mergeInputExpected,
|
|
missingInputs: mergeMissingInputs,
|
|
state: mergeState
|
|
};
|
|
};
|
|
if (total <= 0) {
|
|
seriesChildSummary = {
|
|
raw: { existing: 0, expected: 0 },
|
|
backup: { existing: 0, expected: 0 },
|
|
encode: { existing: 0, expected: 0 },
|
|
...(isMultipartContainer ? { merge: buildMergeSummary() } : {})
|
|
};
|
|
}
|
|
let rawCount = 0;
|
|
let backupCount = 0;
|
|
let encodeCount = 0;
|
|
for (const child of summaryChildren) {
|
|
if (child?.rawStatus?.exists) {
|
|
rawCount += 1;
|
|
}
|
|
// Backup should be considered present when rip-success markers are set
|
|
// or when RAW is physically available for that child.
|
|
const backupPresent = Boolean(
|
|
child?.backupSuccess
|
|
|| child?.rawStatus?.exists
|
|
|| String(child?.makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS'
|
|
);
|
|
if (backupPresent) {
|
|
backupCount += 1;
|
|
}
|
|
if (child?.encodeSuccess) {
|
|
encodeCount += 1;
|
|
}
|
|
}
|
|
if (total > 0) {
|
|
seriesChildSummary = {
|
|
raw: { existing: rawCount, expected: total },
|
|
backup: { existing: backupCount, expected: total },
|
|
encode: { existing: encodeCount, expected: total },
|
|
...(isMultipartContainer ? { merge: buildMergeSummary() } : {})
|
|
};
|
|
}
|
|
}
|
|
|
|
const outputFolders = await this.getJobOutputFoldersForLineage(jobId);
|
|
|
|
if (!shouldLoadLogs) {
|
|
return {
|
|
...enrichJobRow(job, settings, { includeFsChecks }),
|
|
outputFolders,
|
|
log_count: baseLogCount,
|
|
children: enrichedChildren,
|
|
...(seriesChildSummary ? { seriesChildSummary } : {}),
|
|
logs: [],
|
|
log: '',
|
|
logMeta: {
|
|
loaded: false,
|
|
total: baseLogCount,
|
|
returned: 0,
|
|
truncated: false
|
|
}
|
|
};
|
|
}
|
|
|
|
const processLog = await this.readProcessLogLines(jobId, {
|
|
includeAll: includeAllLogs,
|
|
tailLines: logTailLines
|
|
});
|
|
|
|
return {
|
|
...enrichJobRow(job, settings, { includeFsChecks }),
|
|
outputFolders,
|
|
log_count: processLog.exists ? processLog.total : 0,
|
|
children: enrichedChildren,
|
|
...(seriesChildSummary ? { seriesChildSummary } : {}),
|
|
logs: [],
|
|
log: processLog.lines.join('\n'),
|
|
logMeta: {
|
|
loaded: true,
|
|
total: includeAllLogs ? processLog.total : processLog.returned,
|
|
returned: processLog.returned,
|
|
truncated: processLog.truncated
|
|
}
|
|
};
|
|
}
|
|
|
|
async generateJobNfo(jobId, options = {}) {
|
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
|
if (!normalizedJobId) {
|
|
const error = new Error('Ungültige Job-ID.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const mode = String(options?.mode || 'manual').trim().toLowerCase() || 'manual';
|
|
const requireSettingEnabled = Boolean(options?.requireSettingEnabled);
|
|
const requireSettingDisabled = Boolean(options?.requireSettingDisabled);
|
|
const failIfExists = Boolean(options?.failIfExists);
|
|
const failIfOutputMissing = Boolean(options?.failIfOutputMissing);
|
|
|
|
const [job, settings] = await Promise.all([
|
|
this.getJobById(normalizedJobId),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
if (!job) {
|
|
const error = new Error('Job nicht gefunden.');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
const enriched = enrichJobRow(job, settings, { includeFsChecks: true });
|
|
const nfoStatus = enriched?.nfoStatus && typeof enriched.nfoStatus === 'object'
|
|
? enriched.nfoStatus
|
|
: buildNfoStatusForJob({
|
|
job: enriched,
|
|
outputPath: enriched?.output_path || null,
|
|
outputStatus: enriched?.outputStatus || null,
|
|
includeFsChecks: true,
|
|
settings,
|
|
mediaType: enriched?.mediaType || null
|
|
});
|
|
|
|
if (!nfoStatus.supported) {
|
|
if (mode === 'auto') {
|
|
return {
|
|
generated: false,
|
|
skipped: true,
|
|
reason: 'unsupported_media',
|
|
nfoStatus
|
|
};
|
|
}
|
|
const error = new Error(
|
|
'NFO-Generierung ist nur für Filme verfügbar (nicht für Serien, Audiobooks oder CDs).'
|
|
);
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
|
|
if (requireSettingEnabled && !nfoStatus.settingEnabled) {
|
|
return {
|
|
generated: false,
|
|
skipped: true,
|
|
reason: 'setting_disabled',
|
|
nfoStatus
|
|
};
|
|
}
|
|
|
|
if (requireSettingDisabled && nfoStatus.settingEnabled) {
|
|
const error = new Error('NFO-Generierung ist in den Settings aktiv. Manuelle Generierung ist nur bei deaktivierter Auto-NFO erlaubt.');
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
|
|
if (!nfoStatus.nfoPath) {
|
|
const error = new Error('Für diesen Job kann keine NFO-Datei erzeugt werden (Output ist keine Datei).');
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
|
|
if (!nfoStatus.outputExists || !nfoStatus.outputIsFile) {
|
|
if (failIfOutputMissing) {
|
|
const error = new Error('NFO kann nicht erzeugt werden: Output-Datei fehlt.');
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
return {
|
|
generated: false,
|
|
skipped: true,
|
|
reason: 'output_missing',
|
|
nfoStatus
|
|
};
|
|
}
|
|
|
|
if (nfoStatus.nfoExists) {
|
|
if (failIfExists) {
|
|
const error = new Error('NFO-Datei existiert bereits.');
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
return {
|
|
generated: false,
|
|
skipped: true,
|
|
reason: 'nfo_exists',
|
|
nfoStatus
|
|
};
|
|
}
|
|
|
|
const metadata = resolveNfoMetadataFromJob(enriched);
|
|
const xml = buildNfoXml(metadata);
|
|
fs.writeFileSync(nfoStatus.nfoPath, xml, 'utf8');
|
|
|
|
const refreshedNfoStatus = buildNfoStatusForJob({
|
|
job: enriched,
|
|
outputPath: enriched?.output_path || null,
|
|
outputStatus: enriched?.outputStatus || null,
|
|
includeFsChecks: true,
|
|
settings,
|
|
mediaType: enriched?.mediaType || null
|
|
});
|
|
|
|
await this.appendLog(
|
|
normalizedJobId,
|
|
'SYSTEM',
|
|
`NFO-Datei erstellt (${mode}): ${nfoStatus.nfoPath}`
|
|
).catch(() => {});
|
|
|
|
logger.info('job:nfo:generated', {
|
|
jobId: normalizedJobId,
|
|
mode,
|
|
nfoPath: nfoStatus.nfoPath
|
|
});
|
|
|
|
return {
|
|
generated: true,
|
|
skipped: false,
|
|
mode,
|
|
nfoPath: nfoStatus.nfoPath,
|
|
metadata,
|
|
nfoStatus: refreshedNfoStatus
|
|
};
|
|
}
|
|
|
|
async getJobArchiveDescriptor(jobId, target, options = {}) {
|
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
|
if (!normalizedJobId) {
|
|
const error = new Error('Ungültige Job-ID.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const normalizedTarget = normalizeArchiveTarget(target);
|
|
if (!normalizedTarget) {
|
|
const error = new Error('Ungültiges Download-Ziel. Erlaubt sind raw und output.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const [job, settings] = await Promise.all([
|
|
this.getJobById(normalizedJobId),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
|
|
if (!job) {
|
|
const error = new Error('Job nicht gefunden.');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
|
|
const enrichedJob = enrichJobRow(job, settings, { includeFsChecks: true });
|
|
const requestedOutputPath = normalizedTarget === 'output'
|
|
? (String(options?.outputPath || '').trim() || null)
|
|
: null;
|
|
let sourcePath = normalizedTarget === 'raw'
|
|
? resolvedPaths.effectiveRawPath
|
|
: resolvedPaths.effectiveOutputPath;
|
|
|
|
if (normalizedTarget === 'output' && requestedOutputPath) {
|
|
const trackedFolders = await this.getJobOutputFoldersForLineage(normalizedJobId);
|
|
const allowedOutputPathMap = new Map();
|
|
const registerAllowedOutputPath = (candidatePath) => {
|
|
const raw = String(candidatePath || '').trim();
|
|
if (!raw) {
|
|
return;
|
|
}
|
|
const normalized = normalizeComparablePath(raw);
|
|
if (!normalized || allowedOutputPathMap.has(normalized)) {
|
|
return;
|
|
}
|
|
allowedOutputPathMap.set(normalized, raw);
|
|
};
|
|
|
|
registerAllowedOutputPath(resolvedPaths.effectiveOutputPath);
|
|
registerAllowedOutputPath(job?.output_path);
|
|
for (const folder of trackedFolders) {
|
|
registerAllowedOutputPath(folder?.output_path);
|
|
}
|
|
|
|
const normalizedRequestedOutputPath = normalizeComparablePath(requestedOutputPath);
|
|
if (!normalizedRequestedOutputPath || !allowedOutputPathMap.has(normalizedRequestedOutputPath)) {
|
|
const error = new Error('Der angeforderte Output-Ordner gehört nicht zu diesem Job.');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
sourcePath = allowedOutputPathMap.get(normalizedRequestedOutputPath);
|
|
}
|
|
|
|
if (!sourcePath) {
|
|
const error = new Error(
|
|
normalizedTarget === 'raw'
|
|
? 'Kein RAW-Pfad für diesen Job vorhanden.'
|
|
: 'Kein Output-Pfad für diesen Job vorhanden.'
|
|
);
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
if (normalizedTarget === 'raw') {
|
|
const discMediaType = String(resolvedPaths?.mediaType || '').trim().toLowerCase();
|
|
const isDiscMedia = ['dvd', 'bluray', 'cd'].includes(discMediaType);
|
|
const rawExists = Boolean(enrichedJob?.rawStatus?.exists);
|
|
const rawNotEmpty = enrichedJob?.rawStatus?.isEmpty !== true;
|
|
if (isIncompleteRawStoragePath(sourcePath) || !rawExists || !rawNotEmpty) {
|
|
const error = new Error('RAW ist noch nicht vollständig und kann nicht heruntergeladen werden.');
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
if (isDiscMedia && !Boolean(enrichedJob?.ripSuccessful)) {
|
|
const error = new Error('RAW-Rip ist noch nicht abgeschlossen und kann nicht heruntergeladen werden.');
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (normalizedTarget === 'output') {
|
|
const statusUpper = String(enrichedJob?.status || '').trim().toUpperCase();
|
|
const outputReady = Boolean(enrichedJob?.encodeSuccess || statusUpper === 'FINISHED');
|
|
const outputExists = Boolean(enrichedJob?.outputStatus?.exists);
|
|
if (isIncompleteOutputStoragePath(sourcePath) || !outputExists || !outputReady) {
|
|
const error = new Error('Output ist noch nicht vollständig und kann nicht heruntergeladen werden.');
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
let sourceStat;
|
|
try {
|
|
sourceStat = await fs.promises.stat(sourcePath);
|
|
} catch (_error) {
|
|
const error = new Error(
|
|
normalizedTarget === 'raw'
|
|
? 'RAW-Pfad wurde nicht gefunden.'
|
|
: 'Output-Pfad wurde nicht gefunden.'
|
|
);
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
if (!sourceStat.isDirectory() && !sourceStat.isFile()) {
|
|
const error = new Error('Nur Dateien oder Verzeichnisse können als ZIP heruntergeladen werden.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
jobId: normalizedJobId,
|
|
displayTitle: buildJobDisplayTitle(job),
|
|
target: normalizedTarget,
|
|
sourcePath,
|
|
sourceType: sourceStat.isDirectory() ? 'directory' : 'file',
|
|
sourceMtimeMs: Number(sourceStat.mtimeMs || 0),
|
|
sourceModifiedAt: sourceStat.mtime ? sourceStat.mtime.toISOString() : null,
|
|
entryName: path.basename(sourcePath) || (normalizedTarget === 'raw' ? 'raw' : 'output'),
|
|
archiveName: buildJobArchiveName(job, normalizedTarget)
|
|
};
|
|
}
|
|
|
|
async getOrphanRawFolders() {
|
|
const settings = await settingsService.getSettingsMap();
|
|
const rawDirs = getOrphanRawScanPathList(settings);
|
|
if (rawDirs.length === 0) {
|
|
const error = new Error('Kein RAW-Pfad verfügbar (weder Settings noch Default-Pfade).');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const db = await getDb();
|
|
const [linkedRows, existingJobIdRows] = await Promise.all([
|
|
db.all(
|
|
`
|
|
SELECT id, raw_path, status, makemkv_info_json, mediainfo_info_json, encode_plan_json, encode_input_path
|
|
FROM jobs
|
|
WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> ''
|
|
`
|
|
),
|
|
db.all(`SELECT id FROM jobs`)
|
|
]);
|
|
const existingJobIdSet = new Set(
|
|
(Array.isArray(existingJobIdRows) ? existingJobIdRows : [])
|
|
.map((row) => normalizeJobIdValue(row?.id))
|
|
.filter(Boolean)
|
|
);
|
|
|
|
const linkedPathMap = new Map();
|
|
const addLinkedPath = (candidatePath, rowRef) => {
|
|
const normalizedPath = normalizeComparablePath(candidatePath);
|
|
if (!normalizedPath) {
|
|
return;
|
|
}
|
|
if (!linkedPathMap.has(normalizedPath)) {
|
|
linkedPathMap.set(normalizedPath, []);
|
|
}
|
|
linkedPathMap.get(normalizedPath).push(rowRef);
|
|
};
|
|
for (const row of linkedRows) {
|
|
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, row);
|
|
const linkedCandidates = [
|
|
normalizeComparablePath(row.raw_path),
|
|
normalizeComparablePath(resolvedPaths.effectiveRawPath)
|
|
].filter(Boolean);
|
|
|
|
for (const linkedPath of linkedCandidates) {
|
|
const rowRef = {
|
|
id: row.id,
|
|
status: row.status
|
|
};
|
|
addLinkedPath(linkedPath, rowRef);
|
|
|
|
// /database scannt nur Top-Level-Ordner je RAW-Root.
|
|
// Viele Jobs (insb. Disc-Backups) speichern raw_path jedoch auf einer tieferen Ebene.
|
|
// Deshalb markieren wir zusätzlich den zugehörigen Top-Level-Ordner als "linked".
|
|
for (const rawRoot of rawDirs) {
|
|
const normalizedRawRoot = normalizeComparablePath(rawRoot);
|
|
if (!normalizedRawRoot || !isPathInside(normalizedRawRoot, linkedPath)) {
|
|
continue;
|
|
}
|
|
const relative = String(path.relative(normalizedRawRoot, linkedPath) || '').trim();
|
|
if (!relative || relative === '.' || relative.startsWith('..')) {
|
|
continue;
|
|
}
|
|
const topSegment = relative.split(path.sep).find((segment) => String(segment || '').trim() && segment !== '.');
|
|
if (!topSegment) {
|
|
continue;
|
|
}
|
|
addLinkedPath(path.join(normalizedRawRoot, topSegment), rowRef);
|
|
}
|
|
}
|
|
}
|
|
|
|
const orphanRows = [];
|
|
const seenOrphanPaths = new Set();
|
|
|
|
for (const rawDir of rawDirs) {
|
|
const rawDirInfo = inspectDirectory(rawDir);
|
|
if (!rawDirInfo.exists || !rawDirInfo.isDirectory) {
|
|
continue;
|
|
}
|
|
const dirEntries = fs.readdirSync(rawDir, { withFileTypes: true });
|
|
|
|
for (const entry of dirEntries) {
|
|
if (!entry.isDirectory() || isHiddenDirectoryName(entry.name)) {
|
|
continue;
|
|
}
|
|
if (/^incomplete_/i.test(String(entry.name || '').trim())) {
|
|
// Incomplete RAW folders represent unfinished rips and are intentionally
|
|
// not re-importable from /database.
|
|
continue;
|
|
}
|
|
|
|
const rawPath = path.join(rawDir, entry.name);
|
|
const normalizedPath = normalizeComparablePath(rawPath);
|
|
if (!normalizedPath || linkedPathMap.has(normalizedPath) || seenOrphanPaths.has(normalizedPath)) {
|
|
continue;
|
|
}
|
|
|
|
const dirInfo = inspectDirectory(rawPath);
|
|
if (!dirInfo.exists || !dirInfo.isDirectory || dirInfo.isEmpty) {
|
|
continue;
|
|
}
|
|
|
|
const stat = fs.statSync(rawPath);
|
|
const metadata = parseRawFolderMetadata(entry.name);
|
|
if (metadata.folderJobId && existingJobIdSet.has(Number(metadata.folderJobId))) {
|
|
continue;
|
|
}
|
|
const detectedMediaType = detectOrphanMediaType(rawPath, {
|
|
seriesRawPathHint: isLikelySeriesRawPath(rawPath, settings)
|
|
});
|
|
orphanRows.push({
|
|
rawPath,
|
|
folderName: entry.name,
|
|
title: metadata.title,
|
|
year: metadata.year,
|
|
imdbId: metadata.imdbId,
|
|
folderJobId: metadata.folderJobId,
|
|
entryCount: Number(dirInfo.entryCount || 0),
|
|
detectedMediaType,
|
|
hasBlurayStructure: detectedMediaType === 'bluray',
|
|
hasDvdStructure: detectedMediaType === 'dvd',
|
|
hasCdStructure: detectedMediaType === 'cd',
|
|
hasAudiobookStructure: detectedMediaType === 'audiobook',
|
|
lastModifiedAt: stat.mtime.toISOString()
|
|
});
|
|
seenOrphanPaths.add(normalizedPath);
|
|
}
|
|
}
|
|
|
|
orphanRows.sort((a, b) => String(b.lastModifiedAt).localeCompare(String(a.lastModifiedAt)));
|
|
return {
|
|
rawDir: rawDirs[0] || null,
|
|
rawDirs,
|
|
rows: orphanRows
|
|
};
|
|
}
|
|
|
|
async deleteOrphanRawFolder(rawPath) {
|
|
const settings = await settingsService.getSettingsMap();
|
|
const rawDirs = getOrphanRawScanPathList(settings);
|
|
const requestedRawPath = String(rawPath || '').trim();
|
|
|
|
if (!requestedRawPath) {
|
|
const error = new Error('rawPath fehlt.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (rawDirs.length === 0) {
|
|
const error = new Error('Kein RAW-Pfad verfügbar (weder Settings noch Default-Pfade).');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const insideConfiguredRawDir = rawDirs.some((candidate) => isPathInside(candidate, requestedRawPath));
|
|
if (!insideConfiguredRawDir) {
|
|
const error = new Error(`RAW-Pfad liegt außerhalb der konfigurierten RAW-Verzeichnisse: ${requestedRawPath}`);
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const absRawPath = normalizeComparablePath(requestedRawPath);
|
|
const dirInfo = inspectDirectory(absRawPath);
|
|
if (!dirInfo.exists || !dirInfo.isDirectory) {
|
|
const error = new Error(`RAW-Pfad existiert nicht als Verzeichnis: ${absRawPath}`);
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
const orphanScan = await this.getOrphanRawFolders();
|
|
const orphanRows = Array.isArray(orphanScan?.rows) ? orphanScan.rows : [];
|
|
const orphanRow = orphanRows.find((row) => normalizeComparablePath(row?.rawPath) === absRawPath);
|
|
if (!orphanRow) {
|
|
const error = new Error('RAW-Ordner ist nicht als verwaister /database-Eintrag verfügbar (ggf. bereits verknüpft).');
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
const deleteResult = deleteFilesRecursively(absRawPath, false);
|
|
return {
|
|
deleted: true,
|
|
rawPath: absRawPath,
|
|
folderName: String(orphanRow?.folderName || path.basename(absRawPath)).trim() || path.basename(absRawPath),
|
|
filesDeleted: Number(deleteResult?.filesDeleted || 0),
|
|
dirsRemoved: Number(deleteResult?.dirsRemoved || 0)
|
|
};
|
|
} catch (deleteError) {
|
|
const error = new Error(`RAW-Ordner konnte nicht gelöscht werden: ${deleteError?.message || deleteError}`);
|
|
error.statusCode = 500;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async importOrphanRawFolder(rawPath) {
|
|
const settings = await settingsService.getSettingsMap();
|
|
const rawDirs = getOrphanRawScanPathList(settings);
|
|
const requestedRawPath = String(rawPath || '').trim();
|
|
|
|
if (!requestedRawPath) {
|
|
const error = new Error('rawPath fehlt.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (rawDirs.length === 0) {
|
|
const error = new Error('Kein RAW-Pfad verfügbar (weder Settings noch Default-Pfade).');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const insideConfiguredRawDir = rawDirs.some((candidate) => isPathInside(candidate, requestedRawPath));
|
|
if (!insideConfiguredRawDir) {
|
|
const error = new Error(`RAW-Pfad liegt außerhalb der konfigurierten RAW-Verzeichnisse: ${requestedRawPath}`);
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const absRawPath = normalizeComparablePath(requestedRawPath);
|
|
const dirInfo = inspectDirectory(absRawPath);
|
|
if (!dirInfo.exists || !dirInfo.isDirectory) {
|
|
const error = new Error(`RAW-Pfad existiert nicht als Verzeichnis: ${absRawPath}`);
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
if (dirInfo.isEmpty) {
|
|
const error = new Error(`RAW-Pfad ist leer: ${absRawPath}`);
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const db = await getDb();
|
|
const linkedRows = await db.all(
|
|
`
|
|
SELECT id, raw_path
|
|
FROM jobs
|
|
WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> ''
|
|
`
|
|
);
|
|
const existing = linkedRows.find((row) => normalizeComparablePath(row.raw_path) === absRawPath);
|
|
if (existing) {
|
|
const error = new Error(`Für RAW-Pfad existiert bereits Job #${existing.id}.`);
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
|
|
const folderName = path.basename(absRawPath);
|
|
const metadata = parseRawFolderMetadata(folderName);
|
|
const effectiveTitle = metadata.title || folderName;
|
|
const importedAt = new Date().toISOString();
|
|
const created = await this.createJob({
|
|
discDevice: null,
|
|
status: 'FINISHED',
|
|
detectedTitle: effectiveTitle
|
|
});
|
|
|
|
const renameSteps = [];
|
|
let finalRawPath = absRawPath;
|
|
const renamedRawPath = buildRawPathForJobId(absRawPath, created.id);
|
|
const shouldRenameRawFolder = normalizeComparablePath(renamedRawPath) !== absRawPath;
|
|
if (shouldRenameRawFolder) {
|
|
if (fs.existsSync(renamedRawPath)) {
|
|
await db.run('DELETE FROM jobs WHERE id = ?', [created.id]);
|
|
const error = new Error(`RAW-Ordner für neue Job-ID existiert bereits: ${renamedRawPath}`);
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
fs.renameSync(absRawPath, renamedRawPath);
|
|
finalRawPath = normalizeComparablePath(renamedRawPath);
|
|
renameSteps.push({ from: absRawPath, to: finalRawPath });
|
|
} catch (error) {
|
|
await db.run('DELETE FROM jobs WHERE id = ?', [created.id]);
|
|
const wrapped = new Error(`RAW-Ordner konnte nicht auf neue Job-ID umbenannt werden: ${error.message}`);
|
|
wrapped.statusCode = 500;
|
|
throw wrapped;
|
|
}
|
|
}
|
|
|
|
const ripCompleteFolderName = applyRawFolderPrefix(path.basename(finalRawPath), RAW_RIP_COMPLETE_PREFIX);
|
|
const ripCompleteRawPath = path.join(path.dirname(finalRawPath), ripCompleteFolderName);
|
|
const shouldMarkRipComplete = normalizeComparablePath(ripCompleteRawPath) !== normalizeComparablePath(finalRawPath);
|
|
if (shouldMarkRipComplete) {
|
|
if (fs.existsSync(ripCompleteRawPath)) {
|
|
await db.run('DELETE FROM jobs WHERE id = ?', [created.id]);
|
|
const error = new Error(`RAW-Ordner für Rip_Complete-Zustand existiert bereits: ${ripCompleteRawPath}`);
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
const previousRawPath = finalRawPath;
|
|
fs.renameSync(previousRawPath, ripCompleteRawPath);
|
|
finalRawPath = normalizeComparablePath(ripCompleteRawPath);
|
|
renameSteps.push({ from: previousRawPath, to: finalRawPath });
|
|
} catch (error) {
|
|
await db.run('DELETE FROM jobs WHERE id = ?', [created.id]);
|
|
const wrapped = new Error(`RAW-Ordner konnte nicht als Rip_Complete markiert werden: ${error.message}`);
|
|
wrapped.statusCode = 500;
|
|
throw wrapped;
|
|
}
|
|
}
|
|
|
|
const seriesRawPathHint = isLikelySeriesRawPath(finalRawPath, settings);
|
|
const detectedMediaType = detectOrphanMediaType(finalRawPath, {
|
|
seriesRawPathHint
|
|
});
|
|
const initialSourceJobContext = await this.resolveOrphanImportSourceJob({
|
|
candidateJobIds: [metadata.folderJobId],
|
|
mediaProfile: detectedMediaType
|
|
});
|
|
const fallbackMediaTypeFromSource = normalizeMediaTypeValue(
|
|
initialSourceJobContext?.mediaProfile
|
|
|| initialSourceJobContext?.makemkvInfo?.analyzeContext?.mediaProfile
|
|
|| initialSourceJobContext?.makemkvInfo?.mediaProfile
|
|
|| null
|
|
);
|
|
const effectiveDetectedMediaType = detectedMediaType === 'other'
|
|
? (fallbackMediaTypeFromSource || (seriesRawPathHint ? 'dvd' : 'other'))
|
|
: detectedMediaType;
|
|
const initialSourceJob = initialSourceJobContext?.job || null;
|
|
const initialSourceSelectedMetadata = initialSourceJobContext?.selectedMetadata && typeof initialSourceJobContext.selectedMetadata === 'object'
|
|
? initialSourceJobContext.selectedMetadata
|
|
: {};
|
|
const cdRecovery = effectiveDetectedMediaType === 'cd'
|
|
? recoverCdJobArtifactsForImport({
|
|
currentRawPath: finalRawPath,
|
|
rawPathCandidates: [
|
|
absRawPath,
|
|
renamedRawPath,
|
|
finalRawPath
|
|
],
|
|
relatedJobIds: [metadata.folderJobId],
|
|
excludeJobIds: [created.id],
|
|
settings,
|
|
baseMetadata: {
|
|
title: initialSourceSelectedMetadata?.title || initialSourceSelectedMetadata?.album || initialSourceJob?.title || metadata.title || null,
|
|
album: initialSourceSelectedMetadata?.album || initialSourceSelectedMetadata?.title || initialSourceJob?.title || metadata.title || null,
|
|
artist: initialSourceSelectedMetadata?.artist || null,
|
|
year: initialSourceSelectedMetadata?.year || initialSourceJob?.year || metadata.year || null
|
|
}
|
|
})
|
|
: null;
|
|
const sourceJobContext = await this.resolveOrphanImportSourceJob({
|
|
candidateJobIds: [
|
|
metadata.folderJobId,
|
|
...(cdRecovery?.logSources || []).map((source) => source?.jobId)
|
|
],
|
|
mediaProfile: effectiveDetectedMediaType
|
|
}) || initialSourceJobContext;
|
|
const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({
|
|
importedAt,
|
|
rawPath: finalRawPath,
|
|
requestedRawPath: absRawPath,
|
|
sourceFolderJobId: metadata.folderJobId || null,
|
|
mediaProfile: effectiveDetectedMediaType,
|
|
// RAW-Import soll wie "Disk analysieren" starten:
|
|
// keine geerbte Serien-/Metadatenzuordnung vor dem eigentlichen Analyse-Scan.
|
|
existingInfo: null,
|
|
recovery: cdRecovery,
|
|
selectedMetadata: null,
|
|
analyzeContextPatch: null,
|
|
stripRecoveryMetadata: true
|
|
});
|
|
await this.updateJob(created.id, {
|
|
...(effectiveDetectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}),
|
|
...(effectiveDetectedMediaType === 'cd' ? { job_kind: 'cd', media_type: 'cd' } : {}),
|
|
...(effectiveDetectedMediaType === 'dvd' ? { job_kind: 'dvd', media_type: 'dvd', parent_job_id: null } : {}),
|
|
...(effectiveDetectedMediaType === 'bluray' ? { job_kind: 'bluray', media_type: 'bluray' } : {}),
|
|
status: 'FINISHED',
|
|
last_state: 'FINISHED',
|
|
// /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: null,
|
|
handbrake_info_json: null,
|
|
mediainfo_info_json: null,
|
|
encode_plan_json: null,
|
|
encode_input_path: null,
|
|
encode_review_confirmed: 0,
|
|
error_message: null,
|
|
end_time: importedAt,
|
|
makemkv_info_json: JSON.stringify(orphanImportInfo)
|
|
});
|
|
|
|
if (cdRecovery?.logSources?.length > 0) {
|
|
await this.restoreImportedProcessLog(created.id, cdRecovery.logSources, {
|
|
importInfo: orphanImportInfo
|
|
});
|
|
}
|
|
|
|
if (!cdRecovery?.logSources?.length) {
|
|
await this.appendLog(
|
|
created.id,
|
|
'SYSTEM',
|
|
`Orphan-RAW-Import Zusatzinfo: ${JSON.stringify(orphanImportInfo)}`
|
|
);
|
|
}
|
|
|
|
await this.appendLog(
|
|
created.id,
|
|
'SYSTEM',
|
|
renameSteps.length > 0
|
|
? `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})`
|
|
);
|
|
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,
|
|
rawPath: absRawPath,
|
|
detectedMediaType: effectiveDetectedMediaType,
|
|
detectedMediaTypeRaw: detectedMediaType,
|
|
seriesRawPathHint
|
|
});
|
|
|
|
const imported = await this.getJobById(created.id);
|
|
return enrichJobRow(imported, settings);
|
|
}
|
|
|
|
async assignMetadata(jobId, payload = {}) {
|
|
const job = await this.getJobById(jobId);
|
|
if (!job) {
|
|
const error = new Error('Job nicht gefunden.');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
const parseTmdbId = (value) => {
|
|
const direct = normalizePositiveIntegerOrNull(value);
|
|
if (direct !== null) {
|
|
return direct;
|
|
}
|
|
const text = String(value || '').trim();
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
const providerMatch = text.match(/tmdb:(\d+)/i);
|
|
if (providerMatch?.[1]) {
|
|
return normalizePositiveIntegerOrNull(providerMatch[1]);
|
|
}
|
|
return null;
|
|
};
|
|
const requestedProviderRaw = String(payload.metadataProvider || '').trim().toLowerCase();
|
|
const requestedTmdbId = parseTmdbId(payload?.tmdbId ?? payload?.providerId ?? null);
|
|
const requestedWorkflowKind = normalizeDvdMetadataWorkflowKind(payload?.workflowKind);
|
|
const metadataProvider = requestedProviderRaw === 'themoviedb'
|
|
? 'tmdb'
|
|
: (requestedProviderRaw || (requestedTmdbId !== null ? 'tmdb' : 'tmdb'));
|
|
const makemkvInfo = parseJsonSafe(job.makemkv_info_json, {});
|
|
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
|
|
? makemkvInfo.analyzeContext
|
|
: {};
|
|
const existingSelectedMetadata = extractSelectedMetadataFromMakemkvInfo(makemkvInfo);
|
|
|
|
if (metadataProvider === 'tmdb') {
|
|
const manualTitle = String(payload.title || '').trim();
|
|
const manualYearRaw = Number(payload.year);
|
|
const manualYear = Number.isFinite(manualYearRaw) ? Math.trunc(manualYearRaw) : null;
|
|
const manualImdbId = String(payload.imdbId || '').trim().toLowerCase() || null;
|
|
const manualPoster = String(payload.poster || '').trim() || null;
|
|
const manualSeasonName = String(payload.seasonName || '').trim() || null;
|
|
const manualSeasonNumber = normalizePositiveNumberOrNull(payload.seasonNumber);
|
|
const manualDiscNumber = normalizePositiveIntegerOrNull(payload.discNumber);
|
|
const manualEpisodeCountRaw = Number(payload.episodeCount);
|
|
const manualEpisodeCount = Number.isFinite(manualEpisodeCountRaw) && manualEpisodeCountRaw > 0
|
|
? Math.trunc(manualEpisodeCountRaw)
|
|
: null;
|
|
const manualEpisodes = Array.isArray(payload.episodes) ? payload.episodes : null;
|
|
const hasExplicitManualEpisodes = Boolean(manualEpisodes && manualEpisodes.length > 0);
|
|
const manualMetadataKind = String(payload.metadataKind || '').trim().toLowerCase() || null;
|
|
const manualProviderId = String(payload.providerId || '').trim() || null;
|
|
|
|
let effectiveTmdbId = requestedTmdbId;
|
|
if (effectiveTmdbId === null) {
|
|
effectiveTmdbId = parseTmdbId(
|
|
existingSelectedMetadata?.tmdbId
|
|
?? existingSelectedMetadata?.providerId
|
|
?? analyzeContext?.tmdbId
|
|
?? analyzeContext?.providerId
|
|
?? null
|
|
);
|
|
}
|
|
|
|
const hasTmdbManualData = Boolean(
|
|
manualTitle
|
|
|| manualYear !== null
|
|
|| manualImdbId
|
|
|| manualPoster
|
|
|| manualSeasonName
|
|
|| manualSeasonNumber !== null
|
|
|| manualDiscNumber !== null
|
|
|| manualEpisodeCount !== null
|
|
|| (manualEpisodes && manualEpisodes.length > 0)
|
|
);
|
|
if (effectiveTmdbId === null && !hasTmdbManualData) {
|
|
const error = new Error('Keine TMDb-/Metadaten zum Aktualisieren angegeben.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const existingYearRaw = Number(existingSelectedMetadata?.year);
|
|
const existingYear = Number.isFinite(existingYearRaw) ? Math.trunc(existingYearRaw) : null;
|
|
const existingWorkflowKind = normalizeDvdMetadataWorkflowKind(
|
|
existingSelectedMetadata?.workflowKind
|
|
|| analyzeContext?.workflowKind
|
|
|| null
|
|
);
|
|
const resolvedWorkflowKind = requestedWorkflowKind
|
|
|| existingWorkflowKind
|
|
|| (manualSeasonNumber !== null ? 'series' : 'film');
|
|
const isFilmWorkflow = resolvedWorkflowKind === 'film';
|
|
let title = manualTitle
|
|
|| String(existingSelectedMetadata?.title || '').trim()
|
|
|| job.title
|
|
|| job.detected_title
|
|
|| null;
|
|
let year = manualYear !== null
|
|
? manualYear
|
|
: (existingYear !== null ? existingYear : (job.year ?? null));
|
|
let imdbId = manualImdbId
|
|
|| String(existingSelectedMetadata?.imdbId || job.imdb_id || '').trim().toLowerCase()
|
|
|| null;
|
|
let posterUrl = manualPoster
|
|
|| String(existingSelectedMetadata?.poster || job.poster_url || '').trim()
|
|
|| null;
|
|
let shouldQueuePosterCache = false;
|
|
if (posterUrl && !thumbnailService.isLocalUrl(posterUrl)) {
|
|
try {
|
|
const cacheResult = await this.cacheAndPromoteExternalPoster(jobId, posterUrl, {
|
|
source: 'TMDb Poster',
|
|
logFailures: false
|
|
});
|
|
if (cacheResult?.ok && cacheResult?.localUrl) {
|
|
posterUrl = cacheResult.localUrl;
|
|
} else {
|
|
shouldQueuePosterCache = true;
|
|
}
|
|
} catch (_error) {
|
|
shouldQueuePosterCache = true;
|
|
}
|
|
}
|
|
let seasonNumber = manualSeasonNumber !== null
|
|
? manualSeasonNumber
|
|
: normalizePositiveNumberOrNull(
|
|
existingSelectedMetadata?.seasonNumber
|
|
?? analyzeContext?.seriesLookupHint?.seasonNumber
|
|
?? null
|
|
);
|
|
let seasonName = manualSeasonName
|
|
|| String(existingSelectedMetadata?.seasonName || '').trim()
|
|
|| null;
|
|
let episodeCount = manualEpisodeCount !== null
|
|
? manualEpisodeCount
|
|
: (Number(existingSelectedMetadata?.episodeCount || 0) || 0);
|
|
let episodes = hasExplicitManualEpisodes
|
|
? manualEpisodes
|
|
: (Array.isArray(existingSelectedMetadata?.episodes) ? existingSelectedMetadata.episodes : []);
|
|
const discNumber = manualDiscNumber !== null
|
|
? manualDiscNumber
|
|
: normalizePositiveIntegerOrNull(
|
|
existingSelectedMetadata?.discNumber
|
|
?? analyzeContext?.seriesLookupHint?.discNumber
|
|
?? null
|
|
);
|
|
const resolvedJobMediaType = inferMediaType(
|
|
job,
|
|
makemkvInfo,
|
|
job?.mediainfo_info_json,
|
|
job?.encode_plan_json,
|
|
job?.handbrake_info_json
|
|
);
|
|
if (!isFilmWorkflow && (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;
|
|
}
|
|
let metadataKind = manualMetadataKind
|
|
|| String(
|
|
existingSelectedMetadata?.metadataKind
|
|
|| analyzeContext?.metadataKind
|
|
|| ''
|
|
).trim().toLowerCase()
|
|
|| null;
|
|
if (!metadataKind) {
|
|
metadataKind = isFilmWorkflow ? 'movie' : (seasonNumber !== null ? 'season' : 'series');
|
|
}
|
|
let providerId = manualProviderId
|
|
|| String(existingSelectedMetadata?.providerId || '').trim()
|
|
|| null;
|
|
if (!providerId && effectiveTmdbId !== null) {
|
|
providerId = isFilmWorkflow
|
|
? `tmdb:${effectiveTmdbId}`
|
|
: (seasonNumber !== null
|
|
? `tmdb:${effectiveTmdbId}:season:${seasonNumber}`
|
|
: `tmdb:${effectiveTmdbId}`);
|
|
}
|
|
let tmdbLanguage = null;
|
|
try {
|
|
const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd');
|
|
tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null;
|
|
} catch (_settingsError) {
|
|
tmdbLanguage = null;
|
|
}
|
|
|
|
let tmdbDetails = existingSelectedMetadata?.tmdbDetails && typeof existingSelectedMetadata.tmdbDetails === 'object'
|
|
? { ...existingSelectedMetadata.tmdbDetails }
|
|
: null;
|
|
if (effectiveTmdbId !== null && !isFilmWorkflow) {
|
|
try {
|
|
const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, {
|
|
language: tmdbLanguage,
|
|
appendToResponse: ['credits', 'external_ids']
|
|
});
|
|
if (seriesDetails) {
|
|
const detailsSummary = tmdbService.buildSeriesDetailsSummary(seriesDetails);
|
|
const createdBy = tmdbService.normalizeNameList(seriesDetails?.created_by, { maxItems: 3 });
|
|
const genres = tmdbService.normalizeNameList(seriesDetails?.genres, { maxItems: 3 });
|
|
tmdbDetails = {
|
|
...(detailsSummary && typeof detailsSummary === 'object' ? detailsSummary : {}),
|
|
createdBy,
|
|
genres
|
|
};
|
|
if (!title) {
|
|
title = String(seriesDetails?.name || seriesDetails?.original_name || '').trim() || null;
|
|
}
|
|
if ((!year || Number(year) <= 0) && tmdbDetails?.firstAirDate) {
|
|
const tmdbYear = Number(String(tmdbDetails.firstAirDate).slice(0, 4));
|
|
if (Number.isFinite(tmdbYear) && tmdbYear > 0) {
|
|
year = Math.trunc(tmdbYear);
|
|
}
|
|
}
|
|
if (!imdbId && tmdbDetails?.imdbId) {
|
|
imdbId = String(tmdbDetails.imdbId).trim().toLowerCase() || null;
|
|
}
|
|
if (!posterUrl) {
|
|
posterUrl = tmdbService.buildImageUrl(seriesDetails?.poster_path, 'w342') || null;
|
|
}
|
|
}
|
|
} catch (tmdbDetailsErr) {
|
|
logger.warn('assignMetadata:tmdb-series-details-fetch-failed', {
|
|
jobId,
|
|
tmdbId: effectiveTmdbId,
|
|
message: tmdbDetailsErr?.message || String(tmdbDetailsErr)
|
|
});
|
|
}
|
|
}
|
|
|
|
if (effectiveTmdbId !== null && isFilmWorkflow) {
|
|
try {
|
|
const movieDetails = await tmdbService.getMovieDetails(effectiveTmdbId, {
|
|
language: tmdbLanguage,
|
|
appendToResponse: ['credits', 'external_ids']
|
|
});
|
|
const detailsSummary = tmdbService.buildMovieDetailsSummary(movieDetails);
|
|
const genres = tmdbService.normalizeNameList(movieDetails?.genres, { maxItems: 3 });
|
|
const cast = tmdbService.normalizeNameList(movieDetails?.credits?.cast, { maxItems: 6 });
|
|
tmdbDetails = {
|
|
...(detailsSummary && typeof detailsSummary === 'object' ? detailsSummary : {}),
|
|
genres,
|
|
seasonCast: cast
|
|
};
|
|
if (!title) {
|
|
title = String(movieDetails?.title || movieDetails?.original_title || '').trim() || null;
|
|
}
|
|
if ((!year || Number(year) <= 0) && tmdbDetails?.releaseDate) {
|
|
const tmdbYear = Number(String(tmdbDetails.releaseDate).slice(0, 4));
|
|
if (Number.isFinite(tmdbYear) && tmdbYear > 0) {
|
|
year = Math.trunc(tmdbYear);
|
|
}
|
|
}
|
|
if (!imdbId && tmdbDetails?.imdbId) {
|
|
imdbId = String(tmdbDetails.imdbId).trim().toLowerCase() || null;
|
|
}
|
|
if (!posterUrl) {
|
|
posterUrl = tmdbService.buildImageUrl(movieDetails?.poster_path, 'w342') || null;
|
|
}
|
|
} catch (tmdbMovieErr) {
|
|
logger.warn('assignMetadata:tmdb-movie-details-fetch-failed', {
|
|
jobId,
|
|
tmdbId: effectiveTmdbId,
|
|
message: tmdbMovieErr?.message || String(tmdbMovieErr)
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!isFilmWorkflow && effectiveTmdbId !== null && seasonNumber !== null) {
|
|
try {
|
|
const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber, {
|
|
language: tmdbLanguage
|
|
});
|
|
const seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, seasonNumber, {
|
|
language: tmdbLanguage
|
|
});
|
|
const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails);
|
|
if (seasonSummary) {
|
|
const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : [];
|
|
if (fetchedEpisodes.length > 0 && !hasExplicitManualEpisodes) {
|
|
episodes = fetchedEpisodes;
|
|
}
|
|
const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0;
|
|
if (fetchedEpisodeCount > 0) {
|
|
episodeCount = fetchedEpisodeCount;
|
|
}
|
|
if (!seasonName && seasonSummary.name) {
|
|
seasonName = String(seasonSummary.name).trim() || null;
|
|
}
|
|
}
|
|
const seasonVoteAverageRaw = Number(seasonDetails?.vote_average || 0);
|
|
const seasonVoteAverage = Number.isFinite(seasonVoteAverageRaw) && seasonVoteAverageRaw > 0
|
|
? Number(seasonVoteAverageRaw.toFixed(1))
|
|
: null;
|
|
const seasonRuntimeValues = Array.isArray(seasonDetails?.episodes)
|
|
? seasonDetails.episodes
|
|
.map((episode) => Number(episode?.runtime || 0))
|
|
.filter((value) => Number.isFinite(value) && value > 0)
|
|
: [];
|
|
const seasonRuntime = tmdbService.formatRuntimeLabel(seasonRuntimeValues);
|
|
const seasonCast = tmdbService.normalizeNameList(seasonCredits?.cast, { maxItems: 6 });
|
|
tmdbDetails = {
|
|
...(tmdbDetails && typeof tmdbDetails === 'object' ? tmdbDetails : {}),
|
|
seasonNumber,
|
|
seasonVoteAverage,
|
|
seasonRuntime,
|
|
runtime: seasonRuntime || (tmdbDetails?.runtime || null),
|
|
seasonCast
|
|
};
|
|
} catch (tmdbSeasonErr) {
|
|
logger.warn('assignMetadata:tmdb-season-fetch-failed', {
|
|
jobId,
|
|
tmdbId: effectiveTmdbId,
|
|
seasonNumber,
|
|
message: tmdbSeasonErr?.message || String(tmdbSeasonErr)
|
|
});
|
|
}
|
|
}
|
|
|
|
const nextSelectedMetadata = {
|
|
...(existingSelectedMetadata && typeof existingSelectedMetadata === 'object' ? existingSelectedMetadata : {}),
|
|
title,
|
|
year,
|
|
imdbId,
|
|
poster: posterUrl,
|
|
workflowKind: isFilmWorkflow ? 'film' : 'series',
|
|
metadataProvider: 'tmdb',
|
|
providerId,
|
|
tmdbId: effectiveTmdbId,
|
|
metadataKind,
|
|
seasonNumber: isFilmWorkflow ? null : seasonNumber,
|
|
seasonName: isFilmWorkflow ? null : seasonName,
|
|
episodeCount: isFilmWorkflow ? 0 : episodeCount,
|
|
episodes: isFilmWorkflow ? [] : (Array.isArray(episodes) ? episodes : []),
|
|
...(isFilmWorkflow ? {} : (discNumber !== null ? { discNumber } : {})),
|
|
...(tmdbDetails && typeof tmdbDetails === 'object' ? { tmdbDetails } : {})
|
|
};
|
|
const existingAnalyzeSelected = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
|
? analyzeContext.selectedMetadata
|
|
: {};
|
|
const existingSeriesLookupHint = analyzeContext?.seriesLookupHint && typeof analyzeContext.seriesLookupHint === 'object'
|
|
? analyzeContext.seriesLookupHint
|
|
: {};
|
|
const nextAnalyzeContext = {
|
|
...analyzeContext,
|
|
workflowKind: isFilmWorkflow ? 'film' : 'series',
|
|
metadataProvider: 'tmdb',
|
|
metadataKind,
|
|
selectedMetadata: {
|
|
...existingAnalyzeSelected,
|
|
...nextSelectedMetadata
|
|
},
|
|
seriesLookupHint: isFilmWorkflow
|
|
? {
|
|
...existingSeriesLookupHint,
|
|
seasonNumber: null,
|
|
discNumber: null
|
|
}
|
|
: {
|
|
...existingSeriesLookupHint,
|
|
query: String(existingSeriesLookupHint.query || title || '').trim() || null,
|
|
seasonNumber: seasonNumber !== null
|
|
? seasonNumber
|
|
: normalizePositiveNumberOrNull(existingSeriesLookupHint.seasonNumber ?? null),
|
|
discNumber: discNumber !== null
|
|
? discNumber
|
|
: normalizePositiveIntegerOrNull(existingSeriesLookupHint.discNumber ?? null)
|
|
}
|
|
};
|
|
const topLevelSelected = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
|
? makemkvInfo.selectedMetadata
|
|
: {};
|
|
const nextMakemkvInfo = {
|
|
...(makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}),
|
|
selectedMetadata: {
|
|
...topLevelSelected,
|
|
...nextSelectedMetadata
|
|
},
|
|
analyzeContext: nextAnalyzeContext
|
|
};
|
|
|
|
await this.updateJob(jobId, {
|
|
title,
|
|
year,
|
|
imdb_id: imdbId,
|
|
poster_url: posterUrl,
|
|
omdb_json: null,
|
|
selected_from_omdb: 0,
|
|
migrate_tmdb: 1,
|
|
makemkv_info_json: JSON.stringify(nextMakemkvInfo)
|
|
});
|
|
|
|
if (shouldQueuePosterCache && posterUrl && !thumbnailService.isLocalUrl(posterUrl)) {
|
|
this.queuePosterCache(jobId, posterUrl, {
|
|
source: 'TMDb Poster',
|
|
logFailures: true
|
|
});
|
|
}
|
|
|
|
await this.appendLog(
|
|
jobId,
|
|
'USER_ACTION',
|
|
effectiveTmdbId !== null
|
|
? `TMDb-Zuordnung aktualisiert: ${effectiveTmdbId}${!isFilmWorkflow && seasonNumber !== null ? ` (Staffel ${seasonNumber})` : ''} (${title || '-'})`
|
|
: `TMDb-Metadaten manuell aktualisiert: title="${title || '-'}", year="${year || '-'}", imdb="${imdbId || '-'}"`
|
|
);
|
|
|
|
const [updated, settings] = await Promise.all([
|
|
this.getJobById(jobId),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
return enrichJobRow(updated, settings);
|
|
}
|
|
|
|
const unsupportedError = new Error('Nur TMDb-Metadaten werden unterstützt.');
|
|
unsupportedError.statusCode = 400;
|
|
throw unsupportedError;
|
|
}
|
|
|
|
async assignCdMetadata(jobId, payload = {}) {
|
|
const job = await this.getJobById(jobId);
|
|
if (!job) {
|
|
const error = new Error('Job nicht gefunden.');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
const title = String(payload.title || '').trim() || null;
|
|
const artist = String(payload.artist || '').trim() || null;
|
|
const yearRaw = Number(payload.year);
|
|
const year = Number.isFinite(yearRaw) && yearRaw > 0 ? Math.trunc(yearRaw) : null;
|
|
const mbId = String(payload.mbId || '').trim() || null;
|
|
let coverUrl = String(payload.coverUrl || '').trim() || null;
|
|
const selectedTracks = Array.isArray(payload.tracks) ? payload.tracks : null;
|
|
|
|
if (!title && !artist && !mbId) {
|
|
const error = new Error('Keine CD-Metadaten zum Aktualisieren angegeben.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const cdInfo = parseJsonSafe(job.makemkv_info_json, {});
|
|
const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : [];
|
|
|
|
let mergedTracks = tocTracks;
|
|
if (selectedTracks && tocTracks.length > 0) {
|
|
mergedTracks = tocTracks.map((t) => {
|
|
const selected = selectedTracks.find((st) => Number(st.position) === Number(t.position));
|
|
const resolvedTitle = String(selected?.title || t.title || `Track ${t.position}`).replace(/\s+/g, ' ').trim();
|
|
const resolvedArtist = String(selected?.artist || t.artist || artist || '').replace(/\s+/g, ' ').trim() || null;
|
|
return {
|
|
...t,
|
|
title: resolvedTitle,
|
|
artist: resolvedArtist,
|
|
selected: selected ? Boolean(selected.selected) : true
|
|
};
|
|
});
|
|
}
|
|
|
|
let shouldQueueCoverCache = false;
|
|
if (coverUrl && !thumbnailService.isLocalUrl(coverUrl)) {
|
|
try {
|
|
const cacheResult = await this.cacheAndPromoteExternalPoster(jobId, coverUrl, {
|
|
source: 'Coverart',
|
|
logFailures: false
|
|
});
|
|
if (cacheResult?.ok && cacheResult?.localUrl) {
|
|
coverUrl = cacheResult.localUrl;
|
|
} else {
|
|
shouldQueueCoverCache = true;
|
|
}
|
|
} catch (_error) {
|
|
shouldQueueCoverCache = true;
|
|
}
|
|
}
|
|
|
|
const prevSelected = cdInfo.selectedMetadata && typeof cdInfo.selectedMetadata === 'object' ? cdInfo.selectedMetadata : {};
|
|
const updatedCdInfo = {
|
|
...cdInfo,
|
|
tracks: mergedTracks,
|
|
selectedMetadata: {
|
|
...prevSelected,
|
|
title: title || prevSelected.title || null,
|
|
artist: artist || prevSelected.artist || null,
|
|
year: year !== null ? year : (prevSelected.year || null),
|
|
mbId: mbId || prevSelected.mbId || null,
|
|
coverUrl: coverUrl || prevSelected.coverUrl || null
|
|
}
|
|
};
|
|
|
|
await this.updateJob(jobId, {
|
|
title: title || null,
|
|
year: year || null,
|
|
imdb_id: mbId || null,
|
|
poster_url: coverUrl || null,
|
|
makemkv_info_json: JSON.stringify(updatedCdInfo)
|
|
});
|
|
|
|
if (shouldQueueCoverCache && coverUrl && !thumbnailService.isLocalUrl(coverUrl)) {
|
|
this.queuePosterCache(jobId, coverUrl, {
|
|
source: 'Coverart',
|
|
logFailures: true
|
|
});
|
|
}
|
|
|
|
await this.appendLog(
|
|
jobId,
|
|
'USER_ACTION',
|
|
`CD-Metadaten aktualisiert: album="${title || '-'}", artist="${artist || '-'}", year="${year || '-'}", mbId="${mbId || '-'}"`
|
|
);
|
|
|
|
const [updated, settings] = await Promise.all([
|
|
this.getJobById(jobId),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
return enrichJobRow(updated, settings);
|
|
}
|
|
|
|
async acknowledgeJobError(jobId) {
|
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
|
if (!normalizedJobId) {
|
|
const error = new Error('Ungültige Job-ID.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const job = await this.getJobById(normalizedJobId);
|
|
if (!job) {
|
|
const error = new Error('Job nicht gefunden.');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
const hasErrorMessage = Boolean(String(job?.error_message || '').trim());
|
|
const statusUpper = String(job?.status || '').trim().toUpperCase();
|
|
const setCancelled = statusUpper === 'ERROR';
|
|
|
|
if (!hasErrorMessage && !setCancelled) {
|
|
const [updatedUnchanged, unchangedSettings] = await Promise.all([
|
|
this.getJobById(normalizedJobId),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
return enrichJobRow(updatedUnchanged, unchangedSettings);
|
|
}
|
|
|
|
await this.updateJob(normalizedJobId, {
|
|
error_message: null,
|
|
...(setCancelled ? { status: 'CANCELLED' } : {}),
|
|
...(!job?.end_time ? { end_time: new Date().toISOString() } : {})
|
|
});
|
|
|
|
await this.appendLog(
|
|
normalizedJobId,
|
|
'USER_ACTION',
|
|
'Fehlermeldung quittiert.'
|
|
);
|
|
|
|
const [updated, settings] = await Promise.all([
|
|
this.getJobById(normalizedJobId),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
return enrichJobRow(updated, settings);
|
|
}
|
|
|
|
async _resolveRelatedJobsForDeletion(jobId, options = {}) {
|
|
const includeRelated = options?.includeRelated !== false;
|
|
const includeLogLinks = options?.includeLogLinks !== false;
|
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
|
if (!normalizedJobId) {
|
|
const error = new Error('Ungültige Job-ID.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const db = await getDb();
|
|
const rows = await db.all('SELECT * FROM jobs ORDER BY id ASC');
|
|
const byId = new Map(rows.map((row) => [Number(row.id), row]));
|
|
const primary = byId.get(normalizedJobId);
|
|
if (!primary) {
|
|
const error = new Error('Job nicht gefunden.');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
if (!includeRelated) {
|
|
return [primary];
|
|
}
|
|
|
|
const childrenByParent = new Map();
|
|
const childrenBySource = new Map();
|
|
for (const row of rows) {
|
|
const rowId = normalizeJobIdValue(row?.id);
|
|
if (!rowId) {
|
|
continue;
|
|
}
|
|
const parentJobId = normalizeJobIdValue(row?.parent_job_id);
|
|
if (parentJobId) {
|
|
if (!childrenByParent.has(parentJobId)) {
|
|
childrenByParent.set(parentJobId, new Set());
|
|
}
|
|
childrenByParent.get(parentJobId).add(rowId);
|
|
}
|
|
const sourceJobId = parseSourceJobIdFromPlan(row?.encode_plan_json);
|
|
if (sourceJobId) {
|
|
if (!childrenBySource.has(sourceJobId)) {
|
|
childrenBySource.set(sourceJobId, new Set());
|
|
}
|
|
childrenBySource.get(sourceJobId).add(rowId);
|
|
}
|
|
}
|
|
|
|
const pending = [normalizedJobId];
|
|
const visited = new Set();
|
|
const parentRow = normalizeJobIdValue(primary?.parent_job_id) !== null
|
|
? byId.get(normalizeJobIdValue(primary?.parent_job_id))
|
|
: null;
|
|
const isPrimarySeriesChild = isSeriesChildRow(primary)
|
|
|| (parentRow && isSeriesContainerRow(parentRow));
|
|
const isPrimaryMultipartChild = isMultipartChildRow(primary)
|
|
|| isMultipartMergeRow(primary)
|
|
|| (parentRow && isMultipartContainerRow(parentRow));
|
|
const isPrimarySeriesContainer = isSeriesContainerRow(primary);
|
|
const isPrimaryMultipartContainer = isMultipartContainerRow(primary);
|
|
const isPrimaryScopedChild = isPrimarySeriesChild || isPrimaryMultipartChild;
|
|
const isPrimaryDiskContainer = isPrimarySeriesContainer || isPrimaryMultipartContainer;
|
|
const enqueue = (value) => {
|
|
const id = normalizeJobIdValue(value);
|
|
if (!id || visited.has(id)) {
|
|
return;
|
|
}
|
|
pending.push(id);
|
|
};
|
|
|
|
while (pending.length > 0) {
|
|
const currentId = normalizeJobIdValue(pending.shift());
|
|
if (!currentId || visited.has(currentId)) {
|
|
continue;
|
|
}
|
|
visited.add(currentId);
|
|
|
|
const row = byId.get(currentId);
|
|
if (!row) {
|
|
continue;
|
|
}
|
|
|
|
if (!(isPrimaryScopedChild || isPrimaryDiskContainer)) {
|
|
enqueue(row.parent_job_id);
|
|
enqueue(parseSourceJobIdFromPlan(row.encode_plan_json));
|
|
}
|
|
|
|
for (const childId of (childrenByParent.get(currentId) || [])) {
|
|
enqueue(childId);
|
|
}
|
|
for (const childId of (childrenBySource.get(currentId) || [])) {
|
|
enqueue(childId);
|
|
}
|
|
|
|
if (includeLogLinks && !(isPrimaryScopedChild || isPrimaryDiskContainer)) {
|
|
try {
|
|
const processLog = await this.readProcessLogLines(currentId, { includeAll: true });
|
|
const linkedJobIds = parseRetryLinkedJobIdsFromLogLines(processLog.lines);
|
|
for (const linkedId of linkedJobIds) {
|
|
enqueue(linkedId);
|
|
}
|
|
} catch (_error) {
|
|
// optional fallback links from process logs; ignore read errors
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isPrimaryScopedChild && parentRow && isDiskContainerRow(parentRow)) {
|
|
const parentId = normalizeJobIdValue(parentRow?.id);
|
|
if (parentId) {
|
|
const remainingChildren = rows.filter((row) => {
|
|
const rowId = normalizeJobIdValue(row?.id);
|
|
if (!rowId) {
|
|
return false;
|
|
}
|
|
if (normalizeJobIdValue(row?.parent_job_id) !== parentId) {
|
|
return false;
|
|
}
|
|
return !visited.has(rowId);
|
|
});
|
|
if (remainingChildren.length === 0) {
|
|
visited.add(parentId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return Array.from(visited)
|
|
.map((id) => byId.get(id))
|
|
.filter(Boolean)
|
|
.sort((left, right) => Number(left.id || 0) - Number(right.id || 0));
|
|
}
|
|
|
|
_collectDeleteCandidatesForJob(job, settings = null, options = {}) {
|
|
const normalizedJobId = normalizeJobIdValue(job?.id);
|
|
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
|
|
const lineageArtifacts = Array.isArray(options?.lineageArtifacts) ? options.lineageArtifacts : [];
|
|
const trackedOutputPaths = Array.isArray(options?.trackedOutputPaths)
|
|
? options.trackedOutputPaths
|
|
: [];
|
|
const toNormalizedPath = (value) => {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
return normalizeComparablePath(raw);
|
|
};
|
|
const unique = (values = []) => Array.from(new Set((Array.isArray(values) ? values : []).filter(Boolean)));
|
|
const sanitizeRoots = (values = []) => unique(values).filter((root) => !isFilesystemRootPath(root));
|
|
|
|
const artifactRawPaths = lineageArtifacts
|
|
.map((artifact) => toNormalizedPath(artifact?.rawPath))
|
|
.filter(Boolean);
|
|
const artifactMoviePaths = lineageArtifacts
|
|
.map((artifact) => toNormalizedPath(artifact?.outputPath))
|
|
.filter(Boolean);
|
|
|
|
const explicitRawPaths = unique([
|
|
toNormalizedPath(job?.raw_path),
|
|
toNormalizedPath(resolvedPaths?.effectiveRawPath),
|
|
...artifactRawPaths
|
|
]);
|
|
const explicitMovieSeedPaths = unique([
|
|
toNormalizedPath(job?.output_path),
|
|
toNormalizedPath(resolvedPaths?.effectiveOutputPath),
|
|
...trackedOutputPaths.map((candidatePath) => toNormalizedPath(candidatePath)),
|
|
...artifactMoviePaths
|
|
]);
|
|
const inferredSiblingMoviePaths = explicitMovieSeedPaths.flatMap((candidatePath) => inferSiblingOutputFolders(candidatePath));
|
|
const explicitMoviePaths = unique([
|
|
...explicitMovieSeedPaths,
|
|
...inferredSiblingMoviePaths.map((candidatePath) => toNormalizedPath(candidatePath))
|
|
]);
|
|
|
|
const rawRoots = sanitizeRoots([
|
|
toNormalizedPath(resolvedPaths?.rawDir),
|
|
...explicitRawPaths.map((candidatePath) => toNormalizedPath(path.dirname(candidatePath)))
|
|
]);
|
|
// Only effective base dir for this job becomes protectedRoot (must never be deleted itself)
|
|
const movieRoots = sanitizeRoots([
|
|
toNormalizedPath(resolvedPaths?.movieDir)
|
|
]);
|
|
// Includes parent dirs of output files for addCandidate safety checks
|
|
const movieAllowedPaths = sanitizeRoots([
|
|
...movieRoots,
|
|
...explicitMoviePaths,
|
|
...explicitMoviePaths.map((candidatePath) => toNormalizedPath(path.dirname(candidatePath)))
|
|
]);
|
|
|
|
const rawCandidates = [];
|
|
const movieCandidates = [];
|
|
const addCandidate = (bucket, target, candidatePath, source, allowedRoots = []) => {
|
|
const normalizedPath = toNormalizedPath(candidatePath);
|
|
if (!normalizedPath) {
|
|
return;
|
|
}
|
|
if (isFilesystemRootPath(normalizedPath)) {
|
|
return;
|
|
}
|
|
const roots = Array.isArray(allowedRoots) ? allowedRoots.filter(Boolean) : [];
|
|
if (roots.length > 0 && !roots.some((root) => isPathInside(root, normalizedPath))) {
|
|
return;
|
|
}
|
|
bucket.push({
|
|
target,
|
|
path: normalizedPath,
|
|
source,
|
|
jobId: normalizedJobId
|
|
});
|
|
};
|
|
|
|
const artifactRawPathSet = new Set(artifactRawPaths);
|
|
for (const rawPath of explicitRawPaths) {
|
|
addCandidate(
|
|
rawCandidates,
|
|
'raw',
|
|
rawPath,
|
|
artifactRawPathSet.has(rawPath) ? 'lineage_raw_path' : 'raw_path',
|
|
rawRoots
|
|
);
|
|
}
|
|
|
|
const rawFolderNames = new Set();
|
|
for (const rawPath of explicitRawPaths) {
|
|
const folderName = String(path.basename(rawPath || '') || '').trim();
|
|
if (!folderName || folderName === '.' || folderName === path.sep) {
|
|
continue;
|
|
}
|
|
rawFolderNames.add(folderName);
|
|
const stripped = stripRawFolderStatePrefix(folderName);
|
|
if (stripped) {
|
|
rawFolderNames.add(stripped);
|
|
rawFolderNames.add(applyRawFolderPrefix(stripped, RAW_INCOMPLETE_PREFIX));
|
|
rawFolderNames.add(applyRawFolderPrefix(stripped, RAW_RIP_COMPLETE_PREFIX));
|
|
}
|
|
}
|
|
for (const rootPath of rawRoots) {
|
|
for (const folderName of rawFolderNames) {
|
|
addCandidate(rawCandidates, 'raw', path.join(rootPath, folderName), 'raw_variant', rawRoots);
|
|
}
|
|
}
|
|
|
|
if (normalizedJobId) {
|
|
for (const rootPath of rawRoots) {
|
|
try {
|
|
if (!fs.existsSync(rootPath) || !fs.lstatSync(rootPath).isDirectory()) {
|
|
continue;
|
|
}
|
|
const entries = fs.readdirSync(rootPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry?.isDirectory?.()) {
|
|
continue;
|
|
}
|
|
const metadata = parseRawFolderMetadata(entry.name);
|
|
if (normalizeJobIdValue(metadata?.folderJobId) === normalizedJobId) {
|
|
addCandidate(
|
|
rawCandidates,
|
|
'raw',
|
|
path.join(rootPath, entry.name),
|
|
'raw_jobid_scan',
|
|
rawRoots
|
|
);
|
|
}
|
|
}
|
|
} catch (_error) {
|
|
// ignore fs errors while collecting optional candidates
|
|
}
|
|
}
|
|
}
|
|
|
|
const artifactMoviePathSet = new Set(artifactMoviePaths);
|
|
for (const outputPath of explicitMoviePaths) {
|
|
addCandidate(
|
|
movieCandidates,
|
|
'movie',
|
|
outputPath,
|
|
artifactMoviePathSet.has(outputPath) ? 'lineage_output_path' : 'output_path',
|
|
movieAllowedPaths
|
|
);
|
|
}
|
|
|
|
if (normalizedJobId && resolvedPaths?.mediaType !== 'cd') {
|
|
const incompleteName = `Incomplete_job-${normalizedJobId}`;
|
|
for (const rootPath of movieRoots) {
|
|
addCandidate(movieCandidates, 'movie', path.join(rootPath, incompleteName), 'movie_incomplete_folder', movieRoots);
|
|
try {
|
|
if (!fs.existsSync(rootPath) || !fs.lstatSync(rootPath).isDirectory()) {
|
|
continue;
|
|
}
|
|
const entries = fs.readdirSync(rootPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry?.isDirectory?.()) {
|
|
continue;
|
|
}
|
|
const match = String(entry.name || '').match(/^incomplete_job-(\d+)\s*$/i);
|
|
if (normalizeJobIdValue(match?.[1]) !== normalizedJobId) {
|
|
continue;
|
|
}
|
|
addCandidate(
|
|
movieCandidates,
|
|
'movie',
|
|
path.join(rootPath, entry.name),
|
|
'movie_incomplete_scan',
|
|
movieRoots
|
|
);
|
|
}
|
|
} catch (_error) {
|
|
// ignore fs errors while collecting optional candidates
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
rawCandidates,
|
|
movieCandidates,
|
|
rawRoots,
|
|
movieRoots
|
|
};
|
|
}
|
|
|
|
_buildDeletePreviewFromJobs(jobs = [], settings = null, lineageArtifactsByJobId = null, outputFoldersByJobId = null) {
|
|
const rows = Array.isArray(jobs) ? jobs : [];
|
|
const artifactsMap = lineageArtifactsByJobId instanceof Map ? lineageArtifactsByJobId : new Map();
|
|
const trackedFoldersMap = outputFoldersByJobId instanceof Map ? outputFoldersByJobId : new Map();
|
|
const candidateMap = new Map();
|
|
const protectedRoots = {
|
|
raw: new Set(),
|
|
movie: new Set()
|
|
};
|
|
const upsertCandidate = (candidate) => {
|
|
const target = String(candidate?.target || '').trim().toLowerCase();
|
|
const candidatePath = String(candidate?.path || '').trim();
|
|
if (!target || !candidatePath) {
|
|
return;
|
|
}
|
|
const key = `${target}:${candidatePath}`;
|
|
if (!candidateMap.has(key)) {
|
|
candidateMap.set(key, {
|
|
target,
|
|
path: candidatePath,
|
|
jobIds: new Set(),
|
|
sources: new Set()
|
|
});
|
|
}
|
|
const row = candidateMap.get(key);
|
|
const candidateJobId = normalizeJobIdValue(candidate?.jobId);
|
|
if (candidateJobId) {
|
|
row.jobIds.add(candidateJobId);
|
|
}
|
|
const source = String(candidate?.source || '').trim();
|
|
if (source) {
|
|
row.sources.add(source);
|
|
}
|
|
};
|
|
|
|
for (const job of rows) {
|
|
const lineageArtifacts = artifactsMap.get(normalizeJobIdValue(job?.id)) || [];
|
|
const trackedFolders = trackedFoldersMap.get(normalizeJobIdValue(job?.id)) || [];
|
|
const collected = this._collectDeleteCandidatesForJob(job, settings, {
|
|
lineageArtifacts,
|
|
trackedOutputPaths: trackedFolders
|
|
.map((folder) => String(folder?.output_path || '').trim())
|
|
.filter(Boolean)
|
|
});
|
|
for (const rootPath of collected.rawRoots || []) {
|
|
protectedRoots.raw.add(rootPath);
|
|
}
|
|
for (const rootPath of collected.movieRoots || []) {
|
|
protectedRoots.movie.add(rootPath);
|
|
}
|
|
for (const candidate of collected.rawCandidates || []) {
|
|
upsertCandidate(candidate);
|
|
}
|
|
for (const candidate of collected.movieCandidates || []) {
|
|
upsertCandidate(candidate);
|
|
}
|
|
}
|
|
|
|
const hasTrackedOutputSource = (sources = []) => {
|
|
const normalizedSources = Array.isArray(sources)
|
|
? sources
|
|
.map((source) => String(source || '').trim().toLowerCase())
|
|
.filter(Boolean)
|
|
: [];
|
|
return normalizedSources.includes('output_path') || normalizedSources.includes('lineage_output_path');
|
|
};
|
|
|
|
const buildList = (target) => {
|
|
const rows = Array.from(candidateMap.values())
|
|
.filter((row) => row.target === target);
|
|
if (target !== 'movie') {
|
|
return rows
|
|
.map((row) => {
|
|
const inspection = inspectDeletionPath(row.path);
|
|
const sources = Array.from(row.sources).sort((left, right) => left.localeCompare(right));
|
|
return {
|
|
target,
|
|
path: row.path,
|
|
exists: Boolean(inspection.exists),
|
|
isDirectory: Boolean(inspection.isDirectory),
|
|
isFile: Boolean(inspection.isFile),
|
|
jobIds: Array.from(row.jobIds).sort((left, right) => left - right),
|
|
sources
|
|
};
|
|
})
|
|
.filter((row) => Boolean(row?.exists))
|
|
.filter(Boolean)
|
|
.sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de'));
|
|
}
|
|
|
|
const moviePathMap = new Map();
|
|
const upsertMoviePath = ({ path: candidatePath, exists, isDirectory, isFile, jobIds, sources }) => {
|
|
const normalizedPath = normalizeComparablePath(candidatePath);
|
|
if (!normalizedPath) {
|
|
return;
|
|
}
|
|
if (!moviePathMap.has(normalizedPath)) {
|
|
moviePathMap.set(normalizedPath, {
|
|
target: 'movie',
|
|
path: normalizedPath,
|
|
exists: Boolean(exists),
|
|
isDirectory: Boolean(isDirectory),
|
|
isFile: Boolean(isFile),
|
|
jobIds: new Set(),
|
|
sources: new Set()
|
|
});
|
|
}
|
|
const row = moviePathMap.get(normalizedPath);
|
|
row.exists = row.exists || Boolean(exists);
|
|
row.isDirectory = row.isDirectory || Boolean(isDirectory);
|
|
row.isFile = row.isFile || Boolean(isFile);
|
|
for (const jobId of (Array.isArray(jobIds) ? jobIds : [])) {
|
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
|
if (normalizedJobId) {
|
|
row.jobIds.add(normalizedJobId);
|
|
}
|
|
}
|
|
for (const source of (Array.isArray(sources) ? sources : [])) {
|
|
const normalizedSource = String(source || '').trim();
|
|
if (normalizedSource) {
|
|
row.sources.add(normalizedSource);
|
|
}
|
|
}
|
|
};
|
|
|
|
for (const row of rows) {
|
|
const inspection = inspectDeletionPath(row.path);
|
|
const sources = Array.from(row.sources).sort((left, right) => left.localeCompare(right));
|
|
// Do not expose stale output paths from DB/lineage in delete preview.
|
|
if (!inspection.exists && hasTrackedOutputSource(sources)) {
|
|
continue;
|
|
}
|
|
if (!inspection.exists) {
|
|
// Output candidates must stay file-based only. Missing paths are not actionable.
|
|
continue;
|
|
}
|
|
if (inspection.isDirectory) {
|
|
const files = collectFilesRecursively(inspection.path);
|
|
for (const filePath of files) {
|
|
upsertMoviePath({
|
|
path: filePath,
|
|
exists: true,
|
|
isDirectory: false,
|
|
isFile: true,
|
|
jobIds: Array.from(row.jobIds),
|
|
sources
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
upsertMoviePath({
|
|
path: inspection.path,
|
|
exists: true,
|
|
isDirectory: false,
|
|
isFile: true,
|
|
jobIds: Array.from(row.jobIds),
|
|
sources
|
|
});
|
|
}
|
|
|
|
return Array.from(moviePathMap.values())
|
|
.map((row) => ({
|
|
target: row.target,
|
|
path: row.path,
|
|
exists: Boolean(row.exists),
|
|
isDirectory: false,
|
|
isFile: true,
|
|
jobIds: Array.from(row.jobIds).sort((left, right) => left - right),
|
|
sources: Array.from(row.sources).sort((left, right) => left.localeCompare(right))
|
|
}))
|
|
.sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de'));
|
|
};
|
|
|
|
return {
|
|
pathCandidates: {
|
|
raw: buildList('raw'),
|
|
movie: buildList('movie')
|
|
},
|
|
protectedRoots: {
|
|
raw: Array.from(protectedRoots.raw).sort((left, right) => left.localeCompare(right)),
|
|
movie: Array.from(protectedRoots.movie).sort((left, right) => left.localeCompare(right))
|
|
}
|
|
};
|
|
}
|
|
|
|
async getJobDeletePreview(jobId, options = {}) {
|
|
const includeRelated = options?.includeRelated !== false;
|
|
const requestedSelectedJobIds = Array.isArray(options?.selectedJobIds)
|
|
? options.selectedJobIds
|
|
.map((item) => normalizeJobIdValue(item))
|
|
.filter(Boolean)
|
|
: null;
|
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
|
if (!normalizedJobId) {
|
|
const error = new Error('Ungültige Job-ID.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const jobs = await this._resolveRelatedJobsForDeletion(normalizedJobId, { includeRelated });
|
|
const allJobIds = jobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
|
|
const allJobIdSet = new Set(allJobIds);
|
|
const hasExplicitSelectedJobIds = Array.isArray(requestedSelectedJobIds);
|
|
const effectiveSelectedJobIds = includeRelated
|
|
? (hasExplicitSelectedJobIds
|
|
? requestedSelectedJobIds.filter((id) => allJobIdSet.has(id))
|
|
: allJobIds)
|
|
: [normalizedJobId];
|
|
const effectiveSelectedJobIdSet = new Set(effectiveSelectedJobIds);
|
|
const selectedJobs = jobs.filter((job) => effectiveSelectedJobIdSet.has(normalizeJobIdValue(job?.id)));
|
|
|
|
const settings = await settingsService.getSettingsMap();
|
|
const relatedJobIds = selectedJobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
|
|
const [lineageArtifactsByJobId, outputFoldersByJobId] = relatedJobIds.length > 0
|
|
? await Promise.all([
|
|
this.listJobLineageArtifactsByJobIds(relatedJobIds),
|
|
this.listJobOutputFoldersByJobIds(relatedJobIds)
|
|
])
|
|
: [new Map(), new Map()];
|
|
const preview = this._buildDeletePreviewFromJobs(
|
|
selectedJobs,
|
|
settings,
|
|
lineageArtifactsByJobId,
|
|
outputFoldersByJobId
|
|
);
|
|
const relatedJobs = jobs.map((job) => {
|
|
const mkInfo = parseJsonSafe(job?.makemkv_info_json, {});
|
|
const roleMeta = resolveDeletePreviewRole(job);
|
|
const id = normalizeJobIdValue(job?.id);
|
|
return {
|
|
id: Number(job.id),
|
|
parentJobId: normalizeJobIdValue(job.parent_job_id),
|
|
title: buildJobDisplayTitle(job),
|
|
status: String(job.status || '').trim() || null,
|
|
discDevice: String(job.disc_device || '').trim() || null,
|
|
isPrimary: Number(job.id) === normalizedJobId,
|
|
selected: Boolean(id && effectiveSelectedJobIdSet.has(id)),
|
|
roleKey: roleMeta.roleKey,
|
|
roleLabel: roleMeta.roleLabel,
|
|
createdAt: String(job.created_at || '').trim() || null,
|
|
orphanRawImport: isOrphanRawImportMakemkvInfo(mkInfo)
|
|
};
|
|
});
|
|
const orphanRawImportJobs = relatedJobs.filter(
|
|
(row) => Boolean(row?.selected) && Boolean(row?.orphanRawImport)
|
|
).length;
|
|
const existingRawCandidates = preview.pathCandidates.raw.filter((row) => row.exists).length;
|
|
const existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length;
|
|
|
|
return {
|
|
jobId: normalizedJobId,
|
|
includeRelated,
|
|
selectedJobIds: effectiveSelectedJobIds,
|
|
relatedJobs,
|
|
pathCandidates: preview.pathCandidates,
|
|
protectedRoots: preview.protectedRoots,
|
|
flags: {
|
|
containsOrphanRawImportJob: orphanRawImportJobs > 0
|
|
},
|
|
counts: {
|
|
relatedJobs: relatedJobs.length,
|
|
selectedJobs: effectiveSelectedJobIds.length,
|
|
orphanRawImportJobs,
|
|
rawCandidates: preview.pathCandidates.raw.length,
|
|
movieCandidates: preview.pathCandidates.movie.length,
|
|
existingRawCandidates,
|
|
existingMovieCandidates
|
|
}
|
|
};
|
|
}
|
|
|
|
_deletePathsFromPreview(preview, target = 'both', options = {}) {
|
|
const normalizedTarget = String(target || 'both').trim().toLowerCase();
|
|
const includesRaw = normalizedTarget === 'raw' || normalizedTarget === 'both';
|
|
const includesMovie = normalizedTarget === 'movie' || normalizedTarget === 'both';
|
|
const selectedRawPathFilter = new Set(
|
|
(Array.isArray(options?.selectedRawPaths) ? options.selectedRawPaths : [])
|
|
.map((rawPath) => String(rawPath || '').trim())
|
|
.filter(Boolean)
|
|
.map((rawPath) => normalizeComparablePath(rawPath))
|
|
);
|
|
const selectedMoviePathFilter = new Set(
|
|
(Array.isArray(options?.selectedMoviePaths) ? options.selectedMoviePaths : [])
|
|
.map((moviePath) => String(moviePath || '').trim())
|
|
.filter(Boolean)
|
|
.map((moviePath) => normalizeComparablePath(moviePath))
|
|
);
|
|
|
|
const summary = {
|
|
target: normalizedTarget,
|
|
raw: { attempted: includesRaw, deleted: false, filesDeleted: 0, dirsRemoved: 0, pathsDeleted: 0, reason: null },
|
|
movie: { attempted: includesMovie, deleted: false, filesDeleted: 0, dirsRemoved: 0, pathsDeleted: 0, reason: null },
|
|
selectedRawPaths: Array.from(selectedRawPathFilter),
|
|
selectedMoviePaths: Array.from(selectedMoviePathFilter),
|
|
deletedPaths: []
|
|
};
|
|
|
|
const applyTarget = (targetKey) => {
|
|
let candidates = (Array.isArray(preview?.pathCandidates?.[targetKey]) ? preview.pathCandidates[targetKey] : [])
|
|
.filter((item) => Boolean(item?.exists) && (Boolean(item?.isDirectory) || Boolean(item?.isFile)));
|
|
if (targetKey === 'raw' && selectedRawPathFilter.size > 0) {
|
|
candidates = candidates.filter((item) => {
|
|
const candidatePath = String(item?.path || '').trim();
|
|
if (!candidatePath) {
|
|
return false;
|
|
}
|
|
return selectedRawPathFilter.has(normalizeComparablePath(candidatePath));
|
|
});
|
|
}
|
|
if (targetKey === 'movie' && selectedMoviePathFilter.size > 0) {
|
|
candidates = candidates.filter((item) => {
|
|
const candidatePath = String(item?.path || '').trim();
|
|
if (!candidatePath) {
|
|
return false;
|
|
}
|
|
return selectedMoviePathFilter.has(normalizeComparablePath(candidatePath));
|
|
});
|
|
|
|
// If selected movie paths are passed explicitly, trust this list as source of
|
|
// truth as well. This protects against stale preview maps after path renames.
|
|
const candidatePathSet = new Set(
|
|
candidates
|
|
.map((item) => normalizeComparablePath(item?.path))
|
|
.filter(Boolean)
|
|
);
|
|
for (const selectedPath of selectedMoviePathFilter) {
|
|
if (!selectedPath || candidatePathSet.has(selectedPath)) {
|
|
continue;
|
|
}
|
|
const inspection = inspectDeletionPath(selectedPath);
|
|
if (!inspection.exists || (!inspection.isDirectory && !inspection.isFile)) {
|
|
continue;
|
|
}
|
|
candidates.push({
|
|
target: 'movie',
|
|
path: inspection.path,
|
|
exists: true,
|
|
isDirectory: Boolean(inspection.isDirectory),
|
|
isFile: Boolean(inspection.isFile),
|
|
jobIds: [],
|
|
sources: ['selected_movie_paths']
|
|
});
|
|
candidatePathSet.add(inspection.path);
|
|
}
|
|
}
|
|
if (candidates.length === 0) {
|
|
if (targetKey === 'raw' && selectedRawPathFilter.size > 0) {
|
|
summary[targetKey].reason = 'Keine ausgewählten RAW-Dateien/Ordner gefunden.';
|
|
} else if (targetKey === 'movie' && selectedMoviePathFilter.size > 0) {
|
|
summary[targetKey].reason = 'Keine ausgewählten Audio/Video-Dateien gefunden.';
|
|
} else {
|
|
summary[targetKey].reason = 'Keine passenden Dateien/Ordner gefunden.';
|
|
}
|
|
return;
|
|
}
|
|
|
|
const protectedRoots = new Set(
|
|
(Array.isArray(preview?.protectedRoots?.[targetKey]) ? preview.protectedRoots[targetKey] : [])
|
|
.map((rootPath) => String(rootPath || '').trim())
|
|
.filter(Boolean)
|
|
.map((rootPath) => normalizeComparablePath(rootPath))
|
|
);
|
|
|
|
const orderedCandidates = [...candidates].sort(
|
|
(left, right) => String(right?.path || '').length - String(left?.path || '').length
|
|
);
|
|
for (const candidate of orderedCandidates) {
|
|
const candidatePath = String(candidate?.path || '').trim();
|
|
if (!candidatePath) {
|
|
continue;
|
|
}
|
|
const inspection = inspectDeletionPath(candidatePath);
|
|
if (!inspection.exists) {
|
|
continue;
|
|
}
|
|
|
|
if (inspection.isDirectory) {
|
|
if (targetKey === 'movie') {
|
|
const filesInDirectory = collectFilesRecursively(inspection.path);
|
|
let filesDeleted = 0;
|
|
for (const filePath of filesInDirectory) {
|
|
const fileInspection = inspectDeletionPath(filePath);
|
|
if (!fileInspection.exists) {
|
|
continue;
|
|
}
|
|
if (fileInspection.isDirectory) {
|
|
continue;
|
|
}
|
|
try {
|
|
fs.unlinkSync(fileInspection.path);
|
|
filesDeleted += 1;
|
|
summary.deletedPaths.push({
|
|
target: targetKey,
|
|
path: fileInspection.path,
|
|
type: 'file',
|
|
keepRoot: false,
|
|
jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : []
|
|
});
|
|
} catch (_error) {
|
|
// continue best-effort for remaining files
|
|
}
|
|
}
|
|
summary[targetKey].filesDeleted += filesDeleted;
|
|
|
|
const keepRoot = protectedRoots.has(inspection.path);
|
|
const dirDeleteResult = deleteFilesRecursively(inspection.path, keepRoot);
|
|
const dirsRemoved = Number(dirDeleteResult?.dirsRemoved || 0);
|
|
if (dirsRemoved > 0 || filesDeleted > 0) {
|
|
summary[targetKey].dirsRemoved += dirsRemoved;
|
|
summary[targetKey].pathsDeleted += 1;
|
|
summary.deletedPaths.push({
|
|
target: targetKey,
|
|
path: inspection.path,
|
|
type: 'directory',
|
|
keepRoot,
|
|
jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : []
|
|
});
|
|
}
|
|
const removedParentDirs = removeEmptyParentDirectories(path.dirname(inspection.path), {
|
|
protectedRoots: Array.from(protectedRoots)
|
|
});
|
|
for (const removedPath of removedParentDirs) {
|
|
summary[targetKey].dirsRemoved += 1;
|
|
summary.deletedPaths.push({
|
|
target: targetKey,
|
|
path: removedPath,
|
|
type: 'directory',
|
|
keepRoot: false,
|
|
jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : []
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const keepRoot = protectedRoots.has(inspection.path);
|
|
const result = deleteFilesRecursively(inspection.path, keepRoot);
|
|
const filesDeleted = Number(result?.filesDeleted || 0);
|
|
const dirsRemoved = Number(result?.dirsRemoved || 0);
|
|
const directoryRemoved = !keepRoot && !fs.existsSync(inspection.path);
|
|
const changed = filesDeleted > 0 || dirsRemoved > 0 || directoryRemoved;
|
|
summary[targetKey].filesDeleted += filesDeleted;
|
|
summary[targetKey].dirsRemoved += dirsRemoved;
|
|
if (changed) {
|
|
summary[targetKey].pathsDeleted += 1;
|
|
summary.deletedPaths.push({
|
|
target: targetKey,
|
|
path: inspection.path,
|
|
type: 'directory',
|
|
keepRoot,
|
|
jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : []
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
fs.unlinkSync(inspection.path);
|
|
summary[targetKey].filesDeleted += 1;
|
|
summary[targetKey].pathsDeleted += 1;
|
|
summary.deletedPaths.push({
|
|
target: targetKey,
|
|
path: inspection.path,
|
|
type: 'file',
|
|
keepRoot: false,
|
|
jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : []
|
|
});
|
|
|
|
if (targetKey === 'movie') {
|
|
const parentDir = normalizeComparablePath(path.dirname(inspection.path));
|
|
const removedParentDirs = removeEmptyParentDirectories(parentDir, {
|
|
protectedRoots: Array.from(protectedRoots)
|
|
});
|
|
for (const removedPath of removedParentDirs) {
|
|
summary[targetKey].dirsRemoved += 1;
|
|
summary.deletedPaths.push({
|
|
target: targetKey,
|
|
path: removedPath,
|
|
type: 'directory',
|
|
keepRoot: false,
|
|
jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : []
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
summary[targetKey].deleted = summary[targetKey].pathsDeleted > 0
|
|
|| summary[targetKey].filesDeleted > 0
|
|
|| summary[targetKey].dirsRemoved > 0;
|
|
if (!summary[targetKey].deleted) {
|
|
summary[targetKey].reason = 'Keine vorhandenen Dateien/Ordner gelöscht.';
|
|
}
|
|
};
|
|
|
|
if (includesRaw) {
|
|
applyTarget('raw');
|
|
}
|
|
if (includesMovie) {
|
|
applyTarget('movie');
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
_deleteProcessLogFile(jobId) {
|
|
const processLogPath = toProcessLogPath(jobId);
|
|
if (!processLogPath || !fs.existsSync(processLogPath)) {
|
|
return;
|
|
}
|
|
try {
|
|
fs.unlinkSync(processLogPath);
|
|
} catch (error) {
|
|
logger.warn('job:process-log:delete-failed', {
|
|
jobId,
|
|
path: processLogPath,
|
|
error: error?.message || String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
_archiveProcessLogFile(jobId, options = {}) {
|
|
const processLogPath = toProcessLogPath(jobId);
|
|
if (!processLogPath || !fs.existsSync(processLogPath)) {
|
|
return null;
|
|
}
|
|
const archiveTarget = buildArchivedProcessLogPath(jobId, options);
|
|
if (!archiveTarget?.archivePath) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
fs.mkdirSync(archiveTarget.archiveDir, { recursive: true });
|
|
fs.renameSync(processLogPath, archiveTarget.archivePath);
|
|
return archiveTarget.archivePath;
|
|
} catch (error) {
|
|
logger.warn('job:process-log:archive-failed', {
|
|
jobId,
|
|
fromPath: processLogPath,
|
|
toPath: archiveTarget.archivePath,
|
|
reason: String(options?.reason || '').trim() || null,
|
|
replacementJobId: Number(options?.replacementJobId) || null,
|
|
error: error?.message || String(error)
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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.`);
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const job = await this.getJobById(jobId);
|
|
if (!job) {
|
|
const error = new Error('Job nicht gefunden.');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
const includeRelated = Boolean(options?.includeRelated);
|
|
const selectedJobIds = Array.isArray(options?.selectedJobIds)
|
|
? options.selectedJobIds
|
|
.map((item) => normalizeJobIdValue(item))
|
|
.filter(Boolean)
|
|
: null;
|
|
const selectedRawPaths = Array.isArray(options?.selectedRawPaths)
|
|
? options.selectedRawPaths
|
|
.map((item) => String(item || '').trim())
|
|
.filter(Boolean)
|
|
: null;
|
|
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
|
|
? options.selectedMoviePaths
|
|
.map((item) => String(item || '').trim())
|
|
.filter(Boolean)
|
|
: null;
|
|
|
|
const hasSelectedJobFilter = Array.isArray(options?.selectedJobIds);
|
|
if (
|
|
includeRelated
|
|
|| hasSelectedJobFilter
|
|
|| (selectedRawPaths && selectedRawPaths.length > 0)
|
|
|| (selectedMoviePaths && selectedMoviePaths.length > 0)
|
|
) {
|
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
|
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated, selectedJobIds });
|
|
const summary = this._deletePathsFromPreview(preview, target, { selectedRawPaths, selectedMoviePaths });
|
|
const relatedJobIds = Array.from(new Set(
|
|
(Array.isArray(preview?.selectedJobIds) ? preview.selectedJobIds : [])
|
|
.map((row) => normalizeJobIdValue(row))
|
|
.filter(Boolean)
|
|
));
|
|
|
|
if (summary.movie?.deleted) {
|
|
await this.removeMissingJobOutputFoldersFromJobs(
|
|
relatedJobIds.length > 0 ? relatedJobIds : [normalizedJobId]
|
|
);
|
|
}
|
|
|
|
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,
|
|
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
|
|
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
|
|
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;
|
|
const effectiveOutputPath = resolvedPaths.effectiveOutputPath;
|
|
const effectiveRawDir = resolvedPaths.rawDir;
|
|
const effectiveMovieDir = resolvedPaths.movieDir;
|
|
const summary = {
|
|
target,
|
|
raw: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null },
|
|
movie: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null }
|
|
};
|
|
let movieDeletedPathForTracking = null;
|
|
let movieCandidatePathForTracking = null;
|
|
|
|
if (target === 'raw' || target === 'both') {
|
|
summary.raw.attempted = true;
|
|
if (!effectiveRawPath) {
|
|
summary.raw.reason = 'Kein raw_path im Job gesetzt.';
|
|
} else if (!effectiveRawDir) {
|
|
const error = new Error(`Kein gültiger RAW-Basispfad für Job ${jobId} (${resolvedPaths.mediaType || 'unknown'}).`);
|
|
error.statusCode = 400;
|
|
throw error;
|
|
} else if (!isPathInside(effectiveRawDir, effectiveRawPath)) {
|
|
const error = new Error(
|
|
`RAW-Pfad liegt außerhalb des effektiven RAW-Basispfads. raw_path=${effectiveRawPath} raw_base=${effectiveRawDir}`
|
|
);
|
|
error.statusCode = 400;
|
|
throw error;
|
|
} else if (!fs.existsSync(effectiveRawPath)) {
|
|
summary.raw.reason = 'RAW-Pfad existiert nicht.';
|
|
} else {
|
|
const rawPath = normalizeComparablePath(effectiveRawPath);
|
|
const rawRoot = normalizeComparablePath(effectiveRawDir);
|
|
const stat = fs.lstatSync(rawPath);
|
|
const isFile = stat.isFile();
|
|
const plan = resolvedPaths.encodePlan || {};
|
|
|
|
// Für Converter-Jobs: nur die zum Job gehörenden Dateien löschen.
|
|
// Der übergeordnete Ordner wird nur entfernt, wenn er danach leer ist.
|
|
// Ausnahme: Ordner-Jobs (isFolder=true) haben einen dedizierten Ordner → komplett löschen.
|
|
const isConverterFolder = resolvedPaths.mediaType === 'converter' && Boolean(plan.isFolder);
|
|
const isSharedAudio = resolvedPaths.mediaType === 'converter' && Boolean(plan.isSharedAudio);
|
|
const isConverterFileJob = resolvedPaths.mediaType === 'converter' && !isConverterFolder;
|
|
|
|
if (isConverterFileJob) {
|
|
// Bestimme die zu löschenden Dateien dieses Jobs
|
|
let filesToDelete = null;
|
|
if (isSharedAudio && Array.isArray(plan.inputPaths) && plan.inputPaths.length > 0) {
|
|
// Shared-Audio-Job: nur die explizit gelisteten Einzeldateien entfernen
|
|
filesToDelete = plan.inputPaths.map(normalizeComparablePath).filter(Boolean);
|
|
} else if (isFile) {
|
|
// Einzeldatei-Job: nur diese eine Datei entfernen
|
|
filesToDelete = [rawPath];
|
|
}
|
|
|
|
if (filesToDelete) {
|
|
let filesDeleted = 0;
|
|
const parentDirs = new Set();
|
|
for (const filePath of filesToDelete) {
|
|
if (!isPathInside(rawRoot, filePath)) continue;
|
|
if (!fs.existsSync(filePath)) continue;
|
|
const fStat = fs.lstatSync(filePath);
|
|
if (!fStat.isFile()) continue;
|
|
fs.unlinkSync(filePath);
|
|
filesDeleted++;
|
|
const parentDir = normalizeComparablePath(path.dirname(filePath));
|
|
if (parentDir && parentDir !== rawRoot && isPathInside(rawRoot, parentDir)) {
|
|
parentDirs.add(parentDir);
|
|
}
|
|
}
|
|
// Übergeordneten Ordner löschen, wenn er nach dem Löschen leer ist
|
|
let dirsRemoved = 0;
|
|
for (const parentDir of parentDirs) {
|
|
try {
|
|
if (!fs.existsSync(parentDir)) continue;
|
|
const remaining = fs.readdirSync(parentDir);
|
|
if (remaining.length === 0) {
|
|
fs.rmdirSync(parentDir);
|
|
dirsRemoved++;
|
|
}
|
|
} catch (_err) { /* ignore */ }
|
|
}
|
|
summary.raw.deleted = true;
|
|
summary.raw.filesDeleted = filesDeleted;
|
|
summary.raw.dirsRemoved = dirsRemoved;
|
|
} else {
|
|
// Fallback: rawPath ist ein Verzeichnis ohne explizite Dateiliste
|
|
const keepRoot = rawPath === rawRoot;
|
|
const result = deleteFilesRecursively(rawPath, keepRoot);
|
|
summary.raw.deleted = true;
|
|
summary.raw.filesDeleted = result.filesDeleted;
|
|
summary.raw.dirsRemoved = result.dirsRemoved;
|
|
}
|
|
} else {
|
|
// Regulärer Job oder dedizierter Converter-Ordner-Job (isFolder=true): gesamten Pfad löschen
|
|
const keepRoot = rawPath === rawRoot;
|
|
const result = deleteFilesRecursively(rawPath, keepRoot);
|
|
summary.raw.deleted = true;
|
|
summary.raw.filesDeleted = result.filesDeleted;
|
|
summary.raw.dirsRemoved = result.dirsRemoved;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (target === 'movie' || target === 'both') {
|
|
summary.movie.attempted = true;
|
|
if (!effectiveOutputPath) {
|
|
summary.movie.reason = 'Kein output_path im Job gesetzt.';
|
|
} else if (!effectiveMovieDir) {
|
|
const error = new Error(`Kein gültiger Movie-Basispfad für Job ${jobId} (${resolvedPaths.mediaType || 'unknown'}).`);
|
|
error.statusCode = 400;
|
|
throw error;
|
|
} else if (!isPathInside(effectiveMovieDir, effectiveOutputPath)) {
|
|
const error = new Error(
|
|
`Movie-Pfad liegt außerhalb des effektiven Movie-Basispfads. output_path=${effectiveOutputPath} movie_base=${effectiveMovieDir}`
|
|
);
|
|
error.statusCode = 400;
|
|
throw error;
|
|
} else if (!fs.existsSync(effectiveOutputPath)) {
|
|
const movieRoot = normalizeComparablePath(effectiveMovieDir);
|
|
const pruneEmptyMovieParents = (startPath) => {
|
|
if (!startPath || !movieRoot) {
|
|
return [];
|
|
}
|
|
return removeEmptyParentDirectories(startPath, {
|
|
protectedRoots: [movieRoot]
|
|
});
|
|
};
|
|
const trackedFolders = await this.getJobOutputFolders(jobId);
|
|
const trackedExistingPaths = Array.from(new Set(
|
|
trackedFolders
|
|
.map((row) => normalizeComparablePath(row?.output_path))
|
|
.filter(Boolean)
|
|
.filter((candidatePath) => isPathInside(effectiveMovieDir, candidatePath))
|
|
.filter((candidatePath) => candidatePath !== movieRoot)
|
|
.filter((candidatePath) => fs.existsSync(candidatePath))
|
|
));
|
|
|
|
if (trackedExistingPaths.length === 1) {
|
|
const trackedPath = trackedExistingPaths[0];
|
|
const stat = fs.lstatSync(trackedPath);
|
|
if (stat.isDirectory()) {
|
|
const keepRoot = trackedPath === movieRoot;
|
|
const result = deleteFilesRecursively(trackedPath, keepRoot);
|
|
const removedParentDirs = keepRoot
|
|
? []
|
|
: pruneEmptyMovieParents(path.dirname(trackedPath));
|
|
summary.movie.deleted = true;
|
|
summary.movie.filesDeleted = result.filesDeleted;
|
|
summary.movie.dirsRemoved = result.dirsRemoved + Number(removedParentDirs.length || 0);
|
|
movieDeletedPathForTracking = trackedPath;
|
|
movieCandidatePathForTracking = trackedPath;
|
|
summary.movie.reason = null;
|
|
} else {
|
|
const parentDir = normalizeComparablePath(path.dirname(trackedPath));
|
|
const isConverterJob = resolvedPaths.mediaType === 'converter';
|
|
const canDeleteParentDir = !isConverterJob
|
|
&& parentDir
|
|
&& parentDir !== movieRoot
|
|
&& isPathInside(movieRoot, parentDir)
|
|
&& fs.existsSync(parentDir)
|
|
&& fs.lstatSync(parentDir).isDirectory();
|
|
|
|
if (canDeleteParentDir) {
|
|
const result = deleteFilesRecursively(parentDir, false);
|
|
const removedParentDirs = pruneEmptyMovieParents(path.dirname(parentDir));
|
|
summary.movie.deleted = true;
|
|
summary.movie.filesDeleted = result.filesDeleted;
|
|
summary.movie.dirsRemoved = result.dirsRemoved + Number(removedParentDirs.length || 0);
|
|
movieDeletedPathForTracking = parentDir;
|
|
movieCandidatePathForTracking = trackedPath;
|
|
} else {
|
|
fs.unlinkSync(trackedPath);
|
|
const removedParentDirs = pruneEmptyMovieParents(parentDir);
|
|
summary.movie.deleted = true;
|
|
summary.movie.filesDeleted = 1;
|
|
summary.movie.dirsRemoved = Number(removedParentDirs.length || 0);
|
|
movieDeletedPathForTracking = trackedPath;
|
|
movieCandidatePathForTracking = trackedPath;
|
|
}
|
|
summary.movie.reason = null;
|
|
}
|
|
} else if (trackedExistingPaths.length > 1) {
|
|
summary.movie.reason = 'Mehrere bekannte Output-Ordner gefunden. Bitte gezielt über die Ordnerauswahl löschen.';
|
|
} else {
|
|
summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.';
|
|
}
|
|
} else {
|
|
const outputPath = normalizeComparablePath(effectiveOutputPath);
|
|
const movieRoot = normalizeComparablePath(effectiveMovieDir);
|
|
const pruneEmptyMovieParents = (startPath) => {
|
|
if (!startPath || !movieRoot) {
|
|
return [];
|
|
}
|
|
return removeEmptyParentDirectories(startPath, {
|
|
protectedRoots: [movieRoot]
|
|
});
|
|
};
|
|
const stat = fs.lstatSync(outputPath);
|
|
if (stat.isDirectory()) {
|
|
const keepRoot = outputPath === movieRoot;
|
|
const result = deleteFilesRecursively(outputPath, keepRoot ? true : false);
|
|
const removedParentDirs = keepRoot
|
|
? []
|
|
: pruneEmptyMovieParents(path.dirname(outputPath));
|
|
summary.movie.deleted = true;
|
|
summary.movie.filesDeleted = result.filesDeleted;
|
|
summary.movie.dirsRemoved = result.dirsRemoved + Number(removedParentDirs.length || 0);
|
|
movieDeletedPathForTracking = outputPath;
|
|
movieCandidatePathForTracking = outputPath;
|
|
} else {
|
|
const parentDir = normalizeComparablePath(path.dirname(outputPath));
|
|
const parentDirName = String(path.basename(parentDir || '') || '').trim();
|
|
const isIncompleteMergeParentDir = /^incomplete_merge_.+_job_\d+\s*$/i.test(parentDirName);
|
|
// Converter jobs output a single file — never delete the parent dir
|
|
const isConverterJob = resolvedPaths.mediaType === 'converter';
|
|
const canDeleteParentDir = !isConverterJob
|
|
&& !isIncompleteMergeParentDir
|
|
&& parentDir
|
|
&& parentDir !== movieRoot
|
|
&& isPathInside(movieRoot, parentDir)
|
|
&& fs.existsSync(parentDir)
|
|
&& fs.lstatSync(parentDir).isDirectory();
|
|
|
|
if (canDeleteParentDir) {
|
|
const result = deleteFilesRecursively(parentDir, false);
|
|
const removedParentDirs = pruneEmptyMovieParents(path.dirname(parentDir));
|
|
summary.movie.deleted = true;
|
|
summary.movie.filesDeleted = result.filesDeleted;
|
|
summary.movie.dirsRemoved = result.dirsRemoved + Number(removedParentDirs.length || 0);
|
|
movieDeletedPathForTracking = parentDir;
|
|
movieCandidatePathForTracking = outputPath;
|
|
} else {
|
|
fs.unlinkSync(outputPath);
|
|
const removedParentDirs = pruneEmptyMovieParents(parentDir);
|
|
summary.movie.deleted = true;
|
|
summary.movie.filesDeleted = 1;
|
|
summary.movie.dirsRemoved = Number(removedParentDirs.length || 0);
|
|
movieDeletedPathForTracking = outputPath;
|
|
movieCandidatePathForTracking = outputPath;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove deleted movie paths from output folders tracking
|
|
if (summary.movie?.deleted) {
|
|
const trackingCleanupPaths = Array.from(new Set([
|
|
movieCandidatePathForTracking,
|
|
movieDeletedPathForTracking
|
|
].filter(Boolean)));
|
|
for (const candidatePath of trackingCleanupPaths) {
|
|
await this.removeJobOutputFolder(jobId, candidatePath);
|
|
}
|
|
}
|
|
|
|
await this.appendLog(
|
|
jobId,
|
|
'USER_ACTION',
|
|
`Dateien gelöscht (${target}) - raw=${JSON.stringify(summary.raw)} movie=${JSON.stringify(summary.movie)}`
|
|
);
|
|
logger.info('job:delete-files', { jobId, summary });
|
|
|
|
const [updated, enrichSettings] = await Promise.all([
|
|
this.getJobById(jobId),
|
|
settingsService.getSettingsMap()
|
|
]);
|
|
return {
|
|
summary,
|
|
job: enrichJobRow(updated, enrichSettings)
|
|
};
|
|
}
|
|
|
|
async addJobOutputFolder(jobId, outputPath, label = null) {
|
|
const normalizedJobId = Number(jobId);
|
|
const normalizedPath = String(outputPath || '').trim();
|
|
if (!normalizedJobId || !normalizedPath) return null;
|
|
const db = await getDb();
|
|
await db.run(
|
|
'INSERT OR IGNORE INTO job_output_folders (job_id, output_path, label) VALUES (?, ?, ?)',
|
|
[normalizedJobId, normalizedPath, label || null]
|
|
);
|
|
}
|
|
|
|
async getJobOutputFolders(jobId) {
|
|
const normalizedJobId = Number(jobId);
|
|
if (!normalizedJobId) return [];
|
|
const db = await getDb();
|
|
const rows = await db.all(
|
|
'SELECT id, job_id, output_path, label, created_at FROM job_output_folders WHERE job_id = ? ORDER BY id ASC',
|
|
[normalizedJobId]
|
|
);
|
|
return rows || [];
|
|
}
|
|
|
|
async listJobOutputFoldersByJobIds(jobIds = []) {
|
|
const normalizedJobIds = Array.isArray(jobIds)
|
|
? jobIds
|
|
.map((value) => normalizeJobIdValue(value))
|
|
.filter(Boolean)
|
|
: [];
|
|
if (normalizedJobIds.length === 0) {
|
|
return new Map();
|
|
}
|
|
|
|
const db = await getDb();
|
|
const placeholders = normalizedJobIds.map(() => '?').join(', ');
|
|
const rows = await db.all(
|
|
`
|
|
SELECT id, job_id, output_path, label, created_at
|
|
FROM job_output_folders
|
|
WHERE job_id IN (${placeholders})
|
|
ORDER BY id ASC
|
|
`,
|
|
normalizedJobIds
|
|
);
|
|
|
|
const byJobId = new Map();
|
|
for (const row of (Array.isArray(rows) ? rows : [])) {
|
|
const ownerJobId = normalizeJobIdValue(row?.job_id);
|
|
if (!ownerJobId) {
|
|
continue;
|
|
}
|
|
if (!byJobId.has(ownerJobId)) {
|
|
byJobId.set(ownerJobId, []);
|
|
}
|
|
byJobId.get(ownerJobId).push({
|
|
id: normalizeJobIdValue(row?.id),
|
|
job_id: ownerJobId,
|
|
output_path: String(row?.output_path || '').trim() || null,
|
|
label: String(row?.label || '').trim() || null,
|
|
created_at: String(row?.created_at || '').trim() || null
|
|
});
|
|
}
|
|
|
|
return byJobId;
|
|
}
|
|
|
|
async getJobOutputFoldersForLineage(jobId, options = {}) {
|
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
|
if (!normalizedJobId) return [];
|
|
const includeMissing = Boolean(options?.includeMissing);
|
|
|
|
const relatedJobs = await this._resolveRelatedJobsForDeletion(normalizedJobId, {
|
|
includeRelated: true,
|
|
includeLogLinks: false
|
|
});
|
|
const relatedJobIds = Array.from(new Set(
|
|
relatedJobs
|
|
.map((row) => normalizeJobIdValue(row?.id))
|
|
.filter(Boolean)
|
|
));
|
|
if (relatedJobIds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const db = await getDb();
|
|
const placeholders = relatedJobIds.map(() => '?').join(', ');
|
|
const trackedRows = await db.all(
|
|
`
|
|
SELECT id, job_id, output_path, label, created_at
|
|
FROM job_output_folders
|
|
WHERE job_id IN (${placeholders})
|
|
ORDER BY id ASC
|
|
`,
|
|
relatedJobIds
|
|
);
|
|
const artifactsByJobId = await this.listJobLineageArtifactsByJobIds(relatedJobIds);
|
|
|
|
const merged = [];
|
|
const seen = new Set();
|
|
const addFolder = (rawFolder, defaults = {}) => {
|
|
const outputPathRaw = String(rawFolder?.output_path || rawFolder?.outputPath || '').trim();
|
|
if (!outputPathRaw) {
|
|
return;
|
|
}
|
|
const normalizedRawPath = normalizeComparablePath(outputPathRaw);
|
|
if (!normalizedRawPath) {
|
|
return;
|
|
}
|
|
let exists = false;
|
|
let resolvedOutputPath = normalizedRawPath;
|
|
try {
|
|
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;
|
|
}
|
|
seen.add(normalized);
|
|
merged.push({
|
|
id: normalizeJobIdValue(rawFolder?.id),
|
|
job_id: normalizeJobIdValue(rawFolder?.job_id ?? rawFolder?.jobId ?? defaults.job_id ?? normalizedJobId),
|
|
output_path: resolvedOutputPath,
|
|
label: String(rawFolder?.label || defaults.label || '').trim() || null,
|
|
created_at: String(rawFolder?.created_at || rawFolder?.createdAt || '').trim() || null,
|
|
exists
|
|
});
|
|
};
|
|
|
|
for (const row of (Array.isArray(trackedRows) ? trackedRows : [])) {
|
|
addFolder(row);
|
|
}
|
|
for (const relatedJob of relatedJobs) {
|
|
addFolder({
|
|
output_path: relatedJob?.output_path || null,
|
|
job_id: relatedJob?.id || null
|
|
});
|
|
const artifacts = artifactsByJobId.get(normalizeJobIdValue(relatedJob?.id)) || [];
|
|
for (const artifact of artifacts) {
|
|
addFolder({
|
|
output_path: artifact?.outputPath || null,
|
|
job_id: relatedJob?.id || null,
|
|
label: 'Lineage-Output',
|
|
created_at: artifact?.createdAt || null
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fallback for legacy runs before output-folder tracking:
|
|
// detect numbered sibling directories on filesystem
|
|
// /path/Album (1995), /path/Album (1995)_2, /path/Album (1995)_3, ...
|
|
const seedPaths = merged
|
|
.filter((row) => Boolean(row?.exists))
|
|
.map((row) => String(row?.output_path || '').trim())
|
|
.filter(Boolean);
|
|
for (const seedPath of seedPaths) {
|
|
const siblings = inferSiblingOutputFolders(seedPath);
|
|
for (const siblingPath of siblings) {
|
|
addFolder({
|
|
output_path: siblingPath,
|
|
job_id: normalizedJobId,
|
|
label: 'Auto-erkannt'
|
|
});
|
|
}
|
|
}
|
|
|
|
merged.sort((left, right) => compareOutputFolderPaths(left?.output_path, right?.output_path));
|
|
return merged;
|
|
}
|
|
|
|
async removeJobOutputFolder(jobId, outputPath) {
|
|
const normalizedJobId = Number(jobId);
|
|
const normalizedPath = normalizeComparablePath(outputPath);
|
|
if (!normalizedJobId || !normalizedPath) return;
|
|
const db = await getDb();
|
|
const rows = await db.all(
|
|
'SELECT id, output_path FROM job_output_folders WHERE job_id = ?',
|
|
[normalizedJobId]
|
|
);
|
|
for (const row of (Array.isArray(rows) ? rows : [])) {
|
|
const candidatePath = normalizeComparablePath(row?.output_path);
|
|
if (!candidatePath || candidatePath !== normalizedPath) {
|
|
continue;
|
|
}
|
|
await db.run('DELETE FROM job_output_folders WHERE id = ?', [Number(row.id)]);
|
|
}
|
|
}
|
|
|
|
async removeJobOutputFolderFromJobs(jobIds = [], outputPath) {
|
|
const normalizedPath = normalizeComparablePath(outputPath);
|
|
const normalizedJobIds = Array.isArray(jobIds)
|
|
? jobIds
|
|
.map((value) => normalizeJobIdValue(value))
|
|
.filter(Boolean)
|
|
: [];
|
|
if (!normalizedPath || normalizedJobIds.length === 0) return;
|
|
const db = await getDb();
|
|
const placeholders = normalizedJobIds.map(() => '?').join(', ');
|
|
const rows = await db.all(
|
|
`SELECT id, output_path FROM job_output_folders WHERE job_id IN (${placeholders})`,
|
|
normalizedJobIds
|
|
);
|
|
for (const row of (Array.isArray(rows) ? rows : [])) {
|
|
const candidatePath = normalizeComparablePath(row?.output_path);
|
|
if (!candidatePath || candidatePath !== normalizedPath) {
|
|
continue;
|
|
}
|
|
await db.run('DELETE FROM job_output_folders WHERE id = ?', [Number(row.id)]);
|
|
}
|
|
}
|
|
|
|
async removeMissingJobOutputFoldersFromJobs(jobIds = []) {
|
|
const normalizedJobIds = Array.isArray(jobIds)
|
|
? jobIds
|
|
.map((value) => normalizeJobIdValue(value))
|
|
.filter(Boolean)
|
|
: [];
|
|
if (normalizedJobIds.length === 0) {
|
|
return { removed: 0, removedPaths: [] };
|
|
}
|
|
const db = await getDb();
|
|
const placeholders = normalizedJobIds.map(() => '?').join(', ');
|
|
const rows = await db.all(
|
|
`SELECT id, output_path FROM job_output_folders WHERE job_id IN (${placeholders})`,
|
|
normalizedJobIds
|
|
);
|
|
const removedPaths = [];
|
|
for (const row of (Array.isArray(rows) ? rows : [])) {
|
|
const normalizedPath = normalizeComparablePath(row?.output_path);
|
|
let removeEntry = false;
|
|
if (!normalizedPath) {
|
|
removeEntry = true;
|
|
} else if (!fs.existsSync(normalizedPath)) {
|
|
removeEntry = true;
|
|
} else {
|
|
try {
|
|
const stat = fs.lstatSync(normalizedPath);
|
|
if (stat.isDirectory()) {
|
|
const entries = fs.readdirSync(normalizedPath);
|
|
if (entries.length === 0) {
|
|
fs.rmdirSync(normalizedPath);
|
|
removeEntry = true;
|
|
}
|
|
}
|
|
} catch (_error) {
|
|
removeEntry = true;
|
|
}
|
|
}
|
|
if (!removeEntry) {
|
|
continue;
|
|
}
|
|
await db.run('DELETE FROM job_output_folders WHERE id = ?', [Number(row.id)]);
|
|
if (normalizedPath) {
|
|
removedPaths.push(normalizedPath);
|
|
}
|
|
}
|
|
return {
|
|
removed: removedPaths.length,
|
|
removedPaths: Array.from(new Set(removedPaths)).sort((left, right) => left.localeCompare(right, 'de'))
|
|
};
|
|
}
|
|
|
|
async deleteSpecificOutputFolders(jobId, folderPaths = []) {
|
|
const paths = Array.isArray(folderPaths) ? folderPaths.filter((p) => typeof p === 'string' && p.trim()) : [];
|
|
if (paths.length === 0) return { deleted: [], failed: [] };
|
|
|
|
const job = await this.getJobById(jobId);
|
|
if (!job) {
|
|
const error = new Error('Job nicht gefunden.');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
|
|
const settings = await settingsService.getSettingsMap();
|
|
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
|
|
const effectiveMovieDir = resolvedPaths.movieDir;
|
|
if (!effectiveMovieDir) {
|
|
const error = new Error('Kein gültiger Movie-Basispfad konfiguriert.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const deleted = [];
|
|
const failed = [];
|
|
const relatedJobs = await this._resolveRelatedJobsForDeletion(jobId, {
|
|
includeRelated: true,
|
|
includeLogLinks: false
|
|
});
|
|
const relatedJobIds = Array.from(new Set(
|
|
relatedJobs
|
|
.map((row) => normalizeJobIdValue(row?.id))
|
|
.filter(Boolean)
|
|
));
|
|
const trackedOutputFolders = await this.getJobOutputFoldersForLineage(jobId, { includeMissing: true });
|
|
const trackedOutputPathSet = new Set(
|
|
trackedOutputFolders
|
|
.map((folder) => normalizeComparablePath(folder?.output_path))
|
|
.filter(Boolean)
|
|
);
|
|
|
|
for (const folderPath of paths) {
|
|
const normalizedFolderPath = normalizeComparablePath(folderPath);
|
|
const isInsideEffectiveRoot = isPathInside(effectiveMovieDir, normalizedFolderPath);
|
|
const isTrackedPath = trackedOutputPathSet.has(normalizedFolderPath);
|
|
if (!isInsideEffectiveRoot && !isTrackedPath) {
|
|
failed.push({ path: folderPath, reason: 'Pfad liegt außerhalb des Output-Verzeichnisses.' });
|
|
continue;
|
|
}
|
|
if (!fs.existsSync(normalizedFolderPath)) {
|
|
await this.removeJobOutputFolderFromJobs(relatedJobIds, normalizedFolderPath);
|
|
deleted.push(normalizedFolderPath);
|
|
continue;
|
|
}
|
|
try {
|
|
const stat = fs.lstatSync(normalizedFolderPath);
|
|
const movieRoot = normalizeComparablePath(effectiveMovieDir);
|
|
if (stat.isDirectory()) {
|
|
const keepRoot = normalizedFolderPath === movieRoot;
|
|
deleteFilesRecursively(normalizedFolderPath, keepRoot);
|
|
if (!keepRoot) {
|
|
removeEmptyParentDirectories(path.dirname(normalizedFolderPath), {
|
|
protectedRoots: [movieRoot]
|
|
});
|
|
}
|
|
} else {
|
|
const parentDir = normalizeComparablePath(path.dirname(normalizedFolderPath));
|
|
const canDeleteParent = parentDir && parentDir !== movieRoot
|
|
&& isPathInside(movieRoot, parentDir)
|
|
&& fs.existsSync(parentDir)
|
|
&& fs.lstatSync(parentDir).isDirectory();
|
|
if (canDeleteParent) {
|
|
deleteFilesRecursively(parentDir, false);
|
|
removeEmptyParentDirectories(path.dirname(parentDir), {
|
|
protectedRoots: [movieRoot]
|
|
});
|
|
} else {
|
|
fs.unlinkSync(normalizedFolderPath);
|
|
if (parentDir) {
|
|
removeEmptyParentDirectories(parentDir, {
|
|
protectedRoots: [movieRoot]
|
|
});
|
|
}
|
|
}
|
|
}
|
|
await this.removeJobOutputFolderFromJobs(relatedJobIds, normalizedFolderPath);
|
|
deleted.push(normalizedFolderPath);
|
|
} catch (error) {
|
|
failed.push({ path: folderPath, reason: error.message });
|
|
}
|
|
}
|
|
|
|
await this.appendLog(
|
|
jobId,
|
|
'USER_ACTION',
|
|
`Output-Ordner gelöscht: ${deleted.length > 0 ? deleted.join(', ') : 'keine'}${failed.length > 0 ? ` | Fehler: ${failed.map((f) => f.path).join(', ')}` : ''}`
|
|
);
|
|
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)) {
|
|
const error = new Error(`Ungültiges target '${fileTarget}'. Erlaubt: none, raw, movie, both.`);
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs);
|
|
const isOrphanRawImportJobRow = (row) => {
|
|
if (!row || typeof row !== 'object') {
|
|
return false;
|
|
}
|
|
const mkInfo = parseJsonSafe(row?.makemkv_info_json, {});
|
|
return isOrphanRawImportMakemkvInfo(mkInfo);
|
|
};
|
|
const resolveEffectiveFileTarget = (requestedTarget, row) => {
|
|
if (!preserveRawForImportJobs || !isOrphanRawImportJobRow(row)) {
|
|
return requestedTarget;
|
|
}
|
|
const normalizedRequested = String(requestedTarget || 'none').trim().toLowerCase();
|
|
if (normalizedRequested === 'raw') {
|
|
return 'none';
|
|
}
|
|
if (normalizedRequested === 'both') {
|
|
return 'movie';
|
|
}
|
|
return normalizedRequested;
|
|
};
|
|
const collectIncompleteMoviePathsFromPreview = (preview) => {
|
|
const rows = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [];
|
|
const incompleteJobPattern = /(^|[\\/])incomplete_job-\d+([\\/]|$)/i;
|
|
const incompleteMergePattern = /(^|[\\/])incomplete_merge_[^\\/]+_job_\d+([\\/]|$)/i;
|
|
return rows
|
|
.filter((row) => Boolean(row?.exists))
|
|
.filter((row) => {
|
|
const candidatePath = String(row?.path || '').trim();
|
|
if (!candidatePath) {
|
|
return false;
|
|
}
|
|
if (incompleteJobPattern.test(candidatePath)) {
|
|
return true;
|
|
}
|
|
if (incompleteMergePattern.test(candidatePath)) {
|
|
// Shared multipart merge folders must never be auto-selected as a whole directory.
|
|
return Boolean(row?.isFile);
|
|
}
|
|
return false;
|
|
})
|
|
.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 : [])
|
|
.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 normalizedSelectedRawPaths = Array.isArray(options?.selectedRawPaths)
|
|
? options.selectedRawPaths
|
|
.map((item) => String(item || '').trim())
|
|
.filter(Boolean)
|
|
: null;
|
|
const normalizedSelectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
|
|
? options.selectedMoviePaths
|
|
.map((item) => String(item || '').trim())
|
|
.filter(Boolean)
|
|
: null;
|
|
const normalizedSelectedJobIds = Array.isArray(options?.selectedJobIds)
|
|
? options.selectedJobIds
|
|
.map((item) => normalizeJobIdValue(item))
|
|
.filter(Boolean)
|
|
: null;
|
|
|
|
const includeRelated = Boolean(options?.includeRelated);
|
|
if (!includeRelated) {
|
|
const resolved = await this.getJobByIdOrReplacement(jobId);
|
|
const existing = resolved?.job || null;
|
|
const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId);
|
|
if (!existing) {
|
|
const error = new Error('Job nicht gefunden.');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
if (!normalizedJobId) {
|
|
const error = new Error('Ungültige Job-ID.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
if (isDiskContainerRow(existing)) {
|
|
const containerChildren = await this.listChildJobs(normalizedJobId);
|
|
if (Array.isArray(containerChildren) && containerChildren.length > 0) {
|
|
const error = new Error(
|
|
'Container kann nicht einzeln gelöscht werden, solange noch Child-Jobs vorhanden sind.'
|
|
);
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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;
|
|
let preview = null;
|
|
let fileSummary = null;
|
|
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 = effectiveFileTarget === 'raw' ? 'both' : 'movie';
|
|
if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) {
|
|
selectedMoviePathsForDelete = autoIncompleteMoviePaths;
|
|
}
|
|
}
|
|
}
|
|
if (effectiveFileTarget !== 'none') {
|
|
if (!preview) {
|
|
preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
|
|
}
|
|
fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
|
|
selectedRawPaths: selectedRawPathsForDelete,
|
|
selectedMoviePaths: selectedMoviePathsForDelete
|
|
});
|
|
if (fileSummary?.movie?.deleted) {
|
|
await this.removeMissingJobOutputFoldersFromJobs([normalizedJobId]);
|
|
}
|
|
}
|
|
|
|
const db = await getDb();
|
|
const pipelineRow = await db.get(
|
|
'SELECT state, active_job_id FROM pipeline_state WHERE id = 1'
|
|
);
|
|
|
|
const isActivePipelineJob = Number(pipelineRow?.active_job_id || 0) === Number(normalizedJobId);
|
|
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.');
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
|
|
await db.exec('BEGIN');
|
|
try {
|
|
if (isActivePipelineJob) {
|
|
await db.run(
|
|
`
|
|
UPDATE pipeline_state
|
|
SET
|
|
state = 'IDLE',
|
|
active_job_id = NULL,
|
|
progress = 0,
|
|
eta = NULL,
|
|
status_text = 'Bereit',
|
|
context_json = '{}',
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = 1
|
|
`
|
|
);
|
|
} else {
|
|
await db.run(
|
|
`
|
|
UPDATE pipeline_state
|
|
SET
|
|
active_job_id = NULL,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = 1 AND active_job_id = ?
|
|
`,
|
|
[normalizedJobId]
|
|
);
|
|
}
|
|
|
|
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');
|
|
throw error;
|
|
}
|
|
|
|
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]);
|
|
}
|
|
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,
|
|
fileTarget: effectiveFileTarget,
|
|
requestedFileTarget: fileTarget,
|
|
includeRelated: false,
|
|
pipelineStateReset: isActivePipelineJob,
|
|
incompleteCleanup: incompleteCleanupSummary,
|
|
emptyDirectoryCleanup,
|
|
filesDeleted: fileSummary
|
|
? {
|
|
raw: fileSummary.raw?.filesDeleted ?? 0,
|
|
movie: fileSummary.movie?.filesDeleted ?? 0
|
|
}
|
|
: { raw: 0, movie: 0 }
|
|
});
|
|
|
|
return {
|
|
deleted: true,
|
|
jobId: normalizedJobId,
|
|
fileTarget: effectiveFileTarget,
|
|
requestedFileTarget: fileTarget,
|
|
includeRelated: false,
|
|
deletedJobIds: [Number(normalizedJobId)],
|
|
fileSummary,
|
|
incompleteCleanup: incompleteCleanupSummary,
|
|
emptyDirectoryCleanup
|
|
};
|
|
}
|
|
|
|
const resolved = await this.getJobByIdOrReplacement(jobId);
|
|
const existing = resolved?.job || null;
|
|
const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId);
|
|
const preview = await this.getJobDeletePreview(normalizedJobId, {
|
|
includeRelated: true,
|
|
selectedJobIds: normalizedSelectedJobIds
|
|
});
|
|
let deleteJobIds = Array.isArray(preview?.selectedJobIds)
|
|
? preview.selectedJobIds
|
|
.map((row) => normalizeJobIdValue(row))
|
|
.filter(Boolean)
|
|
: [];
|
|
let rowById = new Map();
|
|
const hasExplicitSelectedJobFilter = Array.isArray(normalizedSelectedJobIds);
|
|
if (!hasExplicitSelectedJobFilter && 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 || !isDiskContainerRow(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;
|
|
throw error;
|
|
}
|
|
|
|
const db = await getDb();
|
|
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', '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 = effectiveFileTarget === 'raw' ? 'both' : 'movie';
|
|
if (!selectedMoviePathsForDelete || selectedMoviePathsForDelete.length === 0) {
|
|
selectedMoviePathsForDelete = autoIncompleteMoviePaths;
|
|
}
|
|
}
|
|
}
|
|
let fileSummary = null;
|
|
if (effectiveFileTarget !== 'none') {
|
|
fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
|
|
selectedRawPaths: selectedRawPathsForDelete,
|
|
selectedMoviePaths: selectedMoviePathsForDelete
|
|
});
|
|
if (fileSummary?.movie?.deleted) {
|
|
await this.removeMissingJobOutputFoldersFromJobs(deleteJobIds);
|
|
}
|
|
}
|
|
|
|
await db.exec('BEGIN');
|
|
try {
|
|
if (activeJobIncluded) {
|
|
await db.run(
|
|
`
|
|
UPDATE pipeline_state
|
|
SET
|
|
state = 'IDLE',
|
|
active_job_id = NULL,
|
|
progress = 0,
|
|
eta = NULL,
|
|
status_text = 'Bereit',
|
|
context_json = '{}',
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = 1
|
|
`
|
|
);
|
|
} else {
|
|
const placeholders = deleteJobIds.map(() => '?').join(', ');
|
|
await db.run(
|
|
`
|
|
UPDATE pipeline_state
|
|
SET
|
|
active_job_id = NULL,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = 1 AND active_job_id IN (${placeholders})
|
|
`,
|
|
deleteJobIds
|
|
);
|
|
}
|
|
|
|
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');
|
|
throw error;
|
|
}
|
|
|
|
for (const deletedJobId of deleteJobIds) {
|
|
await this.closeProcessLog(deletedJobId);
|
|
this._deleteProcessLogFile(deletedJobId);
|
|
thumbnailService.deleteThumbnail(deletedJobId);
|
|
}
|
|
const includeMovieCleanup = effectiveFileTarget === 'movie' || effectiveFileTarget === 'both';
|
|
let incompleteCleanupSummary = null;
|
|
if (includeMovieCleanup) {
|
|
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)
|
|
? preview.relatedJobs.filter((row) => deletedJobIdSet.has(normalizeJobIdValue(row?.id)))
|
|
: [];
|
|
|
|
logger.warn('job:deleted', {
|
|
jobId: normalizedJobId,
|
|
fileTarget: effectiveFileTarget,
|
|
requestedFileTarget: fileTarget,
|
|
includeRelated: true,
|
|
deletedJobIds: deleteJobIds,
|
|
deletedJobCount: deleteJobIds.length,
|
|
pipelineStateReset: activeJobIncluded,
|
|
incompleteCleanup: incompleteCleanupSummary,
|
|
emptyDirectoryCleanup,
|
|
filesDeleted: fileSummary
|
|
? {
|
|
raw: fileSummary.raw?.filesDeleted ?? 0,
|
|
movie: fileSummary.movie?.filesDeleted ?? 0
|
|
}
|
|
: { raw: 0, movie: 0 }
|
|
});
|
|
|
|
return {
|
|
deleted: true,
|
|
jobId: normalizedJobId,
|
|
fileTarget: effectiveFileTarget,
|
|
requestedFileTarget: fileTarget,
|
|
includeRelated: true,
|
|
deletedJobIds: deleteJobIds,
|
|
deletedJobs,
|
|
fileSummary,
|
|
incompleteCleanup: incompleteCleanupSummary,
|
|
emptyDirectoryCleanup
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = new HistoryService();
|