const { getDb } = require('../db/database'); const logger = require('./logger').child('HISTORY'); const fs = require('fs'); const path = require('path'); const settingsService = require('./settingsService'); const omdbService = require('./omdbService'); 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; 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 parseInfoFromValue(value, fallback = null) { if (!value) { return fallback; } if (typeof value === 'object') { return value; } return parseJsonSafe(value, fallback); } 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 detectOrphanMediaType(rawPath) { if (hasBlurayStructure(rawPath)) { return 'bluray'; } if (hasDvdStructure(rawPath)) { return 'dvd'; } if (hasCdStructure(rawPath)) { return 'cd'; } if (hasAudiobookStructure(rawPath)) { return 'audiobook'; } 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') { 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'; } 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 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 folderName = path.basename(stored); if (!folderName) return stored; 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); if (rawDir) { pushCandidate(path.join(String(rawDir).trim(), folderName)); } for (const extraDir of Array.isArray(extraDirs) ? extraDirs : []) { pushCandidate(path.join(String(extraDir || '').trim(), folderName)); } 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(), folderName) : 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 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); // 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 rawDir = String(effectiveSettings?.raw_dir || '').trim(); const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim(); const movieDir = configuredMovieDir || rawDir; const rawLookupDirs = getConfiguredMediaPathList(settings || {}, 'raw_dir') .filter((candidate) => normalizeComparablePath(candidate) !== normalizeComparablePath(rawDir)); const effectiveRawPath = job?.raw_path ? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs) : (job?.raw_path || null); const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path) ? 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 omdbInfo = parseJsonSafe(job.omdb_json, null); 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 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 encodeSuccess = directoryLikeOutput ? finishedWithOutput : (mediaType === 'converter' ? finishedWithOutput : String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'); return { ...job, raw_path: resolvedPaths.effectiveRawPath, output_path: resolvedPaths.effectiveOutputPath, makemkvInfo, handbrakeInfo, mediainfoInfo, omdbInfo, encodePlan, mediaType, ripSuccessful, backupSuccess, encodeSuccess, rawStatus, outputStatus, movieDirStatus }; } 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 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 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 normalizeJobIdValue(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } 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 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); } 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); this._deleteProcessLogFile(fromJobId); logger.warn('job:retired', { sourceJobId: fromJobId, replacementJobId: toJobId, reason, sourceWasActive: sourceIsActive }); return { retired: true, sourceJobId: fromJobId, replacementJobId: toJobId, reason }; } 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 existingInfo = options.existingInfo && typeof options.existingInfo === 'object' ? options.existingInfo : {}; const recovery = options.recovery && typeof options.recovery === 'object' ? options.recovery : null; 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: { ...(existingInfo.analyzeContext && typeof existingInfo.analyzeContext === 'object' ? existingInfo.analyzeContext : {}), mediaProfile }, importContext: nextImportContext }; if (mediaProfile === 'cd' && recovery) { if (Array.isArray(recovery.tracks) && recovery.tracks.length > 0) { nextInfo.tracks = recovery.tracks; } if (recovery.selectedMetadata && typeof recovery.selectedMetadata === 'object') { const recoverySelectedMetadata = Object.fromEntries( Object.entries(recovery.selectedMetadata).filter(([, value]) => { if (value === null || value === undefined) { return false; } if (typeof value === 'string') { return value.trim().length > 0; } return true; }) ); nextInfo.selectedMetadata = { ...(existingInfo.selectedMetadata && typeof existingInfo.selectedMetadata === 'object' ? existingInfo.selectedMetadata : {}), ...recoverySelectedMetadata }; } 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 = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' ? makemkvInfo.selectedMetadata : {}; const rowMediaProfile = normalizeMediaTypeValue( makemkvInfo?.mediaProfile || makemkvInfo?.analyzeContext?.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?.omdb_json || row?.imdb_id || 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, 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?.omdb_json && sourceJob?.omdb_json) { patch.omdb_json = sourceJob.omdb_json; patch.selected_from_omdb = Number(sourceJob.selected_from_omdb || 0); } 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 getJobById(jobId) { const db = await getDb(); return db.get('SELECT * FROM jobs WHERE id = ?', [jobId]); } async getJobs(filters = {}) { const db = await getDb(); const where = []; const values = []; const includeFsChecks = filters?.includeFsChecks !== false; 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}%`); } 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() ]); return jobs.map((job) => ({ ...enrichJobRow(job, settings, { includeFsChecks }), log_count: includeFsChecks ? (hasProcessLogFile(job.id) ? 1 : 0) : 0 })); } 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])); return ids .map((id) => byId.get(id)) .filter(Boolean) .map((job) => ({ ...enrichJobRow(job, settings), log_count: hasProcessLogFile(job.id) ? 1 : 0 })); } 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', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', '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 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' ) 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 job = await this.repairImportedCdJobArtifacts(loadedJob, settings); 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 outputFolders = await this.getJobOutputFoldersForLineage(jobId); if (!shouldLoadLogs) { return { ...enrichJobRow(job, settings, { includeFsChecks }), outputFolders, log_count: baseLogCount, 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, logs: [], log: processLog.lines.join('\n'), logMeta: { loaded: true, total: includeAllLogs ? processLog.total : processLog.returned, returned: processLog.returned, truncated: processLog.truncated } }; } 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 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; } 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 = getConfiguredMediaPathList(settings, 'raw_dir'); if (rawDirs.length === 0) { const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,cd,audiobook,other}).'); error.statusCode = 400; throw error; } const db = await getDb(); const linkedRows = await 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) <> '' ` ); const linkedPathMap = new Map(); 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) { if (!linkedPathMap.has(linkedPath)) { linkedPathMap.set(linkedPath, []); } linkedPathMap.get(linkedPath).push({ id: row.id, status: row.status }); } } 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; } 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); const detectedMediaType = detectOrphanMediaType(rawPath); 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 importOrphanRawFolder(rawPath) { const settings = await settingsService.getSettingsMap(); const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir'); 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 konfiguriert (raw_dir oder raw_dir_{bluray,dvd,cd,audiobook,other}).'); 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); let omdbById = null; if (metadata.imdbId) { try { omdbById = await omdbService.fetchByImdbId(metadata.imdbId); } catch (error) { logger.warn('job:import-orphan-raw:omdb-fetch-failed', { rawPath: absRawPath, imdbId: metadata.imdbId, message: error.message }); } } const effectiveTitle = omdbById?.title || metadata.title || folderName; const 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 detectedMediaType = detectOrphanMediaType(finalRawPath); const initialSourceJobContext = await this.resolveOrphanImportSourceJob({ candidateJobIds: [metadata.folderJobId], mediaProfile: detectedMediaType }); const initialSourceJob = initialSourceJobContext?.job || null; const initialSourceSelectedMetadata = initialSourceJobContext?.selectedMetadata && typeof initialSourceJobContext.selectedMetadata === 'object' ? initialSourceJobContext.selectedMetadata : {}; const orphanPosterUrl = omdbById?.poster || null; const cdRecovery = detectedMediaType === '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: detectedMediaType }) || initialSourceJobContext; const sourceJob = sourceJobContext?.job || null; const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object' ? sourceJobContext.selectedMetadata : {}; const sourcePosterCandidate = this.getPreferredPosterCandidateForImport(sourceJobContext, null); const recoveredCdEncodePlan = cdRecovery ? this.buildRecoveredCdEncodePlan(cdRecovery) : null; const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({ importedAt, rawPath: finalRawPath, requestedRawPath: absRawPath, sourceFolderJobId: metadata.folderJobId || null, mediaProfile: detectedMediaType, existingInfo: sourceJobContext?.makemkvInfo || null, recovery: cdRecovery }); const recoveredPosterUrl = orphanPosterUrl || (thumbnailService.isLocalUrl(sourcePosterCandidate) ? null : sourcePosterCandidate) || null; const recoveredExternalId = String( omdbById?.imdbId || metadata.imdbId || sourceSelectedMetadata?.mbId || sourceSelectedMetadata?.musicBrainzId || sourceSelectedMetadata?.musicbrainzId || sourceSelectedMetadata?.musicbrainz_id || sourceSelectedMetadata?.music_brainz_id || sourceSelectedMetadata?.musicbrainz || sourceSelectedMetadata?.mbid || sourceJob?.imdb_id || '' ).trim() || null; await this.updateJob(created.id, { ...(detectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}), ...(detectedMediaType === 'cd' ? { job_kind: 'cd' } : {}), ...(detectedMediaType === 'dvd' ? { job_kind: 'dvd' } : {}), ...(detectedMediaType === 'bluray' ? { job_kind: 'bluray' } : {}), status: 'FINISHED', last_state: 'FINISHED', title: omdbById?.title || sourceSelectedMetadata?.title || sourceSelectedMetadata?.album || sourceJob?.title || metadata.title || cdRecovery?.selectedMetadata?.title || null, year: Number.isFinite(Number(omdbById?.year)) ? Number(omdbById.year) : ( sourceSelectedMetadata?.year || sourceJob?.year || metadata.year || cdRecovery?.selectedMetadata?.year || null ), imdb_id: recoveredExternalId, poster_url: recoveredPosterUrl, omdb_json: omdbById?.raw ? JSON.stringify(omdbById.raw) : (sourceJob?.omdb_json || null), selected_from_omdb: omdbById ? 1 : Number(sourceJob?.selected_from_omdb || 0), rip_successful: 1, raw_path: finalRawPath, output_path: cdRecovery?.outputPath || null, handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: recoveredCdEncodePlan ? JSON.stringify(recoveredCdEncodePlan) : 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 }); } // Bild direkt persistieren (kein Rip-Prozess, daher kein Cache-Zwischenschritt) if (orphanPosterUrl || sourcePosterCandidate) { this.restoreImportedPoster(created.id, sourceJobContext, orphanPosterUrl || sourcePosterCandidate).catch(() => {}); } if (!cdRecovery?.logSources?.length) { await this.appendLog( created.id, 'SYSTEM', `Orphan-RAW-Import Zusatzinfo: ${JSON.stringify(orphanImportInfo)}` ); } await this.appendLog( created.id, 'SYSTEM', renameSteps.length > 0 ? `Historieneintrag aus RAW erstellt (Medientyp: ${detectedMediaType}). Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}` : `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath} (Medientyp: ${detectedMediaType})` ); if (metadata.imdbId) { await this.appendLog( created.id, 'SYSTEM', omdbById ? `OMDb-Zuordnung via IMDb-ID übernommen: ${omdbById.imdbId} (${omdbById.title || '-'})` : `OMDb-Zuordnung via IMDb-ID fehlgeschlagen: ${metadata.imdbId}` ); } if (sourceJob) { await this.appendLog( created.id, 'SYSTEM', `Metadaten aus vorhandenem Job #${sourceJob.id} wiederhergestellt.` ); } logger.info('job:import-orphan-raw', { jobId: created.id, rawPath: absRawPath, detectedMediaType }); const imported = await this.getJobById(created.id); return enrichJobRow(imported, settings); } async assignOmdbMetadata(jobId, payload = {}) { const job = await this.getJobById(jobId); if (!job) { const error = new Error('Job nicht gefunden.'); error.statusCode = 404; throw error; } const imdbIdInput = String(payload.imdbId || '').trim().toLowerCase(); let omdb = null; if (imdbIdInput) { try { omdb = await omdbService.fetchByImdbId(imdbIdInput); } catch (omdbErr) { logger.warn('assignOmdbMetadata:fetch-failed', { jobId, imdbId: imdbIdInput, message: omdbErr.message }); } } const manualTitle = String(payload.title || '').trim(); const manualYearRaw = Number(payload.year); const manualYear = Number.isFinite(manualYearRaw) ? Math.trunc(manualYearRaw) : null; const manualPoster = String(payload.poster || '').trim() || null; const hasManual = manualTitle.length > 0 || manualYear !== null || imdbIdInput.length > 0; if (!omdb && !hasManual) { const error = new Error('Keine OMDb-/Metadaten zum Aktualisieren angegeben.'); error.statusCode = 400; throw error; } const title = omdb?.title || manualTitle || job.title || job.detected_title || null; const year = Number.isFinite(Number(omdb?.year)) ? Number(omdb.year) : (manualYear !== null ? manualYear : (job.year ?? null)); const imdbId = omdb?.imdbId || imdbIdInput || job.imdb_id || null; const posterUrl = omdb?.poster || manualPoster || job.poster_url || null; const selectedFromOmdb = omdb ? 1 : Number(payload.fromOmdb ? 1 : 0); await this.updateJob(jobId, { title, year, imdb_id: imdbId, poster_url: posterUrl, omdb_json: omdb?.raw ? JSON.stringify(omdb.raw) : (job.omdb_json || null), selected_from_omdb: selectedFromOmdb }); // Bild herunterladen, in persistenten Ordner verschieben und poster_url aktualisieren if (posterUrl && !thumbnailService.isLocalUrl(posterUrl)) { this.queuePosterCache(jobId, posterUrl, { source: 'OMDb Poster', logFailures: true }); } await this.appendLog( jobId, 'USER_ACTION', omdb ? `OMDb-Zuordnung aktualisiert: ${omdb.imdbId} (${omdb.title || '-'})` : `Metadaten manuell aktualisiert: title="${title || '-'}", year="${year || '-'}", imdb="${imdbId || '-'}"` ); const [updated, settings] = await Promise.all([ this.getJobById(jobId), settingsService.getSettingsMap() ]); return enrichJobRow(updated, settings); } 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; const 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 }; }); } 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 (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 _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 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; } 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) { 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 } } } 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([ ...getConfiguredMediaPathList(settings || {}, 'raw_dir'), 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); const includeMovieParentCandidate = resolvedPaths?.mediaType !== 'converter'; for (const outputPath of explicitMoviePaths) { addCandidate( movieCandidates, 'movie', outputPath, artifactMoviePathSet.has(outputPath) ? 'lineage_output_path' : 'output_path', movieAllowedPaths ); const parentDir = toNormalizedPath(path.dirname(outputPath)); if (includeMovieParentCandidate && parentDir && !movieRoots.includes(parentDir)) { addCandidate( movieCandidates, 'movie', parentDir, artifactMoviePathSet.has(outputPath) ? 'lineage_output_parent' : 'output_parent', 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) => Array.from(candidateMap.values()) .filter((row) => row.target === target) .map((row) => { const inspection = inspectDeletionPath(row.path); const sources = Array.from(row.sources).sort((left, right) => left.localeCompare(right)); // Do not expose stale output file paths from DB/lineage in delete preview. // They cannot be deleted anyway and show up as "ghost paths" in UI/QA checks. if (target === 'movie' && !inspection.exists && hasTrackedOutputSource(sources)) { return null; } 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(Boolean) .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 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 settings = await settingsService.getSettingsMap(); const relatedJobIds = jobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean); const [lineageArtifactsByJobId, outputFoldersByJobId] = await Promise.all([ this.listJobLineageArtifactsByJobIds(relatedJobIds), this.listJobOutputFoldersByJobIds(relatedJobIds) ]); const preview = this._buildDeletePreviewFromJobs( jobs, settings, lineageArtifactsByJobId, outputFoldersByJobId ); const relatedJobs = jobs.map((job) => ({ 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, createdAt: String(job.created_at || '').trim() || null })); const existingRawCandidates = preview.pathCandidates.raw.filter((row) => row.exists).length; const existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length; return { jobId: normalizedJobId, includeRelated, relatedJobs, pathCandidates: preview.pathCandidates, protectedRoots: preview.protectedRoots, counts: { relatedJobs: relatedJobs.length, 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 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 }, 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 === 'movie' && selectedMoviePathFilter.size > 0) { candidates = candidates.filter((item) => { const candidatePath = String(item?.path || '').trim(); if (!candidatePath) { return false; } return selectedMoviePathFilter.has(normalizeComparablePath(candidatePath)); }); } if (candidates.length === 0) { summary[targetKey].reason = targetKey === 'movie' && selectedMoviePathFilter.size > 0 ? 'Keine ausgewählten Audio/Video-Ordner gefunden.' : '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) { 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 : [] }); } 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) }); } } async deleteJobFiles(jobId, target = 'both') { 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 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: ${effectiveRawPath}`); 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: ${effectiveOutputPath}`); error.statusCode = 400; throw error; } else if (!fs.existsSync(effectiveOutputPath)) { const movieRoot = normalizeComparablePath(effectiveMovieDir); 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); summary.movie.deleted = true; summary.movie.filesDeleted = result.filesDeleted; summary.movie.dirsRemoved = result.dirsRemoved; 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); summary.movie.deleted = true; summary.movie.filesDeleted = result.filesDeleted; summary.movie.dirsRemoved = result.dirsRemoved; movieDeletedPathForTracking = parentDir; movieCandidatePathForTracking = trackedPath; } else { fs.unlinkSync(trackedPath); summary.movie.deleted = true; summary.movie.filesDeleted = 1; summary.movie.dirsRemoved = 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 stat = fs.lstatSync(outputPath); if (stat.isDirectory()) { const keepRoot = outputPath === movieRoot; const result = deleteFilesRecursively(outputPath, keepRoot ? true : false); summary.movie.deleted = true; summary.movie.filesDeleted = result.filesDeleted; summary.movie.dirsRemoved = result.dirsRemoved; movieDeletedPathForTracking = outputPath; movieCandidatePathForTracking = outputPath; } else { const parentDir = normalizeComparablePath(path.dirname(outputPath)); // Converter jobs output a single file — never delete the parent dir 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); summary.movie.deleted = true; summary.movie.filesDeleted = result.filesDeleted; summary.movie.dirsRemoved = result.dirsRemoved; movieDeletedPathForTracking = parentDir; movieCandidatePathForTracking = outputPath; } else { fs.unlinkSync(outputPath); summary.movie.deleted = true; summary.movie.filesDeleted = 1; summary.movie.dirsRemoved = 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 outputPath = String(rawFolder?.output_path || rawFolder?.outputPath || '').trim(); if (!outputPath) { return; } const normalized = normalizeComparablePath(outputPath); if (!normalized || seen.has(normalized)) { return; } let exists = false; try { exists = fs.existsSync(normalized); } catch (_error) { exists = false; } 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: outputPath, 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 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); } 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); } else { fs.unlinkSync(normalizedFolderPath); } } 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 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 includeRelated = Boolean(options?.includeRelated); if (!includeRelated) { const existing = await this.getJobById(jobId); if (!existing) { const error = new Error('Job nicht gefunden.'); error.statusCode = 404; throw error; } let fileSummary = null; if (fileTarget !== 'none') { const preview = await this.getJobDeletePreview(jobId, { includeRelated: false }); fileSummary = this._deletePathsFromPreview(preview, fileTarget, { selectedMoviePaths: options?.selectedMoviePaths }); } 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(jobId); const runningStates = new Set(['ANALYZING', '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 = ? `, [jobId] ); } await db.run('DELETE FROM jobs WHERE id = ?', [jobId]); await db.exec('COMMIT'); } catch (error) { await db.exec('ROLLBACK'); throw error; } await this.closeProcessLog(jobId); this._deleteProcessLogFile(jobId); thumbnailService.deleteThumbnail(jobId); logger.warn('job:deleted', { jobId, fileTarget, includeRelated: false, pipelineStateReset: isActivePipelineJob, filesDeleted: fileSummary ? { raw: fileSummary.raw?.filesDeleted ?? 0, movie: fileSummary.movie?.filesDeleted ?? 0 } : { raw: 0, movie: 0 } }); return { deleted: true, jobId, fileTarget, includeRelated: false, deletedJobIds: [Number(jobId)], fileSummary }; } const normalizedJobId = normalizeJobIdValue(jobId); const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true }); const deleteJobIds = Array.isArray(preview?.relatedJobs) ? preview.relatedJobs .map((row) => normalizeJobIdValue(row?.id)) .filter(Boolean) : []; 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', '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; } let fileSummary = null; if (fileTarget !== 'none') { fileSummary = this._deletePathsFromPreview(preview, fileTarget, { selectedMoviePaths: options?.selectedMoviePaths }); } 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); 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); } logger.warn('job:deleted', { jobId: normalizedJobId, fileTarget, includeRelated: true, deletedJobIds: deleteJobIds, deletedJobCount: deleteJobIds.length, pipelineStateReset: activeJobIncluded, filesDeleted: fileSummary ? { raw: fileSummary.raw?.filesDeleted ?? 0, movie: fileSummary.movie?.filesDeleted ?? 0 } : { raw: 0, movie: 0 } }); return { deleted: true, jobId: normalizedJobId, fileTarget, includeRelated: true, deletedJobIds: deleteJobIds, deletedJobs: preview.relatedJobs, fileSummary }; } } module.exports = new HistoryService();