const fs = require('fs'); const path = require('path'); const { EventEmitter } = require('events'); const { execFile } = require('child_process'); const { getDb } = require('../db/database'); const settingsService = require('./settingsService'); const historyService = require('./historyService'); const omdbService = require('./omdbService'); const musicBrainzService = require('./musicBrainzService'); const audnexService = require('./audnexService'); const cdRipService = require('./cdRipService'); const audiobookService = require('./audiobookService'); const scriptService = require('./scriptService'); const scriptChainService = require('./scriptChainService'); const runtimeActivityService = require('./runtimeActivityService'); const wsService = require('./websocketService'); const diskDetectionService = require('./diskDetectionService'); const notificationService = require('./notificationService'); const logger = require('./logger').child('PIPELINE'); const { spawnTrackedProcess } = require('./processRunner'); const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers'); const { ensureDir, sanitizeFileName, sanitizeFileNameWithExtension, renderTemplate, findMediaFiles } = require('../utils/files'); const { buildMediainfoReview } = require('../utils/encodePlan'); const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis'); const { errorToMeta } = require('../utils/errorMeta'); const userPresetService = require('./userPresetService'); const thumbnailService = require('./thumbnailService'); const activationBytesService = require('./activationBytesService'); const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); const REVIEW_REFRESH_SETTING_PREFIXES = [ 'handbrake_', 'mediainfo_', 'makemkv_rip_', 'makemkv_analyze_', 'output_extension_', 'output_template_' ]; const REVIEW_REFRESH_SETTING_KEYS = new Set([ 'makemkv_min_length_minutes', 'handbrake_preset', 'handbrake_extra_args', 'mediainfo_extra_args', 'makemkv_rip_mode', 'makemkv_analyze_extra_args', 'makemkv_rip_extra_args', 'output_extension', 'output_template' ]); const QUEUE_ACTIONS = { START_PREPARED: 'START_PREPARED', START_CD: 'START_CD', RETRY: 'RETRY', REENCODE: 'REENCODE', RESTART_ENCODE: 'RESTART_ENCODE', RESTART_REVIEW: 'RESTART_REVIEW' }; const QUEUE_ACTION_LABELS = { [QUEUE_ACTIONS.START_PREPARED]: 'Start', [QUEUE_ACTIONS.RETRY]: 'Retry Rippen', [QUEUE_ACTIONS.REENCODE]: 'RAW neu encodieren', [QUEUE_ACTIONS.RESTART_ENCODE]: 'Encode neu starten', [QUEUE_ACTIONS.RESTART_REVIEW]: 'Review neu berechnen', [QUEUE_ACTIONS.START_CD]: 'Audio CD starten' }; const PRE_ENCODE_PROGRESS_RESERVE = 10; const POST_ENCODE_PROGRESS_RESERVE = 10; const POST_ENCODE_FINISH_BUFFER = 1; const MIN_EXTENSIONLESS_DISC_IMAGE_BYTES = 256 * 1024 * 1024; const MAKEMKV_BACKUP_FAILURE_MSG_CODES = new Set([5069, 5080]); const RAW_INCOMPLETE_PREFIX = 'Incomplete_'; const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_'; const RAW_FOLDER_STATES = Object.freeze({ INCOMPLETE: 'incomplete', RIP_COMPLETE: 'rip_complete', COMPLETE: 'complete' }); const SUBTITLE_CONFIDENCE_SCORES = Object.freeze({ low: 1, medium: 2, high: 3 }); const FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD = 0.35; const FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD = 0.35; const FORCED_SUBTITLE_MAX_EVENT_COUNT = 220; const FORCED_SUBTITLE_MIN_EVENT_GAP = 12; const FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES = 64 * 1024; const CONVERTER_SUPPORTED_SCAN_EXTENSIONS = Object.freeze([ 'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov', 'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus' ]); const CONVERTER_SUPPORTED_SCAN_EXTENSION_SET = new Set(CONVERTER_SUPPORTED_SCAN_EXTENSIONS); function nowIso() { return new Date().toISOString(); } function normalizeCdTrackText(value) { return String(value || '') .normalize('NFC') // Keep umlauts/special letters, but strip heart symbols from imported metadata. .replace(/[♥❤♡❥❣❦❧]/gu, ' ') .replace(/\p{C}+/gu, ' ') .replace(/\s+/g, ' ') .trim(); } function normalizePositiveInteger(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function parseConverterScanExtensions(rawValue) { const raw = String(rawValue || '').trim(); if (!raw) { return [...CONVERTER_SUPPORTED_SCAN_EXTENSIONS]; } const seen = new Set(); const parsed = raw .split(',') .map((item) => String(item || '').trim().toLowerCase()) .filter((item) => { if (!item || !CONVERTER_SUPPORTED_SCAN_EXTENSION_SET.has(item) || seen.has(item)) { return false; } seen.add(item); return true; }); if (parsed.length === 0) { return [...CONVERTER_SUPPORTED_SCAN_EXTENSIONS]; } return parsed; } function getFileExtensionWithoutDot(fileName) { const ext = path.extname(String(fileName || '')).trim().toLowerCase(); if (!ext.startsWith('.')) { return ''; } return ext.slice(1); } function cleanupTempUploads(files = []) { for (const file of Array.isArray(files) ? files : []) { const tempPath = String(file?.path || '').trim(); if (!tempPath || !fs.existsSync(tempPath)) { continue; } try { fs.unlinkSync(tempPath); } catch (_error) { // Best-effort cleanup only. } } } function normalizeCdTrackPositionList(values = []) { const source = Array.isArray(values) ? values : []; const seen = new Set(); const output = []; for (const value of source) { const normalized = normalizePositiveInteger(value); if (!normalized) { continue; } const key = String(normalized); if (seen.has(key)) { continue; } seen.add(key); output.push(normalized); } return output; } function isCdPlaceholderTrackTitle(value) { const normalized = normalizeCdTrackText(value).toLowerCase(); if (!normalized) { return true; } return /^track\s*\d+$/i.test(normalized); } function evaluateCdDirectReencodeEligibility({ job = {}, encodePlan = {}, mkInfo = {}, handbrakeInfo = {} } = {}) { const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; const info = mkInfo && typeof mkInfo === 'object' ? mkInfo : {}; const hbInfo = handbrakeInfo && typeof handbrakeInfo === 'object' ? handbrakeInfo : {}; const selectedMetadata = info?.selectedMetadata && typeof info.selectedMetadata === 'object' ? info.selectedMetadata : {}; const tracks = Array.isArray(info?.tracks) && info.tracks.length > 0 ? info.tracks : (Array.isArray(plan?.tracks) ? plan.tracks : []); const selectedTrackPositions = normalizeCdTrackPositionList( Array.isArray(plan?.selectedTracks) && plan.selectedTracks.length > 0 ? plan.selectedTracks : tracks .filter((track) => track?.selected !== false) .map((track) => normalizePositiveInteger(track?.position)) ); const trackByPosition = new Map( tracks .map((track) => { const position = normalizePositiveInteger(track?.position); if (!position) { return null; } return [position, track]; }) .filter(Boolean) ); const normalizedFormat = String(plan?.format || '').trim().toLowerCase(); const hasSupportedFormat = Boolean(normalizedFormat && cdRipService.SUPPORTED_FORMATS.has(normalizedFormat)); const hasTracks = tracks.length > 0; const hasSelectedTracks = selectedTrackPositions.length > 0; const albumTitle = normalizeCdTrackText( selectedMetadata?.title || selectedMetadata?.album || job?.title || job?.detected_title || '' ); const albumArtist = normalizeCdTrackText(selectedMetadata?.artist || ''); const hasCoreMetadata = Boolean(albumTitle && albumArtist); const hasMeaningfulTrackTitles = selectedTrackPositions.some((position) => { const track = trackByPosition.get(position) || null; const title = normalizeCdTrackText(track?.title || ''); return Boolean(title) && !isCdPlaceholderTrackTitle(title); }); const hasPriorRunEvidence = Boolean( plan?.directReencodeReady || (Array.isArray(hbInfo?.tracks) && hbInfo.tracks.length > 0) || String(hbInfo?.status || '').trim().toUpperCase() === 'SUCCESS' ); const reasons = []; if (!hasSupportedFormat) { reasons.push('Kein valides Ausgabeformat aus einem vorherigen Lauf gefunden.'); } if (!hasTracks || !hasSelectedTracks) { reasons.push('Keine verwertbare Trackauswahl aus einem vorherigen Lauf vorhanden.'); } if (!hasCoreMetadata) { reasons.push('Album-Metadaten sind unvollständig (Titel/Interpret fehlen).'); } if (!hasMeaningfulTrackTitles) { reasons.push('Tracktitel sind nicht aus einem bestätigten Metadaten-Lauf übernommen.'); } if (!hasPriorRunEvidence) { reasons.push('Kein bestätigter Vorlauf mit gültigen CD-Metadaten gefunden.'); } return { eligible: reasons.length === 0, reasons }; } function parseCdTrackDurationSec(track = null) { const durationSec = Number(track?.durationSec); if (Number.isFinite(durationSec) && durationSec > 0) { return Math.max(0, Math.trunc(durationSec)); } const durationMs = Number(track?.durationMs); if (Number.isFinite(durationMs) && durationMs > 0) { return Math.max(0, Math.round(durationMs / 1000)); } return 0; } function buildCdLiveTrackRows(selectedTrackPositions = [], tocTracks = [], fallbackArtist = null) { const orderedPositions = normalizeCdTrackPositionList(selectedTrackPositions); const byPosition = new Map( (Array.isArray(tocTracks) ? tocTracks : []) .map((track) => { const position = normalizePositiveInteger(track?.position); if (!position) { return null; } return [position, track]; }) .filter(Boolean) ); return orderedPositions.map((position, index) => { const track = byPosition.get(position) || {}; return { order: index + 1, position, title: normalizeCdTrackText(track?.title) || `Track ${position}`, artist: normalizeCdTrackText(track?.artist) || normalizeCdTrackText(fallbackArtist) || '', durationSec: parseCdTrackDurationSec(track) }; }); } function buildCdLiveProgressSnapshot({ trackRows = [], phase = 'rip', trackIndex = 0, trackTotal = null, trackPosition = null, ripCompletedCount = 0, encodeCompletedCount = 0, failedTrackPosition = null }) { const rows = Array.isArray(trackRows) ? trackRows : []; const total = rows.length; const normalizedPhase = String(phase || '').trim().toLowerCase() === 'encode' ? 'encode' : 'rip'; const normalizedTrackTotal = normalizePositiveInteger(trackTotal) || total; const normalizedTrackIndex = normalizePositiveInteger(trackIndex); const normalizedTrackPosition = normalizePositiveInteger(trackPosition); const normalizedFailedTrackPosition = normalizePositiveInteger(failedTrackPosition); const safeRipCompleted = Math.max(0, Math.min(total, Math.trunc(Number(ripCompletedCount) || 0))); const safeEncodeCompleted = Math.max(0, Math.min(total, Math.trunc(Number(encodeCompletedCount) || 0))); const selectedTrackPositions = rows.map((row) => row.position); const ripCompletedTrackPositions = selectedTrackPositions.slice(0, safeRipCompleted); const encodeCompletedTrackPositions = selectedTrackPositions.slice(0, safeEncodeCompleted); const trackStates = rows.map((row, index) => { const ripDone = index < safeRipCompleted; const encodeDone = index < safeEncodeCompleted; let ripStatus = ripDone ? 'done' : 'pending'; let encodeStatus = encodeDone ? 'done' : 'pending'; if (!ripDone && normalizedPhase === 'rip' && normalizedTrackPosition && row.position === normalizedTrackPosition) { ripStatus = 'in_progress'; } else if (!ripDone && normalizedPhase === 'rip' && normalizedFailedTrackPosition && row.position === normalizedFailedTrackPosition) { ripStatus = 'error'; } if (!encodeDone && normalizedPhase === 'encode' && normalizedTrackPosition && row.position === normalizedTrackPosition) { encodeStatus = 'in_progress'; } else if (!encodeDone && normalizedPhase === 'encode' && normalizedFailedTrackPosition && row.position === normalizedFailedTrackPosition) { encodeStatus = 'error'; } return { ...row, selected: true, ripStatus, encodeStatus }; }); return { phase: normalizedPhase, trackIndex: normalizedTrackIndex || 0, trackTotal: normalizedTrackTotal, trackPosition: normalizedTrackPosition || null, ripCompleted: safeRipCompleted, encodeCompleted: safeEncodeCompleted, selectedTrackPositions, ripCompletedTrackPositions, encodeCompletedTrackPositions, trackStates, updatedAt: nowIso() }; } function normalizeMediaProfile(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'; } if (raw === 'converter') { return 'converter'; } return null; } function normalizeJobKind(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 resolveConverterJobKind(converterMediaType = null) { const normalized = String(converterMediaType || '').trim().toLowerCase(); if (normalized === 'audio') { return 'converter_audio'; } if (normalized === 'iso') { return 'converter_iso'; } return 'converter_video'; } function resolveJobKindForMediaProfile(mediaProfile, options = {}) { const explicit = normalizeJobKind(options?.jobKind); if (explicit) { return explicit; } const normalizedProfile = normalizeMediaProfile(mediaProfile); if (!normalizedProfile) { return null; } if (normalizedProfile === 'converter') { return resolveConverterJobKind(options?.converterMediaType); } if (normalizedProfile === 'audiobook' || normalizedProfile === 'cd' || normalizedProfile === 'dvd' || normalizedProfile === 'bluray') { return normalizedProfile; } return null; } function inferMediaProfileFromJobKind(rawJobKind) { const normalized = normalizeJobKind(rawJobKind); 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(rawJobKind || '').trim().toLowerCase(); if (legacy === 'converter') { return 'converter'; } return null; } function isConverterJobRecord(job = null, encodePlan = null) { const profileFromJobKind = inferMediaProfileFromJobKind(job?.job_kind); if (profileFromJobKind === 'converter') { return true; } const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : null; const profileFromPlanJobKind = inferMediaProfileFromJobKind(plan?.jobKind); if (profileFromPlanJobKind === 'converter') { return true; } const mediaType = String(job?.media_type || '').trim().toLowerCase(); if (mediaType === 'converter') { return true; } const planProfile = String(plan?.mediaProfile || '').trim().toLowerCase(); return planProfile === 'converter'; } function isSpecificMediaProfile(value) { return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook' || value === 'converter'; } function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { const fstype = String(rawFsType || '').trim().toLowerCase(); const model = String(rawModel || '').trim().toLowerCase(); const hasBlurayModelMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re)/.test(model); const hasDvdModelMarker = /dvd/.test(model); const hasCdOnlyModelMarker = /(^|[\s_-])cd([\s_-]|$)|cd-?rom/.test(model) && !hasBlurayModelMarker && !hasDvdModelMarker; if (!fstype) { if (hasBlurayModelMarker) { return 'bluray'; } if (hasDvdModelMarker) { return 'dvd'; } return null; } if (fstype.includes('udf')) { // UDF is used by both DVDs (UDF 1.02) and Blu-rays (UDF 2.5/2.6). // Drive model alone (hasBlurayModelMarker) is not reliable: a BD-ROM drive // with a DVD inside would incorrectly be detected as Blu-ray. // Return null so the mountpoint BDMV/VIDEO_TS check can decide. if (hasBlurayModelMarker) { return null; } if (hasDvdModelMarker) { return 'dvd'; } return 'dvd'; } if (fstype.includes('iso9660') || fstype.includes('cdfs')) { // iso9660/cdfs is never used by Blu-ray discs (they use UDF 2.5/2.6). // Ignore hasBlurayModelMarker here – it only reflects drive capability. if (hasCdOnlyModelMarker) { return 'other'; } return 'dvd'; } return null; } function isLikelyExtensionlessDvdImageFile(filePath, knownSize = null) { const ext = path.extname(String(filePath || '')).toLowerCase(); // Only treat as having a real extension if it looks like one (e.g. ".mkv", not ". 1") if (ext !== '' && /^\.[a-z0-9]+$/.test(ext)) { return false; } let size = Number(knownSize); if (!Number.isFinite(size) || size < 0) { try { size = Number(fs.statSync(filePath).size || 0); } catch (_error) { return false; } } return size >= MIN_EXTENSIONLESS_DISC_IMAGE_BYTES; } function listTopLevelExtensionlessDvdImages(dirPath) { const sourceDir = String(dirPath || '').trim(); if (!sourceDir) { return []; } let entries; try { entries = fs.readdirSync(sourceDir, { withFileTypes: true }); } catch (_error) { return []; } const results = []; for (const entry of entries) { if (!entry.isFile()) { continue; } const absPath = path.join(sourceDir, entry.name); let stat; try { stat = fs.statSync(absPath); } catch (_error) { continue; } if (!isLikelyExtensionlessDvdImageFile(absPath, stat.size)) { continue; } results.push({ path: absPath, size: Number(stat.size || 0) }); } results.sort((a, b) => b.size - a.size || a.path.localeCompare(b.path)); return results; } function hasConverterPathSegment(value) { const normalized = String(value || '') .trim() .replace(/\\/g, '/') .toLowerCase(); if (!normalized) { return false; } return /(^|\/)converter(\/|$)/.test(normalized); } function inferMediaProfileFromRawPath(rawPath) { const source = String(rawPath || '').trim(); if (!source) { return null; } if (hasConverterPathSegment(source)) { return 'converter'; } try { const sourceStat = fs.statSync(source); if (sourceStat.isFile()) { if (path.extname(source).toLowerCase() === '.aax') { return 'audiobook'; } if (isLikelyExtensionlessDvdImageFile(source, sourceStat.size)) { return 'dvd'; } return null; } } catch (_error) { // ignore fs errors } const bdmvPath = path.join(source, 'BDMV'); const bdmvStreamPath = path.join(bdmvPath, 'STREAM'); try { if (fs.existsSync(bdmvStreamPath) || fs.existsSync(bdmvPath)) { return 'bluray'; } } catch (_error) { // ignore fs errors } const videoTsPath = path.join(source, 'VIDEO_TS'); try { if (fs.existsSync(videoTsPath)) { return 'dvd'; } } catch (_error) { // ignore fs errors } try { const audiobookFiles = findMediaFiles(source, ['.aax']); if (audiobookFiles.length > 0) { return 'audiobook'; } } catch (_error) { // ignore fs errors } if (listTopLevelExtensionlessDvdImages(source).length > 0) { return 'dvd'; } return null; } function inferMediaProfileFromDeviceInfo(deviceInfo = null) { const device = deviceInfo && typeof deviceInfo === 'object' ? deviceInfo : null; if (!device) { return null; } const explicit = normalizeMediaProfile( device.mediaProfile || device.profile || device.type || null ); if (explicit) { return explicit; } // Only use disc-specific fields for keyword detection, NOT device.model. // The drive model describes drive capability (e.g. "BD-ROM"), not disc type. // A BD-ROM drive with a DVD inserted would otherwise be misdetected as Blu-ray. const discMarkerText = [ device.discLabel, device.label, device.fstype, ] .map((value) => String(value || '').trim().toLowerCase()) .filter(Boolean) .join(' '); if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd-rom|bd-r|bd-re/.test(discMarkerText)) { return 'bluray'; } if (/(^|[\s_-])video_ts($|[\s_-])|dvd/.test(discMarkerText)) { return 'dvd'; } const byFsTypeAndModel = inferMediaProfileFromFsTypeAndModel(device.fstype, device.model); if (byFsTypeAndModel) { return byFsTypeAndModel; } const mountpoint = String(device.mountpoint || '').trim(); if (mountpoint) { try { if (fs.existsSync(path.join(mountpoint, 'BDMV'))) { return 'bluray'; } } catch (_error) { // ignore fs errors } try { if (fs.existsSync(path.join(mountpoint, 'VIDEO_TS'))) { return 'dvd'; } } catch (_error) { // ignore fs errors } } return null; } function fileTimestamp() { const d = new Date(); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); const h = String(d.getHours()).padStart(2, '0'); const min = String(d.getMinutes()).padStart(2, '0'); const s = String(d.getSeconds()).padStart(2, '0'); return `${y}${m}${day}-${h}${min}${s}`; } function withTimestampBeforeExtension(targetPath, suffix) { const dir = path.dirname(targetPath); const ext = path.extname(targetPath); const base = path.basename(targetPath, ext); return path.join(dir, `${base}_${suffix}${ext}`); } function withTimestampSuffix(targetPath, suffix) { const dir = path.dirname(targetPath); const base = path.basename(targetPath); return path.join(dir, `${base}_${suffix}`); } function resolveOutputTemplateValues(job, fallbackJobId = null) { return { title: job.title || job.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'), year: job.year || new Date().getFullYear(), imdbId: job.imdb_id || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb') }; } const DEFAULT_OUTPUT_TEMPLATE = '${title} (${year})/${title} (${year})'; function resolveOutputPathParts(settings, values) { const template = String(settings.output_template || DEFAULT_OUTPUT_TEMPLATE).trim() || DEFAULT_OUTPUT_TEMPLATE; const rendered = renderTemplate(template, values); const segments = rendered .replace(/\\/g, '/') .replace(/\/+/g, '/') .replace(/^\/+|\/+$/g, '') .split('/') .map((seg) => sanitizeFileName(seg)) .filter(Boolean); if (segments.length === 0) { return { folderPath: '', baseName: 'untitled' }; } const baseName = segments[segments.length - 1]; const folderParts = segments.slice(0, -1); return { folderPath: folderParts.length > 0 ? path.join(...folderParts) : '', baseName }; } function buildFinalOutputPathFromJob(settings, job, fallbackJobId = null) { const movieDir = settings.movie_dir; const values = resolveOutputTemplateValues(job, fallbackJobId); const { folderPath, baseName } = resolveOutputPathParts(settings, values); const ext = String(settings.output_extension || 'mkv').trim() || 'mkv'; if (folderPath) { return path.join(movieDir, folderPath, `${baseName}.${ext}`); } return path.join(movieDir, `${baseName}.${ext}`); } function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) { const movieDir = settings.movie_dir; const values = resolveOutputTemplateValues(job, fallbackJobId); const { baseName } = resolveOutputPathParts(settings, values); const ext = String(settings.output_extension || 'mkv').trim() || 'mkv'; const numericJobId = Number(fallbackJobId || job?.id || 0); const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0 ? `Incomplete_job-${numericJobId}` : 'Incomplete_job-unknown'; return path.join(movieDir, incompleteFolder, `${baseName}.${ext}`); } function ensureUniqueOutputPath(outputPath) { if (!fs.existsSync(outputPath)) { return outputPath; } let stat = null; try { stat = fs.statSync(outputPath); } catch (_error) { stat = null; } const isDirectory = Boolean(stat?.isDirectory?.()); // Use sequential numbering (_2, _3, ...) for directories and parent-folder of files if (isDirectory) { for (let i = 2; i < 200; i++) { const attempt = `${outputPath}_${i}`; if (!fs.existsSync(attempt)) return attempt; } } else { const parentDir = path.dirname(outputPath); const fileName = path.basename(outputPath); for (let i = 2; i < 200; i++) { const attempt = path.join(`${parentDir}_${i}`, fileName); if (!fs.existsSync(attempt)) return attempt; } } // Fallback to timestamp if sequential limit exceeded const ts = fileTimestamp(); return isDirectory ? withTimestampSuffix(outputPath, ts) : withTimestampBeforeExtension(outputPath, ts); } function chownRecursive(targetPath, ownerSpec) { const spec = String(ownerSpec || '').trim(); if (!spec || !targetPath) { return; } try { const { spawnSync } = require('child_process'); const result = spawnSync('chown', ['-R', spec, targetPath], { timeout: 15000 }); if (result.status !== 0) { logger.warn('chown:failed', { targetPath, spec, stderr: String(result.stderr || '') }); } } catch (error) { logger.warn('chown:error', { targetPath, spec, error: error?.message }); } } function moveFileWithFallback(sourcePath, targetPath) { try { fs.renameSync(sourcePath, targetPath); } catch (error) { if (error?.code !== 'EXDEV') { throw error; } fs.copyFileSync(sourcePath, targetPath); fs.unlinkSync(sourcePath); } } function movePathWithFallback(sourcePath, targetPath) { try { fs.renameSync(sourcePath, targetPath); } catch (error) { if (error?.code !== 'EXDEV') { throw error; } const stat = fs.statSync(sourcePath); if (stat.isDirectory()) { fs.cpSync(sourcePath, targetPath, { recursive: true }); fs.rmSync(sourcePath, { recursive: true, force: true }); return; } fs.copyFileSync(sourcePath, targetPath); fs.unlinkSync(sourcePath); } } function removeDirectoryIfEmpty(directoryPath) { try { const entries = fs.readdirSync(directoryPath); if (entries.length === 0) { fs.rmdirSync(directoryPath); } } catch (_error) { // Best effort cleanup. } } function finalizeOutputPathForCompletedEncode(incompleteOutputPath, preferredFinalOutputPath) { const sourcePath = String(incompleteOutputPath || '').trim(); if (!sourcePath) { throw new Error('Encode-Finalisierung fehlgeschlagen: temporärer Output-Pfad fehlt.'); } if (!fs.existsSync(sourcePath)) { throw new Error(`Encode-Finalisierung fehlgeschlagen: temporäre Datei fehlt (${sourcePath}).`); } const plannedTargetPath = String(preferredFinalOutputPath || '').trim(); if (!plannedTargetPath) { throw new Error('Encode-Finalisierung fehlgeschlagen: finaler Output-Pfad fehlt.'); } const sourceResolved = path.resolve(sourcePath); const targetPath = ensureUniqueOutputPath(plannedTargetPath); const targetResolved = path.resolve(targetPath); const outputPathWithTimestamp = targetPath !== plannedTargetPath; if (sourceResolved === targetResolved) { return { outputPath: targetPath, outputPathWithTimestamp }; } ensureDir(path.dirname(targetPath)); movePathWithFallback(sourcePath, targetPath); removeDirectoryIfEmpty(path.dirname(sourcePath)); return { outputPath: targetPath, outputPathWithTimestamp }; } function buildAudiobookMetadataForJob(job, makemkvInfo = null, encodePlan = null) { const mkInfo = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}; const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; const metadataSource = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : ( mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object' ? mkInfo.selectedMetadata : (mkInfo?.detectedMetadata && typeof mkInfo.detectedMetadata === 'object' ? mkInfo.detectedMetadata : {}) ); const title = String(metadataSource?.title || job?.title || job?.detected_title || 'Audiobook').trim() || 'Audiobook'; const durationMs = Number.isFinite(Number(metadataSource?.durationMs)) ? Number(metadataSource.durationMs) : 0; const chaptersSource = Array.isArray(metadataSource?.chapters) ? metadataSource.chapters : (Array.isArray(mkInfo?.chapters) ? mkInfo.chapters : []); const chapters = audiobookService.normalizeChapterList(chaptersSource, { durationMs, fallbackTitle: title, createFallback: false }); return { title, author: String(metadataSource?.author || metadataSource?.artist || '').trim() || null, asin: String(metadataSource?.asin || '').trim() || null, chapterSource: String(metadataSource?.chapterSource || '').trim() || null, narrator: String(metadataSource?.narrator || '').trim() || null, description: String(metadataSource?.description || '').trim() || null, series: String(metadataSource?.series || '').trim() || null, part: String(metadataSource?.part || '').trim() || null, year: Number.isFinite(Number(metadataSource?.year)) ? Math.trunc(Number(metadataSource.year)) : (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : null), durationMs, chapters, poster: String(metadataSource?.poster || job?.poster_url || '').trim() || null }; } function buildAudiobookOutputConfig(settings, job, makemkvInfo = null, encodePlan = null, fallbackJobId = null) { const metadata = buildAudiobookMetadataForJob(job, makemkvInfo, encodePlan); const movieDir = String( settings?.movie_dir || settings?.raw_dir || settingsService.DEFAULT_AUDIOBOOK_DIR || settingsService.DEFAULT_AUDIOBOOK_RAW_DIR || '' ).trim(); const outputTemplate = String( settings?.output_template || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE ).trim() || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE; const chapterOutputTemplate = String( settings?.output_chapter_template_audiobook || audiobookService.DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE ).trim() || audiobookService.DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE; const outputFormat = audiobookService.normalizeOutputFormat( encodePlan?.format || 'm4b' ); const numericJobId = Number(fallbackJobId || job?.id || 0); const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0 ? `Incomplete_job-${numericJobId}` : 'Incomplete_job-unknown'; const incompleteBaseDir = path.join(movieDir, incompleteFolder); if (outputFormat === 'm4b') { const preferredFinalOutputPath = audiobookService.buildOutputPath( metadata, movieDir, outputTemplate, outputFormat ); const incompleteOutputPath = path.join(incompleteBaseDir, path.basename(preferredFinalOutputPath)); return { metadata, outputFormat, preferredFinalOutputPath, incompleteOutputPath, preferredChapterPlan: null, incompleteChapterPlan: null }; } const preferredChapterPlan = audiobookService.buildChapterOutputPlan( metadata, metadata.chapters, movieDir, chapterOutputTemplate, outputFormat ); const incompleteChapterPlan = audiobookService.buildChapterOutputPlan( metadata, preferredChapterPlan.chapters, incompleteBaseDir, chapterOutputTemplate, outputFormat ); return { metadata: { ...metadata, chapters: preferredChapterPlan.chapters }, outputFormat, preferredFinalOutputPath: preferredChapterPlan.outputDir, incompleteOutputPath: incompleteChapterPlan.outputDir, preferredChapterPlan, incompleteChapterPlan }; } function truncateLine(value, max = 180) { const raw = String(value || '').replace(/\s+/g, ' ').trim(); if (raw.length <= max) { return raw; } return `${raw.slice(0, max)}...`; } function appendTailText(currentValue, nextChunk, maxChars = 12000) { const chunk = String(nextChunk || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); if (!chunk) { return { value: currentValue || '', truncated: false }; } const normalizedChunk = chunk.endsWith('\n') ? chunk : `${chunk}\n`; const combined = `${String(currentValue || '')}${normalizedChunk}`; if (combined.length <= maxChars) { return { value: combined, truncated: false }; } return { value: combined.slice(-maxChars), truncated: true }; } function extractProgressDetail(source, line) { const text = truncateLine(line, 220); if (!text) { return null; } if (source.startsWith('MAKEMKV')) { const prgc = text.match(/^PRGC:\d+,\d+,\"([^\"]+)\"/i); if (prgc) { return truncateLine(prgc[1], 160); } if (/Title\s+#?\d+/i.test(text)) { return text; } if (/copying|saving|writing|decrypt/i.test(text)) { return text; } if (/operation|progress|processing/i.test(text)) { return text; } } if (source === 'HANDBRAKE') { if (/Encoding:\s*task/i.test(text)) { return text; } if (/Muxing|work result|subtitle scan|frame/i.test(text)) { return text; } } return null; } function composeStatusText(stage, percent, detail) { const base = percent !== null && percent !== undefined ? `${stage} ${percent.toFixed(2)}%` : stage; if (detail) { return `${base} - ${detail}`; } return base; } function clampProgressPercent(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return null; } return Math.max(0, Math.min(100, parsed)); } function composeEncodeScriptStatusText(percent, phase, itemType, index, total, label, statusWord = null) { const phaseLabel = phase === 'pre' ? 'Pre-Encode' : 'Post-Encode'; const itemLabel = itemType === 'chain' ? 'Kette' : 'Skript'; const position = Number.isFinite(index) && Number.isFinite(total) && total > 0 ? ` ${index}/${total}` : ''; const status = statusWord ? ` ${statusWord}` : ''; const detail = String(label || '').trim(); return `ENCODING ${percent.toFixed(2)}% - ${phaseLabel} ${itemLabel}${position}${status}${detail ? `: ${detail}` : ''}`; } function parseMakeMkvMessageCode(line) { const match = String(line || '').match(/\bMSG:(\d+),/i); if (!match) { return null; } const code = Number(match[1]); if (!Number.isFinite(code)) { return null; } return Math.trunc(code); } function isMakeMkvBackupFailureMarker(line) { const text = String(line || '').trim(); if (!text) { return false; } const code = parseMakeMkvMessageCode(text); if (code !== null && MAKEMKV_BACKUP_FAILURE_MSG_CODES.has(code)) { return true; } return /backup\s+failed/i.test(text) || /backup\s+fehlgeschlagen/i.test(text); } function findMakeMkvBackupFailureMarker(lines) { if (!Array.isArray(lines)) { return null; } return lines.find((line) => isMakeMkvBackupFailureMarker(line)) || null; } function createEncodeScriptProgressTracker({ jobId, preSteps = 0, postSteps = 0, updateProgress }) { const preTotal = Math.max(0, Math.trunc(Number(preSteps) || 0)); const postTotal = Math.max(0, Math.trunc(Number(postSteps) || 0)); const hasPre = preTotal > 0; const hasPost = postTotal > 0; const preReserve = hasPre ? PRE_ENCODE_PROGRESS_RESERVE : 0; const postReserve = hasPost ? POST_ENCODE_PROGRESS_RESERVE : 0; const finalPercentBeforeFinish = hasPost ? (100 - POST_ENCODE_FINISH_BUFFER) : 100; const handBrakeStart = preReserve; const handBrakeEnd = Math.max(handBrakeStart, finalPercentBeforeFinish - postReserve); let preCompleted = 0; let postCompleted = 0; const clampPhasePercent = (value) => { const clamped = clampProgressPercent(value); if (clamped === null) { return 0; } return Number(clamped.toFixed(2)); }; const calculatePrePercent = () => { if (preTotal <= 0) { return clampPhasePercent(handBrakeStart); } return clampPhasePercent((preCompleted / preTotal) * preReserve); }; const calculatePostPercent = () => { if (postTotal <= 0) { return clampPhasePercent(handBrakeEnd); } return clampPhasePercent(handBrakeEnd + ((postCompleted / postTotal) * postReserve)); }; const callProgress = async (percent, statusText) => { if (typeof updateProgress !== 'function') { return; } await updateProgress('ENCODING', percent, null, statusText, jobId); }; return { hasScriptSteps: hasPre || hasPost, handBrakeStart, handBrakeEnd, mapHandBrakePercent(percent) { if (!this.hasScriptSteps) { return percent; } const normalized = clampProgressPercent(percent); if (normalized === null) { return percent; } const ratio = normalized / 100; return clampPhasePercent(handBrakeStart + ((handBrakeEnd - handBrakeStart) * ratio)); }, async onStepStart(phase, itemType, index, total, label) { if (phase === 'pre' && preTotal <= 0) { return; } if (phase === 'post' && postTotal <= 0) { return; } const percent = phase === 'pre' ? calculatePrePercent() : calculatePostPercent(); await callProgress(percent, composeEncodeScriptStatusText(percent, phase, itemType, index, total, label, 'startet')); }, async onStepComplete(phase, itemType, index, total, label, success = true) { if (phase === 'pre' && preTotal <= 0) { return; } if (phase === 'post' && postTotal <= 0) { return; } if (phase === 'pre') { preCompleted = Math.min(preTotal, preCompleted + 1); } else { postCompleted = Math.min(postTotal, postCompleted + 1); } const percent = phase === 'pre' ? calculatePrePercent() : calculatePostPercent(); await callProgress( percent, composeEncodeScriptStatusText( percent, phase, itemType, index, total, label, success ? 'OK' : 'Fehler' ) ); } }; } function shouldKeepHighlight(line) { return /error|fail|warn|fehl|title\s+#|saving|encoding:|muxing|copying|decrypt/i.test(line) || isMakeMkvBackupFailureMarker(line); } function normalizeNonNegativeInteger(rawValue) { if (rawValue === null || rawValue === undefined) { return null; } if (typeof rawValue === 'string' && rawValue.trim() === '') { return null; } const parsed = Number(rawValue); if (!Number.isFinite(parsed) || parsed < 0) { return null; } return Math.trunc(parsed); } function parseDetectedTitle(lines) { const candidates = []; const blockedPatterns = [ /evaluierungsversion/i, /evaluation version/i, /es verbleiben noch/i, /days remaining/i, /makemkv/i, /www\./i, /beta/i ]; const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim(); for (const line of lines) { const cinfoMatch = line.match(/CINFO:2,0,"([^"]+)"/i); if (cinfoMatch) { candidates.push(cinfoMatch[1]); } const tinfoMatch = line.match(/TINFO:\d+,2,\d+,"([^"]+)"/i); if (tinfoMatch) { candidates.push(tinfoMatch[1]); } } const clean = candidates .map(normalize) .filter((value) => value.length > 2 && !value.startsWith('/')) .filter((value) => !blockedPatterns.some((pattern) => pattern.test(value))) .filter((value) => !/^disc\s*\d*$/i.test(value)) .filter((value) => !/^unknown/i.test(value)); if (clean.length === 0) { return null; } clean.sort((a, b) => b.length - a.length); return clean[0]; } function parseMediainfoJsonOutput(rawOutput) { const text = String(rawOutput || '').trim(); if (!text) { return null; } const extractJsonObjects = (value) => { const source = String(value || ''); const objects = []; let start = -1; let depth = 0; let inString = false; let escaped = false; for (let i = 0; i < source.length; i += 1) { const ch = source[i]; if (inString) { if (escaped) { escaped = false; } else if (ch === '\\') { escaped = true; } else if (ch === '"') { inString = false; } continue; } if (ch === '"') { inString = true; continue; } if (ch === '{') { if (depth === 0) { start = i; } depth += 1; continue; } if (ch === '}' && depth > 0) { depth -= 1; if (depth === 0 && start >= 0) { objects.push(source.slice(start, i + 1)); start = -1; } } } return objects; }; const parsedObjects = []; const rawObjects = extractJsonObjects(text); for (const candidate of rawObjects) { try { parsedObjects.push(JSON.parse(candidate)); } catch (_error) { // ignore malformed blocks and continue } } if (parsedObjects.length === 0) { try { return JSON.parse(text); } catch (_error) { return null; } } const hasTitleList = (entry) => Array.isArray(entry?.TitleList) || Array.isArray(entry?.Scan?.TitleList) || Array.isArray(entry?.title_list); const hasMediaTrack = (entry) => Array.isArray(entry?.media?.track) || Array.isArray(entry?.Media?.track); const getTitleList = (entry) => { if (Array.isArray(entry?.TitleList)) { return entry.TitleList; } if (Array.isArray(entry?.Scan?.TitleList)) { return entry.Scan.TitleList; } if (Array.isArray(entry?.title_list)) { return entry.title_list; } return []; }; const titleSets = parsedObjects .map((entry, index) => ({ entry, index })) .filter(({ entry }) => hasTitleList(entry)) .map(({ entry, index }) => { const titles = getTitleList(entry); let audioTracks = 0; let subtitleTracks = 0; let validAudioTracks = 0; let validSubtitleTracks = 0; for (const title of titles) { const audioList = Array.isArray(title?.AudioList) ? title.AudioList : []; const subtitleList = Array.isArray(title?.SubtitleList) ? title.SubtitleList : []; audioTracks += audioList.length; subtitleTracks += subtitleList.length; validAudioTracks += audioList.filter((track) => Number.isFinite(Number(track?.TrackNumber)) && Number(track.TrackNumber) > 0).length; validSubtitleTracks += subtitleList.filter((track) => Number.isFinite(Number(track?.TrackNumber)) && Number(track.TrackNumber) > 0).length; } return { entry, index, titleCount: titles.length, audioTracks, subtitleTracks, validAudioTracks, validSubtitleTracks }; }); if (titleSets.length > 0) { titleSets.sort((a, b) => b.validAudioTracks - a.validAudioTracks || b.validSubtitleTracks - a.validSubtitleTracks || b.audioTracks - a.audioTracks || b.subtitleTracks - a.subtitleTracks || b.titleCount - a.titleCount || b.index - a.index ); return titleSets[0].entry; } const mediaSets = parsedObjects .map((entry, index) => ({ entry, index })) .filter(({ entry }) => hasMediaTrack(entry)) .map(({ entry, index }) => { const tracks = Array.isArray(entry?.media?.track) ? entry.media.track : (Array.isArray(entry?.Media?.track) ? entry.Media.track : []); return { entry, index, trackCount: tracks.length }; }); if (mediaSets.length > 0) { mediaSets.sort((a, b) => b.trackCount - a.trackCount || b.index - a.index); return mediaSets[0].entry; } return parsedObjects[parsedObjects.length - 1] || null; } function getMediaInfoTrackList(mediaInfoJson) { if (Array.isArray(mediaInfoJson?.media?.track)) { return mediaInfoJson.media.track; } if (Array.isArray(mediaInfoJson?.Media?.track)) { return mediaInfoJson.Media.track; } return []; } function countMediaInfoTrackTypes(mediaInfoJson) { const tracks = getMediaInfoTrackList(mediaInfoJson); let audioCount = 0; let subtitleCount = 0; for (const track of tracks) { const type = String(track?.['@type'] || '').trim().toLowerCase(); if (type === 'audio') { audioCount += 1; continue; } if (type === 'text' || type === 'subtitle') { subtitleCount += 1; } } return { audioCount, subtitleCount }; } function shouldRunDvdTrackFallback(parsedMediaInfo, mediaProfile, inputPath) { if (normalizeMediaProfile(mediaProfile) !== 'dvd') { return false; } if (path.extname(String(inputPath || '')).toLowerCase() !== '') { return false; } const counts = countMediaInfoTrackTypes(parsedMediaInfo); return counts.audioCount === 0 && counts.subtitleCount === 0; } function parseHmsDurationToSeconds(raw) { const value = String(raw || '').trim(); if (!value) { return 0; } const match = value.match(/^(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?$/); if (!match) { return 0; } const hours = Number(match[1]); const minutes = Number(match[2]); const seconds = Number(match[3]); if (!Number.isFinite(hours) || !Number.isFinite(minutes) || !Number.isFinite(seconds)) { return 0; } return (hours * 3600) + (minutes * 60) + seconds; } function parseHandBrakeDurationSeconds(rawDuration) { if (rawDuration && typeof rawDuration === 'object') { const hours = Number(rawDuration.Hours ?? rawDuration.hours ?? 0); const minutes = Number(rawDuration.Minutes ?? rawDuration.minutes ?? 0); const seconds = Number(rawDuration.Seconds ?? rawDuration.seconds ?? 0); if (Number.isFinite(hours) && Number.isFinite(minutes) && Number.isFinite(seconds)) { return Math.max(0, Math.trunc((hours * 3600) + (minutes * 60) + seconds)); } } const parsedHms = parseHmsDurationToSeconds(rawDuration); if (parsedHms > 0) { return parsedHms; } const asNumber = Number(rawDuration); if (Number.isFinite(asNumber) && asNumber > 0) { return Math.max(0, Math.trunc(asNumber)); } return 0; } function formatDurationClock(seconds) { const total = Number(seconds || 0); if (!Number.isFinite(total) || total <= 0) { return null; } const rounded = Math.max(0, Math.trunc(total)); const h = Math.floor(rounded / 3600); const m = Math.floor((rounded % 3600) / 60); const s = rounded % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } function normalizeTrackLanguage(raw) { const value = String(raw || '').trim(); if (!value) { return 'und'; } return value.toLowerCase().slice(0, 3); } function normalizePositiveTrackId(rawValue) { const parsed = Number(rawValue); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function normalizeSubtitleConfidence(raw) { const value = String(raw || '').trim().toLowerCase(); if (value === 'high' || value === 'medium' || value === 'low') { return value; } return 'low'; } function subtitleConfidenceScore(raw) { const normalized = normalizeSubtitleConfidence(raw); return SUBTITLE_CONFIDENCE_SCORES[normalized] || 0; } function parseSubtitleEventCount(track) { const candidates = [ track?.eventCount, track?.EventCount, track?.countOfEvents, track?.CountOfEvents, track?.elementCount, track?.ElementCount ]; for (const candidate of candidates) { const numeric = Number(candidate); if (Number.isFinite(numeric) && numeric >= 0) { return Math.trunc(numeric); } } return null; } function parseSubtitleStreamSizeBytes(track) { const numericCandidates = [ track?.streamSizeBytes, track?.streamSize, track?.StreamSize, track?.sizeBytes, track?.bytes, track?.Bytes ]; for (const candidate of numericCandidates) { const numeric = Number(candidate); if (Number.isFinite(numeric) && numeric > 0) { return Math.trunc(numeric); } } const textCandidates = [ track?.streamSizeString, track?.StreamSize_String, track?.sizeString, track?.Size_String ]; for (const candidate of textCandidates) { const parsed = parseSizeToBytes(candidate); if (parsed > 0) { return parsed; } } return null; } function parseSubtitleDefaultFlag(track) { if (typeof track?.defaultFlag === 'boolean') { return track.defaultFlag; } const candidates = [ track?.default, track?.Default, track?.isDefault, track?.IsDefault, track?.subtitlePreviewDefaultTrack, track?.defaultTrack ]; for (const candidate of candidates) { if (typeof candidate === 'boolean') { return candidate; } const raw = String(candidate || '').trim().toLowerCase(); if (!raw) { continue; } if (raw === 'yes' || raw === 'true' || raw === '1') { return true; } if (raw === 'no' || raw === 'false' || raw === '0') { return false; } } return false; } function parseLooseBoolean(value) { if (typeof value === 'boolean') { return value; } if (typeof value === 'number') { if (value === 1) { return true; } if (value === 0) { return false; } } const raw = String(value || '').trim().toLowerCase(); if (!raw) { return null; } if (raw === 'yes' || raw === 'true' || raw === '1' || raw === 'y') { return true; } if (raw === 'no' || raw === 'false' || raw === '0' || raw === 'n') { return false; } return null; } function parseSubtitleForcedFlag(track) { if (!track || typeof track !== 'object') { return null; } const candidates = [ track?.forcedFlag, track?.forced, track?.Forced, track?.isForced, track?.IsForced, track?.forced_only, track?.forcedOnly, track?.Attributes?.Forced ]; for (const candidate of candidates) { const parsed = parseLooseBoolean(candidate); if (parsed === true || parsed === false) { return parsed; } } return null; } function parseSubtitleSdhFlag(track) { if (!track || typeof track !== 'object') { return null; } const candidates = [ track?.sdhFlag, track?.hearingImpaired, track?.HearingImpaired, track?.isHearingImpaired, track?.IsHearingImpaired, track?.Hearing_Impaired, track?.closedCaptions, track?.ClosedCaptions, track?.Attributes?.HearingImpaired, track?.Attributes?.ClosedCaptions ]; for (const candidate of candidates) { const parsed = parseLooseBoolean(candidate); if (parsed === true || parsed === false) { return parsed; } } return null; } function collectSubtitleText(track) { return [ track?.title, track?.description, track?.name, track?.format, track?.label ] .map((value) => String(value || '').trim().toLowerCase()) .filter(Boolean) .join(' '); } function isLikelyBitmapSubtitleFormat(track) { const text = [ track?.format, track?.codec, track?.codecName, track?.title, track?.description, track?.name, track?.label ] .map((value) => String(value || '').trim().toLowerCase()) .filter(Boolean) .join(' '); if (!text) { return false; } return ( /\bpgs\b/.test(text) || /\bhdmv\b/.test(text) || /\bsup\b/.test(text) || /\bvobsub\b/.test(text) || /\bdvd[-_\s]?sub/.test(text) || /\bdvb[-_\s]?sub/.test(text) ); } function isLikelySdhSubtitleTrack(track) { const text = collectSubtitleText(track); if (!text) { return false; } return ( /\bsdh\b/.test(text) || /\bcc\b/.test(text) || /\bhoh\b/.test(text) || /\bcaptions?\b/.test(text) || /hard[-\s]?of[-\s]?hearing/.test(text) || /hearing\s+impaired/.test(text) || /(?:gehörlos|hoergeschaedigt|hörgeschädigt)/.test(text) ); } function isLikelyForcedSubtitleTrack(track) { const text = collectSubtitleText(track); if (!text) { return false; } if (isLikelySdhSubtitleTrack(track) || /\bnot forced\b/.test(text)) { return false; } return ( /\bforced(?:\s+only)?\b/.test(text) || /nur\s+erzwungen/.test(text) || /\berzwungen\b/.test(text) ); } function compareSubtitleTracksByForcedHeuristic(a, b) { const aSdh = Number(Boolean(a?.sdhLikely)); const bSdh = Number(Boolean(b?.sdhLikely)); if (aSdh !== bSdh) { return aSdh - bSdh; } const aDefault = Number(Boolean(a?.defaultFlag)); const bDefault = Number(Boolean(b?.defaultFlag)); if (aDefault !== bDefault) { return aDefault - bDefault; } const aEventKnown = Number.isFinite(a?.eventCount) ? 0 : 1; const bEventKnown = Number.isFinite(b?.eventCount) ? 0 : 1; if (aEventKnown !== bEventKnown) { return aEventKnown - bEventKnown; } if (aEventKnown === 0 && a.eventCount !== b.eventCount) { return a.eventCount - b.eventCount; } const aSizeKnown = Number.isFinite(a?.streamSizeBytes) ? 0 : 1; const bSizeKnown = Number.isFinite(b?.streamSizeBytes) ? 0 : 1; if (aSizeKnown !== bSizeKnown) { return aSizeKnown - bSizeKnown; } if (aSizeKnown === 0 && a.streamSizeBytes !== b.streamSizeBytes) { return a.streamSizeBytes - b.streamSizeBytes; } const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER; const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER; if (aTrackId !== bTrackId) { return aTrackId - bTrackId; } return a.originalIndex - b.originalIndex; } function isHeuristicForcedSubtitleCandidate(entries, candidate) { if (!candidate) { return false; } if (!isLikelyBitmapSubtitleFormat(candidate)) { return false; } const comparable = (Array.isArray(entries) ? entries : []) .filter((entry) => entry !== candidate && !entry.sdhLikely && isLikelyBitmapSubtitleFormat(entry)); if (comparable.length === 0) { return false; } const candidateEventCount = Number(candidate?.eventCount); const comparableEventCounts = comparable .map((entry) => Number(entry?.eventCount)) .filter((value) => Number.isFinite(value) && value > 0); const maxComparableEventCount = comparableEventCounts.length > 0 ? Math.max(...comparableEventCounts) : null; const hasEventSignal = Number.isFinite(candidateEventCount) && candidateEventCount >= 0 && Number.isFinite(maxComparableEventCount) && maxComparableEventCount > 0 && candidateEventCount <= FORCED_SUBTITLE_MAX_EVENT_COUNT && (candidateEventCount / maxComparableEventCount) <= FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD && (maxComparableEventCount - candidateEventCount) >= FORCED_SUBTITLE_MIN_EVENT_GAP; const candidateStreamSize = Number(candidate?.streamSizeBytes); const comparableSizes = comparable .map((entry) => Number(entry?.streamSizeBytes)) .filter((value) => Number.isFinite(value) && value > 0); const maxComparableSize = comparableSizes.length > 0 ? Math.max(...comparableSizes) : null; const hasSizeSignal = Number.isFinite(candidateStreamSize) && candidateStreamSize > 0 && Number.isFinite(maxComparableSize) && maxComparableSize > 0 && (candidateStreamSize / maxComparableSize) <= FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD && (maxComparableSize - candidateStreamSize) >= FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES; const signalCount = Number(hasEventSignal) + Number(hasSizeSignal); if (signalCount === 0) { return false; } if (candidate.defaultFlag && signalCount < 2) { return false; } return true; } function compareSubtitleTracksForDedup(a, b) { const aSdh = Number(Boolean(a?.sdhLikely)); const bSdh = Number(Boolean(b?.sdhLikely)); if (aSdh !== bSdh) { return aSdh - bSdh; } const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag)); if (defaultDiff !== 0) { return defaultDiff; } const confidenceDiff = subtitleConfidenceScore(b?.confidence) - subtitleConfidenceScore(a?.confidence); if (confidenceDiff !== 0) { return confidenceDiff; } const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER; const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER; if (aTrackId !== bTrackId) { return aTrackId - bTrackId; } return a.originalIndex - b.originalIndex; } function annotateSubtitleForcedAvailability(handBrakeSubtitleTracks, makeMkvSubtitleTracks) { const hbTracks = Array.isArray(handBrakeSubtitleTracks) ? handBrakeSubtitleTracks : []; if (hbTracks.length === 0) { return []; } const mkTracks = Array.isArray(makeMkvSubtitleTracks) ? makeMkvSubtitleTracks : []; const normalizedMkTracks = mkTracks.map((track, index) => { const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'); const sourceTrackId = normalizePositiveTrackId(track?.sourceTrackId ?? track?.id); return { ...track, language, sourceTrackId, originalIndex: index }; }); const mkBySourceTrackId = new Map(); const mkByLanguage = new Map(); for (const track of normalizedMkTracks) { if (track.sourceTrackId) { const key = String(track.sourceTrackId); if (!mkBySourceTrackId.has(key)) { mkBySourceTrackId.set(key, track); } } if (!mkByLanguage.has(track.language)) { mkByLanguage.set(track.language, []); } mkByLanguage.get(track.language).push(track); } const normalizedHbTracks = hbTracks.map((track, index) => { const id = normalizePositiveTrackId(track?.id) || normalizeScanTrackId(track?.id, index); const forcedFlag = parseSubtitleForcedFlag(track); const sdhFlag = parseSubtitleSdhFlag(track); return { track, originalIndex: index, id, sourceTrackId: normalizePositiveTrackId(track?.sourceTrackId ?? track?.id), language: normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'), defaultFlag: parseSubtitleDefaultFlag(track), forcedFlag, sdhFlag, eventCount: parseSubtitleEventCount(track), streamSizeBytes: parseSubtitleStreamSizeBytes(track), mkTrack: null, sdhLikely: (sdhFlag === true) || isLikelySdhSubtitleTrack(track), forced: false, confidence: null, confidenceSource: 'heuristic', duplicate: false, selected: false }; }); const hbByLanguage = new Map(); for (const entry of normalizedHbTracks) { if (!hbByLanguage.has(entry.language)) { hbByLanguage.set(entry.language, []); } hbByLanguage.get(entry.language).push(entry); } for (const [language, entries] of hbByLanguage.entries()) { const sortedEntries = [...entries].sort((a, b) => ( (a.sourceTrackId || Number.MAX_SAFE_INTEGER) - (b.sourceTrackId || Number.MAX_SAFE_INTEGER) || a.originalIndex - b.originalIndex )); const mkLanguageTracks = [...(mkByLanguage.get(language) || [])].sort((a, b) => ( (a.sourceTrackId || Number.MAX_SAFE_INTEGER) - (b.sourceTrackId || Number.MAX_SAFE_INTEGER) || a.originalIndex - b.originalIndex )); for (let index = 0; index < sortedEntries.length; index += 1) { const entry = sortedEntries[index]; let mkMatch = null; if (entry.sourceTrackId) { mkMatch = mkBySourceTrackId.get(String(entry.sourceTrackId)) || null; if (mkMatch && mkMatch.language !== language) { mkMatch = null; } } if (!mkMatch) { if (mkLanguageTracks.length === sortedEntries.length) { mkMatch = mkLanguageTracks[index] || null; } else if (mkLanguageTracks.length === 1) { mkMatch = mkLanguageTracks[0]; } } entry.mkTrack = mkMatch; if (mkMatch && ((parseSubtitleSdhFlag(mkMatch) === true) || isLikelySdhSubtitleTrack(mkMatch))) { entry.sdhLikely = true; } } } for (const entry of normalizedHbTracks) { const forcedByExplicitFlag = entry.forcedFlag === true; const forcedBlockedByExplicitFlag = entry.forcedFlag === false; const forcedFromTitle = !entry.sdhLikely && !forcedBlockedByExplicitFlag && (isLikelyForcedSubtitleTrack(entry.track) || isLikelyForcedSubtitleTrack(entry.mkTrack)); if (forcedByExplicitFlag) { entry.forced = true; entry.confidence = 'high'; entry.confidenceSource = 'explicit_flag'; } else if (forcedFromTitle) { entry.forced = true; entry.confidence = 'medium'; entry.confidenceSource = 'title'; } else { entry.forced = false; entry.confidence = null; entry.confidenceSource = 'heuristic'; } } for (const [language, entries] of hbByLanguage.entries()) { if (!entries.some((entry) => entry.forced)) { const heuristicCandidate = [...entries] .filter((entry) => !entry.sdhLikely && entry.forcedFlag !== false) .sort(compareSubtitleTracksByForcedHeuristic)[0] || null; if (!entries.some((entry) => entry.forced) && heuristicCandidate && isHeuristicForcedSubtitleCandidate(entries, heuristicCandidate)) { // Last-resort: infer forced by comparatively tiny event/size profile. // Avoid auto-detection for SDH/CC tracks and weak one-track cases. heuristicCandidate.forced = true; heuristicCandidate.confidence = 'low'; heuristicCandidate.confidenceSource = 'heuristic'; } } for (const entry of entries) { if (!entry.forced) { continue; } if (entry.confidenceSource === 'explicit_flag') { entry.confidence = 'high'; } else if (entry.confidenceSource === 'title') { entry.confidence = 'medium'; } else { entry.confidence = 'low'; } } const forcedEntries = entries.filter((entry) => entry.forced); const fullEntries = entries.filter((entry) => !entry.forced && !entry.sdhLikely); const sdhEntries = entries.filter((entry) => !entry.forced && entry.sdhLikely); const forcedWinner = forcedEntries.length > 0 ? [...forcedEntries].sort(compareSubtitleTracksForDedup)[0] : null; const fullWinner = fullEntries.length > 0 ? [...fullEntries].sort(compareSubtitleTracksForDedup)[0] : null; const sdhWinner = sdhEntries.length > 0 ? [...sdhEntries].sort(compareSubtitleTracksForDedup)[0] : null; const forcedSourceTrackIds = normalizeTrackIdList([ ...(forcedWinner?.sourceTrackId ? [forcedWinner.sourceTrackId] : []) ]); const forcedAvailable = Boolean(forcedWinner); for (const entry of entries) { const typeWinner = entry.forced ? forcedWinner : (entry.sdhLikely ? sdhWinner : fullWinner); entry.duplicate = Boolean(typeWinner && typeWinner !== entry); if (entry.forced) { entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate); } else if (entry.sdhLikely) { // Keep SDH as an available variant, but auto-select it only when no regular full track exists. entry.selected = Boolean(!fullWinner && typeWinner && typeWinner === entry && !entry.duplicate); } else { entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate); } const subtitleType = entry.forced ? 'forced' : 'full'; const confidence = entry.forced ? normalizeSubtitleConfidence(entry.confidence || 'low') : null; const burned = Boolean(entry.track?.subtitlePreviewBurnIn || entry.track?.burnIn); const defaultTrack = Boolean(entry.defaultFlag); const forcedOnly = Boolean( entry.track?.isForcedOnly ?? entry.track?.forcedOnly ?? entry.track?.subtitlePreviewForcedOnly ?? (entry.forced && !entry.sdhLikely) ); const fullHasForced = !forcedOnly && Boolean( entry.track?.fullHasForced ?? entry.track?.subtitleFullHasForced ?? entry.track?.hasForcedVariant ); const flags = []; if (burned) { flags.push('burned'); } if (entry.selected && entry.forced) { flags.push('forced'); } if (forcedOnly) { flags.push('forced-only'); } if (entry.selected && defaultTrack) { flags.push('default'); } entry.track = { ...entry.track, id: entry.id, sourceTrackId: entry.sourceTrackId || entry.id, language: entry.language, forcedTrack: entry.forced, forcedAvailable, forcedSourceTrackIds, isForcedOnly: forcedOnly, fullHasForced, subtitleType, sourceConfidence: confidence, confidenceSource: entry.confidenceSource, sdhLikely: Boolean(entry.sdhLikely), duplicate: entry.duplicate, selected: entry.selected, selectedByRule: entry.selected, subtitlePreviewSummary: entry.selected ? 'Übernehmen' : (entry.duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen'), subtitlePreviewFlags: entry.selected ? flags : [], subtitlePreviewBurnIn: entry.selected ? burned : false, subtitlePreviewForced: entry.selected ? entry.forced : false, subtitlePreviewForcedOnly: entry.selected ? forcedOnly : false, subtitlePreviewDefaultTrack: entry.selected ? defaultTrack : false }; } } return normalizedHbTracks .sort((a, b) => a.originalIndex - b.originalIndex) .map((entry) => entry.track); } function enrichTitleInfoWithForcedSubtitleAvailability(titleInfo, makeMkvSubtitleTracks) { if (!titleInfo || typeof titleInfo !== 'object') { return titleInfo; } return { ...titleInfo, subtitleTracks: annotateSubtitleForcedAvailability( Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : [], makeMkvSubtitleTracks ) }; } function pickScanTitleList(scanJson) { if (!scanJson || typeof scanJson !== 'object') { return []; } const direct = Array.isArray(scanJson.TitleList) ? scanJson.TitleList : null; if (direct) { return direct; } const scanNode = scanJson.Scan && typeof scanJson.Scan === 'object' ? scanJson.Scan : null; if (scanNode) { const scanTitles = Array.isArray(scanNode.TitleList) ? scanNode.TitleList : null; if (scanTitles) { return scanTitles; } } const alt = Array.isArray(scanJson.title_list) ? scanJson.title_list : null; return alt || []; } // Returns the HandBrake-selected main feature title ID (the "1 valid title" HandBrake reports). // Present in the JSON as MainFeature at the top level or inside the Scan node. function pickScanMainFeatureTitleId(scanJson) { if (!scanJson || typeof scanJson !== 'object') { return null; } const direct = scanJson.MainFeature ?? scanJson.mainFeature ?? null; if (direct != null) { const id = Number(direct); return Number.isFinite(id) && id > 0 ? id : null; } const scanNode = scanJson.Scan && typeof scanJson.Scan === 'object' ? scanJson.Scan : null; if (scanNode) { const nested = scanNode.MainFeature ?? scanNode.mainFeature ?? null; if (nested != null) { const id = Number(nested); return Number.isFinite(id) && id > 0 ? id : null; } } return null; } function resolvePlaylistInfoFromAnalysis(playlistAnalysis, playlistIdRaw) { const playlistId = normalizePlaylistId(playlistIdRaw); if (!playlistId || !playlistAnalysis) { return { playlistId: playlistId || null, playlistFile: playlistId ? `${playlistId}.mpls` : null, recommended: false, evaluationLabel: null, segmentCommand: playlistId ? `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts` : null, segmentFiles: [] }; } const recommended = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId) === playlistId; const evaluated = (Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates : []) .find((item) => normalizePlaylistId(item?.playlistId) === playlistId) || null; const segmentMap = playlistAnalysis.playlistSegments && typeof playlistAnalysis.playlistSegments === 'object' ? playlistAnalysis.playlistSegments : {}; const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null; const segmentFiles = Array.isArray(segmentEntry?.segmentFiles) ? segmentEntry.segmentFiles.filter((item) => String(item || '').trim().length > 0) : []; return { playlistId, playlistFile: `${playlistId}.mpls`, recommended, evaluationLabel: evaluated?.evaluationLabel || (recommended ? 'wahrscheinlich korrekt (Heuristik)' : null), segmentCommand: segmentEntry?.segmentCommand || `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`, segmentFiles }; } function normalizeScanTrackId(rawValue, fallbackIndex) { const parsed = Number(rawValue); if (Number.isFinite(parsed) && parsed > 0) { return Math.trunc(parsed); } return Math.max(1, Math.trunc(fallbackIndex) + 1); } function parseSizeToBytes(rawValue) { const text = String(rawValue || '').trim(); if (!text) { return 0; } if (/^\d+$/.test(text)) { const numeric = Number(text); if (Number.isFinite(numeric) && numeric > 0) { return Math.trunc(numeric); } } const match = text.match(/([0-9]+(?:[.,][0-9]+)?)\s*([kmgt]?b)/i); if (!match) { return 0; } const value = Number(String(match[1]).replace(',', '.')); if (!Number.isFinite(value) || value <= 0) { return 0; } const unit = String(match[2] || 'b').toLowerCase(); const factorMap = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3, tb: 1024 ** 4 }; const factor = factorMap[unit] || 1; return Math.max(0, Math.trunc(value * factor)); } function parseMakeMkvDurationSeconds(rawValue) { const hms = parseHmsDurationToSeconds(rawValue); if (hms > 0) { return hms; } const text = String(rawValue || '').trim(); if (!text) { return 0; } const hours = Number((text.match(/(\d+)\s*h/i) || [])[1] || 0); const minutes = Number((text.match(/(\d+)\s*m/i) || [])[1] || 0); const seconds = Number((text.match(/(\d+)\s*s/i) || [])[1] || 0); if (hours || minutes || seconds) { return (hours * 3600) + (minutes * 60) + seconds; } const numeric = Number(text); if (Number.isFinite(numeric) && numeric > 0) { return Math.trunc(numeric); } return 0; } function buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo) { const durationSeconds = Math.max(0, Math.trunc(Number(titleInfo?.durationSeconds || 0))); const tracks = []; tracks.push({ '@type': 'General', // MediaInfo reports numeric Duration as milliseconds. Keep this format so // parseDurationSeconds() does not misinterpret long titles. Duration: String(durationSeconds * 1000), Duration_String3: formatDurationClock(durationSeconds) || null }); const audioTracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []; const subtitleTracks = Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : []; for (const track of audioTracks) { tracks.push({ '@type': 'Audio', ID: String(track?.sourceTrackId ?? track?.id ?? ''), Language: track?.language || 'und', Language_String3: track?.language || 'und', Title: track?.title || null, Format: track?.codecName || track?.format || null, Channels: track?.channels || null }); } for (const track of subtitleTracks) { tracks.push({ '@type': 'Text', ID: String(track?.sourceTrackId ?? track?.id ?? ''), Language: track?.language || 'und', Language_String3: track?.language || 'und', Title: track?.title || null, Format: track?.format || null, Default: track?.defaultFlag ? 'Yes' : 'No', CountOfEvents: Number.isFinite(Number(track?.eventCount)) ? Math.trunc(Number(track.eventCount)) : null, StreamSize: Number.isFinite(Number(track?.streamSizeBytes)) ? Math.trunc(Number(track.streamSizeBytes)) : null }); } return { media: { track: tracks } }; } function remapReviewTrackIdsToSourceIds(review) { if (!review || !Array.isArray(review.titles)) { return review; } const normalizeSourceId = (track) => { const normalized = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null; return normalized; }; const titles = review.titles.map((title) => ({ ...title, audioTracks: (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => { const sourceTrackId = normalizeSourceId(track); return { ...track, id: sourceTrackId || track?.id || null, sourceTrackId: sourceTrackId || track?.sourceTrackId || track?.id || null }; }), subtitleTracks: (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => { const sourceTrackId = normalizeSourceId(track); return { ...track, id: sourceTrackId || track?.id || null, sourceTrackId: sourceTrackId || track?.sourceTrackId || track?.id || null }; }) })); return { ...review, titles }; } function extractPlaylistIdFromHandBrakeTitle(title) { const directCandidates = [ title?.Playlist, title?.playlist, title?.PlaylistName, title?.playlistName, title?.SourcePlaylist, title?.sourcePlaylist ]; for (const candidate of directCandidates) { const normalized = normalizePlaylistId(candidate); if (normalized) { return normalized; } } const textCandidates = [ title?.Path, title?.path, title?.Name, title?.name, title?.File, title?.file, title?.TitleName, title?.titleName, title?.SourceName, title?.sourceName ]; for (const candidate of textCandidates) { const text = String(candidate || '').trim(); if (!text) { continue; } const match = text.match(/(\d{1,5})\.mpls\b/i); if (!match) { continue; } const normalized = normalizePlaylistId(match[1]); if (normalized) { return normalized; } } return null; } function parseHandBrakeScanSizeBytes(title) { const numeric = Number(title?.Size?.Bytes ?? title?.Bytes ?? 0); if (!Number.isFinite(numeric) || numeric <= 0) { return 0; } return Math.trunc(numeric); } function buildHandBrakeScanTitleRows(scanJson) { const titleList = pickScanTitleList(scanJson); return titleList .map((title, idx) => { const handBrakeTitleId = normalizeScanTrackId( title?.Index ?? title?.index ?? title?.Title ?? title?.title, idx ); const playlist = extractPlaylistIdFromHandBrakeTitle(title); const durationSeconds = parseHandBrakeDurationSeconds( title?.Duration ?? title?.duration ?? title?.Length ?? title?.length ); const sizeBytes = parseHandBrakeScanSizeBytes(title); const audioTrackCount = Array.isArray(title?.AudioList) ? title.AudioList.length : 0; const subtitleTrackCount = Array.isArray(title?.SubtitleList) ? title.SubtitleList.length : 0; return { handBrakeTitleId, playlist, durationSeconds, sizeBytes, audioTrackCount, subtitleTrackCount }; }) .filter((item) => Number.isFinite(item.handBrakeTitleId) && item.handBrakeTitleId > 0); } function listAvailableHandBrakePlaylists(scanJson) { const rows = buildHandBrakeScanTitleRows(scanJson); return Array.from(new Set( rows .map((item) => normalizePlaylistId(item?.playlist)) .filter(Boolean) )).sort(); } function resolveHandBrakeTitleIdForPlaylist(scanJson, playlistIdRaw, options = {}) { const playlistId = normalizePlaylistId(playlistIdRaw); if (!playlistId) { return null; } const expectedMakemkvTitleIdRaw = Number(options?.expectedMakemkvTitleId); const expectedMakemkvTitleId = Number.isFinite(expectedMakemkvTitleIdRaw) && expectedMakemkvTitleIdRaw >= 0 ? Math.trunc(expectedMakemkvTitleIdRaw) : null; const expectedDurationRaw = Number(options?.expectedDurationSeconds); const expectedDurationSeconds = Number.isFinite(expectedDurationRaw) && expectedDurationRaw > 0 ? Math.trunc(expectedDurationRaw) : null; const expectedSizeRaw = Number(options?.expectedSizeBytes); const expectedSizeBytes = Number.isFinite(expectedSizeRaw) && expectedSizeRaw > 0 ? Math.trunc(expectedSizeRaw) : null; const durationToleranceRaw = Number(options?.durationToleranceSeconds); const durationToleranceSeconds = Number.isFinite(durationToleranceRaw) && durationToleranceRaw >= 0 ? Math.trunc(durationToleranceRaw) : 5; const rows = buildHandBrakeScanTitleRows(scanJson); const matches = rows.filter((item) => item.playlist === playlistId); const scoreForExpected = (row) => { const durationDelta = expectedDurationSeconds !== null ? Math.abs(Number(row?.durationSeconds || 0) - expectedDurationSeconds) : Number.MAX_SAFE_INTEGER; const sizeDelta = expectedSizeBytes !== null ? Math.abs(Number(row?.sizeBytes || 0) - expectedSizeBytes) : Number.MAX_SAFE_INTEGER; const trackRichness = Number(row?.audioTrackCount || 0) + Number(row?.subtitleTrackCount || 0); return { row, durationDelta, sizeDelta, trackRichness }; }; const sortByExpectedScore = (a, b) => a.durationDelta - b.durationDelta || a.sizeDelta - b.sizeDelta || b.trackRichness - a.trackRichness || b.row.durationSeconds - a.row.durationSeconds || b.row.sizeBytes - a.row.sizeBytes || a.row.handBrakeTitleId - b.row.handBrakeTitleId; if (matches.length > 0) { if (expectedDurationSeconds !== null || expectedSizeBytes !== null) { const scored = matches.map(scoreForExpected).sort(sortByExpectedScore); if (expectedDurationSeconds !== null) { const withinTolerance = scored.filter((item) => item.durationDelta <= durationToleranceSeconds); if (withinTolerance.length > 0) { return withinTolerance[0].row.handBrakeTitleId; } } return scored[0].row.handBrakeTitleId; } const best = matches.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.handBrakeTitleId - b.handBrakeTitleId )[0]; return best?.handBrakeTitleId || null; } // Fallback 1: choose closest duration/size if playlist metadata is absent in scan JSON. if ((expectedDurationSeconds !== null || expectedSizeBytes !== null) && rows.length > 0) { const scored = rows.map(scoreForExpected).sort(sortByExpectedScore); if (expectedDurationSeconds !== null) { const withinTolerance = scored.filter((item) => item.durationDelta <= durationToleranceSeconds); if (withinTolerance.length > 0) { return withinTolerance[0].row.handBrakeTitleId; } } return scored[0].row.handBrakeTitleId; } // Fallback 2: map MakeMKV title-id to HandBrake title-id if ordering matches. if (expectedMakemkvTitleId !== null) { const byPlusOne = rows.find((item) => item.handBrakeTitleId === (expectedMakemkvTitleId + 1)); if (byPlusOne) { return byPlusOne.handBrakeTitleId; } const byEqual = rows.find((item) => item.handBrakeTitleId === expectedMakemkvTitleId); if (byEqual) { return byEqual.handBrakeTitleId; } } if (rows.length === 1) { return rows[0].handBrakeTitleId; } return null; } function isHandBrakePlaylistCacheEntryCompatible(entry, playlistIdRaw, options = {}) { const playlistId = normalizePlaylistId(playlistIdRaw); if (!playlistId) { return false; } if (!entry || typeof entry !== 'object') { return false; } const handBrakeTitleId = Number(entry?.handBrakeTitleId); if (!Number.isFinite(handBrakeTitleId) || handBrakeTitleId <= 0) { return false; } const titleInfo = entry?.titleInfo && typeof entry.titleInfo === 'object' ? entry.titleInfo : null; if (!titleInfo) { return false; } const cachedPlaylistId = normalizePlaylistId(titleInfo?.playlistId || null); if (cachedPlaylistId && cachedPlaylistId !== playlistId) { return false; } const expectedDurationRaw = Number(options?.expectedDurationSeconds); const expectedDurationSeconds = Number.isFinite(expectedDurationRaw) && expectedDurationRaw > 0 ? Math.trunc(expectedDurationRaw) : null; const cachedDurationRaw = Number(titleInfo?.durationSeconds); const cachedDurationSeconds = Number.isFinite(cachedDurationRaw) && cachedDurationRaw > 0 ? Math.trunc(cachedDurationRaw) : null; if (expectedDurationSeconds !== null && cachedDurationSeconds !== null) { // Reject clearly wrong cache mappings (e.g. 30s instead of 6681s movie title). if (Math.abs(expectedDurationSeconds - cachedDurationSeconds) > 120) { return false; } } const expectedSizeRaw = Number(options?.expectedSizeBytes); const expectedSizeBytes = Number.isFinite(expectedSizeRaw) && expectedSizeRaw > 0 ? Math.trunc(expectedSizeRaw) : null; const cachedSizeRaw = Number(titleInfo?.sizeBytes); const cachedSizeBytes = Number.isFinite(cachedSizeRaw) && cachedSizeRaw > 0 ? Math.trunc(cachedSizeRaw) : null; if (expectedSizeBytes !== null && cachedSizeBytes !== null) { const delta = Math.abs(expectedSizeBytes - cachedSizeBytes); if (delta > (512 * 1024 * 1024)) { return false; } } return true; } function normalizeCodecNumber(value) { const numeric = Number(value); if (!Number.isFinite(numeric)) { return null; } return Math.trunc(numeric); } function hasDtsHdMarker(track) { const text = `${track?.description || ''} ${track?.title || ''} ${track?.format || ''} ${track?.codecName || ''}` .toLowerCase(); const codec = normalizeCodecNumber(track?.codec); return text.includes('dts-hd') || text.includes('dts hd') || codec === 262144; } function isLikelyDtsCoreTrack(track) { const text = `${track?.description || ''} ${track?.title || ''} ${track?.format || ''} ${track?.codecName || ''}` .toLowerCase(); const codec = normalizeCodecNumber(track?.codec); const looksDts = text.includes('dts') || text.includes('dca'); const looksHd = text.includes('dts-hd') || text.includes('dts hd') || codec === 262144; if (!looksDts || looksHd) { return false; } // HandBrake uses 8192 for DTS core in scan JSON. if (codec !== null && codec !== 8192) { return false; } return true; } function filterDtsCoreFallbackTracks(audioTracks) { const tracks = Array.isArray(audioTracks) ? audioTracks : []; if (tracks.length === 0) { return []; } const hdLanguages = new Set( tracks .filter((track) => hasDtsHdMarker(track)) .map((track) => String(track?.language || 'und')) ); if (hdLanguages.size === 0) { return tracks; } return tracks.filter((track) => { const language = String(track?.language || 'und'); if (!hdLanguages.has(language)) { return true; } return !isLikelyDtsCoreTrack(track); }); } function parseHandBrakeTitleList(scanJson) { const titleList = pickScanTitleList(scanJson); if (!Array.isArray(titleList) || titleList.length === 0) { return []; } return titleList.map((title, idx) => { const handBrakeTitleId = normalizeScanTrackId( title?.Index ?? title?.index ?? title?.Title ?? title?.title, idx ); const durationSeconds = parseHandBrakeDurationSeconds( title?.Duration ?? title?.duration ?? title?.Length ?? title?.length ); const audioTrackCount = Array.isArray(title?.AudioList) ? title.AudioList.length : 0; const subtitleTrackCount = Array.isArray(title?.SubtitleList) ? title.SubtitleList.length : 0; const sizeBytes = Number(title?.Size?.Bytes ?? title?.Bytes ?? 0) || 0; return { handBrakeTitleId, durationSeconds, audioTrackCount, subtitleTrackCount, sizeBytes }; }); } function parseHandBrakeSelectedTitleInfo(scanJson, options = {}) { const titleList = pickScanTitleList(scanJson); if (!Array.isArray(titleList) || titleList.length === 0) { return null; } const preferredPlaylistId = normalizePlaylistId(options?.playlistId || null); const rawPreferredHandBrakeTitleId = Number(options?.handBrakeTitleId); const preferredHandBrakeTitleId = Number.isFinite(rawPreferredHandBrakeTitleId) && rawPreferredHandBrakeTitleId > 0 ? Math.trunc(rawPreferredHandBrakeTitleId) : null; const makeMkvSubtitleTracks = Array.isArray(options?.makeMkvSubtitleTracks) ? options.makeMkvSubtitleTracks : []; const parsedTitles = titleList.map((title, idx) => { const handBrakeTitleId = normalizeScanTrackId( title?.Index ?? title?.index ?? title?.Title ?? title?.title, idx ); const playlistId = normalizePlaylistId( title?.Playlist || title?.playlist || title?.PlaylistName || title?.playlistName || null ); const durationSeconds = parseHandBrakeDurationSeconds( title?.Duration ?? title?.duration ?? title?.Length ?? title?.length ); const sizeBytes = Number(title?.Size?.Bytes ?? title?.Bytes ?? 0) || 0; const rawFileName = String( title?.Name || title?.TitleName || title?.File || title?.SourceName || '' ).trim(); const fileName = rawFileName || `Title #${handBrakeTitleId}`; const audioTracksRaw = (Array.isArray(title?.AudioList) ? title.AudioList : []) .map((track, trackIndex) => { const sourceTrackId = normalizeScanTrackId( // Prefer source numbering from HandBrake JSON so UI/CLI IDs stay stable // (e.g. audio 2..10, subtitle 11..21 on some Blu-rays). track?.TrackNumber ?? track?.Track ?? track?.id ?? track?.ID ?? track?.Index, trackIndex ); const languageCode = normalizeTrackLanguage( track?.LanguageCode || track?.ISO639_2 || track?.Language || track?.language || 'und' ); const languageLabel = String( track?.Language || track?.LanguageCode || track?.language || languageCode ).trim() || languageCode; return { id: sourceTrackId, sourceTrackId, language: languageCode, languageLabel, title: track?.Name || track?.Description || null, description: track?.Description || null, codec: track?.Codec ?? null, codecName: track?.CodecName || null, format: track?.Codec || track?.CodecName || track?.CodecParam || null, channels: track?.ChannelLayoutName || track?.ChannelLayout || track?.Channels || null }; }) .filter((track) => Number.isFinite(Number(track?.sourceTrackId)) && Number(track.sourceTrackId) > 0); const audioTracks = filterDtsCoreFallbackTracks(audioTracksRaw); const subtitleTracksRaw = (Array.isArray(title?.SubtitleList) ? title.SubtitleList : []) .map((track, trackIndex) => { const sourceTrackId = normalizeScanTrackId( track?.TrackNumber ?? track?.Track ?? track?.id ?? track?.ID ?? track?.Index, trackIndex ); const languageCode = normalizeTrackLanguage( track?.LanguageCode || track?.ISO639_2 || track?.Language || track?.language || 'und' ); const languageLabel = String( track?.Language || track?.LanguageCode || track?.language || languageCode ).trim() || languageCode; return { id: sourceTrackId, sourceTrackId, language: languageCode, languageLabel, title: track?.Name || track?.Description || null, format: track?.SourceName || track?.Format || track?.Codec || null, channels: null, forcedFlag: parseSubtitleForcedFlag(track), sdhFlag: parseSubtitleSdhFlag(track), defaultFlag: parseSubtitleDefaultFlag(track), eventCount: parseSubtitleEventCount(track), streamSizeBytes: parseSubtitleStreamSizeBytes(track) }; }) .filter((track) => Number.isFinite(Number(track?.sourceTrackId)) && Number(track.sourceTrackId) > 0); const subtitleTracks = annotateSubtitleForcedAvailability(subtitleTracksRaw, makeMkvSubtitleTracks); return { handBrakeTitleId, playlistId, durationSeconds, sizeBytes, fileName, audioTracks, subtitleTracks }; }); let selected = null; if (preferredHandBrakeTitleId) { selected = parsedTitles.find((title) => title.handBrakeTitleId === preferredHandBrakeTitleId) || null; } if (!selected && preferredPlaylistId) { const playlistMatches = parsedTitles .filter((title) => normalizePlaylistId(title?.playlistId) === preferredPlaylistId) .sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.handBrakeTitleId - b.handBrakeTitleId); selected = playlistMatches[0] || null; } // Use HandBrake's own MainFeature designation before falling back to heuristics. // This is exactly the "1 valid title" HandBrake reports in text output. if (!selected) { const mainFeatureId = pickScanMainFeatureTitleId(scanJson); if (mainFeatureId) { selected = parsedTitles.find((title) => title.handBrakeTitleId === mainFeatureId) || null; } } if (!selected) { selected = parsedTitles .slice() .sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.handBrakeTitleId - b.handBrakeTitleId)[0] || null; } if (!selected) { return null; } return { source: 'handbrake_scan', titleId: selected.handBrakeTitleId, handBrakeTitleId: selected.handBrakeTitleId, fileName: selected.fileName, durationSeconds: selected.durationSeconds, sizeBytes: selected.sizeBytes, playlistId: selected.playlistId || preferredPlaylistId || null, audioTracks: selected.audioTracks, subtitleTracks: selected.subtitleTracks }; } function pickTitleIdForTrackReview(playlistAnalysis, selectedTitleId = null) { const explicit = normalizeNonNegativeInteger(selectedTitleId); if (explicit !== null) { return explicit; } const recommendationTitleId = Number(playlistAnalysis?.recommendation?.titleId); if (Number.isFinite(recommendationTitleId) && recommendationTitleId >= 0) { return Math.trunc(recommendationTitleId); } const candidates = Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates : []; if (candidates.length > 0) { const candidatesWithPlaylist = candidates.filter((item) => normalizePlaylistId(item?.playlistId)); const sortPool = candidatesWithPlaylist.length > 0 ? candidatesWithPlaylist : candidates; const sortedCandidates = [...sortPool].sort((a, b) => Number(b?.durationSeconds || 0) - Number(a?.durationSeconds || 0) || Number(b?.sizeBytes || 0) - Number(a?.sizeBytes || 0) || Number(a?.titleId || 0) - Number(b?.titleId || 0) ); const candidateTitleId = Number(sortedCandidates[0]?.titleId); if (Number.isFinite(candidateTitleId) && candidateTitleId >= 0) { return Math.trunc(candidateTitleId); } } const titles = Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : []; if (titles.length > 0) { const sortedTitles = [...titles].sort((a, b) => Number(b?.durationSeconds || 0) - Number(a?.durationSeconds || 0) || Number(b?.sizeBytes || 0) - Number(a?.sizeBytes || 0) || Number(a?.titleId || 0) - Number(b?.titleId || 0) ); const titleId = Number(sortedTitles[0]?.titleId); if (Number.isFinite(titleId) && titleId >= 0) { return Math.trunc(titleId); } } return null; } function isCandidateTitleId(playlistAnalysis, titleId) { const normalizedTitleId = normalizeNonNegativeInteger(titleId); if (normalizedTitleId === null) { return false; } const candidates = Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates : []; return candidates.some((item) => Number(item?.titleId) === normalizedTitleId); } function buildDiscScanReview({ scanJson, settings, playlistAnalysis = null, selectedPlaylistId = null, selectedMakemkvTitleId = null, mediaProfile = null, sourceArg = null, mode = 'pre_rip', preRip = true, encodeInputPath = null }) { const minLengthMinutes = Number(settings?.makemkv_min_length_minutes || 0); const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60)); const selectedPlaylist = normalizePlaylistId(selectedPlaylistId); const selectedMakemkvId = Number(selectedMakemkvTitleId); const titleList = pickScanTitleList(scanJson); const parsedTitles = titleList.map((title, idx) => { const reviewTitleId = normalizeScanTrackId( title?.Index ?? title?.index ?? title?.Title ?? title?.title, idx ); const durationSeconds = parseHandBrakeDurationSeconds( title?.Duration ?? title?.duration ?? title?.Length ?? title?.length ); const durationMinutes = Number((durationSeconds / 60).toFixed(2)); const rawPlaylist = title?.Playlist || title?.playlist || title?.PlaylistName || title?.playlistName || null; const playlistInfo = resolvePlaylistInfoFromAnalysis(playlistAnalysis, rawPlaylist); const mappedMakemkvTitle = Array.isArray(playlistAnalysis?.titles) ? (playlistAnalysis.titles.find((item) => normalizePlaylistId(item?.playlistId) === normalizePlaylistId(playlistInfo.playlistId) ) || null) : null; const makemkvTitleId = Number.isFinite(Number(mappedMakemkvTitle?.titleId)) ? Math.trunc(Number(mappedMakemkvTitle.titleId)) : null; const audioList = Array.isArray(title?.AudioList) ? title.AudioList : []; const subtitleList = Array.isArray(title?.SubtitleList) ? title.SubtitleList : []; const audioTracksRaw = audioList.map((item, trackIndex) => { const trackId = normalizeScanTrackId(item?.TrackNumber ?? item?.Track ?? item?.id, trackIndex); const languageLabel = String(item?.Language || item?.LanguageCode || item?.language || 'und'); const format = item?.Codec || item?.CodecName || item?.CodecParam || item?.Name || null; return { id: trackId, sourceTrackId: trackId, language: normalizeTrackLanguage(item?.LanguageCode || item?.ISO639_2 || languageLabel), languageLabel, title: item?.Name || item?.Description || null, description: item?.Description || null, codec: item?.Codec ?? null, codecName: item?.CodecName || null, format, channels: item?.ChannelLayoutName || item?.ChannelLayout || item?.Channels || null, selectedByRule: true, encodePreviewActions: [], encodePreviewSummary: 'Übernehmen' }; }); const audioTracks = filterDtsCoreFallbackTracks(audioTracksRaw); const subtitleTracksRaw = subtitleList.map((item, trackIndex) => { const trackId = normalizeScanTrackId(item?.TrackNumber ?? item?.Track ?? item?.id, trackIndex); const languageLabel = String(item?.Language || item?.LanguageCode || item?.language || 'und'); return { id: trackId, sourceTrackId: trackId, language: normalizeTrackLanguage(item?.LanguageCode || item?.ISO639_2 || languageLabel), languageLabel, title: item?.Name || item?.Description || null, format: item?.SourceName || item?.Format || null, forcedFlag: parseSubtitleForcedFlag(item), sdhFlag: parseSubtitleSdhFlag(item), defaultFlag: parseSubtitleDefaultFlag(item), eventCount: parseSubtitleEventCount(item), streamSizeBytes: parseSubtitleStreamSizeBytes(item), selectedByRule: true, subtitlePreviewSummary: 'Übernehmen', subtitlePreviewFlags: [], subtitlePreviewBurnIn: false, subtitlePreviewForced: false, subtitlePreviewForcedOnly: false, subtitlePreviewDefaultTrack: false }; }); const subtitleTracks = annotateSubtitleForcedAvailability( subtitleTracksRaw, Array.isArray(mappedMakemkvTitle?.subtitleTracks) ? mappedMakemkvTitle.subtitleTracks : [] ); return { id: reviewTitleId, filePath: encodeInputPath || `disc-track-scan://title-${reviewTitleId}`, fileName: `Disc Title ${reviewTitleId}`, makemkvTitleId, sizeBytes: Number(title?.Size?.Bytes ?? title?.Bytes ?? 0) || 0, durationSeconds, durationMinutes, selectedByMinLength: durationSeconds >= minLengthSeconds, playlistMatch: playlistInfo, audioTracks, subtitleTracks }; }); const encodeCandidates = parsedTitles.filter((item) => item.selectedByMinLength); const selectedPlaylistCandidate = selectedPlaylist ? encodeCandidates.filter((item) => normalizePlaylistId(item?.playlistMatch?.playlistId) === selectedPlaylist) : []; const selectedMakemkvCandidate = Number.isFinite(selectedMakemkvId) && selectedMakemkvId >= 0 ? encodeCandidates.find((item) => Number(item?.makemkvTitleId) === Math.trunc(selectedMakemkvId)) || null : null; const preferredByIndex = Number.isFinite(selectedMakemkvId) && selectedMakemkvId >= 0 ? encodeCandidates.find((item) => Number(item?.id) === Math.trunc(selectedMakemkvId)) || null : null; let encodeInputTitle = null; if (selectedPlaylistCandidate.length > 0) { encodeInputTitle = selectedPlaylistCandidate.reduce((best, current) => ( !best || current.durationSeconds > best.durationSeconds ? current : best ), null); } else if (selectedMakemkvCandidate) { encodeInputTitle = selectedMakemkvCandidate; } else if (preferredByIndex) { encodeInputTitle = preferredByIndex; } else { encodeInputTitle = encodeCandidates.reduce((best, current) => ( !best || current.durationSeconds > best.durationSeconds ? current : best ), null); } const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired && !selectedPlaylist); const normalizedTitles = parsedTitles.map((title) => { const isEncodeInput = Boolean(encodeInputTitle && Number(encodeInputTitle.id) === Number(title.id)); return { ...title, selectedForEncode: isEncodeInput, encodeInput: isEncodeInput, eligibleForEncode: title.selectedByMinLength, playlistId: title.playlistMatch?.playlistId || null, playlistFile: title.playlistMatch?.playlistFile || null, playlistRecommended: Boolean(title.playlistMatch?.recommended), playlistEvaluationLabel: title.playlistMatch?.evaluationLabel || null, playlistSegmentCommand: title.playlistMatch?.segmentCommand || null, playlistSegmentFiles: Array.isArray(title.playlistMatch?.segmentFiles) ? title.playlistMatch.segmentFiles : [], audioTracks: title.audioTracks.map((track) => { const selectedForEncode = isEncodeInput && Boolean(track.selectedByRule); return { ...track, selectedForEncode, encodeActions: [], encodeActionSummary: selectedForEncode ? 'Übernehmen' : 'Nicht übernommen' }; }), subtitleTracks: title.subtitleTracks.map((track) => { const selectedForEncode = isEncodeInput && Boolean(track.selectedByRule); const previewFlags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []; const previewSummary = track?.subtitlePreviewSummary || 'Nicht übernommen'; return { ...track, selectedForEncode, burnIn: selectedForEncode ? Boolean(track?.subtitlePreviewBurnIn) : false, forced: selectedForEncode ? Boolean(track?.subtitlePreviewForced) : false, forcedOnly: selectedForEncode ? Boolean(track?.subtitlePreviewForcedOnly) : false, defaultTrack: selectedForEncode ? Boolean(track?.subtitlePreviewDefaultTrack) : false, flags: selectedForEncode ? previewFlags : [], subtitleActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen' }; }) }; }); const selectedTitleIds = normalizedTitles.filter((item) => item.selectedByMinLength).map((item) => item.id); const recommendedPlaylistId = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId || null); const recommendedReviewTitle = normalizedTitles.find((item) => item.playlistId === recommendedPlaylistId) || null; return { generatedAt: nowIso(), mode, mediaProfile: normalizeMediaProfile(mediaProfile) || null, preRip: Boolean(preRip), reviewConfirmed: false, minLengthMinutes, minLengthSeconds, selectedTitleIds, selectors: { preset: settings?.handbrake_preset || '-', extraArgs: settings?.handbrake_extra_args || '', presetProfileSource: 'disc-scan', audio: { mode: 'manual', encoders: [], copyMask: [], fallbackEncoder: '-' }, subtitle: { mode: 'manual', forcedOnly: false, burnBehavior: 'none' } }, notes: [ preRip ? `Vorab-Spurprüfung von Disc-Quelle ${sourceArg || '-'}.` : `Titel-/Spurprüfung aus RAW-Quelle ${sourceArg || '-'}.`, preRip ? 'Backup/Rip startet erst nach manueller Bestätigung und CTA.' : 'Encode startet erst nach manueller Bestätigung und CTA.' ], titles: normalizedTitles, encodeInputPath: encodeInputTitle ? (encodeInputPath || `disc-track-scan://title-${encodeInputTitle.id}`) : null, encodeInputTitleId: encodeInputTitle ? encodeInputTitle.id : null, playlistDecisionRequired, playlistRecommendation: recommendedPlaylistId ? { playlistId: recommendedPlaylistId, playlistFile: `${recommendedPlaylistId}.mpls`, reviewTitleId: recommendedReviewTitle?.id || null, reason: playlistAnalysis?.recommendation?.reason || null } : null, titleSelectionRequired: Boolean(playlistDecisionRequired && !encodeInputTitle) }; } function findExistingRawDirectory(rawBaseDir, metadataBase) { if (!rawBaseDir || !metadataBase) { return null; } if (!fs.existsSync(rawBaseDir)) { return null; } let entries; try { entries = fs.readdirSync(rawBaseDir, { withFileTypes: true }); } catch (_error) { return null; } const normalizedBase = sanitizeFileName(metadataBase); const escapedBase = normalizedBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedIncompletePrefix = RAW_INCOMPLETE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedRipCompletePrefix = RAW_RIP_COMPLETE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const folderPattern = new RegExp( `^(?:(?:${escapedIncompletePrefix}|${escapedRipCompletePrefix}))?${escapedBase}(?:\\s\\[tt\\d{6,12}\\])?\\s-\\sRAW\\s-\\sjob-\\d+\\s*$`, 'i' ); const candidates = entries .filter((entry) => entry.isDirectory() && folderPattern.test(entry.name)) .map((entry) => { const absPath = path.join(rawBaseDir, entry.name); try { const dirEntries = fs.readdirSync(absPath); const stat = fs.statSync(absPath); return { path: absPath, entryCount: dirEntries.length, mtimeMs: Number(stat.mtimeMs || 0) }; } catch (_error) { return null; } }) .filter((item) => item && item.entryCount > 0) .sort((a, b) => b.mtimeMs - a.mtimeMs); return candidates.length > 0 ? candidates[0].path : null; } function buildRawMetadataBase(jobLike = {}, fallbackJobId = null) { const normalizedJobId = Number(fallbackJobId || jobLike?.id || 0); const fallbackTitle = Number.isFinite(normalizedJobId) && normalizedJobId > 0 ? `job-${Math.trunc(normalizedJobId)}` : 'job-unknown'; const rawYear = Number(jobLike?.year ?? jobLike?.fallbackYear ?? null); const yearValue = Number.isFinite(rawYear) && rawYear > 0 ? Math.trunc(rawYear) : new Date().getFullYear(); return sanitizeFileName( renderTemplate('${title} (${year})', { title: jobLike?.title || jobLike?.detected_title || jobLike?.detectedTitle || fallbackTitle, year: yearValue }) ); } function normalizeRawFolderState(rawState, fallback = RAW_FOLDER_STATES.INCOMPLETE) { const state = String(rawState || '').trim().toLowerCase(); if (!state) { return fallback; } if (state === RAW_FOLDER_STATES.INCOMPLETE) { return RAW_FOLDER_STATES.INCOMPLETE; } if (state === RAW_FOLDER_STATES.RIP_COMPLETE || state === 'ripcomplete' || state === 'rip-complete') { return RAW_FOLDER_STATES.RIP_COMPLETE; } if (state === RAW_FOLDER_STATES.COMPLETE || state === 'none' || state === 'final') { return RAW_FOLDER_STATES.COMPLETE; } return fallback; } function stripRawStatePrefix(folderName) { const rawName = String(folderName || '').trim(); if (!rawName) { return ''; } return rawName .replace(/^Incomplete_/i, '') .replace(/^Rip_Complete_/i, '') .trim(); } function applyRawFolderStateToName(folderName, state) { const baseName = stripRawStatePrefix(folderName); if (!baseName) { return baseName; } const normalizedState = normalizeRawFolderState(state, RAW_FOLDER_STATES.COMPLETE); if (normalizedState === RAW_FOLDER_STATES.INCOMPLETE) { return `${RAW_INCOMPLETE_PREFIX}${baseName}`; } if (normalizedState === RAW_FOLDER_STATES.RIP_COMPLETE) { return `${RAW_RIP_COMPLETE_PREFIX}${baseName}`; } return baseName; } function resolveRawFolderStateFromPath(rawPath) { const sourcePath = String(rawPath || '').trim(); if (!sourcePath) { return RAW_FOLDER_STATES.COMPLETE; } const folderName = path.basename(sourcePath); if (/^Incomplete_/i.test(folderName)) { return RAW_FOLDER_STATES.INCOMPLETE; } if (/^Rip_Complete_/i.test(folderName)) { return RAW_FOLDER_STATES.RIP_COMPLETE; } return RAW_FOLDER_STATES.COMPLETE; } function resolveRawFolderStateFromOptions(options = {}) { if (options && Object.prototype.hasOwnProperty.call(options, 'state')) { return normalizeRawFolderState(options.state, RAW_FOLDER_STATES.INCOMPLETE); } if (options && options.ripComplete) { return RAW_FOLDER_STATES.RIP_COMPLETE; } if (options && Object.prototype.hasOwnProperty.call(options, 'incomplete')) { return options.incomplete ? RAW_FOLDER_STATES.INCOMPLETE : RAW_FOLDER_STATES.COMPLETE; } return RAW_FOLDER_STATES.INCOMPLETE; } function buildRawDirName(metadataBase, jobId, options = {}) { const state = resolveRawFolderStateFromOptions(options); const baseName = sanitizeFileName(`${metadataBase} - RAW - job-${jobId}`); return applyRawFolderStateToName(baseName, state); } function buildRawPathForState(rawPath, state) { const sourcePath = String(rawPath || '').trim(); if (!sourcePath) { return null; } const folderName = path.basename(sourcePath); const nextFolderName = applyRawFolderStateToName(folderName, state); if (!nextFolderName) { return sourcePath; } return path.join(path.dirname(sourcePath), nextFolderName); } function buildRipCompleteRawPath(rawPath) { return buildRawPathForState(rawPath, RAW_FOLDER_STATES.RIP_COMPLETE); } function buildCompletedRawPath(rawPath) { return buildRawPathForState(rawPath, RAW_FOLDER_STATES.COMPLETE); } function normalizeComparablePath(inputPath) { const source = String(inputPath || '').trim(); if (!source) { return ''; } return path.resolve(source).replace(/[\\/]+$/, ''); } function isPathInsideDirectory(parentPath, candidatePath) { const parent = normalizeComparablePath(parentPath); const candidate = normalizeComparablePath(candidatePath); if (!parent || !candidate) { return false; } if (candidate === parent) { return true; } const parentWithSep = parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`; return candidate.startsWith(parentWithSep); } function remapPathToRetargetedRawRoot(inputPath, previousRawPath, nextRawPath) { const sourceInputPath = String(inputPath || '').trim(); if (!sourceInputPath) { return null; } const previousRaw = normalizeComparablePath(previousRawPath); const nextRaw = normalizeComparablePath(nextRawPath); const input = normalizeComparablePath(sourceInputPath); if (!previousRaw || !nextRaw || !input) { return sourceInputPath; } if (input === previousRaw) { return nextRaw; } if (!isPathInsideDirectory(previousRaw, input)) { return sourceInputPath; } const relative = path.relative(previousRaw, input); if (!relative || relative === '.') { return nextRaw; } return path.join(nextRaw, relative); } function isEncodeInputMismatchedWithRaw(rawPath, encodeInputPath) { const raw = normalizeComparablePath(rawPath); const input = normalizeComparablePath(encodeInputPath); if (!raw || !input) { return true; } if (raw === input) { return false; } return !isPathInsideDirectory(raw, input); } function isJobFinished(jobLike = null) { const status = String(jobLike?.status || '').trim().toUpperCase(); const lastState = String(jobLike?.last_state || '').trim().toUpperCase(); return status === 'FINISHED' || lastState === 'FINISHED'; } function toPlaylistFile(playlistId) { const normalized = normalizePlaylistId(playlistId); return normalized ? `${normalized}.mpls` : null; } function describePlaylistManualDecision(playlistAnalysis) { const obfuscationDetected = Boolean(playlistAnalysis?.obfuscationDetected); const candidateCount = Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates.length : 0; const reasonCodeRaw = String(playlistAnalysis?.manualDecisionReason || '').trim(); const reasonCode = reasonCodeRaw || ( obfuscationDetected ? 'multiple_similar_candidates' : (candidateCount > 1 ? 'multiple_candidates_after_min_length' : 'manual_selection_required') ); const detailText = obfuscationDetected ? 'Blu-ray verwendet Playlist-Obfuscation (mehrere gleichlange Kandidaten).' : (candidateCount > 1 ? `Mehrere Playlists erfüllen MIN_LENGTH_MINUTES (${candidateCount} Kandidaten).` : 'Manuelle Playlist-Auswahl erforderlich.'); return { obfuscationDetected, candidateCount, reasonCode, detailText }; } function buildPlaylistCandidates(playlistAnalysis) { const rawList = Array.isArray(playlistAnalysis?.candidatePlaylists) ? playlistAnalysis.candidatePlaylists : []; const sourceRows = [ ...(Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates : []), ...(Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates : []), ...(Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : []) ]; const segmentMap = playlistAnalysis?.playlistSegments && typeof playlistAnalysis.playlistSegments === 'object' ? playlistAnalysis.playlistSegments : {}; return rawList .map((playlistId) => normalizePlaylistId(playlistId)) .filter(Boolean) .map((playlistId) => { const source = sourceRows.find((item) => normalizePlaylistId(item?.playlistId || item?.playlistFile) === playlistId) || null; const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null; const score = Number(source?.score); const sequenceCoherence = Number(source?.structuralMetrics?.sequenceCoherence); const titleId = Number(source?.titleId ?? source?.id); const handBrakeTitleId = Number(source?.handBrakeTitleId); const durationSecondsRaw = Number(source?.durationSeconds ?? source?.duration ?? 0); const durationSeconds = Number.isFinite(durationSecondsRaw) && durationSecondsRaw > 0 ? Math.trunc(durationSecondsRaw) : 0; const sizeBytesRaw = Number(source?.sizeBytes ?? source?.size ?? 0); const sizeBytes = Number.isFinite(sizeBytesRaw) && sizeBytesRaw > 0 ? Math.trunc(sizeBytesRaw) : 0; const durationLabelRaw = String(source?.durationLabel || '').trim(); const durationLabel = durationLabelRaw || formatDurationClock(durationSeconds); const sourceAudioTracks = Array.isArray(source?.audioTracks) ? source.audioTracks : []; const fallbackAudioTrackPreview = sourceAudioTracks .slice(0, 8) .map((track) => { const rawTrackId = Number(track?.sourceTrackId ?? track?.id); const trackId = Number.isFinite(rawTrackId) && rawTrackId > 0 ? Math.trunc(rawTrackId) : null; const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'); const languageLabel = String(track?.languageLabel || track?.language || language).trim() || language; const format = String(track?.format || '').trim(); const channels = String(track?.channels || '').trim(); const parts = []; if (trackId !== null) { parts.push(`#${trackId}`); } parts.push(language); parts.push(languageLabel); if (format) { parts.push(format); } if (channels) { parts.push(channels); } return parts.join(' | '); }) .filter((line) => line.length > 0); const sourceAudioTrackPreview = Array.isArray(source?.audioTrackPreview) ? source.audioTrackPreview.map((line) => String(line || '').trim()).filter((line) => line.length > 0) : []; const audioTrackPreview = sourceAudioTrackPreview.length > 0 ? sourceAudioTrackPreview : fallbackAudioTrackPreview; const audioSummary = String(source?.audioSummary || '').trim() || buildHandBrakeAudioSummary(audioTrackPreview); return { playlistId, playlistFile: toPlaylistFile(playlistId), titleId: Number.isFinite(titleId) ? Math.trunc(titleId) : null, durationSeconds, durationLabel: durationLabel || null, sizeBytes, score: Number.isFinite(score) ? score : null, recommended: Boolean(source?.recommended), evaluationLabel: source?.evaluationLabel || null, sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null, segmentCommand: source?.segmentCommand || segmentEntry?.segmentCommand || `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`, segmentFiles: Array.isArray(source?.segmentFiles) && source.segmentFiles.length > 0 ? source.segmentFiles : (Array.isArray(segmentEntry?.segmentFiles) ? segmentEntry.segmentFiles : []), handBrakeTitleId: Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0 ? Math.trunc(handBrakeTitleId) : null, audioSummary: audioSummary || null, audioTrackPreview }; }); } function buildHandBrakeAudioTrackPreview(titleInfo) { const tracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []; return tracks .map((track) => { const rawTrackId = Number(track?.sourceTrackId ?? track?.id); const trackId = Number.isFinite(rawTrackId) && rawTrackId > 0 ? Math.trunc(rawTrackId) : null; const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'); const description = String(track?.description || track?.title || '').trim(); const codec = String(track?.codecName || track?.format || '').trim(); const channels = String(track?.channels || '').trim(); const parts = []; if (trackId !== null) { parts.push(`#${trackId}`); } parts.push(language); if (description) { parts.push(description); } else { if (codec) { parts.push(codec); } if (channels) { parts.push(channels); } } return parts.join(' | ').trim(); }) .filter((line) => line.length > 0); } function buildHandBrakeAudioSummary(previewLines) { const lines = Array.isArray(previewLines) ? previewLines.filter((line) => String(line || '').trim().length > 0) : []; if (lines.length === 0) { return null; } return lines.slice(0, 3).join(' || '); } function normalizeHandBrakePlaylistScanCache(rawCache) { if (!rawCache || typeof rawCache !== 'object') { return null; } const inputPath = String(rawCache?.inputPath || '').trim() || null; const source = String(rawCache?.source || '').trim() || 'HANDBRAKE_SCAN_PLAYLIST_MAP'; const generatedAt = String(rawCache?.generatedAt || '').trim() || null; const rawEntries = []; if (rawCache?.byPlaylist && typeof rawCache.byPlaylist === 'object') { for (const [key, value] of Object.entries(rawCache.byPlaylist)) { rawEntries.push({ key, value }); } } else if (Array.isArray(rawCache?.playlists)) { for (const item of rawCache.playlists) { rawEntries.push({ key: item?.playlistId || item?.playlistFile || null, value: item }); } } const byPlaylist = {}; for (const entry of rawEntries) { const row = entry?.value && typeof entry.value === 'object' ? entry.value : null; const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || entry?.key || null); if (!playlistId) { continue; } const rawHandBrakeTitleId = Number(row?.handBrakeTitleId ?? row?.titleId); const handBrakeTitleId = Number.isFinite(rawHandBrakeTitleId) && rawHandBrakeTitleId > 0 ? Math.trunc(rawHandBrakeTitleId) : null; const titleInfo = row?.titleInfo && typeof row.titleInfo === 'object' ? row.titleInfo : null; const audioTrackPreview = Array.isArray(row?.audioTrackPreview) ? row.audioTrackPreview.map((line) => String(line || '').trim()).filter((line) => line.length > 0) : buildHandBrakeAudioTrackPreview(titleInfo); const audioSummary = String(row?.audioSummary || '').trim() || buildHandBrakeAudioSummary(audioTrackPreview); byPlaylist[playlistId] = { playlistId, handBrakeTitleId, titleInfo, audioTrackPreview, audioSummary: audioSummary || null }; } if (Object.keys(byPlaylist).length === 0) { return null; } return { generatedAt, source, inputPath, byPlaylist }; } function getCachedHandBrakePlaylistEntry(scanCache, playlistIdRaw) { const playlistId = normalizePlaylistId(playlistIdRaw); if (!playlistId) { return null; } const normalized = normalizeHandBrakePlaylistScanCache(scanCache); if (!normalized) { return null; } return normalized.byPlaylist[playlistId] || null; } function hasCachedHandBrakeDataForPlaylistCandidates(scanCache, playlistCandidates = []) { const normalized = normalizeHandBrakePlaylistScanCache(scanCache); if (!normalized) { return false; } const candidateIds = (Array.isArray(playlistCandidates) ? playlistCandidates : []) .map((item) => normalizePlaylistId(item?.playlistId || item?.playlistFile || item)) .filter(Boolean); if (candidateIds.length === 0) { return false; } return candidateIds.every((playlistId) => { const row = normalized.byPlaylist[playlistId]; return Boolean(row && row.handBrakeTitleId && row.titleInfo); }); } function buildHandBrakePlaylistScanCache(scanJson, playlistCandidates = [], rawPath = null) { const candidateMetaByPlaylist = new Map(); for (const row of (Array.isArray(playlistCandidates) ? playlistCandidates : [])) { const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || row); if (!playlistId || candidateMetaByPlaylist.has(playlistId)) { continue; } candidateMetaByPlaylist.set(playlistId, { expectedMakemkvTitleId: normalizeNonNegativeInteger(row?.titleId), expectedDurationSeconds: Number(row?.durationSeconds || 0) || null, expectedSizeBytes: Number(row?.sizeBytes || 0) || null }); } const candidateIds = Array.from(new Set( (Array.isArray(playlistCandidates) ? playlistCandidates : []) .map((item) => normalizePlaylistId(item?.playlistId || item?.playlistFile || item)) .filter(Boolean) )); const byPlaylist = {}; for (const playlistId of candidateIds) { const expected = candidateMetaByPlaylist.get(playlistId) || {}; const handBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(scanJson, playlistId, expected); if (!handBrakeTitleId) { continue; } const titleInfo = parseHandBrakeSelectedTitleInfo(scanJson, { playlistId, handBrakeTitleId }); if (!titleInfo) { continue; } if (!isHandBrakePlaylistCacheEntryCompatible({ playlistId, handBrakeTitleId, titleInfo }, playlistId, expected)) { continue; } const audioTrackPreview = buildHandBrakeAudioTrackPreview(titleInfo); byPlaylist[playlistId] = { playlistId, handBrakeTitleId, titleInfo, audioTrackPreview, audioSummary: buildHandBrakeAudioSummary(audioTrackPreview) }; } return normalizeHandBrakePlaylistScanCache({ generatedAt: nowIso(), source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', inputPath: rawPath || null, byPlaylist }); } function enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, scanCache) { const analysis = playlistAnalysis && typeof playlistAnalysis === 'object' ? playlistAnalysis : null; const normalizedCache = normalizeHandBrakePlaylistScanCache(scanCache); if (!analysis || !normalizedCache) { return analysis; } const enrichRow = (row) => { const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || null); if (!playlistId) { return row; } const cached = normalizedCache.byPlaylist[playlistId]; if (!cached) { return row; } return { ...row, handBrakeTitleId: cached.handBrakeTitleId || null, audioSummary: cached.audioSummary || null, audioTrackPreview: Array.isArray(cached.audioTrackPreview) ? cached.audioTrackPreview : [] }; }; const recommendationPlaylistId = normalizePlaylistId(analysis?.recommendation?.playlistId); const recommendationCached = recommendationPlaylistId ? normalizedCache.byPlaylist[recommendationPlaylistId] || null : null; return { ...analysis, evaluatedCandidates: Array.isArray(analysis?.evaluatedCandidates) ? analysis.evaluatedCandidates.map((row) => enrichRow(row)) : [], candidates: Array.isArray(analysis?.candidates) ? analysis.candidates.map((row) => enrichRow(row)) : [], titles: Array.isArray(analysis?.titles) ? analysis.titles.map((row) => enrichRow(row)) : [], recommendation: analysis?.recommendation && typeof analysis.recommendation === 'object' ? { ...analysis.recommendation, handBrakeTitleId: recommendationCached?.handBrakeTitleId || null, audioSummary: recommendationCached?.audioSummary || null, audioTrackPreview: Array.isArray(recommendationCached?.audioTrackPreview) ? recommendationCached.audioTrackPreview : [] } : analysis?.recommendation || null }; } function pickTitleIdForPlaylist(playlistAnalysis, playlistId) { const normalized = normalizePlaylistId(playlistId); if (!normalized || !playlistAnalysis) { return null; } const playlistMap = playlistAnalysis?.playlistToTitleId && typeof playlistAnalysis.playlistToTitleId === 'object' ? playlistAnalysis.playlistToTitleId : null; if (playlistMap) { const byFile = Number(playlistMap[`${normalized}.mpls`]); if (Number.isFinite(byFile) && byFile >= 0) { return Math.trunc(byFile); } const byId = Number(playlistMap[normalized]); if (Number.isFinite(byId) && byId >= 0) { return Math.trunc(byId); } } const sources = [ ...(Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates : []), ...(Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates : []), ...(Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : []) ]; const matches = sources .filter((item) => normalizePlaylistId(item?.playlistId) === normalized) .map((item) => ({ titleId: Number(item?.titleId ?? item?.id), durationSeconds: Number(item?.durationSeconds || 0), sizeBytes: Number(item?.sizeBytes || 0) })) .filter((item) => Number.isFinite(item.titleId) && item.titleId >= 0) .sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId); return matches.length > 0 ? matches[0].titleId : null; } function normalizeReviewTitleId(rawValue) { const value = Number(rawValue); if (!Number.isFinite(value) || value <= 0) { return null; } return Math.trunc(value); } function applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId) { const normalizedTitleId = normalizeReviewTitleId(selectedEncodeTitleId); if (!normalizedTitleId) { return { plan: encodePlan, selectedTitle: null }; } const titles = Array.isArray(encodePlan?.titles) ? encodePlan.titles : []; const selectedTitle = titles.find((item) => Number(item?.id) === normalizedTitleId) || null; if (!selectedTitle) { const error = new Error(`Gewählter Titel #${normalizedTitleId} ist nicht vorhanden.`); error.statusCode = 400; throw error; } const eligible = selectedTitle?.eligibleForEncode !== undefined ? Boolean(selectedTitle.eligibleForEncode) : Boolean(selectedTitle?.selectedByMinLength); if (!eligible) { const error = new Error(`Titel #${normalizedTitleId} ist laut MIN_LENGTH_MINUTES nicht encodierbar.`); error.statusCode = 400; throw error; } const remappedTitles = titles.map((title) => { const isEncodeInput = Number(title?.id) === normalizedTitleId; const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => { const selectedByRule = Boolean(track?.selectedByRule); const selectedForEncode = isEncodeInput && selectedByRule; const previewActions = Array.isArray(track?.encodePreviewActions) ? track.encodePreviewActions : []; const previewSummary = track?.encodePreviewSummary || 'Nicht übernommen'; return { ...track, selectedForEncode, encodeActions: selectedForEncode ? previewActions : [], encodeActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen' }; }); const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => { const selectedByRule = Boolean(track?.selectedByRule); const selectedForEncode = isEncodeInput && selectedByRule; const previewFlags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []; const previewSummary = track?.subtitlePreviewSummary || 'Nicht übernommen'; return { ...track, selectedForEncode, burnIn: selectedForEncode ? Boolean(track?.subtitlePreviewBurnIn) : false, forced: selectedForEncode ? Boolean(track?.subtitlePreviewForced) : false, forcedOnly: selectedForEncode ? Boolean(track?.subtitlePreviewForcedOnly) : false, defaultTrack: selectedForEncode ? Boolean(track?.subtitlePreviewDefaultTrack) : false, flags: selectedForEncode ? previewFlags : [], subtitleActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen' }; }); return { ...title, encodeInput: isEncodeInput, selectedForEncode: isEncodeInput, audioTracks, subtitleTracks }; }); return { plan: { ...encodePlan, titles: remappedTitles, encodeInputTitleId: normalizedTitleId, encodeInputPath: selectedTitle?.filePath || null, titleSelectionRequired: false }, selectedTitle }; } function normalizeTrackIdList(rawList) { const list = Array.isArray(rawList) ? rawList : []; const seen = new Set(); const output = []; for (const item of list) { const value = Number(item); if (!Number.isFinite(value) || value <= 0) { continue; } const normalized = Math.trunc(value); const key = String(normalized); if (seen.has(key)) { continue; } seen.add(key); output.push(normalized); } return output; } function normalizeTrackIdSequence(rawList, options = {}) { const list = Array.isArray(rawList) ? rawList : []; const dedupe = options?.dedupe !== false; const seen = new Set(); const output = []; for (const item of list) { const value = Number(item); if (!Number.isFinite(value) || value <= 0) { continue; } const normalized = Math.trunc(value); const key = String(normalized); if (dedupe && seen.has(key)) { continue; } if (dedupe) { seen.add(key); } output.push(normalized); } return output; } function isBurnedSubtitleTrack(track) { const previewFlags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : (Array.isArray(track?.flags) ? track.flags : []); const hasBurnedFlag = previewFlags.some((flag) => String(flag || '').trim().toLowerCase() === 'burned'); const summary = `${track?.subtitlePreviewSummary || ''} ${track?.subtitleActionSummary || ''}`; return Boolean( track?.subtitlePreviewBurnIn || track?.burnIn || hasBurnedFlag || /burned/i.test(summary) ); } function normalizeSubtitleVariantSelection(rawSelection) { const map = new Map(); const order = []; const seenOrder = new Set(); const append = (rawLanguage, rawValue = null) => { const language = normalizeTrackLanguage(rawLanguage); if (!language) { return; } let full = false; let forced = false; if (typeof rawValue === 'string') { const lowered = rawValue.trim().toLowerCase(); if (lowered === 'both' || lowered === 'full+forced' || lowered === 'forced+full') { full = true; forced = true; } else { full = lowered.includes('full'); forced = lowered.includes('forced'); } } else if (Array.isArray(rawValue)) { const normalizedItems = rawValue.map((item) => String(item || '').trim().toLowerCase()); full = normalizedItems.includes('full'); forced = normalizedItems.includes('forced'); } else { full = Boolean(rawValue?.full); forced = Boolean(rawValue?.forced); } map.set(language, { full, forced }); if (!seenOrder.has(language)) { seenOrder.add(language); order.push(language); } }; if (Array.isArray(rawSelection)) { for (const entry of rawSelection) { append(entry?.language ?? entry?.lang ?? entry?.code, entry); } } else if (rawSelection && typeof rawSelection === 'object') { for (const [language, value] of Object.entries(rawSelection)) { append(language, value); } } return { map, order }; } function normalizeSubtitleLanguageOrder(rawOrder = []) { const list = Array.isArray(rawOrder) ? rawOrder : []; const output = []; const seen = new Set(); for (const item of list) { const language = normalizeTrackLanguage(item); if (!language || seen.has(language)) { continue; } seen.add(language); output.push(language); } return output; } function normalizeSubtitlePhysicalTrack(track, originalIndex = 0) { const id = normalizeTrackIdList([track?.id ?? track?.sourceTrackId])[0] || null; if (!id) { return null; } const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'); const explicitForcedOnly = Boolean( track?.isForcedOnly ?? track?.subtitlePreviewForcedOnly ?? track?.forcedOnly ?? track?.forcedTrackOnly ); const subtitleType = String(track?.subtitleType || '').trim().toLowerCase(); const isForcedOnly = explicitForcedOnly || subtitleType === 'forced'; const fullHasForced = !isForcedOnly && Boolean( track?.fullHasForced ?? track?.subtitleFullHasForced ?? track?.hasForcedVariant ?? track?.fullTrackHasForced ); const confidence = normalizeSubtitleConfidence(track?.sourceConfidence || track?.confidence || 'low'); return { id, language, defaultFlag: parseSubtitleDefaultFlag(track), confidence, isForcedOnly, fullHasForced, selectedForEncode: Boolean(track?.selectedForEncode), originalIndex }; } function compareSubtitleForcedCandidatePriority(a, b) { const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag)); if (defaultDiff !== 0) { return defaultDiff; } const confidenceDiff = subtitleConfidenceScore(b?.confidence) - subtitleConfidenceScore(a?.confidence); if (confidenceDiff !== 0) { return confidenceDiff; } if (a.id !== b.id) { return a.id - b.id; } return a.originalIndex - b.originalIndex; } function compareSubtitleFullCandidatePriority(a, b) { const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag)); if (defaultDiff !== 0) { return defaultDiff; } if (a.id !== b.id) { return a.id - b.id; } return a.originalIndex - b.originalIndex; } function buildSubtitleVariantSelectionFromTrackIds(subtitleTracks, selectedTrackIds = []) { const requestedTrackIds = new Set(normalizeTrackIdList(selectedTrackIds).map(String)); const candidates = (Array.isArray(subtitleTracks) ? subtitleTracks : []) .map((track, index) => normalizeSubtitlePhysicalTrack(track, index)) .filter(Boolean) .filter((track) => !requestedTrackIds.size || requestedTrackIds.has(String(track.id))); const map = new Map(); const order = []; const seenOrder = new Set(); for (const track of candidates) { const current = map.get(track.language) || { full: false, forced: false }; if (track.isForcedOnly) { current.forced = true; } else { current.full = true; } map.set(track.language, current); if (!seenOrder.has(track.language)) { seenOrder.add(track.language); order.push(track.language); } } return { map, order }; } function buildEmptySubtitleVariantSelectionForAllLanguages(subtitleTracks) { const map = new Map(); const order = []; const seen = new Set(); const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : []; for (const track of tracks) { if (isBurnedSubtitleTrack(track) || Boolean(track?.duplicate)) { continue; } const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'); if (!language || seen.has(language)) { continue; } seen.add(language); order.push(language); map.set(language, { full: false, forced: false }); } return { map, order }; } function resolveDeterministicSubtitleSelection({ subtitleTracks = [], subtitleVariantSelection = null, subtitleLanguageOrder = [], fallbackSelectedSubtitleTrackIds = [], fallbackSelectedSubtitleTrackIdsOrdered = [], hasExplicitVariantSelection = false } = {}) { const tracks = (Array.isArray(subtitleTracks) ? subtitleTracks : []) .map((track, index) => ({ track, normalized: normalizeSubtitlePhysicalTrack(track, index) })) .filter((entry) => Boolean(entry.normalized)) .filter((entry) => !isBurnedSubtitleTrack(entry.track) && !Boolean(entry.track?.duplicate)) .map((entry) => entry.normalized); const groupedByLanguage = new Map(); for (const track of tracks) { if (!groupedByLanguage.has(track.language)) { groupedByLanguage.set(track.language, []); } groupedByLanguage.get(track.language).push(track); } const fallbackSelectionSet = new Set(normalizeTrackIdList(fallbackSelectedSubtitleTrackIds).map(String)); const explicitSelection = normalizeSubtitleVariantSelection(subtitleVariantSelection); const explicitOrder = normalizeSubtitleLanguageOrder(subtitleLanguageOrder); const fallbackSelectionFromTrackIds = buildSubtitleVariantSelectionFromTrackIds( subtitleTracks, fallbackSelectedSubtitleTrackIds ); const fallbackSelectionFromOrderedTrackIds = buildSubtitleVariantSelectionFromTrackIds( subtitleTracks, normalizeTrackIdSequence(fallbackSelectedSubtitleTrackIdsOrdered, { dedupe: false }) ); const availableLanguagesSorted = Array.from(groupedByLanguage.entries()) .map(([language, languageTracks]) => { const minTrackId = languageTracks.reduce((minValue, track) => Math.min(minValue, track.id), Number.MAX_SAFE_INTEGER); return { language, minTrackId }; }) .sort((a, b) => a.minTrackId - b.minTrackId || a.language.localeCompare(b.language)) .map((item) => item.language); const languageOrder = []; const seenLanguages = new Set(); const pushLanguage = (language) => { if (!groupedByLanguage.has(language) || seenLanguages.has(language)) { return; } seenLanguages.add(language); languageOrder.push(language); }; for (const language of explicitOrder) { pushLanguage(language); } if (!hasExplicitVariantSelection) { for (const language of explicitSelection.order) { pushLanguage(language); } for (const language of fallbackSelectionFromOrderedTrackIds.order) { pushLanguage(language); } for (const language of fallbackSelectionFromTrackIds.order) { pushLanguage(language); } } for (const language of availableLanguagesSorted) { pushLanguage(language); } const subtitleTrackIdsOrdered = []; const subtitleForcedSelectionIndexes = []; const selectedPhysicalTrackIds = []; const selectedPhysicalTrackSet = new Set(); const validationErrors = []; const normalizedVariantSelection = {}; const appendTrackId = (trackId, options = {}) => { subtitleTrackIdsOrdered.push(trackId); const position = subtitleTrackIdsOrdered.length; if (options?.forcedIndex) { subtitleForcedSelectionIndexes.push(position); } const key = String(trackId); if (!selectedPhysicalTrackSet.has(key)) { selectedPhysicalTrackSet.add(key); selectedPhysicalTrackIds.push(trackId); } }; for (const language of languageOrder) { const languageTracks = groupedByLanguage.get(language) || []; const forcedOnlyCandidates = languageTracks .filter((track) => track.isForcedOnly) .sort(compareSubtitleForcedCandidatePriority); const fullCandidates = languageTracks .filter((track) => !track.isForcedOnly) .sort(compareSubtitleFullCandidatePriority); const fullHasForcedCandidates = fullCandidates .filter((track) => track.fullHasForced) .sort(compareSubtitleFullCandidatePriority); const bestForcedOnlyTrack = forcedOnlyCandidates[0] || null; const bestFullTrack = fullCandidates[0] || null; const bestFullHasForcedTrack = fullHasForcedCandidates[0] || null; const explicitEntry = explicitSelection.map.get(language) || null; const fallbackEntryFromTrackIds = fallbackSelectionFromTrackIds.map.get(language) || null; const fallbackEntryFromOrderedTrackIds = fallbackSelectionFromOrderedTrackIds.map.get(language) || null; const hasAnyFallbackSelectedTrack = languageTracks.some((track) => fallbackSelectionSet.has(String(track.id)) || track.selectedForEncode); let requestedFull = false; let requestedForced = false; if (explicitEntry) { requestedFull = Boolean(explicitEntry.full); requestedForced = Boolean(explicitEntry.forced); } else if (hasExplicitVariantSelection) { requestedFull = false; requestedForced = false; } else if (fallbackEntryFromOrderedTrackIds) { requestedFull = Boolean(fallbackEntryFromOrderedTrackIds.full); requestedForced = Boolean(fallbackEntryFromOrderedTrackIds.forced); } else if (fallbackEntryFromTrackIds) { requestedFull = Boolean(fallbackEntryFromTrackIds.full); requestedForced = Boolean(fallbackEntryFromTrackIds.forced); } else if (hasAnyFallbackSelectedTrack) { requestedFull = Boolean(bestFullTrack); requestedForced = Boolean(bestForcedOnlyTrack); } else { requestedFull = Boolean(bestFullTrack); requestedForced = Boolean(bestForcedOnlyTrack); } normalizedVariantSelection[language] = { full: requestedFull, forced: requestedForced }; const languageActive = requestedFull || requestedForced; if (!languageActive) { continue; } if (bestForcedOnlyTrack) { // Case A: forced-only track exists -> forced entry is always included. appendTrackId(bestForcedOnlyTrack.id); if (requestedFull && bestFullTrack) { appendTrackId(bestFullTrack.id); } continue; } if (bestFullHasForcedTrack) { // Case B: no forced-only track, but full track contains forced content. if (requestedFull && requestedForced) { appendTrackId(bestFullHasForcedTrack.id); appendTrackId(bestFullHasForcedTrack.id, { forcedIndex: true }); } else if (requestedForced) { appendTrackId(bestFullHasForcedTrack.id, { forcedIndex: true }); } else if (requestedFull) { appendTrackId(bestFullHasForcedTrack.id); } continue; } // Case C: full-only track available, forced request is invalid. if (requestedForced && !requestedFull) { validationErrors.push(`Sprache ${language}: Forced wurde gewählt, aber es gibt keinen forced-only oder fullHasForced Track.`); continue; } if (requestedFull && bestFullTrack) { appendTrackId(bestFullTrack.id); continue; } if (!bestFullTrack) { validationErrors.push(`Sprache ${language}: Kein gültiger Full-Track verfügbar.`); } } const invalidForcedIndexes = subtitleForcedSelectionIndexes.filter((index) => index <= 0 || index > subtitleTrackIdsOrdered.length); if (invalidForcedIndexes.length > 0) { validationErrors.push(`Ungültige --subtitle-forced Indizes: ${invalidForcedIndexes.join(',')}`); } return { subtitleTrackIdsOrdered, subtitleForcedSelectionIndexes, selectedPhysicalTrackIds, subtitleVariantSelection: normalizedVariantSelection, subtitleLanguageOrder: languageOrder, validationErrors }; } function normalizeScriptIdList(rawList) { const list = Array.isArray(rawList) ? rawList : []; const seen = new Set(); const output = []; for (const item of list) { const value = Number(item); if (!Number.isFinite(value) || value <= 0) { continue; } const normalized = Math.trunc(value); const key = String(normalized); if (seen.has(key)) { continue; } seen.add(key); output.push(normalized); } return output; } function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : null; if (!plan || !Array.isArray(plan.titles)) { return { plan: encodePlan, selectionApplied: false, audioTrackIds: [], subtitleTrackIds: [], subtitleForcedTrackIndexes: [], subtitleSelectionValidationErrors: [] }; } const encodeInputTitleId = normalizeReviewTitleId(plan.encodeInputTitleId); if (!encodeInputTitleId) { return { plan, selectionApplied: false, audioTrackIds: [], subtitleTrackIds: [], subtitleForcedTrackIndexes: [], subtitleSelectionValidationErrors: [] }; } const selectionPayload = selectedTrackSelection && typeof selectedTrackSelection === 'object' ? selectedTrackSelection : null; if (!selectionPayload) { return { plan, selectionApplied: false, audioTrackIds: [], subtitleTrackIds: [], subtitleForcedTrackIndexes: [], subtitleSelectionValidationErrors: [] }; } const rawSelection = selectionPayload[encodeInputTitleId] || selectionPayload[String(encodeInputTitleId)] || selectionPayload; if (!rawSelection || typeof rawSelection !== 'object') { return { plan, selectionApplied: false, audioTrackIds: [], subtitleTrackIds: [], subtitleForcedTrackIndexes: [], subtitleSelectionValidationErrors: [] }; } const encodeTitle = plan.titles.find((title) => Number(title?.id) === encodeInputTitleId) || null; if (!encodeTitle) { return { plan, selectionApplied: false, audioTrackIds: [], subtitleTrackIds: [], subtitleForcedTrackIndexes: [], subtitleSelectionValidationErrors: [] }; } const validAudioTrackIds = new Set( (Array.isArray(encodeTitle.audioTracks) ? encodeTitle.audioTracks : []) .map((track) => Number(track?.id)) .filter((id) => Number.isFinite(id)) .map((id) => Math.trunc(id)) ); const validSubtitleTrackIds = new Set( (Array.isArray(encodeTitle.subtitleTracks) ? encodeTitle.subtitleTracks : []) .filter((track) => !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate)) .map((track) => Number(track?.id)) .filter((id) => Number.isFinite(id)) .map((id) => Math.trunc(id)) ); const requestedAudioTrackIds = normalizeTrackIdList(rawSelection.audioTrackIds) .filter((id) => validAudioTrackIds.has(id)); const requestedSubtitleTrackIds = normalizeTrackIdList(rawSelection.subtitleTrackIds) .filter((id) => validSubtitleTrackIds.has(id)); const requestedSubtitleTrackIdsOrdered = normalizeTrackIdSequence(rawSelection.subtitleTrackIds, { dedupe: false }) .filter((id) => validSubtitleTrackIds.has(id)); const explicitSubtitleVariants = normalizeSubtitleVariantSelection(rawSelection.subtitleVariantSelection); const explicitSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(rawSelection.subtitleLanguageOrder); let effectiveSubtitleVariantSelection = explicitSubtitleVariants; const hasRawSubtitleTrackIds = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleTrackIds'); const hasRawSubtitleVariantSelection = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleVariantSelection'); if (effectiveSubtitleVariantSelection.map.size === 0 && !hasRawSubtitleVariantSelection && hasRawSubtitleTrackIds) { effectiveSubtitleVariantSelection = requestedSubtitleTrackIds.length > 0 ? buildSubtitleVariantSelectionFromTrackIds(encodeTitle.subtitleTracks, requestedSubtitleTrackIds) : buildEmptySubtitleVariantSelectionForAllLanguages(encodeTitle.subtitleTracks); } else if (effectiveSubtitleVariantSelection.map.size === 0 && !hasRawSubtitleVariantSelection && requestedSubtitleTrackIds.length > 0) { effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds(encodeTitle.subtitleTracks, requestedSubtitleTrackIds); } const resolvedSubtitleSelection = resolveDeterministicSubtitleSelection({ subtitleTracks: encodeTitle.subtitleTracks, subtitleVariantSelection: Object.fromEntries(effectiveSubtitleVariantSelection.map.entries()), subtitleLanguageOrder: explicitSubtitleLanguageOrder.length > 0 ? explicitSubtitleLanguageOrder : effectiveSubtitleVariantSelection.order, fallbackSelectedSubtitleTrackIds: requestedSubtitleTrackIds, fallbackSelectedSubtitleTrackIdsOrdered: requestedSubtitleTrackIdsOrdered, hasExplicitVariantSelection: hasRawSubtitleVariantSelection }); const audioSelectionSet = new Set(requestedAudioTrackIds.map((id) => String(id))); const subtitleSelectionSet = new Set(resolvedSubtitleSelection.selectedPhysicalTrackIds.map((id) => String(id))); const remappedTitles = plan.titles.map((title) => { const isEncodeInput = Number(title?.id) === encodeInputTitleId; const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => { const trackId = Number(track?.id); const selectedForEncode = isEncodeInput && audioSelectionSet.has(String(Math.trunc(trackId))); const previewActions = Array.isArray(track?.encodePreviewActions) ? track.encodePreviewActions : []; const previewSummary = track?.encodePreviewSummary || 'Nicht übernommen'; return { ...track, selectedForEncode, encodeActions: selectedForEncode ? previewActions : [], encodeActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen' }; }); const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => { const trackId = Number(track?.id); const selectedForEncode = isEncodeInput && subtitleSelectionSet.has(String(Math.trunc(trackId))); const previewFlags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []; const previewSummary = track?.subtitlePreviewSummary || 'Nicht übernommen'; return { ...track, selectedForEncode, burnIn: selectedForEncode ? Boolean(track?.subtitlePreviewBurnIn) : false, forced: selectedForEncode ? Boolean(track?.subtitlePreviewForced) : false, forcedOnly: selectedForEncode ? Boolean(track?.subtitlePreviewForcedOnly) : false, defaultTrack: selectedForEncode ? Boolean(track?.subtitlePreviewDefaultTrack) : false, flags: selectedForEncode ? previewFlags : [], subtitleActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen' }; }); return { ...title, encodeInput: isEncodeInput, selectedForEncode: isEncodeInput, audioTracks, subtitleTracks }; }); return { plan: { ...plan, titles: remappedTitles, manualTrackSelection: { titleId: encodeInputTitleId, audioTrackIds: requestedAudioTrackIds, subtitleTrackIds: resolvedSubtitleSelection.selectedPhysicalTrackIds, subtitleTrackIdsOrdered: resolvedSubtitleSelection.subtitleTrackIdsOrdered, subtitleForcedTrackIndexes: resolvedSubtitleSelection.subtitleForcedSelectionIndexes, subtitleVariantSelection: resolvedSubtitleSelection.subtitleVariantSelection, subtitleLanguageOrder: resolvedSubtitleSelection.subtitleLanguageOrder, subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors, updatedAt: nowIso() } }, selectionApplied: true, audioTrackIds: requestedAudioTrackIds, subtitleTrackIds: resolvedSubtitleSelection.subtitleTrackIdsOrdered, subtitleForcedTrackIndexes: resolvedSubtitleSelection.subtitleForcedSelectionIndexes, subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors }; } function extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath = null) { const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : null; if (!plan || !Array.isArray(plan.titles)) { return null; } const encodeInputTitleId = normalizeReviewTitleId(plan.encodeInputTitleId); let encodeTitle = null; if (encodeInputTitleId) { encodeTitle = plan.titles.find((title) => Number(title?.id) === encodeInputTitleId) || null; } if (!encodeTitle && inputPath) { encodeTitle = plan.titles.find((title) => String(title?.filePath || '') === String(inputPath || '')) || null; } if (!encodeTitle) { return null; } const allAudioTracks = Array.isArray(encodeTitle.audioTracks) ? encodeTitle.audioTracks : []; const allSubtitleTracks = Array.isArray(encodeTitle.subtitleTracks) ? encodeTitle.subtitleTracks : []; // manualTrackSelection stores validated track?.id values written by applyManualTrackSelectionToPlan. // Use it as the authoritative source for audio/subtitle IDs. Fall back to the selectedForEncode // flags on the track objects when the field is absent (e.g. auto-selected plans without user review). // Always resolve through track?.id – never sourceTrackId, which may hold MakeMKV stream IDs // (e.g. 189) that have no relation to HandBrake's 1-indexed track positions. const manualSelection = plan.manualTrackSelection && typeof plan.manualTrackSelection === 'object' ? plan.manualTrackSelection : null; const manualTitleId = normalizeReviewTitleId(manualSelection?.titleId); const manualMatchesTitle = !manualTitleId || !encodeInputTitleId || manualTitleId === encodeInputTitleId; const audioTrackIds = (manualMatchesTitle && Array.isArray(manualSelection?.audioTrackIds)) ? normalizeTrackIdList(manualSelection.audioTrackIds) : normalizeTrackIdList(allAudioTracks.filter((t) => Boolean(t?.selectedForEncode)).map((t) => t?.id)); const fallbackSubtitleTrackIds = (manualMatchesTitle && Array.isArray(manualSelection?.subtitleTrackIds)) ? normalizeTrackIdList(manualSelection.subtitleTrackIds) : normalizeTrackIdList(allSubtitleTracks.filter((t) => Boolean(t?.selectedForEncode)).map((t) => t?.id)); const fallbackSubtitleTrackIdsOrdered = (manualMatchesTitle && Array.isArray(manualSelection?.subtitleTrackIdsOrdered)) ? normalizeTrackIdSequence(manualSelection.subtitleTrackIdsOrdered, { dedupe: false }) : []; const manualSubtitleVariantSelection = manualMatchesTitle && manualSelection?.subtitleVariantSelection ? manualSelection.subtitleVariantSelection : null; const hasManualSubtitleVariantSelection = Boolean( manualMatchesTitle && manualSelection && Object.prototype.hasOwnProperty.call(manualSelection, 'subtitleVariantSelection') ); const manualSubtitleLanguageOrder = manualMatchesTitle && Array.isArray(manualSelection?.subtitleLanguageOrder) ? manualSelection.subtitleLanguageOrder : []; const resolvedSubtitleSelection = resolveDeterministicSubtitleSelection({ subtitleTracks: allSubtitleTracks, subtitleVariantSelection: manualSubtitleVariantSelection, subtitleLanguageOrder: manualSubtitleLanguageOrder, fallbackSelectedSubtitleTrackIds: fallbackSubtitleTrackIds, fallbackSelectedSubtitleTrackIdsOrdered: fallbackSubtitleTrackIdsOrdered, hasExplicitVariantSelection: hasManualSubtitleVariantSelection }); const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIdsOrdered.length > 0 ? resolvedSubtitleSelection.subtitleTrackIdsOrdered : (hasManualSubtitleVariantSelection ? [] : (fallbackSubtitleTrackIdsOrdered.length > 0 ? fallbackSubtitleTrackIdsOrdered : fallbackSubtitleTrackIds)); const subtitleForcedTrackIndexes = resolvedSubtitleSelection.subtitleForcedSelectionIndexes; // Resolve burn/default/forced attributes from the actual track objects, matched by track?.id. const subtitleTrackIdSet = new Set(normalizeTrackIdList(subtitleTrackIds).map(String)); const selectedSubtitleTracks = allSubtitleTracks.filter((t) => { const tid = normalizeTrackIdList([t?.id])[0]; return tid !== undefined && subtitleTrackIdSet.has(String(tid)); }); const subtitleBurnTrackId = normalizeTrackIdList( selectedSubtitleTracks.filter((track) => Boolean(track?.burnIn)).map((track) => track?.id) )[0] || null; const subtitleDefaultTrackId = normalizeTrackIdList( selectedSubtitleTracks.filter((track) => Boolean(track?.defaultTrack)).map((track) => track?.id) )[0] || null; const subtitleForcedTrackId = subtitleForcedTrackIndexes.length === 1 ? (subtitleTrackIds[subtitleForcedTrackIndexes[0] - 1] || null) : null; const subtitleForcedOnly = false; return { titleId: Number(encodeTitle?.id) || null, audioTrackIds, subtitleTrackIds, subtitleForcedTrackIndexes, subtitleVariantSelection: resolvedSubtitleSelection.subtitleVariantSelection, subtitleLanguageOrder: resolvedSubtitleSelection.subtitleLanguageOrder, subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors, subtitleBurnTrackId, subtitleDefaultTrackId, subtitleForcedTrackId, subtitleForcedOnly }; } function buildPlaylistSegmentFileSet(playlistAnalysis, selectedPlaylistId = null) { const analysis = playlistAnalysis && typeof playlistAnalysis === 'object' ? playlistAnalysis : null; if (!analysis) { return new Set(); } const segmentMap = analysis.playlistSegments && typeof analysis.playlistSegments === 'object' ? analysis.playlistSegments : {}; const set = new Set(); const appendSegments = (playlistIdRaw) => { const playlistId = normalizePlaylistId(playlistIdRaw); if (!playlistId) { return; } const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null; const segmentFiles = Array.isArray(segmentEntry?.segmentFiles) ? segmentEntry.segmentFiles : []; for (const file of segmentFiles) { const name = path.basename(String(file || '').trim()).toLowerCase(); if (!name) { continue; } set.add(name); } }; if (selectedPlaylistId) { appendSegments(selectedPlaylistId); return set; } appendSegments(analysis?.recommendation?.playlistId || null); if (set.size > 0) { return set; } const candidates = Array.isArray(analysis.evaluatedCandidates) ? analysis.evaluatedCandidates : []; for (const candidate of candidates) { appendSegments(candidate?.playlistId || null); } return set; } function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedPlaylistId = null } = {}) { const sourcePath = String(rawPath || '').trim(); if (!sourcePath) { return { mediaFiles: [], source: 'none' }; } try { const sourceStat = fs.statSync(sourcePath); if (sourceStat.isFile()) { const ext = path.extname(sourcePath).toLowerCase(); const supportedSingleFileExts = new Set([ '.aax', '.m4b', '.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts', '.flv', '.webm', '.iso' ]); if ( supportedSingleFileExts.has(ext) || isLikelyExtensionlessDvdImageFile(sourcePath, sourceStat.size) ) { return { mediaFiles: [{ path: sourcePath, size: Number(sourceStat.size || 0) }], source: ext === '' ? 'single_extensionless' : 'single_file' }; } return { mediaFiles: [], source: 'none' }; } } catch (_error) { return { mediaFiles: [], source: 'none' }; } const topLevelExtensionlessImages = listTopLevelExtensionlessDvdImages(sourcePath); if (topLevelExtensionlessImages.length > 0) { return { mediaFiles: topLevelExtensionlessImages, source: 'dvd_image' }; } const primary = findMediaFiles(sourcePath, [ '.aax', '.m4b', '.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts', '.flv', '.webm', '.iso' ]); if (primary.length > 0) { return { mediaFiles: primary, source: path.extname(primary[0]?.path || '').toLowerCase() === '.aax' ? 'audiobook' : 'mkv' }; } const streamDir = path.join(sourcePath, 'BDMV', 'STREAM'); const backupRoot = fs.existsSync(streamDir) ? streamDir : sourcePath; let backupFiles = findMediaFiles(backupRoot, ['.m2ts']); if (backupFiles.length === 0) { const vobFiles = findMediaFiles(sourcePath, ['.vob']); if (vobFiles.length > 0) { return { mediaFiles: vobFiles, source: 'dvd' }; } return { mediaFiles: [], source: 'none' }; } const allowedSegments = buildPlaylistSegmentFileSet(playlistAnalysis, selectedPlaylistId); if (allowedSegments.size > 0) { const filtered = backupFiles.filter((file) => allowedSegments.has(path.basename(file.path).toLowerCase())); if (filtered.length > 0) { backupFiles = filtered; } } return { mediaFiles: backupFiles, source: 'backup' }; } function hasBluRayBackupStructure(rawPath) { if (!rawPath) { return false; } const bdmvDir = path.join(rawPath, 'BDMV'); const streamDir = path.join(bdmvDir, 'STREAM'); try { return fs.existsSync(bdmvDir) && fs.existsSync(streamDir); } catch (_error) { return false; } } function findPreferredRawInput(rawPath, options = {}) { const { mediaFiles } = collectRawMediaCandidates(rawPath, options); if (!Array.isArray(mediaFiles) || mediaFiles.length === 0) { return null; } return mediaFiles[0]; } function extractManualSelectionPayloadFromPlan(encodePlan) { const selection = extractHandBrakeTrackSelectionFromPlan(encodePlan); if (!selection) { return null; } return { audioTrackIds: normalizeTrackIdList(selection.audioTrackIds), subtitleTrackIds: normalizeTrackIdList(selection.subtitleTrackIds), subtitleTrackIdsOrdered: normalizeTrackIdSequence(selection.subtitleTrackIds, { dedupe: false }), subtitleForcedTrackIndexes: normalizeTrackIdList(selection.subtitleForcedTrackIndexes), subtitleVariantSelection: selection.subtitleVariantSelection || null, subtitleLanguageOrder: Array.isArray(selection.subtitleLanguageOrder) ? selection.subtitleLanguageOrder : [] }; } function normalizeChainIdList(rawList) { const list = Array.isArray(rawList) ? rawList : []; const seen = new Set(); const output = []; for (const item of list) { const value = Number(item); if (!Number.isFinite(value) || value <= 0) { continue; } const normalized = Math.trunc(value); const key = String(normalized); if (seen.has(key)) { continue; } seen.add(key); output.push(normalized); } return output; } function normalizeUserPresetForPlan(rawPreset) { if (!rawPreset || typeof rawPreset !== 'object') { return null; } const rawId = Number(rawPreset.id); const presetId = Number.isFinite(rawId) && rawId > 0 ? Math.trunc(rawId) : null; const name = String(rawPreset.name || '').trim(); const handbrakePreset = String(rawPreset.handbrakePreset || '').trim(); const extraArgs = String(rawPreset.extraArgs || '').trim(); if (!presetId && !name && !handbrakePreset && !extraArgs) { return null; } return { id: presetId, name: name || (presetId ? `Preset #${presetId}` : 'User-Preset'), handbrakePreset: handbrakePreset || null, extraArgs: extraArgs || null }; } function buildScriptDescriptorList(scriptIds, sourceScripts = []) { const normalizedIds = normalizeScriptIdList(scriptIds); if (normalizedIds.length === 0) { return []; } const source = Array.isArray(sourceScripts) ? sourceScripts : []; const namesById = new Map( source .map((item) => { const id = Number(item?.id ?? item?.scriptId); const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null; const name = String(item?.name || '').trim(); if (!normalizedId || !name) { return null; } return [normalizedId, name]; }) .filter(Boolean) ); return normalizedIds.map((id) => ({ id, name: namesById.get(id) || `Skript #${id}` })); } function findSelectedTitleInPlan(encodePlan) { if (!encodePlan || !Array.isArray(encodePlan.titles) || encodePlan.titles.length === 0) { return null; } const preferredTitleId = normalizeReviewTitleId(encodePlan.encodeInputTitleId); if (preferredTitleId) { const byId = encodePlan.titles.find((title) => normalizeReviewTitleId(title?.id) === preferredTitleId) || null; if (byId) { return byId; } } return encodePlan.titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) || null; } function resolvePrefillEncodeTitleId(reviewPlan, previousPlan) { const reviewTitles = Array.isArray(reviewPlan?.titles) ? reviewPlan.titles : []; if (reviewTitles.length === 0) { return null; } const previousSelectedTitle = findSelectedTitleInPlan(previousPlan); if (!previousSelectedTitle) { return null; } const previousPlaylistId = normalizePlaylistId( previousSelectedTitle?.playlistId || previousPlan?.selectedPlaylistId || null ); if (previousPlaylistId) { const byPlaylist = reviewTitles.find((title) => normalizePlaylistId(title?.playlistId) === previousPlaylistId) || null; const id = normalizeReviewTitleId(byPlaylist?.id); if (id) { return id; } } const previousMakemkvTitleId = normalizeNonNegativeInteger( previousSelectedTitle?.makemkvTitleId ?? previousPlan?.selectedMakemkvTitleId ?? null ); if (previousMakemkvTitleId !== null) { const byMakemkvTitleId = reviewTitles.find((title) => ( normalizeNonNegativeInteger(title?.makemkvTitleId) === previousMakemkvTitleId )) || null; const id = normalizeReviewTitleId(byMakemkvTitleId?.id); if (id) { return id; } } const previousFileName = path.basename( String(previousSelectedTitle?.filePath || previousSelectedTitle?.fileName || '').trim() ).toLowerCase(); if (previousFileName) { const byFileName = reviewTitles.find((title) => { const candidate = path.basename( String(title?.filePath || title?.fileName || '').trim() ).toLowerCase(); return candidate && candidate === previousFileName; }) || null; const id = normalizeReviewTitleId(byFileName?.id); if (id) { return id; } } const previousTitleId = normalizeReviewTitleId(previousPlan?.encodeInputTitleId); if (!previousTitleId) { return null; } const fallback = reviewTitles.find((title) => normalizeReviewTitleId(title?.id) === previousTitleId) || null; return normalizeReviewTitleId(fallback?.id); } function mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds, { excludeBurned = false } = {}) { const tracks = Array.isArray(targetTracks) ? targetTracks : []; const allowedTracks = excludeBurned ? tracks.filter((track) => !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate)) : tracks; const requested = normalizeTrackIdList(sourceTrackIds); if (requested.length === 0 || allowedTracks.length === 0) { return []; } const mapped = []; const seen = new Set(); for (const sourceTrackId of requested) { const match = allowedTracks.find((track) => { const sourceId = normalizeTrackIdList([track?.sourceTrackId])[0] || null; const reviewId = normalizeTrackIdList([track?.id])[0] || null; return sourceId === sourceTrackId || reviewId === sourceTrackId; }) || null; const targetId = normalizeTrackIdList([match?.id])[0] || null; if (targetId === null) { continue; } const key = String(targetId); if (seen.has(key)) { continue; } seen.add(key); mapped.push(targetId); } return mapped; } function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = null) { const hasReviewTitles = reviewPlan && Array.isArray(reviewPlan?.titles) && reviewPlan.titles.length > 0; const hasPreviousTitles = previousPlan && Array.isArray(previousPlan?.titles) && previousPlan.titles.length > 0; if (!hasReviewTitles || !hasPreviousTitles) { return { plan: reviewPlan, applied: false, selectedEncodeTitleId: normalizeReviewTitleId(reviewPlan?.encodeInputTitleId), preEncodeScriptCount: 0, postEncodeScriptCount: 0, preEncodeChainCount: 0, postEncodeChainCount: 0, userPresetApplied: false }; } let nextPlan = reviewPlan; const prefillTitleId = resolvePrefillEncodeTitleId(nextPlan, previousPlan); let selectedTitleApplied = false; if (prefillTitleId) { try { const remapped = applyEncodeTitleSelectionToPlan(nextPlan, prefillTitleId); nextPlan = remapped.plan; selectedTitleApplied = true; } catch (_error) { // Keep calculated review defaults when title from previous run is no longer available. } } const previousSelectedTitle = findSelectedTitleInPlan(previousPlan); const nextSelectedTitle = findSelectedTitleInPlan(nextPlan); let trackSelectionApplied = false; if (previousSelectedTitle && nextSelectedTitle) { const previousAudioSourceIds = normalizeTrackIdList( (Array.isArray(previousSelectedTitle?.audioTracks) ? previousSelectedTitle.audioTracks : []) .filter((track) => Boolean(track?.selectedForEncode)) .map((track) => track?.sourceTrackId ?? track?.id) ); const previousSubtitleSourceIds = normalizeTrackIdList( (Array.isArray(previousSelectedTitle?.subtitleTracks) ? previousSelectedTitle.subtitleTracks : []) .filter((track) => Boolean(track?.selectedForEncode)) .map((track) => track?.sourceTrackId ?? track?.id) ); const mappedAudioTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds( nextSelectedTitle?.audioTracks, previousAudioSourceIds ); const mappedSubtitleTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds( nextSelectedTitle?.subtitleTracks, previousSubtitleSourceIds, { excludeBurned: true } ); const fallbackAudioTrackIds = normalizeTrackIdList( (Array.isArray(nextSelectedTitle?.audioTracks) ? nextSelectedTitle.audioTracks : []) .filter((track) => Boolean(track?.selectedByRule)) .map((track) => track?.id) ); const fallbackSubtitleTrackIds = normalizeTrackIdList( (Array.isArray(nextSelectedTitle?.subtitleTracks) ? nextSelectedTitle.subtitleTracks : []) .filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate)) .map((track) => track?.id) ); const effectiveAudioTrackIds = previousAudioSourceIds.length > 0 && mappedAudioTrackIds.length === 0 ? fallbackAudioTrackIds : mappedAudioTrackIds; const effectiveSubtitleTrackIds = previousSubtitleSourceIds.length > 0 && mappedSubtitleTrackIds.length === 0 ? fallbackSubtitleTrackIds : mappedSubtitleTrackIds; const targetTitleId = normalizeReviewTitleId(nextSelectedTitle?.id || nextPlan?.encodeInputTitleId); if (targetTitleId) { const trackSelectionResult = applyManualTrackSelectionToPlan(nextPlan, { [targetTitleId]: { audioTrackIds: effectiveAudioTrackIds, subtitleTrackIds: effectiveSubtitleTrackIds } }); nextPlan = trackSelectionResult.plan; trackSelectionApplied = Boolean(trackSelectionResult.selectionApplied); } } const preEncodeScriptIds = normalizeScriptIdList(previousPlan?.preEncodeScriptIds || []); const postEncodeScriptIds = normalizeScriptIdList(previousPlan?.postEncodeScriptIds || []); const preEncodeChainIds = normalizeChainIdList(previousPlan?.preEncodeChainIds || []); const postEncodeChainIds = normalizeChainIdList(previousPlan?.postEncodeChainIds || []); const userPreset = normalizeUserPresetForPlan(previousPlan?.userPreset || null); nextPlan = { ...nextPlan, preEncodeScriptIds, postEncodeScriptIds, preEncodeScripts: buildScriptDescriptorList(preEncodeScriptIds, previousPlan?.preEncodeScripts || []), postEncodeScripts: buildScriptDescriptorList(postEncodeScriptIds, previousPlan?.postEncodeScripts || []), preEncodeChainIds, postEncodeChainIds, userPreset, reviewConfirmed: false, reviewConfirmedAt: null, prefilledFromPreviousRun: true, prefilledFromPreviousRunAt: nowIso() }; const applied = selectedTitleApplied || trackSelectionApplied || preEncodeScriptIds.length > 0 || postEncodeScriptIds.length > 0 || preEncodeChainIds.length > 0 || postEncodeChainIds.length > 0 || Boolean(userPreset); return { plan: nextPlan, applied, selectedEncodeTitleId: normalizeReviewTitleId(nextPlan?.encodeInputTitleId), preEncodeScriptCount: preEncodeScriptIds.length, postEncodeScriptCount: postEncodeScriptIds.length, preEncodeChainCount: preEncodeChainIds.length, postEncodeChainCount: postEncodeChainIds.length, userPresetApplied: Boolean(userPreset) }; } class PipelineService extends EventEmitter { constructor() { super(); this.snapshot = { state: 'IDLE', activeJobId: null, progress: 0, eta: null, statusText: null, context: {} }; this.detectedDisc = null; this.cdDrives = new Map(); // devicePath → per-drive CD state object this.driveLocksByJob = new Map(); // jobId -> { devicePath, owner } this.activeProcess = null; this.activeProcesses = new Map(); this.cancelRequestedByJob = new Set(); this.jobProgress = new Map(); this.lastPersistAt = 0; this.lastProgressKey = null; this.queueEntries = []; this.queuePumpRunning = false; this.queueEntrySeq = 1; this.pluginRegistryInitialized = false; this.sourcePluginRegistry = null; this.lastQueueSnapshot = { maxParallelJobs: 1, runningCount: 0, runningJobs: [], queuedJobs: [], queuedCount: 0, updatedAt: nowIso() }; } normalizeBooleanSetting(value) { if (typeof value === 'boolean') { return value; } if (typeof value === 'number') { return value !== 0; } const normalized = String(value || '').trim().toLowerCase(); return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'; } ensureSourcePluginRegistry() { if (this.pluginRegistryInitialized) { return this.sourcePluginRegistry; } try { const { registry } = require('../plugins/PluginRegistry'); const { BluRayPlugin } = require('../plugins/BluRayPlugin'); const { DVDPlugin } = require('../plugins/DVDPlugin'); const { CdPlugin } = require('../plugins/CdPlugin'); const { AudiobookPlugin } = require('../plugins/AudiobookPlugin'); const { ConverterPlugin } = require('../plugins/ConverterPlugin'); const plugins = [ new BluRayPlugin(), new DVDPlugin(), new CdPlugin(), new AudiobookPlugin(), new ConverterPlugin() ]; for (const plugin of plugins) { if (!registry.getPlugin(plugin.id)) { registry.register(plugin); } } this.sourcePluginRegistry = registry; this.pluginRegistryInitialized = true; return this.sourcePluginRegistry; } catch (error) { logger.warn('plugin-architecture:registry-init-failed', { error: errorToMeta(error) }); return null; } } async resolveAnalyzePlugin(discInfo = null) { const registry = this.ensureSourcePluginRegistry(); if (!registry) { return null; } return registry.findPlugin(discInfo) || null; } sanitizePluginExecutionState(rawState = null) { if (!rawState || typeof rawState !== 'object' || Array.isArray(rawState)) { return null; } const normalizeStage = (value) => { const stage = String(value || '').trim().toLowerCase(); return stage || 'unknown'; }; const pluginId = String(rawState.pluginId || '').trim().toLowerCase() || 'unknown'; const pluginName = String(rawState.pluginName || '').trim() || pluginId; const pluginFile = String(rawState.pluginFile || '').trim() || null; const markerSource = String(rawState.markerSource || '').trim().toLowerCase() || 'plugin-file'; const byStageRaw = rawState.byStage && typeof rawState.byStage === 'object' && !Array.isArray(rawState.byStage) ? rawState.byStage : {}; const byStage = {}; for (const [stageKey, stageMeta] of Object.entries(byStageRaw)) { const normalizedStage = normalizeStage(stageKey); const count = Number(stageMeta?.count); byStage[normalizedStage] = { count: Number.isFinite(count) && count > 0 ? Math.trunc(count) : 1, lastMarkedAt: String(stageMeta?.lastMarkedAt || '').trim() || null }; } const explicitStages = Array.isArray(rawState.stages) ? rawState.stages.map((stage) => normalizeStage(stage)).filter(Boolean) : []; const rawLastStage = String(rawState.lastStage || '').trim(); const lastStage = rawLastStage ? normalizeStage(rawLastStage) : null; const stages = Array.from(new Set([ ...explicitStages, ...Object.keys(byStage), ...(lastStage ? [lastStage] : []) ])); return { markerSource, pluginId, pluginName, pluginFile, jobId: this.normalizeQueueJobId(rawState.jobId), firstMarkedAt: String(rawState.firstMarkedAt || '').trim() || null, lastMarkedAt: String(rawState.lastMarkedAt || '').trim() || null, lastStage, stages, byStage }; } mergePluginExecutionState(existingState = null, nextState = null) { const existing = this.sanitizePluginExecutionState(existingState); const incoming = this.sanitizePluginExecutionState(nextState); if (!existing) { return incoming; } if (!incoming) { return existing; } const byStage = {}; const stageKeys = new Set([ ...Object.keys(existing.byStage || {}), ...Object.keys(incoming.byStage || {}) ]); for (const stage of stageKeys) { const previousMeta = existing.byStage?.[stage] || null; const nextMeta = incoming.byStage?.[stage] || null; const count = Number(previousMeta?.count || 0) + Number(nextMeta?.count || 0); byStage[stage] = { count: count > 0 ? Math.trunc(count) : 1, lastMarkedAt: nextMeta?.lastMarkedAt || previousMeta?.lastMarkedAt || null }; } const stages = Array.from(new Set([ ...(Array.isArray(existing.stages) ? existing.stages : []), ...(Array.isArray(incoming.stages) ? incoming.stages : []) ])); return { markerSource: incoming.markerSource || existing.markerSource || 'plugin-file', pluginId: incoming.pluginId || existing.pluginId || 'unknown', pluginName: incoming.pluginName || existing.pluginName || incoming.pluginId || existing.pluginId || 'unknown', pluginFile: incoming.pluginFile || existing.pluginFile || null, jobId: this.normalizeQueueJobId(incoming.jobId || existing.jobId), firstMarkedAt: existing.firstMarkedAt || incoming.firstMarkedAt || incoming.lastMarkedAt || existing.lastMarkedAt || null, lastMarkedAt: incoming.lastMarkedAt || existing.lastMarkedAt || null, lastStage: incoming.lastStage || existing.lastStage || (stages.length > 0 ? stages[stages.length - 1] : null), stages, byStage }; } withPluginExecutionMeta(info = null, executionState = null) { const baseInfo = info && typeof info === 'object' && !Array.isArray(info) ? { ...info } : {}; const mergedExecution = this.mergePluginExecutionState(baseInfo.pluginExecution, executionState); if (!mergedExecution) { return baseInfo; } baseInfo.pluginExecution = mergedExecution; return baseInfo; } async applyPluginExecutionMarker(marker = null, executionState = null) { const mergedExecution = this.mergePluginExecutionState(null, executionState || marker); const jobId = this.normalizeQueueJobId(mergedExecution?.jobId || marker?.jobId); if (!mergedExecution || !jobId) { return; } const previousJobProgress = this.jobProgress.get(jobId) || {}; const nextJobProgress = { ...previousJobProgress, state: previousJobProgress.state || this.snapshot.state, progress: previousJobProgress.progress ?? this.snapshot.progress ?? 0, eta: previousJobProgress.eta ?? null, statusText: previousJobProgress.statusText ?? this.snapshot.statusText ?? null, context: { ...(previousJobProgress.context && typeof previousJobProgress.context === 'object' ? previousJobProgress.context : {}), pluginExecution: this.mergePluginExecutionState(previousJobProgress.context?.pluginExecution, mergedExecution) } }; this.jobProgress.set(jobId, nextJobProgress); const snapshotJobId = this.normalizeQueueJobId(this.snapshot.activeJobId || this.snapshot.context?.jobId); if (snapshotJobId === jobId) { this.snapshot = { ...this.snapshot, context: { ...(this.snapshot.context && typeof this.snapshot.context === 'object' ? this.snapshot.context : {}), pluginExecution: this.mergePluginExecutionState(this.snapshot.context?.pluginExecution, mergedExecution) } }; await this.persistSnapshot(false); } const cdDriveEntry = this._getCdDriveByJobId(jobId); if (cdDriveEntry?.devicePath) { const existingDrive = this.cdDrives.get(cdDriveEntry.devicePath); if (existingDrive) { this.cdDrives.set(cdDriveEntry.devicePath, { ...existingDrive, context: { ...(existingDrive.context && typeof existingDrive.context === 'object' ? existingDrive.context : {}), pluginExecution: this.mergePluginExecutionState(existingDrive.context?.pluginExecution, mergedExecution) } }); } } wsService.broadcast('PIPELINE_PROGRESS', { state: nextJobProgress.state || this.snapshot.state, activeJobId: jobId, progress: nextJobProgress.progress ?? 0, eta: nextJobProgress.eta ?? null, statusText: nextJobProgress.statusText ?? null, contextPatch: { pluginExecution: nextJobProgress.context.pluginExecution } }); } async buildPluginContext(pluginId, extra = {}) { const { PluginContext } = require('../plugins/PluginContext'); const normalizedJobId = this.normalizeQueueJobId(extra?.jobId ?? extra?.job?.id); const emitProgress = typeof extra?.emitProgress === 'function' ? extra.emitProgress : () => {}; const emitState = typeof extra?.emitState === 'function' ? extra.emitState : () => {}; return new PluginContext({ settings: settingsService, db: await getDb(), logger: require('./logger').child(`PLUGIN:${String(pluginId || 'unknown').trim().toUpperCase() || 'UNKNOWN'}`), websocket: wsService, processRunner: { spawnTrackedProcess }, emitProgress, emitState, onPluginExecution: (marker, aggregateState) => { void this.applyPluginExecutionMarker(marker, aggregateState); }, extra: { ...extra, jobId: normalizedJobId, pluginId } }); } async runPluginReviewScan(plugin, job, { jobId, rawPath = null, deviceInfo = null, reviewMode = 'rip', source = 'HANDBRAKE_SCAN', silent = false } = {}) { if (!plugin || typeof plugin.review !== 'function') { const error = new Error('Plugin-Review nicht verfügbar.'); error.statusCode = 500; throw error; } const pluginCtx = await this.buildPluginContext(plugin.id, { jobId, rawJobDir: rawPath || null, deviceInfo: deviceInfo || null, reviewMode: String(reviewMode || 'rip').trim().toLowerCase() || 'rip', runCommand: this.runCommand.bind(this), reviewSource: String(source || 'HANDBRAKE_SCAN').trim().toUpperCase() || 'HANDBRAKE_SCAN', reviewStage: 'MEDIAINFO_CHECK', reviewSilent: Boolean(silent) }); const pluginResult = await plugin.review(job, pluginCtx); return { scanLines: Array.isArray(pluginResult?.scanLines) ? pluginResult.scanLines : [], runInfo: pluginResult?.runInfo && typeof pluginResult.runInfo === 'object' ? pluginResult.runInfo : null, sourceArg: String(pluginResult?.sourceArg || '').trim() || null, pluginExecution: this.sanitizePluginExecutionState(pluginCtx.getPluginExecution()) }; } isRipSuccessful(job = null) { if (Number(job?.rip_successful || 0) === 1) { return true; } if (isJobFinished(job)) { return true; } const mkInfo = this.safeParseJson(job?.makemkv_info_json); return String(mkInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; } isEncodeSuccessful(job = null) { const handBrakeInfo = this.safeParseJson(job?.handbrake_info_json); return String(handBrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; } resolveDesiredRawFolderState(job = null) { if (!this.isRipSuccessful(job)) { return RAW_FOLDER_STATES.INCOMPLETE; } if (this.isEncodeSuccessful(job)) { return RAW_FOLDER_STATES.COMPLETE; } return RAW_FOLDER_STATES.RIP_COMPLETE; } resolveCurrentRawPath(rawBaseDir, storedRawPath, extraBaseDirs = []) { const stored = String(storedRawPath || '').trim(); if (!stored) { return null; } const folderName = path.basename(stored); const currentBaseDir = path.dirname(stored); const allBaseDirs = [currentBaseDir, rawBaseDir, ...extraBaseDirs].filter(Boolean); const uniqueBaseDirs = Array.from(new Set(allBaseDirs.map((item) => String(item).trim()).filter(Boolean))); const variantFolderNames = Array.from( new Set( [ folderName, applyRawFolderStateToName(folderName, RAW_FOLDER_STATES.RIP_COMPLETE), applyRawFolderStateToName(folderName, RAW_FOLDER_STATES.INCOMPLETE), applyRawFolderStateToName(folderName, RAW_FOLDER_STATES.COMPLETE) ].map((item) => String(item || '').trim()).filter(Boolean) ) ); const candidates = []; const pushCandidate = (candidatePath) => { const normalized = String(candidatePath || '').trim(); if (!normalized || candidates.includes(normalized)) { return; } candidates.push(normalized); }; pushCandidate(stored); for (const baseDir of uniqueBaseDirs) { for (const variantFolderName of variantFolderNames) { pushCandidate(path.join(baseDir, variantFolderName)); } } const existingDirectories = []; for (const candidate of candidates) { try { if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { existingDirectories.push(candidate); } } catch (_error) { // ignore fs errors } } if (existingDirectories.length === 0) { return null; } for (const candidate of existingDirectories) { try { if (hasBluRayBackupStructure(candidate) || findPreferredRawInput(candidate)) { return candidate; } } catch (_error) { // ignore fs errors } } return existingDirectories[0]; } buildRawPathLookupConfig(settingsMap = {}, mediaProfile = null) { const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {}; const normalizedMediaProfile = normalizeMediaProfile(mediaProfile); const effectiveSettings = settingsService.resolveEffectiveToolSettings(sourceMap, normalizedMediaProfile); const preferredDefaultRawDir = normalizedMediaProfile === 'cd' ? settingsService.DEFAULT_CD_DIR : normalizedMediaProfile === 'audiobook' ? settingsService.DEFAULT_AUDIOBOOK_RAW_DIR : settingsService.DEFAULT_RAW_DIR; const uniqueRawDirs = Array.from( new Set( [ effectiveSettings?.raw_dir, sourceMap?.raw_dir, sourceMap?.raw_dir_bluray, sourceMap?.raw_dir_dvd, sourceMap?.raw_dir_cd, sourceMap?.raw_dir_audiobook, preferredDefaultRawDir, settingsService.DEFAULT_RAW_DIR, settingsService.DEFAULT_CD_DIR, settingsService.DEFAULT_AUDIOBOOK_RAW_DIR ] .map((item) => String(item || '').trim()) .filter(Boolean) ) ); return { effectiveSettings, rawBaseDir: uniqueRawDirs[0] || String(preferredDefaultRawDir || '').trim() || null, rawExtraDirs: uniqueRawDirs.slice(1) }; } resolveCurrentRawPathForSettings(settingsMap = {}, mediaProfile = null, storedRawPath = null) { const stored = String(storedRawPath || '').trim(); if (!stored) { return null; } const { rawBaseDir, rawExtraDirs } = this.buildRawPathLookupConfig(settingsMap, mediaProfile); return this.resolveCurrentRawPath(rawBaseDir, stored, rawExtraDirs); } async resolveUsableRawInputForReadyJob(jobId, job, encodePlan = null) { const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : this.safeParseJson(job?.encode_plan_json); const mkInfo = this.safeParseJson(job?.makemkv_info_json); const mediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: mkInfo, encodePlan: plan, rawPath: job?.raw_path }); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const resolvedRawPath = this.resolveCurrentRawPathForSettings( settings, mediaProfile, job?.raw_path ) || String(job?.raw_path || '').trim() || null; if (!resolvedRawPath || !fs.existsSync(resolvedRawPath)) { return { mediaProfile, resolvedRawPath, inputPath: null, hasUsableRawInput: false }; } let inputPath = null; try { if (hasBluRayBackupStructure(resolvedRawPath)) { inputPath = resolvedRawPath; } else { const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); inputPath = findPreferredRawInput(resolvedRawPath, { playlistAnalysis: playlistDecision.playlistAnalysis, selectedPlaylistId: playlistDecision.selectedPlaylist })?.path || null; } } catch (_error) { inputPath = null; } return { mediaProfile, resolvedRawPath, inputPath, hasUsableRawInput: Boolean(inputPath) }; } async alignRawFolderJobId(rawPath, targetJobId) { const sourceRawPath = String(rawPath || '').trim(); const normalizedTargetJobId = this.normalizeQueueJobId(targetJobId); if (!sourceRawPath || !normalizedTargetJobId) { return sourceRawPath || null; } const normalizedSourceRawPath = normalizeComparablePath(sourceRawPath); const folderName = path.basename(normalizedSourceRawPath); const baseName = stripRawStatePrefix(folderName); if (!/\s-\sRAW\s-\sjob-\d+\s*$/i.test(baseName)) { return normalizedSourceRawPath; } const metadataBase = baseName.replace(/\s-\sRAW\s-\sjob-\d+\s*$/i, '').trim(); if (!metadataBase) { return normalizedSourceRawPath; } const rawState = resolveRawFolderStateFromPath(normalizedSourceRawPath); const nextFolderName = buildRawDirName(metadataBase, normalizedTargetJobId, { state: rawState }); const normalizedTargetRawPath = normalizeComparablePath( path.join(path.dirname(normalizedSourceRawPath), nextFolderName) ); if (!normalizedTargetRawPath || normalizedTargetRawPath === normalizedSourceRawPath) { return normalizedSourceRawPath; } try { if (!fs.existsSync(normalizedSourceRawPath) || !fs.statSync(normalizedSourceRawPath).isDirectory()) { return normalizedSourceRawPath; } if (fs.existsSync(normalizedTargetRawPath)) { logger.warn('raw-path:align-job-id:target-exists', { targetJobId: normalizedTargetJobId, sourceRawPath: normalizedSourceRawPath, targetRawPath: normalizedTargetRawPath }); return normalizedSourceRawPath; } fs.renameSync(normalizedSourceRawPath, normalizedTargetRawPath); await historyService.updateRawPathByOldPath(normalizedSourceRawPath, normalizedTargetRawPath); logger.info('raw-path:align-job-id:renamed', { targetJobId: normalizedTargetJobId, from: normalizedSourceRawPath, to: normalizedTargetRawPath }); return normalizedTargetRawPath; } catch (error) { logger.warn('raw-path:align-job-id:failed', { targetJobId: normalizedTargetJobId, sourceRawPath: normalizedSourceRawPath, targetRawPath: normalizedTargetRawPath, error: errorToMeta(error) }); return normalizedSourceRawPath; } } async migrateRawFolderNamingOnStartup(db) { const settings = await settingsService.getSettingsMap(); const rawBaseDir = String(settings?.raw_dir || settingsService.DEFAULT_RAW_DIR || '').trim(); const rawExtraDirs = [ settings?.raw_dir_bluray, settings?.raw_dir_dvd, settings?.raw_dir_cd, settings?.raw_dir_audiobook, settingsService.DEFAULT_CD_DIR, settingsService.DEFAULT_AUDIOBOOK_RAW_DIR ].map((d) => String(d || '').trim()).filter(Boolean); const allRawDirs = [rawBaseDir, settingsService.DEFAULT_RAW_DIR, ...rawExtraDirs] .filter((d, i, arr) => arr.indexOf(d) === i && d && fs.existsSync(d)); if (allRawDirs.length === 0) { return; } const rows = await db.all(` SELECT id, title, year, detected_title, raw_path, status, last_state, rip_successful, makemkv_info_json, handbrake_info_json FROM jobs WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' AND (media_type IS NULL OR media_type <> 'converter') AND (job_kind IS NULL OR job_kind NOT LIKE 'converter_%') `); if (!Array.isArray(rows) || rows.length === 0) { return; } let renamedCount = 0; let pathUpdateCount = 0; let ripFlagUpdateCount = 0; let conflictCount = 0; let missingCount = 0; const discoveredByJobId = new Map(); for (const scanDir of allRawDirs) { try { const dirEntries = fs.readdirSync(scanDir, { withFileTypes: true }); for (const entry of dirEntries) { if (!entry.isDirectory()) { continue; } const match = String(entry.name || '').match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i); if (!match) { continue; } const mappedJobId = Number(match[1]); if (!Number.isFinite(mappedJobId) || mappedJobId <= 0) { continue; } const candidatePath = path.join(scanDir, entry.name); let mtimeMs = 0; try { mtimeMs = Number(fs.statSync(candidatePath).mtimeMs || 0); } catch (_error) { // ignore fs errors and keep zero mtime } const current = discoveredByJobId.get(mappedJobId); if (!current || mtimeMs > current.mtimeMs) { discoveredByJobId.set(mappedJobId, { path: candidatePath, mtimeMs }); } } } catch (scanError) { logger.warn('startup:raw-dir-migrate:scan-failed', { scanDir, error: errorToMeta(scanError) }); } } for (const row of rows) { const jobId = Number(row?.id); if (!Number.isFinite(jobId) || jobId <= 0) { continue; } const ripSuccessful = this.isRipSuccessful(row); if (ripSuccessful && Number(row?.rip_successful || 0) !== 1) { await historyService.updateJob(jobId, { rip_successful: 1 }); ripFlagUpdateCount += 1; } const currentRawPath = this.resolveCurrentRawPath(rawBaseDir, row.raw_path, rawExtraDirs) || discoveredByJobId.get(jobId)?.path || null; if (!currentRawPath) { missingCount += 1; continue; } // Keep renamed folder in the same base dir as the current path const currentBaseDir = path.dirname(currentRawPath); const currentFolderName = stripRawStatePrefix(path.basename(currentRawPath)); const folderYearMatch = currentFolderName.match(/\((19|20)\d{2}\)/); const fallbackYear = folderYearMatch ? Number(String(folderYearMatch[0]).replace(/[()]/g, '')) : null; const metadataBase = buildRawMetadataBase({ title: row.title || row.detected_title || null, year: row.year || null, fallbackYear }, jobId); const desiredRawFolderState = this.resolveDesiredRawFolderState(row); const desiredRawPath = path.join( currentBaseDir, buildRawDirName(metadataBase, jobId, { state: desiredRawFolderState }) ); let finalRawPath = currentRawPath; if (normalizeComparablePath(currentRawPath) !== normalizeComparablePath(desiredRawPath)) { if (fs.existsSync(desiredRawPath)) { conflictCount += 1; logger.warn('startup:raw-dir-migrate:target-exists', { jobId, currentRawPath, desiredRawPath }); } else { try { fs.renameSync(currentRawPath, desiredRawPath); finalRawPath = desiredRawPath; renamedCount += 1; } catch (renameError) { logger.warn('startup:raw-dir-migrate:rename-failed', { jobId, currentRawPath, desiredRawPath, error: errorToMeta(renameError) }); continue; } } } if (normalizeComparablePath(row.raw_path) !== normalizeComparablePath(finalRawPath)) { await historyService.updateRawPathByOldPath(row.raw_path, finalRawPath); pathUpdateCount += 1; } } if (renamedCount > 0 || pathUpdateCount > 0 || ripFlagUpdateCount > 0 || conflictCount > 0 || missingCount > 0) { logger.info('startup:raw-dir-migrate:done', { renamedCount, pathUpdateCount, ripFlagUpdateCount, conflictCount, missingCount, scannedDirs: allRawDirs }); } } async init() { const db = await getDb(); try { await this.migrateRawFolderNamingOnStartup(db); } catch (migrationError) { logger.warn('init:raw-dir-migrate-failed', { error: errorToMeta(migrationError) }); } const row = await db.get('SELECT * FROM pipeline_state WHERE id = 1'); if (row) { this.snapshot = { state: row.state, activeJobId: row.active_job_id, progress: Number(row.progress || 0), eta: row.eta, statusText: row.status_text, context: this.safeParseJson(row.context_json) }; logger.info('init:loaded-snapshot', { snapshot: this.snapshot }); } try { await this.recoverStaleRunningJobsOnStartup(db); } catch (recoveryError) { logger.warn('init:stale-running-recovery-failed', { error: errorToMeta(recoveryError) }); } try { await this._restoreDriveLocksFromJobs(); } catch (driveLockRecoveryError) { logger.warn('init:drive-lock-recovery-failed', { error: errorToMeta(driveLockRecoveryError) }); } // Always start with a clean ripper/session snapshot after server restart. const hasContextKeys = this.snapshot.context && typeof this.snapshot.context === 'object' && Object.keys(this.snapshot.context).length > 0; if (this.snapshot.state !== 'IDLE' || this.snapshot.activeJobId || hasContextKeys) { await this.resetFrontendState('server_restart', { force: true, keepDetectedDevice: false }); } await this.emitQueueChanged(); void this.pumpQueue(); } async recoverStaleRunningJobsOnStartup(db) { const staleRows = await db.all(` SELECT id, status, last_state FROM jobs WHERE status IN ('ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING') ORDER BY updated_at ASC, id ASC `); const rows = Array.isArray(staleRows) ? staleRows : []; if (rows.length === 0) { return { scanned: 0, preparedReadyToEncode: 0, markedError: 0, skipped: 0 }; } let preparedReadyToEncode = 0; let markedError = 0; let skipped = 0; for (const row of rows) { const jobId = this.normalizeQueueJobId(row?.id); if (!jobId) { skipped += 1; continue; } const rawStage = String(row?.status || row?.last_state || '').trim().toUpperCase(); const stage = RUNNING_STATES.has(rawStage) ? rawStage : 'ENCODING'; const message = `Server-Neustart erkannt während ${stage}. Laufender Prozess wurde beendet.`; if (stage === 'ENCODING') { try { await historyService.appendLog(jobId, 'SYSTEM', message); } catch (_error) { // keep recovery path even if log append fails } try { await this.restartEncodeWithLastSettings(jobId, { immediate: true, triggerReason: 'server_restart' }); preparedReadyToEncode += 1; continue; } catch (error) { logger.warn('startup:recover-stale-encoding:restart-failed', { jobId, error: errorToMeta(error) }); try { await historyService.appendLog( jobId, 'SYSTEM', `Startup-Recovery Encode fehlgeschlagen, setze Job auf ERROR: ${error?.message || 'unknown'}` ); } catch (_logError) { // ignore logging fallback errors } } } if (stage === 'CD_ENCODING') { // CD was encoding WAVs — try to restart the encode from existing WAV files try { await historyService.appendLog(jobId, 'SYSTEM', message); } catch (_error) { // keep recovery path even if log append fails } try { await this.reencodeFromRaw(jobId, { immediate: true }); preparedReadyToEncode += 1; continue; } catch (error) { logger.warn('startup:recover-stale-cd-encoding:restart-failed', { jobId, error: errorToMeta(error) }); try { await historyService.appendLog( jobId, 'SYSTEM', `Startup-Recovery CD-Encode fehlgeschlagen, setze Job auf ERROR: ${error?.message || 'unknown'}` ); } catch (_logError) { // ignore logging fallback errors } } } await historyService.updateJob(jobId, { status: 'ERROR', last_state: stage, end_time: nowIso(), error_message: message }); try { await historyService.appendLog(jobId, 'SYSTEM', message); } catch (_error) { // ignore logging failures during startup recovery } markedError += 1; } logger.warn('startup:recover-stale-running-jobs', { scanned: rows.length, preparedReadyToEncode, markedError, skipped }); return { scanned: rows.length, preparedReadyToEncode, markedError, skipped }; } safeParseJson(raw) { if (!raw) { return {}; } try { return JSON.parse(raw); } catch (error) { logger.warn('safeParseJson:failed', { raw, error: errorToMeta(error) }); return {}; } } getSnapshot() { const jobProgress = {}; for (const [id, data] of this.jobProgress) { jobProgress[id] = data; } const cdDrives = {}; for (const [devicePath, driveState] of this.cdDrives) { cdDrives[devicePath] = driveState; } const detectedDiscs = {}; for (const [path, device] of diskDetectionService.detectedDiscs) { detectedDiscs[path] = device; } const driveLocks = diskDetectionService.getActiveLocks(); return { ...this.snapshot, jobProgress, queue: this.lastQueueSnapshot, cdDrives, detectedDiscs, driveLocks }; } _broadcastPipelineStateChanged() { const snapshotPayload = this.getSnapshot(); wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); this.emit('stateChanged', snapshotPayload); return snapshotPayload; } _setCdDriveState(devicePath, updates) { const existing = this.cdDrives.get(devicePath) || { devicePath, state: 'DISC_DETECTED', jobId: null, device: null, progress: 0, eta: null, statusText: null, context: {} }; const next = { ...existing, ...updates, devicePath }; this.cdDrives.set(devicePath, next); // If a drive is detached from a job (or reassigned), clear stale live // progress for the previous job so ripper views do not stay in CD_ENCODING. const previousJobId = Number(existing?.jobId || 0); const nextJobId = Number(next?.jobId || 0); if (Number.isFinite(previousJobId) && previousJobId > 0) { const previousJobStillBound = Number.isFinite(nextJobId) && nextJobId > 0 && nextJobId === previousJobId; if (!previousJobStillBound) { this.jobProgress.delete(previousJobId); } } // Sync jobProgress so per-job queries work const resolvedJobId = Number(next.jobId || 0); if (Number.isFinite(resolvedJobId) && resolvedJobId > 0) { const prev = this.jobProgress.get(resolvedJobId) || {}; const contextPatch = next.context && typeof next.context === 'object' ? next.context : null; const mergedContext = contextPatch ? { ...(prev.context || {}), ...contextPatch } : (prev.context || null); const nextJobProgress = { ...prev, state: next.state, progress: next.progress ?? prev.progress ?? 0, eta: next.eta ?? null, statusText: next.statusText ?? null }; if (mergedContext && Object.keys(mergedContext).length > 0) { nextJobProgress.context = mergedContext; } this.jobProgress.set(resolvedJobId, nextJobProgress); } // Manage disc-polling suspension while any CD drive is actively ripping const CD_ACTIVE_STATES = new Set(['CD_ANALYZING', 'CD_RIPPING']); const anyActive = Array.from(this.cdDrives.values()).some((d) => CD_ACTIVE_STATES.has(d.state)); if (anyActive) { diskDetectionService.suspendPolling(); } else { diskDetectionService.resumePolling(); } logger.info('cd:drive:state', { devicePath, state: next.state, jobId: next.jobId }); const snapshotPayload = this.getSnapshot(); wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); this.emit('stateChanged', snapshotPayload); void this.emitQueueChanged(); } _removeCdDrive(devicePath) { const drive = this.cdDrives.get(devicePath); if (!drive) { return; } const jobId = Number(drive.jobId || 0); this.cdDrives.delete(devicePath); if (Number.isFinite(jobId) && jobId > 0) { this.jobProgress.delete(jobId); } // Resume polling if no other active drives const CD_ACTIVE_STATES = new Set(['CD_ANALYZING', 'CD_RIPPING']); const anyActive = Array.from(this.cdDrives.values()).some((d) => CD_ACTIVE_STATES.has(d.state)); if (!anyActive) { diskDetectionService.resumePolling(); } logger.info('cd:drive:removed', { devicePath }); const snapshotPayload = this.getSnapshot(); wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); this.emit('stateChanged', snapshotPayload); } _getCdDriveByJobId(jobId) { const normalizedId = Number(jobId); if (!Number.isFinite(normalizedId) || normalizedId <= 0) { return null; } for (const [devicePath, drive] of this.cdDrives) { if (Number(drive.jobId) === normalizedId) { return { ...drive, devicePath }; } } return null; } _releaseCdDrive(devicePath, options = {}) { const normalizedPath = String(devicePath || '').trim(); if (!normalizedPath) { return; } const existing = this.cdDrives.get(normalizedPath) || null; const fallbackDevice = options?.device && typeof options.device === 'object' ? options.device : (existing?.device && typeof existing.device === 'object' ? existing.device : { path: normalizedPath, mediaProfile: 'cd' }); const releasedDevice = { ...fallbackDevice, path: normalizedPath, mediaProfile: 'cd' }; this._setCdDriveState(normalizedPath, { state: 'DISC_DETECTED', jobId: null, device: releasedDevice, progress: 0, eta: null, statusText: null, context: { device: releasedDevice, devicePath: normalizedPath, mediaProfile: 'cd' } }); } normalizeDrivePath(value) { return String(value || '').trim(); } _buildDriveLockOwner(jobId, extra = {}) { const normalizedJobId = Number(jobId); return { jobId: Number.isFinite(normalizedJobId) && normalizedJobId > 0 ? Math.trunc(normalizedJobId) : null, stage: String(extra?.stage || '').trim().toUpperCase() || null, source: String(extra?.source || '').trim() || null, mediaProfile: normalizeMediaProfile(extra?.mediaProfile) || null, reason: String(extra?.reason || '').trim() || null, acquiredAt: nowIso() }; } _getDriveLockByJobId(jobId) { const normalizedJobId = Number(jobId); if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { return null; } return this.driveLocksByJob.get(Math.trunc(normalizedJobId)) || null; } _getDriveLockByPath(devicePath) { const normalizedPath = this.normalizeDrivePath(devicePath); if (!normalizedPath) { return null; } for (const [jobId, lock] of this.driveLocksByJob.entries()) { if (this.normalizeDrivePath(lock?.devicePath) === normalizedPath) { return { ...lock, jobId: Number(jobId) }; } } return null; } _acquireDriveLockForJob(devicePath, jobId, extra = {}) { const normalizedPath = this.normalizeDrivePath(devicePath); const normalizedJobId = Number(jobId); if (!normalizedPath || !Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { return false; } const targetJobId = Math.trunc(normalizedJobId); const existing = this._getDriveLockByJobId(targetJobId); if (existing && this.normalizeDrivePath(existing.devicePath) === normalizedPath) { return true; } if (existing) { this._releaseDriveLockForJob(targetJobId, { reason: 'rebind' }); } const owner = this._buildDriveLockOwner(targetJobId, extra); diskDetectionService.lockDevice(normalizedPath, owner); this.driveLocksByJob.set(targetJobId, { devicePath: normalizedPath, owner }); logger.info('drive-lock:acquired', { devicePath: normalizedPath, jobId: targetJobId, stage: owner.stage, source: owner.source, reason: owner.reason }); this._broadcastPipelineStateChanged(); return true; } _releaseDriveLockForJob(jobId, options = {}) { const normalizedJobId = Number(jobId); if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { return false; } const targetJobId = Math.trunc(normalizedJobId); const existing = this.driveLocksByJob.get(targetJobId); if (!existing) { return false; } diskDetectionService.unlockDevice(existing.devicePath, existing.owner || null); this.driveLocksByJob.delete(targetJobId); logger.info('drive-lock:released', { devicePath: existing.devicePath, jobId: targetJobId, reason: String(options?.reason || '').trim() || null }); this._broadcastPipelineStateChanged(); return true; } _buildDriveLockedError(devicePath, lock = null) { const normalizedPath = this.normalizeDrivePath(devicePath) || ''; const lockedByJobId = Number(lock?.jobId || lock?.owner?.jobId || 0) || null; const stage = String(lock?.owner?.stage || '').trim().toUpperCase() || null; const message = lockedByJobId ? `Laufwerk ${normalizedPath} ist gesperrt (Job #${lockedByJobId}${stage ? `, ${stage}` : ''}).` : `Laufwerk ${normalizedPath} ist derzeit gesperrt.`; const error = new Error(message); error.statusCode = 409; error.details = [{ field: 'devicePath', message }]; return error; } _shouldKeepDriveLockForJobRow(job = null) { const status = String(job?.status || '').trim().toUpperCase(); const lastState = String(job?.last_state || '').trim().toUpperCase(); const ripSuccessful = Number(job?.rip_successful || 0) === 1; if (!this.normalizeDrivePath(job?.disc_device)) { return false; } if (status === 'RIPPING' || status === 'CD_RIPPING') { return true; } if ((status === 'ERROR' || status === 'CANCELLED') && !ripSuccessful) { return lastState === 'RIPPING' || lastState === 'CD_RIPPING'; } return false; } async _restoreDriveLocksFromJobs() { const db = await getDb(); const rows = await db.all( ` SELECT id, disc_device, status, last_state, rip_successful FROM jobs WHERE disc_device IS NOT NULL AND TRIM(disc_device) <> '' AND status IN ('RIPPING', 'CD_RIPPING', 'ERROR', 'CANCELLED') ` ); let restored = 0; for (const row of (Array.isArray(rows) ? rows : [])) { if (!this._shouldKeepDriveLockForJobRow(row)) { continue; } const jobId = Number(row?.id); const devicePath = this.normalizeDrivePath(row?.disc_device); if (!Number.isFinite(jobId) || jobId <= 0 || !devicePath) { continue; } const acquired = this._acquireDriveLockForJob(devicePath, jobId, { stage: String(row?.status || row?.last_state || '').trim().toUpperCase() || null, source: 'startup_recovery', reason: 'pending_rip_recovery' }); if (acquired) { restored += 1; } } if (restored > 0) { logger.warn('drive-lock:restored', { restored }); } } async _cleanupReplaceableDriveJobsForAnalyze(devicePath) { const normalizedPath = this.normalizeDrivePath(devicePath); if (!normalizedPath) { return []; } const existingLock = this._getDriveLockByPath(normalizedPath); if (diskDetectionService.isDeviceLocked(normalizedPath) || existingLock) { throw this._buildDriveLockedError(normalizedPath, existingLock); } const db = await getDb(); const rows = await db.all( ` SELECT * FROM jobs WHERE disc_device = ? ORDER BY updated_at DESC, id DESC `, [normalizedPath] ); const replaceableStates = new Set([ 'ANALYZING', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'READY_TO_START', 'MEDIAINFO_CHECK', // READY_TO_ENCODE intentionally excluded: user has confirmed encoding, // a new analyze must not silently delete a queued encode job. 'CD_ANALYZING', 'CD_METADATA_SELECTION', // CD_READY_TO_RIP intentionally excluded: user has confirmed track // selection, a new analyze must not discard that. 'ERROR', 'CANCELLED' ]); const deleted = []; for (const row of (Array.isArray(rows) ? rows : [])) { const jobId = Number(row?.id); if (!Number.isFinite(jobId) || jobId <= 0) { continue; } const normalizedJobId = Math.trunc(jobId); const rowState = String(row?.status || row?.last_state || '').trim().toUpperCase(); if (!replaceableStates.has(rowState)) { continue; } if (this.activeProcesses.has(normalizedJobId)) { const error = new Error(`Analyse nicht möglich: Job #${normalizedJobId} für ${normalizedPath} ist noch aktiv.`); error.statusCode = 409; throw error; } if (this._shouldKeepDriveLockForJobRow(row)) { throw this._buildDriveLockedError(normalizedPath, this._getDriveLockByJobId(normalizedJobId)); } await historyService.deleteJob(normalizedJobId, 'none', { includeRelated: false }); await this.onJobsDeleted([normalizedJobId], { suppressQueueRefresh: true }); deleted.push(normalizedJobId); } if (deleted.length > 0) { logger.info('analyze:drive:replace-existing-jobs', { devicePath: normalizedPath, deletedJobIds: deleted }); } return deleted; } _forceUnlockDriveLocksForDeletedJobs(devicePaths = [], deletedJobIds = []) { const normalizedPaths = Array.from(new Set( (Array.isArray(devicePaths) ? devicePaths : []) .map((value) => this.normalizeDrivePath(value)) .filter((value) => Boolean(value) && !value.startsWith('__virtual__')) )); if (normalizedPaths.length === 0 || typeof diskDetectionService.forceUnlockDevice !== 'function') { return []; } const deletedSet = new Set( (Array.isArray(deletedJobIds) ? deletedJobIds : []) .map((value) => Number(value)) .filter((value) => Number.isFinite(value) && value > 0) .map((value) => Math.trunc(value)) ); const activeLocks = diskDetectionService.getActiveLocks(); const forceUnlockedPaths = []; for (const devicePath of normalizedPaths) { const lock = activeLocks.find((entry) => this.normalizeDrivePath(entry?.path) === devicePath) || null; if (!lock) { continue; } const owners = Array.isArray(lock?.owners) ? lock.owners : []; const ownerJobIds = owners .map((owner) => Number(owner?.jobId)) .filter((value) => Number.isFinite(value) && value > 0) .map((value) => Math.trunc(value)); const hasForeignOwner = ownerJobIds.some((jobId) => !deletedSet.has(jobId)); if (hasForeignOwner) { logger.warn('drive-lock:force-unlock:skipped-foreign-owner', { devicePath, ownerJobIds }); continue; } const clearedCount = Number(diskDetectionService.forceUnlockDevice(devicePath, { reason: 'job_deleted', deletedJobIds: Array.from(deletedSet) }) || 0); if (clearedCount > 0) { forceUnlockedPaths.push(devicePath); } } if (forceUnlockedPaths.length > 0) { logger.warn('drive-lock:force-unlocked-after-delete', { devicePaths: forceUnlockedPaths }); } return forceUnlockedPaths; } async _rescanDrivesAfterDelete(devicePaths = []) { const normalizedPaths = Array.from(new Set( (Array.isArray(devicePaths) ? devicePaths : []) .map((value) => this.normalizeDrivePath(value)) .filter((value) => Boolean(value) && !value.startsWith('__virtual__')) )); if (normalizedPaths.length === 0) { return; } const retryDelaysMs = [0, 450, 1250]; for (const devicePath of normalizedPaths) { let lastResult = null; for (let attempt = 0; attempt < retryDelaysMs.length; attempt += 1) { const waitMs = retryDelaysMs[attempt]; if (waitMs > 0) { await new Promise((resolve) => setTimeout(resolve, waitMs)); } try { lastResult = await diskDetectionService.rescanDriveAndEmit(devicePath); } catch (error) { logger.warn('job-delete:drive-rescan:failed', { devicePath, attempt: attempt + 1, error: errorToMeta(error) }); continue; } const detectedProfile = String(lastResult?.device?.mediaProfile || '').trim().toLowerCase(); const present = Boolean(lastResult?.present); const locked = Boolean(lastResult?.locked); if (locked) { continue; } if (!present) { continue; } if (detectedProfile && detectedProfile !== 'other' && detectedProfile !== 'unknown') { break; } } logger.info('job-delete:drive-rescan:done', { devicePath, emitted: lastResult?.emitted || 'none', present: Boolean(lastResult?.present), mediaProfile: lastResult?.device?.mediaProfile || null, locked: Boolean(lastResult?.locked) }); } } _forceStopActiveProcessForJob(jobId, options = {}) { const normalizedJobId = Number(jobId); if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { return false; } const targetJobId = Math.trunc(normalizedJobId); const processHandle = this.activeProcesses.get(targetJobId) || null; if (!processHandle) { return false; } const childPid = Number(processHandle?.child?.pid); try { processHandle?.cancel?.(); } catch (_error) { // ignore cancellation race errors } if (Number.isFinite(childPid) && childPid > 0) { try { process.kill(-childPid, 'SIGKILL'); } catch (_error) { /* noop */ } try { process.kill(childPid, 'SIGKILL'); } catch (_error) { /* noop */ } } try { processHandle?.child?.kill?.('SIGKILL'); } catch (_error) { // noop } logger.warn('job-delete:active-process-killed', { jobId: targetJobId, reason: String(options?.reason || '').trim() || null, pid: Number.isFinite(childPid) && childPid > 0 ? childPid : null }); return true; } async onJobsDeleted(jobIds = [], options = {}) { const normalizedIds = Array.isArray(jobIds) ? jobIds .map((value) => Number(value)) .filter((value) => Number.isFinite(value) && value > 0) .map((value) => Math.trunc(value)) : []; if (normalizedIds.length === 0) { return { cleaned: 0, removedQueueEntries: 0 }; } const resetDriveState = Boolean(options?.resetDriveState); const extraDevicePaths = Array.isArray(options?.devicePaths) ? options.devicePaths .map((value) => this.normalizeDrivePath(value)) .filter((value) => Boolean(value)) : []; const affectedDevicePaths = new Set(extraDevicePaths); const queueIds = new Set(normalizedIds.map((value) => String(value))); const previousQueueLength = this.queueEntries.length; this.queueEntries = this.queueEntries.filter((entry) => !queueIds.has(String(Number(entry?.jobId || 0)))); const removedQueueEntries = previousQueueLength - this.queueEntries.length; for (const jobId of normalizedIds) { this.cancelRequestedByJob.add(jobId); this._forceStopActiveProcessForJob(jobId, { reason: 'job_deleted' }); const lockForJob = this._getDriveLockByJobId(jobId); if (lockForJob?.devicePath) { affectedDevicePaths.add(this.normalizeDrivePath(lockForJob.devicePath)); } this._releaseDriveLockForJob(jobId, { reason: 'job_deleted' }); const cdDriveForJob = this._getCdDriveByJobId(jobId); if (cdDriveForJob?.devicePath) { affectedDevicePaths.add(this.normalizeDrivePath(cdDriveForJob.devicePath)); if (String(cdDriveForJob.devicePath).startsWith('__virtual__')) { this._removeCdDrive(cdDriveForJob.devicePath); } else { if (resetDriveState) { this._removeCdDrive(cdDriveForJob.devicePath); } else { this._releaseCdDrive(cdDriveForJob.devicePath, { device: cdDriveForJob.device || null }); } } } else { this.jobProgress.delete(jobId); } this.activeProcesses.delete(jobId); } // Keep the cancellation marker briefly so any late async step in the old // pipeline thread aborts before spawning follow-up processes. for (const jobId of normalizedIds) { setTimeout(() => { this.cancelRequestedByJob.delete(jobId); }, 120000); } const forceUnlockedPaths = this._forceUnlockDriveLocksForDeletedJobs(Array.from(affectedDevicePaths), normalizedIds); let driveResetChanged = false; if (resetDriveState) { for (const devicePath of affectedDevicePaths) { const normalizedPath = this.normalizeDrivePath(devicePath); if (!normalizedPath || normalizedPath.startsWith('__virtual__')) { continue; } const removedDisc = diskDetectionService.detectedDiscs.get(normalizedPath) || null; if (removedDisc) { diskDetectionService.detectedDiscs.delete(normalizedPath); wsService.broadcast('DISC_REMOVED', { device: removedDisc }); driveResetChanged = true; } if (this.detectedDisc && this.normalizeDrivePath(this.detectedDisc.path) === normalizedPath) { this.detectedDisc = null; driveResetChanged = true; } } } this.syncPrimaryActiveProcess(); if (driveResetChanged) { const snapshotPayload = this.getSnapshot(); wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); this.emit('stateChanged', snapshotPayload); } if (!options?.suppressQueueRefresh) { await this.emitQueueChanged(); void this.pumpQueue(); } if (resetDriveState || forceUnlockedPaths.length > 0) { void this._rescanDrivesAfterDelete(Array.from(affectedDevicePaths)); } return { cleaned: normalizedIds.length, removedQueueEntries }; } normalizeParallelJobsLimit(rawValue) { const value = Number(rawValue); if (!Number.isFinite(value) || value < 1) { return 1; } return Math.max(1, Math.min(12, Math.trunc(value))); } normalizeQueueJobId(rawValue) { const value = Number(rawValue); if (!Number.isFinite(value) || value <= 0) { return null; } return Math.trunc(value); } async resolveExistingJobForAction(jobId, action = 'job_action') { const resolved = await historyService.getJobByIdOrReplacement(jobId); if (resolved?.job) { if (resolved.replaced && resolved.requestedJobId !== resolved.resolvedJobId) { logger.warn('job:resolved-replacement', { action, requestedJobId: resolved.requestedJobId, resolvedJobId: resolved.resolvedJobId }); } return resolved; } const missingJobId = Number(resolved?.requestedJobId || jobId); const error = new Error(`Job ${missingJobId} nicht gefunden.`); error.statusCode = 404; throw error; } isJobRunningStatus(status) { return RUNNING_STATES.has(String(status || '').trim().toUpperCase()); } syncPrimaryActiveProcess() { if (this.activeProcesses.size === 0) { this.activeProcess = null; return; } const first = Array.from(this.activeProcesses.values())[0] || null; this.activeProcess = first; } async getMaxParallelJobs() { const settings = await settingsService.getSettingsMap(); return this.normalizeParallelJobsLimit(settings?.pipeline_max_parallel_jobs); } async getMaxParallelCdEncodes() { const settings = await settingsService.getSettingsMap(); return this.normalizeParallelJobsLimit(settings?.pipeline_max_parallel_cd_encodes ?? 2); } async getMaxTotalEncodes() { const settings = await settingsService.getSettingsMap(); const value = Number(settings?.pipeline_max_total_encodes); return Number.isFinite(value) && value >= 1 ? Math.min(24, Math.trunc(value)) : 3; } async getCdBypassesQueue() { const settings = await settingsService.getSettingsMap(); const value = settings?.pipeline_cd_bypasses_queue; return value === 'true' || value === true; } normalizeQueuePoolType(rawValue) { const value = String(rawValue || '').trim().toLowerCase(); if (value === 'audio' || value === 'cd') { return 'audio'; } if (value === 'film' || value === 'video') { return 'film'; } return null; } resolveQueuePoolTypeForJob(job = null) { if (!job || typeof job !== 'object') { return 'film'; } const explicitMediaType = String(job?.mediaType || job?.media_type || '').trim().toLowerCase(); const encodePlan = this.extractQueueJobPlan(job) || this.safeParseJson(job?.encode_plan_json) || {}; const makemkvInfo = job?.makemkvInfo || this.safeParseJson(job?.makemkv_info_json); const converterMediaType = String(encodePlan?.converterMediaType || '').trim().toLowerCase(); if (explicitMediaType === 'cd') { return 'audio'; } if (explicitMediaType === 'audiobook') { return 'audio'; } if (explicitMediaType === 'converter' && converterMediaType === 'audio') { return 'audio'; } const resolvedMediaProfile = this.resolveMediaProfileForJob(job, { encodePlan, makemkvInfo, mediaProfile: explicitMediaType || null }); if (resolvedMediaProfile === 'cd') { return 'audio'; } if (resolvedMediaProfile === 'audiobook') { return 'audio'; } if (resolvedMediaProfile === 'converter' && converterMediaType === 'audio') { return 'audio'; } return 'film'; } resolveQueuePoolTypeForEntry(entry = null, jobsById = null) { const explicitPoolType = this.normalizeQueuePoolType(entry?.poolType); if (explicitPoolType) { return explicitPoolType; } if (String(entry?.action || '').trim().toUpperCase() === QUEUE_ACTIONS.START_CD) { return 'audio'; } const jobId = Number(entry?.jobId); if (Number.isFinite(jobId) && jobId > 0) { const sourceJob = jobsById instanceof Map ? (jobsById.get(Math.trunc(jobId)) || null) : null; if (sourceJob) { return this.resolveQueuePoolTypeForJob(sourceJob); } } return 'film'; } buildRunningPoolUsage(runningJobs = []) { const rows = Array.isArray(runningJobs) ? runningJobs : []; let filmRunning = 0; let audioRunning = 0; for (const job of rows) { const poolType = this.resolveQueuePoolTypeForJob(job); if (poolType === 'audio') { audioRunning += 1; } else { filmRunning += 1; } } return { filmRunning, audioRunning, totalRunning: filmRunning + audioRunning }; } findQueueEntryIndexByJobId(jobId) { return this.queueEntries.findIndex((entry) => Number(entry?.jobId) === Number(jobId)); } normalizeQueueChainIdList(rawList) { const list = Array.isArray(rawList) ? rawList : []; const seen = new Set(); const output = []; for (const item of list) { const value = Number(item); if (!Number.isFinite(value) || value <= 0) { continue; } const normalized = Math.trunc(value); const key = String(normalized); if (seen.has(key)) { continue; } seen.add(key); output.push(normalized); } return output; } extractQueueJobPlan(row) { const source = row && typeof row === 'object' ? row : null; if (!source) { return null; } if (source.encodePlan && typeof source.encodePlan === 'object') { return source.encodePlan; } if (source.encode_plan_json) { try { const parsed = JSON.parse(source.encode_plan_json); if (parsed && typeof parsed === 'object') { return parsed; } } catch (_) { // ignore parse errors for queue decorations } } return null; } async buildQueueJobScriptMeta(rows = []) { const list = Array.isArray(rows) ? rows : []; const byJobId = new Map(); const allScriptIds = new Set(); const allChainIds = new Set(); const scriptNameHints = new Map(); const chainNameHints = new Map(); const addScriptHints = (items) => { for (const item of (Array.isArray(items) ? items : [])) { if (!item || typeof item !== 'object') { continue; } const id = normalizeScriptIdList([item.id ?? item.scriptId])[0] || null; const name = String(item.name || item.scriptName || '').trim(); if (!id) { continue; } allScriptIds.add(id); if (name) { scriptNameHints.set(id, name); } } }; const addChainHints = (items) => { for (const item of (Array.isArray(items) ? items : [])) { if (!item || typeof item !== 'object') { continue; } const id = this.normalizeQueueChainIdList([item.id ?? item.chainId])[0] || null; const name = String(item.name || item.chainName || '').trim(); if (!id) { continue; } allChainIds.add(id); if (name) { chainNameHints.set(id, name); } } }; for (const row of list) { const jobId = this.normalizeQueueJobId(row?.id); if (!jobId) { continue; } const plan = this.extractQueueJobPlan(row); if (!plan) { continue; } const preScriptIds = normalizeScriptIdList([ ...normalizeScriptIdList(plan?.preEncodeScriptIds || []), ...normalizeScriptIdList((Array.isArray(plan?.preEncodeScripts) ? plan.preEncodeScripts : []).map((item) => item?.id ?? item?.scriptId)) ]); const postScriptIds = normalizeScriptIdList([ ...normalizeScriptIdList(plan?.postEncodeScriptIds || []), ...normalizeScriptIdList((Array.isArray(plan?.postEncodeScripts) ? plan.postEncodeScripts : []).map((item) => item?.id ?? item?.scriptId)) ]); const preChainIds = this.normalizeQueueChainIdList([ ...this.normalizeQueueChainIdList(plan?.preEncodeChainIds || []), ...this.normalizeQueueChainIdList((Array.isArray(plan?.preEncodeChains) ? plan.preEncodeChains : []).map((item) => item?.id ?? item?.chainId)) ]); const postChainIds = this.normalizeQueueChainIdList([ ...this.normalizeQueueChainIdList(plan?.postEncodeChainIds || []), ...this.normalizeQueueChainIdList((Array.isArray(plan?.postEncodeChains) ? plan.postEncodeChains : []).map((item) => item?.id ?? item?.chainId)) ]); addScriptHints(plan?.preEncodeScripts); addScriptHints(plan?.postEncodeScripts); addChainHints(plan?.preEncodeChains); addChainHints(plan?.postEncodeChains); for (const id of preScriptIds) allScriptIds.add(id); for (const id of postScriptIds) allScriptIds.add(id); for (const id of preChainIds) allChainIds.add(id); for (const id of postChainIds) allChainIds.add(id); byJobId.set(jobId, { preScriptIds, postScriptIds, preChainIds, postChainIds }); } if (byJobId.size === 0) { return new Map(); } const scriptNameById = new Map(); const chainNameById = new Map(); for (const [id, name] of scriptNameHints.entries()) { scriptNameById.set(id, name); } for (const [id, name] of chainNameHints.entries()) { chainNameById.set(id, name); } if (allScriptIds.size > 0) { const scriptService = require('./scriptService'); try { const scripts = await scriptService.resolveScriptsByIds(Array.from(allScriptIds), { strict: false }); for (const script of scripts) { const id = Number(script?.id); const name = String(script?.name || '').trim(); if (Number.isFinite(id) && id > 0 && name) { scriptNameById.set(id, name); } } } catch (error) { logger.warn('queue:script-summary:resolve-failed', { error: errorToMeta(error) }); } } if (allChainIds.size > 0) { const scriptChainService = require('./scriptChainService'); try { const chains = await scriptChainService.getChainsByIds(Array.from(allChainIds)); for (const chain of chains) { const id = Number(chain?.id); const name = String(chain?.name || '').trim(); if (Number.isFinite(id) && id > 0 && name) { chainNameById.set(id, name); } } } catch (error) { logger.warn('queue:chain-summary:resolve-failed', { error: errorToMeta(error) }); } } const output = new Map(); for (const [jobId, data] of byJobId.entries()) { const preScripts = data.preScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`); const postScripts = data.postScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`); const preChains = data.preChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`); const postChains = data.postChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`); const hasScripts = preScripts.length > 0 || postScripts.length > 0; const hasChains = preChains.length > 0 || postChains.length > 0; output.set(jobId, { hasScripts, hasChains, summary: { preScripts, postScripts, preChains, postChains } }); } return output; } async getQueueSnapshot() { const [maxParallelJobs, maxParallelCdEncodes, maxTotalEncodes, cdBypassesQueue] = await Promise.all([ this.getMaxParallelJobs(), this.getMaxParallelCdEncodes(), this.getMaxTotalEncodes(), this.getCdBypassesQueue() ]); const [runningJobs, idleJobsRaw] = await Promise.all([ historyService.getRunningJobs(), historyService.getQueueIdleJobs() ]); const runningPoolUsage = this.buildRunningPoolUsage(runningJobs); const runningEncodeCount = runningPoolUsage.filmRunning; const runningCdCount = runningPoolUsage.audioRunning; const queuedJobIds = this.queueEntries .filter((entry) => !entry.type || entry.type === 'job') .map((entry) => Number(entry.jobId)) .filter((id) => Number.isFinite(id) && id > 0); const queuedJobIdSet = new Set(queuedJobIds); const runningJobIdSet = new Set( runningJobs .map((job) => Number(job?.id)) .filter((id) => Number.isFinite(id) && id > 0) ); const idleJobs = (Array.isArray(idleJobsRaw) ? idleJobsRaw : []).filter((job) => { const jobId = Number(job?.id); if (!Number.isFinite(jobId) || jobId <= 0) { return false; } return !queuedJobIdSet.has(jobId) && !runningJobIdSet.has(jobId); }); const queuedRows = queuedJobIds.length > 0 ? await historyService.getJobsByIds(queuedJobIds) : []; const queuedById = new Map(queuedRows.map((row) => [Number(row.id), row])); const scriptMetaByJobId = await this.buildQueueJobScriptMeta( Array.from( new Map( [...runningJobs, ...queuedRows, ...idleJobs].map((row) => [Number(row?.id), row]) ).values() ) ); const queue = { maxParallelJobs, maxParallelCdEncodes, maxTotalEncodes, cdBypassesQueue, runningCount: runningEncodeCount, runningCdCount, idleCount: idleJobs.length, runningJobs: runningJobs.map((job) => ({ jobId: Number(job.id), title: job.title || job.detected_title || `Job #${job.id}`, status: job.status, lastState: job.last_state || null, poolType: this.resolveQueuePoolTypeForJob(job), hasScripts: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasScripts), hasChains: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasChains), scriptSummary: scriptMetaByJobId.get(Number(job.id))?.summary || null })), idleJobs: idleJobs.map((job) => ({ jobId: Number(job.id), title: job.title || job.detected_title || `Job #${job.id}`, status: job.status, lastState: job.last_state || null, poolType: this.resolveQueuePoolTypeForJob(job), hasScripts: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasScripts), hasChains: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasChains), scriptSummary: scriptMetaByJobId.get(Number(job.id))?.summary || null })), queuedJobs: this.queueEntries.map((entry, index) => { const entryType = entry.type || 'job'; const base = { entryId: entry.id, position: index + 1, type: entryType, enqueuedAt: entry.enqueuedAt }; if (entryType === 'script') { return { ...base, scriptId: entry.scriptId, title: entry.scriptName || `Skript #${entry.scriptId}`, status: 'QUEUED' }; } if (entryType === 'chain') { return { ...base, chainId: entry.chainId, title: entry.chainName || `Kette #${entry.chainId}`, status: 'QUEUED' }; } if (entryType === 'wait') { return { ...base, waitSeconds: entry.waitSeconds, title: `Warten ${entry.waitSeconds}s`, status: 'QUEUED' }; } // type === 'job' const row = queuedById.get(Number(entry.jobId)); const scriptMeta = scriptMetaByJobId.get(Number(entry.jobId)) || null; return { ...base, jobId: Number(entry.jobId), action: entry.action, actionLabel: QUEUE_ACTION_LABELS[entry.action] || entry.action, poolType: this.resolveQueuePoolTypeForEntry(entry, queuedById), title: row?.title || row?.detected_title || `Job #${entry.jobId}`, status: row?.status || null, lastState: row?.last_state || null, hasScripts: Boolean(scriptMeta?.hasScripts), hasChains: Boolean(scriptMeta?.hasChains), scriptSummary: scriptMeta?.summary || null }; }), queuedCount: this.queueEntries.length, updatedAt: nowIso() }; return queue; } async emitQueueChanged() { try { this.lastQueueSnapshot = await this.getQueueSnapshot(); wsService.broadcast('PIPELINE_QUEUE_CHANGED', this.lastQueueSnapshot); } catch (error) { logger.warn('queue:emit:failed', { error: errorToMeta(error) }); } } async reorderQueue(orderedEntryIds = []) { const incoming = Array.isArray(orderedEntryIds) ? orderedEntryIds.map((value) => Number(value)).filter((v) => Number.isFinite(v) && v > 0) : []; if (incoming.length !== this.queueEntries.length) { const error = new Error('Queue-Reihenfolge ungültig: Anzahl passt nicht.'); error.statusCode = 400; throw error; } const currentIdSet = new Set(this.queueEntries.map((entry) => entry.id)); const incomingSet = new Set(incoming); if (incomingSet.size !== incoming.length || incoming.some((id) => !currentIdSet.has(id))) { const error = new Error('Queue-Reihenfolge ungültig: IDs passen nicht zur aktuellen Queue.'); error.statusCode = 400; throw error; } const byEntryId = new Map(this.queueEntries.map((entry) => [entry.id, entry])); this.queueEntries = incoming.map((id) => byEntryId.get(id)).filter(Boolean); await this.emitQueueChanged(); return this.lastQueueSnapshot; } async enqueueNonJobEntry(type, params = {}, insertAfterEntryId = null) { const validTypes = new Set(['script', 'chain', 'wait']); if (!validTypes.has(type)) { const error = new Error(`Unbekannter Queue-Eintragstyp: ${type}`); error.statusCode = 400; throw error; } let entry; if (type === 'script') { const scriptId = Number(params.scriptId); if (!Number.isFinite(scriptId) || scriptId <= 0) { const error = new Error('scriptId fehlt oder ist ungültig.'); error.statusCode = 400; throw error; } const scriptService = require('./scriptService'); let script; try { script = await scriptService.getScriptById(scriptId); } catch (_) { /* ignore */ } entry = { id: this.queueEntrySeq++, type: 'script', scriptId, scriptName: script?.name || null, enqueuedAt: nowIso() }; } else if (type === 'chain') { const chainId = Number(params.chainId); if (!Number.isFinite(chainId) || chainId <= 0) { const error = new Error('chainId fehlt oder ist ungültig.'); error.statusCode = 400; throw error; } const scriptChainService = require('./scriptChainService'); let chain; try { chain = await scriptChainService.getChainById(chainId); } catch (_) { /* ignore */ } entry = { id: this.queueEntrySeq++, type: 'chain', chainId, chainName: chain?.name || null, enqueuedAt: nowIso() }; } else { const waitSeconds = Math.round(Number(params.waitSeconds)); if (!Number.isFinite(waitSeconds) || waitSeconds < 1 || waitSeconds > 3600) { const error = new Error('waitSeconds muss zwischen 1 und 3600 liegen.'); error.statusCode = 400; throw error; } entry = { id: this.queueEntrySeq++, type: 'wait', waitSeconds, enqueuedAt: nowIso() }; } if (insertAfterEntryId != null) { const idx = this.queueEntries.findIndex((e) => e.id === Number(insertAfterEntryId)); if (idx >= 0) { this.queueEntries.splice(idx + 1, 0, entry); } else { this.queueEntries.push(entry); } } else { this.queueEntries.push(entry); } await this.emitQueueChanged(); void this.pumpQueue(); return { entryId: entry.id, type, position: this.queueEntries.indexOf(entry) + 1 }; } async removeQueueEntry(entryId) { const normalizedId = Number(entryId); if (!Number.isFinite(normalizedId) || normalizedId <= 0) { const error = new Error('Ungültige entryId.'); error.statusCode = 400; throw error; } const idx = this.queueEntries.findIndex((e) => e.id === normalizedId); if (idx < 0) { const error = new Error(`Queue-Eintrag #${normalizedId} nicht gefunden.`); error.statusCode = 404; throw error; } this.queueEntries.splice(idx, 1); await this.emitQueueChanged(); return this.lastQueueSnapshot; } async enqueueOrStartAction(action, jobId, startNow, options = {}) { const normalizedJobId = this.normalizeQueueJobId(jobId); if (!normalizedJobId) { const error = new Error('Ungültige Job-ID für Queue-Aktion.'); error.statusCode = 400; throw error; } if (!Object.values(QUEUE_ACTIONS).includes(action)) { const error = new Error(`Unbekannte Queue-Aktion '${action}'.`); error.statusCode = 400; throw error; } if (typeof startNow !== 'function') { const error = new Error('Queue-Aktion kann nicht gestartet werden (startNow fehlt).'); error.statusCode = 500; throw error; } let poolType = this.normalizeQueuePoolType(options?.poolType); if (!poolType) { const poolJob = await historyService.getJobById(normalizedJobId).catch(() => null); poolType = this.resolveQueuePoolTypeForJob(poolJob); } const existingQueueIndex = this.findQueueEntryIndexByJobId(normalizedJobId); if (existingQueueIndex >= 0) { return { queued: true, started: false, queuePosition: existingQueueIndex + 1, action, poolType }; } const [maxFilm, maxCd, maxTotal, cdBypass, runningJobs] = await Promise.all([ this.getMaxParallelJobs(), this.getMaxParallelCdEncodes(), this.getMaxTotalEncodes(), this.getCdBypassesQueue(), historyService.getRunningJobs() ]); const { filmRunning, audioRunning, totalRunning } = this.buildRunningPoolUsage(runningJobs); const queueJobEntries = this.queueEntries.filter((entry) => !entry.type || entry.type === 'job'); const queuedPoolTypeCount = queueJobEntries.filter( (entry) => this.resolveQueuePoolTypeForEntry(entry) === poolType ).length; const hasBlockingAudioQueueEntry = this.queueEntries.some((entry) => { if (entry?.type && entry.type !== 'job') { return true; } return this.resolveQueuePoolTypeForEntry(entry) === 'audio'; }); const laneRunning = poolType === 'audio' ? audioRunning : filmRunning; const laneCap = poolType === 'audio' ? maxCd : maxFilm; const shouldQueue = poolType === 'audio' && cdBypass ? (hasBlockingAudioQueueEntry || laneRunning >= laneCap || totalRunning >= maxTotal) : (this.queueEntries.length > 0 || laneRunning >= laneCap || totalRunning >= maxTotal); if (!shouldQueue) { const result = await startNow(); await this.emitQueueChanged(); return { queued: false, started: true, action, poolType, ...(result && typeof result === 'object' ? result : {}) }; } this.queueEntries.push({ id: this.queueEntrySeq++, jobId: normalizedJobId, action, poolType, ...(options?.entryData && typeof options.entryData === 'object' ? options.entryData : {}), enqueuedAt: nowIso() }); await historyService.appendLog( normalizedJobId, 'USER_ACTION', `In Queue aufgenommen: ${QUEUE_ACTION_LABELS[action] || action}` ); await this.emitQueueChanged(); void this.pumpQueue(); return { queued: true, started: false, queuePosition: this.queueEntries.length, action, poolType, queuedPoolTypeCount }; } async enqueueOrStartCdAction(jobId, ripConfig, startNow) { return this.enqueueOrStartAction( QUEUE_ACTIONS.START_CD, jobId, startNow, { poolType: 'audio', entryData: { ripConfig: ripConfig || {} } } ); } async dispatchNonJobEntry(entry) { const type = entry?.type; logger.info('queue:non-job:dispatch', { type, entryId: entry?.id }); if (type === 'wait') { const seconds = Math.max(1, Number(entry.waitSeconds || 1)); logger.info('queue:wait:start', { seconds }); await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); logger.info('queue:wait:done', { seconds }); return; } if (type === 'script') { const scriptService = require('./scriptService'); let script; try { script = await scriptService.getScriptById(entry.scriptId); } catch (_) { /* ignore */ } if (!script) { logger.warn('queue:script:not-found', { scriptId: entry.scriptId }); return; } const activityId = runtimeActivityService.startActivity('script', { name: script.name, source: 'queue', scriptId: script.id, currentStep: 'Queue-Ausfuehrung' }); let prepared = null; try { prepared = await scriptService.createExecutableScriptFile(script, { source: 'queue', scriptId: script.id, scriptName: script.name }); let stdout = ''; let stderr = ''; let stdoutTruncated = false; let stderrTruncated = false; const processHandle = spawnTrackedProcess({ cmd: prepared.cmd, args: prepared.args, context: { source: 'queue', scriptId: script.id }, onStdoutLine: (line) => { const next = appendTailText(stdout, line); stdout = next.value; stdoutTruncated = stdoutTruncated || next.truncated; runtimeActivityService.appendActivityOutput(activityId, { stdout: line }); }, onStderrLine: (line) => { const next = appendTailText(stderr, line); stderr = next.value; stderrTruncated = stderrTruncated || next.truncated; runtimeActivityService.appendActivityOutput(activityId, { stderr: line }); } }); let exitCode = 0; let runError = null; try { const result = await processHandle.promise; exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0; } catch (error) { runError = error; exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null; if (exitCode === null) { throw error; } } logger.info('queue:script:done', { scriptId: script.id, exitCode }); const output = [stdout, stderr].filter(Boolean).join('\n').trim(); const success = Number(exitCode) === 0; runtimeActivityService.completeActivity(activityId, { status: success ? 'success' : 'error', success, outcome: success ? 'success' : 'error', exitCode: Number.isFinite(Number(exitCode)) ? Number(exitCode) : null, message: success ? 'Queue-Skript abgeschlossen' : `Queue-Skript fehlgeschlagen (Exit ${exitCode ?? 'n/a'})`, output: output || null, stdout: stdout || null, stderr: stderr || null, stdoutTruncated, stderrTruncated, errorMessage: success ? null : `Queue-Skript fehlgeschlagen (Exit ${exitCode ?? 'n/a'})` }); if (runError && !success) { logger.warn('queue:script:exit-nonzero', { scriptId: script.id, exitCode }); } } catch (err) { runtimeActivityService.completeActivity(activityId, { status: 'error', success: false, outcome: 'error', message: err?.message || 'Queue-Skript Fehler', errorMessage: err?.message || 'Queue-Skript Fehler' }); logger.error('queue:script:error', { scriptId: entry.scriptId, error: errorToMeta(err) }); } finally { if (prepared?.cleanup) await prepared.cleanup(); } return; } if (type === 'chain') { const scriptChainService = require('./scriptChainService'); try { await scriptChainService.executeChain(entry.chainId, { source: 'queue' }); } catch (err) { logger.error('queue:chain:error', { chainId: entry.chainId, error: errorToMeta(err) }); } } } async dispatchQueuedEntry(entry) { const action = entry?.action; const jobId = Number(entry?.jobId); if (!Number.isFinite(jobId) || jobId <= 0) { return; } switch (action) { case QUEUE_ACTIONS.START_PREPARED: await this.startPreparedJob(jobId, { immediate: true }); break; case QUEUE_ACTIONS.RETRY: await this.retry(jobId, { immediate: true }); break; case QUEUE_ACTIONS.REENCODE: await this.reencodeFromRaw(jobId, { immediate: true }); break; case QUEUE_ACTIONS.RESTART_ENCODE: await this.restartEncodeWithLastSettings(jobId, { immediate: true }); break; case QUEUE_ACTIONS.RESTART_REVIEW: await this.restartReviewFromRaw(jobId, { immediate: true }); break; case QUEUE_ACTIONS.START_CD: await this.startCdRip(jobId, entry.ripConfig || {}); break; default: { const error = new Error(`Unbekannte Queue-Aktion: ${String(action || '-')}`); error.statusCode = 400; throw error; } } } async pumpQueue() { if (this.queuePumpRunning) { return; } this.queuePumpRunning = true; try { while (this.queueEntries.length > 0) { // Get current running counts and limits const [allRunningJobs, maxFilm, maxCd, maxTotal, cdBypass] = await Promise.all([ historyService.getRunningJobs(), this.getMaxParallelJobs(), this.getMaxParallelCdEncodes(), this.getMaxTotalEncodes(), this.getCdBypassesQueue() ]); const runningUsage = this.buildRunningPoolUsage(allRunningJobs); const filmRunning = runningUsage.filmRunning; const cdRunning = runningUsage.audioRunning; const totalRunning = runningUsage.totalRunning; // Counts all actively processing jobs (excludes waiting states like READY_TO_ENCODE). const anyActiveJobs = totalRunning; // Find next startable entry let entryIndex = -1; for (let i = 0; i < this.queueEntries.length; i++) { const candidate = this.queueEntries[i]; const isNonJob = candidate.type && candidate.type !== 'job'; if (isNonJob) { // Non-job entries (script, chain, wait) only start when no jobs are actively running. // anyActiveJobs covers all active states (ANALYZING, RIPPING, ENCODING, MEDIAINFO_CHECK etc.) // activeProcesses provides a second safety net for race conditions during state transitions. if (anyActiveJobs === 0 && this.activeProcesses.size === 0) { entryIndex = i; } break; // FIFO: stop scanning regardless (non-job blocks everything behind it) } // Job entry: check hierarchical limits if (totalRunning >= maxTotal) { // Total limit reached – nothing can start break; } const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate); if (candidatePoolType === 'audio') { if (cdRunning < maxCd) { entryIndex = i; break; } // CD limit reached if (!cdBypass) break; // Strict FIFO: stop scanning continue; // Bypass mode: skip this blocked CD entry } else { // Film/video job entry if (filmRunning < maxFilm) { entryIndex = i; break; } // Film limit reached if (!cdBypass) break; // Strict FIFO: stop scanning continue; // Bypass mode: skip this blocked film entry } } if (entryIndex < 0) { break; // Nothing can start right now } const entry = this.queueEntries.splice(entryIndex, 1)[0]; if (!entry) { break; } const isNonJob = entry.type && entry.type !== 'job'; await this.emitQueueChanged(); try { if (isNonJob) { await this.dispatchNonJobEntry(entry); continue; } await historyService.appendLog( entry.jobId, 'SYSTEM', `Queue-Start: ${QUEUE_ACTION_LABELS[entry.action] || entry.action}` ); await this.dispatchQueuedEntry(entry); } catch (error) { if (Number(error?.statusCode || 0) === 409) { this.queueEntries.splice(entryIndex, 0, entry); await this.emitQueueChanged(); break; } logger.error('queue:entry:failed', { type: entry.type || 'job', action: entry.action, jobId: entry.jobId, error: errorToMeta(error) }); if (entry.jobId) { await historyService.appendLog( entry.jobId, 'SYSTEM', `Queue-Start fehlgeschlagen (${QUEUE_ACTION_LABELS[entry.action] || entry.action}): ${error.message}` ); } } } } finally { this.queuePumpRunning = false; await this.emitQueueChanged(); } } async resetFrontendState(reason = 'manual', options = {}) { const force = Boolean(options?.force); const keepDetectedDevice = options?.keepDetectedDevice !== false; if (!force && (this.activeProcesses.size > 0 || RUNNING_STATES.has(this.snapshot.state))) { logger.warn('ui:reset:skipped-busy', { reason, state: this.snapshot.state, activeJobId: this.snapshot.activeJobId }); return { reset: false, skipped: 'busy' }; } const device = keepDetectedDevice ? (this.detectedDisc || null) : null; const nextState = device ? 'DISC_DETECTED' : 'IDLE'; const statusText = device ? 'Neue Disk erkannt' : 'Bereit'; logger.warn('ui:reset', { reason, previousState: this.snapshot.state, previousActiveJobId: this.snapshot.activeJobId, nextState, keepDetectedDevice }); await this.setState(nextState, { activeJobId: null, progress: 0, eta: null, statusText, context: device ? { device } : {} }); return { reset: true, state: nextState }; } async notifyPushover(eventKey, payload = {}) { try { const result = await notificationService.notify(eventKey, payload); logger.debug('notify:event', { eventKey, sent: Boolean(result?.sent), reason: result?.reason || null }); } catch (error) { logger.warn('notify:event:failed', { eventKey, error: errorToMeta(error) }); } } async ejectDriveIfEnabled(settingsMap, devicePath = null) { try { const enabled = String(settingsMap?.auto_eject_after_rip || '').trim().toLowerCase(); if (enabled !== 'true' && enabled !== '1') { return; } // Collect the list of drives to eject const devicesToEject = []; if (devicePath) { devicesToEject.push(devicePath); } else if (settingsMap?.drive_mode === 'explicit') { try { const parsed = JSON.parse(settingsMap?.drive_devices || '[]'); if (Array.isArray(parsed)) { parsed .map((e) => (typeof e === 'string' ? e.trim() : String(e?.path || '').trim())) .filter(Boolean) .forEach((p) => devicesToEject.push(p)); } } catch (_error) { // ignore } if (devicesToEject.length === 0) { const legacy = String(settingsMap?.drive_device || '').trim(); if (legacy) devicesToEject.push(legacy); } } else { devicesToEject.push(String(settingsMap?.drive_device || '').trim() || '/dev/sr0'); } for (const device of devicesToEject) { logger.info('eject:drive', { device }); await new Promise((resolve) => { execFile('eject', [device], { timeout: 10000 }, (error) => { if (error) { logger.warn('eject:drive:failed', { device, error: errorToMeta(error) }); } else { logger.info('eject:drive:ok', { device }); } resolve(); }); }); } } catch (error) { logger.warn('eject:drive:error', { error: errorToMeta(error) }); } } normalizeDiscValue(value) { return String(value || '').trim().toLowerCase(); } isSameDisc(a, b) { const aDiscLabel = this.normalizeDiscValue(a?.discLabel); const bDiscLabel = this.normalizeDiscValue(b?.discLabel); if (aDiscLabel && bDiscLabel) { return aDiscLabel === bDiscLabel; } const aPath = this.normalizeDiscValue(a?.path); const bPath = this.normalizeDiscValue(b?.path); if (aPath && bPath) { return aPath === bPath; } const aLabel = this.normalizeDiscValue(a?.label); const bLabel = this.normalizeDiscValue(b?.label); if (aLabel && bLabel) { return aLabel === bLabel; } return false; } shouldSuspendDrivePollingForState(state, context = null) { const normalizedState = String(state || '').trim().toUpperCase(); const DRIVE_ACTIVE_STATES = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING']); if (DRIVE_ACTIVE_STATES.has(normalizedState)) { return true; } // RAW-based review/restart states should not touch optical drives. if (!['READY_TO_ENCODE', 'WAITING_FOR_USER_DECISION'].includes(normalizedState)) { return false; } const resolvedContext = context && typeof context === 'object' ? context : {}; const review = resolvedContext.mediaInfoReview && typeof resolvedContext.mediaInfoReview === 'object' ? resolvedContext.mediaInfoReview : null; const mode = String(resolvedContext.mode || review?.mode || '').trim().toLowerCase(); const isPreRip = mode === 'pre_rip' || Boolean(review?.preRip); if (isPreRip) { return false; } const rawPath = String(resolvedContext.rawPath || '').trim(); const inputPath = String(resolvedContext.inputPath || review?.encodeInputPath || '').trim(); const hasFilesystemInput = [rawPath, inputPath].some((candidate) => candidate && !candidate.startsWith('disc-track-scan://') ); return hasFilesystemInput; } async setState(state, patch = {}) { const previous = this.snapshot.state; const previousContext = this.snapshot.context; const previousActiveJobId = this.snapshot.activeJobId; const contextPatch = patch.context && typeof patch.context === 'object' && !Array.isArray(patch.context) ? patch.context : null; this.snapshot = { ...this.snapshot, state, activeJobId: patch.activeJobId !== undefined ? patch.activeJobId : this.snapshot.activeJobId, progress: patch.progress !== undefined ? patch.progress : this.snapshot.progress, eta: patch.eta !== undefined ? patch.eta : this.snapshot.eta, statusText: patch.statusText !== undefined ? patch.statusText : this.snapshot.statusText, context: patch.context !== undefined ? patch.context : this.snapshot.context }; // Keep per-job progress map in sync when a job starts or finishes. if (patch.activeJobId != null) { const activeJobId = Number(patch.activeJobId); const previousJobProgress = this.jobProgress.get(activeJobId) || {}; const mergedContext = contextPatch ? { ...(previousJobProgress.context && typeof previousJobProgress.context === 'object' ? previousJobProgress.context : {}), ...contextPatch } : (previousJobProgress.context && typeof previousJobProgress.context === 'object' ? previousJobProgress.context : null); const nextProgress = { ...previousJobProgress, state, progress: patch.progress ?? 0, eta: patch.eta ?? null, statusText: patch.statusText ?? null }; if (mergedContext && Object.keys(mergedContext).length > 0) { nextProgress.context = mergedContext; } this.jobProgress.set(activeJobId, nextProgress); } else if (patch.activeJobId === null) { // Job slot cleared – remove the finished job's live entry so it falls // back to DB data in the frontend. // Use patch.finishingJobId when provided (parallel-safe); fall back to // previousActiveJobId only when no parallel job has overwritten the slot. const finishingJobId = patch.finishingJobId != null ? Number(patch.finishingJobId) : (previousActiveJobId != null ? Number(previousActiveJobId) : null); if (finishingJobId != null) { this.jobProgress.delete(finishingJobId); } } logger.info('state:changed', { from: previous, to: state, activeJobId: this.snapshot.activeJobId, statusText: this.snapshot.statusText }); const shouldSuspendCurrentPolling = this.shouldSuspendDrivePollingForState(state, this.snapshot.context); const shouldSuspendPreviousPolling = this.shouldSuspendDrivePollingForState(previous, previousContext); if (shouldSuspendCurrentPolling) { diskDetectionService.suspendPolling(); } else if (shouldSuspendPreviousPolling) { diskDetectionService.resumePolling(); } await this.persistSnapshot(); const snapshotPayload = this.getSnapshot(); wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); this.emit('stateChanged', snapshotPayload); void this.emitQueueChanged(); void this.pumpQueue(); } async persistSnapshot(force = true) { if (!force) { const now = Date.now(); if (now - this.lastPersistAt < 300) { return; } this.lastPersistAt = now; } const db = await getDb(); await db.run( ` UPDATE pipeline_state SET state = ?, active_job_id = ?, progress = ?, eta = ?, status_text = ?, context_json = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1 `, [ this.snapshot.state, this.snapshot.activeJobId, this.snapshot.progress, this.snapshot.eta, this.snapshot.statusText, JSON.stringify(this.snapshot.context || {}) ] ); } async updateProgress(stage, percent, eta, statusText, jobIdOverride = null, options = {}) { const effectiveJobId = jobIdOverride != null ? Number(jobIdOverride) : this.snapshot.activeJobId; const previousJobProgress = effectiveJobId != null ? (this.jobProgress.get(effectiveJobId) || {}) : null; const effectiveProgress = percent ?? this.snapshot.progress; let effectiveEta; if (eta !== undefined) { effectiveEta = eta; } else if (previousJobProgress && previousJobProgress.eta !== undefined) { effectiveEta = previousJobProgress.eta ?? null; } else { effectiveEta = this.snapshot.eta ?? null; } const effectiveStatusText = statusText ?? this.snapshot.statusText; const progressOptions = options && typeof options === 'object' ? options : {}; const contextPatch = progressOptions.contextPatch && typeof progressOptions.contextPatch === 'object' && !Array.isArray(progressOptions.contextPatch) ? progressOptions.contextPatch : null; // Update per-job progress so concurrent jobs don't overwrite each other. if (effectiveJobId != null) { const mergedContext = contextPatch ? { ...(previousJobProgress.context && typeof previousJobProgress.context === 'object' ? previousJobProgress.context : {}), ...contextPatch } : (previousJobProgress.context && typeof previousJobProgress.context === 'object' ? previousJobProgress.context : null); const nextProgress = { ...previousJobProgress, state: stage, progress: effectiveProgress, eta: effectiveEta, statusText: effectiveStatusText }; if (mergedContext && Object.keys(mergedContext).length > 0) { nextProgress.context = mergedContext; } this.jobProgress.set(effectiveJobId, nextProgress); } // Only update the global snapshot fields when this update belongs to the // currently active job (avoids the snapshot jumping between parallel jobs). if (effectiveJobId === this.snapshot.activeJobId || effectiveJobId == null) { const nextContext = contextPatch ? { ...(this.snapshot.context && typeof this.snapshot.context === 'object' ? this.snapshot.context : {}), ...contextPatch } : this.snapshot.context; this.snapshot = { ...this.snapshot, state: stage, progress: effectiveProgress, eta: effectiveEta, statusText: effectiveStatusText, context: nextContext }; await this.persistSnapshot(false); } const rounded = Number((effectiveProgress || 0).toFixed(2)); const key = `${effectiveJobId}:${stage}:${rounded}`; if (key !== this.lastProgressKey) { this.lastProgressKey = key; logger.debug('progress:update', { stage, activeJobId: effectiveJobId, progress: rounded, eta: effectiveEta, statusText: effectiveStatusText }); } wsService.broadcast('PIPELINE_PROGRESS', { state: stage, activeJobId: effectiveJobId, progress: effectiveProgress, eta: effectiveEta, statusText: effectiveStatusText, contextPatch }); } async onDiscInserted(deviceInfo) { const rawDevice = deviceInfo && typeof deviceInfo === 'object' ? deviceInfo : {}; const explicitProfile = normalizeMediaProfile(rawDevice.mediaProfile); const inferredProfile = inferMediaProfileFromDeviceInfo(rawDevice); const resolvedMediaProfile = isSpecificMediaProfile(explicitProfile) ? explicitProfile : (isSpecificMediaProfile(inferredProfile) ? inferredProfile : (explicitProfile || inferredProfile || 'other')); const resolvedDevice = { ...rawDevice, mediaProfile: resolvedMediaProfile }; logger.info('disc:inserted', { deviceInfo: resolvedDevice, mediaProfile: resolvedMediaProfile }); wsService.broadcast('DISC_DETECTED', { device: resolvedDevice }); // CD discs are tracked per-drive in cdDrives, not in the global state machine if (resolvedMediaProfile === 'cd') { const cdDevicePath = String(resolvedDevice.path || '').trim(); if (cdDevicePath) { const existingDrive = this.cdDrives.get(cdDevicePath); const ACTIVE_CD_STATES = new Set(['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING']); if (!ACTIVE_CD_STATES.has(existingDrive?.state)) { this._setCdDriveState(cdDevicePath, { state: 'DISC_DETECTED', device: resolvedDevice }); } } return; } const previousDevice = this.snapshot.context?.device || this.detectedDisc; const previousState = this.snapshot.state; const previousJobId = this.snapshot.context?.jobId || this.snapshot.activeJobId || null; const discChanged = previousDevice ? !this.isSameDisc(previousDevice, resolvedDevice) : false; this.detectedDisc = resolvedDevice; if (discChanged && !RUNNING_STATES.has(previousState) && previousState !== 'DISC_DETECTED' && previousState !== 'READY_TO_ENCODE') { const message = `Disk gewechselt (${resolvedDevice.discLabel || resolvedDevice.path || 'unbekannt'}). Bitte neu analysieren.`; logger.info('disc:changed:reset', { fromState: previousState, previousDevice, newDevice: resolvedDevice, previousJobId }); if (previousJobId && (previousState === 'METADATA_SELECTION' || previousState === 'READY_TO_START' || previousState === 'WAITING_FOR_USER_DECISION')) { await historyService.updateJob(previousJobId, { status: 'ERROR', last_state: 'ERROR', end_time: nowIso(), error_message: message }); await historyService.appendLog(previousJobId, 'SYSTEM', message); } await this.setState('DISC_DETECTED', { activeJobId: null, progress: 0, eta: null, statusText: 'Neue Disk erkannt', context: { device: resolvedDevice } }); return; } if (this.snapshot.state === 'IDLE' || this.snapshot.state === 'FINISHED' || this.snapshot.state === 'ERROR' || this.snapshot.state === 'DISC_DETECTED') { await this.setState('DISC_DETECTED', { activeJobId: null, progress: 0, eta: null, statusText: 'Neue Disk erkannt', context: { device: resolvedDevice } }); } } async onDiscRemoved(deviceInfo) { logger.info('disc:removed', { deviceInfo }); wsService.broadcast('DISC_REMOVED', { device: deviceInfo }); const removedPath = String(deviceInfo?.path || '').trim(); // If it's a tracked CD drive, remove or leave it depending on active state if (removedPath && this.cdDrives.has(removedPath)) { const driveState = this.cdDrives.get(removedPath); const ACTIVE_CD_STATES = new Set(['CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING']); if (!ACTIVE_CD_STATES.has(driveState?.state)) { this._removeCdDrive(removedPath); } // If actively ripping, leave the entry – rip completion will clean it up return; } this.detectedDisc = null; if (this.snapshot.state === 'DISC_DETECTED') { await this.setState('IDLE', { activeJobId: null, progress: 0, eta: null, statusText: 'Keine Disk erkannt', context: {} }); } } ensureNotBusy(action, jobId = null) { const normalizedJobId = this.normalizeQueueJobId(jobId); if (!normalizedJobId) { return; } if (this.activeProcesses.has(normalizedJobId)) { const error = new Error(`Job #${normalizedJobId} ist bereits aktiv. Aktion '${action}' aktuell nicht möglich.`); error.statusCode = 409; logger.warn('busy:blocked-action', { action, jobId: normalizedJobId, activeState: this.snapshot.state, activeJobId: this.snapshot.activeJobId }); throw error; } } isPrimaryJob(jobId) { const activeState = String(this.snapshot.state || '').toUpperCase(); if (!['ENCODING', 'RIPPING'].includes(activeState)) { return true; } return Number(this.snapshot.activeJobId) === Number(jobId); } withAnalyzeContextMediaProfile(makemkvInfo, mediaProfile) { const normalizedProfile = normalizeMediaProfile(mediaProfile); const base = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}; return { ...base, analyzeContext: { ...(base.analyzeContext || {}), mediaProfile: normalizedProfile || null } }; } resolveMediaProfileForJob(job, options = {}) { const pickSpecificProfile = (value) => { const normalized = normalizeMediaProfile(value); if (!normalized) { return null; } if (isSpecificMediaProfile(normalized)) { return normalized; } return null; }; const explicitProfile = pickSpecificProfile(options?.mediaProfile); if (explicitProfile) { return explicitProfile; } const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object' ? options.encodePlan : null; const profileFromJobKind = inferMediaProfileFromJobKind( options?.jobKind || job?.job_kind || encodePlan?.jobKind ); if (profileFromJobKind) { return profileFromJobKind; } const profileFromPlan = pickSpecificProfile(encodePlan?.mediaProfile); if (profileFromPlan) { return profileFromPlan; } const converterMediaTypeHint = String(encodePlan?.converterMediaType || '').trim().toLowerCase(); if (converterMediaTypeHint === 'video' || converterMediaTypeHint === 'audio' || converterMediaTypeHint === 'iso') { return 'converter'; } if (String(encodePlan?.mode || '').trim().toLowerCase() === 'converter') { return 'converter'; } if ( hasConverterPathSegment(encodePlan?.inputPath) || hasConverterPathSegment(encodePlan?.encodeInputPath) || hasConverterPathSegment(job?.raw_path) || hasConverterPathSegment(options?.rawPath) || hasConverterPathSegment(job?.encode_input_path) ) { return 'converter'; } const mkInfo = options?.makemkvInfo && typeof options.makemkvInfo === 'object' ? options.makemkvInfo : this.safeParseJson(job?.makemkv_info_json); const analyzeContext = mkInfo?.analyzeContext || {}; const profileFromAnalyze = pickSpecificProfile( analyzeContext.mediaProfile || mkInfo?.mediaProfile ); if (profileFromAnalyze) { return profileFromAnalyze; } const currentContextProfile = ( Number(this.snapshot.context?.jobId) === Number(job?.id) ? pickSpecificProfile(this.snapshot.context?.mediaProfile) : null ); if (currentContextProfile) { return currentContextProfile; } const deviceProfile = inferMediaProfileFromDeviceInfo( options?.deviceInfo || this.detectedDisc || this.snapshot.context?.device || null ); if (isSpecificMediaProfile(deviceProfile)) { return deviceProfile; } const rawPathProfile = inferMediaProfileFromRawPath(options?.rawPath || job?.raw_path || null); if (rawPathProfile) { return rawPathProfile; } return 'other'; } resolveJobKindForJob(job, options = {}) { const explicitKind = normalizeJobKind(options?.jobKind); if (explicitKind) { return explicitKind; } const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object' ? options.encodePlan : this.safeParseJson(job?.encode_plan_json); const kindFromPlan = normalizeJobKind(encodePlan?.jobKind); if (kindFromPlan) { return kindFromPlan; } const kindFromJob = normalizeJobKind(job?.job_kind); if (kindFromJob) { return kindFromJob; } const mediaProfile = this.resolveMediaProfileForJob(job, { ...options, encodePlan }); return resolveJobKindForMediaProfile(mediaProfile, { converterMediaType: options?.converterMediaType || encodePlan?.converterMediaType || null }); } async getEffectiveSettingsForJob(job, options = {}) { const mediaProfile = this.resolveMediaProfileForJob(job, options); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); return { settings, mediaProfile }; } async runCapturedCommand(cmd, args = []) { const command = String(cmd || '').trim(); const argv = Array.isArray(args) ? args.map((item) => String(item)) : []; if (!command) { throw new Error('Kommando fehlt.'); } return new Promise((resolve, reject) => { execFile(command, argv, { maxBuffer: 32 * 1024 * 1024 }, (error, stdout, stderr) => { if (error) { error.stdout = stdout; error.stderr = stderr; reject(error); return; } resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') }); }); }); } async ensureMakeMKVRegistration(jobId, stage) { const registrationConfig = await settingsService.buildMakeMKVRegisterConfig(); if (!registrationConfig) { return { applied: false, reason: 'not_configured' }; } await historyService.appendLog( jobId, 'SYSTEM', 'Setze MakeMKV-Registrierungsschlüssel aus den Settings (makemkvcon reg).' ); await this.runCommand({ jobId, stage, source: 'MAKEMKV_REG', cmd: registrationConfig.cmd, args: registrationConfig.args, argsForLog: registrationConfig.argsForLog }); return { applied: true }; } isReviewRefreshSettingKey(key) { const normalized = String(key || '').trim().toLowerCase(); if (!normalized) { return false; } if (REVIEW_REFRESH_SETTING_KEYS.has(normalized)) { return true; } return REVIEW_REFRESH_SETTING_PREFIXES.some((prefix) => normalized.startsWith(prefix)); } async refreshEncodeReviewAfterSettingsSave(changedKeys = []) { const keys = Array.isArray(changedKeys) ? changedKeys.map((item) => String(item || '').trim()).filter(Boolean) : []; const queueLimitKeys = ['pipeline_max_parallel_jobs', 'pipeline_max_parallel_cd_encodes', 'pipeline_max_total_encodes', 'pipeline_cd_bypasses_queue']; if (keys.some((k) => queueLimitKeys.includes(k))) { await this.emitQueueChanged(); void this.pumpQueue(); } const relevantKeys = keys.filter((key) => this.isReviewRefreshSettingKey(key)); if (relevantKeys.length === 0) { return { triggered: false, reason: 'no_relevant_setting_changes', relevantKeys: [] }; } if (this.activeProcesses.size > 0 || RUNNING_STATES.has(this.snapshot.state)) { return { triggered: false, reason: 'pipeline_busy', relevantKeys }; } const rawJobId = Number(this.snapshot.activeJobId || this.snapshot.context?.jobId || null); const activeJobId = Number.isFinite(rawJobId) && rawJobId > 0 ? Math.trunc(rawJobId) : null; if (!activeJobId) { return { triggered: false, reason: 'no_active_job', relevantKeys }; } const job = await historyService.getJobById(activeJobId); if (!job) { return { triggered: false, reason: 'active_job_not_found', relevantKeys, jobId: activeJobId }; } if (job.status !== 'READY_TO_ENCODE' && job.last_state !== 'READY_TO_ENCODE') { return { triggered: false, reason: 'active_job_not_ready_to_encode', relevantKeys, jobId: activeJobId, status: job.status, lastState: job.last_state }; } const existingPlan = this.safeParseJson(job.encode_plan_json); const refreshSettings = await settingsService.getSettingsMap(); const refreshMediaProfile = this.resolveMediaProfileForJob(job, { encodePlan: existingPlan, rawPath: job.raw_path }); const resolvedRefreshRawPath = this.resolveCurrentRawPathForSettings( refreshSettings, refreshMediaProfile, job.raw_path ); if (!resolvedRefreshRawPath) { return { triggered: false, reason: 'raw_path_missing', relevantKeys, jobId: activeJobId, rawPath: job.raw_path || null }; } if (resolvedRefreshRawPath !== job.raw_path) { await historyService.updateJob(activeJobId, { raw_path: resolvedRefreshRawPath }); } const mode = existingPlan?.mode || this.snapshot.context?.mode || 'rip'; const sourceJobId = existingPlan?.sourceJobId || this.snapshot.context?.sourceJobId || null; await historyService.appendLog( activeJobId, 'SYSTEM', `Settings gespeichert (${relevantKeys.join(', ')}). Titel-/Spurprüfung wird mit aktueller Konfiguration neu gestartet.` ); this.runReviewForRawJob(activeJobId, resolvedRefreshRawPath, { mode, sourceJobId }).catch((error) => { logger.error('settings:refresh-review:failed', { jobId: activeJobId, relevantKeys, error: errorToMeta(error) }); }); return { triggered: true, reason: 'refresh_started', relevantKeys, jobId: activeJobId, mode }; } resolvePlaylistDecisionForJob(jobId, job, selectionOverride = null) { const activeContext = this.snapshot.context?.jobId === jobId ? (this.snapshot.context || {}) : {}; const mkInfo = this.safeParseJson(job?.makemkv_info_json); const analyzeContext = mkInfo?.analyzeContext || {}; const playlistAnalysis = activeContext.playlistAnalysis || analyzeContext.playlistAnalysis || mkInfo?.playlistAnalysis || null; const playlistDecisionRequired = Boolean( activeContext.playlistDecisionRequired !== undefined ? activeContext.playlistDecisionRequired : (analyzeContext.playlistDecisionRequired !== undefined ? analyzeContext.playlistDecisionRequired : playlistAnalysis?.manualDecisionRequired) ); const rawSelection = selectionOverride || activeContext.selectedPlaylist || analyzeContext.selectedPlaylist || null; const selectedPlaylist = normalizePlaylistId(rawSelection); const rawSelectedTitleId = activeContext.selectedTitleId ?? analyzeContext.selectedTitleId ?? null; let selectedTitleId = null; if (selectedPlaylist) { selectedTitleId = pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylist); } if (selectedTitleId === null) { const parsedSelectedTitleId = normalizeNonNegativeInteger(rawSelectedTitleId); if (parsedSelectedTitleId !== null) { selectedTitleId = parsedSelectedTitleId; } } if (!selectedPlaylist && selectedTitleId !== null && !isCandidateTitleId(playlistAnalysis, selectedTitleId)) { selectedTitleId = null; } const candidatePlaylists = buildPlaylistCandidates(playlistAnalysis); const recommendation = playlistAnalysis?.recommendation || null; return { playlistAnalysis, playlistDecisionRequired, candidatePlaylists, selectedPlaylist, selectedTitleId, recommendation }; } async analyzeDisc(devicePath = null) { this.ensureNotBusy('analyze'); logger.info('analyze:start', { devicePath }); const requestedDevicePath = this.normalizeDrivePath(devicePath); if (requestedDevicePath) { const existingLock = this._getDriveLockByPath(requestedDevicePath); if (diskDetectionService.isDeviceLocked(requestedDevicePath) || existingLock) { throw this._buildDriveLockedError(requestedDevicePath, existingLock); } } let device; if (requestedDevicePath) { const driveEntry = this.cdDrives.get(requestedDevicePath); device = driveEntry?.device || null; if (!device && this.detectedDisc?.path === requestedDevicePath) { device = this.detectedDisc; } if (!device) { // Try the disk detection service's per-drive map — covers non-CD drives // not yet tracked by the global state machine (e.g. second DVD drive). const diskDetected = diskDetectionService.detectedDiscs.get(requestedDevicePath); device = diskDetected || { path: requestedDevicePath }; } } else { device = this.detectedDisc || this.snapshot.context?.device; } if (!device) { const error = new Error('Keine Disk erkannt.'); error.statusCode = 400; logger.warn('analyze:no-disc'); throw error; } // Use only disc-specific labels — never the drive hardware model (device.model // reflects the drive hardware, e.g. "BD-RE BH16NS55", not the disc content). const detectedTitle = String( device.discLabel || device.label || 'Unknown Disc' ).trim(); const explicitProfile = normalizeMediaProfile(device?.mediaProfile); const inferredProfile = inferMediaProfileFromDeviceInfo(device); let mediaProfile = isSpecificMediaProfile(explicitProfile) ? explicitProfile : (isSpecificMediaProfile(inferredProfile) ? inferredProfile : (explicitProfile || inferredProfile || 'other')); let deviceWithProfile = { ...device, mediaProfile }; const effectiveAnalyzeDevicePath = this.normalizeDrivePath(deviceWithProfile?.path || requestedDevicePath); if (effectiveAnalyzeDevicePath) { await this._cleanupReplaceableDriveJobsForAnalyze(effectiveAnalyzeDevicePath); } // Fallback for Audio-CDs with ambiguous filesystem markers: // if profile inference ended up as non-CD and the drive reports either no FS type // or iso9660/cdfs, probe the TOC directly and force CD routing when tracks exist. const analyzeFsType = String(deviceWithProfile?.fstype || device?.fstype || '').trim().toLowerCase(); const isAmbiguousCdFsType = !analyzeFsType || analyzeFsType.includes('iso9660') || analyzeFsType.includes('cdfs'); if ( mediaProfile !== 'cd' && String(device?.path || '').trim() && isAmbiguousCdFsType ) { try { const settingsMap = await settingsService.getSettingsMap(); const cdparanoiaCmd = String(settingsMap?.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; const tocTracks = await cdRipService.readToc(device.path, cdparanoiaCmd); if (tocTracks.length > 0) { logger.info('analyze:media-profile-override-to-cd', { devicePath: device.path, previousMediaProfile: mediaProfile, trackCount: tocTracks.length }); mediaProfile = 'cd'; deviceWithProfile = { ...deviceWithProfile, mediaProfile: 'cd', fstype: deviceWithProfile.fstype || 'audio_cd' }; } } catch (error) { logger.debug('analyze:media-profile-cd-probe-failed', { devicePath: device.path, error: errorToMeta(error) }); } } const analyzePlugin = await this.resolveAnalyzePlugin(deviceWithProfile, 'analyze'); // Route audio CDs to the dedicated CD pipeline if (mediaProfile === 'cd') { return this.analyzeCd(deviceWithProfile, { plugin: analyzePlugin?.id === 'cd' ? analyzePlugin : null }); } const job = await historyService.createJob({ discDevice: device.path, status: 'METADATA_SELECTION', detectedTitle, jobKind: resolveJobKindForMediaProfile(mediaProfile) }); try { let effectiveDetectedTitle = detectedTitle; let omdbCandidates = null; let pluginExecution = null; if (analyzePlugin) { const pluginContext = await this.buildPluginContext(analyzePlugin.id, { discInfo: deviceWithProfile, jobId: job.id }); try { const pluginResult = await analyzePlugin.analyze(device.path, job, pluginContext); pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); const pluginDetectedTitle = String(pluginResult?.detectedTitle || '').trim(); if (pluginDetectedTitle) { effectiveDetectedTitle = pluginDetectedTitle; } if (Array.isArray(pluginResult?.omdbCandidates)) { omdbCandidates = pluginResult.omdbCandidates; } logger.info('plugin:analyze:used', { jobId: job.id, pluginId: analyzePlugin.id, mediaProfile }); } catch (error) { pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); logger.warn('plugin:analyze:fallback-legacy', { jobId: job.id, pluginId: analyzePlugin.id, mediaProfile, error: errorToMeta(error) }); } } if (!Array.isArray(omdbCandidates)) { omdbCandidates = await omdbService.search(effectiveDetectedTitle).catch(() => []); } logger.info('metadata:prepare:result', { jobId: job.id, detectedTitle: effectiveDetectedTitle, omdbCandidateCount: omdbCandidates.length }); const prepareInfo = this.withPluginExecutionMeta( this.withAnalyzeContextMediaProfile({ phase: 'PREPARE', preparedAt: nowIso(), analyzeContext: { playlistAnalysis: null, playlistDecisionRequired: false, selectedPlaylist: null, selectedTitleId: null } }, mediaProfile), pluginExecution ); await historyService.updateJob(job.id, { status: 'METADATA_SELECTION', last_state: 'METADATA_SELECTION', detected_title: effectiveDetectedTitle, makemkv_info_json: JSON.stringify(prepareInfo) }); await historyService.appendLog( job.id, 'SYSTEM', `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".` ); const runningJobs = await historyService.getRunningJobs(); const foreignRunningJobs = runningJobs.filter((item) => Number(item?.id) !== Number(job.id)); // Only block metadata selection if a foreign job requires active user interaction // in the pipeline UI. Autonomous jobs (ENCODING, RIPPING, READY_TO_ENCODE) run // independently via activeProcesses and must not prevent new disc metadata selection. const PIPELINE_INTERACTIVE_STATES = new Set([ 'ANALYZING', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'MEDIAINFO_CHECK' ]); const keepCurrentPipelineSession = foreignRunningJobs.some( (item) => PIPELINE_INTERACTIVE_STATES.has(String(item?.status || '').toUpperCase()) ); if (!keepCurrentPipelineSession) { await this.setState('METADATA_SELECTION', { activeJobId: job.id, progress: 0, eta: null, statusText: 'Metadaten auswählen', context: { jobId: job.id, device: deviceWithProfile, detectedTitle: effectiveDetectedTitle, detectedTitleSource: effectiveDetectedTitle !== detectedTitle ? 'plugin' : (device.discLabel ? 'discLabel' : 'fallback'), omdbCandidates, mediaProfile, playlistAnalysis: null, playlistDecisionRequired: false, playlistCandidates: [], selectedPlaylist: null, selectedTitleId: null, ...(pluginExecution ? { pluginExecution } : {}) } }); } else { await historyService.appendLog( job.id, 'SYSTEM', `Metadaten-Auswahl im Hintergrund vorbereitet. Aktive Session bleibt bei interaktivem Job #${foreignRunningJobs.map((item) => item.id).join(',')}.` ); } void this.notifyPushover('metadata_ready', { title: 'Ripster - Metadaten bereit', message: `Job #${job.id}: ${effectiveDetectedTitle} (${omdbCandidates.length} Treffer)` }); return { jobId: job.id, detectedTitle: effectiveDetectedTitle, omdbCandidates }; } catch (error) { logger.error('metadata:prepare:failed', { jobId: job.id, error: errorToMeta(error) }); await this.failJob(job.id, 'METADATA_SELECTION', error); throw error; } } async searchOmdb(query) { logger.info('omdb:search', { query }); const results = await omdbService.search(query); logger.info('omdb:search:done', { query, count: results.length }); return results; } async runDiscTrackReviewForJob(jobId, deviceInfo = null, options = {}) { this.ensureNotBusy('runDiscTrackReviewForJob', jobId); logger.info('disc-track-review:start', { jobId, deviceInfo, options }); const job = await historyService.getJobById(jobId); if (!job) { const error = new Error(`Job ${jobId} nicht gefunden.`); error.statusCode = 404; throw error; } const mkInfo = this.safeParseJson(job.makemkv_info_json); const mediaProfile = this.resolveMediaProfileForJob(job, { mediaProfile: options?.mediaProfile, deviceInfo, makemkvInfo: mkInfo }); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const reviewPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'review').catch(() => null); let reviewPluginExecution = null; const analyzeContext = mkInfo?.analyzeContext || {}; const playlistAnalysis = analyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null; const selectedPlaylistId = normalizePlaylistId( options?.selectedPlaylist || analyzeContext.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null ); const selectedMakemkvTitleIdRaw = options?.selectedTitleId ?? analyzeContext.selectedTitleId ?? this.snapshot.context?.selectedTitleId ?? null; const selectedMakemkvTitleId = normalizeNonNegativeInteger(selectedMakemkvTitleIdRaw); const selectedMetadata = { title: job.title || job.detected_title || null, year: job.year || null, imdbId: job.imdb_id || null, poster: job.poster_url || null }; await this.setState('MEDIAINFO_CHECK', { activeJobId: jobId, progress: 0, eta: null, statusText: 'Vorab-Spurprüfung (Disc) läuft', context: { ...(this.snapshot.context || {}), jobId, reviewConfirmed: false, mode: 'pre_rip', mediaProfile, selectedMetadata } }); await historyService.updateJob(jobId, { status: 'MEDIAINFO_CHECK', last_state: 'MEDIAINFO_CHECK', error_message: null, makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile(mkInfo, mediaProfile)), mediainfo_info_json: null, encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0 }); const lines = []; let runInfo = null; let sourceArg = null; const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { jobId, deviceInfo, reviewMode: 'pre_rip', source: 'HANDBRAKE_SCAN', silent: !this.isPrimaryJob(jobId) }); lines.push(...pluginScan.scanLines); runInfo = pluginScan.runInfo || null; sourceArg = pluginScan.sourceArg || null; reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, pluginScan.pluginExecution ); const parsed = parseMediainfoJsonOutput(lines.join('\n')); if (!parsed) { const error = new Error('HandBrake Scan-Ausgabe konnte nicht als JSON gelesen werden.'); error.runInfo = runInfo; throw error; } const review = buildDiscScanReview({ scanJson: parsed, settings, playlistAnalysis, selectedPlaylistId, selectedMakemkvTitleId, mediaProfile, sourceArg }); if (!Array.isArray(review.titles) || review.titles.length === 0) { const error = new Error('Vorab-Spurprüfung lieferte keine Titel.'); error.statusCode = 400; throw error; } const persistedMediainfoInfo = this.withPluginExecutionMeta({ generatedAt: nowIso(), source: 'disc_scan', runInfo }, reviewPluginExecution); await historyService.updateJob(jobId, { status: 'READY_TO_ENCODE', last_state: 'READY_TO_ENCODE', error_message: null, mediainfo_info_json: JSON.stringify(persistedMediainfoInfo), encode_plan_json: JSON.stringify(review), encode_input_path: review.encodeInputPath || null, encode_review_confirmed: 0 }); await historyService.appendLog( jobId, 'SYSTEM', `Vorab-Spurprüfung abgeschlossen: ${review.titles.length} Titel, Auswahl=${review.encodeInputTitleId ? `Titel #${review.encodeInputTitleId}` : 'keine'}.` ); await this.setState('READY_TO_ENCODE', { activeJobId: jobId, progress: 0, eta: null, statusText: review.titleSelectionRequired ? 'Vorab-Spurprüfung fertig - Titel per Checkbox wählen' : 'Vorab-Spurprüfung fertig - Auswahl bestätigen, dann Backup/Encode starten', context: { ...(this.snapshot.context || {}), jobId, inputPath: review.encodeInputPath || null, hasEncodableTitle: Boolean(review.encodeInputTitleId), reviewConfirmed: false, mode: 'pre_rip', mediaProfile, mediaInfoReview: review, selectedMetadata, ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) } }); return review; } async handleDiscTrackReviewFailure(jobId, error, context = {}) { const message = error?.message || String(error); const runInfo = error?.runInfo && typeof error.runInfo === 'object' ? error.runInfo : null; const isDiscScanFailure = String(runInfo?.source || '').toUpperCase() === 'HANDBRAKE_SCAN' || /no title found/i.test(message); if (!isDiscScanFailure) { await this.failJob(jobId, 'MEDIAINFO_CHECK', error); return; } logger.warn('disc-track-review:fallback-to-manual-rip', { jobId, message, runInfo: runInfo || null }); await historyService.updateJob(jobId, { status: 'READY_TO_START', last_state: 'READY_TO_START', error_message: null, mediainfo_info_json: JSON.stringify({ source: 'disc_scan', failedAt: nowIso(), error: message, runInfo }), encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0 }); await historyService.appendLog( jobId, 'SYSTEM', `Vorab-Spurprüfung fehlgeschlagen (${message}). Fallback: Backup/Rip kann manuell gestartet werden; Spurauswahl erfolgt danach.` ); await this.setState('READY_TO_START', { activeJobId: jobId, progress: 0, eta: null, statusText: 'Vorab-Spurprüfung fehlgeschlagen - Backup manuell starten', context: { ...(this.snapshot.context || {}), jobId, selectedMetadata: context.selectedMetadata || this.snapshot.context?.selectedMetadata || null, playlistAnalysis: context.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null, playlistDecisionRequired: Boolean(context.playlistDecisionRequired ?? this.snapshot.context?.playlistDecisionRequired), playlistCandidates: context.playlistCandidates || this.snapshot.context?.playlistCandidates || [], selectedPlaylist: context.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null, selectedTitleId: context.selectedTitleId ?? this.snapshot.context?.selectedTitleId ?? null, preRipScanFailed: true, preRipScanError: message } }); } async runBackupTrackReviewForJob(jobId, rawPath, options = {}) { this.ensureNotBusy('runBackupTrackReviewForJob', jobId); logger.info('backup-track-review:start', { jobId, rawPath, options }); const job = await historyService.getJobById(jobId); if (!job) { const error = new Error(`Job ${jobId} nicht gefunden.`); error.statusCode = 404; throw error; } if (!rawPath || !fs.existsSync(rawPath)) { const error = new Error(`RAW-Pfad nicht gefunden (${rawPath || '-'})`); error.statusCode = 400; throw error; } const mode = String(options?.mode || 'rip').trim().toLowerCase() || 'rip'; const forcePlaylistReselection = Boolean(options?.forcePlaylistReselection); const forceFreshAnalyze = Boolean(options?.forceFreshAnalyze); const mkInfo = this.safeParseJson(job.makemkv_info_json); const mediaProfile = this.resolveMediaProfileForJob(job, { mediaProfile: options?.mediaProfile, rawPath, makemkvInfo: mkInfo }); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const reviewPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'review').catch(() => null); let reviewPluginExecution = null; const analyzeContext = mkInfo?.analyzeContext || {}; let playlistAnalysis = forceFreshAnalyze ? null : (analyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null); let handBrakePlaylistScan = forceFreshAnalyze ? null : normalizeHandBrakePlaylistScanCache(analyzeContext.handBrakePlaylistScan || null); if (playlistAnalysis && handBrakePlaylistScan) { playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); } const selectedPlaylistSource = (forcePlaylistReselection || forceFreshAnalyze) ? (options?.selectedPlaylist || null) : (options?.selectedPlaylist || analyzeContext.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null); const selectedPlaylistId = normalizePlaylistId( selectedPlaylistSource ); const selectedTitleSource = (forcePlaylistReselection || forceFreshAnalyze) ? (options?.selectedTitleId ?? null) : (options?.selectedTitleId ?? analyzeContext.selectedTitleId ?? this.snapshot.context?.selectedTitleId ?? null); const selectedMakemkvTitleId = normalizeNonNegativeInteger(selectedTitleSource); const selectedMetadata = { title: job.title || job.detected_title || null, year: job.year || null, imdbId: job.imdb_id || null, poster: job.poster_url || null }; if (this.isPrimaryJob(jobId)) { await this.setState('MEDIAINFO_CHECK', { activeJobId: jobId, progress: 0, eta: null, statusText: 'Titel-/Spurprüfung aus RAW-Backup läuft', context: { ...(this.snapshot.context || {}), jobId, rawPath, inputPath: null, hasEncodableTitle: false, reviewConfirmed: false, mode, mediaProfile, sourceJobId: options.sourceJobId || null, selectedMetadata } }); } await historyService.updateJob(jobId, { status: 'MEDIAINFO_CHECK', last_state: 'MEDIAINFO_CHECK', error_message: null, makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile(mkInfo, mediaProfile)), mediainfo_info_json: null, encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0 }); if (forcePlaylistReselection && !selectedPlaylistId) { await historyService.appendLog( jobId, 'SYSTEM', 'Re-Encode: gespeicherte Playlist-Auswahl wird ignoriert. Bitte Playlist manuell neu auswählen.' ); } if (forceFreshAnalyze) { await historyService.appendLog( jobId, 'SYSTEM', 'Review-Neustart erzwingt frische MakeMKV Full-Analyse (kein Reuse von Playlist-/HandBrake-Cache).' ); } // Build playlist->TITLE_ID mapping once from MakeMKV full robot scan on RAW backup. let makeMkvAnalyzeRunInfo = null; let analyzedFromFreshRun = false; const existingPostBackupAnalyze = mkInfo?.postBackupAnalyze && typeof mkInfo.postBackupAnalyze === 'object' ? mkInfo.postBackupAnalyze : null; await this.ensureMakeMKVRegistration(jobId, 'MEDIAINFO_CHECK'); if (selectedPlaylistId) { if (!playlistAnalysis || !Array.isArray(playlistAnalysis?.titles) || playlistAnalysis.titles.length === 0) { const error = new Error( 'Playlist-Auswahl kann nicht fortgesetzt werden: MakeMKV-Mapping fehlt. Bitte zuerst die RAW-Analyse erneut starten.' ); error.statusCode = 409; throw error; } await historyService.appendLog( jobId, 'SYSTEM', 'Verwende vorhandenes MakeMKV-Playlist-Mapping aus dem letzten Full-Scan (kein erneuter Full-Scan).' ); } else { const analyzeLines = []; const analyzeConfig = await settingsService.buildMakeMKVAnalyzePathConfig(rawPath, { mediaProfile }); logger.info('backup-track-review:makemkv-analyze-command', { jobId, cmd: analyzeConfig.cmd, args: analyzeConfig.args, sourceArg: analyzeConfig.sourceArg }); makeMkvAnalyzeRunInfo = await this.runCommand({ jobId, stage: 'MEDIAINFO_CHECK', source: 'MAKEMKV_ANALYZE_BACKUP', cmd: analyzeConfig.cmd, args: analyzeConfig.args, parser: parseMakeMkvProgress, collectLines: analyzeLines, silent: !this.isPrimaryJob(jobId) }); const analyzed = analyzePlaylistObfuscation( analyzeLines, Number(settings.makemkv_min_length_minutes || 60), {} ); playlistAnalysis = analyzed || null; analyzedFromFreshRun = true; } const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired); let playlistCandidates = buildPlaylistCandidates(playlistAnalysis); const selectedTitleFromPlaylist = selectedPlaylistId ? pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylistId) : null; const selectedTitleFromContext = (!selectedPlaylistId && !isCandidateTitleId(playlistAnalysis, selectedMakemkvTitleId)) ? null : selectedMakemkvTitleId; const selectedTitleForContext = selectedTitleFromPlaylist ?? selectedTitleFromContext ?? null; if (selectedPlaylistId && playlistCandidates.length > 0) { const isKnownPlaylist = playlistCandidates.some((item) => item.playlistId === selectedPlaylistId); if (!isKnownPlaylist) { const error = new Error(`Playlist ${selectedPlaylistId}.mpls ist nicht in den erkannten Kandidaten enthalten.`); error.statusCode = 400; throw error; } } const shouldPrepareHandBrakeDecisionData = Boolean( playlistDecisionRequired && !selectedPlaylistId && playlistCandidates.length > 0 ); if (shouldPrepareHandBrakeDecisionData) { const hasCompleteCache = hasCachedHandBrakeDataForPlaylistCandidates(handBrakePlaylistScan, playlistCandidates); if (!hasCompleteCache) { await this.updateProgress( 'MEDIAINFO_CHECK', 25, null, 'HandBrake Trackdaten für Playlist-Auswahl werden vorbereitet', jobId ); try { const resolveScanLines = []; const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { jobId, rawPath, reviewMode: 'rip', source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', silent: !this.isPrimaryJob(jobId) }); resolveScanLines.push(...pluginScan.scanLines); reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, pluginScan.pluginExecution ); const resolveScanJson = parseMediainfoJsonOutput(resolveScanLines.join('\n')); if (resolveScanJson) { const preparedCache = buildHandBrakePlaylistScanCache(resolveScanJson, playlistCandidates, rawPath); if (preparedCache) { handBrakePlaylistScan = preparedCache; playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); playlistCandidates = buildPlaylistCandidates(playlistAnalysis); await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Playlist-Trackdaten vorbereitet: ${Object.keys(preparedCache.byPlaylist || {}).length} Kandidaten aus --scan -t 0 analysiert.` ); } else { await historyService.appendLog( jobId, 'SYSTEM', 'HandBrake Playlist-Trackdaten konnten aus --scan -t 0 nicht auf Kandidaten abgebildet werden.' ); } } else { const error = new Error('HandBrake Playlist-Trackdaten konnten nicht geparst werden.'); error.runInfo = null; throw error; } } catch (error) { logger.warn('backup-track-review:handbrake-predecision-failed', { jobId, error: errorToMeta(error) }); await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Voranalyse für Playlist-Auswahl fehlgeschlagen: ${error.message}` ); throw error; } } else { playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); playlistCandidates = buildPlaylistCandidates(playlistAnalysis); await historyService.appendLog( jobId, 'SYSTEM', 'HandBrake Playlist-Trackdaten aus Cache übernommen (kein erneuter --scan -t 0).' ); } } const updatedMakemkvInfoBase = { ...mkInfo, analyzeContext: { ...(mkInfo?.analyzeContext || {}), mediaProfile, playlistAnalysis: playlistAnalysis || null, playlistDecisionRequired, selectedPlaylist: selectedPlaylistId || null, selectedTitleId: selectedTitleForContext, handBrakePlaylistScan: handBrakePlaylistScan || null }, postBackupAnalyze: analyzedFromFreshRun ? { analyzedAt: nowIso(), source: 'MAKEMKV_ANALYZE_BACKUP', runInfo: makeMkvAnalyzeRunInfo, error: null } : { analyzedAt: existingPostBackupAnalyze?.analyzedAt || nowIso(), source: 'MAKEMKV_ANALYZE_BACKUP', runInfo: existingPostBackupAnalyze?.runInfo || null, reused: true, error: null } }; const buildUpdatedMakemkvInfo = () => this.withPluginExecutionMeta(updatedMakemkvInfoBase, reviewPluginExecution); const getMergedReviewPluginExecution = () => this.sanitizePluginExecutionState( buildUpdatedMakemkvInfo()?.pluginExecution ); if (playlistDecisionRequired && !selectedPlaylistId) { const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates : []; const recommendationFile = toPlaylistFile(playlistAnalysis?.recommendation?.playlistId); const decisionContext = describePlaylistManualDecision(playlistAnalysis); const waitingMediainfoInfo = this.withPluginExecutionMeta({ generatedAt: nowIso(), source: 'makemkv_backup_robot', runInfo: makeMkvAnalyzeRunInfo }, reviewPluginExecution); await historyService.updateJob(jobId, { status: 'WAITING_FOR_USER_DECISION', last_state: 'WAITING_FOR_USER_DECISION', error_message: null, makemkv_info_json: JSON.stringify(buildUpdatedMakemkvInfo()), mediainfo_info_json: JSON.stringify(waitingMediainfoInfo), encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0 }); await historyService.appendLog(jobId, 'SYSTEM', 'Mehrere mögliche Haupttitel erkannt!'); await historyService.appendLog(jobId, 'SYSTEM', decisionContext.detailText); for (const candidate of evaluated) { const playlistFile = toPlaylistFile(candidate?.playlistId) || `Titel #${candidate?.titleId || '-'}`; const score = Number(candidate?.score); const scoreLabel = Number.isFinite(score) ? score.toFixed(0) : '-'; const durationLabel = String(candidate?.durationLabel || '').trim() || formatDurationClock(candidate?.durationSeconds) || '-'; const recommendedLabel = candidate?.recommended ? ' (empfohlen)' : ''; const evaluationLabel = candidate?.evaluationLabel ? ` | ${candidate.evaluationLabel}` : ''; await historyService.appendLog( jobId, 'SYSTEM', `${playlistFile} -> Dauer ${durationLabel} | Score ${scoreLabel}${recommendedLabel}${evaluationLabel}` ); } await historyService.appendLog( jobId, 'SYSTEM', `Status=awaiting_playlist_selection${recommendationFile ? ` | Empfehlung=${recommendationFile}` : ''}` ); const mergedPluginExecutionForWaiting = getMergedReviewPluginExecution(); await this.setState('WAITING_FOR_USER_DECISION', { activeJobId: jobId, progress: 0, eta: null, statusText: 'awaiting_playlist_selection', context: { ...(this.snapshot.context || {}), jobId, rawPath, inputPath: null, hasEncodableTitle: false, reviewConfirmed: false, mode, mediaProfile, sourceJobId: options.sourceJobId || null, selectedMetadata, playlistAnalysis: playlistAnalysis || null, playlistDecisionRequired: true, playlistCandidates, selectedPlaylist: null, selectedTitleId: null, waitingForManualPlaylistSelection: true, manualDecisionState: 'awaiting_playlist_selection', mediaInfoReview: null, ...(mergedPluginExecutionForWaiting ? { pluginExecution: mergedPluginExecutionForWaiting } : {}) } }); const notificationMessage = [ '⚠️ Manuelle Prüfung erforderlich!', decisionContext.obfuscationDetected ? 'Mehrere gleichlange Playlists erkannt.' : 'Mehrere Playlists erfüllen MIN_LENGTH_MINUTES.', '', 'Empfehlung:', recommendationFile || '(keine eindeutige Empfehlung)', '', 'Bitte Titel manuell bestätigen,', 'bevor Encoding gestartet wird.' ].join('\n'); void this.notifyPushover('metadata_ready', { title: 'Ripster - Playlist-Auswahl erforderlich', message: notificationMessage, priority: 1 }); return { awaitingPlaylistSelection: true, playlistAnalysis, playlistCandidates, recommendation: playlistAnalysis?.recommendation || null }; } if (selectedPlaylistId) { await historyService.appendLog( jobId, 'USER_ACTION', `Playlist-Auswahl übernommen: ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId}.` ); } const selectedTitleForReview = pickTitleIdForTrackReview(playlistAnalysis, selectedTitleForContext); if (selectedTitleForReview === null) { const error = new Error('Titel-/Spurprüfung aus RAW nicht möglich: keine auflösbare Titel-ID vorhanden.'); error.statusCode = 400; throw error; } const selectedTitleFromAnalysis = Array.isArray(playlistAnalysis?.titles) ? (playlistAnalysis.titles.find((item) => Number(item?.titleId) === Number(selectedTitleForReview)) || null) : null; const resolvedPlaylistId = normalizePlaylistId( selectedPlaylistId || selectedTitleFromAnalysis?.playlistId || playlistAnalysis?.recommendation?.playlistId || null ); if (!resolvedPlaylistId) { const error = new Error( `Playlist konnte für MakeMKV Titel #${selectedTitleForReview} nicht aufgelöst werden.` ); error.statusCode = 400; throw error; } if (updatedMakemkvInfoBase && updatedMakemkvInfoBase.analyzeContext) { updatedMakemkvInfoBase.analyzeContext.selectedTitleId = selectedTitleForReview; updatedMakemkvInfoBase.analyzeContext.selectedPlaylist = resolvedPlaylistId; } const cachedHandBrakePlaylistEntry = getCachedHandBrakePlaylistEntry(handBrakePlaylistScan, resolvedPlaylistId); const expectedDurationForCache = Number(selectedTitleFromAnalysis?.durationSeconds || 0) || null; const expectedSizeForCache = Number(selectedTitleFromAnalysis?.sizeBytes || 0) || null; const hasCachedHandBrakeEntry = Boolean( isHandBrakePlaylistCacheEntryCompatible( cachedHandBrakePlaylistEntry, resolvedPlaylistId, { expectedDurationSeconds: expectedDurationForCache, expectedSizeBytes: expectedSizeForCache } ) ); if (cachedHandBrakePlaylistEntry && !hasCachedHandBrakeEntry) { await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Cache für ${toPlaylistFile(resolvedPlaylistId)} verworfen (inkompatible Playlist-/Dauerdaten).` ); } await this.updateProgress( 'MEDIAINFO_CHECK', 30, null, hasCachedHandBrakeEntry ? `HandBrake Trackdaten aus Cache (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})` : `HandBrake Titel-/Spurscan läuft (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})`, jobId ); let handBrakeResolveRunInfo = null; let handBrakeTitleRunInfo = null; let resolvedHandBrakeTitleId = null; const reviewTitleSource = 'handbrake'; const makeMkvSubtitleTracksForSelection = Array.isArray(selectedTitleFromAnalysis?.subtitleTracks) ? selectedTitleFromAnalysis.subtitleTracks : []; let reviewTitleInfo = null; if (hasCachedHandBrakeEntry) { resolvedHandBrakeTitleId = Math.trunc(Number(cachedHandBrakePlaylistEntry.handBrakeTitleId)); reviewTitleInfo = enrichTitleInfoWithForcedSubtitleAvailability( cachedHandBrakePlaylistEntry.titleInfo, makeMkvSubtitleTracksForSelection ); handBrakeResolveRunInfo = { source: 'HANDBRAKE_SCAN_PLAYLIST_MAP_CACHE', stage: 'MEDIAINFO_CHECK', status: 'CACHED', exitCode: 0, startedAt: handBrakePlaylistScan?.generatedAt || null, endedAt: nowIso(), durationMs: 0, cmd: 'cache', args: [`playlist=${resolvedPlaylistId}`, `title=${resolvedHandBrakeTitleId}`], highlights: [ `Cache verwendet: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId}` ] }; handBrakeTitleRunInfo = handBrakeResolveRunInfo; await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Track-Analyse aus Cache: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (kein erneuter --scan).` ); } else { try { const resolveScanLines = []; const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { jobId, rawPath, reviewMode: 'rip', source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', silent: !this.isPrimaryJob(jobId) }); resolveScanLines.push(...pluginScan.scanLines); handBrakeResolveRunInfo = pluginScan.runInfo || null; reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, pluginScan.pluginExecution ); const resolveScanJson = parseMediainfoJsonOutput(resolveScanLines.join('\n')); if (!resolveScanJson) { const error = new Error('HandBrake Playlist-Mapping lieferte kein parsebares JSON.'); error.runInfo = handBrakeResolveRunInfo; throw error; } resolvedHandBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(resolveScanJson, resolvedPlaylistId, { expectedMakemkvTitleId: selectedTitleForReview, expectedDurationSeconds: expectedDurationForCache, expectedSizeBytes: expectedSizeForCache }); if (!resolvedHandBrakeTitleId) { const knownPlaylists = listAvailableHandBrakePlaylists(resolveScanJson); const error = new Error( `Kein HandBrake-Titel für ${toPlaylistFile(resolvedPlaylistId)} gefunden.` + ` ${knownPlaylists.length > 0 ? `Scan-Playlists: ${knownPlaylists.map((id) => `${id}.mpls`).join(', ')}` : 'Scan enthält keine erkennbaren Playlist-IDs.'}` ); error.statusCode = 400; error.runInfo = handBrakeResolveRunInfo; throw error; } reviewTitleInfo = parseHandBrakeSelectedTitleInfo(resolveScanJson, { playlistId: resolvedPlaylistId, handBrakeTitleId: resolvedHandBrakeTitleId, makeMkvSubtitleTracks: makeMkvSubtitleTracksForSelection }); if (!reviewTitleInfo) { const error = new Error( `HandBrake lieferte keine verwertbaren Trackdaten für ${toPlaylistFile(resolvedPlaylistId)} (-t ${resolvedHandBrakeTitleId}).` ); error.statusCode = 400; error.runInfo = handBrakeResolveRunInfo; throw error; } if (!isHandBrakePlaylistCacheEntryCompatible({ playlistId: resolvedPlaylistId, handBrakeTitleId: resolvedHandBrakeTitleId, titleInfo: reviewTitleInfo }, resolvedPlaylistId, { expectedDurationSeconds: expectedDurationForCache, expectedSizeBytes: expectedSizeForCache })) { const error = new Error( `HandBrake Titel-Mapping inkonsistent für ${toPlaylistFile(resolvedPlaylistId)} (-t ${resolvedHandBrakeTitleId}).` ); error.statusCode = 400; error.runInfo = handBrakeResolveRunInfo; throw error; } handBrakeTitleRunInfo = handBrakeResolveRunInfo; await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Track-Analyse aktiv: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (aus --scan -t 0).` ); const audioTrackPreview = buildHandBrakeAudioTrackPreview(reviewTitleInfo); const fallbackCache = normalizeHandBrakePlaylistScanCache(handBrakePlaylistScan) || { generatedAt: nowIso(), source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', inputPath: rawPath, byPlaylist: {} }; fallbackCache.byPlaylist[resolvedPlaylistId] = { playlistId: resolvedPlaylistId, handBrakeTitleId: Math.trunc(Number(resolvedHandBrakeTitleId)), titleInfo: reviewTitleInfo, audioTrackPreview, audioSummary: buildHandBrakeAudioSummary(audioTrackPreview) }; handBrakePlaylistScan = normalizeHandBrakePlaylistScanCache(fallbackCache); playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); } catch (error) { logger.warn('backup-track-review:handbrake-scan-failed', { jobId, selectedPlaylistId: resolvedPlaylistId, selectedTitleForReview, error: errorToMeta(error) }); throw error; } } if (updatedMakemkvInfoBase && updatedMakemkvInfoBase.analyzeContext) { updatedMakemkvInfoBase.analyzeContext.handBrakePlaylistScan = handBrakePlaylistScan || null; } playlistCandidates = buildPlaylistCandidates(playlistAnalysis); let presetProfile = null; try { presetProfile = await settingsService.buildHandBrakePresetProfile(rawPath, { titleId: resolvedHandBrakeTitleId, mediaProfile }); } catch (error) { logger.warn('backup-track-review:preset-profile-failed', { jobId, error: errorToMeta(error) }); presetProfile = { source: 'fallback', message: `Preset-Profil konnte nicht geladen werden: ${error.message}` }; } const syntheticFilePath = path.join( rawPath, reviewTitleSource === 'handbrake' && Number.isFinite(Number(resolvedHandBrakeTitleId)) && Number(resolvedHandBrakeTitleId) > 0 ? `handbrake_t${String(Math.trunc(Number(resolvedHandBrakeTitleId))).padStart(2, '0')}.mkv` : `makemkv_t${String(selectedTitleForReview).padStart(2, '0')}.mkv` ); const syntheticMediaInfoByPath = { [syntheticFilePath]: buildSyntheticMediaInfoFromMakeMkvTitle(reviewTitleInfo) }; let review = buildMediainfoReview({ mediaFiles: [{ path: syntheticFilePath, size: Number(reviewTitleInfo?.sizeBytes || 0) }], mediaInfoByPath: syntheticMediaInfoByPath, settings, presetProfile, playlistAnalysis, preferredEncodeTitleId: selectedTitleForReview, selectedPlaylistId: resolvedPlaylistId || reviewTitleInfo?.playlistId || null, selectedMakemkvTitleId: selectedTitleForReview }); review = remapReviewTrackIdsToSourceIds(review); const resolvedPlaylistInfo = resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId); const minLengthMinutesForReview = Number(review?.minLengthMinutes ?? settings?.makemkv_min_length_minutes ?? 0); const minLengthSecondsForReview = Math.max(0, Math.round(minLengthMinutesForReview * 60)); const subtitleTrackMetaBySourceId = new Map( (Array.isArray(reviewTitleInfo?.subtitleTracks) ? reviewTitleInfo.subtitleTracks : []) .map((track) => { const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null; return sourceTrackId ? [sourceTrackId, track] : null; }) .filter(Boolean) ); const normalizedTitles = (Array.isArray(review.titles) ? review.titles : []) .slice(0, 1) .map((title) => { const durationSeconds = Number(reviewTitleInfo?.durationSeconds || title?.durationSeconds || 0); const eligibleForEncode = durationSeconds >= minLengthSecondsForReview; const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => { const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null; const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null; const selectedByRule = Boolean(sourceMeta?.selectedByRule ?? track?.selectedByRule); const duplicate = Boolean(sourceMeta?.duplicate ?? track?.duplicate); const subtitleType = String( sourceMeta?.subtitleType || track?.subtitleType || (sourceMeta?.forcedTrack ? 'forced' : (track?.forcedTrack ? 'forced' : 'full')) ).trim().toLowerCase() === 'forced' ? 'forced' : 'full'; const rawConfidence = String(sourceMeta?.sourceConfidence || track?.sourceConfidence || '') .trim() .toLowerCase(); const confidenceSource = String(sourceMeta?.confidenceSource || track?.confidenceSource || '') .trim() .toLowerCase(); let sourceConfidence = null; if (subtitleType === 'forced') { if (rawConfidence === 'high' || rawConfidence === 'medium' || rawConfidence === 'low') { sourceConfidence = rawConfidence; } else if (confidenceSource === 'title') { sourceConfidence = 'medium'; } else { sourceConfidence = 'low'; } } const subtitlePreviewBurnIn = Boolean(sourceMeta?.subtitlePreviewBurnIn ?? track?.subtitlePreviewBurnIn); const subtitlePreviewForced = Boolean( sourceMeta?.subtitlePreviewForced ?? track?.subtitlePreviewForced ?? (selectedByRule && subtitleType === 'forced') ); const subtitlePreviewForcedOnly = Boolean( sourceMeta?.subtitlePreviewForcedOnly ?? track?.subtitlePreviewForcedOnly ); const isForcedOnly = Boolean( sourceMeta?.isForcedOnly ?? track?.isForcedOnly ?? sourceMeta?.forcedOnly ?? track?.forcedOnly ?? subtitlePreviewForcedOnly ?? subtitleType === 'forced' ); const fullHasForced = !isForcedOnly && Boolean( sourceMeta?.fullHasForced ?? track?.fullHasForced ?? sourceMeta?.subtitleFullHasForced ?? track?.subtitleFullHasForced ); const subtitlePreviewDefaultTrack = Boolean( sourceMeta?.subtitlePreviewDefaultTrack ?? track?.subtitlePreviewDefaultTrack ?? sourceMeta?.defaultFlag ?? track?.defaultFlag ); const subtitlePreviewFlags = Array.isArray(sourceMeta?.subtitlePreviewFlags) ? sourceMeta.subtitlePreviewFlags : (Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []); const subtitlePreviewSummary = String( sourceMeta?.subtitlePreviewSummary || track?.subtitlePreviewSummary || (selectedByRule ? 'Übernehmen' : (duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen')) ).trim() || 'Nicht übernommen'; const selectedForEncode = selectedByRule; return { ...track, id: sourceTrackId || track?.id || null, sourceTrackId: sourceTrackId || track?.sourceTrackId || track?.id || null, language: sourceMeta?.language || track?.language || 'und', languageLabel: sourceMeta?.languageLabel || track?.languageLabel || track?.language || 'und', title: sourceMeta?.title ?? track?.title ?? null, format: sourceMeta?.format || track?.format || null, defaultFlag: Boolean(sourceMeta?.defaultFlag ?? track?.defaultFlag), eventCount: Number.isFinite(Number(sourceMeta?.eventCount)) ? Math.trunc(Number(sourceMeta.eventCount)) : (Number.isFinite(Number(track?.eventCount)) ? Math.trunc(Number(track.eventCount)) : null), streamSizeBytes: Number.isFinite(Number(sourceMeta?.streamSizeBytes)) ? Math.trunc(Number(sourceMeta.streamSizeBytes)) : (Number.isFinite(Number(track?.streamSizeBytes)) ? Math.trunc(Number(track.streamSizeBytes)) : null), forcedTrack: Boolean(sourceMeta?.forcedTrack ?? subtitlePreviewForced), forcedAvailable: Boolean(sourceMeta?.forcedAvailable), forcedSourceTrackIds: normalizeTrackIdList(sourceMeta?.forcedSourceTrackIds || []), isForcedOnly, fullHasForced, subtitleType, sourceConfidence, confidenceSource, duplicate, selected: selectedByRule, selectedByRule, selectedForEncode, subtitlePreviewSummary, subtitlePreviewFlags, subtitlePreviewBurnIn, subtitlePreviewForced, subtitlePreviewForcedOnly, subtitlePreviewDefaultTrack, burnIn: selectedForEncode ? subtitlePreviewBurnIn : false, forced: selectedForEncode ? subtitlePreviewForced : false, forcedOnly: selectedForEncode ? subtitlePreviewForcedOnly : false, defaultTrack: selectedForEncode ? subtitlePreviewDefaultTrack : false, flags: selectedForEncode ? subtitlePreviewFlags : [], subtitleActionSummary: selectedForEncode ? subtitlePreviewSummary : 'Nicht übernommen' }; }); return { ...title, filePath: rawPath, fileName: reviewTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`, durationSeconds, durationMinutes: Number(((durationSeconds / 60)).toFixed(2)), selectedByMinLength: eligibleForEncode, eligibleForEncode, sizeBytes: Number(reviewTitleInfo?.sizeBytes || title?.sizeBytes || 0), playlistId: resolvedPlaylistInfo.playlistId || title?.playlistId || null, playlistFile: resolvedPlaylistInfo.playlistFile || title?.playlistFile || null, playlistRecommended: Boolean(resolvedPlaylistInfo.recommended || title?.playlistRecommended), playlistEvaluationLabel: resolvedPlaylistInfo.evaluationLabel || title?.playlistEvaluationLabel || null, playlistSegmentCommand: resolvedPlaylistInfo.segmentCommand || title?.playlistSegmentCommand || null, playlistSegmentFiles: Array.isArray(resolvedPlaylistInfo.segmentFiles) && resolvedPlaylistInfo.segmentFiles.length > 0 ? resolvedPlaylistInfo.segmentFiles : (Array.isArray(title?.playlistSegmentFiles) ? title.playlistSegmentFiles : []), subtitleTracks }; }); const encodeInputTitleId = Number(normalizedTitles[0]?.id || review.encodeInputTitleId || null) || null; review = { ...review, mode, mediaProfile, sourceJobId: options.sourceJobId || null, reviewConfirmed: false, partial: false, processedFiles: 1, totalFiles: 1, handBrakeTitleId: resolvedHandBrakeTitleId || null, selectedPlaylistId: resolvedPlaylistId || null, selectedMakemkvTitleId: selectedTitleForReview, titleSelectionRequired: false, titles: normalizedTitles, selectedTitleIds: encodeInputTitleId ? [encodeInputTitleId] : [], encodeInputTitleId, encodeInputPath: rawPath, notes: [ ...(Array.isArray(review.notes) ? review.notes : []), 'MakeMKV Full-Analyse wurde einmal für Playlist-/Titel-Mapping verwendet.', `HandBrake Track-Analyse aktiv: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (aus --scan -t 0).` ] }; const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan( review, options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' ? options.previousEncodePlan : null ); review = reviewPrefillResult.plan; if (!Array.isArray(review.titles) || review.titles.length === 0) { const error = new Error('Titel-/Spurprüfung aus RAW lieferte keine Titel.'); error.statusCode = 400; throw error; } const persistedMediainfoInfo = this.withPluginExecutionMeta({ generatedAt: nowIso(), source: 'raw_backup_handbrake_playlist_scan', makemkvAnalyzeRunInfo: makeMkvAnalyzeRunInfo, makemkvTitleAnalyzeRunInfo: null, handbrakePlaylistResolveRunInfo: handBrakeResolveRunInfo, handbrakeTitleRunInfo: handBrakeTitleRunInfo, handbrakeTitleId: resolvedHandBrakeTitleId || null }, reviewPluginExecution); await historyService.updateJob(jobId, { status: 'READY_TO_ENCODE', last_state: 'READY_TO_ENCODE', error_message: null, makemkv_info_json: JSON.stringify(buildUpdatedMakemkvInfo()), mediainfo_info_json: JSON.stringify(persistedMediainfoInfo), encode_plan_json: JSON.stringify(review), encode_input_path: review.encodeInputPath || null, encode_review_confirmed: 0 }); await historyService.appendLog( jobId, 'SYSTEM', `Titel-/Spurprüfung aus RAW abgeschlossen (MakeMKV Titel #${selectedTitleForReview}): ${review.titles.length} Titel, Vorauswahl=${review.encodeInputTitleId ? `Titel #${review.encodeInputTitleId}` : 'keine'}.` ); if (reviewPrefillResult.applied) { await historyService.appendLog( jobId, 'SYSTEM', `Vorherige Encode-Auswahl als Standard übernommen: Titel #${reviewPrefillResult.selectedEncodeTitleId || '-'}, ` + `Pre-Skripte=${reviewPrefillResult.preEncodeScriptCount}, Pre-Ketten=${reviewPrefillResult.preEncodeChainCount}, ` + `Post-Skripte=${reviewPrefillResult.postEncodeScriptCount}, Post-Ketten=${reviewPrefillResult.postEncodeChainCount}, ` + `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.` ); } if (playlistDecisionRequired) { const playlistFiles = playlistCandidates.map((item) => item.playlistFile).filter(Boolean); const recommendationFile = toPlaylistFile(playlistAnalysis?.recommendation?.playlistId); const decisionContext = describePlaylistManualDecision(playlistAnalysis); await historyService.appendLog( jobId, 'SYSTEM', `${decisionContext.detailText} (RAW). Kandidaten: ${playlistFiles.join(', ') || 'keine'}.` ); if (recommendationFile) { await historyService.appendLog( jobId, 'SYSTEM', `Playlist-Empfehlung: ${recommendationFile}` ); } } const hasEncodableTitle = Boolean(review.encodeInputPath && review.encodeInputTitleId); const readyStatusText = review.titleSelectionRequired ? 'Titel-/Spurprüfung fertig - Titel per Checkbox wählen' : (hasEncodableTitle ? 'Titel-/Spurprüfung fertig - Auswahl bestätigen, dann Encode manuell starten' : 'Titel-/Spurprüfung fertig - kein Titel erfüllt MIN_LENGTH_MINUTES'); if (this.isPrimaryJob(jobId)) { const mergedPluginExecutionForReady = getMergedReviewPluginExecution(); await this.setState('READY_TO_ENCODE', { activeJobId: jobId, progress: 0, eta: null, statusText: readyStatusText, context: { ...(this.snapshot.context || {}), jobId, rawPath, inputPath: review.encodeInputPath || null, hasEncodableTitle, reviewConfirmed: false, mode, mediaProfile, sourceJobId: options.sourceJobId || null, mediaInfoReview: review, selectedMetadata, playlistAnalysis: playlistAnalysis || null, playlistDecisionRequired, playlistCandidates, selectedPlaylist: resolvedPlaylistId || null, selectedTitleId: selectedTitleForReview, ...(mergedPluginExecutionForReady ? { pluginExecution: mergedPluginExecutionForReady } : {}) } }); } else { await this.updateProgress('READY_TO_ENCODE', 0, null, readyStatusText, jobId, { contextPatch: { jobId, rawPath, inputPath: review.encodeInputPath || null, hasEncodableTitle, reviewConfirmed: false, mode, mediaProfile, sourceJobId: options.sourceJobId || null, mediaInfoReview: review, selectedMetadata, playlistAnalysis: playlistAnalysis || null, playlistDecisionRequired, playlistCandidates, selectedPlaylist: resolvedPlaylistId || null, selectedTitleId: selectedTitleForReview } }); } void this.notifyPushover('metadata_ready', { title: 'Ripster - RAW geprüft', message: `Job #${jobId}: bereit zum manuellen Encode-Start` }); return review; } async runReviewForRawJob(jobId, rawPath, options = {}) { const useBackupReview = hasBluRayBackupStructure(rawPath); logger.info('review:dispatch', { jobId, rawPath, mode: options?.mode || 'rip', useBackupReview }); if (useBackupReview) { return this.runBackupTrackReviewForJob(jobId, rawPath, options); } return this.runMediainfoReviewForJob(jobId, rawPath, options); } async selectMetadata({ jobId, title, year, imdbId, poster, fromOmdb = null, selectedPlaylist = null, selectedHandBrakeTitleId = null }) { this.ensureNotBusy('selectMetadata', jobId); logger.info('metadata:selected', { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist, selectedHandBrakeTitleId }); const job = await historyService.getJobById(jobId); if (!job) { const error = new Error(`Job ${jobId} nicht gefunden.`); error.statusCode = 404; throw error; } const mkInfo = this.safeParseJson(job.makemkv_info_json); const encodePlanForProfile = this.safeParseJson(job.encode_plan_json); const mediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: mkInfo, encodePlan: encodePlanForProfile }); if (mediaProfile === 'converter') { const converterPlan = this.safeParseJson(job.encode_plan_json) || {}; const converterInputPath = String(converterPlan?.inputPath || job?.raw_path || '').trim(); if (!converterInputPath || !fs.existsSync(converterInputPath)) { const error = new Error(`Converter-Eingabedatei nicht gefunden: ${converterInputPath || '-'}`); error.statusCode = 404; throw error; } const normalizedSelectedPlaylist = normalizePlaylistId(selectedPlaylist); const normalizedSelectedHandBrakeTitleId = normalizeReviewTitleId(selectedHandBrakeTitleId); const waitingForDecision = ( String(job.status || '').trim().toUpperCase() === 'WAITING_FOR_USER_DECISION' || String(job.last_state || '').trim().toUpperCase() === 'WAITING_FOR_USER_DECISION' ); const hasExplicitMetadataPayload = ( title !== undefined || year !== undefined || imdbId !== undefined || poster !== undefined || (fromOmdb !== null && fromOmdb !== undefined) ); if ((normalizedSelectedPlaylist || normalizedSelectedHandBrakeTitleId) && waitingForDecision && !hasExplicitMetadataPayload) { await historyService.updateJob(jobId, { status: 'MEDIAINFO_CHECK', last_state: 'MEDIAINFO_CHECK', error_message: null, mediainfo_info_json: null, encode_input_path: null, encode_review_confirmed: 0 }); await historyService.appendLog( jobId, 'USER_ACTION', normalizedSelectedHandBrakeTitleId ? `Converter Titel-Auswahl gesetzt: -t ${normalizedSelectedHandBrakeTitleId}.` : `Converter Playlist-Auswahl gesetzt: ${toPlaylistFile(normalizedSelectedPlaylist) || normalizedSelectedPlaylist}.` ); try { await this.runMediainfoReviewForJob(jobId, converterInputPath, { mode: 'converter', mediaProfile: 'converter', selectedPlaylist: normalizedSelectedPlaylist || null, selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId || null, previousEncodePlan: converterPlan }); } catch (error) { logger.error('metadata:converter:selection:review-failed', { jobId, selectedPlaylist: normalizedSelectedPlaylist || null, selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId || null, error: errorToMeta(error) }); await this.failJob(jobId, 'MEDIAINFO_CHECK', error); throw error; } return historyService.getJobById(jobId); } const hasTitleInput = title !== undefined && title !== null && String(title).trim().length > 0; const effectiveTitle = hasTitleInput ? String(title).trim() : (job.title || job.detected_title || path.basename(converterInputPath, path.extname(converterInputPath)) || 'Converter Job'); const hasYearInput = year !== undefined && year !== null && String(year).trim() !== ''; let effectiveYear = job.year ?? null; if (hasYearInput) { const parsedYear = Number(year); effectiveYear = Number.isNaN(parsedYear) ? null : parsedYear; } const effectiveImdbId = imdbId === undefined ? (job.imdb_id || null) : (imdbId || null); const selectedFromOmdb = fromOmdb === null || fromOmdb === undefined ? Number(job.selected_from_omdb || 0) : (fromOmdb ? 1 : 0); const posterValue = poster === undefined ? (job.poster_url || null) : (poster || null); let omdbJsonValue = job.omdb_json || null; if (fromOmdb && effectiveImdbId) { try { const omdbFull = await omdbService.fetchByImdbId(effectiveImdbId); if (omdbFull?.raw) { omdbJsonValue = JSON.stringify(omdbFull.raw); } } catch (omdbErr) { logger.warn('metadata:converter:omdb-fetch-failed', { jobId, imdbId: effectiveImdbId, error: errorToMeta(omdbErr) }); } } const nextEncodePlan = { ...converterPlan, mediaProfile: 'converter', converterMediaType: String(converterPlan?.converterMediaType || '').trim().toLowerCase() || 'video', inputPath: converterInputPath, metadata: { ...(converterPlan?.metadata && typeof converterPlan.metadata === 'object' ? converterPlan.metadata : {}), title: effectiveTitle, year: effectiveYear, imdbId: effectiveImdbId, poster: posterValue }, reviewConfirmed: false }; await historyService.updateJob(jobId, { title: effectiveTitle, year: effectiveYear, imdb_id: effectiveImdbId, poster_url: posterValue, selected_from_omdb: selectedFromOmdb, omdb_json: omdbJsonValue, status: 'READY_TO_START', last_state: 'READY_TO_START', raw_path: converterInputPath, media_type: 'converter', encode_plan_json: JSON.stringify(nextEncodePlan), encode_review_confirmed: 0, error_message: null }); if (posterValue && !thumbnailService.isLocalUrl(posterValue)) { historyService.queuePosterCache(jobId, posterValue, { source: 'Poster', logFailures: true }); } await historyService.appendLog( jobId, 'SYSTEM', 'Converter-Metadaten übernommen. Starte Mediainfo-Prüfung mit Titel-/Spurauswahl.' ); await this.startPreparedJob(jobId); return historyService.getJobById(jobId); } const normalizedSelectedPlaylist = normalizePlaylistId(selectedPlaylist); const normalizedSelectedHandBrakeTitleId = normalizeReviewTitleId(selectedHandBrakeTitleId); const waitingForPlaylistSelection = ( job.status === 'WAITING_FOR_USER_DECISION' || job.last_state === 'WAITING_FOR_USER_DECISION' ); const hasExplicitMetadataPayload = ( title !== undefined || year !== undefined || imdbId !== undefined || poster !== undefined || (fromOmdb !== null && fromOmdb !== undefined) ); if (normalizedSelectedHandBrakeTitleId && waitingForPlaylistSelection && job.raw_path && !hasExplicitMetadataPayload && !normalizedSelectedPlaylist) { const currentMkInfo = mkInfo; const currentAnalyzeContext = currentMkInfo?.analyzeContext || {}; const updatedMkInfo = this.withAnalyzeContextMediaProfile({ ...currentMkInfo, analyzeContext: { ...currentAnalyzeContext, selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId } }, mediaProfile); await historyService.updateJob(jobId, { status: 'MEDIAINFO_CHECK', last_state: 'MEDIAINFO_CHECK', error_message: null, makemkv_info_json: JSON.stringify(updatedMkInfo), mediainfo_info_json: null, encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0 }); await historyService.appendLog( jobId, 'USER_ACTION', `DVD Titel-Auswahl gesetzt: -t ${normalizedSelectedHandBrakeTitleId}.` ); try { await this.runMediainfoReviewForJob(jobId, job.raw_path, { mode: 'rip', mediaProfile, selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId }); } catch (error) { logger.error('metadata:handbrake-title-selection:review-failed', { jobId, selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId, error: errorToMeta(error) }); await this.failJob(jobId, 'MEDIAINFO_CHECK', error); throw error; } return historyService.getJobById(jobId); } if (normalizedSelectedPlaylist && waitingForPlaylistSelection && job.raw_path && !hasExplicitMetadataPayload) { const currentMkInfo = mkInfo; const currentAnalyzeContext = currentMkInfo?.analyzeContext || {}; const currentPlaylistAnalysis = currentAnalyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null; const selectedTitleId = pickTitleIdForPlaylist(currentPlaylistAnalysis, normalizedSelectedPlaylist); const updatedMkInfo = this.withAnalyzeContextMediaProfile({ ...currentMkInfo, analyzeContext: { ...currentAnalyzeContext, playlistAnalysis: currentPlaylistAnalysis || null, playlistDecisionRequired: Boolean(currentPlaylistAnalysis?.manualDecisionRequired), selectedPlaylist: normalizedSelectedPlaylist, selectedTitleId: selectedTitleId ?? null } }, mediaProfile); await historyService.updateJob(jobId, { status: 'MEDIAINFO_CHECK', last_state: 'MEDIAINFO_CHECK', error_message: null, makemkv_info_json: JSON.stringify(updatedMkInfo), mediainfo_info_json: null, encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0 }); await historyService.appendLog( jobId, 'USER_ACTION', `Playlist-Auswahl gesetzt: ${toPlaylistFile(normalizedSelectedPlaylist) || normalizedSelectedPlaylist}.` ); try { await this.runBackupTrackReviewForJob(jobId, job.raw_path, { mode: 'rip', selectedPlaylist: normalizedSelectedPlaylist, selectedTitleId: selectedTitleId ?? null, mediaProfile }); } catch (error) { logger.error('metadata:playlist-selection:review-failed', { jobId, selectedPlaylist: normalizedSelectedPlaylist, selectedTitleId: selectedTitleId ?? null, error: errorToMeta(error) }); await this.failJob(jobId, 'MEDIAINFO_CHECK', error); throw error; } return historyService.getJobById(jobId); } const hasTitleInput = title !== undefined && title !== null && String(title).trim().length > 0; const effectiveTitle = hasTitleInput ? String(title).trim() : (job.title || job.detected_title || 'Unknown Title'); const hasYearInput = year !== undefined && year !== null && String(year).trim() !== ''; let effectiveYear = job.year ?? null; if (hasYearInput) { const parsedYear = Number(year); effectiveYear = Number.isNaN(parsedYear) ? null : parsedYear; } const effectiveImdbId = imdbId === undefined ? (job.imdb_id || null) : (imdbId || null); const selectedFromOmdb = fromOmdb === null || fromOmdb === undefined ? Number(job.selected_from_omdb || 0) : (fromOmdb ? 1 : 0); const posterValue = poster === undefined ? (job.poster_url || null) : (poster || null); // Fetch full OMDb details when selecting from OMDb with a valid IMDb ID. let omdbJsonValue = job.omdb_json || null; if (fromOmdb && effectiveImdbId) { try { const omdbFull = await omdbService.fetchByImdbId(effectiveImdbId); if (omdbFull?.raw) { omdbJsonValue = JSON.stringify(omdbFull.raw); } } catch (omdbErr) { logger.warn('metadata:omdb-fetch-failed', { jobId, imdbId: effectiveImdbId, error: errorToMeta(omdbErr) }); } } const selectedMetadata = { title: effectiveTitle, year: effectiveYear, imdbId: effectiveImdbId, poster: posterValue }; const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' ? 'backup' : 'mkv'; const isBackupMode = ripMode === 'backup'; const metadataBase = buildRawMetadataBase({ title: selectedMetadata.title || job.detected_title || null, year: selectedMetadata.year || null }, jobId); const existingRawPath = findExistingRawDirectory(settings.raw_dir, metadataBase); let updatedRawPath = existingRawPath || null; if (existingRawPath) { const existingRawState = resolveRawFolderStateFromPath(existingRawPath); const renameState = existingRawState === RAW_FOLDER_STATES.INCOMPLETE ? RAW_FOLDER_STATES.INCOMPLETE : RAW_FOLDER_STATES.RIP_COMPLETE; const renamedDirName = buildRawDirName(metadataBase, jobId, { state: renameState }); const renamedRawPath = path.join(settings.raw_dir, renamedDirName); if (existingRawPath !== renamedRawPath && !fs.existsSync(renamedRawPath)) { try { fs.renameSync(existingRawPath, renamedRawPath); updatedRawPath = renamedRawPath; await historyService.updateRawPathByOldPath(existingRawPath, renamedRawPath); logger.info('metadata:raw-dir-renamed', { from: existingRawPath, to: renamedRawPath, jobId, state: renameState }); } catch (renameError) { logger.warn('metadata:raw-dir-rename-failed', { existingRawPath, renamedRawPath, error: errorToMeta(renameError) }); } } } const basePlaylistDecision = this.resolvePlaylistDecisionForJob(jobId, job, selectedPlaylist); const playlistDecision = isBackupMode ? { ...basePlaylistDecision, playlistAnalysis: null, playlistDecisionRequired: false, candidatePlaylists: [], selectedPlaylist: null, selectedTitleId: null, recommendation: null } : basePlaylistDecision; const requiresManualPlaylistSelection = Boolean( playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null ); const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; const updatedMakemkvInfo = this.withAnalyzeContextMediaProfile({ ...mkInfo, analyzeContext: { ...(mkInfo?.analyzeContext || {}), playlistAnalysis: playlistDecision.playlistAnalysis || mkInfo?.analyzeContext?.playlistAnalysis || null, playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired), selectedPlaylist: playlistDecision.selectedPlaylist || null, selectedTitleId: playlistDecision.selectedTitleId ?? null } }, mediaProfile); await historyService.updateJob(jobId, { title: effectiveTitle, year: effectiveYear, imdb_id: effectiveImdbId, poster_url: posterValue, selected_from_omdb: selectedFromOmdb, omdb_json: omdbJsonValue, status: nextStatus, last_state: nextStatus, raw_path: updatedRawPath, makemkv_info_json: JSON.stringify(updatedMakemkvInfo) }); // Bild in Cache laden (async, blockiert nicht) if (posterValue && !thumbnailService.isLocalUrl(posterValue)) { historyService.queuePosterCache(jobId, posterValue, { source: 'Poster', logFailures: true }); } const runningJobs = await historyService.getRunningJobs(); const foreignRunningJobs = runningJobs.filter((item) => Number(item?.id) !== Number(jobId)); // Only block the state transition if a foreign job requires active user interaction. // Autonomous jobs (ENCODING, RIPPING, READY_TO_ENCODE) must not prevent this job // from advancing through its own pipeline steps. const PIPELINE_INTERACTIVE_STATES = new Set([ 'ANALYZING', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'MEDIAINFO_CHECK' ]); const keepCurrentPipelineSession = foreignRunningJobs.some( (item) => PIPELINE_INTERACTIVE_STATES.has(String(item?.status || '').toUpperCase()) ); if (existingRawPath) { await historyService.appendLog( jobId, 'SYSTEM', `Vorhandenes RAW-Verzeichnis erkannt: ${existingRawPath}` ); } else { await historyService.appendLog( jobId, 'SYSTEM', `Kein bestehendes RAW-Verzeichnis zu den Metadaten gefunden (${metadataBase}).` ); } if (!keepCurrentPipelineSession) { await this.setState(nextStatus, { activeJobId: jobId, progress: 0, eta: null, statusText: requiresManualPlaylistSelection ? 'waiting_for_manual_playlist_selection' : (existingRawPath ? 'Metadaten übernommen - vorhandenes RAW erkannt' : 'Metadaten übernommen - bereit zum Start'), context: { ...(this.snapshot.context || {}), jobId, rawPath: updatedRawPath, mediaProfile, selectedMetadata, playlistAnalysis: playlistDecision.playlistAnalysis || null, playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired), playlistCandidates: playlistDecision.candidatePlaylists, selectedPlaylist: playlistDecision.selectedPlaylist || null, selectedTitleId: playlistDecision.selectedTitleId ?? null, waitingForManualPlaylistSelection: requiresManualPlaylistSelection, manualDecisionState: requiresManualPlaylistSelection ? 'waiting_for_manual_playlist_selection' : null } }); } else { await historyService.appendLog( jobId, 'SYSTEM', `Metadaten übernommen. Aktive Session bleibt bei laufendem Job #${foreignRunningJobs.map((item) => item.id).join(',')}.` ); } if (requiresManualPlaylistSelection) { const playlistFiles = playlistDecision.candidatePlaylists .map((item) => item.playlistFile) .filter(Boolean); const recommendationFile = toPlaylistFile(playlistDecision.recommendation?.playlistId); const decisionContext = describePlaylistManualDecision(playlistDecision.playlistAnalysis); await historyService.appendLog( jobId, 'SYSTEM', `${decisionContext.detailText} Status=waiting_for_manual_playlist_selection. Kandidaten: ${playlistFiles.join(', ') || 'keine'}.` ); if (recommendationFile) { await historyService.appendLog(jobId, 'SYSTEM', `Empfehlung laut MakeMKV-TINFO-Analyse: ${recommendationFile}`); } await historyService.appendLog( jobId, 'SYSTEM', 'Bitte selected_playlist setzen (z.B. 00800 oder 00800.mpls), bevor Backup/Encoding gestartet wird.' ); return historyService.getJobById(jobId); } if (playlistDecision.playlistDecisionRequired && playlistDecision.selectedPlaylist) { await historyService.appendLog( jobId, 'SYSTEM', `Manuelle Playlist-Auswahl übernommen: ${toPlaylistFile(playlistDecision.selectedPlaylist) || playlistDecision.selectedPlaylist}` ); } if (existingRawPath) { await historyService.appendLog( jobId, 'SYSTEM', 'Metadaten übernommen. Starte automatische Spur-Ermittlung (Mediainfo) mit vorhandenem RAW.' ); const startResult = await this.startPreparedJob(jobId); logger.info('metadata:auto-track-review-started', { jobId, stage: startResult?.stage || null, reusedRaw: Boolean(startResult?.reusedRaw), selectedPlaylist: playlistDecision.selectedPlaylist || null, selectedTitleId: playlistDecision.selectedTitleId ?? null }); return historyService.getJobById(jobId); } await historyService.appendLog( jobId, 'SYSTEM', 'Metadaten übernommen. Starte Backup/Rip automatisch.' ); const startResult = await this.startPreparedJob(jobId); logger.info('metadata:auto-start', { jobId, stage: startResult?.stage || null, reusedRaw: Boolean(startResult?.reusedRaw), selectedPlaylist: playlistDecision.selectedPlaylist || null, selectedTitleId: playlistDecision.selectedTitleId ?? null }); return historyService.getJobById(jobId); } async startPreparedJob(jobId, options = {}) { const resolvedPreparedJob = await this.resolveExistingJobForAction(jobId, 'start_prepared'); jobId = resolvedPreparedJob.resolvedJobId; const optionPreloadedJob = options?.preloadedJob && Number(options.preloadedJob?.id) === Number(jobId) ? options.preloadedJob : null; const immediate = Boolean(options?.immediate); if (!immediate) { const preloadedJob = optionPreloadedJob || resolvedPreparedJob.job || await historyService.getJobById(jobId); if (!preloadedJob.title && !preloadedJob.detected_title) { const error = new Error('Start nicht möglich: keine Metadaten vorhanden.'); error.statusCode = 400; throw error; } const preloadedMakemkvInfo = this.safeParseJson(preloadedJob.makemkv_info_json); const preloadedEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json); const preloadedMediaProfile = this.resolveMediaProfileForJob(preloadedJob, { makemkvInfo: preloadedMakemkvInfo, encodePlan: preloadedEncodePlan }); if (preloadedMediaProfile === 'audiobook') { return this.startAudiobookEncode(jobId, { ...options, preloadedJob }); } if (preloadedMediaProfile === 'converter') { const preloadedPlan = this.safeParseJson(preloadedJob.encode_plan_json) || {}; const converterMediaType = String(preloadedPlan?.converterMediaType || '').trim().toLowerCase(); if (converterMediaType === 'audio') { return this.startConverterEncode(jobId, { ...options, preloadedJob }); } return this.startPreparedConverterVideoJob(jobId, { ...options, preloadedJob }); } const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE'; if (isReadyToEncode) { // Check whether this confirmed job will rip first (pre_rip mode) or encode directly. // Pre-rip jobs bypass the encode queue because the next step is a rip, not an encode. const jobEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json); const jobMode = String(jobEncodePlan?.mode || '').trim().toLowerCase(); let willRipFirst = jobMode === 'pre_rip' || Boolean(jobEncodePlan?.preRip); if (willRipFirst) { const rawReuse = await this.resolveUsableRawInputForReadyJob(jobId, preloadedJob, jobEncodePlan); if (rawReuse.hasUsableRawInput) { willRipFirst = false; } } if (willRipFirst) { return this.startPreparedJob(jobId, { ...options, immediate: true }); } return this.enqueueOrStartAction( QUEUE_ACTIONS.START_PREPARED, jobId, () => this.startPreparedJob(jobId, { ...options, immediate: true }) ); } let hasUsableRawInput = false; if (preloadedJob.raw_path) { try { if (fs.existsSync(preloadedJob.raw_path)) { const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, preloadedJob); hasUsableRawInput = Boolean(findPreferredRawInput(preloadedJob.raw_path, { playlistAnalysis: playlistDecision.playlistAnalysis, selectedPlaylistId: playlistDecision.selectedPlaylist })); } } catch (_error) { hasUsableRawInput = false; } } if (!hasUsableRawInput) { // No raw input yet → will rip from disc. Bypass the encode queue entirely. return this.startPreparedJob(jobId, { ...options, immediate: true }); } return this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob }); } const job = optionPreloadedJob || resolvedPreparedJob.job || await historyService.getJobById(jobId); const jobMakemkvInfo = this.safeParseJson(job.makemkv_info_json); const jobEncodePlan = this.safeParseJson(job.encode_plan_json); const jobMediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: jobMakemkvInfo, encodePlan: jobEncodePlan }); if (jobMediaProfile === 'audiobook') { return this.startAudiobookEncode(jobId, { ...options, immediate: true, preloadedJob: job }); } if (jobMediaProfile === 'converter') { const converterPlan = this.safeParseJson(job.encode_plan_json) || {}; const converterMediaType = String(converterPlan?.converterMediaType || '').trim().toLowerCase(); if (converterMediaType === 'audio') { return this.startConverterEncode(jobId, { ...options, immediate: true, preloadedJob: job }); } return this.startPreparedConverterVideoJob(jobId, { ...options, immediate: true, preloadedJob: job }); } this.ensureNotBusy('startPreparedJob', jobId); logger.info('startPreparedJob:requested', { jobId }); this.cancelRequestedByJob.delete(Number(jobId)); if (!job.title && !job.detected_title) { const error = new Error('Start nicht möglich: keine Metadaten vorhanden.'); error.statusCode = 400; throw error; } await historyService.resetProcessLog(jobId); const encodePlanForReadyState = this.safeParseJson(job.encode_plan_json); const readyMode = String(encodePlanForReadyState?.mode || '').trim().toLowerCase(); const isPreRipReadyState = readyMode === 'pre_rip' || Boolean(encodePlanForReadyState?.preRip); const isReadyToEncode = job.status === 'READY_TO_ENCODE' || job.last_state === 'READY_TO_ENCODE'; if (isReadyToEncode) { if (!Number(job.encode_review_confirmed || 0)) { const error = new Error('Encode-Start nicht erlaubt: Mediainfo-Prüfung muss zuerst bestätigt werden.'); error.statusCode = 409; throw error; } if (isPreRipReadyState) { const rawReuse = await this.resolveUsableRawInputForReadyJob(jobId, job, encodePlanForReadyState); if (rawReuse.hasUsableRawInput) { const updatePayload = { status: 'ENCODING', last_state: 'ENCODING', error_message: null, end_time: null }; if ( rawReuse.resolvedRawPath && normalizeComparablePath(rawReuse.resolvedRawPath) !== normalizeComparablePath(job.raw_path) ) { updatePayload.raw_path = rawReuse.resolvedRawPath; } if ( rawReuse.inputPath && normalizeComparablePath(rawReuse.inputPath) !== normalizeComparablePath(job.encode_input_path) ) { updatePayload.encode_input_path = rawReuse.inputPath; } await historyService.updateJob(jobId, updatePayload); await historyService.appendLog( jobId, 'SYSTEM', `Pre-Rip Auswahl erkannt, nutzbares RAW gefunden (${rawReuse.resolvedRawPath}). Starte Encode direkt aus RAW; Laufwerk wird nicht angesprochen.` ); this.startEncodingFromPrepared(jobId).catch((error) => { logger.error('startPreparedJob:encode-background-from-prerip-raw-failed', { jobId, error: errorToMeta(error) }); if (error?.jobAlreadyFailed) { return; } this.failJob(jobId, 'ENCODING', error).catch((failError) => { logger.error('startPreparedJob:encode-background-from-prerip-raw-failJob-failed', { jobId, error: errorToMeta(failError) }); }); }); return { started: true, stage: 'ENCODING', reusedRaw: true }; } await historyService.updateJob(jobId, { status: 'RIPPING', last_state: 'RIPPING', error_message: null, end_time: null }); this.startRipEncode(jobId).catch((error) => { logger.error('startPreparedJob:rip-background-failed', { jobId, error: errorToMeta(error) }); }); return { started: true, stage: 'RIPPING' }; } await historyService.updateJob(jobId, { status: 'ENCODING', last_state: 'ENCODING', error_message: null, end_time: null }); this.startEncodingFromPrepared(jobId).catch((error) => { logger.error('startPreparedJob:encode-background-failed', { jobId, error: errorToMeta(error) }); if (error?.jobAlreadyFailed) { return; } this.failJob(jobId, 'ENCODING', error).catch((failError) => { logger.error('startPreparedJob:encode-background-failJob-failed', { jobId, error: errorToMeta(failError) }); }); }); return { started: true, stage: 'ENCODING' }; } const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); const mediaProfile = this.resolveMediaProfileForJob(job, { encodePlan: encodePlanForReadyState }); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' ? 'backup' : 'mkv'; const enforcePlaylistBeforeStart = ripMode !== 'backup'; if (enforcePlaylistBeforeStart && playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null) { const error = new Error( 'Start nicht möglich: waiting_for_manual_playlist_selection aktiv. Bitte zuerst selected_playlist setzen.' ); error.statusCode = 409; throw error; } let existingRawInput = null; if (job.raw_path) { try { if (fs.existsSync(job.raw_path)) { existingRawInput = findPreferredRawInput(job.raw_path, { playlistAnalysis: playlistDecision.playlistAnalysis, selectedPlaylistId: playlistDecision.selectedPlaylist }); } } catch (error) { logger.warn('startPreparedJob:existing-raw-check-failed', { jobId, rawPath: job.raw_path, error: errorToMeta(error) }); } } // An Incomplete_ raw folder means the rip never finished — skip it and re-rip. if (existingRawInput && resolveRawFolderStateFromPath(job.raw_path) === RAW_FOLDER_STATES.INCOMPLETE) { logger.info('startPreparedJob:raw-incomplete-skip', { jobId, rawPath: job.raw_path }); existingRawInput = null; } if (existingRawInput) { await historyService.updateJob(jobId, { status: 'MEDIAINFO_CHECK', last_state: 'MEDIAINFO_CHECK', start_time: nowIso(), end_time: null, error_message: null, output_path: null, handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0 }); await historyService.appendLog( jobId, 'SYSTEM', `Vorhandenes RAW wird verwendet. Starte Titel-/Spurprüfung: ${job.raw_path}` ); this.runReviewForRawJob(jobId, job.raw_path, { mode: 'rip', mediaProfile }).catch((error) => { logger.error('startPreparedJob:review-background-failed', { jobId, error: errorToMeta(error) }); this.failJob(jobId, 'MEDIAINFO_CHECK', error).catch((failError) => { logger.error('startPreparedJob:review-background-failJob-failed', { jobId, error: errorToMeta(failError) }); }); }); return { started: true, stage: 'MEDIAINFO_CHECK', reusedRaw: true, rawPath: job.raw_path }; } if (job.raw_path) { await historyService.appendLog( jobId, 'SYSTEM', `Kein verwertbares RAW unter ${job.raw_path} gefunden. Starte neuen Rip.` ); } await historyService.updateJob(jobId, { status: 'RIPPING', last_state: 'RIPPING', error_message: null, end_time: null, handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0, output_path: null }); this.startRipEncode(jobId).catch((error) => { logger.error('startPreparedJob:background-failed', { jobId, error: errorToMeta(error) }); }); return { started: true, stage: 'RIPPING' }; } async startPreparedConverterVideoJob(jobId, options = {}) { const immediate = Boolean(options?.immediate); const preloadedJob = options?.preloadedJob && Number(options.preloadedJob?.id) === Number(jobId) ? options.preloadedJob : null; const job = preloadedJob || await historyService.getJobById(jobId); if (!job) { const error = new Error(`Job ${jobId} nicht gefunden.`); error.statusCode = 404; throw error; } const encodePlan = this.safeParseJson(job.encode_plan_json) || {}; const statusUpper = String(job.status || '').trim().toUpperCase(); const lastStateUpper = String(job.last_state || '').trim().toUpperCase(); const isReadyToEncode = statusUpper === 'READY_TO_ENCODE' || lastStateUpper === 'READY_TO_ENCODE'; if (isReadyToEncode) { if (!Number(job.encode_review_confirmed || 0)) { const error = new Error('Encode-Start nicht erlaubt: Mediainfo-Prüfung muss zuerst bestätigt werden.'); error.statusCode = 409; throw error; } if (!immediate) { return this.enqueueOrStartAction( QUEUE_ACTIONS.START_PREPARED, jobId, () => this.startPreparedConverterVideoJob(jobId, { ...options, immediate: true, preloadedJob: job }) ); } await historyService.updateJob(jobId, { status: 'ENCODING', last_state: 'ENCODING', error_message: null, end_time: null }); this.startConverterEncode(jobId, { immediate: true, preloadedJob: job }).catch((error) => { logger.error('startPreparedConverterVideoJob:encode-background-failed', { jobId, error: errorToMeta(error) }); if (error?.jobAlreadyFailed) { return; } this.failJob(jobId, 'ENCODING', error).catch((failError) => { logger.error('startPreparedConverterVideoJob:encode-background-failJob-failed', { jobId, error: errorToMeta(failError) }); }); }); return { started: true, stage: 'ENCODING' }; } if (statusUpper === 'METADATA_SELECTION') { await historyService.appendLog( jobId, 'SYSTEM', 'Metadaten-Auswahl übersprungen. Starte Review ohne OMDb-Zuordnung.' ); } if (statusUpper === 'WAITING_FOR_USER_DECISION') { const error = new Error('Start nicht möglich: Bitte zuerst die erforderliche Titel/Playlist-Auswahl treffen.'); error.statusCode = 409; throw error; } const inputPath = String(encodePlan?.inputPath || job?.raw_path || '').trim(); if (!inputPath || !fs.existsSync(inputPath)) { const error = new Error(`Mediainfo-Prüfung nicht möglich: Eingabedatei fehlt (${inputPath || '-'})`); error.statusCode = 400; throw error; } await historyService.updateJob(jobId, { status: 'MEDIAINFO_CHECK', last_state: 'MEDIAINFO_CHECK', start_time: nowIso(), end_time: null, error_message: null, output_path: null, handbrake_info_json: null, mediainfo_info_json: null, encode_input_path: null, encode_review_confirmed: 0 }); await historyService.appendLog( jobId, 'SYSTEM', `Converter-Review gestartet (Video): ${path.basename(inputPath)}` ); this.runMediainfoReviewForJob(jobId, inputPath, { mode: 'converter', mediaProfile: 'converter', previousEncodePlan: encodePlan }).catch((error) => { logger.error('startPreparedConverterVideoJob:review-background-failed', { jobId, error: errorToMeta(error) }); this.failJob(jobId, 'MEDIAINFO_CHECK', error).catch((failError) => { logger.error('startPreparedConverterVideoJob:review-background-failJob-failed', { jobId, error: errorToMeta(failError) }); }); }); return { started: true, stage: 'MEDIAINFO_CHECK', reusedRaw: true, rawPath: inputPath }; } async confirmEncodeReview(jobId, options = {}) { this.ensureNotBusy('confirmEncodeReview', jobId); const skipPipelineStateUpdate = Boolean(options?.skipPipelineStateUpdate); logger.info('confirmEncodeReview:requested', { jobId, selectedEncodeTitleId: options?.selectedEncodeTitleId ?? null, selectedTrackSelectionProvided: Boolean(options?.selectedTrackSelection), skipPipelineStateUpdate, selectedHandBrakePreset: options?.selectedHandBrakePreset ?? null, selectedPostEncodeScriptIdsCount: Array.isArray(options?.selectedPostEncodeScriptIds) ? options.selectedPostEncodeScriptIds.length : 0 }); let job = await historyService.getJobById(jobId); if (!job) { const error = new Error(`Job ${jobId} nicht gefunden.`); error.statusCode = 404; throw error; } if (job.status !== 'READY_TO_ENCODE' && job.last_state !== 'READY_TO_ENCODE') { const currentStatus = String(job.status || job.last_state || '').trim().toUpperCase(); const recoverableStatus = currentStatus === 'ERROR' || currentStatus === 'CANCELLED'; const recoveryPlan = this.safeParseJson(job.encode_plan_json); const recoveryMode = String(recoveryPlan?.mode || '').trim().toLowerCase(); const recoveryPreRip = recoveryMode === 'pre_rip' || Boolean(recoveryPlan?.preRip); const recoveryHasInput = recoveryPreRip ? Boolean(recoveryPlan?.encodeInputTitleId) : Boolean(job?.encode_input_path || recoveryPlan?.encodeInputPath || job?.raw_path); const recoveryHasConfirmedPlan = Boolean( recoveryPlan && Array.isArray(recoveryPlan?.titles) && recoveryPlan.titles.length > 0 && (Number(job?.encode_review_confirmed || 0) === 1 || Boolean(recoveryPlan?.reviewConfirmed)) && recoveryHasInput ); if (recoverableStatus && recoveryHasConfirmedPlan) { await historyService.appendLog( jobId, 'SYSTEM', `Bestätigung angefordert obwohl Status ${currentStatus}. Letzte Encode-Auswahl wird automatisch geladen.` ); await this.restartEncodeWithLastSettings(jobId, { immediate: true, triggerReason: 'confirm_auto_prepare' }); job = await historyService.getJobById(jobId); } } if (!job || (job.status !== 'READY_TO_ENCODE' && job.last_state !== 'READY_TO_ENCODE')) { const error = new Error('Bestätigung nicht möglich: Job ist nicht im Status READY_TO_ENCODE.'); error.statusCode = 409; throw error; } const encodePlan = this.safeParseJson(job.encode_plan_json); if (!encodePlan || !Array.isArray(encodePlan.titles)) { const error = new Error('Bestätigung nicht möglich: keine Mediainfo-Auswertung vorhanden.'); error.statusCode = 400; throw error; } const selectedEncodeTitleId = options?.selectedEncodeTitleId ?? null; const planWithSelectionResult = applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId); let planForConfirm = planWithSelectionResult.plan; const trackSelectionResult = applyManualTrackSelectionToPlan( planForConfirm, options?.selectedTrackSelection || null ); if (Array.isArray(trackSelectionResult.subtitleSelectionValidationErrors) && trackSelectionResult.subtitleSelectionValidationErrors.length > 0) { const error = new Error( `Subtitle-Auswahl ungültig: ${trackSelectionResult.subtitleSelectionValidationErrors.join(' | ')}` ); error.statusCode = 400; throw error; } planForConfirm = trackSelectionResult.plan; const hasExplicitPostScriptSelection = options?.selectedPostEncodeScriptIds !== undefined; const selectedPostEncodeScriptIds = hasExplicitPostScriptSelection ? normalizeScriptIdList(options?.selectedPostEncodeScriptIds || []) : normalizeScriptIdList(planForConfirm?.postEncodeScriptIds || encodePlan?.postEncodeScriptIds || []); const selectedPostEncodeScripts = await scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, { strict: true }); const hasExplicitPreScriptSelection = options?.selectedPreEncodeScriptIds !== undefined; const selectedPreEncodeScriptIds = hasExplicitPreScriptSelection ? normalizeScriptIdList(options?.selectedPreEncodeScriptIds || []) : normalizeScriptIdList(planForConfirm?.preEncodeScriptIds || encodePlan?.preEncodeScriptIds || []); const selectedPreEncodeScripts = await scriptService.resolveScriptsByIds(selectedPreEncodeScriptIds, { strict: true }); const hasExplicitPostChainSelection = options?.selectedPostEncodeChainIds !== undefined; const selectedPostEncodeChainIds = hasExplicitPostChainSelection ? normalizeChainIdList(options?.selectedPostEncodeChainIds || []) : normalizeChainIdList(planForConfirm?.postEncodeChainIds || encodePlan?.postEncodeChainIds || []); const hasExplicitPreChainSelection = options?.selectedPreEncodeChainIds !== undefined; const selectedPreEncodeChainIds = hasExplicitPreChainSelection ? normalizeChainIdList(options?.selectedPreEncodeChainIds || []) : normalizeChainIdList(planForConfirm?.preEncodeChainIds || encodePlan?.preEncodeChainIds || []); const confirmedMode = String(planForConfirm?.mode || encodePlan?.mode || 'rip').trim().toLowerCase(); const isPreRipMode = confirmedMode === 'pre_rip' || Boolean(planForConfirm?.preRip); if (planForConfirm?.playlistDecisionRequired && !planForConfirm?.encodeInputPath && !planForConfirm?.encodeInputTitleId) { const error = new Error('Bestätigung nicht möglich: Bitte zuerst einen Titel per Checkbox auswählen.'); error.statusCode = 400; throw error; } // Resolve user preset: explicit payload wins, otherwise preserve currently selected preset from encode plan. const hasExplicitUserPresetSelection = Object.prototype.hasOwnProperty.call(options || {}, 'selectedUserPresetId'); const hasExplicitHandBrakePresetSelection = Object.prototype.hasOwnProperty.call(options || {}, 'selectedHandBrakePreset'); const rawHandBrakePreset = hasExplicitHandBrakePresetSelection ? String(options?.selectedHandBrakePreset || '').trim() : ''; let resolvedUserPreset = null; if (hasExplicitUserPresetSelection) { const rawUserPresetId = options?.selectedUserPresetId; const userPresetId = rawUserPresetId !== null && rawUserPresetId !== undefined && String(rawUserPresetId).trim() !== '' ? Number(rawUserPresetId) : null; if (Number.isFinite(userPresetId) && userPresetId > 0) { resolvedUserPreset = await userPresetService.getPresetById(userPresetId); if (!resolvedUserPreset) { const error = new Error(`User-Preset ${userPresetId} nicht gefunden.`); error.statusCode = 404; throw error; } } } if ((!resolvedUserPreset || !hasExplicitUserPresetSelection) && hasExplicitHandBrakePresetSelection) { resolvedUserPreset = rawHandBrakePreset ? { name: `HandBrake: ${rawHandBrakePreset}`, handbrakePreset: rawHandBrakePreset, extraArgs: '' } : null; } if (!hasExplicitUserPresetSelection && !hasExplicitHandBrakePresetSelection) { resolvedUserPreset = normalizeUserPresetForPlan(encodePlan?.userPreset || null); } const confirmedPlan = { ...planForConfirm, postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)), postEncodeScripts: selectedPostEncodeScripts.map((item) => ({ id: Number(item.id), name: item.name })), preEncodeScriptIds: selectedPreEncodeScripts.map((item) => Number(item.id)), preEncodeScripts: selectedPreEncodeScripts.map((item) => ({ id: Number(item.id), name: item.name })), postEncodeChainIds: selectedPostEncodeChainIds, preEncodeChainIds: selectedPreEncodeChainIds, reviewConfirmed: true, reviewConfirmedAt: nowIso(), userPreset: normalizeUserPresetForPlan(resolvedUserPreset) }; const readyMediaProfile = this.resolveMediaProfileForJob(job, { encodePlan: confirmedPlan }); if (readyMediaProfile === 'converter' && !confirmedPlan.userPreset) { const error = new Error('Bestätigung nicht möglich: Bitte ein User- oder HandBrake-Preset auswählen.'); error.statusCode = 400; throw error; } const confirmSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile); const resolvedConfirmRawPath = this.resolveCurrentRawPathForSettings( confirmSettings, readyMediaProfile, job.raw_path ); const activeConfirmRawPath = resolvedConfirmRawPath || String(job.raw_path || '').trim() || null; let inputPath = isPreRipMode ? null : (job.encode_input_path || confirmedPlan.encodeInputPath || this.snapshot.context?.inputPath || null); if (!isPreRipMode && activeConfirmRawPath) { const needsInputRefresh = !inputPath || !fs.existsSync(inputPath) || !isPathInsideDirectory(activeConfirmRawPath, inputPath); if (needsInputRefresh) { const selectedPlaylistId = normalizePlaylistId( confirmedPlan?.selectedPlaylistId || confirmedPlan?.selectedPlaylist || null ); if (hasBluRayBackupStructure(activeConfirmRawPath)) { inputPath = activeConfirmRawPath; } else { const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job, selectedPlaylistId); inputPath = findPreferredRawInput(activeConfirmRawPath, { playlistAnalysis: playlistDecision.playlistAnalysis, selectedPlaylistId: selectedPlaylistId || playlistDecision.selectedPlaylist })?.path || null; } } } confirmedPlan.encodeInputPath = inputPath; const hasEncodableTitle = isPreRipMode ? Boolean(confirmedPlan?.encodeInputTitleId) : Boolean(inputPath); await historyService.updateJob(jobId, { encode_review_confirmed: 1, encode_plan_json: JSON.stringify(confirmedPlan), encode_input_path: inputPath, ...(activeConfirmRawPath ? { raw_path: activeConfirmRawPath } : {}) }); await historyService.appendLog( jobId, 'USER_ACTION', `Mediainfo-Prüfung bestätigt.${isPreRipMode ? ' Backup/Rip darf gestartet werden.' : ' Encode darf gestartet werden.'}${confirmedPlan.encodeInputTitleId ? ` Gewählter Titel #${confirmedPlan.encodeInputTitleId}.` : ''}` + ` Audio-Spuren: ${trackSelectionResult.audioTrackIds.length > 0 ? trackSelectionResult.audioTrackIds.join(',') : 'none'}.` + ` Subtitle-Spuren: ${trackSelectionResult.subtitleTrackIds.length > 0 ? trackSelectionResult.subtitleTrackIds.join(',') : 'none'}.` + ` Subtitle-Forced-Indizes: ${Array.isArray(trackSelectionResult.subtitleForcedTrackIndexes) && trackSelectionResult.subtitleForcedTrackIndexes.length > 0 ? trackSelectionResult.subtitleForcedTrackIndexes.join(',') : 'none'}.` + ` Pre-Encode-Scripte: ${selectedPreEncodeScripts.length > 0 ? selectedPreEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.` + ` Pre-Encode-Ketten: ${selectedPreEncodeChainIds.length > 0 ? selectedPreEncodeChainIds.join(',') : 'none'}.` + ` Post-Encode-Scripte: ${selectedPostEncodeScripts.length > 0 ? selectedPostEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.` + ` Post-Encode-Ketten: ${selectedPostEncodeChainIds.length > 0 ? selectedPostEncodeChainIds.join(',') : 'none'}.` + (resolvedUserPreset ? ` User-Preset: "${resolvedUserPreset.name}"${resolvedUserPreset.id ? ` (ID ${resolvedUserPreset.id})` : ''}.` : '') ); if (!skipPipelineStateUpdate) { await this.setState('READY_TO_ENCODE', { activeJobId: jobId, progress: 0, eta: null, statusText: hasEncodableTitle ? (isPreRipMode ? 'Spurauswahl bestätigt - Backup/Rip + Encode manuell starten' : 'Mediainfo bestätigt - Encode manuell starten') : (isPreRipMode ? 'Spurauswahl bestätigt - kein passender Titel gewählt' : 'Mediainfo bestätigt - kein Titel erfüllt MIN_LENGTH_MINUTES'), context: { ...(this.snapshot.context || {}), jobId, inputPath, hasEncodableTitle, mediaProfile: readyMediaProfile, mediaInfoReview: confirmedPlan, reviewConfirmed: true } }); } return historyService.getJobById(jobId); } async reencodeFromRaw(sourceJobId, options = {}) { this.ensureNotBusy('reencodeFromRaw', sourceJobId); logger.info('reencodeFromRaw:requested', { sourceJobId }); this.cancelRequestedByJob.delete(Number(sourceJobId)); const sourceJob = await historyService.getJobById(sourceJobId); if (!sourceJob) { const error = new Error(`Quelle-Job ${sourceJobId} nicht gefunden.`); error.statusCode = 404; throw error; } if (!sourceJob.raw_path) { const error = new Error('Re-Encode nicht möglich: raw_path fehlt.'); error.statusCode = 400; throw error; } if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(sourceJob.status)) { const error = new Error('Re-Encode nicht möglich: Quelljob ist noch aktiv.'); error.statusCode = 409; throw error; } const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json); // Importierte Jobs (RAW-Ordner-Import) müssen zuerst vollständig analysiert und // encodiert werden, bevor ein Re-Encode möglich ist. Nur "Neu einlesen" ist erlaubt. if (mkInfo?.source === 'orphan_raw_import' && !sourceJob.handbrake_info_json) { const error = new Error( 'Re-Encode nicht möglich: Job wurde via RAW-Import erstellt und wurde noch nicht vollständig encodiert. Bitte zuerst "Neu einlesen" ausführen.' ); error.statusCode = 409; throw error; } const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); const reencodeMediaProfile = this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: mkInfo, encodePlan: sourceEncodePlan, rawPath: sourceJob.raw_path }); if (reencodeMediaProfile === 'audiobook') { const reencodeSettings = await settingsService.getSettingsMap(); const resolvedAudiobookRawPath = this.resolveCurrentRawPathForSettings( reencodeSettings, reencodeMediaProfile, sourceJob.raw_path ); if (!resolvedAudiobookRawPath) { const error = new Error(`Re-Encode nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`); error.statusCode = 400; throw error; } const rawInput = findPreferredRawInput(resolvedAudiobookRawPath); if (!rawInput) { const error = new Error('Re-Encode nicht möglich: keine AAX-Datei im RAW-Pfad gefunden.'); error.statusCode = 400; throw error; } const refreshedPlan = { ...(sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {}), mediaProfile: 'audiobook', jobKind: 'audiobook', mode: 'audiobook', encodeInputPath: rawInput.path }; const reencodeDeleteFolders = Array.isArray(options?.deleteFolders) ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) : []; if (reencodeDeleteFolders.length > 0) { try { const specificDeleteResult = await historyService.deleteSpecificOutputFolders(sourceJobId, reencodeDeleteFolders); await historyService.appendLog( sourceJobId, 'USER_ACTION', `Audiobook-Re-Encode: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` ); } catch (error) { logger.warn('reencodeFromRaw:audiobook:delete-specific-folders-failed', { jobId: sourceJobId, error: errorToMeta(error) }); } } await historyService.resetProcessLog(sourceJobId); await historyService.updateJob(sourceJobId, { media_type: 'audiobook', job_kind: 'audiobook', status: 'READY_TO_START', last_state: 'READY_TO_START', start_time: null, end_time: null, error_message: null, output_path: null, handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: JSON.stringify(refreshedPlan), encode_input_path: rawInput.path, encode_review_confirmed: 1, raw_path: resolvedAudiobookRawPath }); await historyService.appendLog( sourceJobId, 'USER_ACTION', `Audiobook-Re-Encode angefordert. Bestehender Job wird wiederverwendet. Input: ${rawInput.path}` ); return this.startPreparedJob(sourceJobId); } if (reencodeMediaProfile === 'cd') { const cdReencodeSettings = await settingsService.getEffectiveSettingsMap('cd'); const resolvedCdRawPath = this.resolveCurrentRawPathForSettings( cdReencodeSettings, 'cd', sourceJob.raw_path ); if (!resolvedCdRawPath) { const error = new Error(`CD-Encode nicht möglich: RAW-Verzeichnis nicht gefunden (${sourceJob.raw_path}).`); error.statusCode = 400; throw error; } // WAV-Dateien im RAW-Verzeichnis suchen const wavFiles = fs.existsSync(resolvedCdRawPath) ? fs.readdirSync(resolvedCdRawPath).filter((f) => /\.wav$/i.test(f)) : []; if (wavFiles.length === 0) { const error = new Error('CD-Encode nicht möglich: keine WAV-Dateien im RAW-Verzeichnis gefunden.'); error.statusCode = 400; throw error; } const cdEncodePlan = sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {}; const cdMkInfo = mkInfo && typeof mkInfo === 'object' ? mkInfo : {}; const cdHandbrakeInfo = this.safeParseJson(sourceJob.handbrake_info_json) || {}; const selectedMeta = cdMkInfo?.selectedMetadata && typeof cdMkInfo.selectedMetadata === 'object' ? cdMkInfo.selectedMetadata : {}; const tocTracks = Array.isArray(cdMkInfo?.tracks) && cdMkInfo.tracks.length > 0 ? cdMkInfo.tracks : (Array.isArray(cdEncodePlan?.tracks) ? cdEncodePlan.tracks : []); const selectedTrackPositions = normalizeCdTrackPositionList( Array.isArray(cdEncodePlan?.selectedTracks) && cdEncodePlan.selectedTracks.length > 0 ? cdEncodePlan.selectedTracks : tocTracks.map((t) => Number(t?.position)).filter((p) => Number.isFinite(p) && p > 0) ); const format = String(cdEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac'; const formatOptions = cdEncodePlan?.formatOptions && typeof cdEncodePlan.formatOptions === 'object' ? cdEncodePlan.formatOptions : {}; const directReencodeEligibility = evaluateCdDirectReencodeEligibility({ job: sourceJob, encodePlan: cdEncodePlan, mkInfo: cdMkInfo, handbrakeInfo: cdHandbrakeInfo }); if (!directReencodeEligibility.eligible) { const reasonText = directReencodeEligibility.reasons.join(' | '); await historyService.appendLog( sourceJobId, 'SYSTEM', `CD-Encode aus RAW blockiert. Bitte zuerst "Vorprüfung starten". Gründe: ${reasonText}` ).catch(() => {}); const error = new Error( `Direktes CD-Encode ist nicht erlaubt: ${reasonText} Bitte zuerst "Vorprüfung starten".` ); error.statusCode = 409; error.details = directReencodeEligibility.reasons.map((message) => ({ field: 'cd_reencode', message })); throw error; } const cdOutputTemplate = String( cdReencodeSettings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE ).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; const cdOutputBaseDir = String(cdReencodeSettings.movie_dir || '').trim() || String(cdReencodeSettings.raw_dir || '').trim() || settingsService.DEFAULT_CD_DIR; const cdOutputOwner = String(cdReencodeSettings.movie_dir_owner || cdReencodeSettings.raw_dir_owner || '').trim(); const baseOutputDir = cdRipService.buildOutputDir(selectedMeta, cdOutputBaseDir, cdOutputTemplate); // Handle output conflict resolution const keepBoth = Boolean(options?.keepBoth); const deleteFolders = Array.isArray(options?.deleteFolders) ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) : []; if (deleteFolders.length > 0) { await historyService.deleteSpecificOutputFolders(sourceJobId, deleteFolders); } const baseOutputStillExists = (() => { try { return fs.existsSync(baseOutputDir); } catch (_error) { return false; } })(); // Conflict strategy: // - keepBoth => always number (_X) // - replace + remaining sibling folders => continue numbering (_X) // - replace + all removed => reuse base folder without numbering const needsNumberedOutput = keepBoth || baseOutputStillExists; const outputDir = needsNumberedOutput ? ensureUniqueOutputPath(baseOutputDir) : baseOutputDir; const reencodeVirtualDrivePath = `__virtual__${sourceJobId}`; const cdLiveTrackRowsReenc = buildCdLiveTrackRows(selectedTrackPositions, tocTracks, selectedMeta?.artist); const initialCdLiveReenc = buildCdLiveProgressSnapshot({ trackRows: cdLiveTrackRowsReenc, phase: 'encode', trackIndex: cdLiveTrackRowsReenc.length > 0 ? 1 : 0, trackTotal: cdLiveTrackRowsReenc.length, trackPosition: cdLiveTrackRowsReenc[0]?.position || null, ripCompletedCount: cdLiveTrackRowsReenc.length, encodeCompletedCount: 0 }); await historyService.resetProcessLog(sourceJobId); await historyService.updateJob(sourceJobId, { status: 'CD_RIPPING', last_state: 'CD_RIPPING', start_time: new Date().toISOString(), end_time: null, error_message: null, output_path: outputDir }); await historyService.appendLog( sourceJobId, 'USER_ACTION', `CD-Encode aus RAW angefordert (skipRip). Format=${format}, Tracks=${selectedTrackPositions.join(',') || 'alle'}, RAW=${resolvedCdRawPath}` ); this._setCdDriveState(reencodeVirtualDrivePath, { state: 'CD_RIPPING', jobId: sourceJobId, progress: 0, eta: null, statusText: 'Tracks werden encodiert …', context: { jobId: sourceJobId, mediaProfile: 'cd', tracks: tocTracks, selectedMetadata: selectedMeta, devicePath: null, virtualDrivePath: reencodeVirtualDrivePath, skipRip: true, cdparanoiaCmd: 'cdparanoia', rawWavDir: resolvedCdRawPath, outputPath: outputDir, outputTemplate: cdOutputTemplate, cdRipConfig: cdEncodePlan, cdLive: initialCdLiveReenc } }); this._runCdRip({ jobId: sourceJobId, devicePath: reencodeVirtualDrivePath, cdparanoiaCmd: 'cdparanoia', rawWavDir: resolvedCdRawPath, rawBaseDir: null, cdMetadataBase: null, outputDir, format, formatOptions, outputTemplate: cdOutputTemplate, rawOwner: null, outputOwner: cdOutputOwner, selectedTrackPositions, tocTracks, selectedMeta, encodePlan: cdEncodePlan, skipRip: true }).catch((error) => { logger.error('reencodeFromRaw:cd:background-failed', { jobId: sourceJobId, error: errorToMeta(error) }); this.failJob(sourceJobId, 'CD_ENCODING', error).catch((failError) => { logger.error('reencodeFromRaw:cd:failJob-failed', { jobId: sourceJobId, error: errorToMeta(failError) }); }); }); return { jobId: sourceJobId, started: true, queued: false }; } const ripSuccessful = this.isRipSuccessful(sourceJob); if (!ripSuccessful) { const error = new Error( `Re-Encode nicht möglich: RAW-Rip ist nicht abgeschlossen (MakeMKV Status ${mkInfo?.status || 'unknown'}).` ); error.statusCode = 400; throw error; } const reencodeSettings = await settingsService.getSettingsMap(); const resolvedReencodeRawPath = this.resolveCurrentRawPathForSettings( reencodeSettings, reencodeMediaProfile, sourceJob.raw_path ); if (!resolvedReencodeRawPath) { const error = new Error(`Re-Encode nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`); error.statusCode = 400; throw error; } await historyService.resetProcessLog(sourceJobId); // Handle explicit folder deletion requested by the user (video/audiobook) const reencodeDeleteFolders = Array.isArray(options?.deleteFolders) ? options.deleteFolders : []; if (reencodeDeleteFolders.length > 0) { try { const specificDeleteResult = await historyService.deleteSpecificOutputFolders(sourceJobId, reencodeDeleteFolders); await historyService.appendLog( sourceJobId, 'USER_ACTION', `Re-Encode: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` ); } catch (error) { logger.warn('reencodeFromRaw:delete-specific-folders-failed', { jobId: sourceJobId, error: errorToMeta(error) }); } } const rawInput = findPreferredRawInput(resolvedReencodeRawPath); if (!rawInput) { const error = new Error('Re-Encode nicht möglich: keine Datei im RAW-Pfad gefunden.'); error.statusCode = 400; throw error; } const resetMakemkvInfoJson = (mkInfo && typeof mkInfo === 'object') ? JSON.stringify({ ...mkInfo, analyzeContext: { ...(mkInfo?.analyzeContext || {}), selectedPlaylist: null, selectedTitleId: null } }) : (sourceJob.makemkv_info_json || null); const reencodeJobUpdate = { status: 'MEDIAINFO_CHECK', last_state: 'MEDIAINFO_CHECK', start_time: nowIso(), end_time: null, error_message: null, output_path: null, handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0, makemkv_info_json: resetMakemkvInfoJson }; if (resolvedReencodeRawPath !== sourceJob.raw_path) { reencodeJobUpdate.raw_path = resolvedReencodeRawPath; } await historyService.updateJob(sourceJobId, reencodeJobUpdate); await historyService.appendLog( sourceJobId, 'USER_ACTION', `Re-Encode angefordert. Bestehender Job wird wiederverwendet. Input-Kandidat: ${rawInput.path}` ); this.runReviewForRawJob(sourceJobId, resolvedReencodeRawPath, { mode: 'reencode', sourceJobId, forcePlaylistReselection: true, mediaProfile: this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: mkInfo, rawPath: resolvedReencodeRawPath }) }).catch((error) => { logger.error('reencodeFromRaw:background-failed', { jobId: sourceJobId, sourceJobId, error: errorToMeta(error) }); this.failJob(sourceJobId, 'MEDIAINFO_CHECK', error).catch((failError) => { logger.error('reencodeFromRaw:background-failJob-failed', { jobId: sourceJobId, sourceJobId, error: errorToMeta(failError) }); }); }); return { started: true, stage: 'MEDIAINFO_CHECK', sourceJobId, jobId: sourceJobId }; } async restartCdReviewFromRaw(jobId, options = {}) { const resolvedCdReviewJob = await this.resolveExistingJobForAction(jobId, 'restart_cd_review'); jobId = resolvedCdReviewJob.resolvedJobId; this.ensureNotBusy('restartCdReviewFromRaw', jobId); logger.info('restartCdReviewFromRaw:requested', { jobId, options }); this.cancelRequestedByJob.delete(Number(jobId)); const sourceJob = resolvedCdReviewJob.job || await historyService.getJobById(jobId); const currentStatus = String(sourceJob.status || '').trim().toUpperCase(); if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(currentStatus)) { const error = new Error(`CD-Vorprüfung nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`); error.statusCode = 409; throw error; } const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json) || {}; const encodePlan = this.safeParseJson(sourceJob.encode_plan_json) || {}; const keepBoth = Boolean(options?.keepBoth); const reviewDeleteFolders = Array.isArray(options?.deleteFolders) ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) : []; const mediaProfile = this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: mkInfo, encodePlan }); if (mediaProfile !== 'cd') { const error = new Error(`CD-Vorprüfung nicht möglich: Job ${jobId} ist kein Audio-CD-Job (erkanntes Profil: ${mediaProfile || 'unbekannt'}).`); error.statusCode = 400; throw error; } const cdSettings = await settingsService.getEffectiveSettingsMap('cd'); const resolvedRawPath = this.resolveCurrentRawPathForSettings(cdSettings, 'cd', sourceJob.raw_path); if (!resolvedRawPath) { const error = new Error(`CD-Vorprüfung nicht möglich: RAW-Verzeichnis nicht erreichbar (${sourceJob.raw_path}).`); error.statusCode = 400; throw error; } const wavFiles = fs.existsSync(resolvedRawPath) ? fs.readdirSync(resolvedRawPath).filter((f) => /\.wav$/i.test(f)) : []; if (wavFiles.length === 0) { const error = new Error('CD-Vorprüfung nicht möglich: keine WAV-Dateien im RAW-Verzeichnis gefunden.'); error.statusCode = 400; throw error; } if (reviewDeleteFolders.length > 0) { try { const specificDeleteResult = await historyService.deleteSpecificOutputFolders(jobId, reviewDeleteFolders); await historyService.appendLog( jobId, 'USER_ACTION', `CD-Vorprüfung: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` ); } catch (error) { logger.warn('restartCdReviewFromRaw:delete-specific-folders-failed', { jobId, error: errorToMeta(error) }); } } const shouldPersistOutputConflict = keepBoth || reviewDeleteFolders.length > 0; const nextEncodePlan = encodePlan && typeof encodePlan === 'object' ? { ...encodePlan } : {}; const tocTracks = Array.isArray(mkInfo.tracks) && mkInfo.tracks.length > 0 ? mkInfo.tracks : (Array.isArray(encodePlan.tracks) && encodePlan.tracks.length > 0 ? encodePlan.tracks : []); // If no track info available (e.g. orphan import with plain WAV files), derive tracks from sorted WAV files const effectiveTocTracks = tocTracks.length > 0 ? tocTracks : wavFiles .filter((f) => /\.wav$/i.test(f)) .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) .map((f, i) => ({ position: i + 1, title: `Track ${i + 1}`, artist: null, selected: true })); const resetTracks = effectiveTocTracks.map((track, index) => { const position = Number(track?.position); const normalizedPosition = Number.isFinite(position) && position > 0 ? Math.trunc(position) : (index + 1); return { ...track, position: normalizedPosition, title: `Track ${normalizedPosition}`, artist: null, selected: track?.selected !== false }; }); const selectedMetadata = {}; nextEncodePlan.tracks = resetTracks; nextEncodePlan.selectedTracks = resetTracks .filter((track) => track.selected !== false) .map((track) => Number(track.position)) .filter((position) => Number.isFinite(position) && position > 0); nextEncodePlan.directReencodeReady = false; nextEncodePlan.directReencodeReadyAt = null; if (shouldPersistOutputConflict) { nextEncodePlan.outputConflict = { strategy: keepBoth ? 'keep_both' : 'replace', keepBoth, requestedAt: nowIso(), deletedFolderCount: reviewDeleteFolders.length }; } else { delete nextEncodePlan.outputConflict; } const updatedMkInfo = { ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), tracks: resetTracks, selectedMetadata: {} }; const cdparanoiaCmd = String(mkInfo.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia'; const jobUpdatePayload = { status: 'CD_METADATA_SELECTION', last_state: 'CD_METADATA_SELECTION', start_time: null, end_time: null, error_message: null, encode_plan_json: JSON.stringify(nextEncodePlan), makemkv_info_json: JSON.stringify(updatedMkInfo) }; await historyService.updateJob(jobId, jobUpdatePayload); const virtualDrivePath = `__virtual__${jobId}`; this._setCdDriveState(virtualDrivePath, { state: 'CD_METADATA_SELECTION', jobId, device: null, progress: 0, eta: null, statusText: 'Vorprüfung (kein Laufwerk)', context: { jobId, mediaProfile: 'cd', devicePath: null, virtualDrivePath, skipRip: true, rawWavDir: resolvedRawPath, cdparanoiaCmd, tracks: resetTracks, selectedMetadata, detectedTitle: sourceJob.detected_title || sourceJob.title || 'Audio CD' } }); const snapshotPayload = this.getSnapshot(); wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); this.emit('stateChanged', snapshotPayload); await historyService.appendLog( jobId, 'USER_ACTION', `CD-Vorprüfung aus RAW gestartet (skipRip). ${wavFiles.length} WAV-Datei(en) in: ${resolvedRawPath}` ); return { jobId, tracks: resetTracks, selectedMetadata }; } async runMediainfoForFile(jobId, inputPath, options = {}) { const lines = []; const config = await settingsService.buildMediaInfoConfig(inputPath, { mediaProfile: options?.mediaProfile || null, settingsMap: options?.settingsMap || null }); logger.info('mediainfo:command', { jobId, inputPath, cmd: config.cmd, args: config.args }); const runInfo = await this.runCommand({ jobId, stage: 'MEDIAINFO_CHECK', source: 'MEDIAINFO', cmd: config.cmd, args: config.args, collectLines: lines, collectStderrLines: false }); const parsed = parseMediainfoJsonOutput(lines.join('\n')); if (!parsed) { const error = new Error(`Mediainfo-Ausgabe konnte nicht als JSON gelesen werden (${path.basename(inputPath)}).`); error.runInfo = runInfo; throw error; } return { runInfo, parsed }; } async runDvdTrackFallbackForFile(jobId, inputPath, options = {}) { const lines = []; const scanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, { mediaProfile: options?.mediaProfile || null, settingsMap: options?.settingsMap || null }); logger.info('mediainfo:track-fallback:handbrake-scan:command', { jobId, inputPath, cmd: scanConfig.cmd, args: scanConfig.args }); const runInfo = await this.runCommand({ jobId, stage: 'MEDIAINFO_CHECK', source: 'HANDBRAKE_SCAN_DVD_TRACK_FALLBACK', cmd: scanConfig.cmd, args: scanConfig.args, collectLines: lines, collectStderrLines: false }); const parsedScan = parseMediainfoJsonOutput(lines.join('\n')); if (!parsedScan) { return { runInfo, parsedMediaInfo: null, titleInfo: null }; } const titleInfo = parseHandBrakeSelectedTitleInfo(parsedScan); if (!titleInfo) { return { runInfo, parsedMediaInfo: null, titleInfo: null }; } return { runInfo, parsedMediaInfo: buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo), titleInfo }; } async runMediainfoReviewForJob(jobId, rawPath, options = {}) { this.ensureNotBusy('runMediainfoReviewForJob', jobId); logger.info('mediainfo:review:start', { jobId, rawPath, options }); const job = await historyService.getJobById(jobId); if (!job) { const error = new Error(`Job ${jobId} nicht gefunden.`); error.statusCode = 404; throw error; } const mkInfo = this.safeParseJson(job.makemkv_info_json); const mediaProfile = this.resolveMediaProfileForJob(job, { mediaProfile: options?.mediaProfile, makemkvInfo: mkInfo, rawPath }); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const reviewPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'review').catch(() => null); let reviewPluginExecution = null; const analyzeContext = mkInfo?.analyzeContext || {}; const selectedPlaylistId = normalizePlaylistId( analyzeContext.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null ); const playlistAnalysis = analyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null; const preferredEncodeTitleId = normalizeNonNegativeInteger(analyzeContext.selectedTitleId); const rawMedia = collectRawMediaCandidates(rawPath, { playlistAnalysis, selectedPlaylistId }); const mediaFiles = rawMedia.mediaFiles; if (mediaFiles.length === 0) { const error = new Error('Mediainfo-Prüfung nicht möglich: keine Datei im RAW-Pfad gefunden.'); error.statusCode = 400; throw error; } const isConverterReview = String(options?.mode || '').trim().toLowerCase() === 'converter' || String(mediaProfile || '').trim().toLowerCase() === 'converter'; const reviewSettings = isConverterReview ? { ...settings, makemkv_min_length_minutes: 0, handbrake_preset: '', handbrake_extra_args: '' } : settings; await historyService.appendLog( jobId, 'SYSTEM', `Mediainfo-Quelle: ${rawMedia.source} (${mediaFiles.length} Datei(en))` ); let presetProfile = null; try { presetProfile = await settingsService.buildHandBrakePresetProfile(mediaFiles[0].path, { mediaProfile, settingsMap: settings }); } catch (error) { logger.warn('mediainfo:review:preset-profile-failed', { jobId, error: errorToMeta(error) }); presetProfile = { source: 'fallback', message: `Preset-Profil konnte nicht geladen werden: ${error.message}` }; } const selectedMetadata = { title: job.title || job.detected_title || null, year: job.year || null, imdbId: job.imdb_id || null, poster: job.poster_url || null }; if (this.isPrimaryJob(jobId)) { await this.setState('MEDIAINFO_CHECK', { activeJobId: jobId, progress: 0, eta: null, statusText: 'Mediainfo-Prüfung läuft', context: { jobId, rawPath, reviewConfirmed: false, mode: options.mode || 'rip', mediaProfile, sourceJobId: options.sourceJobId || null, selectedMetadata } }); } await historyService.updateJob(jobId, { status: 'MEDIAINFO_CHECK', last_state: 'MEDIAINFO_CHECK', makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile(mkInfo, mediaProfile)) }); const mediaInfoByPath = {}; const mediaInfoRuns = []; let effectiveMediaFiles = mediaFiles; const buildReviewSnapshot = (processedCount) => { const processedFiles = effectiveMediaFiles .slice(0, processedCount) .filter((item) => Boolean(mediaInfoByPath[item.path])); if (processedFiles.length === 0) { return null; } return { ...buildMediainfoReview({ mediaFiles: processedFiles, mediaInfoByPath, settings: reviewSettings, presetProfile, playlistAnalysis, preferredEncodeTitleId, selectedPlaylistId, selectedMakemkvTitleId: preferredEncodeTitleId }), mode: options.mode || 'rip', mediaProfile, sourceJobId: options.sourceJobId || null, reviewConfirmed: false, partial: processedFiles.length < effectiveMediaFiles.length, processedFiles: processedFiles.length, totalFiles: effectiveMediaFiles.length }; }; // For DVD sources, use HandBrake scan directly instead of mediainfo (mediainfo // is unreliable on VOB files and often returns incomplete track information). const isDvdSource = rawMedia.source === 'dvd' || rawMedia.source === 'dvd_image' || rawMedia.source === 'single_extensionless'; let dvdHandBrakeScanJson = null; let dvdHandBrakeScanInputPath = null; let dvdSelectedTitleInfo = null; // set when HandBrake scan succeeds; used to skip re-selection let dvdScannedCandidates = null; // set when scan yields multiple MIN_LENGTH candidates needing user selection if (isDvdSource && mediaFiles.length > 0) { // For 'dvd': scan the folder that contains VIDEO_TS (parent of the VIDEO_TS dir). // For image/extensionless: scan the file directly. dvdHandBrakeScanInputPath = rawMedia.source === 'dvd' ? path.dirname(path.dirname(mediaFiles[0].path)) : mediaFiles[0].path; await historyService.appendLog( jobId, 'SYSTEM', `DVD-Analyse via HandBrake Scan: ${path.basename(dvdHandBrakeScanInputPath)}` ); await this.updateProgress('MEDIAINFO_CHECK', 10, null, 'HandBrake DVD-Scan läuft', jobId); const dvdScanLines = []; let dvdScanRunInfo = null; const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { jobId, rawPath: dvdHandBrakeScanInputPath, reviewMode: 'rip', source: 'HANDBRAKE_SCAN_DVD', silent: !this.isPrimaryJob(jobId) }); dvdScanLines.push(...pluginScan.scanLines); dvdScanRunInfo = pluginScan.runInfo || null; reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, pluginScan.pluginExecution ); dvdHandBrakeScanJson = parseMediainfoJsonOutput(dvdScanLines.join('\n')); if (dvdHandBrakeScanJson) { const minLengthMinutes = Number(settings?.makemkv_min_length_minutes || 0); const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60)); const allDvdTitles = parseHandBrakeTitleList(dvdHandBrakeScanJson); const filteredDvdCandidates = minLengthSeconds > 0 ? allDvdTitles.filter((t) => t.durationSeconds >= minLengthSeconds) : allDvdTitles; await historyService.appendLog( jobId, 'SYSTEM', `HandBrake DVD-Scan: ${allDvdTitles.length} Titel gesamt, ${filteredDvdCandidates.length} ≥ ${minLengthMinutes} min.` ); // For synthetic mediaInfo: prefer the single MIN_LENGTH candidate; otherwise fall back to // the best available title (longest / main feature) as a placeholder for the review. const preferredTitleId = filteredDvdCandidates.length === 1 ? filteredDvdCandidates[0].handBrakeTitleId : null; const titleInfo = parseHandBrakeSelectedTitleInfo(dvdHandBrakeScanJson, { handBrakeTitleId: preferredTitleId || undefined }); if (titleInfo) { const syntheticMediaInfo = buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo); mediaInfoByPath[dvdHandBrakeScanInputPath] = syntheticMediaInfo; effectiveMediaFiles = [{ path: dvdHandBrakeScanInputPath, size: 0 }]; const audioCount = Array.isArray(titleInfo.audioTracks) ? titleInfo.audioTracks.length : 0; const subtitleCount = Array.isArray(titleInfo.subtitleTracks) ? titleInfo.subtitleTracks.length : 0; await historyService.appendLog( jobId, 'SYSTEM', `HandBrake DVD-Scan: Audio=${audioCount}, Subtitle=${subtitleCount}, Dauer=${formatDurationClock(titleInfo.durationSeconds)}` ); if (filteredDvdCandidates.length === 1) { // Exactly one title passes MIN_LENGTH → auto-select it. dvdSelectedTitleInfo = titleInfo; } else if (filteredDvdCandidates.length > 1) { // Multiple candidates → user must choose; store for downstream selection dialog. dvdScannedCandidates = filteredDvdCandidates; } else { // No title passes MIN_LENGTH filter → auto-select best available (degraded mode). dvdSelectedTitleInfo = titleInfo; } } else { const error = new Error('HandBrake DVD-Scan: Kein Titel erkannt.'); error.runInfo = dvdScanRunInfo; throw error; } } else { const error = new Error('HandBrake DVD-Scan: Ausgabe nicht lesbar.'); error.runInfo = dvdScanRunInfo; throw error; } mediaInfoRuns.push({ filePath: dvdHandBrakeScanInputPath, runInfo: dvdScanRunInfo, fallbackRunInfo: null }); const partialReview = buildReviewSnapshot(1); if (this.isPrimaryJob(jobId)) { await this.setState('MEDIAINFO_CHECK', { activeJobId: jobId, progress: 50, eta: null, statusText: `HandBrake DVD-Scan abgeschlossen: ${path.basename(dvdHandBrakeScanInputPath)}`, context: { jobId, rawPath, inputPath: partialReview?.encodeInputPath || null, hasEncodableTitle: Boolean(partialReview?.encodeInputPath), reviewConfirmed: false, mode: options.mode || 'rip', mediaProfile, sourceJobId: options.sourceJobId || null, mediaInfoReview: partialReview, selectedMetadata, ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) } }); } } // For non-DVD sources (or when HandBrake scan failed), run mediainfo on each file. if (!isDvdSource || !dvdHandBrakeScanJson) { for (let i = 0; i < mediaFiles.length; i += 1) { const file = mediaFiles[i]; const percent = Number((((i + 1) / mediaFiles.length) * 100).toFixed(2)); await this.updateProgress('MEDIAINFO_CHECK', percent, null, `Mediainfo ${i + 1}/${mediaFiles.length}: ${path.basename(file.path)}`, jobId); const result = await this.runMediainfoForFile(jobId, file.path, { mediaProfile, settingsMap: settings }); let parsedMediaInfo = result.parsed; let fallbackRunInfo = null; if (shouldRunDvdTrackFallback(parsedMediaInfo, mediaProfile, file.path)) { try { const fallback = await this.runDvdTrackFallbackForFile(jobId, file.path, { mediaProfile, settingsMap: settings }); if (fallback?.parsedMediaInfo) { parsedMediaInfo = fallback.parsedMediaInfo; fallbackRunInfo = fallback.runInfo || null; const audioCount = Array.isArray(fallback?.titleInfo?.audioTracks) ? fallback.titleInfo.audioTracks.length : 0; const subtitleCount = Array.isArray(fallback?.titleInfo?.subtitleTracks) ? fallback.titleInfo.subtitleTracks.length : 0; await historyService.appendLog( jobId, 'SYSTEM', `DVD Track-Fallback aktiv (${path.basename(file.path)}): Audio=${audioCount}, Subtitle=${subtitleCount}.` ); } else { await historyService.appendLog( jobId, 'SYSTEM', `DVD Track-Fallback ohne Ergebnis (${path.basename(file.path)}).` ); } } catch (error) { logger.warn('mediainfo:track-fallback:failed', { jobId, inputPath: file.path, error: errorToMeta(error) }); } } mediaInfoByPath[file.path] = parsedMediaInfo; mediaInfoRuns.push({ filePath: file.path, runInfo: result.runInfo, fallbackRunInfo }); const partialReview = buildReviewSnapshot(i + 1); if (this.isPrimaryJob(jobId)) { await this.setState('MEDIAINFO_CHECK', { activeJobId: jobId, progress: percent, eta: null, statusText: `Mediainfo ${i + 1}/${mediaFiles.length} analysiert: ${path.basename(file.path)}`, context: { jobId, rawPath, inputPath: partialReview?.encodeInputPath || null, hasEncodableTitle: Boolean(partialReview?.encodeInputPath), reviewConfirmed: false, mode: options.mode || 'rip', mediaProfile, sourceJobId: options.sourceJobId || null, mediaInfoReview: partialReview, selectedMetadata } }); } } } const review = buildMediainfoReview({ mediaFiles: effectiveMediaFiles, mediaInfoByPath, settings: reviewSettings, presetProfile, playlistAnalysis, preferredEncodeTitleId, selectedPlaylistId, selectedMakemkvTitleId: preferredEncodeTitleId }); let enrichedReview = { ...review, mode: options.mode || 'rip', mediaProfile, sourceJobId: options.sourceJobId || null, reviewConfirmed: false, partial: false, processedFiles: effectiveMediaFiles.length, totalFiles: effectiveMediaFiles.length }; const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan( enrichedReview, options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' ? options.previousEncodePlan : null ); enrichedReview = reviewPrefillResult.plan; const previousEncodePlan = options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' ? options.previousEncodePlan : null; const converterReviewMode = String(options?.mode || '').trim().toLowerCase() === 'converter' || String(options?.mediaProfile || '').trim().toLowerCase() === 'converter' || String(previousEncodePlan?.mediaProfile || '').trim().toLowerCase() === 'converter'; if (converterReviewMode && previousEncodePlan) { const normalizeConverterItem = (item) => { if (!item || typeof item !== 'object') return null; const type = String(item?.type || '').trim().toLowerCase(); const id = Number(item?.id); if ((type !== 'script' && type !== 'chain') || !Number.isFinite(id) || id <= 0) { return null; } return { type, id: Math.trunc(id) }; }; const preservedPreItems = Array.isArray(previousEncodePlan?.preEncodeItems) ? previousEncodePlan.preEncodeItems.map(normalizeConverterItem).filter(Boolean) : []; const preservedPostItems = Array.isArray(previousEncodePlan?.postEncodeItems) ? previousEncodePlan.postEncodeItems.map(normalizeConverterItem).filter(Boolean) : []; enrichedReview = { ...enrichedReview, mode: 'converter', mediaProfile: 'converter', converterMediaType: String(previousEncodePlan?.converterMediaType || '').trim().toLowerCase() || 'video', inputPath: String(previousEncodePlan?.inputPath || job?.raw_path || rawPath || '').trim() || null, inputPaths: Array.isArray(previousEncodePlan?.inputPaths) ? previousEncodePlan.inputPaths : null, outputPath: String(previousEncodePlan?.outputPath || '').trim() || null, outputDir: String(previousEncodePlan?.outputDir || '').trim() || null, outputFormat: String(previousEncodePlan?.outputFormat || '').trim().toLowerCase() || null, userPreset: previousEncodePlan?.userPreset || null, audioFormatOptions: previousEncodePlan?.audioFormatOptions && typeof previousEncodePlan.audioFormatOptions === 'object' ? previousEncodePlan.audioFormatOptions : {}, audioTrackTemplate: String(previousEncodePlan?.audioTrackTemplate || '').trim() || null, audioFiles: Array.isArray(previousEncodePlan?.audioFiles) ? previousEncodePlan.audioFiles : null, metadata: previousEncodePlan?.metadata && typeof previousEncodePlan.metadata === 'object' ? previousEncodePlan.metadata : null, tracks: Array.isArray(previousEncodePlan?.tracks) ? previousEncodePlan.tracks : null, isFolder: Boolean(previousEncodePlan?.isFolder), isSharedAudio: Boolean(previousEncodePlan?.isSharedAudio), preEncodeItems: preservedPreItems, postEncodeItems: preservedPostItems, preEncodeScriptIds: normalizeScriptIdList(previousEncodePlan?.preEncodeScriptIds || []), postEncodeScriptIds: normalizeScriptIdList(previousEncodePlan?.postEncodeScriptIds || []), preEncodeChainIds: normalizeChainIdList(previousEncodePlan?.preEncodeChainIds || []), postEncodeChainIds: normalizeChainIdList(previousEncodePlan?.postEncodeChainIds || []), reviewConfirmed: false }; } // HandBrake title scan for DVD structures (VIDEO_TS directories / ISO images) const needsHandBrakeTitleScan = rawMedia.source === 'dvd' || rawMedia.source === 'dvd_image' || rawMedia.source === 'single_extensionless'; if (needsHandBrakeTitleScan) { // Use the same path that was used for the DVD HandBrake scan above, or fall back // to the existing logic for cases where the scan was not yet performed. const dvdScanInputPath = dvdHandBrakeScanInputPath || (rawMedia.source === 'dvd' ? rawPath : mediaFiles[0].path); const preSelectedHandBrakeTitleId = normalizeReviewTitleId(options?.selectedHandBrakeTitleId); if (preSelectedHandBrakeTitleId) { enrichedReview = { ...enrichedReview, handBrakeTitleId: preSelectedHandBrakeTitleId, encodeInputPath: dvdScanInputPath }; await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Titel-Auswahl (manuell) übernommen: -t ${preSelectedHandBrakeTitleId}` ); } else if (dvdSelectedTitleInfo) { // Exactly one title passed the MIN_LENGTH filter (or fallback to best available) — // auto-select it without requiring user input. enrichedReview = { ...enrichedReview, handBrakeTitleId: dvdSelectedTitleInfo.handBrakeTitleId, encodeInputPath: dvdScanInputPath }; await historyService.appendLog( jobId, 'SYSTEM', `HandBrake DVD Titel automatisch gewählt (aus Scan): -t ${dvdSelectedTitleInfo.handBrakeTitleId}` ); } else if (dvdScannedCandidates && dvdScannedCandidates.length > 0) { // Multiple titles passed the MIN_LENGTH filter during the initial DVD scan — // present them to the user without running a second HandBrake scan. const candidatesData = dvdScannedCandidates.map((t) => ({ handBrakeTitleId: t.handBrakeTitleId, durationSeconds: t.durationSeconds, durationMinutes: Number((t.durationSeconds / 60).toFixed(1)), audioTrackCount: t.audioTrackCount, subtitleTrackCount: t.subtitleTrackCount, sizeBytes: t.sizeBytes || 0 })); enrichedReview = { ...enrichedReview, handBrakeTitleDecisionRequired: true, handBrakeTitleCandidates: candidatesData, handBrakeDvdInputPath: dvdScanInputPath, encodeInputPath: null }; const waitingMediainfoInfo = this.withPluginExecutionMeta({ generatedAt: nowIso(), files: mediaInfoRuns }, reviewPluginExecution); await historyService.updateJob(jobId, { status: 'WAITING_FOR_USER_DECISION', last_state: 'WAITING_FOR_USER_DECISION', error_message: null, mediainfo_info_json: JSON.stringify(waitingMediainfoInfo), encode_plan_json: JSON.stringify(enrichedReview), encode_input_path: null, encode_review_confirmed: 0 }); await historyService.appendLog( jobId, 'SYSTEM', `HandBrake DVD Titelauswahl erforderlich: ${candidatesData.length} Kandidaten. Nutzer muss Titel wählen.` ); const dvdWaitingStatusText = `DVD Titelauswahl: ${candidatesData.length} Kandidaten gefunden`; const dvdWaitingContextPatch = { jobId, rawPath, handBrakeTitleDecisionRequired: true, handBrakeTitleCandidates: candidatesData, handBrakeDvdInputPath: dvdScanInputPath, reviewConfirmed: false, mode: options.mode || 'rip', mediaProfile, sourceJobId: options.sourceJobId || null, mediaInfoReview: enrichedReview, selectedMetadata }; if (this.isPrimaryJob(jobId)) { await this.setState('WAITING_FOR_USER_DECISION', { activeJobId: jobId, progress: 0, eta: null, statusText: dvdWaitingStatusText, context: { ...dvdWaitingContextPatch, ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) } }); } else { await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, { contextPatch: dvdWaitingContextPatch }); } void this.notifyPushover('metadata_ready', { title: 'Ripster - DVD Titelauswahl', message: `Job #${jobId}: ${candidatesData.length} DVD-Titel gefunden, manuelle Auswahl erforderlich` }); return enrichedReview; } else { // No pre-scanned result available — run a fresh HandBrake scan for title selection. let dvdTitleScanJson = null; await historyService.appendLog( jobId, 'SYSTEM', 'HandBrake Titel-Scan für DVD-Struktur wird gestartet...' ); await this.updateProgress('MEDIAINFO_CHECK', 100, null, 'HandBrake DVD Titel-Scan läuft', jobId); const dvdTitleScanLines = []; let dvdTitleScanRunInfo = null; const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { jobId, rawPath: dvdScanInputPath, reviewMode: 'rip', source: 'HANDBRAKE_SCAN_DVD_TITLES', silent: !this.isPrimaryJob(jobId) }); dvdTitleScanLines.push(...pluginScan.scanLines); dvdTitleScanRunInfo = pluginScan.runInfo || null; reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, pluginScan.pluginExecution ); dvdTitleScanJson = parseMediainfoJsonOutput(dvdTitleScanLines.join('\n')); if (dvdTitleScanJson) { const allTitles = parseHandBrakeTitleList(dvdTitleScanJson); const minLengthMinutes = Number(settings?.makemkv_min_length_minutes || 0); const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60)); const titleCandidates = allTitles.filter((t) => t.durationSeconds >= minLengthSeconds); await historyService.appendLog( jobId, 'SYSTEM', `HandBrake DVD Titel-Scan: ${allTitles.length} gesamt, ${titleCandidates.length} ≥ ${minLengthMinutes} min.` ); if (titleCandidates.length === 1) { enrichedReview = { ...enrichedReview, handBrakeTitleId: titleCandidates[0].handBrakeTitleId, encodeInputPath: dvdScanInputPath }; await historyService.appendLog( jobId, 'SYSTEM', `HandBrake DVD Titel automatisch gewählt: -t ${titleCandidates[0].handBrakeTitleId}` ); } else if (titleCandidates.length > 1) { const candidatesData = titleCandidates.map((t) => ({ handBrakeTitleId: t.handBrakeTitleId, durationSeconds: t.durationSeconds, durationMinutes: Number((t.durationSeconds / 60).toFixed(1)), audioTrackCount: t.audioTrackCount, subtitleTrackCount: t.subtitleTrackCount, sizeBytes: t.sizeBytes || 0 })); enrichedReview = { ...enrichedReview, handBrakeTitleDecisionRequired: true, handBrakeTitleCandidates: candidatesData, handBrakeDvdInputPath: dvdScanInputPath, encodeInputPath: null }; const waitingMediainfoInfo = this.withPluginExecutionMeta({ generatedAt: nowIso(), files: mediaInfoRuns }, reviewPluginExecution); await historyService.updateJob(jobId, { status: 'WAITING_FOR_USER_DECISION', last_state: 'WAITING_FOR_USER_DECISION', error_message: null, mediainfo_info_json: JSON.stringify(waitingMediainfoInfo), encode_plan_json: JSON.stringify(enrichedReview), encode_input_path: null, encode_review_confirmed: 0 }); await historyService.appendLog( jobId, 'SYSTEM', `HandBrake DVD Titelauswahl erforderlich: ${titleCandidates.length} Kandidaten. Nutzer muss Titel wählen.` ); const dvdWaitingStatusText = `DVD Titelauswahl: ${titleCandidates.length} Kandidaten gefunden`; const dvdWaitingContextPatch = { jobId, rawPath, handBrakeTitleDecisionRequired: true, handBrakeTitleCandidates: candidatesData, handBrakeDvdInputPath: dvdScanInputPath, reviewConfirmed: false, mode: options.mode || 'rip', mediaProfile, sourceJobId: options.sourceJobId || null, mediaInfoReview: enrichedReview, selectedMetadata }; if (this.isPrimaryJob(jobId)) { await this.setState('WAITING_FOR_USER_DECISION', { activeJobId: jobId, progress: 0, eta: null, statusText: dvdWaitingStatusText, context: { ...dvdWaitingContextPatch, ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) } }); } else { await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, { contextPatch: dvdWaitingContextPatch }); } void this.notifyPushover('metadata_ready', { title: 'Ripster - DVD Titelauswahl', message: `Job #${jobId}: ${titleCandidates.length} DVD-Titel gefunden, manuelle Auswahl erforderlich` }); return enrichedReview; } else { await historyService.appendLog( jobId, 'SYSTEM', `HandBrake DVD Titel-Scan: Kein Titel ≥ ${minLengthMinutes} min. Ohne Titel-ID fortgefahren.` ); enrichedReview = { ...enrichedReview, encodeInputPath: dvdScanInputPath }; } } else { const error = new Error('HandBrake DVD Titel-Scan: Ausgabe nicht lesbar.'); error.runInfo = dvdTitleScanRunInfo; throw error; } } } const hasEncodableTitle = Boolean(enrichedReview.encodeInputPath); const titleSelectionRequired = Boolean(enrichedReview.titleSelectionRequired); if (!hasEncodableTitle && !titleSelectionRequired && !enrichedReview.handBrakeTitleDecisionRequired) { enrichedReview.notes = [ ...(Array.isArray(enrichedReview.notes) ? enrichedReview.notes : []), 'Kein Titel erfüllt aktuell MIN_LENGTH_MINUTES. Bitte Konfiguration prüfen.' ]; } const persistedMediainfoInfo = this.withPluginExecutionMeta({ generatedAt: nowIso(), files: mediaInfoRuns }, reviewPluginExecution); await historyService.updateJob(jobId, { status: 'READY_TO_ENCODE', last_state: 'READY_TO_ENCODE', error_message: null, mediainfo_info_json: JSON.stringify(persistedMediainfoInfo), encode_plan_json: JSON.stringify(enrichedReview), encode_input_path: enrichedReview.encodeInputPath || null, encode_review_confirmed: 0 }); await historyService.appendLog( jobId, 'SYSTEM', `Mediainfo-Prüfung abgeschlossen: ${enrichedReview.titles.length} Titel, Input=${enrichedReview.encodeInputPath || (titleSelectionRequired ? 'Titelauswahl erforderlich' : 'kein passender Titel')}` ); if (reviewPrefillResult.applied) { await historyService.appendLog( jobId, 'SYSTEM', `Vorherige Encode-Auswahl als Standard übernommen: Titel #${reviewPrefillResult.selectedEncodeTitleId || '-'}, ` + `Pre-Skripte=${reviewPrefillResult.preEncodeScriptCount}, Pre-Ketten=${reviewPrefillResult.preEncodeChainCount}, ` + `Post-Skripte=${reviewPrefillResult.postEncodeScriptCount}, Post-Ketten=${reviewPrefillResult.postEncodeChainCount}, ` + `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.` ); } const mediainfoReadyStatusText = titleSelectionRequired ? 'Mediainfo geprüft - Titelauswahl per Checkbox erforderlich' : (hasEncodableTitle ? 'Mediainfo geprüft - Encode manuell starten' : 'Mediainfo geprüft - kein Titel erfüllt MIN_LENGTH_MINUTES'); const mediainfoReadyContextPatch = { jobId, rawPath, inputPath: enrichedReview.encodeInputPath || null, hasEncodableTitle, reviewConfirmed: false, mode: options.mode || 'rip', mediaProfile, sourceJobId: options.sourceJobId || null, mediaInfoReview: enrichedReview, selectedMetadata }; if (this.isPrimaryJob(jobId)) { await this.setState('READY_TO_ENCODE', { activeJobId: jobId, progress: 0, eta: null, statusText: mediainfoReadyStatusText, context: { ...mediainfoReadyContextPatch, ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) } }); } else { await this.updateProgress('READY_TO_ENCODE', 0, null, mediainfoReadyStatusText, jobId, { contextPatch: mediainfoReadyContextPatch }); } void this.notifyPushover('metadata_ready', { title: 'Ripster - Mediainfo geprüft', message: `Job #${jobId}: bereit zum manuellen Encode-Start` }); return enrichedReview; } async runEncodeChains(jobId, chainIds, context = {}, phase = 'post', progressTracker = null) { const ids = Array.isArray(chainIds) ? chainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0) : []; if (ids.length === 0) { return { configured: 0, succeeded: 0, failed: 0, results: [] }; } const results = []; let succeeded = 0; let failed = 0; for (let index = 0; index < ids.length; index += 1) { const chainId = ids[index]; const chainLabel = `#${chainId}`; if (progressTracker?.onStepStart) { await progressTracker.onStepStart(phase, 'chain', index + 1, ids.length, chainLabel); } await historyService.appendLog(jobId, 'SYSTEM', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette startet (ID ${chainId})...`); try { const chainResult = await scriptChainService.executeChain(chainId, { ...context, jobId, source: phase === 'pre' ? 'pre_encode_chain' : 'post_encode_chain' }, { appendLog: (src, msg) => historyService.appendLog(jobId, src, msg) }); if (chainResult.aborted || chainResult.failed > 0) { failed += 1; await historyService.appendLog(jobId, 'ERROR', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette "${chainResult.chainName}" fehlgeschlagen.`); } else { succeeded += 1; await historyService.appendLog(jobId, 'SYSTEM', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette "${chainResult.chainName}" erfolgreich.`); } if (progressTracker?.onStepComplete) { await progressTracker.onStepComplete( phase, 'chain', index + 1, ids.length, chainResult.chainName || chainLabel, !(chainResult.aborted || chainResult.failed > 0) ); } results.push({ chainId, ...chainResult }); } catch (error) { failed += 1; results.push({ chainId, success: false, error: error.message }); await historyService.appendLog(jobId, 'ERROR', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette ${chainId} Fehler: ${error.message}`); logger.warn(`encode:${phase}-chain:failed`, { jobId, chainId, error: errorToMeta(error) }); if (progressTracker?.onStepComplete) { await progressTracker.onStepComplete(phase, 'chain', index + 1, ids.length, chainLabel, false); } } } return { configured: ids.length, succeeded, failed, results }; } async runPreEncodeScripts(jobId, encodePlan, context = {}, progressTracker = null) { const scriptIds = normalizeScriptIdList(encodePlan?.preEncodeScriptIds || []); const chainIds = Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : []; const executionStage = String(context?.pipelineStage || 'ENCODING').trim().toUpperCase() || 'ENCODING'; if (scriptIds.length === 0 && chainIds.length === 0) { return { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] }; } const scripts = await scriptService.resolveScriptsByIds(scriptIds, { strict: false }); const scriptById = new Map(scripts.map((item) => [Number(item.id), item])); const results = []; let succeeded = 0; let failed = 0; let skipped = 0; let aborted = false; for (let index = 0; index < scriptIds.length; index += 1) { const scriptId = scriptIds[index]; const script = scriptById.get(Number(scriptId)); const scriptLabel = script?.name || `#${scriptId}`; if (progressTracker?.onStepStart) { await progressTracker.onStepStart('pre', 'script', index + 1, scriptIds.length, scriptLabel); } if (!script) { failed += 1; aborted = true; results.push({ scriptId, scriptName: null, status: 'ERROR', error: 'missing' }); await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript #${scriptId} nicht gefunden. Kette abgebrochen.`); if (progressTracker?.onStepComplete) { await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, scriptLabel, false); } break; } await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}`); const activityId = runtimeActivityService.startActivity('script', { name: script.name, source: 'pre_encode', scriptId: script.id, jobId, currentStep: `Pre-Encode ${index + 1}/${scriptIds.length}` }); let prepared = null; try { prepared = await scriptService.createExecutableScriptFile(script, { source: 'pre_encode', mode: context?.mode || null, jobId, jobTitle: context?.jobTitle || null, inputPath: context?.inputPath || null, outputPath: context?.outputPath || null, rawPath: context?.rawPath || null }); const runInfo = await this.runCommand({ jobId, stage: executionStage, source: 'PRE_ENCODE_SCRIPT', cmd: prepared.cmd, args: prepared.args, argsForLog: prepared.argsForLog, onStdoutLine: (line) => { runtimeActivityService.appendActivityOutput(activityId, { stdout: line }); }, onStderrLine: (line) => { runtimeActivityService.appendActivityOutput(activityId, { stderr: line }); } }); succeeded += 1; results.push({ scriptId: script.id, scriptName: script.name, status: 'SUCCESS', runInfo }); const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : ''; runtimeActivityService.completeActivity(activityId, { status: 'success', success: true, outcome: 'success', exitCode: Number.isFinite(Number(runInfo?.exitCode)) ? Number(runInfo.exitCode) : null, message: 'Pre-Encode Skript erfolgreich', output: runOutput || null, stdout: runInfo?.stdoutTail || null, stderr: runInfo?.stderrTail || null, stdoutTruncated: Boolean(runInfo?.stdoutTruncated), stderrTruncated: Boolean(runInfo?.stderrTruncated) }); await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript erfolgreich: ${script.name}`); if (progressTracker?.onStepComplete) { await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, script.name, true); } } catch (error) { const runInfo = error?.runInfo && typeof error.runInfo === 'object' ? error.runInfo : null; const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : ''; const runStatus = String(runInfo?.status || '').trim().toUpperCase(); const cancelled = runStatus === 'CANCELLED'; runtimeActivityService.completeActivity(activityId, { status: 'error', success: false, outcome: cancelled ? 'cancelled' : 'error', cancelled, message: error?.message || 'Pre-Encode Skriptfehler', errorMessage: error?.message || 'Pre-Encode Skriptfehler', output: runOutput || null, stdout: runInfo?.stdoutTail || null, stderr: runInfo?.stderrTail || null, stdoutTruncated: Boolean(runInfo?.stdoutTruncated), stderrTruncated: Boolean(runInfo?.stderrTruncated) }); failed += 1; aborted = true; results.push({ scriptId: script.id, scriptName: script.name, status: 'ERROR', error: error?.message || 'unknown' }); await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript fehlgeschlagen: ${script.name} (${error?.message || 'unknown'})`); logger.warn('encode:pre-script:failed', { jobId, scriptId: script.id, error: errorToMeta(error) }); if (progressTracker?.onStepComplete) { await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, script.name, false); } break; } finally { if (prepared?.cleanup) { await prepared.cleanup(); } } } if (!aborted && chainIds.length > 0) { const chainResult = await this.runEncodeChains(jobId, chainIds, context, 'pre', progressTracker); if (chainResult.failed > 0) { aborted = true; failed += chainResult.failed; } succeeded += chainResult.succeeded; results.push(...chainResult.results); } if (aborted) { const pendingScripts = scriptIds.slice(results.filter((r) => r.scriptId != null).length); for (const pendingId of pendingScripts) { const s = scriptById.get(Number(pendingId)); skipped += 1; results.push({ scriptId: Number(pendingId), scriptName: s?.name || null, status: 'SKIPPED_ABORTED' }); } throw Object.assign(new Error('Pre-Encode Skripte fehlgeschlagen - Encode wird nicht gestartet.'), { statusCode: 500, preEncodeFailed: true }); } return { configured: scriptIds.length + chainIds.length, attempted: scriptIds.length - skipped + chainIds.length, succeeded, failed, skipped, aborted, results }; } async runPostEncodeScripts(jobId, encodePlan, context = {}, progressTracker = null) { const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []); const chainIds = Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : []; const executionStage = String(context?.pipelineStage || 'ENCODING').trim().toUpperCase() || 'ENCODING'; if (scriptIds.length === 0 && chainIds.length === 0) { return { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] }; } const scripts = await scriptService.resolveScriptsByIds(scriptIds, { strict: false }); const scriptById = new Map(scripts.map((item) => [Number(item.id), item])); const results = []; let succeeded = 0; let failed = 0; let skipped = 0; let aborted = false; let abortReason = null; let failedScriptName = null; let failedScriptId = null; const titleForPush = context?.jobTitle || `Job #${jobId}`; for (let index = 0; index < scriptIds.length; index += 1) { const scriptId = scriptIds[index]; const script = scriptById.get(Number(scriptId)); const scriptLabel = script?.name || `#${scriptId}`; if (progressTracker?.onStepStart) { await progressTracker.onStepStart('post', 'script', index + 1, scriptIds.length, scriptLabel); } if (!script) { failed += 1; aborted = true; failedScriptId = Number(scriptId); failedScriptName = `Script #${scriptId}`; abortReason = `Post-Encode Skript #${scriptId} wurde nicht gefunden (${index + 1}/${scriptIds.length}).`; await historyService.appendLog(jobId, 'SYSTEM', abortReason); results.push({ scriptId, scriptName: null, status: 'ERROR', error: 'missing' }); if (progressTracker?.onStepComplete) { await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, scriptLabel, false); } break; } await historyService.appendLog( jobId, 'SYSTEM', `Post-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}` ); const activityId = runtimeActivityService.startActivity('script', { name: script.name, source: 'post_encode', scriptId: script.id, jobId, currentStep: `Post-Encode ${index + 1}/${scriptIds.length}` }); let prepared = null; try { prepared = await scriptService.createExecutableScriptFile(script, { source: 'post_encode', mode: context?.mode || null, jobId, jobTitle: context?.jobTitle || null, inputPath: context?.inputPath || null, outputPath: context?.outputPath || null, rawPath: context?.rawPath || null }); const runInfo = await this.runCommand({ jobId, stage: executionStage, source: 'POST_ENCODE_SCRIPT', cmd: prepared.cmd, args: prepared.args, argsForLog: prepared.argsForLog, onStdoutLine: (line) => { runtimeActivityService.appendActivityOutput(activityId, { stdout: line }); }, onStderrLine: (line) => { runtimeActivityService.appendActivityOutput(activityId, { stderr: line }); } }); succeeded += 1; results.push({ scriptId: script.id, scriptName: script.name, status: 'SUCCESS', runInfo }); const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : ''; runtimeActivityService.completeActivity(activityId, { status: 'success', success: true, outcome: 'success', exitCode: Number.isFinite(Number(runInfo?.exitCode)) ? Number(runInfo.exitCode) : null, message: 'Post-Encode Skript erfolgreich', output: runOutput || null, stdout: runInfo?.stdoutTail || null, stderr: runInfo?.stderrTail || null, stdoutTruncated: Boolean(runInfo?.stdoutTruncated), stderrTruncated: Boolean(runInfo?.stderrTruncated) }); await historyService.appendLog( jobId, 'SYSTEM', `Post-Encode Skript erfolgreich: ${script.name}` ); if (progressTracker?.onStepComplete) { await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, script.name, true); } } catch (error) { const runInfo = error?.runInfo && typeof error.runInfo === 'object' ? error.runInfo : null; const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : ''; const runStatus = String(runInfo?.status || '').trim().toUpperCase(); const cancelled = runStatus === 'CANCELLED'; runtimeActivityService.completeActivity(activityId, { status: 'error', success: false, outcome: cancelled ? 'cancelled' : 'error', cancelled, message: error?.message || 'Post-Encode Skriptfehler', errorMessage: error?.message || 'Post-Encode Skriptfehler', output: runOutput || null, stdout: runInfo?.stdoutTail || null, stderr: runInfo?.stderrTail || null, stdoutTruncated: Boolean(runInfo?.stdoutTruncated), stderrTruncated: Boolean(runInfo?.stderrTruncated) }); failed += 1; aborted = true; failedScriptId = Number(script.id); failedScriptName = script.name; abortReason = error?.message || 'unknown'; results.push({ scriptId: script.id, scriptName: script.name, status: 'ERROR', error: abortReason }); await historyService.appendLog( jobId, 'SYSTEM', `Post-Encode Skript fehlgeschlagen: ${script.name} (${abortReason})` ); logger.warn('encode:post-script:failed', { jobId, scriptId: script.id, scriptName: script.name, error: errorToMeta(error) }); if (progressTracker?.onStepComplete) { await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, script.name, false); } break; } finally { if (prepared?.cleanup) { await prepared.cleanup(); } } } if (aborted) { const executedScriptIds = new Set(results.map((item) => Number(item?.scriptId))); for (const pendingScriptId of scriptIds) { const numericId = Number(pendingScriptId); if (executedScriptIds.has(numericId)) { continue; } const pendingScript = scriptById.get(numericId); skipped += 1; results.push({ scriptId: numericId, scriptName: pendingScript?.name || null, status: 'SKIPPED_ABORTED' }); } await historyService.appendLog( jobId, 'SYSTEM', `Post-Encode Skriptkette abgebrochen nach Fehler in ${failedScriptName || `Script #${failedScriptId || 'unknown'}`}.` ); void this.notifyPushover('job_error', { title: 'Ripster - Post-Encode Skriptfehler', message: `${titleForPush}: ${failedScriptName || `Script #${failedScriptId || 'unknown'}`} fehlgeschlagen (${abortReason || 'unknown'}). Skriptkette abgebrochen.` }); } if (!aborted && chainIds.length > 0) { const chainResult = await this.runEncodeChains(jobId, chainIds, context, 'post', progressTracker); if (chainResult.failed > 0) { aborted = true; failed += chainResult.failed; abortReason = `Post-Encode Kette fehlgeschlagen`; void this.notifyPushover('job_error', { title: 'Ripster - Post-Encode Kettenfehler', message: `${context?.jobTitle || `Job #${jobId}`}: Eine Post-Encode Kette ist fehlgeschlagen.` }); } succeeded += chainResult.succeeded; results.push(...chainResult.results); } return { configured: scriptIds.length + chainIds.length, attempted: scriptIds.length - skipped + chainIds.length, succeeded, failed, skipped, aborted, abortReason, failedScriptId, failedScriptName, results }; } async startEncodingFromPrepared(jobId) { this.ensureNotBusy('startEncodingFromPrepared', jobId); logger.info('encode:start-from-prepared', { jobId }); const job = await historyService.getJobById(jobId); if (!job) { const error = new Error(`Job ${jobId} nicht gefunden.`); error.statusCode = 404; throw error; } const encodePlan = this.safeParseJson(job.encode_plan_json); const mediaProfile = this.resolveMediaProfileForJob(job, { encodePlan, rawPath: job.raw_path }); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const encodePlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'encode').catch(() => null); let encodePluginExecution = null; const resolvedRawPath = this.resolveCurrentRawPathForSettings(settings, mediaProfile, job.raw_path); const activeRawPath = resolvedRawPath || String(job.raw_path || '').trim() || null; if (activeRawPath && normalizeComparablePath(activeRawPath) !== normalizeComparablePath(job.raw_path)) { await historyService.updateJob(jobId, { raw_path: activeRawPath }); await historyService.appendLog( jobId, 'SYSTEM', `RAW-Pfad für Encode-Start aktualisiert: ${job.raw_path} -> ${activeRawPath}` ); } const movieDir = settings.movie_dir; ensureDir(movieDir); const mode = encodePlan?.mode || this.snapshot.context?.mode || 'rip'; let inputPath = job.encode_input_path || encodePlan?.encodeInputPath || this.snapshot.context?.inputPath || null; let playlistDecision = null; const resolveInputFromRaw = (rawPathCandidate) => { if (!rawPathCandidate) { return null; } if (hasBluRayBackupStructure(rawPathCandidate)) { return rawPathCandidate; } if (!playlistDecision) { playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); } return findPreferredRawInput(rawPathCandidate, { playlistAnalysis: playlistDecision.playlistAnalysis, selectedPlaylistId: playlistDecision.selectedPlaylist })?.path || null; }; if (inputPath && !fs.existsSync(inputPath)) { const recoveredInputPath = resolveInputFromRaw(activeRawPath); if (recoveredInputPath && fs.existsSync(recoveredInputPath)) { await historyService.appendLog( jobId, 'SYSTEM', `Encode-Input wurde auf aktuellen RAW-Pfad korrigiert: ${inputPath} -> ${recoveredInputPath}` ); inputPath = recoveredInputPath; } } if (!inputPath) { inputPath = resolveInputFromRaw(activeRawPath); } if (!inputPath) { const error = new Error('Encode-Start nicht möglich: kein Input-Pfad vorhanden.'); error.statusCode = 400; throw error; } if (!fs.existsSync(inputPath)) { const error = new Error(`Encode-Start nicht möglich: Input-Datei fehlt (${inputPath}).`); error.statusCode = 400; throw error; } const incompleteOutputPath = buildIncompleteOutputPathFromJob(settings, job, jobId); const preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, job, jobId); ensureDir(path.dirname(incompleteOutputPath)); await this.setState('ENCODING', { activeJobId: jobId, progress: 0, eta: null, statusText: mode === 'reencode' ? 'Re-Encoding mit HandBrake' : 'Encoding mit HandBrake', context: { jobId, mode, inputPath, outputPath: incompleteOutputPath, reviewConfirmed: true, mediaProfile, mediaInfoReview: encodePlan || null, selectedMetadata: { title: job.title || job.detected_title || null, year: job.year || null, imdbId: job.imdb_id || null, poster: job.poster_url || null } } }); await historyService.updateJob(jobId, { status: 'ENCODING', last_state: 'ENCODING', output_path: incompleteOutputPath, encode_input_path: inputPath, ...(activeRawPath ? { raw_path: activeRawPath } : {}) }); await historyService.appendLog( jobId, 'SYSTEM', `Temporärer Encode-Output: ${incompleteOutputPath} (wird nach erfolgreichem Encode in den finalen Zielordner verschoben).` ); if (mode === 'reencode') { void this.notifyPushover('reencode_started', { title: 'Ripster - Re-Encode gestartet', message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` }); } else { void this.notifyPushover('encoding_started', { title: 'Ripster - Encoding gestartet', message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` }); } const preEncodeContext = { mode, jobId, jobTitle: job.title || job.detected_title || null, inputPath, rawPath: activeRawPath, mediaProfile }; const preScriptIds = normalizeScriptIdList(encodePlan?.preEncodeScriptIds || []); const preChainIds = Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : []; const postScriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []); const postChainIds = Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : []; const normalizedPreChainIds = Array.isArray(preChainIds) ? preChainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0) : []; const normalizedPostChainIds = Array.isArray(postChainIds) ? postChainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0) : []; const encodeScriptProgressTracker = createEncodeScriptProgressTracker({ jobId, preSteps: preScriptIds.length + normalizedPreChainIds.length, postSteps: postScriptIds.length + normalizedPostChainIds.length, updateProgress: this.updateProgress.bind(this) }); let preEncodeScriptsSummary = { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] }; if (preScriptIds.length > 0 || preChainIds.length > 0) { await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Encode Skripte/Ketten werden ausgeführt...'); try { preEncodeScriptsSummary = await this.runPreEncodeScripts( jobId, encodePlan, preEncodeContext, encodeScriptProgressTracker ); } catch (preError) { if (preError.preEncodeFailed) { await this.failJob(jobId, 'ENCODING', preError); throw preError; } throw preError; } await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Encode Skripte/Ketten abgeschlossen.'); } try { const trackSelection = extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath); if (Array.isArray(trackSelection?.subtitleSelectionValidationErrors) && trackSelection.subtitleSelectionValidationErrors.length > 0) { const error = new Error( `Subtitle-Auswahl ungültig: ${trackSelection.subtitleSelectionValidationErrors.join(' | ')}` ); error.statusCode = 400; throw error; } let handBrakeTitleId = null; let directoryInput = false; try { if (fs.existsSync(inputPath) && fs.statSync(inputPath).isDirectory()) { directoryInput = true; } } catch (_error) { directoryInput = false; handBrakeTitleId = null; } if (directoryInput) { const reviewMappedTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId); if (reviewMappedTitleId) { handBrakeTitleId = reviewMappedTitleId; await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Titel-Mapping aus Vorbereitung übernommen: -t ${handBrakeTitleId}` ); } const selectedPlaylistId = normalizePlaylistId( encodePlan?.selectedPlaylistId || (Array.isArray(encodePlan?.titles) ? (encodePlan.titles.find((title) => Boolean(title?.selectedForEncode))?.playlistId || null) : null) || this.snapshot.context?.selectedPlaylist || null ); const selectedEncodeTitle = Array.isArray(encodePlan?.titles) ? ( encodePlan.titles.find((title) => Boolean(title?.selectedForEncode) && normalizePlaylistId(title?.playlistId) === selectedPlaylistId ) || encodePlan.titles.find((title) => Boolean(title?.selectedForEncode)) || null ) : null; const expectedMakemkvTitleIdForResolve = normalizeNonNegativeInteger( selectedEncodeTitle?.makemkvTitleId ?? encodePlan?.playlistRecommendation?.makemkvTitleId ?? this.snapshot.context?.selectedTitleId ?? null ); const expectedDurationSecondsForResolve = Number(selectedEncodeTitle?.durationSeconds || 0) || null; const expectedSizeBytesForResolve = Number(selectedEncodeTitle?.sizeBytes || 0) || null; if (!handBrakeTitleId && selectedPlaylistId) { const titleResolveScanLines = []; const titleResolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, { mediaProfile, settingsMap: settings }); logger.info('encoding:title-resolve-scan:command', { jobId, cmd: titleResolveScanConfig.cmd, args: titleResolveScanConfig.args, sourceArg: titleResolveScanConfig.sourceArg, selectedPlaylistId }); const titleResolveRunInfo = await this.runCommand({ jobId, stage: 'ENCODING', source: 'HANDBRAKE_SCAN_TITLE_RESOLVE', cmd: titleResolveScanConfig.cmd, args: titleResolveScanConfig.args, collectLines: titleResolveScanLines, collectStderrLines: false }); const titleResolveParsed = parseMediainfoJsonOutput(titleResolveScanLines.join('\n')); if (!titleResolveParsed) { const error = new Error('HandBrake Scan-Ausgabe für Titel-Mapping konnte nicht als JSON gelesen werden.'); error.runInfo = titleResolveRunInfo; throw error; } handBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(titleResolveParsed, selectedPlaylistId, { expectedMakemkvTitleId: expectedMakemkvTitleIdForResolve, expectedDurationSeconds: expectedDurationSecondsForResolve, expectedSizeBytes: expectedSizeBytesForResolve }); if (!handBrakeTitleId) { const knownPlaylists = listAvailableHandBrakePlaylists(titleResolveParsed); const error = new Error( `Kein HandBrake-Titel für Playlist ${selectedPlaylistId}.mpls gefunden.` + ` ${knownPlaylists.length > 0 ? `Scan-Playlists: ${knownPlaylists.map((id) => `${id}.mpls`).join(', ')}` : 'Scan enthält keine erkennbaren Playlist-IDs.'}` ); error.statusCode = 400; throw error; } await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Titel-Mapping: ${selectedPlaylistId}.mpls -> -t ${handBrakeTitleId}` ); } else if (!handBrakeTitleId) { const dvdResolveScanLines = []; const dvdResolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, { mediaProfile, settingsMap: settings }); await historyService.appendLog( jobId, 'SYSTEM', 'HandBrake Titel-Scan für Verzeichnis ohne Playlist wird gestartet...' ); logger.info('encoding:dvd-title-resolve-scan:command', { jobId, cmd: dvdResolveScanConfig.cmd, args: dvdResolveScanConfig.args }); const dvdResolveRunInfo = await this.runCommand({ jobId, stage: 'ENCODING', source: 'HANDBRAKE_SCAN_TITLE_RESOLVE', cmd: dvdResolveScanConfig.cmd, args: dvdResolveScanConfig.args, collectLines: dvdResolveScanLines, collectStderrLines: false }); const dvdResolveParsed = parseMediainfoJsonOutput(dvdResolveScanLines.join('\n')); if (dvdResolveParsed) { const dvdTitleInfo = parseHandBrakeSelectedTitleInfo(dvdResolveParsed, { handBrakeTitleId: null, playlistId: null }); if (dvdTitleInfo?.handBrakeTitleId) { handBrakeTitleId = dvdTitleInfo.handBrakeTitleId; await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Titel-Scan: längster Titel gefunden -> -t ${handBrakeTitleId}` ); } } else { logger.warn('encoding:dvd-title-resolve-scan:parse-failed', { jobId, runInfo: dvdResolveRunInfo }); } } } if (!handBrakeTitleId) { const fallbackTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId); if (fallbackTitleId) { handBrakeTitleId = fallbackTitleId; await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Titel-ID aus Encode-Plan übernommen: -t ${handBrakeTitleId}` ); } } const effectiveEncodePlan = { ...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}), trackSelection: trackSelection || null, handBrakeTitleId: handBrakeTitleId || null }; if (trackSelection) { await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Track-Override: audio=${trackSelection.audioTrackIds.length > 0 ? trackSelection.audioTrackIds.join(',') : 'none'}, subtitles=${trackSelection.subtitleTrackIds.length > 0 ? trackSelection.subtitleTrackIds.join(',') : 'none'}, subtitle-burned=${trackSelection.subtitleBurnTrackId ?? 'none'}, subtitle-default=${trackSelection.subtitleDefaultTrackId ?? 'none'}, subtitle-forced-index=${Array.isArray(trackSelection.subtitleForcedTrackIndexes) && trackSelection.subtitleForcedTrackIndexes.length > 0 ? trackSelection.subtitleForcedTrackIndexes.join(',') : 'none'}` ); } if (handBrakeTitleId) { await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Titel-Selektion aktiv: -t ${handBrakeTitleId}` ); } const handBrakeProgressParser = encodeScriptProgressTracker.hasScriptSteps ? (line) => { const parsed = parseHandBrakeProgress(line); if (!parsed || parsed.percent === null || parsed.percent === undefined) { return parsed; } return { ...parsed, percent: encodeScriptProgressTracker.mapHandBrakePercent(parsed.percent) }; } : parseHandBrakeProgress; let handbrakeInfo = null; const encodeCtx = await this.buildPluginContext(encodePlugin.id, { jobId, inputPath, outputPath: incompleteOutputPath, encodePlan: effectiveEncodePlan, runCommand: this.runCommand.bind(this), progressParser: handBrakeProgressParser, encodeSource: 'HANDBRAKE', encodeStage: 'ENCODING' }); handbrakeInfo = await encodePlugin.encode(job, encodeCtx); encodePluginExecution = this.mergePluginExecutionState( encodePluginExecution, this.sanitizePluginExecutionState(encodeCtx.getPluginExecution()) ); logger.info('plugin:encode:used', { jobId, pluginId: encodePlugin.id, mediaProfile }); const outputFinalization = finalizeOutputPathForCompletedEncode( incompleteOutputPath, preferredFinalOutputPath ); const finalizedOutputPath = outputFinalization.outputPath; chownRecursive(path.dirname(finalizedOutputPath), settings.movie_dir_owner); if (outputFinalization.outputPathWithTimestamp) { await historyService.appendLog( jobId, 'SYSTEM', `Finaler Output existierte bereits. Neuer Zielpfad (nummeriert): ${finalizedOutputPath}` ); } await historyService.appendLog( jobId, 'SYSTEM', `Encode-Output finalisiert: ${finalizedOutputPath}` ); historyService.addJobOutputFolder(jobId, finalizedOutputPath).catch((e) => { logger.warn('encode:record-output-folder-failed', { jobId, error: e?.message }); }); let postEncodeScriptsSummary = { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] }; try { postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, encodePlan, { mode, jobTitle: job.title || job.detected_title || null, inputPath, outputPath: finalizedOutputPath, rawPath: activeRawPath }, encodeScriptProgressTracker); } catch (error) { logger.warn('encode:post-script:summary-failed', { jobId, error: errorToMeta(error) }); await historyService.appendLog( jobId, 'SYSTEM', `Post-Encode Skripte konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}` ); } if (postEncodeScriptsSummary.configured > 0) { await historyService.appendLog( jobId, 'SYSTEM', `Post-Encode Skripte abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.${postEncodeScriptsSummary.aborted ? ' Kette wurde abgebrochen.' : ''}` ); } let finalizedRawPath = activeRawPath || null; if (activeRawPath) { const currentRawPath = String(activeRawPath || '').trim(); const completedRawPath = buildCompletedRawPath(currentRawPath); if (completedRawPath && completedRawPath !== currentRawPath) { if (fs.existsSync(completedRawPath)) { logger.warn('encoding:raw-dir-finalize:target-exists', { jobId, sourceRawPath: currentRawPath, targetRawPath: completedRawPath }); await historyService.appendLog( jobId, 'SYSTEM', `RAW-Ordner konnte nicht finalisiert werden (Ziel existiert bereits): ${completedRawPath}` ); } else { try { fs.renameSync(currentRawPath, completedRawPath); await historyService.updateRawPathByOldPath(currentRawPath, completedRawPath); finalizedRawPath = completedRawPath; await historyService.appendLog( jobId, 'SYSTEM', `RAW-Ordner nach erfolgreichem Encode finalisiert (Prefix entfernt): ${currentRawPath} -> ${completedRawPath}` ); } catch (rawRenameError) { logger.warn('encoding:raw-dir-finalize:rename-failed', { jobId, sourceRawPath: currentRawPath, targetRawPath: completedRawPath, error: errorToMeta(rawRenameError) }); await historyService.appendLog( jobId, 'SYSTEM', `RAW-Ordner konnte nach Encode nicht finalisiert werden: ${rawRenameError.message}` ); } } } } const handbrakeInfoWithPostScripts = this.withPluginExecutionMeta({ ...handbrakeInfo, preEncodeScripts: preEncodeScriptsSummary, postEncodeScripts: postEncodeScriptsSummary }, encodePluginExecution); await historyService.updateJob(jobId, { handbrake_info_json: JSON.stringify(handbrakeInfoWithPostScripts), status: 'FINISHED', last_state: 'FINISHED', end_time: nowIso(), raw_path: finalizedRawPath, rip_successful: 1, output_path: finalizedOutputPath, error_message: null }); // Thumbnail aus Cache in persistenten Ordner verschieben const promotedUrl = thumbnailService.promoteJobThumbnail(jobId); if (promotedUrl) { await historyService.updateJob(jobId, { poster_url: promotedUrl }).catch(() => {}); } logger.info('encoding:finished', { jobId, mode, outputPath: finalizedOutputPath }); const finishedStatusTextBase = mode === 'reencode' ? 'Re-Encode abgeschlossen' : 'Job abgeschlossen'; const finishedStatusText = postEncodeScriptsSummary.failed > 0 ? `${finishedStatusTextBase} (${postEncodeScriptsSummary.failed} Skript(e) fehlgeschlagen)` : finishedStatusTextBase; // Only switch the pipeline UI to FINISHED if this job is still the active one. // If the pipeline has moved to another job (e.g. metadata selection for a new disc // that was inserted while this one was encoding), complete silently so the other // job's UI state is not disrupted. The DB status is already FINISHED (above). if (Number(this.snapshot.activeJobId) === Number(jobId)) { await this.setState('FINISHED', { activeJobId: jobId, progress: 100, eta: null, statusText: finishedStatusText, context: { jobId, mode, outputPath: finalizedOutputPath } }); } else { logger.info('encoding:finished:background', { jobId, mode, activeJobId: this.snapshot.activeJobId }); void this.pumpQueue(); } if (mode === 'reencode') { void this.notifyPushover('reencode_finished', { title: 'Ripster - Re-Encode abgeschlossen', message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}` }); } else { void this.notifyPushover('job_finished', { title: 'Ripster - Job abgeschlossen', message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}` }); void this.ejectDriveIfEnabled(settings); } setTimeout(async () => { if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) { await this.setState('IDLE', { finishingJobId: jobId, activeJobId: null, progress: 0, eta: null, statusText: 'Bereit', context: {} }); } }, 3000); } catch (error) { if (error.runInfo && error.runInfo.source === 'HANDBRAKE') { await historyService.updateJob(jobId, { handbrake_info_json: JSON.stringify(error.runInfo) }); } logger.error('encode:start-from-prepared:failed', { jobId, mode, error: errorToMeta(error) }); await this.failJob(jobId, 'ENCODING', error); error.jobAlreadyFailed = true; throw error; } } async startRipEncode(jobId) { this.ensureNotBusy('startRipEncode', jobId); logger.info('ripEncode:start', { jobId }); let job = await historyService.getJobById(jobId); if (!job) { const error = new Error(`Job ${jobId} nicht gefunden.`); error.statusCode = 404; throw error; } const preRipPlanBeforeRip = this.safeParseJson(job.encode_plan_json); const preRipModeBeforeRip = String(preRipPlanBeforeRip?.mode || '').trim().toLowerCase(); const hasPreRipConfirmedSelection = (preRipModeBeforeRip === 'pre_rip' || Boolean(preRipPlanBeforeRip?.preRip)) && Number(job.encode_review_confirmed || 0) === 1; const preRipTrackSelectionPayload = hasPreRipConfirmedSelection ? extractManualSelectionPayloadFromPlan(preRipPlanBeforeRip) : null; const preRipPostEncodeScriptIds = hasPreRipConfirmedSelection ? normalizeScriptIdList(preRipPlanBeforeRip?.postEncodeScriptIds || []) : []; const preRipPreEncodeScriptIds = hasPreRipConfirmedSelection ? normalizeScriptIdList(preRipPlanBeforeRip?.preEncodeScriptIds || []) : []; const preRipPostEncodeChainIds = hasPreRipConfirmedSelection ? (Array.isArray(preRipPlanBeforeRip?.postEncodeChainIds) ? preRipPlanBeforeRip.postEncodeChainIds : []) .map(Number).filter((id) => Number.isFinite(id) && id > 0) : []; const preRipPreEncodeChainIds = hasPreRipConfirmedSelection ? (Array.isArray(preRipPlanBeforeRip?.preEncodeChainIds) ? preRipPlanBeforeRip.preEncodeChainIds : []) .map(Number).filter((id) => Number.isFinite(id) && id > 0) : []; const mkInfo = this.safeParseJson(job.makemkv_info_json); const mediaProfile = this.resolveMediaProfileForJob(job, { encodePlan: preRipPlanBeforeRip, makemkvInfo: mkInfo, deviceInfo: this.detectedDisc || this.snapshot.context?.device || null }); const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); const selectedTitleId = playlistDecision.selectedTitleId; const selectedPlaylist = playlistDecision.selectedPlaylist; const selectedPlaylistFile = toPlaylistFile(selectedPlaylist); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const rawBaseDir = settings.raw_dir; const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' ? 'backup' : 'mkv'; const effectiveSelectedTitleId = ripMode === 'mkv' ? (selectedTitleId ?? null) : null; const effectiveSelectedPlaylist = ripMode === 'mkv' ? (selectedPlaylist || null) : null; const effectiveSelectedPlaylistFile = ripMode === 'mkv' ? selectedPlaylistFile : null; const selectedPlaylistTitleInfo = ripMode === 'mkv' && Array.isArray(playlistDecision.playlistAnalysis?.titles) ? (playlistDecision.playlistAnalysis.titles.find((item) => Number(item?.titleId) === Number(selectedTitleId) ) || null) : null; logger.info('rip:playlist-resolution', { jobId, ripMode, selectedPlaylist: effectiveSelectedPlaylistFile, selectedTitleId: effectiveSelectedTitleId, selectedTitleDurationSeconds: Number(selectedPlaylistTitleInfo?.durationSeconds || 0), selectedTitleDurationLabel: selectedPlaylistTitleInfo?.durationLabel || null }); logger.debug('ripEncode:paths', { jobId, rawBaseDir }); ensureDir(rawBaseDir); const metadataBase = buildRawMetadataBase({ title: job.title || job.detected_title || null, year: job.year || null }, jobId); const rawDirName = buildRawDirName(metadataBase, jobId, { state: RAW_FOLDER_STATES.INCOMPLETE }); const rawJobDir = path.join(rawBaseDir, rawDirName); ensureDir(rawJobDir); chownRecursive(rawJobDir, settings.raw_dir_owner); logger.info('rip:raw-dir-created', { jobId, rawJobDir }); const deviceCandidate = this.detectedDisc || this.snapshot.context?.device || { path: job.disc_device, index: 0 }; const deviceProfile = normalizeMediaProfile(deviceCandidate?.mediaProfile) || inferMediaProfileFromDeviceInfo(deviceCandidate) || mediaProfile; const device = { ...deviceCandidate, mediaProfile: deviceProfile }; const devicePath = device.path || null; await this.setState('RIPPING', { activeJobId: jobId, progress: 0, eta: null, statusText: ripMode === 'backup' ? 'Backup mit MakeMKV' : 'Ripping mit MakeMKV', context: { jobId, device, mediaProfile, ripMode, playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired), playlistCandidates: playlistDecision.candidatePlaylists, selectedPlaylist: effectiveSelectedPlaylist, selectedTitleId: effectiveSelectedTitleId, preRipSelectionLocked: hasPreRipConfirmedSelection, selectedMetadata: { title: job.title || job.detected_title || null, year: job.year || null, imdbId: job.imdb_id || null, poster: job.poster_url || null } } }); void this.notifyPushover('rip_started', { title: ripMode === 'backup' ? 'Ripster - Backup gestartet' : 'Ripster - Rip gestartet', message: `${job.title || job.detected_title || `Job #${jobId}`} (${device.path || 'disc'})` }); const backupOutputBase = ripMode === 'backup' && mediaProfile === 'dvd' ? sanitizeFileName(job.title || job.detected_title || `disc-${jobId}`) : null; await historyService.updateJob(jobId, { status: 'RIPPING', last_state: 'RIPPING', raw_path: rawJobDir, handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0, output_path: null, error_message: null, end_time: null }); job = await historyService.getJobById(jobId); let makemkvInfo = null; try { await this.ensureMakeMKVRegistration(jobId, 'RIPPING'); if (ripMode === 'backup') { await historyService.appendLog( jobId, 'SYSTEM', 'Backup-Modus aktiv: MakeMKV erstellt 1:1 Backup ohne Titel-/Playlist-Einschränkungen.' ); } else if (effectiveSelectedPlaylistFile) { await historyService.appendLog( jobId, 'SYSTEM', `Manuelle Playlist-Auswahl aktiv: ${effectiveSelectedPlaylistFile} (Titel ${effectiveSelectedTitleId}).` ); if (selectedPlaylistTitleInfo) { await historyService.appendLog( jobId, 'SYSTEM', `Playlist-Auflösung: Titel ${effectiveSelectedTitleId} Dauer ${selectedPlaylistTitleInfo.durationLabel || `${selectedPlaylistTitleInfo.durationSeconds || 0}s`}.` ); } } else if (playlistDecision.playlistDecisionRequired) { const decisionContext = describePlaylistManualDecision(playlistDecision.playlistAnalysis); await historyService.appendLog( jobId, 'SYSTEM', `${decisionContext.detailText} Rip läuft ohne Vorauswahl. Finale Titelwahl erfolgt in der Mediainfo-Prüfung per Checkbox.` ); } const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'rip').catch(() => null); if (devicePath) { const existingLock = this._getDriveLockByPath(devicePath); const existingLockJobId = Number(existingLock?.jobId || existingLock?.owner?.jobId || 0) || null; const lockedByOtherJob = ( (diskDetectionService.isDeviceLocked(devicePath) || Boolean(existingLock)) && existingLockJobId !== Number(jobId) ); if (lockedByOtherJob) { throw this._buildDriveLockedError(devicePath, existingLock); } this._acquireDriveLockForJob(devicePath, jobId, { stage: 'RIPPING', source: 'MAKEMKV_RIP', mediaProfile, reason: 'rip_running' }); } let ripCommandSucceeded = false; try { // Plugin-Pfad: VideoDiscPlugin.rip() baut eigene ripConfig + ruft ctx.extra.runCommand() const ripCtx = await this.buildPluginContext(ripPlugin.id, { jobId, rawJobDir, deviceInfo: device, selectedTitleId: effectiveSelectedTitleId, backupOutputBase, runCommand: this.runCommand.bind(this) }); makemkvInfo = await ripPlugin.rip(job, ripCtx); ripCommandSucceeded = true; } finally { if (devicePath && !ripCommandSucceeded) { logger.warn('drive-lock:retained-after-rip-failure', { devicePath, jobId }); } } // Check for MakeMKV backup failure even when exit code is 0. // MakeMKV can emit localized failure text while still exiting with 0. const backupFailureLine = ripMode === 'backup' ? findMakeMkvBackupFailureMarker(makemkvInfo?.highlights) : null; if (backupFailureLine) { const msgCode = parseMakeMkvMessageCode(backupFailureLine); throw Object.assign( new Error(`MakeMKV Backup fehlgeschlagen${msgCode !== null ? ` (MSG:${msgCode})` : ''}: ${backupFailureLine}`), { runInfo: makemkvInfo } ); } const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json); // Merge bestehende Analyze-Marker aus der DB mit den Live-Markern aus dem Rip-Lauf, // damit analyze nicht verloren geht, wenn jobProgress nur die neueste Rip-Phase enthält. const postRipPluginExecution = this.mergePluginExecutionState( mkInfoBeforeRip?.pluginExecution, this.jobProgress.get(Number(jobId))?.context?.pluginExecution ); await historyService.updateJob(jobId, { makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ ...makemkvInfo, analyzeContext: mkInfoBeforeRip?.analyzeContext || null, pluginExecution: postRipPluginExecution || null }, mediaProfile)), rip_successful: 1 }); // Mark RAW as rip-complete until encode succeeds. let activeRawJobDir = rawJobDir; const ripCompleteRawJobDir = buildRipCompleteRawPath(rawJobDir); if (ripCompleteRawJobDir && ripCompleteRawJobDir !== rawJobDir) { if (fs.existsSync(ripCompleteRawJobDir)) { logger.warn('rip:raw-complete:rename-skip', { jobId, rawJobDir, ripCompleteRawJobDir }); await historyService.appendLog( jobId, 'SYSTEM', `RAW-Ordner konnte nach Rip nicht als Rip_Complete markiert werden (Zielordner existiert): ${ripCompleteRawJobDir}` ); } else { try { fs.renameSync(rawJobDir, ripCompleteRawJobDir); activeRawJobDir = ripCompleteRawJobDir; chownRecursive(activeRawJobDir, settings.raw_dir_owner); await historyService.updateRawPathByOldPath(rawJobDir, ripCompleteRawJobDir); await historyService.appendLog( jobId, 'SYSTEM', `RAW-Ordner nach erfolgreichem Rip als Rip_Complete markiert: ${rawJobDir} → ${ripCompleteRawJobDir}` ); } catch (renameError) { logger.warn('rip:raw-complete:rename-failed', { jobId, rawJobDir, ripCompleteRawJobDir, error: errorToMeta(renameError) }); await historyService.appendLog( jobId, 'SYSTEM', `RAW-Ordner konnte nach Rip nicht als Rip_Complete markiert werden: ${renameError.message}` ); } } } if (devicePath) { this._releaseDriveLockForJob(jobId, { reason: 'rip_successful' }); } const review = await this.runReviewForRawJob(jobId, activeRawJobDir, { mode: 'rip', mediaProfile }); logger.info('rip:review-ready', { jobId, encodeInputPath: review.encodeInputPath, selectedTitleCount: Array.isArray(review.selectedTitleIds) ? review.selectedTitleIds.length : (Array.isArray(review.titles) ? review.titles.filter((item) => Boolean(item?.selectedForEncode)).length : 0) }); if (hasPreRipConfirmedSelection && !review?.awaitingPlaylistSelection) { await historyService.appendLog( jobId, 'SYSTEM', 'Vorab bestätigte Spurauswahl erkannt. Übernehme Auswahl automatisch und starte Encode.' ); await this.confirmEncodeReview(jobId, { selectedEncodeTitleId: review?.encodeInputTitleId || null, selectedTrackSelection: preRipTrackSelectionPayload || null, selectedPostEncodeScriptIds: preRipPostEncodeScriptIds, selectedPreEncodeScriptIds: preRipPreEncodeScriptIds, selectedPostEncodeChainIds: preRipPostEncodeChainIds, selectedPreEncodeChainIds: preRipPreEncodeChainIds }); const autoStartResult = await this.startPreparedJob(jobId); logger.info('rip:auto-encode-started', { jobId, stage: autoStartResult?.stage || null }); } } catch (error) { if (error.runInfo && error.runInfo.source === 'MAKEMKV_RIP') { const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json); await historyService.updateJob(jobId, { makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ ...error.runInfo, analyzeContext: mkInfoBeforeRip?.analyzeContext || null, pluginExecution: mkInfoBeforeRip?.pluginExecution || null }, mediaProfile)) }); } if ( error.runInfo && [ 'MEDIAINFO', 'HANDBRAKE_SCAN', 'HANDBRAKE_SCAN_PLAYLIST_MAP', 'HANDBRAKE_SCAN_SELECTED_TITLE', 'MAKEMKV_ANALYZE_BACKUP' ].includes(error.runInfo.source) ) { await historyService.updateJob(jobId, { mediainfo_info_json: JSON.stringify({ failedAt: nowIso(), runInfo: error.runInfo }) }); } logger.error('ripEncode:failed', { jobId, stage: this.snapshot.state, error: errorToMeta(error) }); await this.failJob(jobId, this.snapshot.state, error); throw error; } } async retry(jobId, options = {}) { const immediate = Boolean(options?.immediate); if (!immediate) { // Retry always starts a rip → bypass the encode queue entirely. return this.retry(jobId, { ...options, immediate: true }); } const resolvedRetryJob = await this.resolveExistingJobForAction(jobId, 'retry'); jobId = resolvedRetryJob.resolvedJobId; this.ensureNotBusy('retry', jobId); logger.info('retry:start', { jobId }); this.cancelRequestedByJob.delete(Number(jobId)); let sourceJob = resolvedRetryJob.job || await historyService.getJobById(jobId); if (!sourceJob.title && !sourceJob.detected_title) { const error = new Error('Retry nicht möglich: keine Metadaten vorhanden.'); error.statusCode = 400; throw error; } const sourceStatus = String(sourceJob.status || '').trim().toUpperCase(); const sourceLastState = String(sourceJob.last_state || '').trim().toUpperCase(); const sourceMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json); const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); const mediaProfile = this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: sourceMakemkvInfo, encodePlan: sourceEncodePlan }); const explicitSourceMediaType = String(sourceJob?.media_type || '').trim().toLowerCase(); const preservedRetryMediaType = explicitSourceMediaType || ( ['converter', 'cd', 'audiobook'].includes(mediaProfile) ? mediaProfile : null ); const isCdRetry = mediaProfile === 'cd'; const isAudiobookRetry = mediaProfile === 'audiobook'; // CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden (z.B. importierte Jobs). // Importierte Audiobooks (orphan_raw_import ohne bisherigen Encode) dürfen ebenfalls // neu gestartet werden, da erst der vollständige Flow durchlaufen werden muss. const isImportedAudiobookWithoutEncode = isAudiobookRetry && sourceStatus === 'FINISHED' && sourceMakemkvInfo?.source === 'orphan_raw_import' && !sourceJob.handbrake_info_json; const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus) || ['ERROR', 'CANCELLED'].includes(sourceLastState) || (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus)) || isImportedAudiobookWithoutEncode; if (!retryable) { const error = new Error( `Retry nicht möglich: Job ${jobId} ist nicht im Status ERROR/CANCELLED (aktuell ${sourceStatus || sourceLastState || '-'}).` ); error.statusCode = 409; throw error; } let cdRetryConfig = null; if (isCdRetry) { const normalizeTrackPosition = (value) => { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); }; const sourceTracks = Array.isArray(sourceMakemkvInfo?.tracks) ? sourceMakemkvInfo.tracks : (Array.isArray(sourceEncodePlan?.tracks) ? sourceEncodePlan.tracks : []); // Keine Trackdaten → Neustart ohne Vorkonfiguration (TOC wird von der CD gelesen) if (sourceTracks.length === 0) { cdRetryConfig = { format: String(sourceEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac', formatOptions: sourceEncodePlan?.formatOptions && typeof sourceEncodePlan.formatOptions === 'object' ? sourceEncodePlan.formatOptions : {}, selectedTracks: [], tracks: [], metadata: { title: sourceMakemkvInfo?.selectedMetadata?.title || sourceJob.title || sourceJob.detected_title || 'Audio CD', artist: sourceMakemkvInfo?.selectedMetadata?.artist || null, year: sourceMakemkvInfo?.selectedMetadata?.year ?? sourceJob.year ?? null, mbId: sourceMakemkvInfo?.selectedMetadata?.mbId || sourceMakemkvInfo?.selectedMetadata?.musicBrainzId || sourceMakemkvInfo?.selectedMetadata?.musicbrainzId || sourceMakemkvInfo?.selectedMetadata?.musicbrainz_id || sourceMakemkvInfo?.selectedMetadata?.music_brainz_id || sourceMakemkvInfo?.selectedMetadata?.musicbrainz || sourceMakemkvInfo?.selectedMetadata?.mbid || null, coverUrl: sourceMakemkvInfo?.selectedMetadata?.coverUrl || sourceJob.poster_url || null }, selectedPreEncodeScriptIds: [], selectedPostEncodeScriptIds: [], selectedPreEncodeChainIds: [], selectedPostEncodeChainIds: [] }; } else { const selectedTracks = normalizeCdTrackPositionList( Array.isArray(sourceEncodePlan?.selectedTracks) ? sourceEncodePlan.selectedTracks : sourceTracks.filter((track) => track?.selected !== false).map((track) => normalizeTrackPosition(track?.position)) ); const selectedMetadata = sourceMakemkvInfo?.selectedMetadata && typeof sourceMakemkvInfo.selectedMetadata === 'object' ? sourceMakemkvInfo.selectedMetadata : {}; cdRetryConfig = { format: String(sourceEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac', formatOptions: sourceEncodePlan?.formatOptions && typeof sourceEncodePlan.formatOptions === 'object' ? sourceEncodePlan.formatOptions : {}, selectedTracks: selectedTracks.length > 0 ? selectedTracks : sourceTracks .map((track) => normalizeTrackPosition(track?.position)) .filter((value) => Number.isFinite(value) && value > 0), tracks: sourceTracks, metadata: { title: selectedMetadata?.title || sourceJob.title || sourceJob.detected_title || 'Audio CD', artist: selectedMetadata?.artist || null, year: selectedMetadata?.year ?? sourceJob.year ?? null, mbId: selectedMetadata?.mbId || selectedMetadata?.musicBrainzId || selectedMetadata?.musicbrainzId || selectedMetadata?.musicbrainz_id || selectedMetadata?.music_brainz_id || selectedMetadata?.musicbrainz || selectedMetadata?.mbid || null, coverUrl: selectedMetadata?.coverUrl || selectedMetadata?.poster || selectedMetadata?.posterUrl || sourceJob.poster_url || null }, selectedPreEncodeScriptIds: normalizeScriptIdList(sourceEncodePlan?.preEncodeScriptIds || []), selectedPostEncodeScriptIds: normalizeScriptIdList(sourceEncodePlan?.postEncodeScriptIds || []), selectedPreEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.preEncodeChainIds || []), selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || []) }; } // end else (sourceTracks.length > 0) } else if (!isAudiobookRetry) { const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile); const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig( retrySettings, mediaProfile ); const resolvedOldRawPath = this.resolveCurrentRawPathForSettings( retrySettings, mediaProfile, sourceJob.raw_path ); if (resolvedOldRawPath) { const oldRawFolderName = path.basename(resolvedOldRawPath); const oldRawLooksLikeJobFolder = /\s-\sRAW\s-\sjob-\d+\s*$/i.test(stripRawStatePrefix(oldRawFolderName)); if (!oldRawLooksLikeJobFolder) { const error = new Error(`Retry nicht möglich: alter RAW-Pfad ist kein Job-RAW-Ordner (${resolvedOldRawPath}).`); error.statusCode = 400; throw error; } const rawDeletionRoots = Array.from(new Set( [ retryRawBaseDir, ...retryRawExtraDirs, path.dirname(String(sourceJob.raw_path || '').trim()) ] .map((dirPath) => normalizeComparablePath(dirPath)) .filter(Boolean) )); const oldRawPathAllowed = rawDeletionRoots.some((rootPath) => isPathInsideDirectory(rootPath, resolvedOldRawPath)); if (!oldRawPathAllowed) { const error = new Error( `Retry nicht möglich: alter RAW-Pfad liegt außerhalb der erlaubten RAW-Verzeichnisse (${resolvedOldRawPath}).` ); error.statusCode = 400; throw error; } try { fs.rmSync(resolvedOldRawPath, { recursive: true, force: true }); } catch (deleteError) { const error = new Error(`Retry nicht möglich: alter RAW-Ordner konnte nicht gelöscht werden (${deleteError.message}).`); error.statusCode = 500; throw error; } await historyService.appendLog( jobId, 'USER_ACTION', `Retry: alter RAW-Ordner wurde entfernt: ${resolvedOldRawPath}` ); sourceJob = await historyService.updateJob(jobId, { raw_path: null, rip_successful: 0 }); } else if (sourceJob.raw_path) { await historyService.appendLog( jobId, 'SYSTEM', `Retry: alter RAW-Pfad ist nicht mehr vorhanden und wird aus dem Job entfernt (${sourceJob.raw_path}).` ); sourceJob = await historyService.updateJob(jobId, { raw_path: null, rip_successful: 0 }); } } const retryJob = await historyService.createJob({ discDevice: sourceJob.disc_device || null, status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'), detectedTitle: sourceJob.detected_title || sourceJob.title || null, jobKind: this.resolveJobKindForJob(sourceJob, { encodePlan: sourceEncodePlan }) }); const retryJobId = Number(retryJob?.id || 0); if (!Number.isFinite(retryJobId) || retryJobId <= 0) { throw new Error('Retry fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); } let retryRawPath = isAudiobookRetry ? (sourceJob.raw_path || null) : null; let retryEncodeInputPath = isAudiobookRetry ? ( sourceJob.encode_input_path || sourceEncodePlan?.encodeInputPath || sourceMakemkvInfo?.rawFilePath || null ) : null; if (retryRawPath) { const previousRetryRawPath = retryRawPath; retryRawPath = await this.alignRawFolderJobId(retryRawPath, retryJobId); if ( previousRetryRawPath && retryRawPath && normalizeComparablePath(previousRetryRawPath) !== normalizeComparablePath(retryRawPath) ) { retryEncodeInputPath = remapPathToRetargetedRawRoot( retryEncodeInputPath, previousRetryRawPath, retryRawPath ); } } const retryUpdatePayload = { parent_job_id: Number(jobId), media_type: preservedRetryMediaType, title: sourceJob.title || null, year: sourceJob.year ?? null, imdb_id: sourceJob.imdb_id || null, poster_url: sourceJob.poster_url || null, omdb_json: sourceJob.omdb_json || null, selected_from_omdb: Number(sourceJob.selected_from_omdb || 0), makemkv_info_json: sourceJob.makemkv_info_json || null, rip_successful: isAudiobookRetry ? 1 : 0, error_message: null, end_time: null, handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: (isCdRetry || isAudiobookRetry) ? (sourceJob.encode_plan_json || null) : null, encode_input_path: retryEncodeInputPath, encode_review_confirmed: isAudiobookRetry ? 1 : 0, output_path: null, raw_path: retryRawPath, status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'), last_state: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING') }; await historyService.updateJob(retryJobId, retryUpdatePayload); // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist if (thumbnailService.isLocalUrl(sourceJob.poster_url)) { const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), retryJobId); if (copiedUrl) { await historyService.updateJob(retryJobId, { poster_url: copiedUrl }).catch(() => {}); } } await historyService.appendLog( retryJobId, 'USER_ACTION', `Retry aus Job #${jobId} gestartet (${isCdRetry ? 'CD' : (isAudiobookRetry ? 'Audiobook' : 'Disc')}).` ); if (preservedRetryMediaType && !explicitSourceMediaType) { await historyService.updateJob(jobId, { media_type: preservedRetryMediaType }).catch(() => {}); } this._releaseDriveLockForJob(jobId, { reason: 'retry_replaced' }); await historyService.retireJobInFavorOf(jobId, retryJobId, { reason: isCdRetry ? 'cd_retry' : (isAudiobookRetry ? 'audiobook_retry' : 'retry') }); this.cancelRequestedByJob.delete(retryJobId); if (isCdRetry) { this.startCdRip(retryJobId, cdRetryConfig || {}).catch((error) => { logger.error('retry:cd:background-failed', { jobId: retryJobId, sourceJobId: jobId, error: errorToMeta(error) }); }); } else if (isAudiobookRetry) { return { jobId: retryJobId, sourceJobId: Number(jobId), replacedSourceJob: true, started: false, queued: false, stage: 'READY_TO_START' }; } else { this.startRipEncode(retryJobId).catch((error) => { logger.error('retry:background-failed', { jobId: retryJobId, sourceJobId: jobId, error: errorToMeta(error) }); }); } return { started: true, sourceJobId: Number(jobId), jobId: retryJobId, replacedSourceJob: true }; } async resumeReadyToEncodeJob(jobId) { const resolvedReadyJob = await this.resolveExistingJobForAction(jobId, 'resume_ready_to_encode'); jobId = resolvedReadyJob.resolvedJobId; this.ensureNotBusy('resumeReadyToEncodeJob', jobId); logger.info('resumeReadyToEncodeJob:requested', { jobId }); const job = resolvedReadyJob.job || await historyService.getJobById(jobId); const isReadyToEncode = job.status === 'READY_TO_ENCODE' || job.last_state === 'READY_TO_ENCODE'; if (!isReadyToEncode) { const error = new Error(`Job ${jobId} ist nicht im Status READY_TO_ENCODE.`); error.statusCode = 409; throw error; } const encodePlan = this.safeParseJson(job.encode_plan_json); if (!encodePlan || !Array.isArray(encodePlan.titles)) { const error = new Error('READY_TO_ENCODE Job kann nicht geladen werden: encode_plan fehlt.'); error.statusCode = 400; throw error; } const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase(); const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip); const reviewConfirmed = Boolean(Number(job.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed); const readyMediaProfile = this.resolveMediaProfileForJob(job, { encodePlan }); const resumeSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile); const resolvedResumeRawPath = this.resolveCurrentRawPathForSettings( resumeSettings, readyMediaProfile, job.raw_path ); const activeResumeRawPath = resolvedResumeRawPath || String(job.raw_path || '').trim() || null; let inputPath = isPreRipMode ? null : (job.encode_input_path || encodePlan?.encodeInputPath || null); if (!isPreRipMode && activeResumeRawPath) { const needsInputRefresh = !inputPath || !fs.existsSync(inputPath) || !isPathInsideDirectory(activeResumeRawPath, inputPath); if (needsInputRefresh) { const selectedPlaylistId = normalizePlaylistId( encodePlan?.selectedPlaylistId || encodePlan?.selectedPlaylist || null ); if (hasBluRayBackupStructure(activeResumeRawPath)) { inputPath = activeResumeRawPath; } else { const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job, selectedPlaylistId); inputPath = findPreferredRawInput(activeResumeRawPath, { playlistAnalysis: playlistDecision.playlistAnalysis, selectedPlaylistId: selectedPlaylistId || playlistDecision.selectedPlaylist })?.path || null; } } } const hasEncodableTitle = isPreRipMode ? Boolean(encodePlan?.encodeInputTitleId) : Boolean(inputPath); const selectedMetadata = { title: job.title || job.detected_title || null, year: job.year || null, imdbId: job.imdb_id || null, poster: job.poster_url || null }; await this.setState('READY_TO_ENCODE', { activeJobId: jobId, progress: 0, eta: null, statusText: hasEncodableTitle ? (reviewConfirmed ? (isPreRipMode ? 'Spurauswahl geladen - Backup/Rip + Encode startbereit' : 'Mediainfo geladen - Encode startbereit') : (isPreRipMode ? 'Spurauswahl geladen - bitte bestätigen' : 'Mediainfo geladen - bitte bestätigen')) : (isPreRipMode ? 'Spurauswahl geladen - kein passender Titel gewählt' : 'Mediainfo geladen - kein Titel erfüllt MIN_LENGTH_MINUTES'), context: { ...(this.snapshot.context || {}), jobId, rawPath: activeResumeRawPath, inputPath, hasEncodableTitle, reviewConfirmed, mode, mediaProfile: readyMediaProfile, sourceJobId: encodePlan?.sourceJobId || null, selectedMetadata, mediaInfoReview: encodePlan } }); await historyService.appendLog( jobId, 'USER_ACTION', 'READY_TO_ENCODE Job nach Neustart in den Ripper geladen.' ); if ( (activeResumeRawPath && normalizeComparablePath(activeResumeRawPath) !== normalizeComparablePath(job.raw_path)) || (!isPreRipMode && inputPath && normalizeComparablePath(inputPath) !== normalizeComparablePath(job.encode_input_path)) ) { const resumeUpdatePayload = {}; if (activeResumeRawPath && normalizeComparablePath(activeResumeRawPath) !== normalizeComparablePath(job.raw_path)) { resumeUpdatePayload.raw_path = activeResumeRawPath; } if (!isPreRipMode) { resumeUpdatePayload.encode_input_path = inputPath; } await historyService.updateJob(jobId, resumeUpdatePayload); } return historyService.getJobById(jobId); } async restartEncodeWithLastSettings(jobId, options = {}) { const immediate = Boolean(options?.immediate); if (!immediate) { // Restart-Encode now prepares an editable READY_TO_ENCODE state first. // No queue slot is needed because encoding is not started automatically here. return this.restartEncodeWithLastSettings(jobId, { ...options, immediate: true }); } const resolvedRestartEncodeJob = await this.resolveExistingJobForAction(jobId, 'restart_encode'); jobId = resolvedRestartEncodeJob.resolvedJobId; this.ensureNotBusy('restartEncodeWithLastSettings', jobId); logger.info('restartEncodeWithLastSettings:requested', { jobId }); this.cancelRequestedByJob.delete(Number(jobId)); const triggerReason = String(options?.triggerReason || 'manual').trim().toLowerCase(); const job = resolvedRestartEncodeJob.job || await historyService.getJobById(jobId); const currentStatus = String(job.status || '').trim().toUpperCase(); if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK'].includes(currentStatus)) { const error = new Error(`Encode-Neustart nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`); error.statusCode = 409; throw error; } const mkInfoForRestart = this.safeParseJson(job.makemkv_info_json); if (mkInfoForRestart?.source === 'orphan_raw_import' && !job.handbrake_info_json) { const error = new Error( 'Encode-Neustart nicht möglich: Job wurde via RAW-Import erstellt und wurde noch nicht vollständig encodiert. Bitte zuerst "Neu einlesen" ausführen.' ); error.statusCode = 409; throw error; } const encodePlan = this.safeParseJson(job.encode_plan_json); if (!encodePlan || !Array.isArray(encodePlan.titles) || encodePlan.titles.length === 0) { const error = new Error('Encode-Neustart nicht möglich: encode_plan fehlt.'); error.statusCode = 400; throw error; } const restartMakemkvInfo = this.safeParseJson(job.makemkv_info_json); const explicitJobMediaType = String(job?.media_type || '').trim().toLowerCase(); const derivedRestartMediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: restartMakemkvInfo, encodePlan }); const preservedRestartMediaType = explicitJobMediaType || ( ['converter', 'cd', 'audiobook'].includes(derivedRestartMediaProfile) ? derivedRestartMediaProfile : null ); const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase(); const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip); const reviewConfirmed = Boolean(Number(job.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed); if (!reviewConfirmed) { const error = new Error('Encode-Neustart nicht möglich: Spurauswahl wurde noch nicht bestätigt.'); error.statusCode = 409; throw error; } const hasEncodableInput = isPreRipMode ? Boolean(encodePlan?.encodeInputTitleId) : Boolean(job.encode_input_path || encodePlan?.encodeInputPath || job.raw_path); if (!hasEncodableInput) { const error = new Error('Encode-Neustart nicht möglich: kein verwertbarer Encode-Input vorhanden.'); error.statusCode = 400; throw error; } const settings = await settingsService.getSettingsMap(); const restartDeleteIncompleteOutput = settings?.handbrake_restart_delete_incomplete_output !== undefined ? Boolean(settings.handbrake_restart_delete_incomplete_output) : true; const handBrakeInfo = this.safeParseJson(job.handbrake_info_json); const encodePreviouslySuccessful = String(handBrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; const previousOutputPath = String(job.output_path || '').trim() || null; const keepBoth = Boolean(options?.keepBoth); const deleteFolders = Array.isArray(options?.deleteFolders) ? options.deleteFolders : []; // Handle explicit folder deletion requested by the user if (deleteFolders.length > 0) { try { const specificDeleteResult = await historyService.deleteSpecificOutputFolders(jobId, deleteFolders); await historyService.appendLog( jobId, 'USER_ACTION', `Encode-Neustart: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` ); } catch (error) { logger.warn('restartEncodeWithLastSettings:delete-specific-folders-failed', { jobId, error: errorToMeta(error) }); } } else if (previousOutputPath && restartDeleteIncompleteOutput && !encodePreviouslySuccessful && !keepBoth) { try { const deleteResult = await historyService.deleteJobFiles(jobId, 'movie'); await historyService.appendLog( jobId, 'USER_ACTION', `Encode-Neustart: unvollständigen Output vor Start entfernt (movie files=${deleteResult?.summary?.movie?.filesDeleted ?? 0}, dirs=${deleteResult?.summary?.movie?.dirsRemoved ?? 0}).` ); } catch (error) { logger.warn('restartEncodeWithLastSettings:delete-incomplete-output-failed', { jobId, outputPath: previousOutputPath, error: errorToMeta(error) }); } } const restartPlan = { ...encodePlan, reviewConfirmed: false, reviewConfirmedAt: null, prefilledFromPreviousRun: true, prefilledFromPreviousRunAt: nowIso() }; const selectedMetadata = { title: job.title || job.detected_title || null, year: job.year || null, imdbId: job.imdb_id || null, poster: job.poster_url || null }; const readyMediaProfile = this.resolveMediaProfileForJob(job, { encodePlan: restartPlan }); const restartSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile); const resolvedRestartRawPath = this.resolveCurrentRawPathForSettings( restartSettings, readyMediaProfile, job.raw_path ); let activeRestartRawPath = resolvedRestartRawPath || String(job.raw_path || '').trim() || null; let inputPath = isPreRipMode ? null : (job.encode_input_path || restartPlan.encodeInputPath || null); if (!isPreRipMode && activeRestartRawPath) { const needsInputRefresh = !inputPath || !fs.existsSync(inputPath) || !isPathInsideDirectory(activeRestartRawPath, inputPath); if (needsInputRefresh) { const selectedPlaylistId = normalizePlaylistId( restartPlan?.selectedPlaylistId || restartPlan?.selectedPlaylist || null ); if (hasBluRayBackupStructure(activeRestartRawPath)) { inputPath = activeRestartRawPath; } else { const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job, selectedPlaylistId); inputPath = findPreferredRawInput(activeRestartRawPath, { playlistAnalysis: playlistDecision.playlistAnalysis, selectedPlaylistId: selectedPlaylistId || playlistDecision.selectedPlaylist })?.path || null; } } } restartPlan.encodeInputPath = inputPath; const hasEncodableTitle = isPreRipMode ? Boolean(restartPlan?.encodeInputTitleId) : Boolean(inputPath); const replacementJob = await historyService.createJob({ discDevice: job.disc_device || null, status: 'READY_TO_ENCODE', detectedTitle: job.detected_title || job.title || null, jobKind: this.resolveJobKindForJob(job, { encodePlan: restartPlan }) }); const replacementJobId = Number(replacementJob?.id || 0); if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { throw new Error('Encode-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); } const previousRestartRawPath = activeRestartRawPath; activeRestartRawPath = await this.alignRawFolderJobId(activeRestartRawPath, replacementJobId); if ( previousRestartRawPath && activeRestartRawPath && normalizeComparablePath(previousRestartRawPath) !== normalizeComparablePath(activeRestartRawPath) ) { inputPath = remapPathToRetargetedRawRoot(inputPath, previousRestartRawPath, activeRestartRawPath); restartPlan.encodeInputPath = inputPath; } await historyService.updateJob(replacementJobId, { parent_job_id: Number(jobId), media_type: preservedRestartMediaType, title: job.title || null, year: job.year ?? null, imdb_id: job.imdb_id || null, poster_url: job.poster_url || null, omdb_json: job.omdb_json || null, selected_from_omdb: Number(job.selected_from_omdb || 0), status: 'READY_TO_ENCODE', last_state: 'READY_TO_ENCODE', error_message: null, end_time: null, output_path: null, disc_device: job.disc_device || null, raw_path: activeRestartRawPath || null, rip_successful: Number(job.rip_successful || 0), makemkv_info_json: job.makemkv_info_json || null, handbrake_info_json: null, mediainfo_info_json: job.mediainfo_info_json || null, encode_plan_json: JSON.stringify(restartPlan), encode_input_path: inputPath, encode_review_confirmed: 0 }); // Keep local poster thumbnails valid for the replacement job id. if (thumbnailService.isLocalUrl(job.poster_url)) { const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId); if (copiedUrl) { await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {}); } } const loadedSelectionText = ( previousOutputPath ? `Letzte bestätigte Auswahl wurde geladen und kann angepasst werden. Vorheriger Output-Pfad: ${previousOutputPath}. autoDeleteIncomplete=${restartDeleteIncompleteOutput ? 'on' : 'off'}` : 'Letzte bestätigte Auswahl wurde geladen und kann angepasst werden.' ); let restartLogMessage; if (triggerReason === 'cancelled_encode') { restartLogMessage = `Encode wurde abgebrochen. ${loadedSelectionText}`; } else if (triggerReason === 'failed_encode') { restartLogMessage = `Encode ist fehlgeschlagen. ${loadedSelectionText}`; } else if (triggerReason === 'server_restart') { restartLogMessage = `Server-Neustart während Encode erkannt. ${loadedSelectionText}`; } else if (triggerReason === 'confirm_auto_prepare') { restartLogMessage = `Status war nicht READY_TO_ENCODE. ${loadedSelectionText}`; } else { restartLogMessage = `Encode-Neustart angefordert. ${loadedSelectionText}`; } await historyService.appendLog(replacementJobId, 'USER_ACTION', restartLogMessage); if (preservedRestartMediaType && !explicitJobMediaType) { await historyService.updateJob(jobId, { media_type: preservedRestartMediaType }).catch(() => {}); } await historyService.retireJobInFavorOf(jobId, replacementJobId, { reason: 'restart_encode' }); await this.setState('READY_TO_ENCODE', { activeJobId: replacementJobId, progress: 0, eta: null, statusText: hasEncodableTitle ? (isPreRipMode ? 'Vorherige Spurauswahl geladen - anpassen und Backup/Rip + Encode starten' : 'Vorherige Encode-Auswahl geladen - anpassen und Encoding starten') : (isPreRipMode ? 'Vorherige Spurauswahl geladen - kein passender Titel gewählt' : 'Vorherige Encode-Auswahl geladen - kein Titel erfüllt MIN_LENGTH_MINUTES'), context: { ...(this.snapshot.context || {}), jobId: replacementJobId, rawPath: activeRestartRawPath, inputPath, hasEncodableTitle, reviewConfirmed: false, mode, mediaProfile: readyMediaProfile, sourceJobId: Number(jobId), selectedMetadata, mediaInfoReview: restartPlan } }); return { restarted: true, started: false, stage: 'READY_TO_ENCODE', reviewConfirmed: false, sourceJobId: Number(jobId), jobId: replacementJobId, replacedSourceJob: true }; } async restartReviewFromRaw(jobId, options = {}) { const resolvedRestartReviewJob = await this.resolveExistingJobForAction(jobId, 'restart_review'); jobId = resolvedRestartReviewJob.resolvedJobId; this.ensureNotBusy('restartReviewFromRaw', jobId); logger.info('restartReviewFromRaw:requested', { jobId, options }); this.cancelRequestedByJob.delete(Number(jobId)); const sourceJob = resolvedRestartReviewJob.job || await historyService.getJobById(jobId); if (!sourceJob.raw_path) { const error = new Error('Review-Neustart nicht möglich: raw_path fehlt.'); error.statusCode = 400; throw error; } const reviewMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json); const reviewEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); const reviewMediaProfile = this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: reviewMakemkvInfo, encodePlan: reviewEncodePlan, rawPath: sourceJob.raw_path }); const explicitSourceMediaType = String(sourceJob?.media_type || '').trim().toLowerCase(); const preservedReviewMediaType = explicitSourceMediaType || ( ['converter', 'cd', 'audiobook'].includes(reviewMediaProfile) ? reviewMediaProfile : null ); const reviewSettings = await settingsService.getSettingsMap(); let resolvedReviewRawPath = this.resolveCurrentRawPathForSettings( reviewSettings, reviewMediaProfile, sourceJob.raw_path ); if (!resolvedReviewRawPath) { const storedRawPath = String(sourceJob.raw_path || '').trim(); const storedFolderName = path.basename(storedRawPath); const rawState = resolveRawFolderStateFromPath(storedRawPath); const strippedFolderName = stripRawStatePrefix(storedFolderName); const metadataBase = strippedFolderName.replace(/\s-\sRAW\s-\sjob-\d+\s*$/i, '').trim(); const recoveryRoots = Array.from(new Set([ path.dirname(storedRawPath), String(reviewMakemkvInfo?.rawPath || '').trim(), String(reviewMakemkvInfo?.importContext?.requestedRawPath || '').trim() ].filter(Boolean))); let recoveredRawPath = null; for (const rootCandidate of recoveryRoots) { const rootDir = fs.existsSync(rootCandidate) && fs.statSync(rootCandidate).isDirectory() ? rootCandidate : path.dirname(rootCandidate); if (!rootDir || !fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) { continue; } if (metadataBase) { const byMetadata = findExistingRawDirectory(rootDir, metadataBase); if (byMetadata) { recoveredRawPath = byMetadata; break; } const normalizedJobId = this.normalizeQueueJobId(jobId); if (normalizedJobId) { const targetedFolder = buildRawDirName(metadataBase, normalizedJobId, { state: rawState }); const targetedPath = path.join(rootDir, targetedFolder); if (fs.existsSync(targetedPath) && fs.statSync(targetedPath).isDirectory()) { recoveredRawPath = targetedPath; break; } } } } if (recoveredRawPath) { resolvedReviewRawPath = recoveredRawPath; await historyService.updateJob(jobId, { raw_path: recoveredRawPath }).catch(() => {}); await historyService.appendLog( jobId, 'SYSTEM', `Review-Neustart: RAW-Pfad automatisch korrigiert: ${sourceJob.raw_path || '-'} -> ${recoveredRawPath}` ).catch(() => {}); } else { const error = new Error(`Review-Neustart nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`); error.statusCode = 400; throw error; } } const resolvedReviewInput = hasBluRayBackupStructure(resolvedReviewRawPath) ? { path: resolvedReviewRawPath } : findPreferredRawInput(resolvedReviewRawPath); const hasRawInput = Boolean(resolvedReviewInput?.path); if (!hasRawInput) { let hasAnyRawEntries = false; try { hasAnyRawEntries = fs.readdirSync(resolvedReviewRawPath).length > 0; } catch (_error) { hasAnyRawEntries = false; } if (!hasAnyRawEntries) { const error = new Error('Review-Neustart nicht möglich: keine Mediendateien im RAW-Pfad gefunden. Disc muss zuerst gerippt werden.'); error.statusCode = 400; throw error; } await historyService.appendLog( jobId, 'SYSTEM', `Review-Neustart: keine direkten Mediendateien erkannt, versuche Analyse trotzdem mit RAW-Pfad ${resolvedReviewRawPath}.` ); } const existingEncodeInputPath = String(sourceJob.encode_input_path || '').trim() || null; const shouldRealignEncodeInput = Boolean( resolvedReviewInput?.path && ( !existingEncodeInputPath || !fs.existsSync(existingEncodeInputPath) || isEncodeInputMismatchedWithRaw(resolvedReviewRawPath, existingEncodeInputPath) ) ); const normalizedReviewInputPath = shouldRealignEncodeInput ? resolvedReviewInput.path : existingEncodeInputPath; const currentStatus = String(sourceJob.status || '').trim().toUpperCase(); if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(currentStatus)) { const error = new Error(`Review-Neustart nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`); error.statusCode = 409; throw error; } const reviewDeleteFolders = Array.isArray(options?.deleteFolders) ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) : []; if (reviewDeleteFolders.length > 0) { try { const specificDeleteResult = await historyService.deleteSpecificOutputFolders(jobId, reviewDeleteFolders); await historyService.appendLog( jobId, 'USER_ACTION', `Review-Neustart: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` ); } catch (error) { logger.warn('restartReviewFromRaw:delete-specific-folders-failed', { jobId, error: errorToMeta(error) }); } } const staleQueueIndex = this.findQueueEntryIndexByJobId(Number(jobId)); let removedQueueActionLabel = null; if (staleQueueIndex >= 0) { const [removed] = this.queueEntries.splice(staleQueueIndex, 1); removedQueueActionLabel = QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'; await this.emitQueueChanged(); } const forcePlaylistReselection = Boolean(options?.forcePlaylistReselection); const previousEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json); const nextMakemkvInfoJson = mkInfo && typeof mkInfo === 'object' ? JSON.stringify({ ...mkInfo, rawPath: resolvedReviewRawPath, analyzeContext: { ...(mkInfo?.analyzeContext || {}), playlistAnalysis: null, playlistDecisionRequired: false, selectedPlaylist: null, selectedTitleId: null, handBrakePlaylistScan: null }, postBackupAnalyze: null }) : sourceJob.makemkv_info_json; const jobUpdatePayload = { status: 'MEDIAINFO_CHECK', last_state: 'MEDIAINFO_CHECK', start_time: nowIso(), end_time: null, error_message: null, output_path: null, handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: null, encode_input_path: normalizedReviewInputPath || null, encode_review_confirmed: 0, makemkv_info_json: nextMakemkvInfoJson, raw_path: resolvedReviewRawPath }; const reuseCurrentJob = Boolean(options?.reuseCurrentJob); const inheritedSourceJobId = this.normalizeQueueJobId(previousEncodePlan?.sourceJobId) || this.normalizeQueueJobId(sourceJob.parent_job_id) || null; const reviewSourceJobId = reuseCurrentJob ? inheritedSourceJobId : Number(jobId); let targetJobId = Number(jobId); let replacedSourceJob = false; let activeReviewRawPath = resolvedReviewRawPath; let activeReviewInputPath = normalizedReviewInputPath || null; if (reuseCurrentJob) { await historyService.resetProcessLog(targetJobId); await historyService.updateJob(targetJobId, { ...jobUpdatePayload, media_type: preservedReviewMediaType, output_path: null, handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: null, encode_input_path: activeReviewInputPath, encode_review_confirmed: 0 }); } else { const replacementJob = await historyService.createJob({ discDevice: sourceJob.disc_device || null, status: 'MEDIAINFO_CHECK', detectedTitle: sourceJob.detected_title || sourceJob.title || null, jobKind: this.resolveJobKindForJob(sourceJob, { encodePlan: previousEncodePlan }) }); const replacementJobId = Number(replacementJob?.id || 0); if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { throw new Error('Review-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); } const previousReviewRawPath = activeReviewRawPath; activeReviewRawPath = await this.alignRawFolderJobId(activeReviewRawPath, replacementJobId); if ( previousReviewRawPath && activeReviewRawPath && normalizeComparablePath(previousReviewRawPath) !== normalizeComparablePath(activeReviewRawPath) ) { activeReviewInputPath = remapPathToRetargetedRawRoot( activeReviewInputPath, previousReviewRawPath, activeReviewRawPath ); } await historyService.updateJob(replacementJobId, { parent_job_id: Number(jobId), media_type: preservedReviewMediaType, title: sourceJob.title || null, year: sourceJob.year ?? null, imdb_id: sourceJob.imdb_id || null, poster_url: sourceJob.poster_url || null, omdb_json: sourceJob.omdb_json || null, selected_from_omdb: Number(sourceJob.selected_from_omdb || 0), disc_device: sourceJob.disc_device || null, rip_successful: Number(sourceJob.rip_successful || 0), output_path: null, handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: null, encode_input_path: activeReviewInputPath, encode_review_confirmed: 0, ...jobUpdatePayload, raw_path: activeReviewRawPath, encode_input_path: activeReviewInputPath }); // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist if (thumbnailService.isLocalUrl(sourceJob.poster_url)) { const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId); if (copiedUrl) { await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {}); } } targetJobId = replacementJobId; replacedSourceJob = true; } if (removedQueueActionLabel) { await historyService.appendLog( targetJobId, 'USER_ACTION', `Queue-Eintrag entfernt (Review-Neustart): ${removedQueueActionLabel}` ); } if (shouldRealignEncodeInput) { await historyService.appendLog( targetJobId, 'SYSTEM', `Review-Neustart: Encode-Input auf aktuellen RAW-Pfad abgeglichen: ${existingEncodeInputPath || '-'} -> ${activeReviewInputPath || '-'}` ); } await historyService.appendLog( targetJobId, 'USER_ACTION', `Review-Neustart aus RAW angefordert.${reuseCurrentJob ? ' Bestehende Job-ID wird weiterverwendet.' : ''}${forcePlaylistReselection ? ' Playlist-Auswahl wird zurückgesetzt.' : ''} MakeMKV Full-Analyse wird vollständig neu ausgeführt.` ); if (!reuseCurrentJob && preservedReviewMediaType && !explicitSourceMediaType) { await historyService.updateJob(jobId, { media_type: preservedReviewMediaType }).catch(() => {}); } if (!reuseCurrentJob) { await historyService.retireJobInFavorOf(jobId, targetJobId, { reason: 'restart_review' }); } await this.setState('MEDIAINFO_CHECK', { activeJobId: targetJobId, progress: 0, eta: null, statusText: 'Titel-/Spurprüfung wird neu gestartet...', context: { ...(this.snapshot.context || {}), jobId: targetJobId, reviewConfirmed: false, mediaInfoReview: null } }); this.runReviewForRawJob(targetJobId, activeReviewRawPath, { mode: options?.mode || 'reencode', sourceJobId: reviewSourceJobId, forcePlaylistReselection, forceFreshAnalyze: true, previousEncodePlan }).catch((error) => { logger.error('restartReviewFromRaw:background-failed', { jobId: targetJobId, sourceJobId: jobId, error: errorToMeta(error) }); this.failJob(targetJobId, 'MEDIAINFO_CHECK', error).catch((failError) => { logger.error('restartReviewFromRaw:background-failJob-failed', { jobId: targetJobId, error: errorToMeta(failError) }); }); }); return { restarted: true, started: true, stage: 'MEDIAINFO_CHECK', sourceJobId: Number(jobId), jobId: targetJobId, replacedSourceJob }; } async cancel(jobId = null) { const normalizedJobId = this.normalizeQueueJobId(jobId) || this.normalizeQueueJobId(this.snapshot.activeJobId) || this.normalizeQueueJobId(this.snapshot.context?.jobId) || this.normalizeQueueJobId(Array.from(this.activeProcesses.keys())[0]); if (!normalizedJobId) { const error = new Error('Kein laufender Prozess zum Abbrechen.'); error.statusCode = 409; throw error; } const processHandle = this.activeProcesses.get(normalizedJobId) || null; if (!processHandle) { const queuedIndex = this.findQueueEntryIndexByJobId(normalizedJobId); if (queuedIndex >= 0) { const [removed] = this.queueEntries.splice(queuedIndex, 1); await historyService.appendLog( normalizedJobId, 'USER_ACTION', `Aus Queue entfernt: ${QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'}` ); await this.emitQueueChanged(); return { cancelled: true, queuedOnly: true, jobId: normalizedJobId }; } } const buildForcedCancelError = (message) => { const reason = String(message || 'Vom Benutzer hart abgebrochen.').trim() || 'Vom Benutzer hart abgebrochen.'; const endedAt = nowIso(); const error = new Error(reason); error.statusCode = 409; error.runInfo = { source: 'USER_CANCEL', stage: this.snapshot.state || null, cmd: null, args: [], startedAt: endedAt, endedAt, durationMs: 0, status: 'CANCELLED', exitCode: null, stdoutLines: 0, stderrLines: 0, lastProgress: 0, eta: null, lastDetail: null, highlights: [] }; return error; }; const forceFinalizeCancelledJob = async (reason, stageHint = null) => { const rawStage = String(stageHint || this.snapshot.state || '').trim().toUpperCase(); const effectiveStage = RUNNING_STATES.has(rawStage) ? rawStage : ( RUNNING_STATES.has(String(this.snapshot.state || '').trim().toUpperCase()) ? String(this.snapshot.state || '').trim().toUpperCase() : 'ENCODING' ); try { await historyService.appendLog(normalizedJobId, 'USER_ACTION', reason); } catch (_error) { // continue with force-cancel even if logging failed } try { await this.failJob(normalizedJobId, effectiveStage, buildForcedCancelError(reason)); } catch (forceError) { logger.error('cancel:force-finalize:failed', { jobId: normalizedJobId, stage: effectiveStage, reason, error: errorToMeta(forceError) }); const fallbackJob = await historyService.getJobById(normalizedJobId); await historyService.updateJob(normalizedJobId, { status: 'CANCELLED', last_state: 'CANCELLED', end_time: nowIso(), error_message: reason }); await this.setState('CANCELLED', { activeJobId: normalizedJobId, progress: this.snapshot.progress, eta: null, statusText: reason, context: { jobId: normalizedJobId, rawPath: fallbackJob?.raw_path || null, error: reason, canRestartReviewFromRaw: Boolean(fallbackJob?.raw_path) } }); } finally { this.cancelRequestedByJob.delete(normalizedJobId); this.activeProcesses.delete(normalizedJobId); this.syncPrimaryActiveProcess(); } return { cancelled: true, queuedOnly: false, forced: true, jobId: normalizedJobId }; }; const runningJob = await historyService.getJobById(normalizedJobId); const runningStatus = String( runningJob?.status || runningJob?.last_state || this.snapshot.state || '' ).trim().toUpperCase(); const SOFT_CANCELABLE_STATES = new Set([ 'READY_TO_START', 'READY_TO_ENCODE', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP' ]); if (!processHandle) { if (SOFT_CANCELABLE_STATES.has(runningStatus)) { // Kein laufender Prozess – Job direkt abbrechen (Review-/Ready-Phasen) const cancelMessage = 'Vom Benutzer abgebrochen.'; await historyService.updateJob(normalizedJobId, { status: 'CANCELLED', // Preserve origin state so ripper/history can distinguish review-cancelled rows. last_state: runningStatus, end_time: nowIso(), error_message: cancelMessage }); const cdDriveForJob = this._getCdDriveByJobId(normalizedJobId); if (cdDriveForJob?.devicePath) { if (String(cdDriveForJob.devicePath).startsWith('__virtual__')) { this._removeCdDrive(cdDriveForJob.devicePath); } else { this._releaseCdDrive(cdDriveForJob.devicePath, { device: cdDriveForJob.device || null }); } } else { this.jobProgress.delete(normalizedJobId); } await historyService.appendLog(normalizedJobId, 'USER_ACTION', `Abbruch im Status ${runningStatus}.`); await this.setState('CANCELLED', { activeJobId: normalizedJobId, progress: 0, eta: null, statusText: cancelMessage, context: { jobId: normalizedJobId, rawPath: runningJob?.raw_path || null, error: cancelMessage, cancelledFromState: runningStatus, canRestartReviewFromRaw: Boolean(runningJob?.raw_path) } }); return { cancelled: true, queuedOnly: false, jobId: normalizedJobId }; } if (RUNNING_STATES.has(runningStatus)) { return forceFinalizeCancelledJob( `Abbruch erzwungen: kein aktiver Prozess-Handle gefunden (Status ${runningStatus}).`, runningStatus ); } const error = new Error(`Kein laufender Prozess für Job #${normalizedJobId} zum Abbrechen.`); error.statusCode = 409; throw error; } let removedQueuedActionLabel = null; const staleQueueIndex = this.findQueueEntryIndexByJobId(normalizedJobId); if (staleQueueIndex >= 0) { const [removed] = this.queueEntries.splice(staleQueueIndex, 1); removedQueuedActionLabel = QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'; await this.emitQueueChanged(); try { await historyService.appendLog( normalizedJobId, 'SYSTEM', `Veralteter Queue-Eintrag beim Abbruch entfernt: ${removedQueuedActionLabel}` ); } catch (_error) { // keep cancel flow even if stale queue entry logging fails } } logger.warn('cancel:requested', { state: this.snapshot.state, activeJobId: this.snapshot.activeJobId, requestedJobId: normalizedJobId, pid: processHandle?.child?.pid || null, removedQueuedAction: removedQueuedActionLabel }); this.cancelRequestedByJob.add(normalizedJobId); processHandle.cancel(); try { await historyService.appendLog( normalizedJobId, 'USER_ACTION', `Abbruch angefordert (hard-cancel). Status=${runningStatus || '-'}.` ); } catch (_error) { // keep hard-cancel flow even if logging fails } const settleResult = await Promise.race([ Promise.resolve(processHandle.promise) .then(() => 'settled') .catch(() => 'settled'), new Promise((resolve) => setTimeout(() => resolve('timeout'), 2200)) ]); const stillActive = this.activeProcesses.has(normalizedJobId); if (settleResult === 'settled' && !stillActive) { return { cancelled: true, queuedOnly: false, jobId: normalizedJobId }; } logger.error('cancel:hard-timeout', { jobId: normalizedJobId, runningStatus, settleResult, stillActive, pid: processHandle?.child?.pid || null }); try { processHandle.cancel(); } catch (_error) { // ignore second cancel errors } const childPid = Number(processHandle?.child?.pid); if (Number.isFinite(childPid) && childPid > 0) { try { process.kill(-childPid, 'SIGKILL'); } catch (_error) { /* noop */ } try { process.kill(childPid, 'SIGKILL'); } catch (_error) { /* noop */ } } try { processHandle?.child?.kill?.('SIGKILL'); } catch (_error) { // noop } this.activeProcesses.delete(normalizedJobId); this.syncPrimaryActiveProcess(); return forceFinalizeCancelledJob( `Abbruch erzwungen: Prozess reagierte nicht rechtzeitig auf Kill-Signal (Status ${runningStatus || '-'}).`, runningStatus ); } async runCommand({ jobId, stage, source, cmd, args, parser, collectLines = null, collectStdoutLines = true, collectStderrLines = true, argsForLog = null, silent = false, onStdoutLine = null, onStderrLine = null }) { const normalizedJobId = this.normalizeQueueJobId(jobId) || Number(jobId) || jobId; const loggableArgs = Array.isArray(argsForLog) ? argsForLog : args; if (this.cancelRequestedByJob.has(Number(normalizedJobId))) { const cancelError = new Error('Job wurde vom Benutzer abgebrochen.'); cancelError.statusCode = 409; const endedAt = nowIso(); cancelError.runInfo = { source, stage, cmd, args: loggableArgs, startedAt: endedAt, endedAt, durationMs: 0, status: 'CANCELLED', exitCode: null, stdoutLines: 0, stderrLines: 0, stdoutTail: '', stderrTail: '', stdoutTruncated: false, stderrTruncated: false, lastProgress: 0, eta: null, lastDetail: null, highlights: [] }; logger.warn('command:cancelled-before-spawn', { jobId: normalizedJobId, stage, source }); throw cancelError; } await historyService.appendLog(jobId, 'SYSTEM', `Spawn ${cmd} ${loggableArgs.join(' ')}`); logger.info('command:spawn', { jobId, stage, source, cmd, args: loggableArgs }); const runInfo = { source, stage, cmd, args: loggableArgs, startedAt: nowIso(), endedAt: null, durationMs: null, status: 'RUNNING', exitCode: null, stdoutLines: 0, stderrLines: 0, stdoutTail: '', stderrTail: '', stdoutTruncated: false, stderrTruncated: false, lastProgress: 0, eta: null, lastDetail: null, highlights: [] }; const applyLine = (line, isStderr) => { const text = truncateLine(line, 400); if (isStderr) { runInfo.stderrLines += 1; } else { runInfo.stdoutLines += 1; } const detail = extractProgressDetail(source, text); if (detail) { runInfo.lastDetail = detail; } if (runInfo.highlights.length < 120 && shouldKeepHighlight(text)) { runInfo.highlights.push(text); } if (parser && !silent) { const progress = parser(text); if (progress && progress.percent !== null) { runInfo.lastProgress = progress.percent; runInfo.eta = progress.eta || runInfo.eta; const statusText = composeStatusText(stage, progress.percent, runInfo.lastDetail); void this.updateProgress(stage, progress.percent, progress.eta, statusText, normalizedJobId); } else if (detail) { const jobEntry = this.jobProgress.get(Number(normalizedJobId)); const currentProgress = jobEntry?.progress ?? Number(this.snapshot.progress || 0); const currentEta = jobEntry?.eta ?? runInfo.eta ?? null; const statusText = composeStatusText(stage, currentProgress, runInfo.lastDetail); void this.updateProgress(stage, currentProgress, currentEta, statusText, normalizedJobId); } } }; const processHandle = spawnTrackedProcess({ cmd, args, context: { jobId, stage, source }, onStdoutLine: (line) => { if (collectLines && collectStdoutLines) { collectLines.push(line); } void historyService.appendProcessLog(jobId, source, line); const nextStdout = appendTailText(runInfo.stdoutTail, line); runInfo.stdoutTail = nextStdout.value; runInfo.stdoutTruncated = runInfo.stdoutTruncated || nextStdout.truncated; if (typeof onStdoutLine === 'function') { try { onStdoutLine(line); } catch (_error) { // ignore observer failures for live runtime mirroring } } applyLine(line, false); }, onStderrLine: (line) => { if (collectLines && collectStderrLines) { collectLines.push(line); } void historyService.appendProcessLog(jobId, `${source}_ERR`, line); const nextStderr = appendTailText(runInfo.stderrTail, line); runInfo.stderrTail = nextStderr.value; runInfo.stderrTruncated = runInfo.stderrTruncated || nextStderr.truncated; if (typeof onStderrLine === 'function') { try { onStderrLine(line); } catch (_error) { // ignore observer failures for live runtime mirroring } } applyLine(line, true); } }); const normalizedProcessKey = Number(normalizedJobId); const previousProcessHandle = this.activeProcesses.get(normalizedProcessKey) || null; this.activeProcesses.set(normalizedProcessKey, processHandle); this.syncPrimaryActiveProcess(); try { const procResult = await processHandle.promise; runInfo.status = 'SUCCESS'; runInfo.exitCode = procResult.code; runInfo.endedAt = nowIso(); runInfo.durationMs = new Date(runInfo.endedAt).getTime() - new Date(runInfo.startedAt).getTime(); await historyService.appendLog(jobId, 'SYSTEM', `${source} abgeschlossen.`); logger.info('command:completed', { jobId, stage, source }); return runInfo; } catch (error) { if (this.cancelRequestedByJob.has(Number(normalizedJobId))) { const cancelError = new Error('Job wurde vom Benutzer abgebrochen.'); cancelError.statusCode = 409; runInfo.status = 'CANCELLED'; runInfo.exitCode = null; runInfo.endedAt = nowIso(); runInfo.durationMs = new Date(runInfo.endedAt).getTime() - new Date(runInfo.startedAt).getTime(); cancelError.runInfo = runInfo; logger.warn('command:cancelled', { jobId, stage, source }); throw cancelError; } runInfo.status = 'ERROR'; runInfo.exitCode = error.code ?? null; runInfo.endedAt = nowIso(); runInfo.durationMs = new Date(runInfo.endedAt).getTime() - new Date(runInfo.startedAt).getTime(); runInfo.errorMessage = error.message; error.runInfo = runInfo; logger.error('command:failed', { jobId, stage, source, error: errorToMeta(error) }); throw error; } finally { const activeHandleForJob = this.activeProcesses.get(normalizedProcessKey) || null; if (activeHandleForJob === processHandle) { if (previousProcessHandle && previousProcessHandle !== processHandle) { this.activeProcesses.set(normalizedProcessKey, previousProcessHandle); } else { this.activeProcesses.delete(normalizedProcessKey); } } // Preserve cancellation marker for outer/orchestrating handles (e.g. CD shared handle). if (!(previousProcessHandle && previousProcessHandle !== processHandle)) { this.cancelRequestedByJob.delete(normalizedProcessKey); } this.syncPrimaryActiveProcess(); await historyService.closeProcessLog(jobId); await this.emitQueueChanged(); void this.pumpQueue(); } } async failJob(jobId, stage, error) { const message = error?.message || String(error); const isCancelled = /abgebrochen|cancelled/i.test(message) || String(error?.runInfo?.status || '').trim().toUpperCase() === 'CANCELLED'; const normalizedStage = String(stage || '').trim().toUpperCase(); const job = await historyService.getJobById(jobId); const title = job?.title || job?.detected_title || `Job #${jobId}`; const finalState = isCancelled ? 'CANCELLED' : 'ERROR'; logger[isCancelled ? 'warn' : 'error']('job:failed', { jobId, stage, error: errorToMeta(error) }); const makemkvInfo = this.safeParseJson(job?.makemkv_info_json); const encodePlan = this.safeParseJson(job?.encode_plan_json); const resolvedMediaProfile = this.resolveMediaProfileForJob(job, { encodePlan, makemkvInfo, mediaProfile: normalizedStage.startsWith('CD_') ? 'cd' : null }); const isCdFailure = resolvedMediaProfile === 'cd' || normalizedStage.startsWith('CD_') || String(job?.status || '').trim().toUpperCase().startsWith('CD_') || String(job?.last_state || '').trim().toUpperCase().startsWith('CD_') || (Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0); const mode = String(encodePlan?.mode || '').trim().toLowerCase(); const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip); const hasEncodableInput = isPreRipMode ? Boolean(encodePlan?.encodeInputTitleId) : Boolean(job?.encode_input_path || encodePlan?.encodeInputPath || job?.raw_path); const hasConfirmedPlan = Boolean( encodePlan && Array.isArray(encodePlan?.titles) && encodePlan.titles.length > 0 && (Number(job?.encode_review_confirmed || 0) === 1 || Boolean(encodePlan?.reviewConfirmed)) && hasEncodableInput ); let hasRawPath = false; try { hasRawPath = Boolean( job?.raw_path && fs.existsSync(job.raw_path) && (hasBluRayBackupStructure(job.raw_path) || findPreferredRawInput(job.raw_path)) ); } catch (_error) { hasRawPath = false; } const shouldAutoRecoverEncode = normalizedStage === 'ENCODING' && hasConfirmedPlan && (!isCancelled || resolvedMediaProfile === 'converter'); if (shouldAutoRecoverEncode) { try { const recoveryReasonLabel = isCancelled ? 'Abbruch' : 'Fehler'; await historyService.appendLog( jobId, 'SYSTEM', `${recoveryReasonLabel} in ${stage}: ${message}. Letzte Encode-Auswahl wird zur direkten Anpassung geladen.` ); await this.restartEncodeWithLastSettings(jobId, { immediate: true, triggerReason: isCancelled ? 'cancelled_encode' : 'failed_encode' }); this.cancelRequestedByJob.delete(Number(jobId)); return; } catch (recoveryError) { logger.error('job:encoding:auto-recover-failed', { jobId, stage, error: errorToMeta(recoveryError) }); await historyService.appendLog( jobId, 'SYSTEM', `Auto-Recovery nach Encode-Abbruch fehlgeschlagen: ${recoveryError?.message || 'unknown'}` ); } } const normalizedJobStatus = String(job?.status || '').trim().toUpperCase(); const normalizedJobLastState = String(job?.last_state || '').trim().toUpperCase(); const ripStageForLockRecovery = ( normalizedStage === 'CD_RIPPING' || normalizedJobStatus === 'CD_RIPPING' || normalizedJobLastState === 'CD_RIPPING' ) ? 'CD_RIPPING' : ( normalizedStage === 'RIPPING' || normalizedJobStatus === 'RIPPING' || normalizedJobLastState === 'RIPPING' ) ? 'RIPPING' : null; const shouldPreserveRipStageForLock = Boolean( ripStageForLockRecovery && Number(job?.rip_successful || 0) !== 1 && (finalState === 'ERROR' || finalState === 'CANCELLED') ); const persistedLastState = shouldPreserveRipStageForLock ? ripStageForLockRecovery : finalState; await historyService.updateJob(jobId, { status: finalState, last_state: persistedLastState, end_time: nowIso(), error_message: message }); await historyService.appendLog( jobId, 'SYSTEM', `${isCancelled ? 'Abbruch' : 'Fehler'} in ${stage}: ${message}` ); const jobProgressContext = this.jobProgress.get(Number(jobId))?.context; const cdSelectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' ? makemkvInfo.selectedMetadata : {}; const fallbackCdArtist = Array.isArray(makemkvInfo?.tracks) ? ( makemkvInfo.tracks .map((track) => String(track?.artist || '').trim()) .find(Boolean) || null ) : null; const resolvedCdMbId = String( cdSelectedMetadata?.mbId || cdSelectedMetadata?.musicBrainzId || cdSelectedMetadata?.musicbrainzId || cdSelectedMetadata?.musicbrainz_id || cdSelectedMetadata?.music_brainz_id || cdSelectedMetadata?.musicbrainz || cdSelectedMetadata?.mbid || '' ).trim() || null; const resolvedCdCoverUrl = String( cdSelectedMetadata?.coverUrl || cdSelectedMetadata?.poster || cdSelectedMetadata?.posterUrl || job?.poster_url || '' ).trim() || null; const resolvedSelectedMetadata = isCdFailure ? { title: cdSelectedMetadata?.title || job?.title || job?.detected_title || null, artist: cdSelectedMetadata?.artist || fallbackCdArtist || null, year: cdSelectedMetadata?.year ?? job?.year ?? null, mbId: resolvedCdMbId, coverUrl: resolvedCdCoverUrl, imdbId: job?.imdb_id || null, poster: job?.poster_url || resolvedCdCoverUrl || null } : { title: job?.title || job?.detected_title || null, year: job?.year || null, imdbId: job?.imdb_id || null, poster: job?.poster_url || null }; const resolvedTracks = isCdFailure ? ( Array.isArray(jobProgressContext?.tracks) && jobProgressContext.tracks.length > 0 ? jobProgressContext.tracks : (Array.isArray(makemkvInfo?.tracks) ? makemkvInfo.tracks : []) ) : []; const resolvedCdRipConfig = isCdFailure ? ( jobProgressContext?.cdRipConfig && typeof jobProgressContext.cdRipConfig === 'object' ? jobProgressContext.cdRipConfig : (encodePlan && typeof encodePlan === 'object' ? encodePlan : null) ) : null; const failContext = { ...(jobProgressContext && typeof jobProgressContext === 'object' ? jobProgressContext : {}), jobId, stage, error: message, rawPath: job?.raw_path || null, outputPath: job?.output_path || null, mediaProfile: isCdFailure ? 'cd' : resolvedMediaProfile, inputPath: job?.encode_input_path || encodePlan?.encodeInputPath || null, selectedMetadata: resolvedSelectedMetadata, ...(isCdFailure ? { tracks: resolvedTracks, cdRipConfig: resolvedCdRipConfig, cdLive: jobProgressContext?.cdLive || null, devicePath: String(job?.disc_device || jobProgressContext?.devicePath || '').trim() || null, cdparanoiaCmd: String(makemkvInfo?.cdparanoiaCmd || jobProgressContext?.cdparanoiaCmd || '').trim() || null } : {}), canRestartEncodeFromLastSettings: hasConfirmedPlan, canRestartReviewFromRaw: resolvedMediaProfile !== 'audiobook' && hasRawPath }; if (isCdFailure) { const resolvedCdDrivePath = ( String(job?.disc_device || '').trim() || String(jobProgressContext?.devicePath || '').trim() || String(jobProgressContext?.virtualDrivePath || '').trim() || String(this._getCdDriveByJobId(jobId)?.devicePath || '').trim() || null ); const normalizedFailStage = String(stage || '').trim().toUpperCase(); const keepCdDriveLockedOnCancel = Boolean( isCancelled && Number(job?.rip_successful || 0) !== 1 && ( this._getDriveLockByJobId(jobId) || normalizedFailStage === 'CD_RIPPING' || String(job?.last_state || '').trim().toUpperCase() === 'CD_RIPPING' ) ); if (resolvedCdDrivePath) { this._setCdDriveState(resolvedCdDrivePath, { state: finalState, jobId, progress: this.cdDrives.get(resolvedCdDrivePath)?.progress ?? 0, eta: null, statusText: message, context: failContext }); if (isCancelled) { if (keepCdDriveLockedOnCancel) { logger.warn('cd:drive:retain-lock-after-cancel', { jobId, devicePath: resolvedCdDrivePath, stage: normalizedFailStage }); } else { this._releaseDriveLockForJob(jobId, { reason: 'cancelled_without_pending_rip' }); if (String(resolvedCdDrivePath).startsWith('__virtual__')) { this._removeCdDrive(resolvedCdDrivePath); } else { this._releaseCdDrive(resolvedCdDrivePath); } } } } else { // No drive mapping available (e.g. orphan/skipRip import path). // Ensure stale live progress does not keep the job "running" in the ripper. this.jobProgress.delete(Number(jobId)); } } else { await this.setState(finalState, { activeJobId: jobId, progress: this.snapshot.progress, eta: null, statusText: message, context: { ...(jobProgressContext && typeof jobProgressContext === 'object' ? jobProgressContext : {}), jobId, stage, error: message, rawPath: job?.raw_path || null, outputPath: job?.output_path || null, mediaProfile: resolvedMediaProfile, inputPath: job?.encode_input_path || encodePlan?.encodeInputPath || null, selectedMetadata: resolvedSelectedMetadata, canRestartEncodeFromLastSettings: hasConfirmedPlan, canRestartReviewFromRaw: resolvedMediaProfile !== 'audiobook' && hasRawPath }}); } this.cancelRequestedByJob.delete(Number(jobId)); void this.notifyPushover(isCancelled ? 'job_cancelled' : 'job_error', { title: isCancelled ? 'Ripster - Job abgebrochen' : 'Ripster - Job Fehler', message: `${title} (${stage}): ${message}` }); } async uploadAudiobookFile(file, options = {}) { const tempFilePath = String(file?.path || '').trim(); const originalName = String(file?.originalname || file?.originalName || '').trim() || path.basename(tempFilePath || 'upload.aax'); const detectedTitle = path.basename(originalName, path.extname(originalName)) || 'Audiobook'; const requestedFormat = String(options?.format || '').trim().toLowerCase() || null; const startImmediately = options?.startImmediately === undefined ? false : !['0', 'false', 'no', 'off'].includes(String(options.startImmediately).trim().toLowerCase()); if (!tempFilePath || !fs.existsSync(tempFilePath)) { const error = new Error('Upload-Datei fehlt.'); error.statusCode = 400; throw error; } if (!audiobookService.isSupportedInputFile(originalName)) { const error = new Error('Nur AAX-Dateien werden für Audiobooks unterstützt.'); error.statusCode = 400; throw error; } const settings = await settingsService.getEffectiveSettingsMap('audiobook'); const rawBaseDir = String( settings?.raw_dir || settingsService.DEFAULT_AUDIOBOOK_RAW_DIR || settingsService.DEFAULT_RAW_DIR || '' ).trim(); const rawTemplate = String( settings?.audiobook_raw_template || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE ).trim() || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE; const outputTemplate = String( settings?.output_template || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE ).trim() || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE; const outputFormat = audiobookService.normalizeOutputFormat( requestedFormat || 'm4b' ); const formatOptions = audiobookService.getDefaultFormatOptions(outputFormat); const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; const ffmpegCommand = String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg'; const job = await historyService.createJob({ discDevice: null, status: 'ANALYZING', detectedTitle, mediaType: 'audiobook', jobKind: 'audiobook' }); let stagedRawDir = null; let stagedRawFilePath = null; try { await historyService.resetProcessLog(job.id); await historyService.appendLog(job.id, 'SYSTEM', `AAX-Upload empfangen: ${originalName}`); logger.info('audiobook:upload:received', { jobId: job.id, originalName, tempFilePath, rawBaseDir, outputFormat, startImmediately }); const probeConfig = audiobookService.buildProbeCommand(ffprobeCommand, tempFilePath); const captured = await this.runCapturedCommand(probeConfig.cmd, probeConfig.args); const probe = audiobookService.parseProbeOutput(captured.stdout); if (!probe) { const error = new Error('FFprobe-Ausgabe konnte nicht gelesen werden.'); error.statusCode = 500; throw error; } const metadata = audiobookService.buildMetadataFromProbe(probe, originalName); const storagePaths = audiobookService.buildRawStoragePaths( metadata, job.id, rawBaseDir, rawTemplate, originalName ); logger.info('audiobook:upload:staging', { jobId: job.id, tempFilePath, rawDir: storagePaths.rawDir, rawFilePath: storagePaths.rawFilePath, rawTemplate, outputTemplate }); ensureDir(storagePaths.rawDir); moveFileWithFallback(tempFilePath, storagePaths.rawFilePath); stagedRawDir = storagePaths.rawDir; stagedRawFilePath = storagePaths.rawFilePath; chownRecursive(storagePaths.rawDir, settings?.raw_dir_owner); logger.info('audiobook:upload:staged', { jobId: job.id, stagedRawDir, stagedRawFilePath }); // Activation Bytes: Cache prüfen und Checksum am Job speichern let aaxChecksum = null; let aaxNeedsActivationBytes = false; try { const abResult = await activationBytesService.resolveActivationBytes(stagedRawFilePath); aaxChecksum = abResult.checksum; await historyService.updateJob(job.id, { aax_checksum: aaxChecksum }); if (abResult.activationBytes) { await historyService.appendLog(job.id, 'SYSTEM', `Activation Bytes im Cache gefunden: checksum=${abResult.checksum}`); logger.info('audiobook:upload:activation-bytes', { jobId: job.id, checksum: abResult.checksum, source: 'cache' }); } else { aaxNeedsActivationBytes = true; logger.info('audiobook:upload:activation-bytes-needed', { jobId: job.id, checksum: abResult.checksum }); } } catch (abError) { logger.warn('audiobook:upload:activation-bytes-failed', { jobId: job.id, error: errorToMeta(abError) }); } let detectedAsin = null; let audnexChapters = []; let audnexBook = null; try { detectedAsin = await audnexService.extractAsinFromAaxFile(storagePaths.rawFilePath); if (detectedAsin) { await historyService.appendLog(job.id, 'SYSTEM', `ASIN erkannt: ${detectedAsin}`); const [chapters, book] = await Promise.allSettled([ audnexService.fetchChaptersByAsin(detectedAsin, 'de'), audnexService.fetchBookByAsin(detectedAsin, 'de') ]); if (chapters.status === 'fulfilled') { audnexChapters = chapters.value; if (audnexChapters.length > 0) { await historyService.appendLog(job.id, 'SYSTEM', `Audnex-Kapitel geladen: ${audnexChapters.length}`); } else { await historyService.appendLog(job.id, 'SYSTEM', `Keine Audnex-Kapitel fuer ASIN ${detectedAsin} gefunden.`); } } else { logger.warn('audiobook:upload:audnex-chapters-failed', { jobId: job.id, asin: detectedAsin, error: errorToMeta(chapters.reason) }); await historyService.appendLog(job.id, 'SYSTEM', `Audnex-Kapitel konnten nicht geladen werden: ${chapters.reason?.message || 'unknown'}`).catch(() => {}); } if (book.status === 'fulfilled' && book.value) { audnexBook = book.value; await historyService.appendLog(job.id, 'SYSTEM', `Audnex-Buchmetadaten geladen: ${audnexBook.narrator ? `Sprecher: ${audnexBook.narrator}` : 'kein Sprecher'}`); } else if (book.status === 'rejected') { logger.warn('audiobook:upload:audnex-book-failed', { jobId: job.id, asin: detectedAsin, error: errorToMeta(book.reason) }); } } else { await historyService.appendLog(job.id, 'SYSTEM', 'Keine ASIN in der AAX-Datei gefunden, verwende eingebettete Kapitel.'); } } catch (audnexError) { logger.warn('audiobook:upload:audnex-failed', { jobId: job.id, stagedRawFilePath: storagePaths.rawFilePath, asin: detectedAsin, error: errorToMeta(audnexError) }); await historyService.appendLog( job.id, 'SYSTEM', `Audnex-Daten konnten nicht geladen werden: ${audnexError?.message || 'unknown'}` ).catch(() => {}); } let posterUrl = null; if (metadata?.hasEmbeddedCover && metadata?.cover) { const coverTempPath = path.join(storagePaths.rawDir, `.job-${job.id}-cover.jpg`); try { const coverCommand = audiobookService.buildCoverExtractionCommand( ffmpegCommand, storagePaths.rawFilePath, coverTempPath, metadata.cover ); await this.runCapturedCommand(coverCommand.cmd, coverCommand.args); posterUrl = thumbnailService.storeLocalThumbnail(job.id, coverTempPath); if (posterUrl) { await historyService.appendLog(job.id, 'SYSTEM', 'Eingebettetes AAX-Cover erkannt und gespeichert.'); } } catch (coverError) { logger.warn('audiobook:upload:cover-extract-failed', { jobId: job.id, stagedRawFilePath: storagePaths.rawFilePath, error: errorToMeta(coverError) }); } finally { try { fs.rmSync(coverTempPath, { force: true }); } catch (_error) { // best effort cleanup } } } const resolvedMetadata = { ...metadata, // Audnex book metadata takes precedence over embedded tags where available ...(audnexBook?.narrator ? { narrator: audnexBook.narrator } : {}), ...(audnexBook?.author ? { author: audnexBook.author, artist: audnexBook.author } : {}), ...(audnexBook?.title ? { title: audnexBook.title, album: audnexBook.title } : {}), ...(audnexBook?.description ? { description: audnexBook.description } : {}), ...(audnexBook?.series ? { series: audnexBook.series } : {}), ...(audnexBook?.part ? { part: audnexBook.part } : {}), ...(audnexBook?.year ? { year: audnexBook.year } : {}), asin: detectedAsin || null, chapterSource: audnexChapters.length > 0 ? 'audnex' : 'probe', chapters: audnexChapters.length > 0 ? audiobookService.normalizeChapterList(audnexChapters, { durationMs: metadata.durationMs, fallbackTitle: metadata.title, createFallback: false }) : metadata.chapters, poster: posterUrl || null }; const makemkvInfo = this.withAnalyzeContextMediaProfile({ status: 'SUCCESS', source: 'aax_upload', importedAt: nowIso(), mediaProfile: 'audiobook', rawFileName: storagePaths.rawFileName, rawFilePath: storagePaths.rawFilePath, chapters: resolvedMetadata.chapters, detectedMetadata: resolvedMetadata, selectedMetadata: resolvedMetadata, probeSummary: { durationMs: resolvedMetadata.durationMs, tagKeys: Object.keys(resolvedMetadata.tags || {}), asin: detectedAsin || null, chapterSource: resolvedMetadata.chapterSource || 'probe' } }, 'audiobook'); const encodePlan = { mediaProfile: 'audiobook', jobKind: 'audiobook', mode: 'audiobook', sourceType: 'upload', uploadedAt: nowIso(), format: outputFormat, formatOptions, rawTemplate, outputTemplate, encodeInputPath: storagePaths.rawFilePath, metadata: resolvedMetadata, reviewConfirmed: true }; await historyService.updateJob(job.id, { media_type: 'audiobook', job_kind: 'audiobook', status: 'READY_TO_START', last_state: 'READY_TO_START', title: resolvedMetadata.title || detectedTitle, detected_title: resolvedMetadata.title || detectedTitle, year: resolvedMetadata.year ?? null, raw_path: storagePaths.rawDir, rip_successful: 1, makemkv_info_json: JSON.stringify(makemkvInfo), handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: JSON.stringify(encodePlan), encode_input_path: storagePaths.rawFilePath, encode_review_confirmed: 1, output_path: null, poster_url: posterUrl || null, error_message: null, end_time: null }); await historyService.appendLog( job.id, 'SYSTEM', `Audiobook analysiert: ${resolvedMetadata.title || detectedTitle} | Autor: ${resolvedMetadata.author || '-'} | Format: ${outputFormat.toUpperCase()}` ); if (!startImmediately) { return { jobId: job.id, started: false, queued: false, stage: 'READY_TO_START', ...(aaxNeedsActivationBytes ? { needsActivationBytes: true, checksum: aaxChecksum } : {}) }; } const startResult = await this.startPreparedJob(job.id); return { jobId: job.id, ...(startResult && typeof startResult === 'object' ? startResult : {}) }; } catch (error) { logger.error('audiobook:upload:failed', { jobId: job.id, originalName, tempFilePath, stagedRawDir, stagedRawFilePath, error: errorToMeta(error) }); const updatePayload = { media_type: 'audiobook', job_kind: 'audiobook', status: 'ERROR', last_state: 'ERROR', end_time: nowIso(), error_message: error?.message || 'Audiobook-Upload fehlgeschlagen.' }; if (stagedRawDir) { updatePayload.raw_path = stagedRawDir; } if (stagedRawFilePath) { updatePayload.encode_input_path = stagedRawFilePath; } await historyService.updateJob(job.id, updatePayload).catch(() => {}); await historyService.appendLog( job.id, 'SYSTEM', `Audiobook-Upload fehlgeschlagen: ${error?.message || 'unknown'}` ).catch(() => {}); throw error; } finally { if (tempFilePath && fs.existsSync(tempFilePath)) { try { fs.rmSync(tempFilePath, { force: true }); } catch (_error) { // best effort cleanup } } } } async startAudiobookWithConfig(jobId, config = {}) { const normalizedJobId = Number(jobId); if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { const error = new Error('Ungültige Job-ID für Audiobook-Start.'); error.statusCode = 400; throw error; } const job = await historyService.getJobById(normalizedJobId); if (!job) { const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); error.statusCode = 404; throw error; } const encodePlan = this.safeParseJson(job.encode_plan_json); const makemkvInfo = this.safeParseJson(job.makemkv_info_json); const mediaProfile = this.resolveMediaProfileForJob(job, { encodePlan, makemkvInfo, mediaProfile: 'audiobook' }); if (mediaProfile !== 'audiobook') { const error = new Error(`Job ${normalizedJobId} ist kein Audiobook-Job.`); error.statusCode = 400; throw error; } const format = audiobookService.normalizeOutputFormat( config?.format || encodePlan?.format || 'm4b' ); const formatOptions = audiobookService.normalizeFormatOptions( format, config?.formatOptions || encodePlan?.formatOptions || {} ); const metadata = buildAudiobookMetadataForJob(job, makemkvInfo, encodePlan); const chapters = audiobookService.normalizeChapterList( Array.isArray(config?.chapters) ? config.chapters : metadata.chapters, { durationMs: metadata.durationMs, fallbackTitle: metadata.title, createFallback: false } ); const resolvedMetadata = { ...metadata, chapters }; const nextMakemkvInfo = { ...(makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}), chapters, selectedMetadata: { ...(makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' ? makemkvInfo.selectedMetadata : {}), ...resolvedMetadata, poster: metadata.poster || job.poster_url || null } }; const nextEncodePlan = { ...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}), mediaProfile: 'audiobook', jobKind: 'audiobook', mode: 'audiobook', format, formatOptions, metadata: resolvedMetadata, reviewConfirmed: true }; await historyService.updateJob(normalizedJobId, { media_type: 'audiobook', job_kind: 'audiobook', status: 'READY_TO_START', last_state: 'READY_TO_START', title: resolvedMetadata.title || job.title || job.detected_title || 'Audiobook', year: resolvedMetadata.year ?? job.year ?? null, makemkv_info_json: JSON.stringify(nextMakemkvInfo), encode_plan_json: JSON.stringify(nextEncodePlan), encode_review_confirmed: 1, error_message: null, handbrake_info_json: null, end_time: null }); await historyService.appendLog( normalizedJobId, 'USER_ACTION', `Audiobook-Encoding konfiguriert: Format ${format.toUpperCase()} | Kapitel: ${chapters.length || 0}` ); const startResult = await this.startPreparedJob(normalizedJobId); return { jobId: normalizedJobId, ...(startResult && typeof startResult === 'object' ? startResult : {}) }; } async startAudiobookEncode(jobId, options = {}) { const immediate = Boolean(options?.immediate); if (!immediate) { return this.enqueueOrStartAction( QUEUE_ACTIONS.START_PREPARED, jobId, () => this.startAudiobookEncode(jobId, { ...options, immediate: true }) ); } this.ensureNotBusy('startAudiobookEncode', jobId); logger.info('audiobook:encode:start', { jobId }); this.cancelRequestedByJob.delete(Number(jobId)); const job = options?.preloadedJob || await historyService.getJobById(jobId); if (!job) { const error = new Error(`Job ${jobId} nicht gefunden.`); error.statusCode = 404; throw error; } const encodePlan = this.safeParseJson(job.encode_plan_json); const makemkvInfo = this.safeParseJson(job.makemkv_info_json); const mediaProfile = this.resolveMediaProfileForJob(job, { encodePlan, makemkvInfo, mediaProfile: 'audiobook' }); if (mediaProfile !== 'audiobook') { const error = new Error(`Job ${jobId} ist kein Audiobook-Job.`); error.statusCode = 400; throw error; } const settings = await settingsService.getEffectiveSettingsMap('audiobook'); const resolvedRawPath = this.resolveCurrentRawPathForSettings( settings, 'audiobook', job.raw_path ) || String(job.raw_path || '').trim() || null; let inputPath = String( job.encode_input_path || encodePlan?.encodeInputPath || makemkvInfo?.rawFilePath || '' ).trim(); if ((!inputPath || !fs.existsSync(inputPath)) && resolvedRawPath) { inputPath = findPreferredRawInput(resolvedRawPath)?.path || ''; } if (!inputPath) { const error = new Error('Audiobook-Encode nicht möglich: keine Input-Datei gefunden.'); error.statusCode = 400; throw error; } if (!fs.existsSync(inputPath)) { const error = new Error(`Audiobook-Encode nicht möglich: Input-Datei fehlt (${inputPath}).`); error.statusCode = 400; throw error; } const { metadata, outputFormat, preferredFinalOutputPath, incompleteOutputPath, preferredChapterPlan, incompleteChapterPlan } = buildAudiobookOutputConfig(settings, job, makemkvInfo, encodePlan, jobId); const formatOptions = audiobookService.normalizeFormatOptions( outputFormat, encodePlan?.formatOptions || {} ); const isSplitOutput = outputFormat !== 'm4b'; const activeChapters = isSplitOutput ? (Array.isArray(incompleteChapterPlan?.chapters) ? incompleteChapterPlan.chapters : []) : (Array.isArray(metadata.chapters) ? metadata.chapters : []); if (isSplitOutput) { try { fs.rmSync(incompleteOutputPath, { recursive: true, force: true }); } catch (_error) { // best effort cleanup } ensureDir(incompleteOutputPath); } else { ensureDir(path.dirname(incompleteOutputPath)); } await historyService.resetProcessLog(jobId); await this.setState('ENCODING', { activeJobId: jobId, progress: 0, eta: null, statusText: `Audiobook-Encoding (${outputFormat.toUpperCase()})`, context: { jobId, mode: 'audiobook', mediaProfile: 'audiobook', inputPath, outputPath: incompleteOutputPath, format: outputFormat, formatOptions, chapters: activeChapters, selectedMetadata: { title: metadata.title || job.title || job.detected_title || null, year: metadata.year ?? job.year ?? null, author: metadata.author || null, narrator: metadata.narrator || null, description: metadata.description || null, series: metadata.series || null, part: metadata.part || null, chapters: activeChapters, durationMs: metadata.durationMs || 0, poster: metadata.poster || job.poster_url || null }, audiobookConfig: { format: outputFormat, formatOptions }, canRestartEncodeFromLastSettings: false, canRestartReviewFromRaw: false } }); await historyService.updateJob(jobId, { media_type: 'audiobook', job_kind: 'audiobook', status: 'ENCODING', last_state: 'ENCODING', start_time: nowIso(), end_time: null, error_message: null, raw_path: resolvedRawPath || job.raw_path || null, output_path: incompleteOutputPath, encode_input_path: inputPath }); await historyService.appendLog( jobId, 'SYSTEM', isSplitOutput ? `Audiobook-Encoding gestartet: ${path.basename(inputPath)} -> ${outputFormat.toUpperCase()} | Kapitel-Dateien: ${activeChapters.length || 0}` : `Audiobook-Encoding gestartet: ${path.basename(inputPath)} -> ${outputFormat.toUpperCase()}` ); void this.notifyPushover('encoding_started', { title: 'Ripster - Audiobook-Encoding gestartet', message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` }); // Activation Bytes für AAX-Dateien aus Cache lesen (vor dem Background-Start, // damit ein Fehler hier den HTTP-Request direkt abbricht). let encodeActivationBytes = null; if (path.extname(inputPath).toLowerCase() === '.aax') { try { const abResult = await activationBytesService.resolveActivationBytes(inputPath); encodeActivationBytes = abResult.activationBytes || null; if (!encodeActivationBytes) { throw new Error('Activation Bytes nicht im Cache – bitte zuerst über den Upload-Dialog eintragen'); } logger.info('audiobook:encode:activation-bytes', { jobId, checksum: abResult.checksum }); } catch (abError) { logger.error('audiobook:encode:activation-bytes-failed', { jobId, error: errorToMeta(abError) }); throw abError; } } // Den eigentlichen ffmpeg-Encode im Hintergrund ausführen, damit der HTTP-Request // sofort nach dem Setzen des ENCODING-Status zurückkehrt (kein Blockieren bis FINISHED). void (async () => { let temporaryChapterMetadataPath = null; try { let ffmpegRunInfo = null; if (isSplitOutput) { const outputFiles = Array.isArray(incompleteChapterPlan?.outputFiles) ? incompleteChapterPlan.outputFiles : []; if (outputFiles.length === 0) { throw new Error('Keine Audiobook-Kapitel für den Encode verfügbar.'); } const chapterRunInfos = []; for (let index = 0; index < outputFiles.length; index += 1) { const entry = outputFiles[index]; const chapter = entry?.chapter || {}; const chapterTitle = String(chapter?.title || `Kapitel ${index + 1}`).trim() || `Kapitel ${index + 1}`; const startPercent = Number(((index / outputFiles.length) * 100).toFixed(2)); const endPercent = Number((((index + 1) / outputFiles.length) * 100).toFixed(2)); ensureDir(path.dirname(entry.outputPath)); await historyService.appendLog( jobId, 'SYSTEM', `Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle} -> ${path.basename(entry.outputPath)}` ); await this.updateProgress( 'ENCODING', startPercent, null, `Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`, jobId, { contextPatch: { outputPath: incompleteOutputPath, completedChapterCount: index, currentChapter: { index: index + 1, total: outputFiles.length, title: chapterTitle } } } ); const ffmpegConfig = audiobookService.buildChapterEncodeCommand( settings?.ffmpeg_command || 'ffmpeg', inputPath, entry.outputPath, outputFormat, formatOptions, metadata, chapter, outputFiles.length, { activationBytes: encodeActivationBytes } ); const baseParser = audiobookService.buildProgressParser(chapter?.durationMs || 0); const scaledParser = baseParser ? (line) => { const progress = baseParser(line); if (!progress || progress.percent == null) { return null; } const scaledPercent = startPercent + ((endPercent - startPercent) * (progress.percent / 100)); return { percent: Number(scaledPercent.toFixed(2)), eta: null }; } : null; logger.info('audiobook:encode:chapter-command', { jobId, chapterIndex: index + 1, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args }); const chapterRunInfo = await this.runCommand({ jobId, stage: 'ENCODING', source: 'FFMPEG', cmd: ffmpegConfig.cmd, args: ffmpegConfig.args, parser: scaledParser }); chapterRunInfos.push({ ...chapterRunInfo, chapterIndex: index + 1, chapterTitle, outputPath: entry.outputPath }); await this.updateProgress( 'ENCODING', endPercent, null, `Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length} abgeschlossen`, jobId, { contextPatch: { completedChapterCount: index + 1, currentChapter: { index: index + 1, total: outputFiles.length, title: chapterTitle } } } ); } ffmpegRunInfo = { source: 'FFMPEG', stage: 'ENCODING', cmd: String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg', args: [''], startedAt: chapterRunInfos[0]?.startedAt || nowIso(), endedAt: chapterRunInfos[chapterRunInfos.length - 1]?.endedAt || nowIso(), durationMs: chapterRunInfos.reduce((sum, item) => sum + Number(item?.durationMs || 0), 0), status: 'SUCCESS', exitCode: 0, stdoutLines: chapterRunInfos.reduce((sum, item) => sum + Number(item?.stdoutLines || 0), 0), stderrLines: chapterRunInfos.reduce((sum, item) => sum + Number(item?.stderrLines || 0), 0), lastProgress: 100, eta: null, lastDetail: `${chapterRunInfos.length} Kapitel abgeschlossen`, highlights: chapterRunInfos.flatMap((item) => (Array.isArray(item?.highlights) ? item.highlights : [])).slice(0, 120), steps: chapterRunInfos }; } else { temporaryChapterMetadataPath = path.join(path.dirname(inputPath), `.job-${jobId}-chapters.ffmeta`); fs.writeFileSync( temporaryChapterMetadataPath, audiobookService.buildChapterMetadataContent(activeChapters, metadata), 'utf8' ); const ffmpegConfig = audiobookService.buildEncodeCommand( settings?.ffmpeg_command || 'ffmpeg', inputPath, incompleteOutputPath, outputFormat, formatOptions, { chapterMetadataPath: temporaryChapterMetadataPath, metadata, activationBytes: encodeActivationBytes } ); logger.info('audiobook:encode:command', { jobId, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args }); ffmpegRunInfo = await this.runCommand({ jobId, stage: 'ENCODING', source: 'FFMPEG', cmd: ffmpegConfig.cmd, args: ffmpegConfig.args, parser: audiobookService.buildProgressParser(metadata.durationMs) }); } const outputFinalization = finalizeOutputPathForCompletedEncode( incompleteOutputPath, preferredFinalOutputPath ); const finalizedOutputPath = outputFinalization.outputPath; let ownershipTarget = path.dirname(finalizedOutputPath); try { const finalizedStat = fs.statSync(finalizedOutputPath); if (finalizedStat.isDirectory()) { ownershipTarget = finalizedOutputPath; } } catch (_error) { ownershipTarget = path.dirname(finalizedOutputPath); } chownRecursive(ownershipTarget, settings?.movie_dir_owner); if (outputFinalization.outputPathWithTimestamp) { await historyService.appendLog( jobId, 'SYSTEM', `Finaler Audiobook-Output existierte bereits. Zielpfad nummeriert: ${finalizedOutputPath}` ); } await historyService.appendLog( jobId, 'SYSTEM', `Audiobook-Output finalisiert: ${finalizedOutputPath}` ); historyService.addJobOutputFolder(jobId, finalizedOutputPath).catch((e) => { logger.warn('audiobook:record-output-folder-failed', { jobId, error: e?.message }); }); const finalizedOutputFiles = isSplitOutput ? (Array.isArray(preferredChapterPlan?.outputFiles) ? preferredChapterPlan.outputFiles.map((entry) => { const relativePath = path.relative(preferredFinalOutputPath, entry.outputPath); return path.join(finalizedOutputPath, relativePath); }) : []) : null; const ffmpegInfo = { ...ffmpegRunInfo, mode: isSplitOutput ? 'audiobook_encode_split' : 'audiobook_encode', format: outputFormat, formatOptions, metadata: { ...metadata, chapters: activeChapters }, inputPath, outputPath: finalizedOutputPath, chapterCount: activeChapters.length, outputFiles: finalizedOutputFiles }; await historyService.updateJob(jobId, { handbrake_info_json: JSON.stringify(ffmpegInfo), status: 'FINISHED', last_state: 'FINISHED', end_time: nowIso(), rip_successful: 1, raw_path: resolvedRawPath || job.raw_path || null, output_path: finalizedOutputPath, error_message: null }); // Only switch the pipeline UI to FINISHED if this job is still the active one. if (Number(this.snapshot.activeJobId) === Number(jobId)) { await this.setState('FINISHED', { activeJobId: jobId, progress: 100, eta: null, statusText: 'Audiobook abgeschlossen', context: { jobId, mode: 'audiobook', mediaProfile: 'audiobook', outputPath: finalizedOutputPath } }); } else { logger.info('audiobook:finished:background', { jobId, activeJobId: this.snapshot.activeJobId }); void this.pumpQueue(); } void this.notifyPushover('job_finished', { title: 'Ripster - Audiobook abgeschlossen', message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}` }); setTimeout(async () => { if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) { await this.setState('IDLE', { finishingJobId: jobId, activeJobId: null, progress: 0, eta: null, statusText: 'Bereit', context: {} }); } }, 3000); return { started: true, stage: 'ENCODING', outputPath: finalizedOutputPath }; } catch (error) { if (temporaryChapterMetadataPath) { try { fs.rmSync(temporaryChapterMetadataPath, { force: true }); } catch (_error) { // best effort cleanup } } if (error.runInfo && error.runInfo.source === 'FFMPEG') { await historyService.updateJob(jobId, { handbrake_info_json: JSON.stringify({ ...error.runInfo, mode: isSplitOutput ? 'audiobook_encode_split' : 'audiobook_encode', format: outputFormat, formatOptions, inputPath }) }).catch(() => {}); } logger.error('audiobook:encode:failed', { jobId, error: errorToMeta(error) }); await this.failJob(jobId, 'ENCODING', error); } finally { if (temporaryChapterMetadataPath) { try { fs.rmSync(temporaryChapterMetadataPath, { force: true }); } catch (_error) { // best effort cleanup } } } })(); return { started: true, stage: 'ENCODING', outputPath: incompleteOutputPath }; } // ── Converter Pipeline ─────────────────────────────────────────────────────── async startConverterEncode(jobId, options = {}) { const immediate = Boolean(options?.immediate); if (!immediate) { const queuedJob = options?.preloadedJob || await historyService.getJobById(jobId); const queuedPlan = this.safeParseJson(queuedJob?.encode_plan_json) || {}; const queuedConverterMediaType = String(queuedPlan?.converterMediaType || '').trim().toLowerCase(); const queuePoolType = queuedConverterMediaType === 'audio' ? 'audio' : 'film'; return this.enqueueOrStartAction( QUEUE_ACTIONS.START_PREPARED, jobId, () => this.startConverterEncode(jobId, { ...options, immediate: true }), { poolType: queuePoolType } ); } this.ensureNotBusy('startConverterEncode', jobId); logger.info('converter:encode:start', { jobId }); this.cancelRequestedByJob.delete(Number(jobId)); const job = options?.preloadedJob || await historyService.getJobById(jobId); if (!job) { const error = new Error(`Job ${jobId} nicht gefunden.`); error.statusCode = 404; throw error; } const encodePlan = this.safeParseJson(job.encode_plan_json) || {}; const inputPath = encodePlan.inputPath || job.raw_path; if (!inputPath || !fs.existsSync(inputPath)) { const error = new Error(`Converter-Eingabedatei nicht gefunden: ${inputPath}`); error.statusCode = 404; throw error; } let outputPath = encodePlan.outputPath || null; let outputDir = encodePlan.outputDir || null; let outputFormat = String(encodePlan.outputFormat || '').trim().toLowerCase() || null; if (!outputPath && !outputDir) { const converterSettings = await settingsService.getSettingsMap(); const converterMediaType = String(encodePlan.converterMediaType || 'video').trim().toLowerCase(); outputFormat = outputFormat || (converterMediaType === 'audio' ? 'flac' : 'mkv'); const parseTemplateSegments = (template) => String(template || '') .replace(/\\/g, '/') .replace(/\/+/g, '/') .replace(/^\/+|\/+$/g, '') .split('/') .map((segment) => String(segment || '').trim()) .filter(Boolean); const renderTemplateSegment = (segment, values, fallback = 'unknown') => { const rendered = renderTemplate(segment, values); return sanitizeFileName(rendered) || fallback; }; if (converterMediaType === 'video' || converterMediaType === 'iso') { const movieDir = String( converterSettings?.converter_movie_dir || require('../config').defaultConverterMovieDir || '' ).trim(); const baseName = sanitizeFileName(path.basename(inputPath, path.extname(inputPath))) || 'output'; const title = String( encodePlan?.metadata?.title || job.title || job.detected_title || baseName ).trim() || baseName; const safeTitle = sanitizeFileName(title) || baseName; const yearValue = Number.isFinite(Number(encodePlan?.metadata?.year)) ? Math.trunc(Number(encodePlan.metadata.year)) : (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : new Date().getFullYear()); const videoTemplateRaw = String( converterSettings?.converter_output_template_video || '{title}' ).trim() || '{title}'; const videoSegmentsRaw = parseTemplateSegments(videoTemplateRaw); const videoSegments = (videoSegmentsRaw.length > 0 ? videoSegmentsRaw : ['{title}']) .map((segment) => renderTemplateSegment(segment, { title: safeTitle, year: yearValue }, safeTitle)); const videoBaseName = videoSegments[videoSegments.length - 1] || safeTitle; const videoFolders = videoSegments.slice(0, -1); outputPath = path.join(movieDir, ...(videoFolders.length > 0 ? videoFolders : []), `${videoBaseName}.${outputFormat}`); } encodePlan.outputFormat = outputFormat; encodePlan.outputPath = outputPath || null; encodePlan.outputDir = outputDir || null; await historyService.updateJob(jobId, { encode_plan_json: JSON.stringify(encodePlan), output_path: outputPath || outputDir || null }); } await historyService.resetProcessLog(jobId); await this.setState('ANALYZING', { activeJobId: jobId, progress: 0, eta: null, statusText: 'Converter: Analyse läuft …', context: { jobId, mode: 'converter', mediaProfile: 'converter', inputPath, converterMediaType: encodePlan.converterMediaType || 'video' } }); await historyService.updateJob(jobId, { status: 'ANALYZING', last_state: 'ANALYZING', start_time: nowIso(), end_time: null, error_message: null }); void (async () => { try { const { ConverterPlugin } = require('../plugins/ConverterPlugin'); const converterPlugin = new ConverterPlugin(); const appendConverterProcessLine = (source, line, stream = 'stderr') => { const normalizedSource = String(source || '').trim().toUpperCase() || 'PROCESS'; const rawLine = String(line || '').trim(); if (!rawLine) { return; } const streamPrefix = stream === 'stdout' ? '[OUT] ' : ''; void historyService.appendLog(jobId, normalizedSource, `${streamPrefix}${rawLine}`); }; const analyzeCtx = await this.buildPluginContext('converter', { jobId, filePath: inputPath, encodePlan, emitProgress: (pct, statusText) => { void this.updateProgress('ANALYZING', pct, null, statusText, jobId); }, appendLogLine: appendConverterProcessLine }); await converterPlugin.analyze(inputPath, job, analyzeCtx); if (this.cancelRequestedByJob.has(Number(jobId))) { const cancelErr = new Error('Job wurde vom Benutzer abgebrochen.'); cancelErr.runInfo = { status: 'CANCELLED' }; throw cancelErr; } await this.setState('ENCODING', { activeJobId: jobId, progress: 0, eta: null, statusText: 'Converter: Encoding läuft …', context: { jobId, mode: 'converter', mediaProfile: 'converter', inputPath, outputPath: outputPath || outputDir, converterMediaType: encodePlan.converterMediaType || 'video' } }); await historyService.updateJob(jobId, { status: 'ENCODING', last_state: 'ENCODING' }); await historyService.appendLog( jobId, 'SYSTEM', `Converter-Encoding gestartet: ${path.basename(inputPath)} → ${encodePlan.converterMediaType || 'video'} / ${encodePlan.outputFormat || '?'}` ); void this.notifyPushover('encoding_started', { title: 'Ripster - Converter gestartet', message: `${job.title || job.detected_title || `Job #${jobId}`} → ${outputPath || outputDir || '?'}` }); const encodeCtx = await this.buildPluginContext('converter', { jobId, inputPath, outputPath, outputDir, encodePlan, isCancelled: () => this.cancelRequestedByJob.has(Number(jobId)), onProcessHandle: (handle) => { if (handle) { this.activeProcesses.set(Number(jobId), handle); } }, emitProgress: (pct, statusText, eta) => { void this.updateProgress('ENCODING', pct, eta ?? null, statusText, jobId); }, appendLogLine: appendConverterProcessLine }); await converterPlugin.encode(job, encodeCtx); this.activeProcesses.delete(Number(jobId)); const finalOutputPath = outputPath || outputDir || null; // Eigentümer der Ausgabedateien setzen if (finalOutputPath) { const converterSettings = await settingsService.getSettingsMap(); const converterMediaType = encodePlan.converterMediaType || 'video'; const outputOwner = converterMediaType === 'audio' ? String(converterSettings?.converter_audio_dir_owner || converterSettings?.converter_movie_dir_owner || '').trim() : String(converterSettings?.converter_movie_dir_owner || '').trim(); if (outputOwner) { chownRecursive(finalOutputPath, outputOwner); } } await historyService.updateJob(jobId, { status: 'FINISHED', last_state: 'FINISHED', end_time: nowIso(), rip_successful: 1, output_path: finalOutputPath, error_message: null }); // Datei-Zuweisungen im Explorer freigeben try { await require('./converterScanService').clearAssignmentsForJob(jobId); } catch (_clearErr) { // nicht kritisch } await historyService.appendLog( jobId, 'SYSTEM', `Converter-Encoding abgeschlossen: ${finalOutputPath}` ); if (Number(this.snapshot.activeJobId) === Number(jobId)) { await this.setState('FINISHED', { activeJobId: jobId, progress: 100, eta: null, statusText: 'Converter abgeschlossen', context: { jobId, mode: 'converter', mediaProfile: 'converter', outputPath: finalOutputPath } }); } else { void this.pumpQueue(); } void this.notifyPushover('job_finished', { title: 'Ripster - Converter abgeschlossen', message: `${job.title || job.detected_title || `Job #${jobId}`} → ${finalOutputPath}` }); setTimeout(async () => { if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) { await this.setState('IDLE', { finishingJobId: jobId, activeJobId: null, progress: 0, eta: null, statusText: 'Bereit', context: {} }); } }, 3000); } catch (error) { this.activeProcesses.delete(Number(jobId)); logger.error('converter:encode:failed', { jobId, error: errorToMeta(error) }); // Datei-Zuweisungen im Explorer freigeben (auch bei Abbruch/Fehler) try { await require('./converterScanService').clearAssignmentsForJob(jobId); } catch (_clearErr) { // nicht kritisch } await this.failJob(jobId, 'ENCODING', error); } })(); return { started: true, stage: 'ANALYZING', outputPath: outputPath || outputDir }; } // ── CD Pipeline ───────────────────────────────────────────────────────────── async analyzeCd(device, options = {}) { const devicePath = String(device?.path || '').trim(); const detectedTitle = String( device?.discLabel || device?.label || 'Audio CD' ).trim(); logger.info('cd:analyze:start', { devicePath, detectedTitle }); const job = await historyService.createJob({ discDevice: devicePath, status: 'CD_METADATA_SELECTION', detectedTitle, jobKind: 'cd' }); try { const settings = await settingsService.getSettingsMap(); let effectiveDetectedTitle = detectedTitle; let cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; // Read TOC this._setCdDriveState(devicePath, { state: 'CD_ANALYZING', jobId: job.id, device, progress: 0, eta: null, statusText: 'CD wird analysiert …', context: { jobId: job.id, device, mediaProfile: 'cd' } }); let tracks = null; let pluginExecution = null; const analyzePlugin = options?.plugin && typeof options.plugin === 'object' ? options.plugin : null; if (analyzePlugin) { const pluginContext = await this.buildPluginContext(analyzePlugin.id, { discInfo: device, jobId: job.id }); try { const pluginResult = await analyzePlugin.analyze(devicePath, job, pluginContext); pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); if (Array.isArray(pluginResult?.tracks) && pluginResult.tracks.length > 0) { tracks = pluginResult.tracks; } const pluginCmd = String(pluginResult?.cdparanoiaCmd || '').trim(); if (pluginCmd) { cdparanoiaCmd = pluginCmd; } const pluginDetectedTitle = String(pluginResult?.detectedTitle || '').trim(); if (pluginDetectedTitle) { effectiveDetectedTitle = pluginDetectedTitle; } logger.info('plugin:analyze:used', { jobId: job.id, pluginId: analyzePlugin.id, mediaProfile: 'cd' }); } catch (error) { pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); logger.warn('plugin:analyze:cd:fallback-legacy', { jobId: job.id, pluginId: analyzePlugin.id, error: errorToMeta(error) }); } } if (!Array.isArray(tracks) || tracks.length === 0) { tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd); } logger.info('cd:analyze:toc', { jobId: job.id, trackCount: tracks.length }); if (!tracks.length) { const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).'); error.statusCode = 400; throw error; } const cdInfo = { phase: 'PREPARE', mediaProfile: 'cd', preparedAt: nowIso(), cdparanoiaCmd, tracks, detectedTitle: effectiveDetectedTitle }; const persistedCdInfo = this.withPluginExecutionMeta(cdInfo, pluginExecution); await historyService.updateJob(job.id, { status: 'CD_METADATA_SELECTION', last_state: 'CD_METADATA_SELECTION', detected_title: effectiveDetectedTitle, makemkv_info_json: JSON.stringify(persistedCdInfo) }); await historyService.appendLog( job.id, 'SYSTEM', `CD analysiert: ${tracks.length} Track(s) gefunden.` ); const previewTrackPos = tracks[0]?.position ? Number(tracks[0].position) : null; const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || ''} ${previewTrackPos || ''} /trackNN.cdda.wav`; this._setCdDriveState(devicePath, { state: 'CD_METADATA_SELECTION', jobId: job.id, device, progress: 0, eta: null, statusText: 'CD-Metadaten auswählen', context: { jobId: job.id, device, mediaProfile: 'cd', devicePath, cdparanoiaCmd, cdparanoiaCommandPreview, detectedTitle: effectiveDetectedTitle, tracks, ...(pluginExecution ? { pluginExecution } : {}) } }); return { jobId: job.id, detectedTitle: effectiveDetectedTitle, tracks }; } catch (error) { logger.error('cd:analyze:failed', { jobId: job.id, error: errorToMeta(error) }); await this.failJob(job.id, 'CD_ANALYZING', error); throw error; } } async searchMusicBrainz(query) { logger.info('musicbrainz:search', { query }); const results = await musicBrainzService.searchByTitle(query); logger.info('musicbrainz:search:done', { query, count: results.length }); return results; } async getMusicBrainzReleaseById(mbId) { const id = String(mbId || '').trim(); if (!id) { const error = new Error('mbId fehlt.'); error.statusCode = 400; throw error; } logger.info('musicbrainz:get-by-id', { mbId: id }); const release = await musicBrainzService.getReleaseById(id); if (!release) { const error = new Error(`MusicBrainz Release ${id} nicht gefunden.`); error.statusCode = 404; throw error; } logger.info('musicbrainz:get-by-id:done', { mbId: id, trackCount: Array.isArray(release.tracks) ? release.tracks.length : 0 }); return release; } async selectCdMetadata(payload) { const { jobId, title, artist, year, mbId, coverUrl, tracks: selectedTracks } = payload || {}; if (!jobId) { const error = new Error('jobId fehlt.'); error.statusCode = 400; throw error; } const job = await historyService.getJobById(jobId); if (!job) { const error = new Error(`Job ${jobId} nicht gefunden.`); error.statusCode = 404; throw error; } logger.info('cd:select-metadata', { jobId, title, artist, year, mbId }); const cdInfo = this.safeParseJson(job.makemkv_info_json) || {}; // Merge track metadata from selection into existing TOC tracks const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : []; const mergedTracks = tocTracks.map((t) => { const selected = Array.isArray(selectedTracks) ? selectedTracks.find((st) => Number(st.position) === Number(t.position)) : null; const resolvedTitle = normalizeCdTrackText(selected?.title) || t.title || `Track ${t.position}`; const resolvedArtist = normalizeCdTrackText(selected?.artist) || t.artist || artist || null; return { ...t, title: resolvedTitle, artist: resolvedArtist, selected: selected ? Boolean(selected.selected) : true }; }); const updatedCdInfo = { ...cdInfo, tracks: mergedTracks, selectedMetadata: { title, artist, year, mbId, coverUrl } }; await historyService.updateJob(jobId, { title: title || null, year: year ? Number(year) : null, poster_url: coverUrl || null, status: 'CD_READY_TO_RIP', last_state: 'CD_READY_TO_RIP', makemkv_info_json: JSON.stringify(updatedCdInfo) }); // Bild in Cache laden (async, blockiert nicht) if (coverUrl) { historyService.queuePosterCache(jobId, coverUrl, { source: 'Coverart', logFailures: true }); } await historyService.appendLog( jobId, 'SYSTEM', `Metadaten gesetzt: "${title}" (${artist || '-'}, ${year || '-'}).` ); const resolvedDevicePath = String(job?.disc_device || '').trim() || null; const resolvedCdparanoiaCmd = String(cdInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia'; const previewTrackPos = mergedTracks[0]?.position ? Number(mergedTracks[0].position) : null; const cdparanoiaCommandPreview = `${resolvedCdparanoiaCmd} -d ${resolvedDevicePath || ''} ${previewTrackPos || ''} /trackNN.cdda.wav`; const existingDrive = resolvedDevicePath ? this.cdDrives.get(resolvedDevicePath) : null; if (resolvedDevicePath) { this._setCdDriveState(resolvedDevicePath, { state: 'CD_READY_TO_RIP', jobId, progress: 0, eta: null, statusText: 'CD bereit zum Rippen', context: { ...(existingDrive?.context || {}), jobId, mediaProfile: 'cd', tracks: mergedTracks, selectedMetadata: { title, artist, year, mbId, coverUrl }, devicePath: resolvedDevicePath, cdparanoiaCmd: resolvedCdparanoiaCmd, cdparanoiaCommandPreview } }); } else { // No real device — update virtual drive if present (orphan CD job) const virtualDrivePath = `__virtual__${jobId}`; const existingVirtualDrive = this.cdDrives.get(virtualDrivePath); if (existingVirtualDrive) { this._setCdDriveState(virtualDrivePath, { state: 'CD_READY_TO_RIP', jobId, progress: 0, eta: null, statusText: 'CD bereit zum Encodieren', context: { ...(existingVirtualDrive.context || {}), jobId, mediaProfile: 'cd', tracks: mergedTracks, selectedMetadata: { title, artist, year, mbId, coverUrl }, devicePath: null, virtualDrivePath, skipRip: true, cdparanoiaCmd: resolvedCdparanoiaCmd } }); const snapshotPayload = this.getSnapshot(); wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); this.emit('stateChanged', snapshotPayload); } } const canAutoStartRawRip = Boolean( resolvedDevicePath && !String(resolvedDevicePath).startsWith('__virtual__') && Number(job?.rip_successful || 0) !== 1 ); if (canAutoStartRawRip) { const autoRawRipConfig = { selectedTracks: mergedTracks .filter((track) => track?.selected !== false) .map((track) => Number(track?.position)) .filter((value) => Number.isFinite(value) && value > 0), tracks: mergedTracks, metadata: { title, artist, year, mbId, coverUrl }, skipEncode: true }; await historyService.appendLog( jobId, 'SYSTEM', 'Metadaten bestätigt. Starte automatisch RAW-Rip (Encode-Auswahl folgt danach).' ); this.startCdRip(Number(jobId), autoRawRipConfig).then((result) => { logger.info('cd:auto-raw-rip:started', { jobId: Number(jobId), replacedSourceJob: Boolean(result?.replacedSourceJob), replacementJobId: Number(result?.jobId || 0) || null }); }).catch((error) => { logger.error('cd:auto-raw-rip:failed', { jobId: Number(jobId), error: errorToMeta(error) }); historyService.appendLog( Number(jobId), 'SYSTEM', `Automatischer RAW-Rip konnte nicht gestartet werden: ${error?.message || 'unknown'}` ).catch(() => {}); }); } return historyService.getJobById(jobId); } async renameJobFolders(jobId) { const job = await historyService.getJobById(jobId); if (!job) { return { renamed: [] }; } const renamed = []; const mediaProfile = this.resolveMediaProfileForJob(job); const isCd = mediaProfile === 'cd'; const isAudiobook = mediaProfile === 'audiobook'; const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const mkInfo = this.safeParseJson(job.makemkv_info_json) || {}; const encodePlan = this.safeParseJson(job.encode_plan_json) || {}; // Rename raw folder const currentRawPath = job.raw_path ? path.resolve(job.raw_path) : null; if (currentRawPath && fs.existsSync(currentRawPath)) { let newRawPath; if (isAudiobook) { const audiobookMeta = buildAudiobookMetadataForJob(job, mkInfo, encodePlan); const rawTemplate = String( settings?.audiobook_raw_template || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE ).trim() || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE; const currentInputPath = findPreferredRawInput(currentRawPath)?.path || String(job.encode_input_path || mkInfo?.rawFilePath || '').trim() || 'input.aax'; const nextRaw = audiobookService.buildRawStoragePaths( audiobookMeta, jobId, settings?.raw_dir || settingsService.DEFAULT_AUDIOBOOK_RAW_DIR, rawTemplate, path.basename(currentInputPath) ); newRawPath = nextRaw.rawDir; } else { const rawBaseDir = path.dirname(currentRawPath); const newMetadataBase = buildRawMetadataBase({ title: job.title || job.detected_title || null, year: job.year || null }, jobId); const currentState = resolveRawFolderStateFromPath(currentRawPath); const newRawDirName = buildRawDirName(newMetadataBase, jobId, { state: currentState }); newRawPath = path.join(rawBaseDir, newRawDirName); } if (normalizeComparablePath(currentRawPath) !== normalizeComparablePath(newRawPath) && !fs.existsSync(newRawPath)) { try { fs.mkdirSync(path.dirname(newRawPath), { recursive: true }); fs.renameSync(currentRawPath, newRawPath); const updatePayload = { raw_path: newRawPath }; if (isAudiobook) { const previousInputPath = String(job.encode_input_path || mkInfo?.rawFilePath || '').trim(); if (previousInputPath && previousInputPath.startsWith(`${currentRawPath}${path.sep}`)) { updatePayload.encode_input_path = path.join(newRawPath, path.basename(previousInputPath)); } } await historyService.updateJob(jobId, updatePayload); renamed.push({ type: 'raw', from: currentRawPath, to: newRawPath }); logger.info('rename-job-folders:raw', { jobId, from: currentRawPath, to: newRawPath }); } catch (err) { logger.warn('rename-job-folders:raw-failed', { jobId, error: err.message }); } } } // Rename output file (film) or output directory (CD) const currentOutputPath = job.output_path ? path.resolve(job.output_path) : null; if (currentOutputPath && fs.existsSync(currentOutputPath)) { try { if (isCd) { const cdInfo = this.safeParseJson(job.makemkv_info_json) || {}; const selectedMeta = cdInfo.selectedMetadata && typeof cdInfo.selectedMetadata === 'object' ? cdInfo.selectedMetadata : {}; const cdMeta = { artist: String(selectedMeta.artist || '').trim() || String(job.title || '').trim() || null, album: String(job.title || selectedMeta.title || '').trim() || null, year: job.year || selectedMeta.year || null }; const cdOutputBaseDir = String(settings.movie_dir || '').trim(); const cdOutputTemplate = String(settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE).trim(); if (cdOutputBaseDir) { const newCdOutputDir = cdRipService.buildOutputDir(cdMeta, cdOutputBaseDir, cdOutputTemplate); if (normalizeComparablePath(currentOutputPath) !== normalizeComparablePath(newCdOutputDir) && !fs.existsSync(newCdOutputDir)) { fs.mkdirSync(path.dirname(newCdOutputDir), { recursive: true }); fs.renameSync(currentOutputPath, newCdOutputDir); await historyService.updateJob(jobId, { output_path: newCdOutputDir }); renamed.push({ type: 'output', from: currentOutputPath, to: newCdOutputDir }); logger.info('rename-job-folders:cd-output', { jobId, from: currentOutputPath, to: newCdOutputDir }); } } } else { const newOutputPath = isAudiobook ? buildAudiobookOutputConfig(settings, job, mkInfo, encodePlan, jobId).preferredFinalOutputPath : buildFinalOutputPathFromJob(settings, job, jobId); if (normalizeComparablePath(currentOutputPath) !== normalizeComparablePath(newOutputPath) && !fs.existsSync(newOutputPath)) { fs.mkdirSync(path.dirname(newOutputPath), { recursive: true }); moveFileWithFallback(currentOutputPath, newOutputPath); try { const oldParentDir = path.dirname(currentOutputPath); if (fs.readdirSync(oldParentDir).length === 0) { fs.rmdirSync(oldParentDir); } } catch (_ignoreErr) {} await historyService.updateJob(jobId, { output_path: newOutputPath }); renamed.push({ type: 'output', from: currentOutputPath, to: newOutputPath }); logger.info(isAudiobook ? 'rename-job-folders:audiobook-output' : 'rename-job-folders:film-output', { jobId, from: currentOutputPath, to: newOutputPath }); } } } catch (err) { logger.warn('rename-job-folders:output-failed', { jobId, isCd, error: err.message }); } } return { renamed }; } async startCdRip(jobId, ripConfig) { const resolvedCdRipJob = await this.resolveExistingJobForAction(jobId, 'start_cd_rip'); jobId = resolvedCdRipJob.resolvedJobId; this.ensureNotBusy('startCdRip', jobId); this.cancelRequestedByJob.delete(Number(jobId)); const sourceJob = resolvedCdRipJob.job || await historyService.getJobById(jobId); let activeJobId = Number(jobId); let activeJob = sourceJob; const cdSettings = await settingsService.getEffectiveSettingsMap('cd'); const sourceResolvedRawPath = this.resolveCurrentRawPathForSettings( cdSettings, 'cd', sourceJob.raw_path ); const sourceHasSuccessfulRawRip = Number(sourceJob?.rip_successful || 0) === 1 && Boolean(sourceResolvedRawPath); const sourceStatus = String(sourceJob.status || sourceJob.last_state || '').trim().toUpperCase(); const shouldReplaceSourceJob = sourceStatus === 'CANCELLED' || sourceStatus === 'ERROR'; if (shouldReplaceSourceJob) { const replacementJob = await historyService.createJob({ discDevice: sourceJob.disc_device || null, status: 'CD_READY_TO_RIP', detectedTitle: sourceJob.detected_title || sourceJob.title || null, jobKind: this.resolveJobKindForJob(sourceJob, { encodePlan: this.safeParseJson(sourceJob.encode_plan_json) }) || 'cd' }); const replacementJobId = Number(replacementJob?.id || 0); if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { throw new Error('CD-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); } let replacementRawPath = sourceHasSuccessfulRawRip ? sourceResolvedRawPath : null; if (replacementRawPath) { replacementRawPath = await this.alignRawFolderJobId(replacementRawPath, replacementJobId); } await historyService.updateJob(replacementJobId, { parent_job_id: Number(jobId), title: sourceJob.title || null, year: sourceJob.year ?? null, imdb_id: sourceJob.imdb_id || null, poster_url: sourceJob.poster_url || null, omdb_json: sourceJob.omdb_json || null, selected_from_omdb: Number(sourceJob.selected_from_omdb || 0), status: 'CD_READY_TO_RIP', last_state: 'CD_READY_TO_RIP', error_message: null, end_time: null, output_path: null, disc_device: sourceJob.disc_device || null, raw_path: replacementRawPath, rip_successful: sourceHasSuccessfulRawRip ? 1 : 0, makemkv_info_json: sourceJob.makemkv_info_json || null, handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: sourceHasSuccessfulRawRip ? (sourceJob.encode_plan_json || null) : null, encode_input_path: null, encode_review_confirmed: 0 }); // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist if (thumbnailService.isLocalUrl(sourceJob.poster_url)) { const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId); if (copiedUrl) { await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {}); } } await historyService.appendLog( replacementJobId, 'USER_ACTION', `CD-Rip Neustart aus Job #${jobId}. Alter Job wurde durch neuen Job ersetzt.` ); this._releaseDriveLockForJob(jobId, { reason: 'cd_restart_replaced' }); await historyService.retireJobInFavorOf(jobId, replacementJobId, { reason: 'cd_restart_rip' }); activeJobId = replacementJobId; activeJob = await historyService.getJobById(replacementJobId); this.cancelRequestedByJob.delete(replacementJobId); if (!activeJob) { throw new Error(`CD-Neustart fehlgeschlagen: neuer Job #${replacementJobId} konnte nicht geladen werden.`); } } const cdInfo = this.safeParseJson(activeJob.makemkv_info_json) || {}; const devicePath = String(activeJob.disc_device || '').trim(); const resolvedExistingRawPath = this.resolveCurrentRawPathForSettings( cdSettings, 'cd', activeJob.raw_path ); const hasSuccessfulRawRip = Number(activeJob?.rip_successful || 0) === 1 && Boolean(resolvedExistingRawPath); const skipRip = Boolean(ripConfig?.skipRip || cdInfo?.skipRip || hasSuccessfulRawRip); const skipEncode = Boolean(ripConfig?.skipEncode); const resolvedCdRipPlugin = await this.resolveAnalyzePlugin({ mediaProfile: 'cd' }, 'rip').catch(() => null); const cdRipPlugin = resolvedCdRipPlugin?.id === 'cd' ? resolvedCdRipPlugin : null; if (!devicePath && !skipRip) { const error = new Error('Kein CD-Laufwerk bekannt.'); error.statusCode = 400; throw error; } // For skipRip (orphan CD): look up virtual drive path or derive it const effectiveDevicePath = devicePath || ( this._getCdDriveByJobId(activeJobId)?.devicePath || `__virtual__${activeJobId}` ); const format = String(ripConfig?.format || 'flac').trim().toLowerCase(); const formatOptions = ripConfig?.formatOptions || {}; const normalizeTrackPosition = (value) => { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); }; const selectedTrackPositions = Array.isArray(ripConfig?.selectedTracks) ? ripConfig.selectedTracks .map(normalizeTrackPosition) .filter((value) => Number.isFinite(value) && value > 0) : []; const normalizeOptionalYear = (value) => { if (value === null || value === undefined || String(value).trim() === '') { return null; } const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); }; const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : []; const incomingTracks = Array.isArray(ripConfig?.tracks) ? ripConfig.tracks : []; const incomingByPosition = new Map(); for (const incoming of incomingTracks) { const position = normalizeTrackPosition(incoming?.position); if (!position) { continue; } incomingByPosition.set(position, incoming); } const selectedMeta = cdInfo.selectedMetadata || {}; const incomingMeta = ripConfig?.metadata && typeof ripConfig.metadata === 'object' ? ripConfig.metadata : {}; const effectiveSelectedMeta = { ...selectedMeta, title: normalizeCdTrackText(incomingMeta?.title) || normalizeCdTrackText(selectedMeta?.title) || normalizeCdTrackText(activeJob?.title) || normalizeCdTrackText(cdInfo?.detectedTitle) || 'Audio CD', artist: normalizeCdTrackText(incomingMeta?.artist) || normalizeCdTrackText(selectedMeta?.artist) || null, year: normalizeOptionalYear(incomingMeta?.year) ?? normalizeOptionalYear(selectedMeta?.year) ?? normalizeOptionalYear(activeJob?.year) ?? null }; const mergedTracks = tocTracks.map((track) => { const position = normalizeTrackPosition(track?.position); if (!position) { return null; } const incoming = incomingByPosition.get(position) || null; const fallbackTitle = normalizeCdTrackText(track?.title) || `Track ${position}`; const fallbackArtist = normalizeCdTrackText(track?.artist) || normalizeCdTrackText(effectiveSelectedMeta?.artist) || ''; const title = normalizeCdTrackText(incoming?.title) || fallbackTitle; const artist = normalizeCdTrackText(incoming?.artist) || fallbackArtist || null; const selected = incoming ? Boolean(incoming?.selected) : (track?.selected !== false); return { ...track, position, title, artist, selected }; }).filter(Boolean); const effectiveSelectedTrackPositions = selectedTrackPositions.length > 0 ? selectedTrackPositions : mergedTracks.filter((track) => track?.selected !== false).map((track) => track.position); const selectedPreEncodeScriptIds = normalizeScriptIdList(ripConfig?.selectedPreEncodeScriptIds || []); const selectedPostEncodeScriptIds = normalizeScriptIdList(ripConfig?.selectedPostEncodeScriptIds || []); const selectedPreEncodeChainIds = normalizeChainIdList(ripConfig?.selectedPreEncodeChainIds || []); const selectedPostEncodeChainIds = normalizeChainIdList(ripConfig?.selectedPostEncodeChainIds || []); const activeEncodePlan = this.safeParseJson(activeJob.encode_plan_json) || {}; const plannedOutputConflict = activeEncodePlan?.outputConflict && typeof activeEncodePlan.outputConflict === 'object' ? activeEncodePlan.outputConflict : {}; const incomingOutputConflict = ripConfig?.outputConflict && typeof ripConfig.outputConflict === 'object' ? ripConfig.outputConflict : {}; const outputConflictStrategy = String( incomingOutputConflict.strategy || plannedOutputConflict.strategy || '' ).trim().toLowerCase(); const keepBothOutput = outputConflictStrategy === 'keep_both' || Boolean(incomingOutputConflict.keepBoth || plannedOutputConflict.keepBoth || ripConfig?.keepBoth); const explicitDeleteFolders = Array.isArray(ripConfig?.deleteFolders) ? ripConfig.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) : []; if (explicitDeleteFolders.length > 0) { try { const specificDeleteResult = await historyService.deleteSpecificOutputFolders(activeJobId, explicitDeleteFolders); await historyService.appendLog( activeJobId, 'USER_ACTION', `CD-Start: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` ); } catch (error) { logger.warn('startCdRip:delete-specific-folders-failed', { jobId: activeJobId, error: errorToMeta(error) }); } } const [ selectedPreEncodeScripts, selectedPostEncodeScripts, selectedPreEncodeChains, selectedPostEncodeChains ] = await Promise.all([ scriptService.resolveScriptsByIds(selectedPreEncodeScriptIds, { strict: true }), scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, { strict: true }), scriptChainService.getChainsByIds(selectedPreEncodeChainIds), scriptChainService.getChainsByIds(selectedPostEncodeChainIds) ]); const ensureResolvedChains = (requestedIds, resolvedChains, fieldName) => { const resolved = Array.isArray(resolvedChains) ? resolvedChains : []; const resolvedSet = new Set( resolved .map((chain) => Number(chain?.id)) .filter((id) => Number.isFinite(id) && id > 0) .map((id) => Math.trunc(id)) ); const missing = requestedIds.filter((id) => !resolvedSet.has(Number(id))); if (missing.length === 0) { return; } const error = new Error(`Skriptkette(n) nicht gefunden: ${missing.join(', ')}`); error.statusCode = 400; error.details = [{ field: fieldName, message: `Nicht gefunden: ${missing.join(', ')}` }]; throw error; }; ensureResolvedChains(selectedPreEncodeChainIds, selectedPreEncodeChains, 'selectedPreEncodeChainIds'); ensureResolvedChains(selectedPostEncodeChainIds, selectedPostEncodeChains, 'selectedPostEncodeChainIds'); const toScriptDescriptor = (script) => { const id = Number(script?.id); const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null; if (!normalizedId) { return null; } const name = String(script?.name || '').trim() || `Skript #${normalizedId}`; return { id: normalizedId, name }; }; const toChainDescriptor = (chain) => { const id = Number(chain?.id); const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null; if (!normalizedId) { return null; } const name = String(chain?.name || '').trim() || `Kette #${normalizedId}`; return { id: normalizedId, name }; }; const settings = cdSettings; const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; const cdOutputTemplate = String( settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE ).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; const cdRawBaseDir = String(settings.raw_dir || '').trim() || settingsService.DEFAULT_CD_DIR; const cdOutputBaseDir = String(settings.movie_dir || '').trim() || cdRawBaseDir; const cdRawOwner = String(settings.raw_dir_owner || '').trim(); const cdOutputOwner = String(settings.movie_dir_owner || settings.raw_dir_owner || '').trim(); // For skipRip: use existing WAV dir instead of creating a new raw directory let rawWavDir; let rawJobDir; let cdMetadataBase = null; if (skipRip) { const existingRawPath = this.resolveCurrentRawPathForSettings(settings, 'cd', activeJob.raw_path); if (!existingRawPath) { const error = new Error(`CD-Encode nicht möglich: RAW-Verzeichnis nicht erreichbar (${activeJob.raw_path}).`); error.statusCode = 400; throw error; } rawWavDir = existingRawPath; rawJobDir = existingRawPath; } else { cdMetadataBase = buildRawMetadataBase({ title: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null, year: effectiveSelectedMeta?.year || null }, activeJobId); const rawDirName = buildRawDirName(cdMetadataBase, activeJobId, { state: RAW_FOLDER_STATES.INCOMPLETE }); rawJobDir = path.join(cdRawBaseDir, rawDirName); rawWavDir = rawJobDir; ensureDir(cdRawBaseDir); ensureDir(rawJobDir); chownRecursive(rawJobDir, cdRawOwner); } let outputDir = null; if (!skipEncode) { const baseOutputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdOutputBaseDir, cdOutputTemplate); const baseOutputStillExists = (() => { try { return fs.existsSync(baseOutputDir); } catch (_error) { return false; } })(); // Conflict strategy: // - keepBoth => always number (_X) // - replace + remaining sibling folders => continue numbering (_X) // - replace + all removed => reuse base folder without numbering const needsNumberedOutput = keepBothOutput || baseOutputStillExists; outputDir = needsNumberedOutput ? ensureUniqueOutputPath(baseOutputDir) : baseOutputDir; ensureDir(outputDir); chownRecursive(outputDir, cdOutputOwner); } const previewTrackPos = effectiveSelectedTrackPositions[0] || mergedTracks[0]?.position || 1; const previewWavPath = path.join(rawWavDir, `track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`); const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${effectiveDevicePath || ''} ${previewTrackPos} ${previewWavPath}`; const cdLiveTrackRows = buildCdLiveTrackRows( effectiveSelectedTrackPositions, mergedTracks, effectiveSelectedMeta?.artist ); const initialCdLive = buildCdLiveProgressSnapshot({ trackRows: cdLiveTrackRows, phase: skipRip ? 'encode' : 'rip', trackIndex: cdLiveTrackRows.length > 0 ? 1 : 0, trackTotal: cdLiveTrackRows.length, trackPosition: cdLiveTrackRows[0]?.position || null, ripCompletedCount: skipRip ? cdLiveTrackRows.length : 0, encodeCompletedCount: 0 }); const startedAt = nowIso(); const cdEncodePlan = { format, formatOptions, selectedTracks: effectiveSelectedTrackPositions, tracks: mergedTracks, directReencodeReady: skipRip || !skipEncode, directReencodeReadyAt: skipRip || !skipEncode ? startedAt : null, outputTemplate: cdOutputTemplate, outputConflict: { strategy: keepBothOutput ? 'keep_both' : 'replace', keepBoth: keepBothOutput }, preEncodeScriptIds: selectedPreEncodeScripts.map((item) => Number(item.id)), postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)), preEncodeScripts: selectedPreEncodeScripts.map(toScriptDescriptor).filter(Boolean), postEncodeScripts: selectedPostEncodeScripts.map(toScriptDescriptor).filter(Boolean), preEncodeChainIds: selectedPreEncodeChainIds, postEncodeChainIds: selectedPostEncodeChainIds, preEncodeChains: selectedPreEncodeChains.map(toChainDescriptor).filter(Boolean), postEncodeChains: selectedPostEncodeChains.map(toChainDescriptor).filter(Boolean) }; const updatedCdInfo = { ...cdInfo, tracks: mergedTracks, selectedMetadata: effectiveSelectedMeta }; const jobUpdate = { title: effectiveSelectedMeta?.title || null, year: normalizeOptionalYear(effectiveSelectedMeta?.year), status: 'CD_RIPPING', last_state: 'CD_RIPPING', start_time: startedAt, end_time: null, error_message: null, output_path: outputDir, handbrake_info_json: null, mediainfo_info_json: null, makemkv_info_json: JSON.stringify(updatedCdInfo), encode_plan_json: JSON.stringify(cdEncodePlan) }; // Only overwrite raw_path for normal rips; skipRip keeps the existing WAV directory if (!skipRip) { jobUpdate.raw_path = rawJobDir; jobUpdate.rip_successful = 0; } await historyService.updateJob(activeJobId, jobUpdate); const existingCdDrive = this.cdDrives.get(effectiveDevicePath); this._setCdDriveState(effectiveDevicePath, { state: 'CD_RIPPING', jobId: activeJobId, progress: 0, eta: null, statusText: skipRip ? 'Tracks werden encodiert …' : (skipEncode ? 'CD wird RAW gerippt …' : 'CD wird gerippt …'), context: { ...(existingCdDrive?.context || {}), jobId: activeJobId, mediaProfile: 'cd', tracks: mergedTracks, selectedMetadata: effectiveSelectedMeta, devicePath: devicePath || null, virtualDrivePath: skipRip ? effectiveDevicePath : undefined, skipRip: skipRip || undefined, cdparanoiaCmd, rawWavDir, outputPath: outputDir, outputTemplate: cdOutputTemplate, cdRipConfig: cdEncodePlan, cdLive: initialCdLive, cdparanoiaCommandPreview } }); if (!skipRip && !String(effectiveDevicePath || '').startsWith('__virtual__')) { this._acquireDriveLockForJob(effectiveDevicePath, activeJobId, { stage: 'CD_RIPPING', source: 'CDPARANOIA_RIP', mediaProfile: 'cd', reason: 'rip_running' }); } else { this._releaseDriveLockForJob(activeJobId, { reason: 'encode_from_raw' }); } logger.info('cd:rip:start', { jobId: activeJobId, devicePath: effectiveDevicePath, skipRip, skipEncode, pluginId: cdRipPlugin?.id || null, format, trackCount: effectiveSelectedTrackPositions.length }); await historyService.appendLog( activeJobId, 'SYSTEM', skipRip ? `CD-Encode aus RAW gestartet: Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` : ( skipEncode ? `CD-RAW-Rip gestartet: Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` : `CD-Rip gestartet: Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` ) ); if ( selectedPreEncodeScripts.length > 0 || selectedPreEncodeChains.length > 0 || selectedPostEncodeScripts.length > 0 || selectedPostEncodeChains.length > 0 ) { await historyService.appendLog( activeJobId, 'SYSTEM', `CD Skript-Auswahl: Pre-Skripte=${selectedPreEncodeScripts.length}, Pre-Ketten=${selectedPreEncodeChains.length}, ` + `Post-Skripte=${selectedPostEncodeScripts.length}, Post-Ketten=${selectedPostEncodeChains.length}.` ); } // Run asynchronously so the HTTP response returns immediately this._runCdRip({ jobId: activeJobId, devicePath: effectiveDevicePath, cdparanoiaCmd, rawWavDir, rawBaseDir: skipRip ? null : cdRawBaseDir, cdMetadataBase, outputDir, format, formatOptions, outputTemplate: cdOutputTemplate, rawOwner: cdRawOwner, outputOwner: cdOutputOwner, selectedTrackPositions: effectiveSelectedTrackPositions, tocTracks: mergedTracks, selectedMeta: effectiveSelectedMeta, encodePlan: cdEncodePlan, ripPlugin: cdRipPlugin, skipRip, skipEncode }).catch((error) => { logger.error('cd:rip:unhandled', { jobId: activeJobId, error: errorToMeta(error) }); }); return { jobId: activeJobId, sourceJobId: shouldReplaceSourceJob ? Number(jobId) : null, replacedSourceJob: shouldReplaceSourceJob, started: true }; } async _runCdRip({ jobId, devicePath, cdparanoiaCmd, rawWavDir, rawBaseDir, cdMetadataBase, outputDir, format, formatOptions, outputTemplate, rawOwner, outputOwner, selectedTrackPositions, tocTracks, selectedMeta, encodePlan = null, ripPlugin = null, skipRip = false, skipEncode = false }) { const processKey = Number(jobId); let currentProcessHandle = null; let lifecycleResolve = null; let lifecycleSettled = false; const lifecyclePromise = new Promise((resolve) => { lifecycleResolve = resolve; }); const settleLifecycle = () => { if (lifecycleSettled) { return; } lifecycleSettled = true; lifecycleResolve({ settled: true }); }; const sharedHandle = { child: null, promise: lifecyclePromise, cancel: () => { try { currentProcessHandle?.cancel?.(); } catch (_error) { // ignore cancel race errors } } }; let currentTrackPosition = null; let buildLiveContext = () => null; this.activeProcesses.set(processKey, sharedHandle); this.syncPrimaryActiveProcess(); try { const normalizedEncodePlan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; const preScriptIds = normalizeScriptIdList(normalizedEncodePlan?.preEncodeScriptIds || []); const preChainIds = normalizeChainIdList(normalizedEncodePlan?.preEncodeChainIds || []); const postScriptIds = normalizeScriptIdList(normalizedEncodePlan?.postEncodeScriptIds || []); const postChainIds = normalizeChainIdList(normalizedEncodePlan?.postEncodeChainIds || []); let preEncodeScriptsSummary = { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] }; let postEncodeScriptsSummary = { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] }; const selectedTrackOrder = normalizeCdTrackPositionList(selectedTrackPositions); const liveTrackRows = buildCdLiveTrackRows(selectedTrackOrder, tocTracks, selectedMeta?.artist); const effectiveTrackTotal = liveTrackRows.length; let ripCompletedCount = 0; let encodeCompletedCount = 0; let currentPhase = 'rip'; let currentTrackIndex = effectiveTrackTotal > 0 ? 1 : 0; currentTrackPosition = liveTrackRows[0]?.position || null; buildLiveContext = (failedTrackPosition = null) => buildCdLiveProgressSnapshot({ trackRows: liveTrackRows, phase: currentPhase, trackIndex: currentTrackIndex, trackTotal: effectiveTrackTotal, trackPosition: currentTrackPosition, ripCompletedCount, encodeCompletedCount, failedTrackPosition }); if (preScriptIds.length > 0 || preChainIds.length > 0) { await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Rip Skripte/Ketten werden ausgeführt...'); preEncodeScriptsSummary = await this.runPreEncodeScripts(jobId, normalizedEncodePlan, { mode: 'cd_rip', jobId, jobTitle: selectedMeta?.title || `Job #${jobId}`, inputPath: devicePath || null, outputPath: outputDir || null, rawPath: rawWavDir || null, mediaProfile: 'cd', pipelineStage: 'CD_RIPPING' }); await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Rip Skripte/Ketten abgeschlossen.'); } let encodeStateApplied = false; let lastProgressPercent = 0; const bindProcessHandle = (handle) => { currentProcessHandle = handle && typeof handle === 'object' ? handle : null; sharedHandle.child = currentProcessHandle?.child || null; this.syncPrimaryActiveProcess(); if (this.cancelRequestedByJob.has(processKey)) { try { currentProcessHandle?.cancel?.(); } catch (_error) { // ignore cancel race errors } } }; const handleRipProgress = async ({ phase, percent, trackIndex, trackTotal, trackPosition, trackEvent }) => { const normalizedPhase = phase === 'encode' ? 'encode' : 'rip'; const stage = normalizedPhase === 'rip' ? 'CD_RIPPING' : 'CD_ENCODING'; const normalizedTrackTotal = normalizePositiveInteger(trackTotal) || effectiveTrackTotal; const normalizedTrackIndex = normalizePositiveInteger(trackIndex) || currentTrackIndex || (normalizedTrackTotal > 0 ? 1 : 0); const normalizedTrackPosition = normalizePositiveInteger(trackPosition) || currentTrackPosition || null; const normalizedTrackEvent = String(trackEvent || '').trim().toLowerCase(); let clampedPercent = Math.max(0, Math.min(100, Number(percent) || 0)); if (skipEncode && normalizedPhase === 'rip') { clampedPercent = Math.max(0, Math.min(100, clampedPercent)); } else if (normalizedPhase === 'rip') { clampedPercent = Math.min(clampedPercent, 50); } else { clampedPercent = Math.max(50, clampedPercent); } if (clampedPercent < lastProgressPercent) { clampedPercent = lastProgressPercent; } clampedPercent = Number(clampedPercent.toFixed(2)); lastProgressPercent = clampedPercent; if (normalizedPhase === 'rip') { currentPhase = 'rip'; currentTrackIndex = normalizedTrackIndex; currentTrackPosition = normalizedTrackPosition; if (normalizedTrackEvent === 'complete') { ripCompletedCount = Math.max(ripCompletedCount, normalizedTrackIndex); if (ripCompletedCount >= normalizedTrackTotal) { currentTrackPosition = null; } } else { ripCompletedCount = Math.max(ripCompletedCount, Math.max(0, normalizedTrackIndex - 1)); } } else { currentPhase = 'encode'; ripCompletedCount = Math.max(ripCompletedCount, normalizedTrackTotal); currentTrackIndex = normalizedTrackIndex; currentTrackPosition = normalizedTrackPosition; if (normalizedTrackEvent === 'complete') { encodeCompletedCount = Math.max(encodeCompletedCount, normalizedTrackIndex); if (encodeCompletedCount >= normalizedTrackTotal) { currentTrackPosition = null; } } else { encodeCompletedCount = Math.max(encodeCompletedCount, Math.max(0, normalizedTrackIndex - 1)); } } if (!skipEncode && normalizedPhase === 'encode' && !encodeStateApplied) { encodeStateApplied = true; await historyService.updateJob(jobId, { status: 'CD_ENCODING', last_state: 'CD_ENCODING' }); } const detail = Number.isFinite(Number(trackIndex)) && Number.isFinite(Number(trackTotal)) && Number(trackTotal) > 0 ? ` (${Math.trunc(Number(trackIndex))}/${Math.trunc(Number(trackTotal))})` : ''; const statusText = normalizedPhase === 'rip' ? `${skipEncode ? 'CD wird RAW gerippt' : 'CD wird gerippt'} …${detail}` : `Tracks werden encodiert …${detail}`; await this.updateProgress(stage, clampedPercent, null, statusText, processKey, { contextPatch: { cdLive: buildLiveContext(null) } }); // Keep cdDrives entry in sync (no broadcast — PIPELINE_PROGRESS covers real-time updates) const currentCdDriveEntry = this.cdDrives.get(devicePath); if (currentCdDriveEntry) { this.cdDrives.set(devicePath, { ...currentCdDriveEntry, state: stage, progress: clampedPercent, statusText, context: { ...(currentCdDriveEntry.context || {}), cdLive: buildLiveContext(null) } }); } }; let cdRipResult = null; if (ripPlugin?.id === 'cd') { const pluginJob = await historyService.getJobById(jobId); const pluginCtx = await this.buildPluginContext(ripPlugin.id, { jobId, emitProgress: (progressPercent, statusText) => { const fallbackPercent = Math.max(lastProgressPercent, Math.min(100, Number(progressPercent) || 0)); const fallbackStage = currentPhase === 'encode' ? 'CD_ENCODING' : 'CD_RIPPING'; void this.updateProgress(fallbackStage, fallbackPercent, null, statusText || null, processKey, { contextPatch: { cdLive: buildLiveContext(null) } }); }, ripConfig: { format, formatOptions, selectedTracks: selectedTrackOrder, tracks: tocTracks, metadata: selectedMeta, skipRip, skipEncode }, rawWavDir, outputDir, outputTemplate, isCancelled: () => this.cancelRequestedByJob.has(processKey), onProcessHandle: bindProcessHandle, onProgress: (event) => { handleRipProgress(event).catch((progressError) => { logger.debug('cd:rip:plugin-progress-update-failed', { jobId, error: errorToMeta(progressError) }); }); }, onLog: async (_level, msg) => { await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {}); }, context: { jobId: processKey } }); cdRipResult = await ripPlugin.rip( pluginJob || { id: jobId, disc_device: devicePath || null, makemkv_info_json: null }, pluginCtx ); logger.info('plugin:cd:rip:used', { jobId, pluginId: ripPlugin.id, skipRip, skipEncode, trackCount: selectedTrackOrder.length }); } else { cdRipResult = await cdRipService.ripAndEncode({ jobId, devicePath, cdparanoiaCmd, rawWavDir, outputDir, format, formatOptions, outputTemplate, selectedTracks: selectedTrackOrder, tracks: tocTracks, meta: selectedMeta, skipRip, skipEncode, onProcessHandle: bindProcessHandle, isCancelled: () => this.cancelRequestedByJob.has(processKey), onProgress: handleRipProgress, onLog: async (_level, msg) => { await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {}); }, context: { jobId: processKey } }); } const { encodeResults: cdEncodeResults = [] } = cdRipResult || {}; settleLifecycle(); if (postScriptIds.length > 0 || postChainIds.length > 0) { await historyService.appendLog(jobId, 'SYSTEM', 'Post-Rip Skripte/Ketten werden ausgeführt...'); try { postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, normalizedEncodePlan, { mode: 'cd_rip', jobId, jobTitle: selectedMeta?.title || `Job #${jobId}`, inputPath: devicePath || null, outputPath: outputDir || null, rawPath: rawWavDir || null, mediaProfile: 'cd', pipelineStage: skipEncode ? 'CD_RIPPING' : 'CD_ENCODING' }); } catch (error) { logger.warn('cd:rip:post-script:failed', { jobId, error: errorToMeta(error) }); await historyService.appendLog( jobId, 'SYSTEM', `Post-Rip Skripte/Ketten konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}` ); } await historyService.appendLog( jobId, 'SYSTEM', `Post-Rip Skripte/Ketten abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ` + `${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.` ); } // RAW-Verzeichnis von Incomplete_ → Zielzustand umbenennen let activeRawDir = rawWavDir; if (!skipRip && rawBaseDir && cdMetadataBase) { try { const targetRawState = skipEncode ? RAW_FOLDER_STATES.RIP_COMPLETE : RAW_FOLDER_STATES.COMPLETE; const completedRawDirName = buildRawDirName(cdMetadataBase, jobId, { state: targetRawState }); const completedRawDir = path.join(rawBaseDir, completedRawDirName); if (activeRawDir !== completedRawDir && fs.existsSync(activeRawDir) && !fs.existsSync(completedRawDir)) { fs.renameSync(activeRawDir, completedRawDir); activeRawDir = completedRawDir; } } catch (_renameError) { // ignore – raw dir bleibt unter bestehendem Namen zugänglich } } const directReencodeReadyAt = normalizedEncodePlan?.directReencodeReadyAt || nowIso(); const persistedEncodePlan = { ...normalizedEncodePlan, directReencodeReady: true, directReencodeReadyAt }; if (skipEncode) { await historyService.updateJob(jobId, { status: 'CD_READY_TO_RIP', last_state: 'CD_READY_TO_RIP', end_time: null, rip_successful: 1, raw_path: activeRawDir, output_path: null, encode_input_path: null, encode_review_confirmed: 0, encode_plan_json: JSON.stringify(persistedEncodePlan), handbrake_info_json: JSON.stringify({ mode: 'cd_rip_raw', tracks: cdEncodeResults, preEncodeScripts: preEncodeScriptsSummary, postEncodeScripts: postEncodeScriptsSummary }) }); const cdPromotedUrl = thumbnailService.promoteJobThumbnail(jobId); if (cdPromotedUrl) { await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {}); } chownRecursive(activeRawDir, rawOwner || outputOwner); await historyService.appendLog( jobId, 'SYSTEM', `CD-RAW-Rip abgeschlossen. Laufwerk freigegeben. Encode-Auswahl kann jetzt gestartet werden (RAW: ${activeRawDir}).` ); this._releaseDriveLockForJob(jobId, { reason: 'cd_raw_rip_successful' }); if (String(devicePath || '').startsWith('__virtual__')) { this._removeCdDrive(devicePath); } else { this._releaseCdDrive(devicePath); } const readyVirtualDrivePath = `__virtual__${jobId}`; currentPhase = 'encode'; ripCompletedCount = effectiveTrackTotal; encodeCompletedCount = 0; currentTrackIndex = effectiveTrackTotal > 0 ? 1 : 0; currentTrackPosition = liveTrackRows[0]?.position || null; const readyCdLive = buildLiveContext(null); this._setCdDriveState(readyVirtualDrivePath, { state: 'CD_READY_TO_RIP', jobId, progress: 0, eta: null, statusText: 'RAW-Rip abgeschlossen - Auswahl und Encode bereit', context: { jobId, mediaProfile: 'cd', devicePath: null, virtualDrivePath: readyVirtualDrivePath, skipRip: true, cdparanoiaCmd, rawWavDir: activeRawDir, outputPath: null, outputTemplate, tracks: tocTracks, selectedMetadata: selectedMeta, cdRipConfig: persistedEncodePlan, cdLive: readyCdLive } }); this.jobProgress.delete(processKey); void this.notifyPushover('metadata_ready', { title: 'Ripster - CD RAW bereit', message: `Job #${jobId}: RAW-Rip abgeschlossen, Auswahl/Encode bereit` }); if (!String(devicePath || '').startsWith('__virtual__')) { void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath)); } } else { await historyService.updateJob(jobId, { status: 'FINISHED', last_state: 'FINISHED', end_time: nowIso(), rip_successful: 1, raw_path: activeRawDir, output_path: outputDir, handbrake_info_json: JSON.stringify({ mode: 'cd_rip', tracks: cdEncodeResults, preEncodeScripts: preEncodeScriptsSummary, postEncodeScripts: postEncodeScriptsSummary }), encode_plan_json: JSON.stringify(persistedEncodePlan) }); const cdPromotedUrl = thumbnailService.promoteJobThumbnail(jobId); if (cdPromotedUrl) { await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {}); } chownRecursive(activeRawDir, rawOwner || outputOwner); if (outputDir) { chownRecursive(outputDir, outputOwner); await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip abgeschlossen. Ausgabe: ${outputDir}`); historyService.addJobOutputFolder(jobId, outputDir).catch((e) => { logger.warn('cd:record-output-folder-failed', { jobId, error: e?.message }); }); } else { await historyService.appendLog(jobId, 'SYSTEM', 'CD-Rip abgeschlossen.'); } this._releaseDriveLockForJob(jobId, { reason: 'cd_rip_successful' }); const finishedStatusText = postEncodeScriptsSummary.failed > 0 ? `CD-Rip abgeschlossen (${postEncodeScriptsSummary.failed} Skript(e) fehlgeschlagen)` : 'CD-Rip abgeschlossen'; currentPhase = 'encode'; ripCompletedCount = effectiveTrackTotal; encodeCompletedCount = effectiveTrackTotal; currentTrackIndex = effectiveTrackTotal; currentTrackPosition = null; const finishedCdLive = buildLiveContext(null); this._setCdDriveState(devicePath, { state: 'FINISHED', jobId, progress: 100, eta: null, statusText: finishedStatusText, context: { jobId, mediaProfile: 'cd', tracks: tocTracks, outputDir, outputPath: outputDir, cdRipConfig: persistedEncodePlan, cdLive: finishedCdLive, selectedMetadata: selectedMeta } }); // Virtual drives (orphan CD jobs without a real device) are removed after encode; real drives reset to DISC_DETECTED if (String(devicePath || '').startsWith('__virtual__')) { this._removeCdDrive(devicePath); } else { this._releaseCdDrive(devicePath); } this.jobProgress.delete(processKey); void this.notifyPushover('job_finished', { title: 'Ripster - CD Rip erfolgreich', message: `Job #${jobId}: ${selectedMeta?.title || 'Audio CD'}` }); if (!String(devicePath || '').startsWith('__virtual__')) { void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath)); } } } catch (error) { settleLifecycle(); const failedCdLive = buildLiveContext(currentTrackPosition || null); const currentDriveEntry = this.cdDrives.get(devicePath); const currentDriveState = currentDriveEntry?.state || 'CD_RIPPING'; const failStage = currentDriveState === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING'; await this.updateProgress(failStage, currentDriveEntry?.progress ?? 0, null, currentDriveEntry?.statusText ?? null, processKey, { contextPatch: { cdLive: failedCdLive } }); logger.error('cd:rip:failed', { jobId, error: errorToMeta(error) }); await this.failJob(jobId, failStage, error); } finally { this.activeProcesses.delete(processKey); this.syncPrimaryActiveProcess(); } } // ═══════════════════════════════════════════════════════════════════════════ // CONVERTER // ═══════════════════════════════════════════════════════════════════════════ /** * Zentraler Intake für dateibasierte Jobs. * Bestehende Endpunkte bleiben erhalten und delegieren intern auf diese Methode. * * @param {object} payload * @param {'audiobook_upload'|'audiobook'|'converter_entry'|'converter'} payload.kind * @param {object} [payload.file] * @param {string} [payload.relPath] * @param {object} [payload.options] * @returns {Promise} */ async createFileJob(payload = {}) { const inferredKind = String(payload?.kind || '').trim().toLowerCase() || (payload?.file ? 'audiobook_upload' : '') || (payload?.relPath ? 'converter_entry' : ''); const kind = inferredKind; if (kind === 'audiobook_upload' || kind === 'audiobook') { if (!payload?.file) { const error = new Error('Upload-Datei fehlt.'); error.statusCode = 400; throw error; } return this.uploadAudiobookFile(payload.file, payload?.options || {}); } if (kind === 'converter_entry' || kind === 'converter') { const relPath = String(payload?.relPath || '').trim(); if (!relPath) { const error = new Error('relPath fehlt.'); error.statusCode = 400; throw error; } return this.createConverterJobFromEntry(relPath, payload?.options || {}); } const error = new Error(`Unbekannter File-Job-Typ: ${kind || 'n/a'}`); error.statusCode = 400; throw error; } /** * Erstellt einen Converter-Job aus einem Scan-Eintrag (File-Explorer-Auswahl). * * @param {string} relPath - Relativer Pfad innerhalb von converter_raw_dir * @param {object} options * @param {string} options.converterMediaType - 'video'|'audio'|'iso' * @param {boolean} options.isFolder - Ordner-Job? * @returns {Promise} Job-Objekt */ async createConverterJobFromEntry(relPath, options = {}) { const converterScanService = require('./converterScanService'); const rawDir = await converterScanService.getRawDir(); if (!rawDir) { const error = new Error('Converter Raw-Ordner nicht konfiguriert.'); error.statusCode = 400; throw error; } const fullPath = path.join(rawDir, relPath); if (!fs.existsSync(fullPath)) { const error = new Error(`Eingabedatei nicht gefunden: ${fullPath}`); error.statusCode = 404; throw error; } const stat = fs.statSync(fullPath); const isDirectory = stat.isDirectory(); const baseName = path.basename(relPath, path.extname(relPath)); const detectedTitle = baseName || 'Converter Job'; let converterMediaType = options.converterMediaType || converterScanService.detectMediaType(path.basename(relPath)); // Für Ordner ohne erkannte Dateiendung: Medientyp anhand der enthaltenen Dateien bestimmen if (isDirectory && !converterMediaType) { try { const dirFiles = fs.readdirSync(fullPath, { withFileTypes: true }); let audioCount = 0; let videoCount = 0; for (const f of dirFiles) { if (!f.isFile()) continue; const mt = converterScanService.detectMediaType(f.name); if (mt === 'audio') audioCount++; else if (mt === 'video' || mt === 'iso') videoCount++; } // Mehrheit gewinnt; bei Gleichstand gilt audio (häufigster Fall: Musikalbum) if (audioCount > 0 && audioCount >= videoCount) converterMediaType = 'audio'; else if (videoCount > 0) converterMediaType = 'video'; } catch (_err) { // Ordner nicht lesbar — bleibt null } } const isVideoLikeConverterJob = converterMediaType === 'video' || converterMediaType === 'iso'; const initialStatus = 'READY_TO_START'; const normalizedConverterMediaType = ['audio', 'video', 'iso'].includes( String(converterMediaType || '').trim().toLowerCase() ) ? String(converterMediaType || '').trim().toLowerCase() : 'video'; const job = await historyService.createJob({ discDevice: null, status: initialStatus, detectedTitle, mediaType: 'converter', jobKind: resolveConverterJobKind(normalizedConverterMediaType) }); await historyService.updateJob(job.id, { media_type: 'converter', job_kind: resolveConverterJobKind(normalizedConverterMediaType), raw_path: fullPath, encode_plan_json: JSON.stringify({ mediaProfile: 'converter', jobKind: resolveConverterJobKind(normalizedConverterMediaType), converterMediaType: normalizedConverterMediaType, inputPath: fullPath, isFolder: isDirectory, audioFiles: isDirectory && normalizedConverterMediaType === 'audio' ? require('./converterScanService').detectFormat ? null // wird beim Start gefüllt : null : null }), last_state: initialStatus }); // Scan-Eintrag als Job markieren await converterScanService.markEntryAsJob(relPath, job.id); await historyService.appendLog( job.id, 'SYSTEM', `Converter-Job erstellt aus: ${relPath} | Typ: ${normalizedConverterMediaType || '-'}` ); if (isVideoLikeConverterJob) { await historyService.appendLog( job.id, 'SYSTEM', 'Metadaten sind optional. Review kann direkt gestartet werden; OMDb kann später zugeordnet werden.' ); } logger.info('converter:job:created-from-entry', { jobId: job.id, relPath, converterMediaType: normalizedConverterMediaType, fullPath }); return historyService.getJobById(job.id); } /** * Lädt Converter-Dateien hoch und legt Unterordner in converter_raw_dir an. * Erstellt KEINE Jobs – das geschieht separat via createConverterJobsFromSelection. * * @param {Array<{path, originalname, size}>} files - Multer-Dateiobjekte * @returns {Promise<{folders: Array<{folderRelPath, fileCount}>}>} */ async uploadConverterFiles(files, options = {}) { const converterScanService = require('./converterScanService'); const rawDir = await converterScanService.getRawDir(); if (!rawDir) { const error = new Error('Converter Raw-Ordner nicht konfiguriert.'); error.statusCode = 400; throw error; } ensureDir(rawDir); const normalizedFiles = Array.isArray(files) ? files : [files]; if (normalizedFiles.length === 0) { const error = new Error('Keine Dateien zum Upload.'); error.statusCode = 400; throw error; } const settings = await settingsService.getSettingsMap(); const allowedExtensions = new Set(parseConverterScanExtensions(settings?.converter_scan_extensions)); const invalidFiles = []; for (const file of normalizedFiles) { const tempPath = String(file?.path || '').trim(); const originalName = String(file?.originalname || file?.originalName || '').trim() || path.basename(tempPath || 'upload'); const extension = getFileExtensionWithoutDot(originalName); if (!extension || !allowedExtensions.has(extension)) { invalidFiles.push(originalName); } } if (invalidFiles.length > 0) { cleanupTempUploads(normalizedFiles); const allowedList = [...allowedExtensions].join(', '); const preview = invalidFiles.slice(0, 6).join(', '); const suffix = invalidFiles.length > 6 ? ` (+${invalidFiles.length - 6} weitere)` : ''; const error = new Error( `Upload abgelehnt: Nicht erlaubte Dateiendung in ${invalidFiles.length} Datei(en): ${preview}${suffix}. Erlaubt: ${allowedList}` ); error.statusCode = 400; throw error; } const folderName = options?.folderName ? String(options.folderName).trim() : null; if (folderName) { // ── Ordner-Upload (wie Klangkiste target_path): alle Dateien in EINEN Ordner ── const safeFolderName = sanitizeFileName(folderName) || `upload_${Date.now()}`; const targetDir = ensureUniqueOutputPath(path.join(rawDir, safeFolderName)); const uniqueFolderName = path.basename(targetDir); ensureDir(targetDir); for (const file of normalizedFiles) { const tempPath = String(file?.path || '').trim(); if (!tempPath || !fs.existsSync(tempPath)) continue; const rawName = String(file?.originalname || '').trim(); const safeFileName = sanitizeFileNameWithExtension(rawName) || rawName || path.basename(tempPath); const targetFile = path.join(targetDir, safeFileName); // Traversal-Schutz if (!targetFile.startsWith(targetDir + path.sep)) { logger.warn('converter:upload:traversal-blocked', { rawName }); continue; } moveFileWithFallback(tempPath, targetFile); } logger.info('converter:upload:folder-placed', { folderName, targetDir, fileCount: normalizedFiles.length }); return { folders: [{ folderRelPath: uniqueFolderName, fileCount: normalizedFiles.length }] }; } // ── Einzeldatei-Upload ───────────────────────────────────────────────── const createdFolders = []; for (const file of normalizedFiles) { const tempPath = String(file?.path || '').trim(); const originalName = String(file?.originalname || file?.originalName || '').trim() || path.basename(tempPath || 'upload'); if (!tempPath || !fs.existsSync(tempPath)) { logger.warn('converter:upload:temp-missing', { originalName, tempPath }); continue; } const baseNameClean = sanitizeFileName( path.basename(originalName, path.extname(originalName)) ) || `upload_${Date.now()}`; const targetDir = ensureUniqueOutputPath(path.join(rawDir, baseNameClean)); const subfolder = path.basename(targetDir); ensureDir(targetDir); const safeFileName = sanitizeFileNameWithExtension(originalName) || originalName; const targetFile = path.join(targetDir, safeFileName); moveFileWithFallback(tempPath, targetFile); logger.info('converter:upload:file-placed', { originalName, targetFile }); createdFolders.push({ folderRelPath: subfolder, fileCount: 1 }); } return { folders: createdFolders }; } /** * Erstellt Converter-Jobs aus ausgewählten Dateipfaden (relativ zu converter_raw_dir). * Video-Dateien bekommen je einen eigenen Job, Audio-Dateien je nach audioMode. * Ordner werden vom Frontend bereits zu Einzel-Dateien expandiert. * * @param {string[]} relPaths - Dateipfade relativ zum rawDir * @param {'individual'|'shared'} audioMode - Modus für Audio-Dateien * @returns {Promise} Erstellte Jobs */ async createConverterJobsFromSelection(relPaths, audioMode = 'individual') { const AUDIO_EXTS = new Set(['.flac', '.mp3', '.wav', '.m4a', '.ogg', '.opus']); const converterScanService = require('./converterScanService'); const rawDir = await converterScanService.getRawDir(); if (!rawDir) { const error = new Error('Converter Raw-Ordner nicht konfiguriert.'); error.statusCode = 400; throw error; } const audioRelPaths = []; const nonAudioRelPaths = []; // Videos, ISOs, Ordner, sonstige for (const relPath of relPaths) { const ext = path.extname(String(relPath || '')).toLowerCase(); if (AUDIO_EXTS.has(ext)) { audioRelPaths.push(relPath); } else { nonAudioRelPaths.push(relPath); } } const createdJobs = []; // Nicht-Audio (Video, Ordner, sonstige): immer ein Job pro Eintrag for (const relPath of nonAudioRelPaths) { const job = await this.createFileJob({ kind: 'converter_entry', relPath, options: {} }); createdJobs.push(job); } // Audio-Dateien if (audioMode === 'shared' && audioRelPaths.length > 0) { // Ein gemeinsamer Job für alle Audio-Dateien const fullPaths = audioRelPaths.map((p) => path.join(rawDir, p)); const rawPath = path.dirname(fullPaths[0]); const detectedTitle = path.basename(rawPath) || 'Converter Audio Job'; const job = await historyService.createJob({ discDevice: null, status: 'READY_TO_START', detectedTitle, mediaType: 'converter', jobKind: 'converter_audio' }); await historyService.updateJob(job.id, { media_type: 'converter', job_kind: 'converter_audio', raw_path: rawPath, encode_plan_json: JSON.stringify({ mediaProfile: 'converter', jobKind: 'converter_audio', converterMediaType: 'audio', inputPath: rawPath, inputPaths: fullPaths, isFolder: false, isSharedAudio: true }), last_state: 'READY_TO_START' }); await converterScanService.assignEntriesToJob(audioRelPaths, job.id); await historyService.appendLog( job.id, 'SYSTEM', `Converter-Job (gemeinsam) erstellt: ${audioRelPaths.length} Audio-Datei(en)` ); logger.info('converter:job:created-shared-audio', { jobId: job.id, fileCount: audioRelPaths.length }); createdJobs.push(await historyService.getJobById(job.id)); } else { // Einzelne Jobs für jede Audio-Datei for (const relPath of audioRelPaths) { const job = await this.createFileJob({ kind: 'converter_entry', relPath, options: { converterMediaType: 'audio' } }); createdJobs.push(job); } } return createdJobs; } /** * Fügt Dateien einem existierenden (noch nicht gestarteten) Converter-Job hinzu. * Aktuell werden Mehrfach-Dateien nur für Audio-Jobs unterstützt. * * @param {number} jobId * @param {string[]} relPaths * @returns {Promise<{job: object, addedRelPaths: string[]}>} */ async assignConverterFilesToJob(jobId, relPaths = []) { const normalizedJobId = Number(jobId); if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { const error = new Error('Ungültige Job-ID.'); error.statusCode = 400; throw error; } const converterScanService = require('./converterScanService'); const rawDir = await converterScanService.getRawDir(); if (!rawDir) { const error = new Error('Converter Raw-Ordner nicht konfiguriert.'); error.statusCode = 400; throw error; } const requestedRelPaths = Array.isArray(relPaths) ? relPaths .map((relPath) => converterScanService.normalizeRelPath(relPath)) .filter((relPath) => relPath !== null && relPath !== '') : []; if (requestedRelPaths.length === 0) { const error = new Error('Keine Dateien für Zuweisung übergeben.'); error.statusCode = 400; throw error; } const job = await historyService.getJobById(normalizedJobId); if (!job) { const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); error.statusCode = 404; throw error; } if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) { const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`); error.statusCode = 409; throw error; } if (String(job.status || '').trim().toUpperCase() !== 'READY_TO_START') { const error = new Error('Dateien können nur nicht gestarteten Converter-Jobs zugewiesen werden.'); error.statusCode = 409; throw error; } const existingPlan = this.safeParseJson(job.encode_plan_json) || {}; const converterMediaType = String(existingPlan.converterMediaType || '').trim().toLowerCase(); if (converterMediaType !== 'audio') { const error = new Error('Weitere Dateien können derzeit nur Audio-Converter-Jobs zugewiesen werden.'); error.statusCode = 409; throw error; } if (Boolean(existingPlan.isFolder)) { const error = new Error('Ordnerbasierte Audio-Jobs können nicht erweitert werden. Bitte neuen gemeinsamen Audio-Job nutzen.'); error.statusCode = 409; throw error; } const existingInputPaths = []; if (Array.isArray(existingPlan.inputPaths) && existingPlan.inputPaths.length > 0) { for (const item of existingPlan.inputPaths) { const normalized = String(item || '').trim(); if (normalized) existingInputPaths.push(normalized); } } else { const singleInput = String(existingPlan.inputPath || job.raw_path || '').trim(); if (singleInput && fs.existsSync(singleInput)) { try { const stat = fs.statSync(singleInput); if (stat.isFile()) { existingInputPaths.push(singleInput); } } catch (_err) { // Ignored: validation below catches empty/invalid setups. } } } if (existingInputPaths.length === 0) { const error = new Error(`Job ${normalizedJobId} enthält keine gültige Eingabedatei.`); error.statusCode = 409; throw error; } const nextInputPaths = [...existingInputPaths]; const knownPaths = new Set( existingInputPaths .map((filePath) => normalizeComparablePath(filePath)) .filter(Boolean) ); const addedRelPaths = []; for (const relPath of requestedRelPaths) { const absolutePath = path.join(rawDir, relPath); if (!fs.existsSync(absolutePath)) { const error = new Error(`Datei nicht gefunden: ${relPath}`); error.statusCode = 404; throw error; } let stat; try { stat = fs.statSync(absolutePath); } catch (_err) { const error = new Error(`Datei nicht lesbar: ${relPath}`); error.statusCode = 409; throw error; } if (!stat.isFile()) { const error = new Error(`Nur Dateien können zugewiesen werden: ${relPath}`); error.statusCode = 409; throw error; } const mediaType = converterScanService.detectMediaType(path.basename(relPath)); if (mediaType !== 'audio') { const error = new Error(`Nur Audio-Dateien können diesem Job zugewiesen werden: ${relPath}`); error.statusCode = 409; throw error; } const entry = await converterScanService.getEntryByRelPath(relPath); const assignedJobId = Number(entry?.job_id); if (Number.isFinite(assignedJobId) && assignedJobId > 0 && assignedJobId !== normalizedJobId) { const error = new Error(`Datei ist bereits Job #${assignedJobId} zugewiesen: ${relPath}`); error.statusCode = 409; throw error; } const comparablePath = normalizeComparablePath(absolutePath); if (comparablePath && knownPaths.has(comparablePath)) { continue; } nextInputPaths.push(absolutePath); if (comparablePath) knownPaths.add(comparablePath); addedRelPaths.push(relPath); } if (addedRelPaths.length === 0) { return { job: await historyService.getJobById(normalizedJobId), addedRelPaths: [] }; } const isSharedAudio = nextInputPaths.length > 1; const primaryInputPath = isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0]; const nextPlan = { ...existingPlan, mediaProfile: 'converter', jobKind: 'converter_audio', converterMediaType: 'audio', isFolder: false, isSharedAudio, inputPath: primaryInputPath, inputPaths: isSharedAudio ? nextInputPaths : null, audioFiles: null, reviewConfirmed: false }; await historyService.updateJob(normalizedJobId, { status: 'READY_TO_START', last_state: 'READY_TO_START', media_type: 'converter', job_kind: 'converter_audio', raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0], encode_plan_json: JSON.stringify(nextPlan), encode_review_confirmed: 0, error_message: null, end_time: null }); await converterScanService.assignEntriesToJob(addedRelPaths, normalizedJobId); await historyService.appendLog( normalizedJobId, 'USER_ACTION', `Dateien dem Converter-Job zugewiesen: ${addedRelPaths.join(', ')}` ); return { job: await historyService.getJobById(normalizedJobId), addedRelPaths }; } /** * Entfernt eine Datei aus einem Converter-Job. * * @param {number} jobId * @param {string} relPath * @returns {Promise<{job: object, removedRelPath: string}>} */ async removeConverterFileFromJob(jobId, relPath) { const normalizedJobId = Number(jobId); if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { const error = new Error('Ungültige Job-ID.'); error.statusCode = 400; throw error; } const converterScanService = require('./converterScanService'); const normalizedRelPath = converterScanService.normalizeRelPath(relPath); if (normalizedRelPath === null || normalizedRelPath === '') { const error = new Error('Ungültiger relPath.'); error.statusCode = 400; throw error; } const rawDir = await converterScanService.getRawDir(); if (!rawDir) { const error = new Error('Converter Raw-Ordner nicht konfiguriert.'); error.statusCode = 400; throw error; } const job = await historyService.getJobById(normalizedJobId); if (!job) { const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); error.statusCode = 404; throw error; } if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) { const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`); error.statusCode = 409; throw error; } if (String(job.status || '').trim().toUpperCase() !== 'READY_TO_START') { const error = new Error('Dateien können nur aus nicht gestarteten Converter-Jobs entfernt werden.'); error.statusCode = 409; throw error; } const existingPlan = this.safeParseJson(job.encode_plan_json) || {}; const converterMediaType = String(existingPlan.converterMediaType || '').trim().toLowerCase(); if (converterMediaType !== 'audio' || Boolean(existingPlan.isFolder)) { const error = new Error('Dateien können derzeit nur aus gemeinsamem Audio-Converter-Job entfernt werden.'); error.statusCode = 409; throw error; } const allInputPaths = []; if (Array.isArray(existingPlan.inputPaths) && existingPlan.inputPaths.length > 0) { for (const item of existingPlan.inputPaths) { const normalized = String(item || '').trim(); if (normalized) allInputPaths.push(normalized); } } else { const singleInput = String(existingPlan.inputPath || job.raw_path || '').trim(); if (singleInput && fs.existsSync(singleInput)) { try { const stat = fs.statSync(singleInput); if (stat.isFile()) { allInputPaths.push(singleInput); } } catch (_err) { // Keep empty fallback and validate below. } } } if (allInputPaths.length === 0) { const error = new Error(`Job ${normalizedJobId} enthält keine gültigen Eingabedateien.`); error.statusCode = 409; throw error; } const targetPath = path.join(rawDir, normalizedRelPath); const targetComparable = normalizeComparablePath(targetPath); const hasTarget = allInputPaths.some( (filePath) => normalizeComparablePath(filePath) === targetComparable ); if (!hasTarget) { const error = new Error(`Datei ist diesem Job nicht zugewiesen: ${normalizedRelPath}`); error.statusCode = 409; throw error; } if (allInputPaths.length <= 1) { const error = new Error('Die letzte Datei kann nicht entfernt werden. Bitte Job löschen.'); error.statusCode = 409; throw error; } const nextInputPaths = allInputPaths.filter( (filePath) => normalizeComparablePath(filePath) !== targetComparable ); const isSharedAudio = nextInputPaths.length > 1; const primaryInputPath = isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0]; const nextPlan = { ...existingPlan, mediaProfile: 'converter', jobKind: 'converter_audio', converterMediaType: 'audio', isFolder: false, isSharedAudio, inputPath: primaryInputPath, inputPaths: isSharedAudio ? nextInputPaths : null, audioFiles: null, reviewConfirmed: false }; await historyService.updateJob(normalizedJobId, { status: 'READY_TO_START', last_state: 'READY_TO_START', media_type: 'converter', job_kind: 'converter_audio', raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0], encode_plan_json: JSON.stringify(nextPlan), encode_review_confirmed: 0, error_message: null, end_time: null }); await converterScanService.clearEntryJobAssignment(normalizedRelPath, normalizedJobId); await historyService.appendLog( normalizedJobId, 'USER_ACTION', `Datei aus Converter-Job entfernt: ${normalizedRelPath}` ); return { job: await historyService.getJobById(normalizedJobId), removedRelPath: normalizedRelPath }; } /** * Entfernt eine Datei aus einem Converter-Job via absolutem Input-Pfad. * Nützlich für Track-Tabellen, die mit inputPaths (absolut) arbeiten. * * @param {number} jobId * @param {string} inputPath * @returns {Promise<{job: object, removedRelPath: string|null}>} */ async removeConverterInputFromJob(jobId, inputPath) { const normalizedJobId = Number(jobId); const normalizedInputPath = String(inputPath || '').trim(); if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { const error = new Error('Ungültige Job-ID.'); error.statusCode = 400; throw error; } if (!normalizedInputPath) { const error = new Error('inputPath fehlt.'); error.statusCode = 400; throw error; } const converterScanService = require('./converterScanService'); const rawDir = await converterScanService.getRawDir(); if (rawDir) { const absoluteRawDir = normalizeComparablePath(rawDir); const absoluteInputPath = normalizeComparablePath(normalizedInputPath); if (absoluteRawDir && absoluteInputPath && absoluteInputPath.startsWith(`${absoluteRawDir}${path.sep}`)) { const relCandidate = path.relative(rawDir, normalizedInputPath); const normalizedRelPath = converterScanService.normalizeRelPath(relCandidate); if (normalizedRelPath && normalizedRelPath !== '.') { return this.removeConverterFileFromJob(normalizedJobId, normalizedRelPath); } } } // Fallback: direkte Plan-Manipulation (wenn kein valider relPath ableitbar ist) const job = await historyService.getJobById(normalizedJobId); if (!job) { const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); error.statusCode = 404; throw error; } const existingPlan = this.safeParseJson(job.encode_plan_json) || {}; const allInputPaths = Array.isArray(existingPlan.inputPaths) ? existingPlan.inputPaths.map((value) => String(value || '').trim()).filter(Boolean) : [String(existingPlan.inputPath || job.raw_path || '').trim()].filter(Boolean); if (allInputPaths.length <= 1) { const error = new Error('Die letzte Datei kann nicht entfernt werden. Bitte Job löschen.'); error.statusCode = 409; throw error; } const targetComparable = normalizeComparablePath(normalizedInputPath); const nextInputPaths = allInputPaths.filter((value) => normalizeComparablePath(value) !== targetComparable); if (nextInputPaths.length === allInputPaths.length) { const error = new Error('Datei ist diesem Job nicht zugewiesen.'); error.statusCode = 409; throw error; } const isSharedAudio = nextInputPaths.length > 1; const nextPlan = { ...existingPlan, mediaProfile: 'converter', jobKind: 'converter_audio', converterMediaType: 'audio', isFolder: false, isSharedAudio, inputPath: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0], inputPaths: isSharedAudio ? nextInputPaths : null, audioFiles: null, reviewConfirmed: false }; await historyService.updateJob(normalizedJobId, { status: 'READY_TO_START', last_state: 'READY_TO_START', media_type: 'converter', job_kind: 'converter_audio', raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0], encode_plan_json: JSON.stringify(nextPlan), encode_review_confirmed: 0, error_message: null, end_time: null }); await historyService.appendLog( normalizedJobId, 'USER_ACTION', `Datei aus Converter-Job entfernt (inputPath): ${normalizedInputPath}` ); return { job: await historyService.getJobById(normalizedJobId), removedRelPath: null }; } /** * Speichert Converter-Draft-Konfiguration persistent für READY_TO_START Jobs. * Damit bleiben UI-Eingaben (Metadaten, Tracktitel, Presets etc.) bei Reload erhalten. * * @param {number} jobId * @param {object} config * @returns {Promise<{job: object}>} */ async updateConverterJobConfig(jobId, config = {}) { const normalizedJobId = Number(jobId); if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { const error = new Error('Ungültige Job-ID.'); error.statusCode = 400; throw error; } const job = await historyService.getJobById(normalizedJobId); if (!job) { const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); error.statusCode = 404; throw error; } if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) { const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`); error.statusCode = 409; throw error; } const statusUpper = String(job.status || '').trim().toUpperCase(); const allowedDraftStates = new Set(['READY_TO_START', 'READY_TO_ENCODE', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION']); if (!allowedDraftStates.has(statusUpper)) { const error = new Error('Draft kann nur für nicht gestartete oder geprüfte Converter-Jobs gespeichert werden.'); error.statusCode = 409; throw error; } const existingPlan = this.safeParseJson(job.encode_plan_json) || {}; const normalizeText = (value, maxLen = 300) => { const normalized = String(value || '').trim(); if (!normalized) return null; return normalized.slice(0, maxLen); }; const normalizeNullableNumber = (value) => { const n = Number(value); return Number.isFinite(n) ? Math.trunc(n) : null; }; const requestedMediaType = String( config.converterMediaType || existingPlan.converterMediaType || 'video' ).trim().toLowerCase(); const converterMediaType = ['video', 'audio', 'iso'].includes(requestedMediaType) ? requestedMediaType : 'video'; const defaultFormat = converterMediaType === 'audio' ? 'flac' : 'mkv'; const allowedAudioFormats = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']); const allowedVideoFormats = new Set(['mkv', 'mp4', 'm4v']); let outputFormat = normalizeText(config.outputFormat || existingPlan.outputFormat || defaultFormat, 20) ?.toLowerCase() || defaultFormat; if (converterMediaType === 'audio' && !allowedAudioFormats.has(outputFormat)) { outputFormat = 'flac'; } if ((converterMediaType === 'video' || converterMediaType === 'iso') && !allowedVideoFormats.has(outputFormat)) { outputFormat = 'mkv'; } let userPreset = existingPlan.userPreset || null; if (Object.prototype.hasOwnProperty.call(config, 'userPreset')) { if (config.userPreset && typeof config.userPreset === 'object') { const presetId = Number(config.userPreset.id); userPreset = { ...(Number.isFinite(presetId) && presetId > 0 ? { id: Math.trunc(presetId) } : {}), ...(normalizeText(config.userPreset.handbrakePreset, 200) ? { handbrakePreset: normalizeText(config.userPreset.handbrakePreset, 200) } : {}), ...(normalizeText(config.userPreset.extraArgs, 1000) ? { extraArgs: normalizeText(config.userPreset.extraArgs, 1000) } : {}) }; if (Object.keys(userPreset).length === 0) { userPreset = null; } } else { userPreset = null; } } const nextAudioFormatOptions = ( Object.prototype.hasOwnProperty.call(config, 'audioFormatOptions') && config.audioFormatOptions && typeof config.audioFormatOptions === 'object' ) ? { ...config.audioFormatOptions } : (existingPlan.audioFormatOptions || {}); const nextMetadata = ( Object.prototype.hasOwnProperty.call(config, 'metadata') && config.metadata && typeof config.metadata === 'object' ) ? { albumTitle: normalizeText(config.metadata.albumTitle, 300), albumArtist: normalizeText(config.metadata.albumArtist, 300), albumYear: normalizeNullableNumber(config.metadata.albumYear), mbId: normalizeText(config.metadata.mbId, 120), coverUrl: normalizeText(config.metadata.coverUrl, 500) || null } : (existingPlan.metadata || null); const normalizeTrack = (track) => { const position = Number(track?.position); if (!Number.isFinite(position) || position <= 0) return null; return { position: Math.trunc(position), title: normalizeText(track?.title, 300) || null, artist: normalizeText(track?.artist, 300) || null }; }; const nextTracks = Array.isArray(config.tracks) ? config.tracks.map(normalizeTrack).filter(Boolean) : (Array.isArray(existingPlan.tracks) ? existingPlan.tracks : null); const sanitizeMbEntry = (entry) => { if (!entry || typeof entry !== 'object') return null; return { mbId: normalizeText(entry.mbId, 120), title: normalizeText(entry.title, 300), artist: normalizeText(entry.artist, 300), year: normalizeNullableNumber(entry.year), country: normalizeText(entry.country, 16) }; }; const nextMusicBrainz = ( Object.prototype.hasOwnProperty.call(config, 'musicBrainz') && config.musicBrainz && typeof config.musicBrainz === 'object' ) ? { query: normalizeText(config.musicBrainz.query, 400), selected: sanitizeMbEntry(config.musicBrainz.selected), results: Array.isArray(config.musicBrainz.results) ? config.musicBrainz.results.map(sanitizeMbEntry).filter(Boolean).slice(0, 50) : [] } : (existingPlan.musicBrainz || null); const normalizeEncodeItem = (item) => { if (!item || typeof item !== 'object') return null; const type = String(item.type || '').trim().toLowerCase(); if (type !== 'script' && type !== 'chain') return null; const id = Number(item.id); if (!Number.isFinite(id) || id <= 0) return null; return { type, id: Math.trunc(id) }; }; const nextPreEncodeItems = Array.isArray(config.preEncodeItems) ? config.preEncodeItems.map(normalizeEncodeItem).filter(Boolean) : (Array.isArray(existingPlan.preEncodeItems) ? existingPlan.preEncodeItems : []); const nextPostEncodeItems = Array.isArray(config.postEncodeItems) ? config.postEncodeItems.map(normalizeEncodeItem).filter(Boolean) : (Array.isArray(existingPlan.postEncodeItems) ? existingPlan.postEncodeItems : []); const nextPlan = { ...existingPlan, mediaProfile: 'converter', jobKind: resolveConverterJobKind(converterMediaType), converterMediaType, outputFormat, userPreset, audioFormatOptions: nextAudioFormatOptions, metadata: nextMetadata, tracks: nextTracks, musicBrainz: nextMusicBrainz, preEncodeItems: nextPreEncodeItems, postEncodeItems: nextPostEncodeItems, reviewConfirmed: false }; const newCoverUrl = nextMetadata?.coverUrl || null; const jobPatch = { media_type: 'converter', job_kind: resolveConverterJobKind(converterMediaType), encode_plan_json: JSON.stringify(nextPlan), encode_review_confirmed: 0, error_message: null }; if (newCoverUrl && !thumbnailService.isLocalUrl(newCoverUrl)) { jobPatch.poster_url = newCoverUrl; } await historyService.updateJob(normalizedJobId, jobPatch); if (newCoverUrl && !thumbnailService.isLocalUrl(newCoverUrl)) { historyService.queuePosterCache(normalizedJobId, newCoverUrl, { source: 'Coverart', logFailures: true }); } return { job: await historyService.getJobById(normalizedJobId) }; } /** * Startet einen Converter-Job mit der finalen Konfiguration (Preset, Format etc.). * * @param {number} jobId * @param {object} config * @param {string} config.converterMediaType - 'video'|'audio'|'iso' * @param {string} config.outputFormat - Ausgabeformat (mkv, mp4, flac, mp3, ...) * @param {object} [config.userPreset] - HandBrake User-Preset (Video) * @param {object} [config.trackSelection] - Audio/Subtitle-Selektion (Video) * @param {number} [config.handBrakeTitleId] - HandBrake Titel-ID (Video) * @param {object} [config.audioFormatOptions] - Format-Optionen (Audio) * @returns {Promise} */ async startConverterJob(jobId, config = {}) { const normalizedJobId = Number(jobId); if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { const error = new Error('Ungültige Job-ID für Converter-Start.'); error.statusCode = 400; throw error; } const job = await historyService.getJobById(normalizedJobId); if (!job) { const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); error.statusCode = 404; throw error; } const existingPlan = this.safeParseJson(job.encode_plan_json) || {}; const converterMediaType = config.converterMediaType || existingPlan.converterMediaType || 'video'; const metadata = config.metadata || existingPlan.metadata || null; const tracks = config.tracks || existingPlan.tracks || null; const converterScanService = require('./converterScanService'); const settings = await settingsService.getSettingsMap(); const rawDir = String( settings?.converter_raw_dir || require('../config').defaultConverterRawDir || '' ).trim(); const movieDir = String( settings?.converter_movie_dir || require('../config').defaultConverterMovieDir || '' ).trim(); const audioDir = String( settings?.converter_audio_dir || require('../config').defaultConverterAudioDir || '' ).trim(); const inputPath = existingPlan.inputPath || job.raw_path; if (!inputPath || !fs.existsSync(inputPath)) { const error = new Error(`Eingabedatei nicht gefunden: ${inputPath}`); error.statusCode = 404; throw error; } // Ausgabepfad bestimmen let outputPath = null; let outputDir = null; let audioTrackTemplate = null; const allowedAudioFormats = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']); const allowedVideoFormats = new Set(['mkv', 'mp4', 'm4v']); const defaultOutputFormat = converterMediaType === 'audio' ? 'flac' : 'mkv'; let outputFormat = String(config.outputFormat || existingPlan.outputFormat || defaultOutputFormat).trim().toLowerCase(); if (converterMediaType === 'audio' && !allowedAudioFormats.has(outputFormat)) { outputFormat = 'flac'; } if ((converterMediaType === 'video' || converterMediaType === 'iso') && !allowedVideoFormats.has(outputFormat)) { outputFormat = 'mkv'; } const baseName = sanitizeFileName( path.basename(inputPath, path.extname(inputPath)) ) || 'output'; const title = job.title || job.detected_title || baseName; const safeTitle = sanitizeFileName(title) || baseName; // Metadaten-basierte Namen (Album-Titel und Interpret) const albumTitle = metadata?.albumTitle ? String(metadata.albumTitle).trim() : null; const albumArtist = metadata?.albumArtist ? String(metadata.albumArtist).trim() : null; const albumYear = Number.isFinite(Number(metadata?.albumYear)) ? Math.trunc(Number(metadata.albumYear)) : (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : null); const safeAlbumTitle = albumTitle ? (sanitizeFileName(albumTitle) || safeTitle) : safeTitle; const safeAlbumArtist = albumArtist ? (sanitizeFileName(albumArtist) || null) : null; const parseTemplateSegments = (template) => String(template || '') .replace(/\\/g, '/') .replace(/\/+/g, '/') .replace(/^\/+|\/+$/g, '') .split('/') .map((segment) => String(segment || '').trim()) .filter(Boolean); const normalizeAudioTemplate = (template) => { let value = String(template || '').trim(); if (!value) { return '{artist} - {title}'; } // Legacy-Recovery: alte fehlerhafte Werte konnten Placeholder-Klammern verlieren. // Auch gemischte Templates (teilweise mit {}, teilweise ohne) werden repariert. value = value.replace( /(^|[^A-Za-z0-9_$\{])(trackNr|trackNumber|artist|album|title|year)(?=$|[^A-Za-z0-9_\}])/g, (_full, prefix, key) => `${prefix}{${key}}` ); // Falls ein kombiniertes Album+Track-Template ohne "/" gespeichert wurde, // trennen wir vor der Tracknummer automatisch. if ( !value.includes('/') && /\{(?:trackNr|trackNumber)\}/i.test(value) && /\{(?:album|year)\}/i.test(value) ) { value = value.replace(/\)\s*(\{(?:trackNr|trackNumber)\})/i, ')/$1'); } return value; }; const renderTemplateSegment = (segment, values, fallback = 'unknown') => { const rendered = renderTemplate(segment, values); return sanitizeFileName(rendered) || fallback; }; const albumTemplateValues = { artist: safeAlbumArtist || safeAlbumTitle, album: safeAlbumTitle, title: safeAlbumTitle, year: albumYear ?? new Date().getFullYear(), trackNr: '01', trackNumber: '1' }; if (converterMediaType === 'video' || converterMediaType === 'iso') { const videoTemplateRaw = String( settings?.converter_output_template_video || '{title}' ).trim() || '{title}'; const videoSegmentsRaw = parseTemplateSegments(videoTemplateRaw); const videoSegments = (videoSegmentsRaw.length > 0 ? videoSegmentsRaw : ['{title}']) .map((segment) => renderTemplateSegment(segment, { title: safeTitle, year: Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : new Date().getFullYear() }, safeTitle)); const videoBaseName = videoSegments[videoSegments.length - 1] || safeTitle; const videoFolders = videoSegments.slice(0, -1); outputPath = path.join(movieDir, ...(videoFolders.length > 0 ? videoFolders : []), `${videoBaseName}.${outputFormat}`); } else if (converterMediaType === 'audio') { const isFolder = Boolean(existingPlan.isFolder || config.isFolder); const isSharedAudio = Boolean(existingPlan.isSharedAudio); const audioTemplateRaw = normalizeAudioTemplate(String( settings?.converter_output_template_audio || '{artist} - {title}' ).trim() || '{artist} - {title}'); const audioSegmentsRaw = parseTemplateSegments(audioTemplateRaw); if (isFolder || isSharedAudio) { const folderSegmentRaw = audioSegmentsRaw.length > 1 ? audioSegmentsRaw.slice(0, -1) : audioSegmentsRaw; const folderSegments = (folderSegmentRaw.length > 0 ? folderSegmentRaw : ['{artist} - {album}']) .map((segment) => renderTemplateSegment(segment, albumTemplateValues, safeAlbumTitle)); outputDir = path.join(audioDir, ...folderSegments); audioTrackTemplate = audioSegmentsRaw.length > 1 ? audioSegmentsRaw[audioSegmentsRaw.length - 1] : (existingPlan.audioTrackTemplate || '{trackNr} - {title}'); } else { const firstTrack = Array.isArray(tracks) && tracks.length > 0 ? tracks[0] : null; const singleValues = { ...albumTemplateValues, title: sanitizeFileName(String(firstTrack?.title || safeAlbumTitle).trim()) || safeAlbumTitle, artist: sanitizeFileName(String(firstTrack?.artist || safeAlbumArtist || safeAlbumTitle).trim()) || (safeAlbumArtist || safeAlbumTitle), trackNr: '01', trackNumber: '1' }; const renderedSegments = (audioSegmentsRaw.length > 0 ? audioSegmentsRaw : ['{artist} - {title}']) .map((segment) => renderTemplateSegment(segment, singleValues, safeAlbumTitle)); const singleBaseName = renderedSegments[renderedSegments.length - 1] || safeAlbumTitle; const singleFolders = renderedSegments.slice(0, -1); outputPath = path.join(audioDir, ...(singleFolders.length > 0 ? singleFolders : []), `${singleBaseName}.${outputFormat}`); } } // Audiodateien im Ordner laden let audioFiles = existingPlan.audioFiles; if (converterMediaType === 'audio' && existingPlan.isFolder && !audioFiles) { const { readdirSync } = fs; const AUDIO_EXTS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']); try { audioFiles = readdirSync(inputPath) .filter((f) => AUDIO_EXTS.has(path.extname(f).slice(1).toLowerCase())) .sort(); } catch (_err) { audioFiles = []; } } const normalizeStartEncodeItem = (item) => { if (!item || typeof item !== 'object') return null; const type = String(item.type || '').trim().toLowerCase(); if (type !== 'script' && type !== 'chain') return null; const id = Number(item.id); if (!Number.isFinite(id) || id <= 0) return null; return { type, id: Math.trunc(id) }; }; const preEncodeItems = Array.isArray(config.preEncodeItems) ? config.preEncodeItems.map(normalizeStartEncodeItem).filter(Boolean) : (Array.isArray(existingPlan.preEncodeItems) ? existingPlan.preEncodeItems : []); const postEncodeItems = Array.isArray(config.postEncodeItems) ? config.postEncodeItems.map(normalizeStartEncodeItem).filter(Boolean) : (Array.isArray(existingPlan.postEncodeItems) ? existingPlan.postEncodeItems : []); const preEncodeScriptIds = preEncodeItems.filter((i) => i.type === 'script').map((i) => i.id); const postEncodeScriptIds = postEncodeItems.filter((i) => i.type === 'script').map((i) => i.id); const preEncodeChainIds = preEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id); const postEncodeChainIds = postEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id); const nextEncodePlan = { ...existingPlan, mediaProfile: 'converter', jobKind: resolveConverterJobKind(converterMediaType), converterMediaType, inputPath, inputPaths: existingPlan.inputPaths || null, outputPath: outputPath || null, outputDir: outputDir || null, outputFormat, userPreset: config.userPreset || existingPlan.userPreset || null, trackSelection: config.trackSelection || existingPlan.trackSelection || null, handBrakeTitleId: config.handBrakeTitleId || existingPlan.handBrakeTitleId || null, audioFormatOptions: config.audioFormatOptions || existingPlan.audioFormatOptions || {}, audioTrackTemplate: audioTrackTemplate || existingPlan.audioTrackTemplate || null, audioFiles: audioFiles || existingPlan.audioFiles || null, metadata: metadata || null, tracks: tracks || null, preEncodeItems, postEncodeItems, preEncodeScriptIds, postEncodeScriptIds, preEncodeChainIds, postEncodeChainIds, reviewConfirmed: converterMediaType === 'audio' }; const isVideoLikeConverterJob = converterMediaType === 'video' || converterMediaType === 'iso'; const metadataTitle = String(metadata?.title || '').trim() || null; const metadataYear = Number.isFinite(Number(metadata?.year)) ? Math.trunc(Number(metadata.year)) : null; const metadataImdbId = String(metadata?.imdbId || '').trim() || null; const metadataPoster = String(metadata?.poster || '').trim() || null; const selectedFromOmdb = metadataImdbId ? 1 : 0; const jobPatch = { status: 'READY_TO_START', last_state: 'READY_TO_START', media_type: 'converter', job_kind: resolveConverterJobKind(converterMediaType), output_path: outputPath || outputDir || null, encode_plan_json: JSON.stringify(nextEncodePlan), encode_review_confirmed: converterMediaType === 'audio' ? 1 : 0, error_message: null, end_time: null }; if (isVideoLikeConverterJob) { if (metadataTitle) { jobPatch.title = metadataTitle; } if (metadataYear !== null) { jobPatch.year = metadataYear; } if (metadataImdbId) { jobPatch.imdb_id = metadataImdbId; } if (metadataPoster) { jobPatch.poster_url = metadataPoster; } jobPatch.selected_from_omdb = selectedFromOmdb; } await historyService.updateJob(normalizedJobId, jobPatch); await historyService.appendLog( normalizedJobId, 'USER_ACTION', `Converter konfiguriert: Typ ${converterMediaType} | Format ${outputFormat}` ); if (isVideoLikeConverterJob) { await historyService.appendLog( normalizedJobId, 'SYSTEM', 'Starte Mediainfo-Review mit Titel-/Spurauswahl (analog Rip-Workflow).' ); } const startResult = await this.startPreparedJob(normalizedJobId); return { jobId: normalizedJobId, ...(startResult && typeof startResult === 'object' ? startResult : {}) }; } /** * Gibt alle Audiobook-Jobs zurück (für die Audiobooks-Seite). */ async getAudiobookJobs() { const db = await require('../db/database').getDb(); const rows = await db.all(` SELECT id, title, detected_title, year, poster_url, status, last_state, media_type, job_kind, encode_plan_json, handbrake_info_json, makemkv_info_json, output_path, raw_path, error_message, start_time, end_time, created_at, updated_at FROM jobs WHERE media_type = 'audiobook' OR job_kind = 'audiobook' OR ( (media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other') AND ( encode_plan_json LIKE '%"mediaProfile":"audiobook"%' OR encode_plan_json LIKE '%"mode":"audiobook"%' ) ) ORDER BY created_at DESC LIMIT 200 `); return rows.map((row) => { const encodePlan = this.safeParseJson(row.encode_plan_json); const handbrakeInfo = this.safeParseJson(row.handbrake_info_json); const makemkvInfo = this.safeParseJson(row.makemkv_info_json); const rawMediaType = String(row?.media_type || '').trim().toLowerCase(); const planMode = String(encodePlan?.mode || '').trim().toLowerCase(); const planProfile = String(encodePlan?.mediaProfile || '').trim().toLowerCase(); const effectiveMediaType = rawMediaType === 'audiobook' ? 'audiobook' : ( planProfile === 'audiobook' || planMode === 'audiobook' ? 'audiobook' : (rawMediaType || null) ); return { ...row, media_type: effectiveMediaType, encodePlan, handbrakeInfo, makemkvInfo }; }); } /** * Gibt alle Converter-Jobs zurück (für die Converter-Seite). */ async getConverterJobs() { const db = await require('../db/database').getDb(); const rows = await db.all(` SELECT id, title, detected_title, year, imdb_id, poster_url, status, last_state, media_type, job_kind, encode_review_confirmed, encode_plan_json, output_path, raw_path, encode_input_path, error_message, start_time, end_time, created_at, updated_at FROM jobs WHERE media_type = 'converter' OR job_kind LIKE 'converter_%' OR ( (media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other') AND ( encode_plan_json LIKE '%"mediaProfile":"converter"%' OR encode_plan_json LIKE '%"converterMediaType"%' OR raw_path LIKE '%/converter/%' OR encode_input_path LIKE '%/converter/%' ) ) ORDER BY created_at DESC LIMIT 200 `); return rows.map((r) => { const encodePlan = this.safeParseJson(r.encode_plan_json); const rawMediaType = String(r?.media_type || '').trim().toLowerCase(); const converterHintFromPlan = ( String(encodePlan?.mediaProfile || '').trim().toLowerCase() === 'converter' || ['video', 'audio', 'iso'].includes(String(encodePlan?.converterMediaType || '').trim().toLowerCase()) ); const converterHintFromPath = ( hasConverterPathSegment(r?.raw_path) || hasConverterPathSegment(r?.encode_input_path) || hasConverterPathSegment(encodePlan?.inputPath) ); const effectiveMediaType = rawMediaType === 'converter' ? 'converter' : ( (converterHintFromPlan || converterHintFromPath) ? 'converter' : (rawMediaType || null) ); return { ...r, media_type: effectiveMediaType, encodePlan }; }); } } module.exports = new PipelineService();