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 { 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', 'other']; const RAW_INCOMPLETE_PREFIX = 'Incomplete_'; const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_'; 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 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'; } 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') { return profileHint; } 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 (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) { const stored = String(storedPath || '').trim(); if (!stored || !rawDir) return stored; const folderName = path.basename(stored); if (!folderName) return stored; return path.join(String(rawDir).trim(), folderName); } 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 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 effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType); const rawDir = String(effectiveSettings?.raw_dir || '').trim(); const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim(); const movieDir = mediaType === 'cd' ? rawDir : configuredMovieDir; const effectiveRawPath = mediaType === 'cd' ? (job?.raw_path || null) : (rawDir && job?.raw_path ? resolveEffectiveRawPath(job.raw_path, rawDir) : (job?.raw_path || null)); const effectiveOutputPath = mediaType === 'cd' ? (job?.output_path || null) : (configuredMovieDir && job?.output_path ? resolveEffectiveOutputPath(job.output_path, configuredMovieDir) : (job?.output_path || null)); return { mediaType, 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 outputStatus = includeFsChecks ? (resolvedPaths.mediaType === 'cd' ? inspectDirectory(resolvedPaths.effectiveOutputPath) : inspectOutputFile(resolvedPaths.effectiveOutputPath)) : (resolvedPaths.mediaType === 'cd' ? 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 ripSuccessful = Number(job?.rip_successful || 0) === 1 || String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; const backupSuccess = ripSuccessful; const encodeSuccess = mediaType === 'cd' ? (String(job?.status || '').trim().toUpperCase() === 'FINISHED' && Boolean(outputStatus?.exists)) : 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 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 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 folderJobIdMatch = normalizedRawName.match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i); const folderJobId = folderJobIdMatch ? Number(folderJobIdMatch[1]) : null; let working = normalizedRawName.replace(/\s*-\s*RAW\s*-\s*job-\d+\s*$/i, '').trim(); 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 replaced = folderName.replace(/(\s-\sRAW\s-\sjob-)\d+\s*$/i, `$1${Math.trunc(normalizedJobId)}`); if (replaced === folderName) { return absRawPath; } return path.join(path.dirname(absRawPath), replaced); } 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 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 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 }) { const db = await getDb(); const startTime = new Date().toISOString(); const result = await db.run( ` INSERT INTO jobs (disc_device, status, start_time, detected_title, last_state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) `, [discDevice, status, startTime, detectedTitle, status] ); logger.info('job:created', { jobId: result.lastID, discDevice, status, detectedTitle }); 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] ); } 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 }; } appendLog(jobId, source, message) { this.appendProcessLog(jobId, source, message); } 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 }; } 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 j.created_at 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 getRunningJobs() { const db = await getDb(); const [rows, settings] = await Promise.all([ db.all( ` SELECT * FROM jobs WHERE status IN ('RIPPING', 'ENCODING', '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 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 [job, settings] = await Promise.all([ db.get('SELECT * FROM jobs WHERE id = ?', [jobId]), settingsService.getSettingsMap() ]); if (!job) { return null; } 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; if (!shouldLoadLogs) { return { ...enrichJobRow(job, settings, { includeFsChecks }), 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 }), 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 getDatabaseRows(filters = {}) { const jobs = await this.getJobs(filters); return jobs.map((job) => ({ ...job, rawFolderName: job.raw_path ? path.basename(job.raw_path) : null })); } 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,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()) { 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); orphanRows.push({ rawPath, folderName: entry.name, title: metadata.title, year: metadata.year, imdbId: metadata.imdbId, folderJobId: metadata.folderJobId, entryCount: Number(dirInfo.entryCount || 0), hasBlurayStructure: fs.existsSync(path.join(rawPath, 'BDMV', 'STREAM')), 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,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 orphanPosterUrl = omdbById?.poster || null; await this.updateJob(created.id, { status: 'FINISHED', last_state: 'FINISHED', title: omdbById?.title || metadata.title || null, year: Number.isFinite(Number(omdbById?.year)) ? Number(omdbById.year) : metadata.year, imdb_id: omdbById?.imdbId || metadata.imdbId || null, poster_url: orphanPosterUrl, omdb_json: omdbById?.raw ? JSON.stringify(omdbById.raw) : null, selected_from_omdb: omdbById ? 1 : 0, rip_successful: 1, raw_path: finalRawPath, output_path: null, handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0, error_message: null, end_time: importedAt, makemkv_info_json: JSON.stringify({ status: 'SUCCESS', source: 'orphan_raw_import', importedAt, rawPath: finalRawPath }) }); // Bild direkt persistieren (kein Rip-Prozess, daher kein Cache-Zwischenschritt) if (orphanPosterUrl) { thumbnailService.cacheJobThumbnail(created.id, orphanPosterUrl) .then(() => { const promotedUrl = thumbnailService.promoteJobThumbnail(created.id); if (promotedUrl) return this.updateJob(created.id, { poster_url: promotedUrl }); }) .catch(() => {}); } await this.appendLog( created.id, 'SYSTEM', renameSteps.length > 0 ? `Historieneintrag aus RAW erstellt. Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}` : `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath}` ); 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}` ); } logger.info('job:import-orphan-raw', { jobId: created.id, rawPath: absRawPath }); 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) { omdb = await omdbService.fetchByImdbId(imdbIdInput); if (!omdb) { const error = new Error(`OMDb Eintrag für ${imdbIdInput} nicht gefunden.`); error.statusCode = 404; throw error; } } 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 in Cache laden (async, blockiert nicht) if (posterUrl && !thumbnailService.isLocalUrl(posterUrl)) { thumbnailService.cacheJobThumbnail(jobId, posterUrl).catch(() => {}); } 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 _resolveRelatedJobsForDeletion(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 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); } 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 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 explicitMoviePaths = unique([ toNormalizedPath(job?.output_path), toNormalizedPath(resolvedPaths?.effectiveOutputPath), ...artifactMoviePaths ]); const rawRoots = sanitizeRoots([ ...getConfiguredMediaPathList(settings || {}, 'raw_dir'), toNormalizedPath(resolvedPaths?.rawDir), ...explicitRawPaths.map((candidatePath) => toNormalizedPath(path.dirname(candidatePath))) ]); const movieRoots = sanitizeRoots([ ...getConfiguredMediaPathList(settings || {}, 'movie_dir'), toNormalizedPath(resolvedPaths?.movieDir), ...explicitMoviePaths.map((candidatePath) => toNormalizedPath(path.dirname(candidatePath))) ]); const rawCandidates = []; const movieCandidates = []; const addCandidate = (bucket, target, candidatePath, source, allowedRoots = []) => { const normalizedPath = toNormalizedPath(candidatePath); if (!normalizedPath) { return; } if (isFilesystemRootPath(normalizedPath)) { return; } const roots = Array.isArray(allowedRoots) ? allowedRoots.filter(Boolean) : []; if (roots.length > 0 && !roots.some((root) => isPathInside(root, normalizedPath))) { return; } bucket.push({ target, path: normalizedPath, source, jobId: normalizedJobId }); }; const artifactRawPathSet = new Set(artifactRawPaths); for (const rawPath of explicitRawPaths) { addCandidate( rawCandidates, 'raw', rawPath, artifactRawPathSet.has(rawPath) ? 'lineage_raw_path' : 'raw_path', rawRoots ); } const rawFolderNames = new Set(); for (const rawPath of explicitRawPaths) { const folderName = String(path.basename(rawPath || '') || '').trim(); if (!folderName || folderName === '.' || folderName === path.sep) { continue; } rawFolderNames.add(folderName); const stripped = stripRawFolderStatePrefix(folderName); if (stripped) { rawFolderNames.add(stripped); rawFolderNames.add(applyRawFolderPrefix(stripped, RAW_INCOMPLETE_PREFIX)); rawFolderNames.add(applyRawFolderPrefix(stripped, RAW_RIP_COMPLETE_PREFIX)); } } for (const rootPath of rawRoots) { for (const folderName of rawFolderNames) { addCandidate(rawCandidates, 'raw', path.join(rootPath, folderName), 'raw_variant', rawRoots); } } if (normalizedJobId) { for (const rootPath of rawRoots) { try { if (!fs.existsSync(rootPath) || !fs.lstatSync(rootPath).isDirectory()) { continue; } const entries = fs.readdirSync(rootPath, { withFileTypes: true }); for (const entry of entries) { if (!entry?.isDirectory?.()) { continue; } const metadata = parseRawFolderMetadata(entry.name); if (normalizeJobIdValue(metadata?.folderJobId) === normalizedJobId) { addCandidate( rawCandidates, 'raw', path.join(rootPath, entry.name), 'raw_jobid_scan', rawRoots ); } } } catch (_error) { // ignore fs errors while collecting optional candidates } } } const artifactMoviePathSet = new Set(artifactMoviePaths); for (const outputPath of explicitMoviePaths) { addCandidate( movieCandidates, 'movie', outputPath, artifactMoviePathSet.has(outputPath) ? 'lineage_output_path' : 'output_path', movieRoots ); const parentDir = toNormalizedPath(path.dirname(outputPath)); if (parentDir && !movieRoots.includes(parentDir)) { addCandidate( movieCandidates, 'movie', parentDir, artifactMoviePathSet.has(outputPath) ? 'lineage_output_parent' : 'output_parent', movieRoots ); } } if (normalizedJobId) { 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) { const rows = Array.isArray(jobs) ? jobs : []; const artifactsMap = lineageArtifactsByJobId instanceof Map ? lineageArtifactsByJobId : 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 collected = this._collectDeleteCandidatesForJob(job, settings, { lineageArtifacts }); 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 buildList = (target) => Array.from(candidateMap.values()) .filter((row) => row.target === target) .map((row) => { const inspection = inspectDeletionPath(row.path); 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: Array.from(row.sources).sort((left, right) => left.localeCompare(right)) }; }) .sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de')); return { pathCandidates: { raw: buildList('raw'), movie: buildList('movie') }, protectedRoots: { raw: Array.from(protectedRoots.raw).sort((left, right) => left.localeCompare(right)), movie: Array.from(protectedRoots.movie).sort((left, right) => left.localeCompare(right)) } }; } async getJobDeletePreview(jobId, options = {}) { const includeRelated = options?.includeRelated !== false; const 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 lineageArtifactsByJobId = await this.listJobLineageArtifactsByJobIds( jobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean) ); const preview = this._buildDeletePreviewFromJobs(jobs, settings, lineageArtifactsByJobId); const relatedJobs = jobs.map((job) => ({ id: Number(job.id), parentJobId: normalizeJobIdValue(job.parent_job_id), title: buildJobDisplayTitle(job), status: String(job.status || '').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') { const normalizedTarget = String(target || 'both').trim().toLowerCase(); const includesRaw = normalizedTarget === 'raw' || normalizedTarget === 'both'; const includesMovie = normalizedTarget === 'movie' || normalizedTarget === 'both'; 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 }, deletedPaths: [] }; const applyTarget = (targetKey) => { const candidates = (Array.isArray(preview?.pathCandidates?.[targetKey]) ? preview.pathCandidates[targetKey] : []) .filter((item) => Boolean(item?.exists) && (Boolean(item?.isDirectory) || Boolean(item?.isFile))); if (candidates.length === 0) { summary[targetKey].reason = 'Keine passenden Dateien/Ordner gefunden.'; return; } const protectedRoots = new Set( (Array.isArray(preview?.protectedRoots?.[targetKey]) ? preview.protectedRoots[targetKey] : []) .map((rootPath) => String(rootPath || '').trim()) .filter(Boolean) .map((rootPath) => normalizeComparablePath(rootPath)) ); const orderedCandidates = [...candidates].sort( (left, right) => String(right?.path || '').length - String(left?.path || '').length ); for (const candidate of orderedCandidates) { const candidatePath = String(candidate?.path || '').trim(); if (!candidatePath) { continue; } const inspection = inspectDeletionPath(candidatePath); if (!inspection.exists) { continue; } if (inspection.isDirectory) { 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 } }; 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 keepRoot = rawPath === rawRoot; const result = deleteFilesRecursively(effectiveRawPath, 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)) { 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; } else { const parentDir = normalizeComparablePath(path.dirname(outputPath)); const canDeleteParentDir = 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; } else { fs.unlinkSync(outputPath); summary.movie.deleted = true; summary.movie.filesDeleted = 1; summary.movie.dirsRemoved = 0; } } } } 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 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); } 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); } 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();