const fs = require('fs'); const path = require('path'); const { EventEmitter } = require('events'); const { execFile } = require('child_process'); const { randomUUID } = require('crypto'); const { getDb } = require('../db/database'); const settingsService = require('./settingsService'); const historyService = require('./historyService'); const tmdbService = require('./tmdbService'); const dvdSeriesScanService = require('./dvdSeriesScanService'); 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, parseMkvmergeProgress } = require('../utils/progressParsers'); const { ensureDir, sanitizeFileName, sanitizeFileNameWithExtension, renderTemplate, findMediaFiles } = require('../utils/files'); const { buildMediainfoReview, refreshAudioTrackActionsForPlanTitles } = require('../utils/encodePlan'); const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis'); const { errorToMeta } = require('../utils/errorMeta'); const userPresetService = require('./userPresetService'); const userPresetDefaultsService = require('./userPresetDefaultsService'); const thumbnailService = require('./thumbnailService'); const activationBytesService = require('./activationBytesService'); const { toBoolean } = require('../utils/validators'); const { tempDir } = require('../config'); const RUNNING_STATES = new Set(['ANALYZING', 'METADATA_LOOKUP', '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_SERIES_EPISODE: 'START_SERIES_EPISODE', 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.START_SERIES_EPISODE]: 'Serien-Episode starten', [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 RAW_STATE_PREFIX_CLEANUP_PATTERN = /^(?:(?:incomplete|rip[_-]?complete|rip[_-]?incomplete)[_-])+/i; 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); const TMDB_SERIES_SEARCH_CACHE_TTL_MS = 2 * 60 * 1000; const tmdbSeriesSearchCache = new Map(); const PROCESS_LOG_RESET_TERMINAL_STATES = new Set(['FINISHED', 'ERROR', 'CANCELLED']); const PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS = 120; function nowIso() { return new Date().toISOString(); } function appendLinesInPlace(target, lines) { if (!Array.isArray(target) || !Array.isArray(lines) || lines.length === 0) { return target; } for (const line of lines) { target.push(line); } return target; } function normalizeLifecycleJobState(value) { return String(value || '').trim().toUpperCase(); } function shouldResetProcessLogForLifecycle(job = null) { const status = normalizeLifecycleJobState(job?.status); const lastState = normalizeLifecycleJobState(job?.last_state); return PROCESS_LOG_RESET_TERMINAL_STATES.has(status) || PROCESS_LOG_RESET_TERMINAL_STATES.has(lastState); } async function resetProcessLogIfLifecycleAllows(jobId, job = null) { if (!shouldResetProcessLogForLifecycle(job)) { return false; } await historyService.resetProcessLog(jobId); return true; } function cloneTmdbSeriesSearchResults(rows = []) { if (!Array.isArray(rows)) { return []; } return rows.map((row) => ({ ...(row && typeof row === 'object' ? row : {}), episodes: Array.isArray(row?.episodes) ? row.episodes.map((episode) => ( episode && typeof episode === 'object' ? { ...episode } : episode )) : [] })); } 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 normalizeDvdMetadataWorkflowKind(value) { const raw = String(value || '').trim().toLowerCase(); if (!raw) { return null; } if (['film', 'movie', 'feature'].includes(raw)) { return 'film'; } if (['series', 'tv', 'season', 'episode'].includes(raw)) { return 'series'; } return null; } function isSeriesDiscMediaProfile(mediaProfile) { const normalized = normalizeMediaProfile(mediaProfile); return normalized === 'dvd' || normalized === 'bluray'; } function normalizeSeriesDetectionClassification(value) { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'series' || normalized === 'film' || normalized === 'ambiguous') { return normalized; } return null; } function hasStrongSeriesLookupHint(seriesLookupHint = null) { const hint = seriesLookupHint && typeof seriesLookupHint === 'object' ? seriesLookupHint : {}; return Number(hint.seasonNumber || 0) > 0 || Number(hint.discNumber || 0) > 0; } function normalizePlayAllTolerancePercent(rawValue, fallback = 10) { const parsed = Number(rawValue); if (!Number.isFinite(parsed)) { return fallback; } if (parsed < 0) { return 0; } if (parsed > 100) { return 100; } return parsed; } function resolveSeriesDiscLabel(mediaProfile) { const normalized = normalizeMediaProfile(mediaProfile); if (normalized === 'bluray') { return 'Blu-ray'; } if (normalized === 'dvd') { return 'DVD'; } return 'Disc'; } function normalizeMovieFingerprintLabel(value) { return String(value || '') .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .trim() .replace(/\s+/g, ' '); } function buildMovieMetadataFingerprint(mediaProfile, metadata = {}) { const normalizedMediaProfile = normalizeMediaProfile(mediaProfile); if (normalizedMediaProfile !== 'dvd' && normalizedMediaProfile !== 'bluray') { return null; } const imdbId = String(metadata?.imdbId || '').trim().toLowerCase(); if (imdbId) { return `${normalizedMediaProfile}|imdb|${imdbId}`; } const title = normalizeMovieFingerprintLabel(metadata?.title || ''); const year = normalizePositiveInteger(metadata?.year); if (!title) { return null; } if (year) { return `${normalizedMediaProfile}|title_year|${title}|${year}`; } return `${normalizedMediaProfile}|title|${title}`; } function toMetadataSearchTitleCase(value) { const source = String(value || '').trim(); if (!source) { return ''; } return source .toLowerCase() .replace(/\b([a-z\u00c0-\u024f])([a-z\u00c0-\u024f0-9']*)/g, (_match, first, rest) => ( `${first.toUpperCase()}${rest}` )); } function pushUniqueMetadataSearchVariant(target, candidate) { const normalized = String(candidate || '').replace(/\s+/g, ' ').trim(); if (!normalized) { return; } if (!target.some((entry) => entry.toLowerCase() === normalized.toLowerCase())) { target.push(normalized); } } function stripTrailingDiscSearchHint(value) { let working = String(value || '').trim(); if (!working) { return ''; } const patterns = [ /\b(?:disc|disk|dvd|bd|br|cd|part|pt|vol|volume|season|staffel|episode|ep)\s*(?:\d{1,3}|[ivxlcdm]{1,8})\b$/i, /\b(?:[a-z]{1,3}\d{1,4}|\d{1,4}[a-z]{1,3})\b$/i ]; for (let i = 0; i < 4; i += 1) { const before = working; for (const pattern of patterns) { working = working.replace(pattern, '').trim(); } working = working.replace(/[-_.,;:]+$/g, '').trim(); if (working === before || !working) { break; } } return working; } function buildMetadataSearchQueryVariants(query) { const rawQuery = String(query || '').trim(); if (!rawQuery) { return []; } if (/^tt\d{6,12}$/i.test(rawQuery)) { return [rawQuery.toLowerCase()]; } const variants = []; const separatorNormalized = rawQuery .replace(/[_]+/g, ' ') .replace(/[.]+/g, ' ') .replace(/\s+/g, ' ') .trim(); const punctuationReduced = separatorNormalized .replace(/[|/\\]+/g, ' ') .replace(/\s+/g, ' ') .trim(); const stripped = stripTrailingDiscSearchHint(punctuationReduced); pushUniqueMetadataSearchVariant(variants, rawQuery); pushUniqueMetadataSearchVariant(variants, separatorNormalized); pushUniqueMetadataSearchVariant(variants, punctuationReduced); pushUniqueMetadataSearchVariant(variants, toMetadataSearchTitleCase(separatorNormalized)); pushUniqueMetadataSearchVariant(variants, stripped); pushUniqueMetadataSearchVariant(variants, toMetadataSearchTitleCase(stripped)); return variants.slice(0, 6); } function normalizeMetadataCandidateWorkflowKind(value) { return normalizeDvdMetadataWorkflowKind(value); } function normalizeMetadataCandidateMetadataKind(value) { const raw = String(value || '').trim().toLowerCase(); if (!raw) { return null; } if (['movie', 'film', 'feature'].includes(raw)) { return 'movie'; } if (['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) { return raw === 'tv' ? 'series' : raw; } return raw; } function normalizeMetadataCandidateType(row = null, fallbackType = null) { const source = row && typeof row === 'object' ? row : {}; const metadataKind = normalizeMetadataCandidateMetadataKind(source?.metadataKind); const workflowKind = normalizeMetadataCandidateWorkflowKind( source?.workflowKind || source?.kind || metadataKind || null ); if (workflowKind === 'series') { return 'series'; } if (workflowKind === 'film') { return 'movie'; } if (metadataKind === 'series' || metadataKind === 'season') { return 'series'; } if (metadataKind === 'movie') { return 'movie'; } const normalizedFallback = String(fallbackType || '').trim().toLowerCase(); if (normalizedFallback === 'series' || normalizedFallback === 'movie') { return normalizedFallback; } if (Number(source?.seasonNumber || 0) > 0) { return 'series'; } return 'movie'; } function normalizeMetadataCandidateRow(row = null, fallbackType = null) { const source = row && typeof row === 'object' ? row : {}; const resultType = normalizeMetadataCandidateType(source, fallbackType); const workflowKind = resultType === 'series' ? 'series' : 'film'; const metadataKind = normalizeMetadataCandidateMetadataKind(source?.metadataKind) || (resultType === 'series' ? 'series' : 'movie'); return { ...source, workflowKind, metadataKind, resultType }; } function deriveWorkflowKindFromMetadataCandidates(metadataCandidates = []) { const rows = Array.isArray(metadataCandidates) ? metadataCandidates : []; let hasSeries = false; let hasMovies = false; for (const row of rows) { const resultType = normalizeMetadataCandidateType(row, null); if (resultType === 'series') { hasSeries = true; } else { hasMovies = true; } if (hasSeries && hasMovies) { return null; } } if (hasSeries) { return 'series'; } if (hasMovies) { return 'film'; } return null; } function extractSelectedMetadataFromMakemkvInfo(makemkvInfo = null) { const mkInfo = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}; const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' ? mkInfo.analyzeContext : {}; const analyzeSelected = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' ? analyzeContext.selectedMetadata : {}; const topLevelSelected = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object' ? mkInfo.selectedMetadata : {}; return { ...topLevelSelected, ...analyzeSelected }; } function resolveDiscNumberFromJobLike(job = null) { const direct = normalizePositiveInteger(job?.disc_number); if (direct) { return direct; } let parsedMakemkvInfo = {}; if (typeof job?.makemkv_info_json === 'string') { try { parsedMakemkvInfo = JSON.parse(job.makemkv_info_json) || {}; } catch (_error) { parsedMakemkvInfo = {}; } } else if (job?.makemkv_info_json && typeof job.makemkv_info_json === 'object') { parsedMakemkvInfo = job.makemkv_info_json; } else if (job?.makemkvInfo && typeof job.makemkvInfo === 'object') { parsedMakemkvInfo = job.makemkvInfo; } const mkInfo = parsedMakemkvInfo; const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(mkInfo); return normalizePositiveInteger( selectedMetadata?.discNumber ?? mkInfo?.analyzeContext?.seriesLookupHint?.discNumber ?? null ); } function normalizeContextJobId(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function resolveSelectedMetadataForJob(job = null, analyzeContext = null, activeContext = null) { const jobId = normalizeContextJobId(job?.id ?? job?.jobId ?? null); const activeContextJobId = normalizeContextJobId( activeContext?.jobId ?? activeContext?.activeJobId ?? null ); const allowActiveContextMetadata = ( jobId === null ? true : (activeContextJobId !== null && activeContextJobId === jobId) ); const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' ? analyzeContext.selectedMetadata : {}; const activeSelectedMetadata = allowActiveContextMetadata && activeContext?.selectedMetadata && typeof activeContext.selectedMetadata === 'object' ? activeContext.selectedMetadata : {}; const merged = { ...analyzeSelectedMetadata, ...activeSelectedMetadata }; const workflowKind = normalizeDvdMetadataWorkflowKind( merged.workflowKind || analyzeContext?.workflowKind || (allowActiveContextMetadata ? activeContext?.workflowKind : null) || null ); return { ...merged, title: String(merged.title || job?.title || job?.detected_title || '').trim() || null, year: Number(merged.year ?? job?.year ?? 0) || null, imdbId: String(merged.imdbId || job?.imdb_id || '').trim() || null, poster: String(merged.poster || job?.poster_url || '').trim() || null, ...(workflowKind ? { workflowKind } : {}), metadataProvider: String( merged.metadataProvider || analyzeContext?.metadataProvider || (allowActiveContextMetadata ? activeContext?.metadataProvider : null) || 'tmdb' ).trim().toLowerCase() || 'tmdb' }; } function isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata = {}, analyzeContext = null) { if (!isSeriesDiscMediaProfile(mediaProfile)) { return false; } const metadata = selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}; const workflowKind = normalizeDvdMetadataWorkflowKind( metadata.workflowKind || analyzeContext?.workflowKind || null ); if (workflowKind === 'film') { return false; } if (workflowKind === 'series') { return true; } const provider = String( metadata.metadataProvider || analyzeContext?.metadataProvider || '' ).trim().toLowerCase(); const metadataKind = String(metadata.metadataKind || '').trim().toLowerCase(); if (['film', 'movie', 'feature'].includes(metadataKind)) { return false; } const seasonRaw = String(metadata.seasonNumber ?? '').trim(); const seasonNumber = Number(seasonRaw.replace(',', '.')); const hasSeasonNumber = seasonRaw ? (Number.isFinite(seasonNumber) ? seasonNumber > 0 : true) : false; const hasSeasonName = Boolean(String(metadata.seasonName || '').trim()); const episodeCount = Number(metadata.episodeCount || 0) || 0; const hasEpisodeList = Array.isArray(metadata.episodes) && metadata.episodes.length > 0; const hasSeriesLookupHint = hasStrongSeriesLookupHint(analyzeContext?.seriesLookupHint); const seriesDecisionClassification = normalizeSeriesDetectionClassification( analyzeContext?.seriesDecision?.classification ); const seriesLikeByAnalysis = ( seriesDecisionClassification === 'series' || ( !seriesDecisionClassification && Boolean(analyzeContext?.seriesAnalysis?.summary?.seriesLike) ) ); const providerIsTmdb = provider === 'tmdb' || provider === 'themoviedb'; const metadataSignalsSeries = ( ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind) || hasSeasonNumber || hasSeasonName || episodeCount > 0 || hasEpisodeList ); if (metadataSignalsSeries) { return true; } // Fallback: wenn die DVD-Serienanalyse bereits positiv war, behandeln wir den // Job weiterhin als Serie (z.B. bei manueller Eingabe ohne explizite Staffel). if (seriesLikeByAnalysis || hasSeriesLookupHint) { return true; } // Zusätzlicher Sicherheitsanker: TMDb + vorhandener Serien-Hint => Serie. return providerIsTmdb && (seriesLikeByAnalysis || hasSeriesLookupHint); } function resolveSeriesAwareRawStorage(settings = {}, mediaProfile = null, selectedMetadata = {}, analyzeContext = null) { const fallbackRawDir = String( settings?.raw_dir || settingsService.DEFAULT_RAW_DIR || '' ).trim(); const fallbackRawOwner = String(settings?.raw_dir_owner || '').trim(); const normalizedMediaProfile = normalizeMediaProfile(mediaProfile); const normalizedSeriesMediaProfile = normalizeMediaProfile(mediaProfile); const seriesRawCandidate = String( settings?.series_raw_dir || (normalizedSeriesMediaProfile === 'bluray' ? settings?.raw_dir_bluray_series : null) || (normalizedSeriesMediaProfile === 'dvd' ? settings?.raw_dir_dvd_series : null) || settings?.raw_dir_bluray_series || settings?.raw_dir_dvd_series || '' ).trim(); const seriesRawOwnerCandidate = String( settings?.series_raw_dir_owner || (normalizedSeriesMediaProfile === 'bluray' ? settings?.raw_dir_bluray_series_owner : null) || (normalizedSeriesMediaProfile === 'dvd' ? settings?.raw_dir_dvd_series_owner : null) || settings?.raw_dir_bluray_series_owner || settings?.raw_dir_dvd_series_owner || '' ).trim(); const seriesDetected = isSeriesDvdMetadataSelection(normalizedMediaProfile, selectedMetadata, analyzeContext); if (!seriesDetected) { return { rawBaseDir: fallbackRawDir, rawOwner: fallbackRawOwner, usingSeriesRawPath: false }; } const effectiveSeriesRawDir = seriesRawCandidate || fallbackRawDir; const effectiveSeriesRawOwner = seriesRawOwnerCandidate || fallbackRawOwner; return { rawBaseDir: effectiveSeriesRawDir, rawOwner: effectiveSeriesRawOwner, usingSeriesRawPath: Boolean(effectiveSeriesRawDir && fallbackRawDir && effectiveSeriesRawDir !== fallbackRawDir) }; } 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' || raw === 'dvd_series_container' || raw === 'dvd_series_child' || raw === 'multipart_movie_container' || raw === 'multipart_movie_child' || raw === 'multipart_movie_merge' ) { return raw; } if (raw === 'converter_audio' || raw === 'converter_video' || raw === 'converter_iso') { return raw; } return null; } function 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})'; const DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE = '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'; const DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'; function parseJsonObjectSafe(raw) { if (!raw) { return {}; } if (typeof raw === 'object' && !Array.isArray(raw)) { return raw; } try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed; } } catch (_error) { // ignore parse errors for path rendering fallback } return {}; } function normalizeTemplateNumberValue(rawValue, fallbackValue = 1) { const fallback = Number.isFinite(Number(fallbackValue)) && Number(fallbackValue) > 0 ? Number(fallbackValue) : 1; const normalizedEpisode = normalizeEpisodeNumberValue(rawValue); if (normalizedEpisode !== null) { return normalizedEpisode; } return fallback; } function formatTemplateTwoDigit(rawValue) { const numeric = Number(rawValue); if (!Number.isFinite(numeric)) { return String(rawValue || '').trim() || '00'; } if (Number.isInteger(numeric)) { return String(Math.trunc(numeric)).padStart(2, '0'); } return String(rawValue || '').trim() || String(numeric); } function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitle = null, fallbackEpisodeNumber = 1, options = {}) { const assignmentSource = assignment && typeof assignment === 'object' ? assignment : {}; const selectedTitleSource = selectedTitle && typeof selectedTitle === 'object' ? selectedTitle : {}; const allowSelectedTitleEpisodeNumber = options?.allowSelectedTitleEpisodeNumber !== false; const episodeStart = normalizeTemplateNumberValue( assignmentSource?.episodeNumberStart ?? assignmentSource?.episodeNoStart ?? assignmentSource?.episodeNumber ?? assignmentSource?.number ?? (allowSelectedTitleEpisodeNumber ? selectedTitleSource?.episodeNumber : null) ?? null, fallbackEpisodeNumber ); const explicitEpisodeEnd = normalizeEpisodeNumberValue( assignmentSource?.episodeNumberEnd ?? assignmentSource?.episodeNoEnd ?? null ); const explicitSpan = normalizePositiveInteger( assignmentSource?.episodeSpan ?? assignmentSource?.episodeCount ?? selectedTitleSource?.episodeSpan ?? null ); let episodeEnd = explicitEpisodeEnd; if (episodeEnd === null && explicitSpan && explicitSpan > 1) { episodeEnd = episodeStart + explicitSpan - 1; } if (episodeEnd !== null && episodeEnd < episodeStart) { episodeEnd = episodeStart; } const normalizedEpisodeEnd = episodeEnd === null ? episodeStart : episodeEnd; return { start: episodeStart, end: normalizedEpisodeEnd, isMulti: normalizedEpisodeEnd > episodeStart }; } function hasExplicitSeriesEpisodeAssignmentRange(assignment = null) { const source = assignment && typeof assignment === 'object' ? assignment : null; if (!source) { return false; } const hasStart = normalizeEpisodeNumberValue( source?.episodeNumberStart ?? source?.episodeNoStart ?? source?.episodeNumber ?? source?.number ?? null ) !== null; const hasEnd = normalizeEpisodeNumberValue( source?.episodeNumberEnd ?? source?.episodeNoEnd ?? null ) !== null; const hasSpan = normalizePositiveInteger( source?.episodeSpan ?? source?.episodeCount ?? null ) !== null; const hasRangeToken = String(source?.episodeRange || '').trim().length > 0; return Boolean(hasStart || hasEnd || hasSpan || hasRangeToken); } function resolveSeriesSelectedTitleIdOrder(encodePlan = null, titles = []) { const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; const explicitSelected = normalizeReviewTitleIdList( Array.isArray(plan?.selectedTitleIds) ? plan.selectedTitleIds : [plan?.encodeInputTitleId] ); if (explicitSelected.length > 0) { return explicitSelected; } return normalizeReviewTitleIdList( (Array.isArray(titles) ? titles : []) .filter((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) .map((title) => title?.id) ); } function resolveSeriesEpisodeRangeForPlanTitle(encodePlan = null, titleId = null, fallbackEpisodeNumber = 1) { const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; const titles = Array.isArray(plan?.titles) ? plan.titles : []; const titlesById = new Map(); for (const title of titles) { const normalizedId = normalizeReviewTitleId(title?.id); if (!normalizedId) { continue; } titlesById.set(Number(normalizedId), title); } const normalizedTitleId = normalizeReviewTitleId(titleId) || normalizeReviewTitleId(plan?.encodeInputTitleId) || null; let orderedTitleIds = resolveSeriesSelectedTitleIdOrder(plan, titles); if (normalizedTitleId && !orderedTitleIds.some((id) => Number(id) === Number(normalizedTitleId))) { orderedTitleIds = [...orderedTitleIds, Number(normalizedTitleId)]; } if (orderedTitleIds.length === 0 && normalizedTitleId) { orderedTitleIds = [Number(normalizedTitleId)]; } const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' ? plan.episodeAssignments : {}; const fallbackStart = normalizePositiveInteger(fallbackEpisodeNumber) || 1; let cursor = fallbackStart; let targetRange = null; let targetAssignment = null; let targetTitle = null; for (const rawId of orderedTitleIds) { const currentId = normalizeReviewTitleId(rawId); if (!currentId) { continue; } const assignment = assignmentMap[String(currentId)] || assignmentMap[currentId] || null; const selectedTitle = titlesById.get(Number(currentId)) || null; const hasExplicitRange = hasExplicitSeriesEpisodeAssignmentRange(assignment); const range = resolveSeriesEpisodeRangeFromAssignment( assignment, selectedTitle, cursor, { allowSelectedTitleEpisodeNumber: hasExplicitRange } ); const normalizedEnd = normalizeEpisodeNumberValue(range?.end); if (normalizedEnd !== null) { cursor = normalizedEnd + 1; } if (normalizedTitleId && Number(currentId) === Number(normalizedTitleId)) { targetRange = range; targetAssignment = assignment; targetTitle = selectedTitle; break; } } if (!targetRange) { const fallbackTitle = normalizedTitleId ? (titlesById.get(Number(normalizedTitleId)) || null) : null; const fallbackAssignment = normalizedTitleId ? (assignmentMap[String(normalizedTitleId)] || assignmentMap[normalizedTitleId] || null) : null; const hasExplicitRange = hasExplicitSeriesEpisodeAssignmentRange(fallbackAssignment); targetRange = resolveSeriesEpisodeRangeFromAssignment( fallbackAssignment, fallbackTitle, cursor, { allowSelectedTitleEpisodeNumber: hasExplicitRange } ); targetAssignment = fallbackAssignment; targetTitle = fallbackTitle; } return { range: targetRange, assignment: targetAssignment, title: targetTitle }; } function templateUsesLeadingZeroForToken(template, token) { const source = String(template || '').trim(); const normalizedToken = String(token || '').trim(); if (!source || !normalizedToken) { return false; } const pattern = new RegExp(`\\{\\s*0\\s*:\\s*${normalizedToken}\\s*\\}`, 'i'); return pattern.test(source); } function formatSeriesEpisodeNumberByTemplate(rawValue, template, token = 'episodeNr') { const numeric = normalizeEpisodeNumberValue(rawValue); if (numeric === null) { return String(rawValue || '').trim() || '0'; } if (Number.isInteger(numeric)) { const normalized = String(Math.trunc(numeric)); if (templateUsesLeadingZeroForToken(template, token)) { return normalized.padStart(2, '0'); } return normalized; } return String(numeric); } function buildSeriesFallbackEpisodeTitle(episodeRangeInfo = null, template = '') { const start = normalizeEpisodeNumberValue(episodeRangeInfo?.start) ?? 1; const end = normalizeEpisodeNumberValue(episodeRangeInfo?.end) ?? start; if (end > start) { const startToken = formatSeriesEpisodeNumberByTemplate(start, template, 'episodeNr'); const endToken = formatSeriesEpisodeNumberByTemplate(end, template, 'episodeNr'); return `Folge ${startToken}-${endToken}`; } const token = formatSeriesEpisodeNumberByTemplate(start, template, 'episodeNr'); return `Folge ${token}`; } function buildSeriesEpisodeRangeToken(start, end) { const startToken = formatTemplateTwoDigit(start); const endToken = formatTemplateTwoDigit(end); if (Number(end) > Number(start)) { return `${startToken}-${endToken}`; } return startToken; } function buildSeriesEpisodePartsToken(episodeRangeInfo = null) { const info = episodeRangeInfo && typeof episodeRangeInfo === 'object' ? episodeRangeInfo : { start: 1, end: 1 }; const start = normalizePositiveInteger(info.start) || 1; const end = normalizePositiveInteger(info.end) || start; const span = Math.max(1, end - start + 1); if (span <= 1) { return '1'; } if (span === 2) { return '1+2'; } return `1-${span}`; } function normalizeSeriesEpisodeTitleTokenForCompare(value) { return String(value || '') .toLowerCase() .normalize('NFD') .replace(/\p{M}+/gu, '') .replace(/[^a-z0-9]+/g, '') .trim(); } function findCommonSeriesEpisodeTitlePrefix(values = []) { const rows = (Array.isArray(values) ? values : []) .map((value) => String(value || '').trim()) .filter(Boolean); if (rows.length === 0) { return ''; } if (rows.length === 1) { return rows[0]; } const tokenRows = rows.map((value) => value.split(/\s+/).filter(Boolean)); const firstTokens = tokenRows[0]; if (firstTokens.length === 0) { return ''; } let prefixLength = 0; for (let index = 0; index < firstTokens.length; index += 1) { const tokenKey = normalizeSeriesEpisodeTitleTokenForCompare(firstTokens[index]); if (!tokenKey) { break; } const matchesAll = tokenRows.slice(1).every((tokens) => { const current = tokens[index]; return normalizeSeriesEpisodeTitleTokenForCompare(current) === tokenKey; }); if (!matchesAll) { break; } prefixLength += 1; } if (prefixLength <= 0) { return ''; } return firstTokens .slice(0, prefixLength) .join(' ') .replace(/[-–—:.,\s]+$/g, '') .trim(); } function stripSeriesEpisodePartSuffix(value) { const source = String(value || '').replace(/\s+/g, ' ').trim(); if (!source) { return ''; } // Output cleanup for multi-episode labels: // remove classic "Teil/Part" markers and pure numeric/roman suffixes in // parentheses like "(1)" / "(2)" without affecting assignment logic. const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)|\(\s*(?:\d{1,2}|[ivxlcdm]{1,6})\s*\))\s*$/i; let normalized = source; let changed = false; for (let i = 0; i < 2; i += 1) { const next = normalized.replace(suffixPattern, '').replace(/[-–—:.,\s]+$/g, '').trim(); if (!next || next === normalized) { break; } normalized = next; changed = true; } if (!changed) { return source; } return normalized || source; } function normalizeSeriesEpisodeTitleForOutput(value, episodeRangeInfo = null) { const source = String(value || '').replace(/\s+/g, ' ').trim(); if (!source) { return ''; } if (!Boolean(episodeRangeInfo?.isMulti)) { return source; } const stripped = stripSeriesEpisodePartSuffix(source) || source; const rawSegments = source .split(/\s+(?:\+|\/|\||&)\s+/) .map((part) => String(part || '').trim()) .filter(Boolean); if (rawSegments.length < 2) { return stripped; } const cleanedSegments = rawSegments .map((part) => stripSeriesEpisodePartSuffix(part) || part) .map((part) => String(part || '').replace(/[-–—:.,\s]+$/g, '').trim()) .filter(Boolean); if (cleanedSegments.length === 0) { return stripped; } const uniqueSegments = []; const seen = new Set(); for (const segment of cleanedSegments) { const key = normalizeSeriesEpisodeTitleTokenForCompare(segment); if (!key || seen.has(key)) { continue; } seen.add(key); uniqueSegments.push(segment); } if (uniqueSegments.length === 1) { return uniqueSegments[0]; } const allSegmentsLookLikePartTitles = rawSegments.every((segment) => /\b(?:teil|part|pt)\b/i.test(segment)); if (allSegmentsLookLikePartTitles) { const commonPrefix = findCommonSeriesEpisodeTitlePrefix(cleanedSegments); if (commonPrefix && commonPrefix.length >= 6) { return commonPrefix; } return uniqueSegments[0] || cleanedSegments[0] || stripped; } return stripped; } function normalizeSeriesTitleForOutputPath(value, fallback = 'series') { const fallbackTitle = normalizeCdTrackText(fallback) || 'series'; const raw = normalizeCdTrackText(value); if (!raw) { return fallbackTitle; } const stripped = String(raw) .replace(/\s*-\s*S\d{1,2}E\d{1,3}(?:-\d{1,3})?\b.*$/i, '') .trim(); return stripped || raw || fallbackTitle; } function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) { const encodePlan = parseJsonObjectSafe(job?.encode_plan_json); const makemkvInfo = parseJsonObjectSafe(job?.makemkv_info_json); const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object' ? makemkvInfo.analyzeContext : {}; const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, null); const mediaProfile = normalizeMediaProfile( job?.media_type || encodePlan?.mediaProfile || analyzeContext?.mediaProfile || null ); const selectedTitleIds = normalizeReviewTitleIdList( Array.isArray(encodePlan?.selectedTitleIds) ? encodePlan.selectedTitleIds : [encodePlan?.encodeInputTitleId] ); const primaryTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId) || selectedTitleIds[0] || null; const seriesByPlan = Boolean( encodePlan?.seriesBatchParent || encodePlan?.seriesBatchChild || encodePlan?.seriesBatchParentJobId ); const isSeries = isSeriesDiscMediaProfile(mediaProfile) && ( seriesByPlan || isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext) ); if (!isSeries) { return null; } const titles = Array.isArray(encodePlan?.titles) ? encodePlan.titles : []; const selectedTitle = primaryTitleId ? (titles.find((title) => Number(title?.id) === Number(primaryTitleId)) || null) : (titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) || null); const episodeAssignments = encodePlan?.episodeAssignments && typeof encodePlan.episodeAssignments === 'object' ? encodePlan.episodeAssignments : {}; const assignment = primaryTitleId ? (episodeAssignments[String(primaryTitleId)] || episodeAssignments[primaryTitleId] || null) : null; const selectedTitlePosition = primaryTitleId ? selectedTitleIds.findIndex((id) => Number(id) === Number(primaryTitleId)) : -1; const fallbackEpisodeNumber = normalizePositiveInteger( encodePlan?.seriesBatchChildIndex ?? null ) || (selectedTitlePosition >= 0 ? selectedTitlePosition + 1 : 1); const episodeResolution = resolveSeriesEpisodeRangeForPlanTitle( encodePlan, primaryTitleId, fallbackEpisodeNumber ); const resolvedAssignment = episodeResolution?.assignment || assignment || null; const resolvedTitle = episodeResolution?.title || selectedTitle || null; const episodeRangeInfo = ( episodeResolution?.range && typeof episodeResolution.range === 'object' ) ? episodeResolution.range : resolveSeriesEpisodeRangeFromAssignment( resolvedAssignment, resolvedTitle, fallbackEpisodeNumber, { allowSelectedTitleEpisodeNumber: hasExplicitSeriesEpisodeAssignmentRange(resolvedAssignment) } ); const seasonNumber = normalizePositiveInteger( resolvedAssignment?.seasonNumber ?? resolvedTitle?.seasonNumber ?? selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null ) || 1; const episodeTemplateValue = episodeRangeInfo.start; const episodeRangeToken = buildSeriesEpisodeRangeToken( episodeRangeInfo.start, episodeRangeInfo.end ); const episodePartsToken = buildSeriesEpisodePartsToken(episodeRangeInfo); const discNumber = normalizePositiveInteger( selectedMetadata?.discNumber ?? resolvedAssignment?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null ); const seriesTitle = normalizeSeriesTitleForOutputPath( selectedMetadata?.seriesTitle || selectedMetadata?.title || job?.detected_title || job?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'series'), fallbackJobId ? `job-${fallbackJobId}` : 'series' ); const singleTemplateRaw = String( (mediaProfile === 'bluray' ? settings?.output_template_bluray_series_episode : settings?.output_template_dvd_series_episode) || settings?.output_template_dvd_series_episode || settings?.output_template_bluray_series_episode || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE ).trim(); const multiTemplateRaw = String( (mediaProfile === 'bluray' ? settings?.output_template_bluray_series_multi_episode : settings?.output_template_dvd_series_multi_episode) || settings?.output_template_dvd_series_multi_episode || settings?.output_template_bluray_series_multi_episode || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE ).trim(); const singleTemplate = singleTemplateRaw || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE; const multiTemplate = multiTemplateRaw || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE; const template = episodeRangeInfo.isMulti ? multiTemplate : singleTemplate; const fallbackEpisodeTitle = buildSeriesFallbackEpisodeTitle(episodeRangeInfo, template); const rawEpisodeTitle = normalizeCdTrackText( resolvedAssignment?.episodeTitle || resolvedTitle?.episodeTitle || fallbackEpisodeTitle ) || fallbackEpisodeTitle; const episodeTitle = normalizeSeriesEpisodeTitleForOutput( rawEpisodeTitle, episodeRangeInfo ) || fallbackEpisodeTitle; const language = String( settings?.dvd_series_language || selectedMetadata?.language || 'unknown' ).trim() || 'unknown'; const year = Number(selectedMetadata?.year || job?.year || new Date().getFullYear()) || new Date().getFullYear(); const rendered = renderTemplate(template, { seriesTitle, seasonNr: seasonNumber, episodeNr: episodeTemplateValue, episodeNoStart: episodeRangeInfo.start, episodeNoEnd: episodeRangeInfo.end, episodeNumberStart: episodeRangeInfo.start, episodeNumberEnd: episodeRangeInfo.end, episodeSpan: Math.max(1, Number(episodeRangeInfo.end || 0) - Number(episodeRangeInfo.start || 0) + 1), episodeRange: episodeRangeToken, parts: episodePartsToken, episodeTitle, discNr: discNumber || '', year, language }); const segments = rendered .replace(/\\/g, '/') .replace(/\/+/g, '/') .replace(/^\/+|\/+$/g, '') .split('/') .map((seg) => sanitizeFileName(seg)) .filter(Boolean); const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled'; const folderParts = segments.slice(0, -1); const folderPath = folderParts.length > 0 ? path.join(...folderParts) : ''; const rootDir = String( settings?.series_dir || settings?.movie_dir || settingsService.DEFAULT_MOVIE_DIR || '' ).trim(); const rootOwner = String( settings?.series_dir_owner || settings?.movie_dir_owner || '' ).trim(); const ext = String(settings?.output_extension || 'mkv').trim() || 'mkv'; return { rootDir, rootOwner, folderPath, baseName, ext }; } 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 appendSuffixToBaseName(baseName, suffix) { const rawBase = String(baseName || '').trim() || 'untitled'; const rawSuffix = String(suffix || '').trim(); if (!rawSuffix) { return rawBase; } return `${rawBase}${rawSuffix}`; } function withPathBaseNameSuffix(targetPath, suffix) { const normalizedPath = String(targetPath || '').trim(); const normalizedSuffix = String(suffix || '').trim(); if (!normalizedPath || !normalizedSuffix) { return normalizedPath; } const parsed = path.parse(normalizedPath); const nextBase = appendSuffixToBaseName(parsed.name || 'untitled', normalizedSuffix); return path.join(parsed.dir || '', `${nextBase}${parsed.ext || ''}`); } function buildMultipartDiscOutputSuffix(job = null) { const encodePlan = parseJsonObjectSafe(job?.encode_plan_json); const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); const encodePlanJobKind = String(encodePlan?.jobKind || '').trim().toLowerCase(); if (jobKind === 'multipart_movie_merge' || encodePlanJobKind === 'multipart_movie_merge') { return ''; } const hasMultipartFlag = Number(job?.is_multipart_movie || 0) === 1 || Number(encodePlan?.isMultipartMovie || 0) === 1; const isMultipartChild = jobKind === 'multipart_movie_child' || encodePlanJobKind === 'multipart_movie_child' || ( hasMultipartFlag && ( normalizePositiveInteger(job?.parent_job_id) || normalizePositiveInteger(encodePlan?.containerJobId) || normalizePositiveInteger(job?.disc_number) ) ); if (!isMultipartChild) { return ''; } const discNumber = resolveDiscNumberFromJobLike(job); if (!discNumber) { return ''; } return `_Disc${discNumber}`; } function parseDecimalSecondsToNanoseconds(value) { const raw = String(value ?? '').trim(); if (!raw) { return null; } const normalized = raw.replace(',', '.'); if (/^-?\d+(?:\.\d+)?$/.test(normalized)) { const sign = normalized.startsWith('-') ? -1 : 1; const unsigned = sign < 0 ? normalized.slice(1) : normalized; const parts = unsigned.split('.'); const secondsPart = Number.parseInt(parts[0] || '0', 10); if (!Number.isFinite(secondsPart)) { return null; } const fractionRaw = parts[1] || ''; const fractionPadded = `${fractionRaw}000000000`.slice(0, 9); const fractionPart = Number.parseInt(fractionPadded, 10); if (!Number.isFinite(fractionPart)) { return null; } return sign * ((secondsPart * 1_000_000_000) + fractionPart); } if (/^\d+:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) { const [hourPart, minutePart, secondPart] = normalized.split(':'); const secondsNs = parseDecimalSecondsToNanoseconds(secondPart); if (secondsNs == null) { return null; } const hours = Number.parseInt(hourPart, 10); const minutes = Number.parseInt(minutePart, 10); if (!Number.isFinite(hours) || !Number.isFinite(minutes)) { return null; } return ((hours * 3600) + (minutes * 60)) * 1_000_000_000 + secondsNs; } return null; } function formatNanosecondsAsClockTimestamp(value) { const numeric = Number(value); if (!Number.isFinite(numeric)) { return '00:00:00'; } const totalSeconds = Math.max(0, Math.floor(numeric / 1_000_000_000)); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } function formatNanosecondsAsMkvSimpleChapterTimestamp(value) { const numeric = Number(value); if (!Number.isFinite(numeric)) { return '00:00:00.000000000'; } const totalNanoseconds = Math.max(0, Math.trunc(numeric)); const hours = Math.floor(totalNanoseconds / 3_600_000_000_000); const remainingAfterHours = totalNanoseconds - (hours * 3_600_000_000_000); const minutes = Math.floor(remainingAfterHours / 60_000_000_000); const remainingAfterMinutes = remainingAfterHours - (minutes * 60_000_000_000); const seconds = Math.floor(remainingAfterMinutes / 1_000_000_000); const nanos = remainingAfterMinutes - (seconds * 1_000_000_000); return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(nanos).padStart(9, '0')}`; } function normalizeMultipartChapterName(value) { return String(value || '') .replace(/\s+/g, ' ') .trim(); } function buildMultipartMergedChapterPlan(sourceChapterGroups = []) { const mergedEntries = []; let offsetNanoseconds = 0; for (const group of sourceChapterGroups) { if (!group || typeof group !== 'object') { continue; } const discLabel = normalizePositiveInteger(group.discNumber) ? `Disc ${normalizePositiveInteger(group.discNumber)}` : 'Disc ?'; const chapters = Array.isArray(group.chapters) ? group.chapters : []; const normalizedChapters = chapters .map((chapter, chapterIndex) => { const directStartNanoseconds = Number(chapter?.startNs); const startNanoseconds = Number.isFinite(directStartNanoseconds) && directStartNanoseconds >= 0 ? Math.trunc(directStartNanoseconds) : parseDecimalSecondsToNanoseconds(chapter?.start_time); if (startNanoseconds == null || startNanoseconds < 0) { return null; } const existingTitle = normalizeMultipartChapterName( chapter?.title || chapter?.name || chapter?.tags?.title || '' ); const fallbackTitle = `Chapter ${String(chapterIndex + 1).padStart(2, '0')}`; return { startNanoseconds, title: existingTitle || fallbackTitle }; }) .filter(Boolean) .sort((left, right) => left.startNanoseconds - right.startNanoseconds); if (normalizedChapters.length > 0) { for (const chapter of normalizedChapters) { mergedEntries.push({ startNanoseconds: offsetNanoseconds + chapter.startNanoseconds, discNumber: normalizePositiveInteger(group.discNumber) || null, title: `${discLabel}: ${chapter.title}` }); } } else { mergedEntries.push({ startNanoseconds: offsetNanoseconds, discNumber: normalizePositiveInteger(group.discNumber) || null, title: `${discLabel}` }); } const parsedDurationNanoseconds = parseDecimalSecondsToNanoseconds(group.duration ?? group.durationSeconds ?? null); if (parsedDurationNanoseconds != null && parsedDurationNanoseconds > 0) { offsetNanoseconds += parsedDurationNanoseconds; } else if (normalizedChapters.length > 0) { const highestStart = normalizedChapters[normalizedChapters.length - 1]?.startNanoseconds || 0; offsetNanoseconds += highestStart + 1_000_000_000; } } if (mergedEntries.length === 0) { return null; } const entries = mergedEntries.map((entry, index) => ({ index: index + 1, discNumber: entry.discNumber || null, startNanoseconds: Math.max(0, Math.trunc(Number(entry.startNanoseconds) || 0)), start: formatNanosecondsAsMkvSimpleChapterTimestamp(entry.startNanoseconds), startClock: formatNanosecondsAsClockTimestamp(entry.startNanoseconds), title: normalizeMultipartChapterName(entry.title) || `Chapter ${String(index + 1).padStart(2, '0')}` })); const lines = []; for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]; const chapterNumber = String(index + 1).padStart(2, '0'); lines.push(`CHAPTER${chapterNumber}=${entry.start}`); lines.push(`CHAPTER${chapterNumber}NAME=${entry.title}`); } return { entries, content: lines.join('\n') }; } function buildMultipartMergedSimpleChapterFileContent(sourceChapterGroups = []) { const plan = buildMultipartMergedChapterPlan(sourceChapterGroups); return plan?.content || null; } function normalizeMergeTrackLanguage(value) { const normalized = String(value || '').trim().toLowerCase(); if (!normalized || normalized === 'und') { return 'und'; } return normalized; } function normalizeMergeTrackTitle(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } function buildComparableMergeTrackSignature(track = null) { const source = track && typeof track === 'object' ? track : {}; const type = String(source.type || '').trim().toLowerCase(); const language = normalizeMergeTrackLanguage(source.language); const codec = String(source.codec || '').trim().toLowerCase() || '?'; const channels = Number(source.channels); const channelsLabel = Number.isFinite(channels) && channels > 0 ? String(Math.trunc(channels)) : '-'; return `${type}|${codec}|${language}|${channelsLabel}`; } function extractMergeTracksFromFfprobeJson(probeJson = null) { const streams = Array.isArray(probeJson?.streams) ? probeJson.streams : []; const tracks = []; streams.forEach((stream, streamIndex) => { const type = String(stream?.codec_type || '').trim().toLowerCase(); if (type !== 'audio' && type !== 'subtitle') { return; } const channelsRaw = Number(stream?.channels); const channels = Number.isFinite(channelsRaw) && channelsRaw > 0 ? Math.trunc(channelsRaw) : null; const indexRaw = Number(stream?.index); tracks.push({ type, streamIndex: Number.isFinite(indexRaw) ? Math.trunc(indexRaw) : streamIndex, codec: String(stream?.codec_name || stream?.codec_tag_string || '').trim() || '?', language: normalizeMergeTrackLanguage(stream?.tags?.language), title: normalizeMergeTrackTitle(stream?.tags?.title), channels, channelLayout: String(stream?.channel_layout || '').trim() || null, default: Number(stream?.disposition?.default || 0) === 1, forced: Number(stream?.disposition?.forced || 0) === 1 }); }); const audioTracks = tracks.filter((track) => track.type === 'audio'); const subtitleTracks = tracks.filter((track) => track.type === 'subtitle'); return { tracks, audioTracks, subtitleTracks, layoutSignature: tracks.map((track) => buildComparableMergeTrackSignature(track)) }; } function buildMultipartMergeStreamSummary(sourceAnalyses = []) { const analyses = Array.isArray(sourceAnalyses) ? sourceAnalyses.filter((entry) => entry && typeof entry === 'object') : []; const comparable = analyses .filter((entry) => entry.probeJson && !entry.error) .map((entry) => ({ ...entry, ...extractMergeTracksFromFfprobeJson(entry.probeJson) })); if (comparable.length === 0) { return { available: false, sourceCount: analyses.length, comparableSourceCount: 0, allSourcesAligned: false, audioTracks: [], subtitleTracks: [], mismatches: [] }; } const reference = comparable[0]; const mismatchEntries = []; for (const entry of comparable.slice(1)) { const sameLength = reference.layoutSignature.length === entry.layoutSignature.length; const sameSignature = sameLength && reference.layoutSignature.every((signature, index) => signature === entry.layoutSignature[index]); if (!sameSignature) { mismatchEntries.push({ sourceJobId: entry.sourceJobId || null, discNumber: entry.discNumber || null, expectedSignature: reference.layoutSignature, actualSignature: entry.layoutSignature }); } } return { available: true, sourceCount: analyses.length, comparableSourceCount: comparable.length, allSourcesAligned: mismatchEntries.length === 0 && comparable.length === analyses.length, audioTracks: reference.audioTracks.map((track, index) => ({ ...track, order: index + 1 })), subtitleTracks: reference.subtitleTracks.map((track, index) => ({ ...track, order: index + 1 })), mismatches: mismatchEntries }; } function quoteShellArgument(value) { const raw = String(value ?? ''); if (raw.length === 0) { return "''"; } if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(raw)) { return raw; } return `'${raw.replace(/'/g, `'\"'\"'`)}'`; } function buildShellCommandPreview(cmd, args = []) { const command = quoteShellArgument(cmd); const argv = Array.isArray(args) ? args : []; if (argv.length === 0) { return command; } return `${command} ${argv.map((arg) => quoteShellArgument(arg)).join(' ')}`; } function normalizeMultipartMergeSourceItemsFromPlan(plan = null) { const sourceItems = Array.isArray(plan?.sourceItems) ? plan.sourceItems : []; const normalizedItems = sourceItems .map((item) => { if (!item || typeof item !== 'object') { return null; } const jobId = normalizePositiveInteger(item?.jobId || item?.sourceJobId || null); const discNumber = normalizePositiveInteger(item?.discNumber || null); const outputPath = String(item?.outputPath || item?.path || '').trim(); if (!jobId || !outputPath) { return null; } return { jobId, discNumber, outputPath }; }) .filter(Boolean); if (normalizedItems.length === 0) { return []; } const orderedSourceJobIds = normalizeReviewTitleIdList( Array.isArray(plan?.orderedSourceJobIds) ? plan.orderedSourceJobIds : normalizedItems.map((item) => item.jobId) ); const byJobId = new Map(normalizedItems.map((item) => [Number(item.jobId), item])); const ordered = []; const seen = new Set(); for (const sourceJobId of orderedSourceJobIds) { const item = byJobId.get(Number(sourceJobId)); if (!item) { continue; } const key = Number(item.jobId); if (seen.has(key)) { continue; } seen.add(key); ordered.push(item); } for (const item of normalizedItems) { const key = Number(item.jobId); if (seen.has(key)) { continue; } seen.add(key); ordered.push(item); } return ordered; } function normalizeIncompleteMergeTitleToken(value, fallback = 'movie') { const normalizedFallback = sanitizeFileName(String(fallback || '').trim() || 'movie') || 'movie'; const normalized = sanitizeFileName(String(value || '').trim()); if (!normalized) { return normalizedFallback; } const compacted = normalized.replace(/\s+/g, '_'); return compacted || normalizedFallback; } function resolveMultipartContainerJobIdFromJob(job = null, fallbackContainerJobId = null) { const encodePlan = parseJsonObjectSafe(job?.encode_plan_json); const directContainerJobId = normalizePositiveInteger( fallbackContainerJobId || ( String(job?.job_kind || job?.jobKind || '').trim().toLowerCase() === 'multipart_movie_container' ? job?.id : null ) || job?.parent_job_id || encodePlan?.containerJobId ); return directContainerJobId || null; } function buildIncompleteMergeFolderNameFromJob(job = null, options = {}) { const fallbackJobId = normalizePositiveInteger(options?.fallbackJobId); const containerJobId = resolveMultipartContainerJobIdFromJob( job, normalizePositiveInteger(options?.containerJobId) ); const titleFromContainer = String(options?.containerTitle || '').trim(); const titleFromJob = String(job?.title || job?.detected_title || '').trim(); const titleValue = titleFromContainer || titleFromJob || (fallbackJobId ? `job-${fallbackJobId}` : 'movie'); const titleToken = normalizeIncompleteMergeTitleToken(titleValue, fallbackJobId ? `job-${fallbackJobId}` : 'movie'); const containerToken = containerJobId || 'unknown'; return `Incomplete_merge_${titleToken}_job_${containerToken}`; } function buildIncompleteMergeOutputPathFromJob( settings, job, preferredFinalOutputPath, fallbackJobId = null, options = {} ) { const normalizedFinalPath = String(preferredFinalOutputPath || '').trim(); const fallbackFinalPath = normalizedFinalPath || buildFinalOutputPathFromJob(settings, job, fallbackJobId); const fallbackFileName = String(path.basename(fallbackFinalPath || '') || '').trim(); const finalFileName = fallbackFileName || ( normalizePositiveInteger(fallbackJobId) ? `job-${normalizePositiveInteger(fallbackJobId)}.${String(settings?.output_extension || 'mkv').trim() || 'mkv'}` : `job-unknown.${String(settings?.output_extension || 'mkv').trim() || 'mkv'}` ); const seriesParts = resolveSeriesOutputPathParts(settings, job, fallbackJobId); const movieDir = seriesParts?.rootDir || settings.movie_dir; const incompleteMergeFolderName = buildIncompleteMergeFolderNameFromJob(job, { fallbackJobId, containerJobId: options?.containerJobId, containerTitle: options?.containerTitle }); return path.join(movieDir, incompleteMergeFolderName, finalFileName); } function areMultipartMergeSourceItemsEquivalent(leftItems = [], rightItems = []) { const left = Array.isArray(leftItems) ? leftItems : []; const right = Array.isArray(rightItems) ? rightItems : []; if (left.length !== right.length) { return false; } for (let index = 0; index < left.length; index += 1) { const leftItem = left[index] || {}; const rightItem = right[index] || {}; const leftJobId = normalizePositiveInteger(leftItem.jobId || leftItem.sourceJobId); const rightJobId = normalizePositiveInteger(rightItem.jobId || rightItem.sourceJobId); if (leftJobId !== rightJobId) { return false; } const leftDiscNumber = normalizePositiveInteger(leftItem.discNumber); const rightDiscNumber = normalizePositiveInteger(rightItem.discNumber); if (leftDiscNumber !== rightDiscNumber) { return false; } const leftOutputPath = normalizeComparablePath(leftItem.outputPath || leftItem.path || null); const rightOutputPath = normalizeComparablePath(rightItem.outputPath || rightItem.path || null); if (leftOutputPath !== rightOutputPath) { return false; } } return true; } function buildFinalOutputPathFromJob(settings, job, fallbackJobId = null) { const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); const outputSuffix = jobKind === 'multipart_movie_merge' ? '_merged' : buildMultipartDiscOutputSuffix(job); const seriesParts = resolveSeriesOutputPathParts(settings, job, fallbackJobId); if (seriesParts?.rootDir) { const seriesBaseName = appendSuffixToBaseName(seriesParts.baseName, outputSuffix); if (seriesParts.folderPath) { return path.join(seriesParts.rootDir, seriesParts.folderPath, `${seriesBaseName}.${seriesParts.ext}`); } return path.join(seriesParts.rootDir, `${seriesBaseName}.${seriesParts.ext}`); } const movieDir = settings.movie_dir; const values = resolveOutputTemplateValues(job, fallbackJobId); const { folderPath, baseName } = resolveOutputPathParts(settings, values); const outputBaseName = appendSuffixToBaseName(baseName, outputSuffix); const ext = String(settings.output_extension || 'mkv').trim() || 'mkv'; if (folderPath) { return path.join(movieDir, folderPath, `${outputBaseName}.${ext}`); } return path.join(movieDir, `${outputBaseName}.${ext}`); } function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) { const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); const outputSuffix = jobKind === 'multipart_movie_merge' ? '_merged' : buildMultipartDiscOutputSuffix(job); const seriesParts = resolveSeriesOutputPathParts(settings, job, fallbackJobId); const movieDir = seriesParts?.rootDir || settings.movie_dir; const values = resolveOutputTemplateValues(job, fallbackJobId); const { baseName: defaultBaseName } = resolveOutputPathParts(settings, values); const baseName = appendSuffixToBaseName(seriesParts?.baseName || defaultBaseName, outputSuffix); const ext = String(seriesParts?.ext || 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 buildMultipartMergeOutputPath(templateOutputPath, sourceItems = [], options = {}) { const normalizedTemplateOutputPath = String(templateOutputPath || '').trim(); if (!normalizedTemplateOutputPath) { return null; } const preferSourceDirectory = options?.preferSourceDirectory === true; const templateParsed = path.parse(normalizedTemplateOutputPath); const fallbackDir = String(templateParsed.dir || '').trim() || process.cwd(); const sourceDirs = preferSourceDirectory ? Array.from(new Set( (Array.isArray(sourceItems) ? sourceItems : []) .map((item) => String(path.dirname(String(item?.outputPath || '').trim()) || '').trim()) .filter(Boolean) )) : []; const preferredDir = (preferSourceDirectory ? (sourceDirs[0] || fallbackDir) : fallbackDir); const preferredExt = String(templateParsed.ext || '').trim() || '.mkv'; const preferredName = String(templateParsed.name || '').trim() || 'merged_output'; return path.join(preferredDir, `${preferredName}${preferredExt}`); } 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 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 parsed = path.parse(outputPath); for (let i = 2; i < 200; i++) { const attempt = path.join(parentDir, `${parsed.name}_${i}${parsed.ext}`); 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 normalizedStage = String(stage || '').trim().toUpperCase(); const baseLabel = normalizedStage === 'ENCODING' ? 'Fortschritt' : stage; const parsedPercent = Number(percent); const wholePercent = Number.isFinite(parsedPercent) ? Math.trunc(Math.max(0, Math.min(100, parsedPercent))) : null; const base = percent !== null && percent !== undefined ? `${baseLabel} ${wholePercent ?? 0}%` : baseLabel; 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 parsedPercent = Number(percent); const wholePercent = Number.isFinite(parsedPercent) ? Math.trunc(Math.max(0, Math.min(100, parsedPercent))) : 0; const position = Number.isFinite(index) && Number.isFinite(total) && total > 0 ? ` ${index}/${total}` : ''; const status = statusWord ? ` ${statusWord}` : ''; const detail = String(label || '').trim(); return `ENCODING ${wholePercent}% - ${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 parseMakeMkvDriveLine(line) { const match = String(line || '').match(/^DRV:\d+,\d+,\d+,\d+,\"([^\"]*)\",\"([^\"]*)\",\"([^\"]*)\"/i); if (!match) { return null; } return { driveName: String(match[1] || '').trim(), discName: String(match[2] || '').trim(), devicePath: String(match[3] || '').trim() }; } function parseMakeMkvReadTarget(line) { const match = String(line || '').match(/occurred while reading '([^']+)'/i); if (!match) { return null; } return String(match[1] || '').trim() || null; } function createMakeMkvForeignDriveLineFilter(targetDevicePath) { const normalizedTargetDevicePath = String(targetDevicePath || '').trim(); if (!normalizedTargetDevicePath) { return null; } const foreignReadTargets = new Set(); return (line) => { const text = String(line || '').trim(); if (!text) { return true; } const driveInfo = parseMakeMkvDriveLine(text); if (driveInfo) { const drivePath = String(driveInfo.devicePath || '').trim(); const isForeignDrive = Boolean(drivePath && drivePath !== normalizedTargetDevicePath); if (isForeignDrive) { if (driveInfo.driveName) { foreignReadTargets.add(driveInfo.driveName); } if (driveInfo.discName) { foreignReadTargets.add(driveInfo.discName); } return false; } return true; } const readTarget = parseMakeMkvReadTarget(text); if (!readTarget) { return true; } if (!foreignReadTargets.has(readTarget)) { return true; } const msgCode = parseMakeMkvMessageCode(text); if (msgCode === 2003 || /scsi error/i.test(text)) { return false; } return true; }; } 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 parseRuntimeMinutesValue(rawValue) { if (Number.isFinite(Number(rawValue)) && Number(rawValue) > 0) { return Math.trunc(Number(rawValue)); } const text = String(rawValue || '').trim(); if (!text) { return null; } const normalized = text.toLowerCase(); const minMatch = normalized.match(/(\d+(?:[.,]\d+)?)\s*min\b/); if (minMatch) { const parsed = Number(String(minMatch[1]).replace(',', '.')); if (Number.isFinite(parsed) && parsed > 0) { return Math.max(1, Math.round(parsed)); } } const hmsMatch = normalized.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); if (hmsMatch) { const hours = Number(hmsMatch[1] || 0); const minutes = Number(hmsMatch[2] || 0); const seconds = Number(hmsMatch[3] || 0); if (Number.isFinite(hours) && Number.isFinite(minutes) && Number.isFinite(seconds)) { const totalMinutes = Math.round(((hours * 3600) + (minutes * 60) + seconds) / 60); return totalMinutes > 0 ? totalMinutes : null; } } const hourMatch = normalized.match(/(\d+)\s*h(?:our|hours)?\s*(\d{1,2})?\s*m?/); if (hourMatch) { const hours = Number(hourMatch[1] || 0); const minutes = Number(hourMatch[2] || 0); if (Number.isFinite(hours) && Number.isFinite(minutes)) { const totalMinutes = (hours * 60) + minutes; return totalMinutes > 0 ? totalMinutes : null; } } const numeric = Number(normalized.replace(',', '.')); if (Number.isFinite(numeric) && numeric > 0) { return Math.max(1, Math.round(numeric)); } return null; } function extractMovieRuntimeMinutesFromMetadata(selectedMetadata = null) { const metadata = selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}; const runtimeSources = [ metadata?.runtime, metadata?.runtimeLabel, metadata?.tmdbDetails?.runtime, metadata?.tmdbDetails?.runtimeLabel ]; for (const source of runtimeSources) { const parsed = parseRuntimeMinutesValue(source); if (Number.isFinite(parsed) && parsed > 0) { return parsed; } } return null; } function resolvePlaylistByRuntimeAutoMatch({ playlistCandidates = [], selectedMetadata = null } = {}) { const runtimeMinutes = extractMovieRuntimeMinutesFromMetadata(selectedMetadata); if (!Number.isFinite(runtimeMinutes) || runtimeMinutes <= 0) { return { playlistId: null, runtimeMinutes: null, reason: 'missing_runtime' }; } const expectedSeconds = runtimeMinutes * 60; const toleranceSeconds = PLAYLIST_RUNTIME_AUTO_MATCH_TOLERANCE_SECONDS; const rows = (Array.isArray(playlistCandidates) ? playlistCandidates : []) .map((item) => ({ playlistId: normalizePlaylistId(item?.playlistId), durationSeconds: Number(item?.durationSeconds || 0) })) .filter((item) => item.playlistId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); if (rows.length < 2) { return { playlistId: null, runtimeMinutes, reason: 'insufficient_candidates' }; } const withinTolerance = rows .map((item) => ({ ...item, deltaSeconds: Math.abs(Math.trunc(item.durationSeconds) - expectedSeconds) })) .filter((item) => item.deltaSeconds <= toleranceSeconds) .sort((a, b) => a.deltaSeconds - b.deltaSeconds || a.playlistId.localeCompare(b.playlistId)); if (withinTolerance.length !== 1) { return { playlistId: null, runtimeMinutes, reason: withinTolerance.length === 0 ? 'no_match_in_tolerance' : 'ambiguous_match', matchCount: withinTolerance.length }; } return { playlistId: withinTolerance[0].playlistId, runtimeMinutes, expectedSeconds, durationSeconds: withinTolerance[0].durationSeconds, deltaSeconds: withinTolerance[0].deltaSeconds, toleranceSeconds }; } function normalizeTrackLanguage(raw) { const value = String(raw || '').trim(); if (!value) { return 'und'; } const normalized = value.toLowerCase().slice(0, 3); if (!normalized || normalized === 'un' || normalized === 'unk' || normalized === 'und') { return 'und'; } return normalized; } function isUnknownTrackLanguage(raw) { const value = String(raw || '').trim().toLowerCase(); return !value || value === 'und' || value === 'unknown' || value === 'unk' || value === 'un'; } function normalizeTrackLanguageLabel(raw, fallbackLanguage = 'und') { const value = String(raw || '').trim(); if (value) { return value; } return String(fallbackLanguage || 'und').trim() || 'und'; } function buildSeriesBackupLanguageEnrichmentCandidatesFromPlaylistCache(scanCache) { const normalizedCache = normalizeHandBrakePlaylistScanCache(scanCache); if (!normalizedCache || !normalizedCache.byPlaylist || typeof normalizedCache.byPlaylist !== 'object') { return []; } return Object.values(normalizedCache.byPlaylist) .map((entry) => { const titleInfo = entry?.titleInfo && typeof entry.titleInfo === 'object' ? entry.titleInfo : null; if (!titleInfo) { return null; } const audioTracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []; const subtitleTracks = Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : []; return { handBrakeTitleId: Number.isFinite(Number(entry?.handBrakeTitleId)) ? Math.trunc(Number(entry.handBrakeTitleId)) : null, fileName: String(titleInfo?.fileName || '').trim() || null, durationSeconds: Number(titleInfo?.durationSeconds || 0) || 0, sizeBytes: Number(titleInfo?.sizeBytes || 0) || 0, audioTracks, subtitleTracks }; }) .filter((title) => title && Number.isFinite(title.durationSeconds) && title.durationSeconds > 0 && (title.audioTracks.length > 0 || title.subtitleTracks.length > 0) ); } function buildSeriesBackupLanguageEnrichmentCandidatesFromHandBrakeScan(scanJson) { const titleList = parseHandBrakeTitleList(scanJson); if (!Array.isArray(titleList) || titleList.length === 0) { return []; } return titleList .map((title) => { const handBrakeTitleId = Number.isFinite(Number(title?.handBrakeTitleId)) ? Math.trunc(Number(title.handBrakeTitleId)) : null; if (!handBrakeTitleId) { return null; } const titleInfo = parseHandBrakeSelectedTitleInfo(scanJson, { handBrakeTitleId, strictHandBrakeTitleId: true }); if (!titleInfo) { return null; } const audioTracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []; const subtitleTracks = Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : []; return { handBrakeTitleId, fileName: String(titleInfo?.fileName || '').trim() || null, durationSeconds: Number(titleInfo?.durationSeconds || title?.durationSeconds || 0) || 0, sizeBytes: Number(titleInfo?.sizeBytes || title?.sizeBytes || 0) || 0, audioTracks, subtitleTracks }; }) .filter((title) => title && Number.isFinite(title.durationSeconds) && title.durationSeconds > 0 && (title.audioTracks.length > 0 || title.subtitleTracks.length > 0) ); } function mapSeriesBackupReviewTitlesToLanguageCandidates(reviewTitles, candidates) { const normalizedTitles = Array.isArray(reviewTitles) ? reviewTitles : []; const normalizedCandidates = Array.isArray(candidates) ? candidates : []; const mapping = new Map(); if (normalizedTitles.length === 0 || normalizedCandidates.length === 0) { return mapping; } const usedCandidateIndexes = new Set(); const normalizeFileName = (value) => String(value || '').trim().toLowerCase(); // 1) Prefer deterministic filename mapping when possible. for (const title of normalizedTitles) { const titleId = Number.isFinite(Number(title?.id)) ? Math.trunc(Number(title.id)) : null; if (!titleId || mapping.has(titleId)) { continue; } const titleFileName = normalizeFileName(title?.fileName || title?.name || null); if (!titleFileName) { continue; } let matchedIndex = null; for (let index = 0; index < normalizedCandidates.length; index += 1) { if (usedCandidateIndexes.has(index)) { continue; } const candidateFileName = normalizeFileName(normalizedCandidates[index]?.fileName || null); if (!candidateFileName || candidateFileName !== titleFileName) { continue; } matchedIndex = index; break; } if (matchedIndex === null) { continue; } usedCandidateIndexes.add(matchedIndex); mapping.set(titleId, normalizedCandidates[matchedIndex]); } // 2) Fallback: duration/size score mapping. for (const title of normalizedTitles) { const titleId = Number.isFinite(Number(title?.id)) ? Math.trunc(Number(title.id)) : null; if (!titleId || mapping.has(titleId)) { continue; } const durationSeconds = Number(title?.durationSeconds || 0) || 0; const sizeBytes = Number(title?.sizeBytes || 0) || 0; let bestIndex = null; let bestScore = Number.POSITIVE_INFINITY; for (let index = 0; index < normalizedCandidates.length; index += 1) { if (usedCandidateIndexes.has(index)) { continue; } const candidate = normalizedCandidates[index]; const durationDelta = Math.abs(Number(candidate?.durationSeconds || 0) - durationSeconds); const sizeDelta = ( sizeBytes > 0 && Number(candidate?.sizeBytes || 0) > 0 ? Math.abs(Number(candidate.sizeBytes) - sizeBytes) : 0 ); const score = (durationDelta * 1024 * 1024) + sizeDelta; if (score < bestScore) { bestScore = score; bestIndex = index; } } if (bestIndex === null) { continue; } usedCandidateIndexes.add(bestIndex); mapping.set(titleId, normalizedCandidates[bestIndex]); } return mapping; } function enrichSeriesBackupReviewLanguagesFromCandidates(reviewPlan, candidates = []) { if (!reviewPlan || !Array.isArray(reviewPlan?.titles) || reviewPlan.titles.length === 0) { return { plan: reviewPlan, applied: false, matchedTitles: 0, enrichedAudioTracks: 0, enrichedSubtitleTracks: 0 }; } const normalizedCandidates = Array.isArray(candidates) ? candidates : []; if (normalizedCandidates.length === 0) { return { plan: reviewPlan, applied: false, matchedTitles: 0, enrichedAudioTracks: 0, enrichedSubtitleTracks: 0 }; } const mapping = mapSeriesBackupReviewTitlesToLanguageCandidates(reviewPlan.titles, normalizedCandidates); if (mapping.size === 0) { return { plan: reviewPlan, applied: false, matchedTitles: 0, enrichedAudioTracks: 0, enrichedSubtitleTracks: 0 }; } let enrichedAudioTracks = 0; let enrichedSubtitleTracks = 0; const enrichedTitles = reviewPlan.titles.map((title) => { const titleId = Number.isFinite(Number(title?.id)) ? Math.trunc(Number(title.id)) : null; if (!titleId || !mapping.has(titleId)) { return title; } const candidate = mapping.get(titleId); const candidateAudioTracks = Array.isArray(candidate?.audioTracks) ? candidate.audioTracks : []; const candidateSubtitleTracks = Array.isArray(candidate?.subtitleTracks) ? candidate.subtitleTracks : []; const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track, index) => { const sourceTrack = candidateAudioTracks[index] || null; if (!sourceTrack) { return track; } const currentLanguage = String(track?.language || '').trim().toLowerCase(); if (!isUnknownTrackLanguage(currentLanguage)) { return track; } const sourceLanguage = normalizeTrackLanguage(sourceTrack?.language || sourceTrack?.languageLabel || 'und'); if (isUnknownTrackLanguage(sourceLanguage)) { return track; } enrichedAudioTracks += 1; return { ...track, language: sourceLanguage, languageLabel: normalizeTrackLanguageLabel(sourceTrack?.languageLabel || sourceTrack?.language, sourceLanguage), title: track?.title || sourceTrack?.title || sourceTrack?.description || null, format: track?.format || sourceTrack?.format || null, channels: track?.channels || sourceTrack?.channels || null }; }); const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track, index) => { const sourceTrack = candidateSubtitleTracks[index] || null; if (!sourceTrack) { return track; } const currentLanguage = String(track?.language || '').trim().toLowerCase(); if (!isUnknownTrackLanguage(currentLanguage)) { return track; } const sourceLanguage = normalizeTrackLanguage(sourceTrack?.language || sourceTrack?.languageLabel || 'und'); if (isUnknownTrackLanguage(sourceLanguage)) { return track; } enrichedSubtitleTracks += 1; return { ...track, language: sourceLanguage, languageLabel: normalizeTrackLanguageLabel(sourceTrack?.languageLabel || sourceTrack?.language, sourceLanguage), title: track?.title || sourceTrack?.title || sourceTrack?.description || null, format: track?.format || sourceTrack?.format || null, forcedFlag: Boolean(track?.forcedFlag ?? sourceTrack?.forcedFlag), sdhFlag: Boolean(track?.sdhFlag ?? sourceTrack?.sdhFlag), defaultFlag: Boolean(track?.defaultFlag ?? sourceTrack?.defaultFlag), eventCount: Number.isFinite(Number(track?.eventCount)) ? Math.trunc(Number(track.eventCount)) : (Number.isFinite(Number(sourceTrack?.eventCount)) ? Math.trunc(Number(sourceTrack.eventCount)) : null), streamSizeBytes: Number.isFinite(Number(track?.streamSizeBytes)) ? Math.trunc(Number(track.streamSizeBytes)) : (Number.isFinite(Number(sourceTrack?.streamSizeBytes)) ? Math.trunc(Number(sourceTrack.streamSizeBytes)) : null) }; }); return { ...title, audioTracks, subtitleTracks }; }); const applied = enrichedAudioTracks > 0 || enrichedSubtitleTracks > 0; if (!applied) { return { plan: reviewPlan, applied: false, matchedTitles: mapping.size, enrichedAudioTracks: 0, enrichedSubtitleTracks: 0 }; } return { plan: { ...reviewPlan, titles: enrichedTitles }, applied: true, matchedTitles: mapping.size, enrichedAudioTracks, enrichedSubtitleTracks }; } 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 normalizePlaylistAliasIds(values, excludePlaylistId = null) { const exclude = normalizePlaylistId(excludePlaylistId); const rows = Array.isArray(values) ? values : []; const seen = new Set(); const aliases = []; for (const value of rows) { const playlistId = normalizePlaylistId(value); if (!playlistId || playlistId === exclude || seen.has(playlistId)) { continue; } seen.add(playlistId); aliases.push(playlistId); } return aliases; } function getPlaylistAliasesForPlaylist(playlistAnalysis, playlistIdRaw) { const playlistId = normalizePlaylistId(playlistIdRaw); if (!playlistId || !playlistAnalysis || typeof playlistAnalysis !== 'object') { return []; } const aliasMap = playlistAnalysis?.playlistAliasMap && typeof playlistAnalysis.playlistAliasMap === 'object' ? playlistAnalysis.playlistAliasMap : null; if (!aliasMap) { return []; } return normalizePlaylistAliasIds(aliasMap[playlistId], playlistId); } function resolvePlaylistInfoFromAnalysis(playlistAnalysis, playlistIdRaw) { const playlistId = normalizePlaylistId(playlistIdRaw); const playlistAliases = getPlaylistAliasesForPlaylist(playlistAnalysis, playlistId); if (!playlistId || !playlistAnalysis) { return { playlistId: playlistId || null, playlistFile: playlistId ? `${playlistId}.mpls` : null, playlistAliases, 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`, playlistAliases, 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 playlistAliases = normalizePlaylistAliasIds(options?.playlistAliases, playlistId); const candidatePlaylistIds = [playlistId, ...playlistAliases]; const candidatePlaylistSet = new Set(candidatePlaylistIds); const rows = buildHandBrakeScanTitleRows(scanJson); const directMatches = rows.filter((item) => item.playlist === playlistId); const matches = rows.filter((item) => candidatePlaylistSet.has(item.playlist)); 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); const scoredDirect = directMatches.map(scoreForExpected).sort(sortByExpectedScore); if (expectedDurationSeconds !== null) { const directWithinTolerance = scoredDirect.filter((item) => item.durationDelta <= durationToleranceSeconds); if (directWithinTolerance.length > 0) { return directWithinTolerance[0].row.handBrakeTitleId; } const aliasWithinTolerance = scored.filter((item) => item.durationDelta <= durationToleranceSeconds && normalizePlaylistId(item?.row?.playlist) !== playlistId ); if (aliasWithinTolerance.length > 0) { return aliasWithinTolerance[0].row.handBrakeTitleId; } } if (scoredDirect.length > 0) { return scoredDirect[0].row.handBrakeTitleId; } return scored[0].row.handBrakeTitleId; } const directBest = directMatches .slice() .sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.handBrakeTitleId - b.handBrakeTitleId )[0]; if (directBest) { return directBest.handBrakeTitleId; } const best = matches.slice().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 allowedPlaylistIds = new Set([ playlistId, ...normalizePlaylistAliasIds(options?.playlistAliases, playlistId) ]); const cachedPlaylistId = normalizePlaylistId(titleInfo?.playlistId || null); if (cachedPlaylistId && !allowedPlaylistIds.has(cachedPlaylistId)) { 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; const chapterList = Array.isArray(title?.ChapterList) ? title.ChapterList : []; const chapterCountFromList = chapterList.length; const chapterCountFromField = Number(title?.ChapterCount ?? title?.chapterCount ?? 0); const chapterCount = chapterCountFromList > 0 ? chapterCountFromList : (Number.isFinite(chapterCountFromField) && chapterCountFromField > 0 ? Math.trunc(chapterCountFromField) : 0); const chapterDurations = chapterList .map((chapter) => parseHandBrakeDurationSeconds( chapter?.Duration ?? chapter?.duration ?? chapter?.Length ?? chapter?.length )) .filter((value) => Number.isFinite(value) && value > 0); const chapterAverageSeconds = chapterDurations.length > 0 ? Number((chapterDurations.reduce((sum, value) => sum + value, 0) / chapterDurations.length).toFixed(2)) : 0; return { handBrakeTitleId, durationSeconds, audioTrackCount, subtitleTrackCount, sizeBytes, chapterCount, chapterAverageSeconds }; }); } function normalizeSeriesDetectionConfidence(value) { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'high' || normalized === 'medium' || normalized === 'low') { return normalized; } return 'low'; } function pickHigherSeriesDetectionConfidence(left, right) { const scores = { low: 1, medium: 2, high: 3 }; const leftNormalized = normalizeSeriesDetectionConfidence(left); const rightNormalized = normalizeSeriesDetectionConfidence(right); return (scores[rightNormalized] || 0) > (scores[leftNormalized] || 0) ? rightNormalized : leftNormalized; } function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options = {}) { const minEpisodeSeconds = Math.max( 60, Math.round(Number(options?.minEpisodeMinutes || 18) * 60) ); const maxEpisodeSeconds = Math.max( minEpisodeSeconds, Math.round(Number(options?.maxEpisodeMinutes || 75) * 60) ); const shortTitleSeconds = Math.max( 60, Math.round(Number(options?.shortTitleMinutes || 5) * 60) ); const minEpisodeChapterCount = Math.max( 1, Math.round(Number(options?.minEpisodeChapterCount || 4)) ); const chapterSimilarityRatio = Math.max( 1.1, Number(options?.chapterSimilarityRatio || 1.8) ); const dominantFeatureDurationRatio = Math.max( 1.2, Number(options?.dominantFeatureDurationRatio || 2.2) ); const playAllToleranceLowerPct = normalizePlayAllTolerancePercent( options?.playAllToleranceLowerPct, 10 ); const playAllToleranceUpperPct = normalizePlayAllTolerancePercent( options?.playAllToleranceUpperPct, 10 ); const titles = parseHandBrakeTitleList(scanJson); if (!Array.isArray(titles) || titles.length === 0) { return null; } const titlesWithDuration = titles .map((title) => ({ id: Number(title?.handBrakeTitleId || 0) || null, durationSeconds: Number(title?.durationSeconds || 0) || 0, chapterCount: Number(title?.chapterCount || 0) || 0, chapterAverageSeconds: Number(title?.chapterAverageSeconds || 0) || 0 })) .filter((title) => Number.isFinite(title.durationSeconds) && title.durationSeconds > 0); if (titlesWithDuration.length === 0) { return null; } const episodeWindowCandidates = titlesWithDuration.filter((title) => title.durationSeconds >= minEpisodeSeconds && title.durationSeconds <= maxEpisodeSeconds ); const areChapterProfilesCompatible = (left, right) => { const leftCount = Number(left?.chapterCount || 0); const rightCount = Number(right?.chapterCount || 0); if (leftCount > 0 && rightCount > 0) { const ratio = Math.max(leftCount, rightCount) / Math.max(1, Math.min(leftCount, rightCount)); if (ratio > chapterSimilarityRatio) { return false; } } const leftAvg = Number(left?.chapterAverageSeconds || 0); const rightAvg = Number(right?.chapterAverageSeconds || 0); if (leftAvg > 0 && rightAvg > 0) { const ratio = Math.max(leftAvg, rightAvg) / Math.max(1, Math.min(leftAvg, rightAvg)); if (ratio > chapterSimilarityRatio) { return false; } } return true; }; let bestCluster = []; for (const anchor of episodeWindowCandidates) { const toleranceSeconds = Math.max(90, Math.round(anchor.durationSeconds * 0.12)); const cluster = episodeWindowCandidates.filter((candidate) => Math.abs(candidate.durationSeconds - anchor.durationSeconds) <= toleranceSeconds && areChapterProfilesCompatible(anchor, candidate) ); if (cluster.length > bestCluster.length) { bestCluster = cluster; continue; } if (cluster.length === bestCluster.length && cluster.length > 0) { const clusterMedian = medianPositiveNumber(cluster.map((entry) => entry.durationSeconds)); const bestMedian = medianPositiveNumber(bestCluster.map((entry) => entry.durationSeconds)); if (clusterMedian > bestMedian) { bestCluster = cluster; } } } const episodeCandidateCount = bestCluster.length; const clusterMedianDuration = medianPositiveNumber(bestCluster.map((entry) => entry.durationSeconds)); const clusterMedianChapterCount = medianPositiveNumber( bestCluster .map((entry) => Number(entry?.chapterCount || 0)) .filter((value) => Number.isFinite(value) && value > 0) ); const clusterIds = new Set( bestCluster .map((entry) => Number(entry?.id)) .filter((value) => Number.isFinite(value) && value > 0) .map((value) => Math.trunc(value)) ); const shortCount = titlesWithDuration.filter((title) => title.durationSeconds <= shortTitleSeconds).length; const episodeSumSeconds = episodeCandidateCount > 0 && clusterMedianDuration > 0 ? Math.round(clusterMedianDuration * episodeCandidateCount) : 0; const playAllLowerBoundSeconds = episodeSumSeconds > 0 ? Math.round(episodeSumSeconds * (1 - (playAllToleranceLowerPct / 100))) : 0; const playAllUpperBoundSeconds = episodeSumSeconds > 0 ? Math.round(episodeSumSeconds * (1 + (playAllToleranceUpperPct / 100))) : 0; const outsideClusterTitles = titlesWithDuration.filter((title) => !clusterIds.has(Number(title?.id))); const playAllCandidate = (() => { if (episodeCandidateCount < 2 || clusterMedianDuration <= 0 || outsideClusterTitles.length === 0) { return null; } return outsideClusterTitles .filter((title) => title.durationSeconds >= playAllLowerBoundSeconds && title.durationSeconds <= playAllUpperBoundSeconds ) .sort((left, right) => right.durationSeconds - left.durationSeconds)[0] || null; })(); const playAllCount = playAllCandidate ? 1 : 0; const playAllCandidateSeconds = Number(playAllCandidate?.durationSeconds || 0) || 0; const playAllWithinTolerance = playAllCandidateSeconds > 0; const longestOutsideClusterSeconds = outsideClusterTitles.length > 0 ? Math.max(...outsideClusterTitles.map((title) => Number(title?.durationSeconds || 0)).filter((value) => Number.isFinite(value) && value > 0)) : 0; const extraCount = titlesWithDuration.filter((title) => !clusterIds.has(Number(title?.id)) && title.durationSeconds > shortTitleSeconds ).length; let seriesLike = episodeCandidateCount >= 2; const longestTitle = titlesWithDuration.reduce((best, current) => ( !best || current.durationSeconds > best.durationSeconds ? current : best ), null); const longestTitleDominates = Boolean( longestTitle && clusterMedianDuration > 0 && longestTitle.durationSeconds >= (clusterMedianDuration * dominantFeatureDurationRatio) && Number(longestTitle?.chapterCount || 0) >= (minEpisodeChapterCount * 2) ); const lowChapterEpisodeCount = bestCluster.filter((entry) => { const chapterCount = Number(entry?.chapterCount || 0); if (chapterCount <= 0) { return longestTitleDominates; } return chapterCount < minEpisodeChapterCount; }).length; const allEpisodeCandidatesLowChapter = ( episodeCandidateCount > 0 && lowChapterEpisodeCount === episodeCandidateCount ); if (seriesLike && longestTitleDominates && allEpisodeCandidatesLowChapter) { seriesLike = false; } let confidence = 'low'; if (seriesLike) { if (episodeCandidateCount >= 3 || playAllCount > 0) { confidence = 'high'; } else if (titlesWithDuration.length >= 3) { confidence = 'medium'; } } const reasons = []; if (episodeCandidateCount >= 3) { reasons.push(`${episodeCandidateCount} ähnlich lange Episodenkandidaten im HandBrake-JSON erkannt.`); } else if (episodeCandidateCount === 2) { reasons.push('Zwei ähnlich lange Episodenkandidaten im HandBrake-JSON erkannt.'); } if (clusterMedianDuration > 0) { reasons.push(`Typische Episodenlänge ca. ${Math.round(clusterMedianDuration / 60)} Minuten.`); } if (playAllCount > 0) { reasons.push('Ein zusätzlicher langer Play-All-Kandidat wurde erkannt.'); } else if (episodeCandidateCount >= 2 && episodeSumSeconds > 0 && longestOutsideClusterSeconds > playAllUpperBoundSeconds) { reasons.push( `Langer Titel außerhalb Play-All-Toleranz erkannt (>${Math.round(playAllUpperBoundSeconds / 60)} Minuten bei ±${playAllToleranceLowerPct}%/${playAllToleranceUpperPct}%).` ); } if (shortCount > 0) { reasons.push(`${shortCount} kurze Titel als Extras/Kurzsegmente erkannt.`); } if (!seriesLike && longestTitleDominates && allEpisodeCandidatesLowChapter) { reasons.push( 'Kurze Episodenkandidaten wurden wegen abweichender Kapitelstruktur als Bonusmaterial gewertet ' + `(Kapitelzahl Kandidaten < ${minEpisodeChapterCount}, Haupttitel kapitelreich).` ); } return { source: 'handbrake_scan_json', generatedAt: nowIso(), summary: { seriesLike, confidence, reasons, titleCount: titles.length, episodeCandidateCount, episodeChapterMedian: clusterMedianChapterCount > 0 ? Number(clusterMedianChapterCount.toFixed(2)) : 0, lowChapterEpisodeCount, longestTitleChapterCount: Number(longestTitle?.chapterCount || 0) || 0, playAllCount, playAllCandidateSeconds, playAllWithinTolerance, episodeSumSeconds, playAllLowerBoundSeconds, playAllUpperBoundSeconds, longestOutsideClusterSeconds, duplicateCount: 0, extraCount, typicalEpisodeMinutes: clusterMedianDuration > 0 ? Number((clusterMedianDuration / 60).toFixed(2)) : 0 } }; } function buildStructuredSeriesAnalysisFromPlaylistAnalysis(playlistAnalysis, options = {}) { const sourceTitles = Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : []; if (sourceTitles.length === 0) { return null; } const parseChapterCountFromText = (value) => { const text = String(value || '').trim(); if (!text) { return 0; } const chapterMatch = text.match(/(\d+)\s+chapter\(s\)/i); if (!chapterMatch) { return 0; } const parsed = Number(chapterMatch[1]); if (!Number.isFinite(parsed) || parsed <= 0) { return 0; } return Math.trunc(parsed); }; const normalizeChapterCountForSeriesHeuristics = (title) => { const directChapterCount = Number(title?.chapterCount ?? title?.chapters ?? 0); if (Number.isFinite(directChapterCount) && directChapterCount > 0) { return Math.trunc(directChapterCount); } const fields = title?.fields && typeof title.fields === 'object' ? title.fields : null; const fieldChapterCount = Number(fields?.[8] ?? fields?.[7] ?? 0); if (Number.isFinite(fieldChapterCount) && fieldChapterCount > 0) { return Math.trunc(fieldChapterCount); } const chapterInfoText = String( title?.description || fields?.[30] || '' ).trim(); return parseChapterCountFromText(chapterInfoText); }; const syntheticScanJson = { MainFeature: 0, TitleList: sourceTitles.map((title, index) => ({ Index: normalizeScanTrackId(title?.titleId, index), Duration: Number(title?.durationSeconds || 0) || String(title?.durationLabel || '').trim() || 0, ChapterCount: normalizeChapterCountForSeriesHeuristics(title), AudioList: Array.isArray(title?.audioTracks) ? title.audioTracks : [], SubtitleList: Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [], Size: { Bytes: Number(title?.sizeBytes || 0) || 0 } })) }; const structured = buildStructuredSeriesAnalysisFromHandBrakeScanJson(syntheticScanJson, options); if (!structured?.summary || typeof structured.summary !== 'object') { return null; } const summary = { ...structured.summary, titleCount: Math.max( Number(structured.summary?.titleCount || 0) || 0, sourceTitles.length ) }; return { source: 'makemkv_analyze_structured', generatedAt: nowIso(), summary }; } function hasDiscDecryptWarningInScan(scanAnalysisLines = null) { const lines = Array.isArray(scanAnalysisLines) ? scanAnalysisLines : (String(scanAnalysisLines || '').trim() ? String(scanAnalysisLines || '').split(/\r?\n/) : []); if (lines.length === 0) { return false; } return lines.some((line) => /(aacs|keydbcfg|no valid aacs|cannot decrypt|can't decrypt|encrypted|libdvdcss|\bcss\b|copy protection|bd\+)/i .test(String(line || '')) ); } function shouldRunMakeMkvSeriesSecondChance(seriesAnalysis = null, scanJson = null, scanAnalysisLines = null) { const summary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' ? seriesAnalysis.summary : null; if (Boolean(summary?.seriesLike)) { return false; } const parsedTitles = parseHandBrakeTitleList(scanJson); const durations = parsedTitles .map((title) => Number(title?.durationSeconds || 0)) .filter((value) => Number.isFinite(value) && value > 0); const titleCount = Math.max( Number(summary?.titleCount || 0) || 0, parsedTitles.length ); const maxDuration = durations.length > 0 ? Math.max(...durations) : 0; const shortOnlyScan = titleCount > 0 && titleCount <= 2 && maxDuration > 0 && maxDuration <= 600; const hasDecryptWarning = hasDiscDecryptWarningInScan(scanAnalysisLines); const noEpisodeSignals = (Number(summary?.episodeCandidateCount || 0) || 0) <= 0 && (Number(summary?.playAllCount || 0) || 0) <= 0; const noFeatureExtras = (Number(summary?.extraCount || 0) || 0) <= 0; const lowConfidence = normalizeSeriesDetectionConfidence(summary?.confidence) === 'low'; return lowConfidence && noEpisodeSignals && (shortOnlyScan || (hasDecryptWarning && noFeatureExtras)); } function mergeSeriesAnalysisWithStructuredFallback(seriesAnalysis = null, structuredSeriesAnalysis = null) { const baseSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' ? seriesAnalysis.summary : null; const structuredSummary = structuredSeriesAnalysis?.summary && typeof structuredSeriesAnalysis.summary === 'object' ? structuredSeriesAnalysis.summary : null; if (!baseSummary && !structuredSummary) { return null; } if (!baseSummary && structuredSummary) { return { source: String(structuredSeriesAnalysis?.source || 'handbrake_scan_json').trim() || 'handbrake_scan_json', generatedAt: structuredSeriesAnalysis?.generatedAt || nowIso(), summary: { ...structuredSummary, confidence: normalizeSeriesDetectionConfidence(structuredSummary?.confidence) } }; } if (baseSummary && !structuredSummary) { return { source: String(seriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text', generatedAt: seriesAnalysis?.generatedAt || nowIso(), summary: { ...baseSummary, confidence: normalizeSeriesDetectionConfidence(baseSummary?.confidence) } }; } const seriesLike = Boolean(baseSummary?.seriesLike) || Boolean(structuredSummary?.seriesLike); const confidence = seriesLike ? pickHigherSeriesDetectionConfidence(baseSummary?.confidence, structuredSummary?.confidence) : 'low'; const reasons = Array.from(new Set([ ...(Array.isArray(baseSummary?.reasons) ? baseSummary.reasons : []), ...(Array.isArray(structuredSummary?.reasons) ? structuredSummary.reasons : []) ])) .map((reason) => String(reason || '').trim()) .filter(Boolean); const summary = { ...baseSummary, seriesLike, confidence, reasons, titleCount: Math.max( Number(baseSummary?.titleCount || 0) || 0, Number(structuredSummary?.titleCount || 0) || 0 ), episodeCandidateCount: Math.max( Number(baseSummary?.episodeCandidateCount || 0) || 0, Number(structuredSummary?.episodeCandidateCount || 0) || 0 ), episodeChapterMedian: Math.max( Number(baseSummary?.episodeChapterMedian || 0) || 0, Number(structuredSummary?.episodeChapterMedian || 0) || 0 ), lowChapterEpisodeCount: Math.max( Number(baseSummary?.lowChapterEpisodeCount || 0) || 0, Number(structuredSummary?.lowChapterEpisodeCount || 0) || 0 ), longestTitleChapterCount: Math.max( Number(baseSummary?.longestTitleChapterCount || 0) || 0, Number(structuredSummary?.longestTitleChapterCount || 0) || 0 ), playAllCount: Math.max( Number(baseSummary?.playAllCount || 0) || 0, Number(structuredSummary?.playAllCount || 0) || 0 ), playAllCandidateSeconds: Math.max( Number(baseSummary?.playAllCandidateSeconds || 0) || 0, Number(structuredSummary?.playAllCandidateSeconds || 0) || 0 ), playAllWithinTolerance: Boolean(baseSummary?.playAllWithinTolerance) || Boolean(structuredSummary?.playAllWithinTolerance), episodeSumSeconds: Math.max( Number(baseSummary?.episodeSumSeconds || 0) || 0, Number(structuredSummary?.episodeSumSeconds || 0) || 0 ), playAllLowerBoundSeconds: Math.max( Number(baseSummary?.playAllLowerBoundSeconds || 0) || 0, Number(structuredSummary?.playAllLowerBoundSeconds || 0) || 0 ), playAllUpperBoundSeconds: Math.max( Number(baseSummary?.playAllUpperBoundSeconds || 0) || 0, Number(structuredSummary?.playAllUpperBoundSeconds || 0) || 0 ), longestOutsideClusterSeconds: Math.max( Number(baseSummary?.longestOutsideClusterSeconds || 0) || 0, Number(structuredSummary?.longestOutsideClusterSeconds || 0) || 0 ), duplicateCount: Math.max( Number(baseSummary?.duplicateCount || 0) || 0, Number(structuredSummary?.duplicateCount || 0) || 0 ), extraCount: Math.max( Number(baseSummary?.extraCount || 0) || 0, Number(structuredSummary?.extraCount || 0) || 0 ), typicalEpisodeMinutes: Math.max( Number(baseSummary?.typicalEpisodeMinutes || 0) || 0, Number(structuredSummary?.typicalEpisodeMinutes || 0) || 0 ) }; const structuredSeriesLike = Boolean(structuredSummary?.seriesLike); const baseSeriesLike = Boolean(baseSummary?.seriesLike); return { source: seriesLike && structuredSeriesLike && !baseSeriesLike ? 'handbrake_scan_json' : (String(seriesAnalysis?.source || structuredSeriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text'), generatedAt: nowIso(), summary }; } function normalizeSeriesScanTitleKind(rawKind) { return String(rawKind || '').trim().toLowerCase(); } function medianPositiveNumber(values = []) { const normalized = (Array.isArray(values) ? values : []) .map((value) => Number(value)) .filter((value) => Number.isFinite(value) && value > 0) .sort((a, b) => a - b); if (normalized.length === 0) { return 0; } const middle = Math.floor(normalized.length / 2); if (normalized.length % 2 === 1) { return normalized[middle]; } return (normalized[middle - 1] + normalized[middle]) / 2; } function deriveSeriesMultiEpisodeCandidateIds(seriesTitles = []) { const normalizedTitles = (Array.isArray(seriesTitles) ? seriesTitles : []) .map((title) => ({ index: normalizePositiveInteger(title?.index), kind: normalizeSeriesScanTitleKind(title?.kind), durationSeconds: Number(title?.durationSeconds || 0), chapterCount: normalizePositiveInteger(title?.chapterCount) || 0 })) .filter((title) => Number.isFinite(title?.index) && title.index > 0 && Number.isFinite(title?.durationSeconds) && title.durationSeconds > 0 ); if (normalizedTitles.length === 0) { return new Set(); } const episodeCandidates = normalizedTitles.filter((title) => title.kind === 'episode_candidate'); if (episodeCandidates.length < 2) { return new Set(); } const baselineEpisodeDuration = medianPositiveNumber( episodeCandidates.map((title) => title.durationSeconds) ); if (!baselineEpisodeDuration) { return new Set(); } const baselineEpisodeDurations = episodeCandidates .map((title) => title.durationSeconds) .filter((value) => value <= baselineEpisodeDuration * 1.35); const baselineDuration = medianPositiveNumber( baselineEpisodeDurations.length > 0 ? baselineEpisodeDurations : episodeCandidates.map((title) => title.durationSeconds) ) || baselineEpisodeDuration; if (!baselineDuration) { return new Set(); } const baselineChapterCount = medianPositiveNumber( episodeCandidates .map((title) => title.chapterCount) .filter((value) => Number.isFinite(value) && value > 0) ); const candidates = normalizedTitles.filter((title) => !( title.kind === 'episode_candidate' || title.kind === 'play_all' || title.kind === 'duplicate' || title.kind === 'short' )); const output = new Set(); for (const title of candidates) { const durationRatio = title.durationSeconds / baselineDuration; const roundedSpan = Math.round(durationRatio); if (!Number.isFinite(roundedSpan) || roundedSpan < 2 || roundedSpan > 4) { continue; } const minDurationRatio = roundedSpan === 2 ? 1.55 : roundedSpan * 0.72; const maxDurationRatio = roundedSpan * 1.32; if (durationRatio < minDurationRatio || durationRatio > maxDurationRatio) { continue; } if (baselineChapterCount > 0 && title.chapterCount > 0) { const chapterRatio = title.chapterCount / baselineChapterCount; const minChapterRatio = roundedSpan === 2 ? 1.2 : Math.max(1.2, roundedSpan * 0.55); if (chapterRatio < minChapterRatio) { continue; } } output.add(Number(title.index)); } return output; } function enrichSeriesAnalyzeContextFromScan(analyzeContext = null, scanLines = null, options = {}) { const baseAnalyzeContext = analyzeContext && typeof analyzeContext === 'object' ? analyzeContext : {}; const existingSeriesTitles = Array.isArray(baseAnalyzeContext?.seriesAnalysis?.titles) ? baseAnalyzeContext.seriesAnalysis.titles : []; if (existingSeriesTitles.length > 0) { return { analyzeContext: baseAnalyzeContext, derived: false, reason: 'already_available' }; } const scanText = Array.isArray(scanLines) ? scanLines.join('\n') : String(scanLines || '').trim(); if (!String(scanText || '').trim()) { return { analyzeContext: baseAnalyzeContext, derived: false, reason: 'scan_missing' }; } let textSeriesAnalysis = null; let textSeriesTitles = []; try { const analysisResult = dvdSeriesScanService.analyzeHandBrakeScan(scanText, options?.seriesOptions || {}); textSeriesAnalysis = analysisResult?.analysis || null; textSeriesTitles = Array.isArray(textSeriesAnalysis?.titles) ? textSeriesAnalysis.titles : []; } catch (_error) { textSeriesAnalysis = null; textSeriesTitles = []; } const scanJson = parseMediainfoJsonOutput(scanText); const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options?.seriesOptions || {}); const playlistStructuredSeriesAnalysis = buildStructuredSeriesAnalysisFromPlaylistAnalysis( baseAnalyzeContext?.playlistAnalysis || null, options?.seriesOptions || {} ); const mergedFromScan = mergeSeriesAnalysisWithStructuredFallback( textSeriesAnalysis, structuredSeriesAnalysis ); const mergedSeriesAnalysis = mergeSeriesAnalysisWithStructuredFallback( mergedFromScan, playlistStructuredSeriesAnalysis ); const derivedFromText = textSeriesTitles.length > 0; const derivedFromStructured = Boolean(structuredSeriesAnalysis?.summary?.seriesLike); const derivedFromPlaylistStructured = Boolean(playlistStructuredSeriesAnalysis?.summary?.seriesLike); if (!mergedSeriesAnalysis || (!derivedFromText && !derivedFromStructured && !derivedFromPlaylistStructured)) { return { analyzeContext: baseAnalyzeContext, derived: false, reason: derivedFromText ? 'scan_parse_failed' : 'no_titles' }; } const nextSeriesAnalysis = derivedFromText ? { ...mergedSeriesAnalysis, titles: textSeriesTitles } : mergedSeriesAnalysis; return { analyzeContext: { ...baseAnalyzeContext, seriesAnalysis: nextSeriesAnalysis }, derived: true, reason: derivedFromText ? 'scan_classification' : (derivedFromStructured ? 'scan_json_structural_classification' : 'playlist_structural_classification') }; } function filterSeriesDvdHandBrakeCandidates(candidates = [], analyzeContext = null) { const sourceCandidates = Array.isArray(candidates) ? candidates : []; const seriesTitles = Array.isArray(analyzeContext?.seriesAnalysis?.titles) ? analyzeContext.seriesAnalysis.titles : []; if (sourceCandidates.length === 0 || seriesTitles.length === 0) { return { candidates: sourceCandidates, applied: false, reason: null, sourceCount: sourceCandidates.length, resultCount: sourceCandidates.length }; } const episodeCandidateIds = new Set( seriesTitles .filter((title) => normalizeSeriesScanTitleKind(title?.kind) === 'episode_candidate') .map((title) => Number(title?.index)) .filter((value) => Number.isFinite(value) && value > 0) .map((value) => Math.trunc(value)) ); const multiEpisodeCandidateIds = deriveSeriesMultiEpisodeCandidateIds(seriesTitles); const blockedIds = new Set( seriesTitles .filter((title) => ['play_all', 'duplicate', 'short'].includes(normalizeSeriesScanTitleKind(title?.kind))) .map((title) => Number(title?.index)) .filter((value) => Number.isFinite(value) && value > 0) .map((value) => Math.trunc(value)) ); let filtered = sourceCandidates; let reason = null; if (episodeCandidateIds.size > 0) { const allowedEpisodeIds = new Set([ ...Array.from(episodeCandidateIds.values()), ...Array.from(multiEpisodeCandidateIds.values()) ]); const byEpisodeIds = sourceCandidates.filter((item) => allowedEpisodeIds.has(Number(item?.handBrakeTitleId))); if (byEpisodeIds.length > 0) { filtered = byEpisodeIds; reason = multiEpisodeCandidateIds.size > 0 ? 'episode_candidates_plus_multi_episode' : 'episode_candidates_only'; } } if (filtered === sourceCandidates && blockedIds.size > 0) { const withoutBlocked = sourceCandidates.filter((item) => !blockedIds.has(Number(item?.handBrakeTitleId))); if (withoutBlocked.length > 0 && withoutBlocked.length < sourceCandidates.length) { filtered = withoutBlocked; reason = 'excluded_playall_duplicate_short'; } } if (filtered === sourceCandidates && sourceCandidates.length >= 3) { const durations = sourceCandidates .map((item) => Number(item?.durationSeconds || 0)) .filter((value) => Number.isFinite(value) && value > 0) .sort((a, b) => a - b); if (durations.length >= 3) { const median = durations[Math.floor(durations.length / 2)]; const sortedDesc = sourceCandidates .map((item) => ({ id: Number(item?.handBrakeTitleId), durationSeconds: Number(item?.durationSeconds || 0) })) .filter((item) => Number.isFinite(item.id) && item.id > 0 && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0) .sort((a, b) => b.durationSeconds - a.durationSeconds); if (sortedDesc.length >= 2) { const longest = sortedDesc[0]; const second = sortedDesc[1]; const looksLikePlayAll = longest.durationSeconds >= median * 1.8 && longest.durationSeconds >= second.durationSeconds * 1.4; if (looksLikePlayAll) { const withoutLongest = sourceCandidates.filter((item) => Number(item?.handBrakeTitleId) !== longest.id); if (withoutLongest.length > 0) { filtered = withoutLongest; reason = 'heuristic_playall_longest'; } } } } } if (filtered === sourceCandidates && sourceCandidates.length === 2) { const sortedDesc = sourceCandidates .map((item) => ({ id: Number(item?.handBrakeTitleId), durationSeconds: Number(item?.durationSeconds || 0) })) .filter((item) => Number.isFinite(item.id) && item.id > 0 && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0) .sort((a, b) => b.durationSeconds - a.durationSeconds); if (sortedDesc.length === 2) { const longest = sortedDesc[0]; const second = sortedDesc[1]; const looksLikePlayAll = longest.durationSeconds >= second.durationSeconds * 1.6; if (looksLikePlayAll) { const withoutLongest = sourceCandidates.filter((item) => Number(item?.handBrakeTitleId) !== longest.id); if (withoutLongest.length > 0) { filtered = withoutLongest; reason = 'heuristic_playall_two_titles'; } } } } if (filtered.length >= 3) { const durations = filtered .map((item) => Number(item?.durationSeconds || 0)) .filter((value) => Number.isFinite(value) && value > 0) .sort((a, b) => a - b); if (durations.length >= 3) { const median = durations[Math.floor(durations.length / 2)]; // Only apply this guard when the disc mostly contains regular-length episodes. // This prevents accidentally removing genuinely short-format episode discs. if (median >= (15 * 60)) { const shortThresholdSeconds = Math.max(5 * 60, Math.round(median * 0.5)); const withoutShort = filtered.filter((item) => Number(item?.durationSeconds || 0) >= shortThresholdSeconds); if (withoutShort.length >= 2 && withoutShort.length < filtered.length) { filtered = withoutShort; reason = reason ? `${reason}+heuristic_excluded_short_titles` : 'heuristic_excluded_short_titles'; } } } } return { candidates: filtered, applied: filtered !== sourceCandidates, reason, sourceCount: sourceCandidates.length, resultCount: filtered.length }; } function filterSeriesBackupReviewTitles(reviewPlan) { const plan = reviewPlan && typeof reviewPlan === 'object' ? reviewPlan : null; if (!plan) { return { plan: reviewPlan, applied: false, reason: 'invalid_plan', sourceCount: 0, resultCount: 0 }; } const titles = Array.isArray(plan.titles) ? plan.titles : []; if (titles.length < 3) { return { plan, applied: false, reason: 'insufficient_titles', sourceCount: titles.length, resultCount: titles.length }; } const rows = titles .map((title, index) => ({ id: normalizeReviewTitleId(title?.id), durationSeconds: Number(title?.durationSeconds || 0), index })) .filter((row) => row.id && Number.isFinite(row.durationSeconds) && row.durationSeconds > 0); if (rows.length < 3) { return { plan, applied: false, reason: 'insufficient_duration_data', sourceCount: titles.length, resultCount: titles.length }; } const longEpisodeDurations = rows .map((row) => row.durationSeconds) .filter((value) => Number.isFinite(value) && value >= (20 * 60)); const topDurationSampleSize = Math.max( 3, Math.min(rows.length, Math.ceil(rows.length * 0.35)) ); const topDurations = rows .map((row) => row.durationSeconds) .sort((a, b) => b - a) .slice(0, topDurationSampleSize); const baselineDurations = (longEpisodeDurations.length >= 2 ? longEpisodeDurations : topDurations) .slice() .sort((a, b) => a - b); const medianDurationSeconds = baselineDurations[Math.floor(baselineDurations.length / 2)]; if (!Number.isFinite(medianDurationSeconds) || medianDurationSeconds < (10 * 60)) { return { plan, applied: false, reason: 'median_too_short_for_filter', sourceCount: titles.length, resultCount: titles.length }; } const shortThresholdSeconds = Math.max(5 * 60, Math.round(medianDurationSeconds * 0.5)); const keptRows = rows.filter((row) => row.durationSeconds >= shortThresholdSeconds); if (keptRows.length < 2 || keptRows.length >= rows.length) { return { plan, applied: false, reason: 'no_relevant_short_titles', sourceCount: titles.length, resultCount: titles.length }; } const keepIdSet = new Set(keptRows.map((row) => Number(row.id))); const filteredTitles = titles .filter((title) => keepIdSet.has(Number(title?.id))) .map((title, index) => ({ ...title, selectedByMinLength: true, eligibleForEncode: true, selectedForEncode: true, encodeInput: index === 0 })); const selectedTitleIds = normalizeReviewTitleIdList(filteredTitles.map((title) => title?.id)); const encodeInputTitleId = selectedTitleIds[0] || null; const selectedEncodeTitle = filteredTitles.find((title) => Number(title?.id) === Number(encodeInputTitleId)) || filteredTitles[0] || null; const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' ? plan.episodeAssignments : {}; const filteredAssignments = {}; for (const titleId of selectedTitleIds) { const assignment = assignmentMap[String(titleId)] || assignmentMap[titleId] || null; if (!assignment || typeof assignment !== 'object') { continue; } filteredAssignments[String(titleId)] = assignment; } const notes = Array.isArray(plan.notes) ? plan.notes : []; const filterNotePrefix = 'Serien-Kurztitel-Filter aktiv:'; const nextNotes = notes.some((entry) => String(entry || '').startsWith(filterNotePrefix)) ? notes : [ ...notes, `${filterNotePrefix} ${filteredTitles.length}/${titles.length} Titel behalten (Schwelle ${formatDurationClock(shortThresholdSeconds)}).` ]; return { plan: { ...plan, titles: filteredTitles, selectedTitleIds, encodeInputTitleId, encodeInputPath: String( selectedEncodeTitle?.filePath || selectedEncodeTitle?.path || plan.encodeInputPath || '' ).trim() || null, titleSelectionRequired: false, episodeAssignments: filteredAssignments, notes: nextNotes }, applied: true, reason: 'filtered_short_titles_by_median', sourceCount: titles.length, resultCount: filteredTitles.length, shortThresholdSeconds }; } function resolveSeriesSinglePlayAllFallback(seriesCandidates = [], allTitles = [], selectedMetadata = null, options = {}) { const episodeCountFromMetadata = normalizePositiveInteger( selectedMetadata?.episodeCount ?? (Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes.length : null) ) || 0; if (episodeCountFromMetadata < 2) { return { applied: false, reason: 'insufficient_episode_metadata' }; } const normalizedCandidates = (Array.isArray(seriesCandidates) ? seriesCandidates : []) .map((item) => { const handBrakeTitleId = normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id); const durationSeconds = Number(item?.durationSeconds || 0); return { raw: item, handBrakeTitleId, durationSeconds }; }) .filter((item) => item.handBrakeTitleId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); if (normalizedCandidates.length < 2) { return { applied: false, reason: 'insufficient_candidates' }; } const sortedByDuration = normalizedCandidates .slice() .sort((a, b) => b.durationSeconds - a.durationSeconds || a.handBrakeTitleId - b.handBrakeTitleId); const longest = sortedByDuration[0]; const second = sortedByDuration[1]; if (!longest || !second) { return { applied: false, reason: 'missing_duration_anchor' }; } const minLongestSeconds = Math.max(20 * 60, Math.round(Number(options?.minLongestSeconds) || (45 * 60))); if (longest.durationSeconds < minLongestSeconds) { return { applied: false, reason: 'longest_title_too_short' }; } const requiredLongestRatio = Math.max(1.8, Number(options?.minLongestVsSecondRatio) || 3.0); if (longest.durationSeconds < second.durationSeconds * requiredLongestRatio) { return { applied: false, reason: 'longest_not_dominant' }; } const expectedEpisodeSeconds = longest.durationSeconds / episodeCountFromMetadata; if (!Number.isFinite(expectedEpisodeSeconds) || expectedEpisodeSeconds < (15 * 60) || expectedEpisodeSeconds > (95 * 60)) { return { applied: false, reason: 'episode_runtime_implausible', episodeCountFromMetadata, expectedEpisodeSeconds }; } const shortThresholdSeconds = Math.max(10 * 60, Math.round(expectedEpisodeSeconds * 0.35)); const restRows = sortedByDuration.slice(1); if (restRows.length === 0) { return { applied: false, reason: 'no_short_candidates' }; } const hasOnlyShortRest = restRows.every((row) => row.durationSeconds <= shortThresholdSeconds); if (!hasOnlyShortRest) { return { applied: false, reason: 'rest_contains_non_short_title', shortThresholdSeconds }; } const restDurationSum = restRows.reduce((sum, row) => sum + Number(row.durationSeconds || 0), 0); if (restDurationSum >= (longest.durationSeconds * 0.55)) { return { applied: false, reason: 'short_titles_sum_too_large', restDurationSum }; } const allRows = (Array.isArray(allTitles) ? allTitles : []) .map((item) => ({ handBrakeTitleId: normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id), durationSeconds: Number(item?.durationSeconds || 0) })) .filter((item) => item.handBrakeTitleId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); const allRowsById = new Map(allRows.map((item) => [Number(item.handBrakeTitleId), item])); return { applied: true, reason: 'single_playall_plus_short_extras', episodeCountFromMetadata, expectedEpisodeSeconds, shortThresholdSeconds, keptTitleId: longest.handBrakeTitleId, keptDurationSeconds: longest.durationSeconds, removedTitleIds: restRows.map((row) => row.handBrakeTitleId), removedCount: restRows.length, removedDurationSumSeconds: restDurationSum, longestVsSecondRatio: Number((longest.durationSeconds / Math.max(1, second.durationSeconds)).toFixed(2)), longestChapterCount: normalizePositiveInteger( allRowsById.get(Number(longest.handBrakeTitleId))?.chapterCount ?? longest?.raw?.chapterCount ?? null ) || null, candidates: [longest.raw] }; } function resolveSeriesPlayAllDoubleEpisodeDecision(seriesCandidates = [], allTitles = [], options = {}) { const normalizedSeriesCandidates = (Array.isArray(seriesCandidates) ? seriesCandidates : []) .map((item) => ({ handBrakeTitleId: normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id), durationSeconds: Number(item?.durationSeconds || 0) })) .filter((item) => item.handBrakeTitleId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); if (normalizedSeriesCandidates.length < 2) { return { required: false, reason: 'insufficient_series_candidates' }; } const allRows = (Array.isArray(allTitles) ? allTitles : []) .map((item) => ({ handBrakeTitleId: normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id), durationSeconds: Number(item?.durationSeconds || 0) })) .filter((item) => item.handBrakeTitleId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0); if (allRows.length === 0) { return { required: false, reason: 'all_titles_missing' }; } const selectedIdSet = new Set(normalizedSeriesCandidates.map((item) => Number(item.handBrakeTitleId))); const nonSelectedRows = allRows .filter((item) => !selectedIdSet.has(Number(item.handBrakeTitleId))) .sort((a, b) => b.durationSeconds - a.durationSeconds || a.handBrakeTitleId - b.handBrakeTitleId); if (nonSelectedRows.length === 0) { return { required: false, reason: 'no_non_selected_title' }; } const longestCandidate = nonSelectedRows[0]; const selectedDurationSumSeconds = normalizedSeriesCandidates .reduce((sum, item) => sum + Number(item.durationSeconds || 0), 0); const longestSelectedDurationSeconds = normalizedSeriesCandidates .reduce((maxValue, item) => Math.max(maxValue, Number(item.durationSeconds || 0)), 0); if (!selectedDurationSumSeconds || !longestSelectedDurationSeconds) { return { required: false, reason: 'duration_data_missing' }; } const minToleranceSeconds = Number(options?.minToleranceSeconds); const ratioTolerance = Number(options?.ratioTolerance); const minDominanceRatio = Number(options?.minDominanceRatio); const maxAutoEpisodeCandidates = Number(options?.maxAutoEpisodeCandidates); const toleranceSeconds = Math.max( Number.isFinite(minToleranceSeconds) && minToleranceSeconds > 0 ? Math.trunc(minToleranceSeconds) : 120, Math.round( selectedDurationSumSeconds * ( Number.isFinite(ratioTolerance) && ratioTolerance > 0 ? ratioTolerance : 0.08 ) ) ); const durationDeltaSeconds = Math.abs(longestCandidate.durationSeconds - selectedDurationSumSeconds); const withinTolerance = durationDeltaSeconds <= toleranceSeconds; const longestVsEpisodeRatio = longestCandidate.durationSeconds / Math.max(1, longestSelectedDurationSeconds); const dominanceThreshold = Number.isFinite(minDominanceRatio) && minDominanceRatio > 0 ? minDominanceRatio : 1.4; const hasDominantLongestCandidate = longestVsEpisodeRatio >= dominanceThreshold; const maxAutoCandidates = Number.isFinite(maxAutoEpisodeCandidates) && maxAutoEpisodeCandidates > 0 ? Math.trunc(maxAutoEpisodeCandidates) : 2; const isBoundaryEpisodeCount = normalizedSeriesCandidates.length <= maxAutoCandidates; const required = withinTolerance && hasDominantLongestCandidate && isBoundaryEpisodeCount; return { required, reason: required ? 'playall_or_double_episode_boundary' : 'not_boundary_case', defaultSelectedTitleIds: normalizeReviewTitleIdList(normalizedSeriesCandidates.map((item) => item.handBrakeTitleId)), longestCandidateTitleId: longestCandidate.handBrakeTitleId, selectedDurationSumSeconds, longestCandidateDurationSeconds: longestCandidate.durationSeconds, durationDeltaSeconds, toleranceSeconds, longestVsEpisodeRatio: Number(longestVsEpisodeRatio.toFixed(3)) }; } 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 strictHandBrakeTitleId = Boolean(options?.strictHandBrakeTitleId && preferredHandBrakeTitleId); 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 && strictHandBrakeTitleId) { return 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 buildSelectedHandBrakeReviewTitles(scanJson, selectedHandBrakeTitleIds = [], options = {}) { const normalizedTitleIds = normalizeReviewTitleIdList(selectedHandBrakeTitleIds) .sort((a, b) => Number(a) - Number(b)); if (normalizedTitleIds.length === 0) { return []; } const encodeInputPath = String(options?.encodeInputPath || '').trim() || null; const selectedSet = new Set(normalizedTitleIds.map((id) => Number(id))); const resolvedTitleIds = []; const titles = []; for (const handBrakeTitleId of normalizedTitleIds) { const titleInfo = parseHandBrakeSelectedTitleInfo(scanJson, { handBrakeTitleId, strictHandBrakeTitleId: true }); if (!titleInfo) { continue; } const normalizedTitleId = normalizeReviewTitleId(titleInfo?.handBrakeTitleId); if (!normalizedTitleId) { continue; } resolvedTitleIds.push(normalizedTitleId); const selectedForEncode = selectedSet.has(normalizedTitleId); titles.push({ id: normalizedTitleId, filePath: encodeInputPath || `disc-track-scan://title-${normalizedTitleId}`, fileName: titleInfo?.fileName || `Title #${normalizedTitleId}`, makemkvTitleId: null, sizeBytes: Number(titleInfo?.sizeBytes || 0) || 0, durationSeconds: Number(titleInfo?.durationSeconds || 0) || 0, durationMinutes: Number(((Number(titleInfo?.durationSeconds || 0) || 0) / 60).toFixed(2)), selectedByMinLength: true, eligibleForEncode: true, selectedForEncode, encodeInput: selectedForEncode, playlistId: titleInfo?.playlistId || null, playlistFile: titleInfo?.playlistId ? `${titleInfo.playlistId}.mpls` : null, playlistRecommended: false, playlistEvaluationLabel: null, playlistSegmentCommand: null, playlistSegmentFiles: [], audioTracks: (Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []).map((track) => ({ ...track, id: normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || track?.id || null, sourceTrackId: normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || track?.sourceTrackId || track?.id || null, selectedByRule: true, selectedForEncode, encodePreviewActions: [], encodePreviewSummary: 'Übernehmen', encodeActions: [], encodeActionSummary: selectedForEncode ? 'Übernehmen' : 'Nicht übernommen' })), subtitleTracks: (Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : []).map((track) => ({ ...track, id: normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || track?.id || null, sourceTrackId: normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || track?.sourceTrackId || track?.id || null, selectedByRule: true, selectedForEncode, subtitlePreviewSummary: 'Übernehmen', subtitlePreviewFlags: [], subtitlePreviewBurnIn: false, subtitlePreviewForced: false, subtitlePreviewForcedOnly: false, subtitlePreviewDefaultTrack: false, burnIn: false, forced: false, forcedOnly: false, defaultTrack: false, flags: [], subtitleActionSummary: selectedForEncode ? 'Übernehmen' : 'Nicht übernommen' })) }); } return titles; } function collectAlternativeFeatureReviewCandidates({ playlistCandidates = [], handBrakePlaylistScan = null, playlistAnalysis = null, selectedPlaylistId = null, selectedHandBrakeTitleId = null, selectedTitleInfo = null, makeMkvSubtitleTracks = [] }) { const normalizedSelectedPlaylistId = normalizePlaylistId(selectedPlaylistId); const normalizedSelectedHandBrakeTitleId = normalizeReviewTitleId(selectedHandBrakeTitleId); const normalizedCache = normalizeHandBrakePlaylistScanCache(handBrakePlaylistScan); const baselineDurationSeconds = Number(selectedTitleInfo?.durationSeconds || 0) || 0; const baselineSizeBytes = Number(selectedTitleInfo?.sizeBytes || 0) || 0; const candidateRows = Array.isArray(playlistCandidates) ? playlistCandidates : []; const candidateMap = new Map(); for (const row of candidateRows) { const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || null); if (!playlistId || candidateMap.has(playlistId)) { continue; } candidateMap.set(playlistId, row); } const resolved = []; const seenKeys = new Set(); const pushCandidate = (playlistIdRaw, titleInfoRaw, sourceRow = null) => { const playlistId = normalizePlaylistId(playlistIdRaw || titleInfoRaw?.playlistId || null); const titleInfo = enrichTitleInfoWithForcedSubtitleAvailability( titleInfoRaw, makeMkvSubtitleTracks ); const handBrakeTitleId = normalizeReviewTitleId( titleInfo?.handBrakeTitleId || sourceRow?.handBrakeTitleId || null ); if (!playlistId || !titleInfo || !handBrakeTitleId) { return; } const durationSeconds = Number(titleInfo?.durationSeconds || sourceRow?.durationSeconds || 0) || 0; const sizeBytes = Number(titleInfo?.sizeBytes || sourceRow?.sizeBytes || 0) || 0; const isSelectedDefault = ( (normalizedSelectedPlaylistId && playlistId === normalizedSelectedPlaylistId) || (normalizedSelectedHandBrakeTitleId && handBrakeTitleId === normalizedSelectedHandBrakeTitleId) ); if (!isSelectedDefault && durationSeconds < baselineDurationSeconds) { return; } const dedupeKey = `${playlistId}:${handBrakeTitleId}`; if (seenKeys.has(dedupeKey)) { return; } seenKeys.add(dedupeKey); resolved.push({ playlistId, handBrakeTitleId, titleInfo, sourceRow, durationSeconds, sizeBytes, playlistInfo: resolvePlaylistInfoFromAnalysis(playlistAnalysis, playlistId), isSelectedDefault }); }; if (normalizedSelectedPlaylistId && selectedTitleInfo) { pushCandidate( normalizedSelectedPlaylistId, selectedTitleInfo, candidateMap.get(normalizedSelectedPlaylistId) || null ); } if (normalizedCache?.byPlaylist && typeof normalizedCache.byPlaylist === 'object') { for (const [playlistId, cacheEntry] of Object.entries(normalizedCache.byPlaylist)) { pushCandidate( playlistId, cacheEntry?.titleInfo || null, candidateMap.get(playlistId) || null ); } } for (const row of candidateRows) { const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || null); if (!playlistId) { continue; } const cacheEntry = normalizedCache?.byPlaylist?.[playlistId] || null; pushCandidate( playlistId, cacheEntry?.titleInfo || null, row ); } const byPriority = resolved .slice() .sort((left, right) => { if (left.isSelectedDefault !== right.isSelectedDefault) { return left.isSelectedDefault ? -1 : 1; } return left.durationSeconds - right.durationSeconds || left.sizeBytes - right.sizeBytes || String(left.playlistId || '').localeCompare(String(right.playlistId || '')) || left.handBrakeTitleId - right.handBrakeTitleId; }); if (byPriority.length === 0 && normalizedSelectedPlaylistId && selectedTitleInfo) { return [{ playlistId: normalizedSelectedPlaylistId, handBrakeTitleId: normalizedSelectedHandBrakeTitleId || null, titleInfo: enrichTitleInfoWithForcedSubtitleAvailability(selectedTitleInfo, makeMkvSubtitleTracks), sourceRow: candidateMap.get(normalizedSelectedPlaylistId) || null, durationSeconds: baselineDurationSeconds, sizeBytes: baselineSizeBytes, playlistInfo: resolvePlaylistInfoFromAnalysis(playlistAnalysis, normalizedSelectedPlaylistId), isSelectedDefault: true }]; } return byPriority; } 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, disableMinLengthFilter = false }) { const minLengthMinutes = disableMinLengthFilter ? 0 : 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, playlistAliases: normalizePlaylistAliasIds(title.playlistMatch?.playlistAliases, title.playlistMatch?.playlistId), 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, options = {}) { const opts = options && typeof options === 'object' ? options : {}; const normalizedJobId = Number(fallbackJobId || jobLike?.id || 0); const fallbackTitle = Number.isFinite(normalizedJobId) && normalizedJobId > 0 ? `job-${Math.trunc(normalizedJobId)}` : 'job-unknown'; const analyzeContext = opts.analyzeContext && typeof opts.analyzeContext === 'object' ? opts.analyzeContext : null; const selectedMetadata = opts.selectedMetadata && typeof opts.selectedMetadata === 'object' ? opts.selectedMetadata : resolveSelectedMetadataForJob(jobLike, analyzeContext, null); const mediaProfile = normalizeMediaProfile( opts.mediaProfile || jobLike?.media_type || analyzeContext?.mediaProfile || null ); const workflowKind = normalizeDvdMetadataWorkflowKind( opts.workflowKind ?? selectedMetadata?.workflowKind ?? analyzeContext?.workflowKind ?? analyzeContext?.selectedMetadata?.workflowKind ?? null ); const jobKind = String(jobLike?.job_kind || '').trim().toLowerCase(); const isMultipartMovie = opts.isMultipartMovie === true || Number(jobLike?.is_multipart_movie || 0) === 1 || jobKind === 'multipart_movie_child' || jobKind === 'multipart_movie_container'; const isSeriesDisc = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); const isDiscFilm = isSeriesDiscMediaProfile(mediaProfile) && workflowKind === 'film'; const seasonNumber = normalizePositiveInteger( opts.seriesSeasonNumber ?? selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null ); const discNumber = normalizePositiveInteger( opts.seriesDiscNumber ?? selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null ); const rawYear = Number( opts.year ?? selectedMetadata?.year ?? jobLike?.year ?? jobLike?.fallbackYear ?? null ); const yearValue = Number.isFinite(rawYear) && rawYear > 0 ? Math.trunc(rawYear) : new Date().getFullYear(); if (mediaProfile === 'cd') { const cdArtist = normalizeCdTrackText( opts.artist ?? selectedMetadata?.artist ?? jobLike?.artist ?? '' ); const cdAlbum = normalizeCdTrackText( opts.album ?? selectedMetadata?.album ?? selectedMetadata?.title ?? jobLike?.title ?? jobLike?.detected_title ?? fallbackTitle ); // CD-RAW-Ordner sollen immer "INTERPRET - ALBUM (YEAR)" enthalten. // Falls ein Teil fehlt, wird mit dem jeweils vorhandenen Teil/Fallback gefuellt. const resolvedArtist = cdArtist || cdAlbum || fallbackTitle; const resolvedAlbum = cdAlbum || cdArtist || fallbackTitle; return sanitizeFileName(`${resolvedArtist} - ${resolvedAlbum} (${yearValue})`); } if (isSeriesDisc && seasonNumber && discNumber) { const seriesTitle = normalizeCdTrackText( opts.seriesTitle || selectedMetadata?.title || selectedMetadata?.seriesTitle || jobLike?.title || jobLike?.detected_title || fallbackTitle ) || fallbackTitle; return sanitizeFileName(`${seriesTitle} (${yearValue}) - S${seasonNumber}D${discNumber}`); } if (isDiscFilm && isMultipartMovie && discNumber) { const movieTitle = normalizeCdTrackText( selectedMetadata?.title || jobLike?.title || jobLike?.detected_title || jobLike?.detectedTitle || fallbackTitle ) || fallbackTitle; return sanitizeFileName(`${movieTitle} (${yearValue}) - D${discNumber}`); } 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 ''; } const cleaned = rawName .replace(RAW_STATE_PREFIX_CLEANUP_PATTERN, '') .replace(/^[_-]+/, '') .trim(); return cleaned; } function extractRawFolderJobId(folderName) { const rawName = stripRawStatePrefix(path.basename(String(folderName || '').trim())); if (!rawName) { return null; } const match = rawName.match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i); const parsed = Number(match?.[1]); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); } function extractSeriesSeasonDiscFromRawFolderName(folderName) { const baseName = stripRawStatePrefix(path.basename(String(folderName || '').trim())); if (!baseName) { return { seasonNumber: null, discNumber: null }; } const tokenMatch = baseName.match(/\bS(\d{1,3})D(\d{1,3})\b/i); if (!tokenMatch) { return { seasonNumber: null, discNumber: null }; } return { seasonNumber: normalizePositiveInteger(tokenMatch[1]), discNumber: normalizePositiveInteger(tokenMatch[2]) }; } function extractMultipartFilmDiscFromRawFolderName(folderName) { const baseName = stripRawStatePrefix(path.basename(String(folderName || '').trim())); if (!baseName) { return null; } const withoutJobSuffix = baseName.replace(/(?:\s*-\s*RAW\s*-\s*job-\d+\s*)+$/i, '').trim(); if (!withoutJobSuffix) { return null; } const match = withoutJobSuffix.match(/\s-\sD(\d{1,3})\s*$/i); return normalizePositiveInteger(match?.[1] || null); } function resolveMultipartDiscNumberPair(options = {}) { const preferredOrphanDiscNumber = normalizePositiveInteger(options?.preferredOrphanDiscNumber); const preferredCurrentDiscNumber = normalizePositiveInteger(options?.preferredCurrentDiscNumber); const orphanDiscNumber = preferredOrphanDiscNumber || 1; let currentDiscNumber = preferredCurrentDiscNumber || (orphanDiscNumber === 1 ? 2 : 1); if (currentDiscNumber === orphanDiscNumber) { currentDiscNumber = orphanDiscNumber === 1 ? 2 : 1; while (currentDiscNumber === orphanDiscNumber) { currentDiscNumber += 1; } } return { orphanDiscNumber, currentDiscNumber }; } function normalizeMultipartMovieSelectedMetadata(baseSelectedMetadata = {}, discNumber = null) { const source = baseSelectedMetadata && typeof baseSelectedMetadata === 'object' ? baseSelectedMetadata : {}; const normalizedDiscNumber = normalizePositiveInteger(discNumber); return { ...source, workflowKind: 'film', metadataProvider: 'tmdb', metadataKind: 'movie', providerId: null, tmdbId: null, seasonNumber: null, seasonName: null, episodeCount: 0, episodes: [], discNumber: normalizedDiscNumber || null }; } 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_|Rip[_-]?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 resolveIncompleteDirectoryFromOutputPath(outputPath, jobId = null) { const normalizedOutputPath = normalizeComparablePath(outputPath); if (!normalizedOutputPath) { return null; } const normalizedJobId = normalizePositiveInteger(jobId); const expectedFolderPattern = normalizedJobId ? new RegExp(`^incomplete_job-${normalizedJobId}\\s*$`, 'i') : /^incomplete_job-\d+\s*$/i; let currentPath = normalizedOutputPath; let remainingDepth = 32; while (currentPath && remainingDepth > 0) { const folderName = String(path.basename(currentPath) || '').trim(); if (expectedFolderPattern.test(folderName)) { return currentPath; } const parentPath = normalizeComparablePath(path.dirname(currentPath)); if (!parentPath || parentPath === currentPath) { break; } currentPath = parentPath; remainingDepth -= 1; } return null; } 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 : {}; const aliasMap = playlistAnalysis?.playlistAliasMap && typeof playlistAnalysis.playlistAliasMap === 'object' ? playlistAnalysis.playlistAliasMap : {}; 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 playlistAliases = normalizePlaylistAliasIds( Array.isArray(source?.playlistAliases) && source.playlistAliases.length > 0 ? source.playlistAliases : aliasMap[playlistId], playlistId ); 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), playlistAliases, 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, playlistAliases: normalizePlaylistAliasIds(row?.playlistAliases, playlistId) }); } 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 normalizeReviewTitleIdList(rawValues) { const values = Array.isArray(rawValues) ? rawValues : []; const seen = new Set(); const normalized = []; for (const value of values) { const id = normalizeReviewTitleId(value); if (!id || seen.has(id)) { continue; } seen.add(id); normalized.push(id); } return normalized; } function resolveSeriesBatchSelectedTitleIdsFromPlan(parentPlan = null) { const plan = parentPlan && typeof parentPlan === 'object' ? parentPlan : {}; const explicitSelectedTitleIds = normalizeReviewTitleIdList( Array.isArray(plan?.selectedTitleIds) ? plan.selectedTitleIds : [plan?.encodeInputTitleId] ); if (explicitSelectedTitleIds.length > 1) { return explicitSelectedTitleIds; } const titles = Array.isArray(plan?.titles) ? plan.titles : []; const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' ? plan.episodeAssignments : {}; const assignmentTitleIds = normalizeReviewTitleIdList(Object.keys(assignmentMap)); const episodeHintTitleIds = normalizeReviewTitleIdList( titles .filter((title) => { if (Boolean(title?.selectedForEncode || title?.encodeInput)) { return true; } const hasEpisodeStartHint = normalizePositiveInteger( title?.episodeNumberStart ?? title?.episodeNumber ?? title?.episodeIdStart ?? title?.episodeId ?? null ); if (hasEpisodeStartHint) { return true; } const hasEpisodeEndHint = normalizePositiveInteger( title?.episodeNumberEnd ?? title?.episodeIdEnd ?? null ); if (hasEpisodeEndHint) { return true; } return Boolean(String(title?.episodeTitle || '').trim()); }) .map((title) => title?.id) ); const mergedFallbackTitleIds = normalizeReviewTitleIdList([ ...explicitSelectedTitleIds, ...assignmentTitleIds, ...episodeHintTitleIds ]); return mergedFallbackTitleIds.length > 0 ? mergedFallbackTitleIds : explicitSelectedTitleIds; } function normalizeEpisodeNumberValue(value) { const numeric = Number(String(value ?? '').trim()); if (!Number.isFinite(numeric) || numeric <= 0) { return null; } const rounded = Math.round(numeric * 100) / 100; return rounded; } function normalizeEpisodeAssignmentsPayload(rawAssignments, selectedTitleIds = []) { const selectedSet = new Set( normalizeReviewTitleIdList(selectedTitleIds).map((id) => Number(id)) ); const assignmentEntries = Array.isArray(rawAssignments) ? rawAssignments .map((entry) => { const titleId = normalizeReviewTitleId(entry?.titleId ?? entry?.id ?? null); if (!titleId) { return null; } return [titleId, entry]; }) .filter(Boolean) : (rawAssignments && typeof rawAssignments === 'object' ? Object.entries(rawAssignments) : []); if (assignmentEntries.length === 0) { return {}; } const output = {}; for (const [rawTitleId, rawAssignment] of assignmentEntries) { const titleId = normalizeReviewTitleId(rawTitleId); if (!titleId) { continue; } if (selectedSet.size > 0 && !selectedSet.has(Number(titleId))) { continue; } const assignment = rawAssignment && typeof rawAssignment === 'object' ? rawAssignment : {}; const episodeIdRaw = Number(assignment?.episodeId ?? assignment?.id ?? 0); const episodeId = Number.isFinite(episodeIdRaw) && episodeIdRaw > 0 ? Math.trunc(episodeIdRaw) : null; const episodeIdStartRaw = Number( assignment?.episodeIdStart ?? assignment?.episodeStartId ?? assignment?.episode_start_id ?? 0 ); const episodeIdStart = Number.isFinite(episodeIdStartRaw) && episodeIdStartRaw > 0 ? Math.trunc(episodeIdStartRaw) : null; const episodeIdEndRaw = Number( assignment?.episodeIdEnd ?? assignment?.episodeEndId ?? assignment?.episode_end_id ?? 0 ); const episodeIdEnd = Number.isFinite(episodeIdEndRaw) && episodeIdEndRaw > 0 ? Math.trunc(episodeIdEndRaw) : null; const baseEpisodeNumber = normalizeEpisodeNumberValue(assignment?.episodeNumber ?? assignment?.number ?? null); const episodeNumberStart = normalizeEpisodeNumberValue( assignment?.episodeNumberStart ?? assignment?.episodeNoStart ?? assignment?.episode_start ?? baseEpisodeNumber ?? null ); let episodeNumberEnd = normalizeEpisodeNumberValue( assignment?.episodeNumberEnd ?? assignment?.episodeNoEnd ?? assignment?.episode_end ?? null ); const explicitSpan = normalizePositiveInteger( assignment?.episodeSpan ?? assignment?.episodeCount ?? null ); if (episodeNumberEnd === null && episodeNumberStart !== null && explicitSpan && explicitSpan > 1) { episodeNumberEnd = episodeNumberStart + explicitSpan - 1; } if (episodeNumberEnd !== null && episodeNumberStart !== null && episodeNumberEnd < episodeNumberStart) { episodeNumberEnd = episodeNumberStart; } const episodeNumber = episodeNumberStart ?? baseEpisodeNumber; const normalizedEpisodeSpan = ( episodeNumberStart !== null && episodeNumberEnd !== null && episodeNumberEnd >= episodeNumberStart ) ? Math.max(1, Math.trunc(episodeNumberEnd - episodeNumberStart + 1)) : ( explicitSpan && explicitSpan > 0 ? explicitSpan : null ); const episodeRange = String(assignment?.episodeRange || '').trim() || null; const seasonNumber = normalizePositiveInteger(assignment?.seasonNumber ?? assignment?.season ?? null); const rawEpisodeTitle = String( assignment?.episodeTitle ?? assignment?.title ?? assignment?.name ?? '' ).trim() || null; const episodeTitleStart = String(assignment?.episodeTitleStart || '').trim() || null; const episodeTitleEnd = String(assignment?.episodeTitleEnd || '').trim() || null; if ( !episodeId && !episodeIdStart && !episodeIdEnd && episodeNumber === null && episodeNumberStart === null && episodeNumberEnd === null && seasonNumber === null && !rawEpisodeTitle && !episodeTitleStart && !episodeTitleEnd ) { continue; } const rangeStart = episodeNumberStart ?? episodeNumber ?? 1; const rangeEnd = episodeNumberEnd ?? episodeNumberStart ?? episodeNumber ?? rangeStart; const episodeTitle = rawEpisodeTitle ? normalizeSeriesEpisodeTitleForOutput( rawEpisodeTitle, { start: rangeStart, end: rangeEnd, isMulti: (normalizedEpisodeSpan || 1) > 1 } ) : null; output[String(titleId)] = { titleId, episodeId: episodeIdStart || episodeId || null, episodeNumber, episodeNumberStart: episodeNumberStart ?? episodeNumber ?? null, episodeNumberEnd: episodeNumberEnd ?? episodeNumberStart ?? episodeNumber ?? null, episodeSpan: normalizedEpisodeSpan, episodeRange, seasonNumber, episodeTitle, episodeIdStart: episodeIdStart || episodeId || null, episodeIdEnd, episodeTitleStart, episodeTitleEnd }; } return output; } function isSeriesBatchParentPlan(rawPlan) { const plan = rawPlan && typeof rawPlan === 'object' ? rawPlan : null; if (!plan) { return false; } return Boolean(plan.seriesBatchParent) && !Boolean(plan.seriesBatchChild); } function isSeriesBatchChildPlan(rawPlan) { const plan = rawPlan && typeof rawPlan === 'object' ? rawPlan : null; if (!plan) { return false; } return Boolean(plan.seriesBatchChild); } function resolveSeriesBatchParentJobIdFromPlan(rawPlan, fallbackParentJobId = null) { const plan = rawPlan && typeof rawPlan === 'object' ? rawPlan : null; if (!plan) { return normalizePositiveInteger(fallbackParentJobId); } const fromPlan = normalizePositiveInteger( plan.seriesBatchParentJobId || plan.parentJobId || null ); if (fromPlan) { return fromPlan; } return normalizePositiveInteger(fallbackParentJobId); } function isTerminalStatus(status) { const normalized = String(status || '').trim().toUpperCase(); return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED'; } function normalizeSeriesEpisodeStatus(status, fallback = 'QUEUED') { const normalized = String(status || '').trim().toUpperCase(); if (['QUEUED', 'RUNNING', 'FINISHED', 'ERROR', 'CANCELLED'].includes(normalized)) { return normalized; } return String(fallback || 'QUEUED').trim().toUpperCase() || 'QUEUED'; } function buildSeriesBatchEpisodesFromPlan(parentJob, parentPlan, titleIds = []) { const plan = parentPlan && typeof parentPlan === 'object' ? parentPlan : {}; const normalizedTitleIds = normalizeReviewTitleIdList(titleIds); const existingEpisodes = Array.isArray(plan?.seriesBatchEpisodes) ? plan.seriesBatchEpisodes : []; const completedTitleIdSet = new Set( normalizeReviewTitleIdList([ ...(Array.isArray(plan?.seriesBatchCompletedTitleIds) ? plan.seriesBatchCompletedTitleIds : []), ...existingEpisodes .filter((entry) => normalizeSeriesEpisodeStatus(entry?.status, 'QUEUED') === 'FINISHED') .map((entry) => entry?.titleId) ]).map((value) => Number(value)) ); const existingByTitleId = new Map(); for (const entry of existingEpisodes) { const titleId = normalizeReviewTitleId(entry?.titleId); if (!titleId) { continue; } existingByTitleId.set(Number(titleId), entry); } const nextEpisodes = []; for (let index = 0; index < normalizedTitleIds.length; index += 1) { const titleId = normalizedTitleIds[index]; const existing = existingByTitleId.get(Number(titleId)) || null; const label = resolveSeriesBatchChildDisplayTitle(parentJob, plan, titleId, index); const parsedProgress = Number(existing?.progress); const rawStatus = normalizeSeriesEpisodeStatus(existing?.status, 'QUEUED'); const status = ( completedTitleIdSet.has(Number(titleId)) && rawStatus !== 'ERROR' && rawStatus !== 'CANCELLED' ) ? 'FINISHED' : rawStatus; const progress = status === 'FINISHED' ? 100 : ( Number.isFinite(parsedProgress) ? Math.max(0, Math.min(100, parsedProgress)) : 0 ); nextEpisodes.push({ episodeIndex: index + 1, titleId: Number(titleId), jobId: normalizePositiveInteger(existing?.jobId) || null, label, status, progress, eta: existing?.eta ?? null, startedAt: existing?.startedAt || null, finishedAt: existing?.finishedAt || null, outputPath: existing?.outputPath || null, error: existing?.error || null, trackSelection: existing?.trackSelection && typeof existing.trackSelection === 'object' ? existing.trackSelection : null, handbrakeInfo: existing?.handbrakeInfo && typeof existing.handbrakeInfo === 'object' ? existing.handbrakeInfo : null }); } return nextEpisodes; } function buildSeriesBatchProgressFromEpisodes(parentJobId, episodes = []) { const rows = Array.isArray(episodes) ? episodes : []; const totalCount = rows.length; let finishedCount = 0; let cancelledCount = 0; let errorCount = 0; let runningCount = 0; let aggregateProgress = 0; const children = []; for (const entry of rows) { const status = normalizeSeriesEpisodeStatus(entry?.status, 'QUEUED'); const progressRaw = Number(entry?.progress); const progress = status === 'FINISHED' ? 100 : ( Number.isFinite(progressRaw) ? Math.max(0, Math.min(100, progressRaw)) : 0 ); if (status === 'FINISHED') { finishedCount += 1; } else if (status === 'ERROR') { errorCount += 1; } else if (status === 'CANCELLED') { cancelledCount += 1; } else if (status === 'RUNNING') { runningCount += 1; } aggregateProgress += (status === 'FINISHED' ? 100 : progress); children.push({ jobId: normalizePositiveInteger(entry?.jobId) || Number(parentJobId), title: String(entry?.label || `Episode #${entry?.episodeIndex || children.length + 1}`).trim(), status, progress: Number(progress.toFixed(2)), eta: entry?.eta ?? null, episodeIndex: normalizePositiveInteger(entry?.episodeIndex) || null, titleId: normalizeReviewTitleId(entry?.titleId) || null, outputPath: entry?.outputPath || null, error: entry?.error || null }); } const overallProgress = totalCount > 0 ? Number((aggregateProgress / totalCount).toFixed(2)) : 0; const summaryText = `Serien-Encoding: ${finishedCount}/${totalCount} abgeschlossen${errorCount > 0 ? ` | Fehler: ${errorCount}` : ''}${cancelledCount > 0 ? ` | Abgebrochen: ${cancelledCount}` : ''}`; return { parentJobId: Number(parentJobId), totalCount, finishedCount, cancelledCount, errorCount, runningCount, overallProgress, summaryText, children }; } function resolveSeriesBatchChildDisplayTitle(parentJob, parentPlan, titleId, childIndex = 0) { const plan = parentPlan && typeof parentPlan === 'object' ? parentPlan : {}; const titles = Array.isArray(plan?.titles) ? plan.titles : []; const selectedTitle = titles.find((title) => Number(title?.id) === Number(titleId)) || null; const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' ? plan.episodeAssignments : {}; const assignment = assignmentMap[String(titleId)] || assignmentMap[titleId] || null; const baseTitle = String( parentJob?.title || parentJob?.detected_title || 'Serie' ).trim() || 'Serie'; const seasonNumber = normalizePositiveInteger( assignment?.seasonNumber ?? selectedTitle?.seasonNumber ?? null ); const episodeRange = resolveSeriesEpisodeRangeFromAssignment( assignment, selectedTitle, childIndex + 1, { allowSelectedTitleEpisodeNumber: hasExplicitSeriesEpisodeAssignmentRange(assignment) } ); const fallbackEpisodeLabel = `Folge ${buildSeriesEpisodeRangeToken(episodeRange.start, episodeRange.end)}`; const rawEpisodeLabel = String( assignment?.episodeTitle || selectedTitle?.episodeTitle || fallbackEpisodeLabel ).trim(); const episodeLabel = normalizeSeriesEpisodeTitleForOutput(rawEpisodeLabel, episodeRange) || fallbackEpisodeLabel; const seasonToken = seasonNumber !== null ? String(seasonNumber).padStart(2, '0') : null; const episodeToken = buildSeriesEpisodeRangeToken( episodeRange.start, episodeRange.end ); const code = seasonToken && episodeToken ? `S${seasonToken}E${episodeToken}` : `Episode ${childIndex + 1}`; return `${baseTitle} - ${code}${episodeLabel ? ` - ${episodeLabel}` : ''}`; } function buildSeriesBatchChildPlan(parentPlan, titleId, parentJobId, childIndex, childCount) { const plan = parentPlan && typeof parentPlan === 'object' ? parentPlan : {}; const titles = Array.isArray(plan?.titles) ? plan.titles : []; const selectedTitle = titles.find((title) => Number(title?.id) === Number(titleId)) || null; const selectedTitleIds = [Number(titleId)]; const handBrakeTitleId = normalizeReviewTitleId( selectedTitle?.handBrakeTitleId ?? selectedTitle?.titleIndex ?? titleId ); const remappedTitles = titles.map((title) => { const currentTitleId = normalizeReviewTitleId(title?.id); const selectedForEncode = currentTitleId !== null && Number(currentTitleId) === Number(titleId); return { ...title, selectedForEncode, encodeInput: selectedForEncode }; }); const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' ? plan.episodeAssignments : {}; const selectedAssignment = assignmentMap[String(titleId)] || assignmentMap[titleId] || null; const inferredRangeForChild = resolveSeriesEpisodeRangeForPlanTitle(plan, titleId, childIndex + 1)?.range || null; const selectedAssignmentHasRange = hasExplicitSeriesEpisodeAssignmentRange(selectedAssignment); const resolvedChildAssignment = ( selectedAssignmentHasRange || !inferredRangeForChild ) ? selectedAssignment : { ...(selectedAssignment && typeof selectedAssignment === 'object' ? selectedAssignment : {}), titleId: Number(titleId), episodeNumber: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumber) ?? inferredRangeForChild.start, episodeNumberStart: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumberStart) ?? inferredRangeForChild.start, episodeNumberEnd: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumberEnd) ?? inferredRangeForChild.end, episodeSpan: normalizePositiveInteger(selectedAssignment?.episodeSpan) || Math.max(1, Math.trunc(Number(inferredRangeForChild.end) - Number(inferredRangeForChild.start) + 1)), episodeRange: String(selectedAssignment?.episodeRange || '').trim() || buildSeriesEpisodeRangeToken(inferredRangeForChild.start, inferredRangeForChild.end), seasonNumber: normalizePositiveInteger(selectedAssignment?.seasonNumber) ?? normalizePositiveInteger(selectedTitle?.seasonNumber) ?? null }; return { ...plan, reviewConfirmed: true, reviewConfirmedAt: String(plan?.reviewConfirmedAt || '').trim() || nowIso(), selectedTitleIds, encodeInputTitleId: Number(titleId), encodeInputPath: String( selectedTitle?.filePath || selectedTitle?.path || plan?.encodeInputPath || '' ).trim() || null, handBrakeTitleId: handBrakeTitleId || null, handBrakeTitleIds: handBrakeTitleId ? [handBrakeTitleId] : [], titleSelectionRequired: false, titles: remappedTitles, episodeAssignments: resolvedChildAssignment ? { [String(titleId)]: { ...resolvedChildAssignment, titleId: Number(titleId), filePath: selectedTitle?.filePath || resolvedChildAssignment?.filePath || null } } : {}, seriesBatchParent: false, seriesBatchChild: true, seriesBatchParentJobId: Number(parentJobId), seriesBatchChildIndex: Number(childIndex) + 1, seriesBatchChildCount: Number(childCount) || 1, seriesBatchTitleId: Number(titleId) }; } function applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId, selectedEncodeTitleIds = null) { const normalizedPrimaryTitleId = normalizeReviewTitleId(selectedEncodeTitleId); const normalizedTitleIds = normalizeReviewTitleIdList([ ...(Array.isArray(selectedEncodeTitleIds) ? selectedEncodeTitleIds : []), ...(normalizedPrimaryTitleId ? [normalizedPrimaryTitleId] : []) ]); if (normalizedTitleIds.length === 0) { return { plan: encodePlan, selectedTitle: null, selectedTitleIds: [] }; } const titles = Array.isArray(encodePlan?.titles) ? encodePlan.titles : []; const selectedTitleSet = new Set(normalizedTitleIds.map((id) => Number(id))); const selectedTitles = []; for (const selectedId of normalizedTitleIds) { const selectedTitle = titles.find((item) => Number(item?.id) === selectedId) || null; if (!selectedTitle) { const error = new Error(`Gewählter Titel #${selectedId} 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 #${selectedId} ist laut MIN_LENGTH_MINUTES nicht encodierbar.`); error.statusCode = 400; throw error; } selectedTitles.push(selectedTitle); } const effectivePrimaryTitleId = normalizedPrimaryTitleId || normalizeReviewTitleId(selectedTitles[0]?.id); const selectedTitle = titles.find((item) => Number(item?.id) === effectivePrimaryTitleId) || selectedTitles[0] || null; const remappedTitles = titles.map((title) => { const titleId = Number(title?.id); const isEncodeInput = titleId === effectivePrimaryTitleId; const selectedForEncode = selectedTitleSet.has(titleId); const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => { const selectedByRule = Boolean(track?.selectedByRule); const trackSelectedForEncode = selectedForEncode && selectedByRule; const previewActions = Array.isArray(track?.encodePreviewActions) ? track.encodePreviewActions : []; const previewSummary = track?.encodePreviewSummary || 'Nicht übernommen'; return { ...track, selectedForEncode: trackSelectedForEncode, encodeActions: trackSelectedForEncode ? previewActions : [], encodeActionSummary: trackSelectedForEncode ? previewSummary : 'Nicht übernommen' }; }); const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => { const selectedByRule = Boolean(track?.selectedByRule); const trackSelectedForEncode = selectedForEncode && selectedByRule; const previewFlags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []; const previewSummary = track?.subtitlePreviewSummary || 'Nicht übernommen'; return { ...track, selectedForEncode: trackSelectedForEncode, burnIn: trackSelectedForEncode ? Boolean(track?.subtitlePreviewBurnIn) : false, forced: trackSelectedForEncode ? Boolean(track?.subtitlePreviewForced) : false, forcedOnly: trackSelectedForEncode ? Boolean(track?.subtitlePreviewForcedOnly) : false, defaultTrack: trackSelectedForEncode ? Boolean(track?.subtitlePreviewDefaultTrack) : false, flags: trackSelectedForEncode ? previewFlags : [], subtitleActionSummary: trackSelectedForEncode ? previewSummary : 'Nicht übernommen' }; }); return { ...title, encodeInput: isEncodeInput, selectedForEncode, audioTracks, subtitleTracks }; }); return { plan: { ...encodePlan, titles: remappedTitles, selectedTitleIds: normalizedTitleIds, encodeInputTitleId: effectivePrimaryTitleId, encodeInputPath: selectedTitle?.filePath || null, handBrakeTitleId: normalizeReviewTitleId( selectedTitle?.handBrakeTitleId ?? selectedTitle?.titleIndex ?? encodePlan?.handBrakeTitleId ?? null ), selectedPlaylistId: normalizePlaylistId( selectedTitle?.playlistId || selectedTitle?.playlistFile || encodePlan?.selectedPlaylistId || null ), selectedMakemkvTitleId: normalizeNonNegativeInteger( selectedTitle?.makemkvTitleId ?? encodePlan?.selectedMakemkvTitleId ?? null ), titleSelectionRequired: false }, selectedTitle, selectedTitleIds: normalizedTitleIds }; } 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 normalizeSubtitleSelectionMode(value, fallback = 'variants') { const mode = String(value || '').trim().toLowerCase(); if (mode === 'track_ids' || mode === 'variants') { return mode; } return fallback; } 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 selectedTitleIds = normalizeReviewTitleIdList( Array.isArray(plan?.selectedTitleIds) ? plan.selectedTitleIds : [encodeInputTitleId] ); const selectedTitleIdSet = new Set( (selectedTitleIds.length > 0 ? selectedTitleIds : [encodeInputTitleId]).map((id) => Number(id)) ); 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 hasRawSubtitleTrackIds = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleTrackIds'); const hasRawSubtitleVariantSelection = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleVariantSelection'); const requestedSubtitleSelectionMode = normalizeSubtitleSelectionMode( rawSelection.subtitleSelectionMode, hasRawSubtitleVariantSelection ? 'variants' : (hasRawSubtitleTrackIds ? 'track_ids' : 'variants') ); const explicitSubtitleVariants = requestedSubtitleSelectionMode === 'variants' ? normalizeSubtitleVariantSelection(rawSelection.subtitleVariantSelection) : { map: new Map(), order: [] }; const explicitSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(rawSelection.subtitleLanguageOrder); let effectiveSubtitleVariantSelection = explicitSubtitleVariants; let forceExplicitEmptySubtitleSelection = false; if (requestedSubtitleSelectionMode === 'track_ids') { if (requestedSubtitleTrackIds.length > 0) { effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds( encodeTitle.subtitleTracks, requestedSubtitleTrackIds ); } else { // User provided subtitleTrackIds explicitly, but none match this title. // Treat this as an explicit empty selection for this title and do not // fall back to auto-selected subtitle flags (which could include all tracks). effectiveSubtitleVariantSelection = buildEmptySubtitleVariantSelectionForAllLanguages(encodeTitle.subtitleTracks); forceExplicitEmptySubtitleSelection = true; } } else if (effectiveSubtitleVariantSelection.map.size === 0 && hasRawSubtitleTrackIds) { if (requestedSubtitleTrackIds.length > 0) { effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds( encodeTitle.subtitleTracks, requestedSubtitleTrackIds ); } else { effectiveSubtitleVariantSelection = buildEmptySubtitleVariantSelectionForAllLanguages(encodeTitle.subtitleTracks); forceExplicitEmptySubtitleSelection = true; } } const hasExplicitSubtitleVariantSelection = ( requestedSubtitleSelectionMode === 'track_ids' || hasRawSubtitleVariantSelection || forceExplicitEmptySubtitleSelection || (hasRawSubtitleTrackIds && effectiveSubtitleVariantSelection.map.size > 0) ); const resolvedSubtitleSelection = resolveDeterministicSubtitleSelection({ subtitleTracks: encodeTitle.subtitleTracks, subtitleVariantSelection: Object.fromEntries(effectiveSubtitleVariantSelection.map.entries()), subtitleLanguageOrder: explicitSubtitleLanguageOrder.length > 0 ? explicitSubtitleLanguageOrder : effectiveSubtitleVariantSelection.order, fallbackSelectedSubtitleTrackIds: requestedSubtitleTrackIds, fallbackSelectedSubtitleTrackIdsOrdered: requestedSubtitleTrackIdsOrdered, hasExplicitVariantSelection: hasExplicitSubtitleVariantSelection }); 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 titleId = Number(title?.id); const isEncodeInput = titleId === encodeInputTitleId; const keepSelectedForEncode = selectedTitleIdSet.has(titleId); const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => { const trackId = Number(track?.id); const selectedForEncode = isEncodeInput ? audioSelectionSet.has(String(Math.trunc(trackId))) : (keepSelectedForEncode ? Boolean(track?.selectedForEncode) : false); 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))) : (keepSelectedForEncode ? Boolean(track?.selectedForEncode) : false); 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: keepSelectedForEncode, audioTracks, subtitleTracks }; }); const currentManualSelection = { titleId: encodeInputTitleId, subtitleSelectionMode: requestedSubtitleSelectionMode, audioTrackIds: requestedAudioTrackIds, subtitleTrackIds: resolvedSubtitleSelection.selectedPhysicalTrackIds, subtitleTrackIdsOrdered: resolvedSubtitleSelection.subtitleTrackIdsOrdered, subtitleForcedTrackIndexes: resolvedSubtitleSelection.subtitleForcedSelectionIndexes, subtitleVariantSelection: resolvedSubtitleSelection.subtitleVariantSelection, subtitleLanguageOrder: resolvedSubtitleSelection.subtitleLanguageOrder, subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors, updatedAt: nowIso() }; const existingManualTrackSelectionByTitle = plan?.manualTrackSelectionByTitle && typeof plan.manualTrackSelectionByTitle === 'object' ? plan.manualTrackSelectionByTitle : {}; const manualTrackSelectionByTitle = { ...existingManualTrackSelectionByTitle, [encodeInputTitleId]: currentManualSelection }; return { plan: { ...plan, titles: remappedTitles, manualTrackSelection: currentManualSelection, manualTrackSelectionByTitle }, 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 manualSelectionByTitle = plan?.manualTrackSelectionByTitle && typeof plan.manualTrackSelectionByTitle === 'object' ? plan.manualTrackSelectionByTitle : {}; const manualSelectionForTitle = encodeInputTitleId ? ( manualSelectionByTitle[encodeInputTitleId] || manualSelectionByTitle[String(encodeInputTitleId)] || null ) : null; const manualSelection = ( manualSelectionForTitle && typeof manualSelectionForTitle === 'object' ? manualSelectionForTitle : ( 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 hasManualSubtitleTrackIds = Boolean( manualMatchesTitle && manualSelection && Object.prototype.hasOwnProperty.call(manualSelection, 'subtitleTrackIds') ); const hasManualSubtitleVariantSelection = Boolean( manualMatchesTitle && manualSelection && Object.prototype.hasOwnProperty.call(manualSelection, 'subtitleVariantSelection') ); const manualSubtitleSelectionMode = normalizeSubtitleSelectionMode( manualMatchesTitle ? manualSelection?.subtitleSelectionMode : null, hasManualSubtitleVariantSelection ? 'variants' : (hasManualSubtitleTrackIds ? 'track_ids' : 'variants') ); const hasExplicitManualSubtitleSelection = Boolean( manualSubtitleSelectionMode === 'track_ids' || hasManualSubtitleVariantSelection || hasManualSubtitleTrackIds ); const manualSubtitleLanguageOrder = manualMatchesTitle && Array.isArray(manualSelection?.subtitleLanguageOrder) ? manualSelection.subtitleLanguageOrder : []; const resolvedSubtitleSelection = resolveDeterministicSubtitleSelection({ subtitleTracks: allSubtitleTracks, subtitleVariantSelection: manualSubtitleVariantSelection, subtitleLanguageOrder: manualSubtitleLanguageOrder, fallbackSelectedSubtitleTrackIds: fallbackSubtitleTrackIds, fallbackSelectedSubtitleTrackIdsOrdered: fallbackSubtitleTrackIdsOrdered, hasExplicitVariantSelection: hasExplicitManualSubtitleSelection }); const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIdsOrdered.length > 0 ? resolvedSubtitleSelection.subtitleTrackIdsOrdered : (hasExplicitManualSubtitleSelection ? [] : (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, subtitleSelectionMode: manualSubtitleSelectionMode, 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 resolveDvdScanInputPathFromRaw(rawPath, rawMedia = null) { const sourcePath = String(rawPath || '').trim(); const media = rawMedia && typeof rawMedia === 'object' ? rawMedia : collectRawMediaCandidates(sourcePath); const mediaFiles = Array.isArray(media?.mediaFiles) ? media.mediaFiles : []; const mediaSource = String(media?.source || '').trim().toLowerCase(); if (mediaFiles.length > 0 && mediaSource === 'dvd') { // mediaFiles[0] points to ".../VIDEO_TS/VTS_XX_*.VOB" -> scan parent folder that contains VIDEO_TS. const firstPath = String(mediaFiles[0]?.path || '').trim(); if (firstPath) { return path.dirname(path.dirname(firstPath)); } } if ( mediaFiles.length > 0 && (mediaSource === 'dvd_image' || mediaSource === 'single_extensionless' || mediaSource === 'single_file') ) { return String(mediaFiles[0]?.path || '').trim() || sourcePath || null; } return sourcePath || null; } 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; } const normalizedTitleId = normalizeReviewTitleId(selection.titleId ?? encodePlan?.encodeInputTitleId); return { titleId: normalizedTitleId || null, subtitleSelectionMode: normalizeSubtitleSelectionMode( selection.subtitleSelectionMode, selection.subtitleVariantSelection ? 'variants' : 'track_ids' ), 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 resolveUserPresetDefaultSlotKey(mediaProfile, isSeriesSelection = false) { const normalizedProfile = normalizeMediaProfile(mediaProfile); if (normalizedProfile !== 'dvd' && normalizedProfile !== 'bluray') { return null; } return `${normalizedProfile}_${isSeriesSelection ? 'series' : 'movie'}`; } async function applyConfiguredDefaultUserPresetToReviewPlan(reviewPlan, options = {}) { const plan = reviewPlan && typeof reviewPlan === 'object' ? reviewPlan : null; if (!plan) { return { plan: reviewPlan, applied: false, slotKey: null, presetId: null, presetName: null }; } const existingUserPreset = normalizeUserPresetForPlan(plan?.userPreset || null); if (existingUserPreset) { return { plan, applied: false, slotKey: null, presetId: existingUserPreset.id || null, presetName: existingUserPreset.name || null }; } const slotKey = resolveUserPresetDefaultSlotKey(options?.mediaProfile || null, Boolean(options?.isSeriesSelection)); if (!slotKey) { return { plan, applied: false, slotKey: null, presetId: null, presetName: null }; } try { const defaultsMap = await userPresetDefaultsService.listDefaults(); const rawPresetId = defaultsMap && typeof defaultsMap === 'object' ? defaultsMap[slotKey] : null; const presetId = normalizePositiveInteger(rawPresetId); if (!presetId) { return { plan, applied: false, slotKey, presetId: null, presetName: null }; } const preset = await userPresetService.getPresetById(presetId); const normalizedPreset = normalizeUserPresetForPlan(preset); if (!normalizedPreset) { return { plan, applied: false, slotKey, presetId, presetName: null }; } const presetMediaType = String(preset?.mediaType || '').trim().toLowerCase(); const normalizedProfile = normalizeMediaProfile(options?.mediaProfile || null); const mediaTypeAllowed = presetMediaType === 'all' || !presetMediaType || presetMediaType === normalizedProfile; if (!mediaTypeAllowed) { return { plan, applied: false, slotKey, presetId, presetName: normalizedPreset.name || null }; } return { plan: { ...plan, userPreset: normalizedPreset }, applied: true, slotKey, presetId: normalizedPreset.id || presetId || null, presetName: normalizedPreset.name || null }; } catch (error) { logger.warn('review:user-preset-default:resolve-failed', { mediaProfile: options?.mediaProfile || null, isSeriesSelection: Boolean(options?.isSeriesSelection), error: errorToMeta(error) }); return { plan, applied: false, slotKey, presetId: null, presetName: 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; } if (Boolean(reviewPlan?.alternativeTitleSelection?.enabled)) { 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 normalizeTrackSignatureText(value) { return String(value || '').trim().toLowerCase().replace(/\s+/g, ' '); } function resolveSubtitleForcedOnlyForSignature(track) { const subtitleType = String(track?.subtitleType || '').trim().toLowerCase(); return Boolean( track?.isForcedOnly ?? track?.subtitlePreviewForcedOnly ?? track?.forcedOnly ?? track?.forcedTrackOnly ?? subtitleType === 'forced' ); } function resolveSubtitleFullHasForcedForSignature(track) { return Boolean( track?.fullHasForced ?? track?.subtitleFullHasForced ?? track?.hasForcedVariant ?? track?.fullTrackHasForced ?? false ); } function buildAudioTrackSignature(track) { return [ normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'), normalizeTrackSignatureText(track?.format || track?.codecName || track?.codec || ''), normalizeTrackSignatureText(track?.channels || track?.channelCount || ''), normalizeTrackSignatureText(track?.channelLayout || ''), normalizeTrackSignatureText(track?.title || track?.description || '') ].join('|'); } function buildSubtitleTrackSignature(track) { return [ normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'), normalizeTrackSignatureText(track?.format || track?.codecName || track?.codec || ''), resolveSubtitleForcedOnlyForSignature(track) ? 'forced' : 'full', resolveSubtitleFullHasForcedForSignature(track) ? 'full_has_forced' : 'plain' ].join('|'); } function mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds, options = {}) { const excludeBurned = Boolean(options?.excludeBurned); const kind = String(options?.kind || 'audio').trim().toLowerCase() === 'subtitle' ? 'subtitle' : 'audio'; const sourceTracks = Array.isArray(options?.sourceTracks) ? options.sourceTracks : []; 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 targetMeta = allowedTracks .map((track, index) => { const targetId = normalizeTrackIdList([track?.id])[0] || null; if (targetId === null) { return null; } return { track, index, targetId, sourceId: normalizeTrackIdList([track?.sourceTrackId])[0] || null, signature: kind === 'subtitle' ? buildSubtitleTrackSignature(track) : buildAudioTrackSignature(track) }; }) .filter(Boolean); const mapped = []; const seen = new Set(); const usedTargetIds = new Set(); for (const sourceTrackId of requested) { const match = targetMeta.find((entry) => !usedTargetIds.has(String(entry.targetId)) && (entry.sourceId === sourceTrackId || entry.targetId === sourceTrackId) ) || null; if (!match) { continue; } const targetId = match.targetId; const key = String(targetId); if (seen.has(key)) { continue; } seen.add(key); usedTargetIds.add(key); mapped.push(targetId); } if (mapped.length >= requested.length) { return mapped; } const requestedSet = new Set(requested.map((id) => String(id))); const sourceMeta = sourceTracks .map((track, index) => { const sourceId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null; if (sourceId === null) { return null; } if (!requestedSet.has(String(sourceId))) { return null; } return { sourceId, index, signature: kind === 'subtitle' ? buildSubtitleTrackSignature(track) : buildAudioTrackSignature(track) }; }) .filter(Boolean) .sort((left, right) => left.index - right.index); for (const source of sourceMeta) { if (!source.signature) { continue; } const match = targetMeta.find((entry) => !usedTargetIds.has(String(entry.targetId)) && entry.signature === source.signature ) || null; if (!match) { continue; } const key = String(match.targetId); if (seen.has(key)) { continue; } seen.add(key); usedTargetIds.add(key); mapped.push(match.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 previousSelectedAudioTracks = (Array.isArray(previousSelectedTitle?.audioTracks) ? previousSelectedTitle.audioTracks : []) .filter((track) => Boolean(track?.selectedForEncode)); const previousSelectedSubtitleTracks = (Array.isArray(previousSelectedTitle?.subtitleTracks) ? previousSelectedTitle.subtitleTracks : []) .filter((track) => Boolean(track?.selectedForEncode)); const previousAudioSourceIds = normalizeTrackIdList( previousSelectedAudioTracks.map((track) => track?.sourceTrackId ?? track?.id) ); const previousSubtitleSourceIds = normalizeTrackIdList( previousSelectedSubtitleTracks.map((track) => track?.sourceTrackId ?? track?.id) ); const mappedAudioTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds( nextSelectedTitle?.audioTracks, previousAudioSourceIds, { kind: 'audio', sourceTracks: previousSelectedAudioTracks } ); const mappedSubtitleTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds( nextSelectedTitle?.subtitleTracks, previousSubtitleSourceIds, { kind: 'subtitle', sourceTracks: previousSelectedSubtitleTracks, 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 || []); nextPlan = { ...nextPlan, preEncodeScriptIds, postEncodeScriptIds, preEncodeScripts: buildScriptDescriptorList(preEncodeScriptIds, previousPlan?.preEncodeScripts || []), postEncodeScripts: buildScriptDescriptorList(postEncodeScriptIds, previousPlan?.postEncodeScripts || []), preEncodeChainIds, postEncodeChainIds, userPreset: null, reviewConfirmed: false, reviewConfirmedAt: null, prefilledFromPreviousRun: true, prefilledFromPreviousRunAt: nowIso() }; const applied = selectedTitleApplied || trackSelectionApplied || preEncodeScriptIds.length > 0 || postEncodeScriptIds.length > 0 || preEncodeChainIds.length > 0 || postEncodeChainIds.length > 0; return { plan: nextPlan, applied, selectedEncodeTitleId: normalizeReviewTitleId(nextPlan?.encodeInputTitleId), preEncodeScriptCount: preEncodeScriptIds.length, postEncodeScriptCount: postEncodeScriptIds.length, preEncodeChainCount: preEncodeChainIds.length, postEncodeChainCount: postEncodeChainIds.length, userPresetApplied: false }; } function applyMultipartSettingsLockToReviewPlan(reviewPlan, lockContext = null) { const plan = reviewPlan && typeof reviewPlan === 'object' ? reviewPlan : null; const lock = lockContext && typeof lockContext === 'object' ? lockContext : null; const lockEnabled = Boolean(lock?.enabled); const sourceJobId = normalizePositiveInteger(lock?.sourceJobId || null); if (!plan || !lockEnabled) { return { plan: reviewPlan, applied: false, sourceJobId: null, sourceDiscNumber: null }; } const sourceDiscNumber = normalizePositiveInteger(lock?.sourceDiscNumber || null); return { plan: { ...plan, multipartSettingsLock: { enabled: true, sourceJobId: sourceJobId || null, sourceDiscNumber: sourceDiscNumber || null, strategy: 'reuse_existing_disc_settings', lockedAt: nowIso() } }, applied: true, sourceJobId: sourceJobId || null, sourceDiscNumber: sourceDiscNumber || null }; } 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.seriesBatchParentByChild = new Map(); this.seriesBatchParentProgressSyncAt = 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); const scanLines = Array.isArray(pluginResult?.scanLines) ? pluginResult.scanLines : []; const scanAnalysisLines = Array.isArray(pluginResult?.scanAnalysisLines) && pluginResult.scanAnalysisLines.length > 0 ? pluginResult.scanAnalysisLines : scanLines; return { scanLines, scanAnalysisLines, runInfo: pluginResult?.runInfo && typeof pluginResult.runInfo === 'object' ? pluginResult.runInfo : null, sourceArg: String(pluginResult?.sourceArg || '').trim() || null, pluginExecution: this.sanitizePluginExecutionState(pluginCtx.getPluginExecution()) }; } async runMakeMkvSeriesSecondChance({ jobId, mediaProfile, deviceInfo = null, rawPath = null, seriesOptions = null, silent = true } = {}) { const normalizedJobId = this.normalizeQueueJobId(jobId); const normalizedMediaProfile = normalizeMediaProfile(mediaProfile); if (!normalizedJobId || !isSeriesDiscMediaProfile(normalizedMediaProfile)) { return { runInfo: null, scanLines: [], playlistAnalysis: null, structuredSeriesAnalysis: null, reason: 'invalid_input' }; } const scanLines = []; let runInfo = null; try { await this.ensureMakeMKVRegistration(normalizedJobId, 'ANALYZING').catch((error) => { logger.warn('dvd-series:makemkv-second-chance:registration-failed', { jobId: normalizedJobId, mediaProfile: normalizedMediaProfile, error: errorToMeta(error) }); }); const settings = await settingsService.getEffectiveSettingsMap(normalizedMediaProfile); const analyzeConfig = rawPath ? await settingsService.buildMakeMKVAnalyzePathConfig(rawPath, { mediaProfile: normalizedMediaProfile, disableMinLengthFilter: true, settingsMap: settings }) : await settingsService.buildMakeMKVAnalyzeConfig(deviceInfo, { mediaProfile: normalizedMediaProfile, disableMinLengthFilter: true, settingsMap: settings }); runInfo = await this.runCommand({ jobId: normalizedJobId, stage: 'ANALYZING', source: 'MAKEMKV_ANALYZE_SERIES_SECOND_CHANCE', cmd: analyzeConfig.cmd, args: analyzeConfig.args, parser: parseMakeMkvProgress, collectLines: scanLines, silent: Boolean(silent) }); } catch (error) { logger.warn('dvd-series:makemkv-second-chance:failed', { jobId: normalizedJobId, mediaProfile: normalizedMediaProfile, source: rawPath ? 'raw_path' : 'device', error: errorToMeta(error) }); return { runInfo: error?.runInfo || runInfo || null, scanLines, playlistAnalysis: null, structuredSeriesAnalysis: null, reason: 'command_failed' }; } const playlistAnalysis = analyzePlaylistObfuscation(scanLines, 0, {}); const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromPlaylistAnalysis( playlistAnalysis, seriesOptions && typeof seriesOptions === 'object' ? seriesOptions : {} ); return { runInfo, scanLines, playlistAnalysis, structuredSeriesAnalysis, reason: structuredSeriesAnalysis ? 'ok' : 'no_structured_signal' }; } 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); const directSuccess = String(handBrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; if (directSuccess) { return true; } const seriesBatchSummary = handBrakeInfo?.seriesBatch && typeof handBrakeInfo.seriesBatch === 'object' ? handBrakeInfo.seriesBatch : null; const seriesBatchMode = String(handBrakeInfo?.mode || '').trim().toLowerCase() === 'series_batch'; if (!seriesBatchMode || !seriesBatchSummary) { return false; } const totalCount = Number(seriesBatchSummary?.totalCount || 0); const finishedCount = Number(seriesBatchSummary?.finishedCount || 0); const errorCount = Number(seriesBatchSummary?.errorCount || 0); const cancelledCount = Number(seriesBatchSummary?.cancelledCount || 0); return totalCount > 0 && finishedCount >= totalCount && errorCount <= 0 && cancelledCount <= 0; } 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, effectiveSettings?.series_raw_dir, sourceMap?.raw_dir, sourceMap?.raw_dir_bluray, sourceMap?.raw_dir_bluray_series, sourceMap?.raw_dir_dvd, sourceMap?.raw_dir_dvd_series, 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, rawState: resolveRawFolderStateFromPath(resolvedRawPath), inputPath: null, hasUsableRawInput: false }; } const rawState = resolveRawFolderStateFromPath(resolvedRawPath); if (rawState === RAW_FOLDER_STATES.INCOMPLETE) { return { mediaProfile, resolvedRawPath, rawState, 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, rawState, inputPath, hasUsableRawInput: Boolean(inputPath) }; } async validateStartupEncodeRecoveryReadiness(job = null) { const resolvedJob = job && typeof job === 'object' ? job : null; const jobId = this.normalizeQueueJobId(resolvedJob?.id); if (!jobId) { return { ok: false, code: 'invalid_job', message: 'Startup-Recovery nicht möglich: ungültiger Job.' }; } const mkInfo = this.safeParseJson(resolvedJob?.makemkv_info_json); const encodePlan = this.safeParseJson(resolvedJob?.encode_plan_json); const sourceTag = String(mkInfo?.source || '').trim().toLowerCase(); const mediaProfile = this.resolveMediaProfileForJob(resolvedJob, { makemkvInfo: mkInfo, encodePlan }); const isOrphanRawImportJob = sourceTag === 'orphan_raw_import'; const isDiscRipJob = isSeriesDiscMediaProfile(mediaProfile) && !isOrphanRawImportJob; if (!isDiscRipJob) { return { ok: true, code: 'not_disc_rip_job', mediaProfile, resolvedRawPath: String(resolvedJob?.raw_path || '').trim() || null, rawState: resolveRawFolderStateFromPath(resolvedJob?.raw_path) }; } const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const resolvedRawPath = this.resolveCurrentRawPathForSettings( settings, mediaProfile, resolvedJob?.raw_path ) || String(resolvedJob?.raw_path || '').trim() || null; if (!resolvedRawPath) { return { ok: false, code: 'missing_raw_path', mediaProfile, resolvedRawPath: null, rawState: RAW_FOLDER_STATES.INCOMPLETE, message: `Server-Neustart: Rip ist unvollständig (RAW-Pfad fehlt). Bitte Rip vom Laufwerk neu starten.` }; } let rawExists = false; let rawIsDirectory = false; try { rawExists = fs.existsSync(resolvedRawPath); rawIsDirectory = rawExists && fs.statSync(resolvedRawPath).isDirectory(); } catch (_error) { rawExists = false; rawIsDirectory = false; } if (!rawExists || !rawIsDirectory) { return { ok: false, code: 'raw_path_missing_on_disk', mediaProfile, resolvedRawPath, rawState: resolveRawFolderStateFromPath(resolvedRawPath), message: `Server-Neustart: Rip ist unvollständig (RAW-Ordner fehlt: ${resolvedRawPath}). Bitte Rip vom Laufwerk neu starten.` }; } const rawState = resolveRawFolderStateFromPath(resolvedRawPath); if (rawState === RAW_FOLDER_STATES.INCOMPLETE) { return { ok: false, code: 'raw_state_incomplete', mediaProfile, resolvedRawPath, rawState, message: `Server-Neustart: Rip ist unvollständig (RAW-Ordner ist als Incomplete markiert: ${resolvedRawPath}). Bitte Rip vom Laufwerk neu starten.` }; } const ripMarkedSuccessful = Number(resolvedJob?.rip_successful || 0) === 1 || String(mkInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; if (!ripMarkedSuccessful) { return { ok: false, code: 'rip_not_marked_successful', mediaProfile, resolvedRawPath, rawState, message: 'Server-Neustart: Rip ist nicht als erfolgreich markiert. Bitte Rip vom Laufwerk neu starten.' }; } let hasUsableRawInput = false; try { hasUsableRawInput = Boolean( hasBluRayBackupStructure(resolvedRawPath) || findPreferredRawInput(resolvedRawPath) ); } catch (_error) { hasUsableRawInput = false; } if (!hasUsableRawInput) { return { ok: false, code: 'raw_input_missing', mediaProfile, resolvedRawPath, rawState, message: `Server-Neustart: Rip enthält keine verwertbaren Dateien (${resolvedRawPath}). Bitte Rip vom Laufwerk neu starten.` }; } return { ok: true, code: 'ok', mediaProfile, resolvedRawPath, rawState }; } 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_bluray_series, settings?.raw_dir_dvd, settings?.raw_dir_dvd_series, 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, encode_plan_json, media_type, job_kind 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_%') `); const jobRows = Array.isArray(rows) ? rows : []; const existingJobIdRows = await db.all('SELECT id FROM jobs'); const existingJobIdSet = new Set( (Array.isArray(existingJobIdRows) ? existingJobIdRows : []) .map((row) => Number(row?.id)) .filter((value) => Number.isFinite(value) && value > 0) .map((value) => Math.trunc(value)) ); const linkedRawPathSet = new Set(); const addLinkedRawPath = (candidatePath) => { const normalizedCandidate = normalizeComparablePath(candidatePath); if (!normalizedCandidate) { return; } linkedRawPathSet.add(normalizedCandidate); for (const rawRoot of allRawDirs) { if (!isPathInsideDirectory(rawRoot, normalizedCandidate)) { continue; } const relative = String(path.relative(rawRoot, normalizedCandidate) || '').trim(); if (!relative || relative === '.' || relative.startsWith('..')) { continue; } const topSegment = relative .split(path.sep) .find((segment) => String(segment || '').trim() && segment !== '.'); if (!topSegment) { continue; } linkedRawPathSet.add(normalizeComparablePath(path.join(rawRoot, topSegment))); } }; let renamedCount = 0; let pathUpdateCount = 0; let ripFlagUpdateCount = 0; let conflictCount = 0; let missingCount = 0; let orphanScanCount = 0; let orphanCandidateCount = 0; let orphanRenamedCount = 0; let orphanConflictCount = 0; let orphanFailedCount = 0; let orphanAlreadyNormalizedCount = 0; let orphanSkippedLinkedCount = 0; let orphanSkippedKnownJobIdCount = 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 mappedJobId = extractRawFolderJobId(entry.name); if (!mappedJobId) { 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 jobRows) { const jobId = Number(row?.id); if (!Number.isFinite(jobId) || jobId <= 0) { continue; } const encodePlan = this.safeParseJson(row?.encode_plan_json); const jobKind = String(row?.job_kind || '').trim().toLowerCase(); const isSeriesEpisodeSubJob = ( isSeriesBatchChildPlan(encodePlan) || Boolean(encodePlan?.seriesBatchVirtualEpisode) || jobKind === 'dvd_series_episode' || jobKind === 'dvd_series_virtual_episode' ); if (isSeriesEpisodeSubJob) { continue; } const currentRawPath = this.resolveCurrentRawPath(rawBaseDir, row.raw_path, rawExtraDirs) || discoveredByJobId.get(jobId)?.path || null; if (!currentRawPath) { missingCount += 1; continue; } addLinkedRawPath(row.raw_path); addLinkedRawPath(currentRawPath); const currentRawState = resolveRawFolderStateFromPath(currentRawPath); let hasUsableRawInput = false; try { hasUsableRawInput = Boolean( hasBluRayBackupStructure(currentRawPath) || findPreferredRawInput(currentRawPath) ); } catch (_error) { hasUsableRawInput = false; } const ripSuccessful = this.isRipSuccessful(row); const strictRipSuccessful = ripSuccessful && currentRawState !== RAW_FOLDER_STATES.INCOMPLETE && hasUsableRawInput; if (strictRipSuccessful && Number(row?.rip_successful || 0) !== 1) { await historyService.updateJob(jobId, { rip_successful: 1 }); ripFlagUpdateCount += 1; } // 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 folderSeriesSeasonDisc = extractSeriesSeasonDiscFromRawFolderName(currentFolderName); const mkInfo = this.safeParseJson(row?.makemkv_info_json); const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' ? mkInfo.analyzeContext : null; const selectedMetadata = resolveSelectedMetadataForJob(row, analyzeContext, null); const metadataBase = buildRawMetadataBase({ title: row.title || row.detected_title || null, year: row.year || null, fallbackYear, detected_title: row.detected_title || null, media_type: row.media_type || null, is_multipart_movie: Number(row?.is_multipart_movie || 0) === 1 ? 1 : 0, job_kind: row?.job_kind || null }, jobId, { mediaProfile: row.media_type || null, analyzeContext, selectedMetadata, seriesSeasonNumber: folderSeriesSeasonDisc.seasonNumber, seriesDiscNumber: folderSeriesSeasonDisc.discNumber }); const desiredRawFolderState = strictRipSuccessful ? (this.isEncodeSuccessful(row) ? RAW_FOLDER_STATES.COMPLETE : RAW_FOLDER_STATES.RIP_COMPLETE) : RAW_FOLDER_STATES.INCOMPLETE; 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; addLinkedRawPath(finalRawPath); } 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; } addLinkedRawPath(finalRawPath); } for (const scanDir of allRawDirs) { let dirEntries = []; try { dirEntries = fs.readdirSync(scanDir, { withFileTypes: true }); } catch (scanError) { logger.warn('startup:raw-orphan-normalize:scan-failed', { scanDir, error: errorToMeta(scanError) }); continue; } for (const entry of dirEntries) { if (!entry?.isDirectory?.()) { continue; } const rawFolderName = String(entry.name || '').trim(); if (!rawFolderName || rawFolderName.startsWith('.')) { continue; } orphanScanCount += 1; const currentRawPath = normalizeComparablePath(path.join(scanDir, rawFolderName)); if (!currentRawPath) { continue; } if (linkedRawPathSet.has(currentRawPath)) { orphanSkippedLinkedCount += 1; continue; } const folderJobId = extractRawFolderJobId(rawFolderName); if (folderJobId && existingJobIdSet.has(folderJobId)) { orphanSkippedKnownJobIdCount += 1; addLinkedRawPath(currentRawPath); continue; } orphanCandidateCount += 1; const cleanedBaseName = stripRawStatePrefix(rawFolderName); const targetFolderName = applyRawFolderStateToName(cleanedBaseName, RAW_FOLDER_STATES.RIP_COMPLETE); if (!targetFolderName) { continue; } const targetRawPath = normalizeComparablePath(path.join(scanDir, targetFolderName)); if (!targetRawPath || targetRawPath === currentRawPath) { orphanAlreadyNormalizedCount += 1; addLinkedRawPath(currentRawPath); continue; } if (fs.existsSync(targetRawPath)) { orphanConflictCount += 1; logger.warn('startup:raw-orphan-normalize:target-exists', { scanDir, currentRawPath, targetRawPath, folderJobId, folderJobIdExists: existingJobIdSet.has(Number(folderJobId || 0)) }); continue; } try { fs.renameSync(currentRawPath, targetRawPath); orphanRenamedCount += 1; addLinkedRawPath(targetRawPath); logger.info('startup:raw-orphan-normalize:renamed', { from: currentRawPath, to: targetRawPath, folderJobId, folderJobIdExists: existingJobIdSet.has(Number(folderJobId || 0)) }); } catch (renameError) { orphanFailedCount += 1; logger.warn('startup:raw-orphan-normalize:rename-failed', { scanDir, currentRawPath, targetRawPath, error: errorToMeta(renameError) }); } } } if ( renamedCount > 0 || pathUpdateCount > 0 || ripFlagUpdateCount > 0 || conflictCount > 0 || missingCount > 0 || orphanCandidateCount > 0 || orphanRenamedCount > 0 || orphanConflictCount > 0 || orphanFailedCount > 0 ) { logger.info('startup:raw-dir-migrate:done', { renamedCount, pathUpdateCount, ripFlagUpdateCount, conflictCount, missingCount, orphanScanCount, orphanCandidateCount, orphanRenamedCount, orphanConflictCount, orphanFailedCount, orphanAlreadyNormalizedCount, orphanSkippedLinkedCount, orphanSkippedKnownJobIdCount, 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.normalizeMultipartOutputsOnStartup(db); } catch (multipartOutputNormalizeError) { logger.warn('init:multipart-output-normalize-failed', { error: errorToMeta(multipartOutputNormalizeError) }); } try { await this.syncAllSeriesContainerStatusesOnStartup(db); } catch (seriesContainerRecoveryError) { logger.warn('init:series-container-recovery-failed', { error: errorToMeta(seriesContainerRecoveryError) }); } try { await this._restoreDriveLocksFromJobs(); } catch (driveLockRecoveryError) { logger.warn('init:drive-lock-recovery-failed', { error: errorToMeta(driveLockRecoveryError) }); } try { await this._forceUnlockStaleDriveLocks(); } catch (driveLockCleanupError) { logger.warn('init:drive-lock-cleanup-failed', { error: errorToMeta(driveLockCleanupError) }); } // 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, raw_path, rip_successful, makemkv_info_json, encode_plan_json, media_type, job_kind FROM jobs WHERE COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container') AND status IN ('ANALYZING', 'METADATA_LOOKUP', '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, blockedIncompleteRip: 0, skipped: 0 }; } let preparedReadyToEncode = 0; let markedError = 0; let blockedIncompleteRip = 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') { let recoveryValidation; try { recoveryValidation = await this.validateStartupEncodeRecoveryReadiness(row); } catch (validationError) { logger.warn('startup:recover-stale-encoding:validation-failed', { jobId, error: errorToMeta(validationError) }); recoveryValidation = { ok: false, code: 'validation_failed', message: `Server-Neustart: Rip-Validierung fehlgeschlagen (${validationError?.message || 'unknown'}). Bitte Rip vom Laufwerk neu starten.` }; } if (!recoveryValidation?.ok) { const recoveryMessage = String(recoveryValidation?.message || '').trim() || `${message} Rip ist unvollständig. Bitte Rip vom Laufwerk neu starten.`; await historyService.updateJob(jobId, { status: 'ERROR', last_state: 'RIPPING', end_time: nowIso(), error_message: recoveryMessage, rip_successful: 0 }); try { await historyService.appendLog(jobId, 'SYSTEM', recoveryMessage); } catch (_error) { // keep recovery path even if log append fails } markedError += 1; blockedIncompleteRip += 1; continue; } 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 } } } if (stage === 'RIPPING' || stage === 'MEDIAINFO_CHECK') { let recoveryValidation; try { recoveryValidation = await this.validateStartupEncodeRecoveryReadiness(row); } catch (validationError) { logger.warn('startup:recover-stale-review:validation-failed', { jobId, stage, error: errorToMeta(validationError) }); recoveryValidation = { ok: false, code: 'validation_failed', message: `Server-Neustart: Rip-Validierung fehlgeschlagen (${validationError?.message || 'unknown'}). Bitte Rip vom Laufwerk neu starten.` }; } if (recoveryValidation?.ok) { const reviewRecoveryMessage = stage === 'MEDIAINFO_CHECK' ? 'Server-Neustart während Titel-/Spurprüfung erkannt. Rip ist bereits fertig; Review kann aus dem RAW-Backup neu gestartet werden.' : 'Server-Neustart nach abgeschlossenem Rip erkannt. Review kann aus dem RAW-Backup neu gestartet werden.'; await historyService.updateJob(jobId, { status: 'ERROR', last_state: 'MEDIAINFO_CHECK', end_time: nowIso(), error_message: reviewRecoveryMessage, raw_path: recoveryValidation?.resolvedRawPath || row?.raw_path || null, rip_successful: 1 }); try { await historyService.appendLog(jobId, 'SYSTEM', reviewRecoveryMessage); } catch (_error) { // keep recovery path even if log append fails } markedError += 1; continue; } const recoveryMessage = String(recoveryValidation?.message || '').trim() || `${message} Rip ist unvollständig. Bitte Rip vom Laufwerk neu starten.`; await historyService.updateJob(jobId, { status: 'ERROR', last_state: 'RIPPING', end_time: nowIso(), error_message: recoveryMessage, rip_successful: 0 }); try { await historyService.appendLog(jobId, 'SYSTEM', recoveryMessage); } catch (_error) { // keep recovery path even if log append fails } markedError += 1; blockedIncompleteRip += 1; continue; } 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, blockedIncompleteRip, skipped }); return { scanned: rows.length, preparedReadyToEncode, markedError, blockedIncompleteRip, 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(); } _resolveDriveRealPath(value) { const normalized = this.normalizeDrivePath(value); if (!normalized || !normalized.startsWith('/')) { return null; } try { if (fs.realpathSync && typeof fs.realpathSync.native === 'function') { return fs.realpathSync.native(normalized); } return fs.realpathSync(normalized); } catch (_error) { return null; } } _isSameDrivePath(pathA, pathB) { const a = this.normalizeDrivePath(pathA); const b = this.normalizeDrivePath(pathB); if (!a || !b) { return false; } if (a === b) { return true; } const aReal = this._resolveDriveRealPath(a); const bReal = this._resolveDriveRealPath(b); if (aReal && bReal && aReal === bReal) { return true; } const aBase = path.basename(a); const bBase = path.basename(b); if (aBase && bBase && aBase === bBase && /^sr\d+$/i.test(aBase)) { return true; } return false; } _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._isSameDrivePath(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._isSameDrivePath(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; } _isProcessHandleRunning(processHandle) { if (!processHandle || typeof processHandle !== 'object') { return false; } const child = processHandle.child; // Conservative fallback for orchestrating/shared handles without direct child. if (!child || typeof child !== 'object') { return true; } const pid = Number(child.pid); if (!Number.isFinite(pid) || pid <= 0) { return false; } if (child.exitCode !== null && child.exitCode !== undefined) { return false; } if (child.signalCode !== null && child.signalCode !== undefined) { return false; } try { process.kill(pid, 0); return true; } catch (error) { if (String(error?.code || '').toUpperCase() === 'EPERM') { return true; } return false; } } _isActiveProcessForJob(jobId, options = {}) { const normalizedJobId = this.normalizeQueueJobId(jobId); if (!normalizedJobId) { return false; } const processHandle = this.activeProcesses.get(normalizedJobId) || null; if (!processHandle) { return false; } const running = this._isProcessHandleRunning(processHandle); if (!running && options?.cleanupStale) { this.activeProcesses.delete(normalizedJobId); this.syncPrimaryActiveProcess(); logger.warn('process-handle:stale-removed', { jobId: normalizedJobId }); } return running; } _hasForeignInteractiveSession(activeJobId = null) { const normalizedActiveJobId = this.normalizeQueueJobId(activeJobId); if (!normalizedActiveJobId) { return false; } const snapshotActiveJobId = this.normalizeQueueJobId(this.snapshot.activeJobId); if (!snapshotActiveJobId || snapshotActiveJobId === normalizedActiveJobId) { return false; } const PIPELINE_INTERACTIVE_STATES = new Set([ 'ANALYZING', 'METADATA_LOOKUP', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'MEDIAINFO_CHECK' ]); const snapshotState = String(this.snapshot.state || '').trim().toUpperCase(); return PIPELINE_INTERACTIVE_STATES.has(snapshotState); } async _findProtectedDriveLockForAnalyze(devicePath) { const normalizedPath = this.normalizeDrivePath(devicePath); if (!normalizedPath) { return null; } const existingLock = this._getDriveLockByPath(normalizedPath); const ownerJobId = this.normalizeQueueJobId(existingLock?.owner?.jobId || existingLock?.jobId); // Only an actually running process may protect the drive from force-unlock. // Stale ERROR/CANCELLED/RIPPING remnants must not block analyze forever. if (ownerJobId && this._isActiveProcessForJob(ownerJobId, { cleanupStale: true })) { return existingLock; } return null; } async _ensureDriveUnlockedForAnalyze(devicePath) { const normalizedPath = this.normalizeDrivePath(devicePath); if (!normalizedPath) { return; } const hasLock = Boolean(diskDetectionService.isDeviceLocked(normalizedPath) || this._getDriveLockByPath(normalizedPath)); if (!hasLock) { return; } const protectedLock = await this._findProtectedDriveLockForAnalyze(normalizedPath); if (protectedLock) { throw this._buildDriveLockedError(normalizedPath, protectedLock); } await this.forceUnlockDrive(normalizedPath, { reason: 'analyze_stale_lock_clear' }); const stillLocked = Boolean(diskDetectionService.isDeviceLocked(normalizedPath) || this._getDriveLockByPath(normalizedPath)); if (stillLocked) { throw this._buildDriveLockedError(normalizedPath, this._getDriveLockByPath(normalizedPath)); } } _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; const mkInfo = this.safeParseJson(job?.makemkv_info_json); const isOrphanRawImportJob = String(mkInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; if (isOrphanRawImportJob) { return false; } 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; } _shouldPreserveSuccessfulRipJobForAnalyze(job = null) { // Never auto-delete jobs once a rip completed successfully. They may still // require manual review/playlist selection and must remain recoverable. return Number(job?.rip_successful || 0) === 1; } async _restoreDriveLocksFromJobs() { const db = await getDb(); const rows = await db.all( ` SELECT id, disc_device, status, last_state, rip_successful, job_kind, makemkv_info_json FROM jobs WHERE disc_device IS NOT NULL AND TRIM(disc_device) <> '' AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') 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 []; } await this._ensureDriveUnlockedForAnalyze(normalizedPath); 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_LOOKUP', '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 = []; const replaceableRows = Array.isArray(rows) ? rows : []; const containerRows = []; for (const row of replaceableRows) { 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._shouldPreserveSuccessfulRipJobForAnalyze(row)) { logger.info('analyze:drive:preserve-successful-rip-job', { devicePath: normalizedPath, jobId: normalizedJobId, status: rowState }); continue; } if (this.isContainerHistoryJob(row)) { containerRows.push(row); continue; } if (this._isActiveProcessForJob(normalizedJobId, { cleanupStale: true })) { 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)) { const lockForJob = this._getDriveLockByJobId(normalizedJobId); const hasPhysicalLock = Boolean(diskDetectionService.isDeviceLocked(normalizedPath)); const hasProtectedProcess = this._isActiveProcessForJob(normalizedJobId, { cleanupStale: true }); if (hasProtectedProcess || lockForJob || hasPhysicalLock) { throw this._buildDriveLockedError(normalizedPath, lockForJob); } logger.warn('analyze:stale-lock-protection-cleared', { devicePath: normalizedPath, jobId: normalizedJobId, status: rowState }); } await historyService.deleteJob(normalizedJobId, 'none', { includeRelated: false }); await this.onJobsDeleted([normalizedJobId], { suppressQueueRefresh: true }); deleted.push(normalizedJobId); } // Delete now-empty container anchors only after child rows were processed. for (const row of containerRows) { 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._isActiveProcessForJob(normalizedJobId, { cleanupStale: true })) { const error = new Error(`Analyse nicht möglich: Job #${normalizedJobId} für ${normalizedPath} ist noch aktiv.`); error.statusCode = 409; throw error; } const hasChildren = await db.get( ` SELECT id FROM jobs WHERE parent_job_id = ? LIMIT 1 `, [normalizedJobId] ); if (hasChildren) { logger.info('analyze:drive:skip-container-cleanup', { devicePath: normalizedPath, containerJobId: normalizedJobId, reason: 'children_present' }); continue; } 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)))); let removedQueueEntries = Math.max(0, previousQueueLength - this.queueEntries.length); for (const deletedJobId of normalizedIds) { removedQueueEntries += this.removeDetachedQueueAutomationEntriesForJob(deletedJobId); } 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); const staleLockClearedPaths = await this._forceUnlockStaleDriveLocks(Array.from(affectedDevicePaths)); 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 || staleLockClearedPaths.length > 0) { void this._rescanDrivesAfterDelete(Array.from(affectedDevicePaths)); } return { cleaned: normalizedIds.length, removedQueueEntries }; } async _forceUnlockStaleDriveLocks(devicePaths = []) { const activeLocks = Array.isArray(diskDetectionService.getActiveLocks?.()) ? diskDetectionService.getActiveLocks() : []; const normalizedPaths = Array.from(new Set( (Array.isArray(devicePaths) ? devicePaths : []) .map((value) => this.normalizeDrivePath(value)) .filter((value) => Boolean(value) && !value.startsWith('__virtual__')) )); const pathsToCheck = normalizedPaths.length > 0 ? normalizedPaths : Array.from(new Set( activeLocks .map((entry) => this.normalizeDrivePath(entry?.path)) .filter((value) => Boolean(value) && !value.startsWith('__virtual__')) )); if (pathsToCheck.length === 0 || typeof diskDetectionService.forceUnlockDevice !== 'function') { return []; } const locksToCheck = pathsToCheck .map((devicePath) => { const lock = activeLocks.find((entry) => this._isSameDrivePath(entry?.path, devicePath)) || null; return lock ? { devicePath, lock } : null; }) .filter(Boolean); if (locksToCheck.length === 0) { return []; } const cleared = []; for (const entry of locksToCheck) { const owners = Array.isArray(entry.lock?.owners) ? entry.lock.owners : []; const hasActiveOwner = owners.some((owner) => { const jobId = Number(owner?.jobId); if (!Number.isFinite(jobId) || jobId <= 0) { return false; } const normalizedJobId = Math.trunc(jobId); return this._isActiveProcessForJob(normalizedJobId, { cleanupStale: true }); }); if (hasActiveOwner) { continue; } const clearedJobIds = []; for (const [jobId, lock] of this.driveLocksByJob.entries()) { if (!this._isSameDrivePath(lock?.devicePath, entry.devicePath)) { continue; } this.driveLocksByJob.delete(jobId); clearedJobIds.push(Number(jobId)); } const clearedCount = Number(diskDetectionService.forceUnlockDevice(entry.devicePath, { reason: 'stale_lock', deletedJobIds: clearedJobIds }) || 0); if (clearedCount > 0 || clearedJobIds.length > 0) { cleared.push(entry.devicePath); } } if (cleared.length > 0) { logger.warn('drive-lock:stale-unlocked', { devicePaths: cleared }); this._broadcastPipelineStateChanged(); } return cleared; } 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 forceUnlockDrive(devicePath, options = {}) { const normalizedPath = this.normalizeDrivePath(devicePath); if (!normalizedPath || normalizedPath.startsWith('__virtual__')) { return { unlocked: 0, path: normalizedPath || null }; } const activeLocks = Array.isArray(diskDetectionService.getActiveLocks?.()) ? diskDetectionService.getActiveLocks() : []; const matchingLockPaths = Array.from(new Set([ normalizedPath, ...activeLocks .map((entry) => this.normalizeDrivePath(entry?.path)) .filter((lockPath) => this._isSameDrivePath(lockPath, normalizedPath)) ])); // Remove any in-memory job lock mappings for this drive (including alias paths) const clearedJobIds = []; for (const [jobId, lock] of this.driveLocksByJob.entries()) { if (matchingLockPaths.some((candidatePath) => this._isSameDrivePath(lock?.devicePath, candidatePath))) { this.driveLocksByJob.delete(jobId); clearedJobIds.push(Number(jobId)); } } let unlocked = 0; for (const lockPath of matchingLockPaths) { unlocked += Number(diskDetectionService.forceUnlockDevice(lockPath, { reason: String(options?.reason || 'manual_force_unlock').trim() || 'manual_force_unlock', deletedJobIds: Array.isArray(options?.relatedJobIds) ? options.relatedJobIds : [] }) || 0); } if (clearedJobIds.length > 0 || unlocked > 0) { logger.warn('drive-lock:force-unlock:manual', { devicePath: normalizedPath, matchingLockPaths, unlocked, clearedJobIds }); } this._broadcastPipelineStateChanged(); void this._rescanDrivesAfterDelete(matchingLockPaths); return { unlocked, path: normalizedPath, matchingLockPaths, clearedJobIds }; } 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) { if (this.isContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) { continue; } const status = String(job?.status || job?.last_state || '').trim().toUpperCase(); if (status !== 'ENCODING' && status !== 'CD_ENCODING') { continue; } const poolType = this.resolveQueuePoolTypeForJob(job); if (poolType === 'audio') { audioRunning += 1; } else { filmRunning += 1; } } return { filmRunning, audioRunning, totalRunning: filmRunning + audioRunning }; } isSeriesBatchParentQueueAnchor(jobLike = null) { const plan = this.extractQueueJobPlan(jobLike); return isSeriesBatchParentPlan(plan); } isSeriesContainerHistoryJob(jobLike = null) { const jobKind = String(jobLike?.job_kind || jobLike?.jobKind || '').trim().toLowerCase(); return jobKind === 'dvd_series_container'; } isMultipartMovieContainerHistoryJob(jobLike = null) { const jobKind = String(jobLike?.job_kind || jobLike?.jobKind || '').trim().toLowerCase(); return jobKind === 'multipart_movie_container'; } isMultipartMovieMergeHistoryJob(jobLike = null) { const jobKind = String(jobLike?.job_kind || jobLike?.jobKind || '').trim().toLowerCase(); return jobKind === 'multipart_movie_merge'; } isMultipartMovieDiscChildHistoryJob(jobLike = null) { const jobKind = String(jobLike?.job_kind || jobLike?.jobKind || '').trim().toLowerCase(); return jobKind === 'multipart_movie_child' || ( Number(jobLike?.is_multipart_movie || 0) === 1 && this.normalizeQueueJobId(jobLike?.parent_job_id) && jobKind !== 'multipart_movie_merge' ); } isContainerHistoryJob(jobLike = null) { return this.isSeriesContainerHistoryJob(jobLike) || this.isMultipartMovieContainerHistoryJob(jobLike); } _deriveSeriesContainerStateFromChildren(childJobs = []) { const rows = Array.isArray(childJobs) ? childJobs : []; if (rows.length === 0) { return null; } const normalizedRows = []; const stateCounts = new Map(); for (const row of rows) { const state = String(row?.status || row?.last_state || '').trim().toUpperCase(); if (!state) { continue; } normalizedRows.push({ row, state }); stateCounts.set(state, (stateCounts.get(state) || 0) + 1); } if (normalizedRows.length === 0) { return null; } const hasState = (state) => stateCounts.has(state); const runningPriority = ['RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'ANALYZING', 'METADATA_LOOKUP', 'CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING']; let nextState = null; for (const candidate of runningPriority) { if (hasState(candidate)) { nextState = candidate; break; } } if (!nextState) { if (hasState('ERROR')) { nextState = 'ERROR'; } else if (hasState('WAITING_FOR_USER_DECISION')) { nextState = 'WAITING_FOR_USER_DECISION'; } else if (hasState('READY_TO_START')) { nextState = 'READY_TO_START'; } else if (hasState('READY_TO_ENCODE')) { nextState = 'READY_TO_ENCODE'; } else if (hasState('METADATA_SELECTION')) { nextState = 'METADATA_SELECTION'; } else if (hasState('CANCELLED')) { nextState = 'CANCELLED'; } else if (hasState('FINISHED')) { nextState = 'FINISHED'; } else { nextState = normalizedRows[0]?.state || null; } } if (!nextState) { return null; } const terminalStates = new Set(['FINISHED', 'ERROR', 'CANCELLED']); const isTerminal = terminalStates.has(nextState); let endTime = null; if (isTerminal) { let latestEndTime = null; let latestEndTimeMs = null; for (const item of normalizedRows) { if (!terminalStates.has(item.state)) { continue; } const rawEndTime = String(item?.row?.end_time || '').trim(); if (!rawEndTime) { continue; } const parsedMs = Date.parse(rawEndTime); if (!Number.isFinite(parsedMs)) { continue; } if (latestEndTimeMs === null || parsedMs > latestEndTimeMs) { latestEndTimeMs = parsedMs; latestEndTime = rawEndTime; } } endTime = latestEndTime || nowIso(); } let errorMessage = null; if (nextState === 'ERROR' || nextState === 'CANCELLED') { const matchingRows = normalizedRows .filter((item) => item.state === nextState) .sort((left, right) => { const rightMs = Date.parse(String(right?.row?.end_time || '')) || 0; const leftMs = Date.parse(String(left?.row?.end_time || '')) || 0; return rightMs - leftMs; }); errorMessage = matchingRows .map((item) => String(item?.row?.error_message || '').trim()) .find(Boolean) || null; } return { state: nextState, isTerminal, endTime, errorMessage }; } async syncSeriesContainerStatusFromChildren(parentJobId, options = {}) { const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); if (!normalizedParentJobId) { return null; } const db = await getDb(); const parentJob = await db.get( ` SELECT id, status, last_state, end_time, error_message, job_kind FROM jobs WHERE id = ? `, [normalizedParentJobId] ); const isSeriesContainer = this.isSeriesContainerHistoryJob(parentJob); const isMultipartContainer = this.isMultipartMovieContainerHistoryJob(parentJob); if (!parentJob || (!isSeriesContainer && !isMultipartContainer)) { return null; } const sourceChildRows = Array.isArray(options?.childJobs) ? options.childJobs : await historyService.listChildJobs(normalizedParentJobId); const childRows = (Array.isArray(sourceChildRows) ? sourceChildRows : []) .filter((row) => Number(row?.id) > 0 && !this.isContainerHistoryJob(row)) .filter((row) => { if (isSeriesContainer) { const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : this.safeParseJson(row?.encode_plan_json); const rowJobKind = String(row?.job_kind || row?.jobKind || '').trim().toLowerCase(); const isSeriesEpisodeSubJob = ( isSeriesBatchChildPlan(encodePlan) || Boolean(encodePlan?.seriesBatchVirtualEpisode) || rowJobKind === 'dvd_series_episode' || rowJobKind === 'dvd_series_virtual_episode' ); return !isSeriesEpisodeSubJob; } if (isMultipartContainer) { return this.isMultipartMovieDiscChildHistoryJob(row) || this.isMultipartMovieMergeHistoryJob(row); } return true; }); if (childRows.length === 0) { return { parentJobId: normalizedParentJobId, updated: false, childCount: 0, state: String(parentJob.status || parentJob.last_state || '').trim().toUpperCase() || null }; } const aggregate = this._deriveSeriesContainerStateFromChildren(childRows); if (!aggregate || !aggregate.state) { return null; } const nextStatus = aggregate.state; const nextLastState = aggregate.state; const nextEndTime = aggregate.isTerminal ? aggregate.endTime : null; const nextErrorMessage = aggregate.errorMessage || null; const currentStatus = String(parentJob?.status || '').trim().toUpperCase(); const currentLastState = String(parentJob?.last_state || '').trim().toUpperCase(); const currentEndTime = String(parentJob?.end_time || '').trim() || null; const currentErrorMessage = String(parentJob?.error_message || '').trim() || null; const changed = currentStatus !== nextStatus || currentLastState !== nextLastState || currentEndTime !== nextEndTime || currentErrorMessage !== nextErrorMessage; if (!changed) { return { parentJobId: normalizedParentJobId, updated: false, childCount: childRows.length, state: nextStatus }; } await historyService.updateJob(normalizedParentJobId, { status: nextStatus, last_state: nextLastState, end_time: nextEndTime, error_message: nextErrorMessage }); return { parentJobId: normalizedParentJobId, updated: true, childCount: childRows.length, state: nextStatus }; } async syncAllSeriesContainerStatusesOnStartup(db = null) { const database = db || await getDb(); const rows = await database.all(` SELECT id FROM jobs WHERE COALESCE(job_kind, '') IN ('dvd_series_container', 'multipart_movie_container') ORDER BY id ASC `); const containerRows = Array.isArray(rows) ? rows : []; let updated = 0; for (const row of containerRows) { const parentJobId = this.normalizeQueueJobId(row?.id); if (!parentJobId) { continue; } try { const result = await this.syncSeriesContainerStatusFromChildren(parentJobId); if (result?.updated) { updated += 1; } } catch (error) { logger.warn('series-container:startup-sync:failed', { parentJobId, error: errorToMeta(error) }); } } if (updated > 0) { logger.info('series-container:startup-sync:updated', { scanned: containerRows.length, updated }); } return { scanned: containerRows.length, updated }; } async resolveMultipartReviewLockOptions(jobOrId, options = {}) { const baseOptions = options && typeof options === 'object' ? { ...options } : {}; const existingPreviousEncodePlan = baseOptions?.previousEncodePlan && typeof baseOptions.previousEncodePlan === 'object' ? baseOptions.previousEncodePlan : null; const existingLock = baseOptions?.multipartSettingsLock && typeof baseOptions.multipartSettingsLock === 'object' ? baseOptions.multipartSettingsLock : null; const preloadedJob = jobOrId && typeof jobOrId === 'object' ? jobOrId : null; const normalizedJobId = this.normalizeQueueJobId(preloadedJob?.id || jobOrId); if (!normalizedJobId) { return baseOptions; } const job = preloadedJob || await historyService.getJobById(normalizedJobId).catch(() => null); if (!job || !this.isMultipartMovieDiscChildHistoryJob(job)) { return baseOptions; } const parentJobId = this.normalizeQueueJobId(job?.parent_job_id); if (!parentJobId) { return baseOptions; } let sourcePlan = existingPreviousEncodePlan; let sourceJobId = this.normalizeQueueJobId(existingLock?.sourceJobId); let sourceDiscNumber = normalizePositiveInteger(existingLock?.sourceDiscNumber || null); const shouldResolveSourceFromSiblings = ( !sourcePlan || !Array.isArray(sourcePlan?.titles) || sourcePlan.titles.length === 0 || !sourceJobId || sourceJobId === normalizedJobId ); if (shouldResolveSourceFromSiblings) { const childRows = await historyService.listChildJobs(parentJobId).catch(() => []); const candidates = (Array.isArray(childRows) ? childRows : []) .filter((row) => this.isMultipartMovieDiscChildHistoryJob(row)) .filter((row) => this.normalizeQueueJobId(row?.id) !== normalizedJobId) .map((row) => { const candidatePlan = this.safeParseJson(row?.encode_plan_json); const hasTitles = Array.isArray(candidatePlan?.titles) && candidatePlan.titles.length > 0; if (!hasTitles) { return null; } const status = String(row?.status || row?.last_state || '').trim().toUpperCase(); const reviewConfirmed = Number(row?.encode_review_confirmed || 0) === 1 || Boolean(candidatePlan?.reviewConfirmed); return { row, plan: candidatePlan, reviewConfirmed, finished: status === 'FINISHED', discNumber: resolveDiscNumberFromJobLike(row) || Number.MAX_SAFE_INTEGER, id: this.normalizeQueueJobId(row?.id) || Number.MAX_SAFE_INTEGER }; }) .filter(Boolean) .sort((left, right) => { if (left.reviewConfirmed !== right.reviewConfirmed) { return left.reviewConfirmed ? -1 : 1; } if (left.finished !== right.finished) { return left.finished ? -1 : 1; } if (left.discNumber !== right.discNumber) { return left.discNumber - right.discNumber; } return left.id - right.id; }); const source = candidates[0] || null; if (!source && (!sourcePlan || !Array.isArray(sourcePlan?.titles) || sourcePlan.titles.length === 0)) { return baseOptions; } if (source) { sourcePlan = source.plan; sourceJobId = source.id; sourceDiscNumber = Number.isFinite(source.discNumber) && source.discNumber > 0 ? source.discNumber : null; } } if (!sourcePlan || !Array.isArray(sourcePlan?.titles) || sourcePlan.titles.length === 0) { return baseOptions; } return { ...baseOptions, previousEncodePlan: sourcePlan, multipartSettingsLock: { enabled: true, sourceJobId: sourceJobId || null, sourceDiscNumber: sourceDiscNumber || null } }; } isMultipartMergeJobCompletedState(jobLike = null) { const state = String(jobLike?.status || jobLike?.last_state || '').trim().toUpperCase(); return state === 'FINISHED'; } async normalizeMultipartOutputsOnStartup(db = null) { const database = db || await getDb(); const rows = await database.all(` SELECT child.id, child.parent_job_id, child.job_kind, child.is_multipart_movie, child.output_path, child.status, child.last_state, child.disc_number, child.makemkv_info_json, parent.job_kind AS parent_job_kind, parent.title AS parent_title, parent.detected_title AS parent_detected_title FROM jobs child LEFT JOIN jobs parent ON parent.id = child.parent_job_id WHERE child.parent_job_id IS NOT NULL AND child.output_path IS NOT NULL AND TRIM(child.output_path) <> '' AND ( COALESCE(child.job_kind, '') = 'multipart_movie_child' OR COALESCE(child.is_multipart_movie, 0) = 1 ) ORDER BY child.parent_job_id ASC, child.id ASC `); const childRows = Array.isArray(rows) ? rows : []; if (childRows.length === 0) { const mergeOutputNormalization = await this.normalizeMultipartMergeOutputsOnStartup(database); return { scanned: 0, updated: 0, unchanged: 0, skipped: 0, failed: 0, mergePlansRefreshed: 0, mergePlanRefreshFailed: 0, mergeOutputs: mergeOutputNormalization }; } let updated = 0; let unchanged = 0; let skipped = 0; let failed = 0; let mergePlansRefreshed = 0; let mergePlanRefreshFailed = 0; const refreshContainerIds = new Set(); const containerRowsById = new Map(); for (const row of childRows) { const childJobId = this.normalizeQueueJobId(row?.id); const parentJobId = this.normalizeQueueJobId(row?.parent_job_id); if (!childJobId || !parentJobId) { skipped += 1; continue; } const parentKind = String(row?.parent_job_kind || '').trim().toLowerCase(); if (parentKind !== 'multipart_movie_container') { skipped += 1; continue; } if (!this.isMultipartMovieDiscChildHistoryJob(row)) { skipped += 1; continue; } const outputPath = String(row?.output_path || '').trim(); if (!outputPath || !fs.existsSync(outputPath)) { skipped += 1; continue; } refreshContainerIds.add(parentJobId); if (!containerRowsById.has(parentJobId)) { containerRowsById.set(parentJobId, { id: parentJobId, job_kind: 'multipart_movie_container', title: row?.parent_title || null, detected_title: row?.parent_detected_title || null }); } try { let rowUpdated = false; const result = await this.ensureMultipartChildOutputHasDiscSuffix(childJobId, { childJob: row }); if (result?.updated) { rowUpdated = true; } const refreshedChild = result?.updated ? await historyService.getJobById(childJobId).catch(() => ({ ...row, output_path: result?.outputPath || row.output_path })) : row; const incompleteMergeResult = await this.ensureMultipartChildOutputUsesIncompleteMergePrefix(childJobId, { childJob: refreshedChild, parentJob: containerRowsById.get(parentJobId) || null }); if (incompleteMergeResult?.updated) { rowUpdated = true; } if (rowUpdated) { updated += 1; } else { unchanged += 1; } } catch (error) { failed += 1; logger.warn('startup:multipart-output-normalize:rename-failed', { childJobId, parentJobId, outputPath, error: errorToMeta(error) }); } } for (const containerJobId of refreshContainerIds) { try { await this.upsertMultipartMergeJobForContainer(containerJobId, { containerJob: containerRowsById.get(containerJobId) || null, createIfMissing: false }); mergePlansRefreshed += 1; } catch (error) { mergePlanRefreshFailed += 1; logger.warn('startup:multipart-output-normalize:merge-plan-refresh-failed', { containerJobId, error: errorToMeta(error) }); } } const mergeOutputNormalization = await this.normalizeMultipartMergeOutputsOnStartup(database); if ( updated > 0 || failed > 0 || skipped > 0 || mergePlansRefreshed > 0 || mergePlanRefreshFailed > 0 || Number(mergeOutputNormalization?.updated || 0) > 0 || Number(mergeOutputNormalization?.failed || 0) > 0 ) { logger.info('startup:multipart-output-normalize:done', { scanned: childRows.length, updated, unchanged, skipped, failed, mergePlansRefreshed, mergePlanRefreshFailed, mergeOutputs: mergeOutputNormalization }); } return { scanned: childRows.length, updated, unchanged, skipped, failed, mergePlansRefreshed, mergePlanRefreshFailed, mergeOutputs: mergeOutputNormalization }; } async ensureMultipartChildOutputHasDiscSuffix(childJobId, options = {}) { const normalizedChildJobId = this.normalizeQueueJobId(childJobId); if (!normalizedChildJobId) { return null; } const childJob = options?.childJob && Number(options.childJob?.id) === Number(normalizedChildJobId) ? options.childJob : await historyService.getJobById(normalizedChildJobId); if (!childJob || !this.isMultipartMovieDiscChildHistoryJob(childJob)) { return null; } const sourceOutputPath = String(childJob?.output_path || '').trim(); if (!sourceOutputPath) { return { jobId: normalizedChildJobId, outputPath: null, updated: false }; } const discSuffix = buildMultipartDiscOutputSuffix(childJob); if (!discSuffix) { return { jobId: normalizedChildJobId, outputPath: sourceOutputPath, updated: false }; } const sourceParsed = path.parse(sourceOutputPath); if (String(sourceParsed.name || '').toLowerCase().endsWith(String(discSuffix).toLowerCase())) { return { jobId: normalizedChildJobId, outputPath: sourceOutputPath, updated: false }; } let targetOutputPath = withPathBaseNameSuffix(sourceOutputPath, discSuffix); if (!targetOutputPath || normalizeComparablePath(targetOutputPath) === normalizeComparablePath(sourceOutputPath)) { return { jobId: normalizedChildJobId, outputPath: sourceOutputPath, updated: false }; } const sourceExists = fs.existsSync(sourceOutputPath); const targetExists = fs.existsSync(targetOutputPath); if (sourceExists && targetExists) { targetOutputPath = ensureUniqueOutputPath(targetOutputPath); } if (sourceExists) { ensureDir(path.dirname(targetOutputPath)); moveFileWithFallback(sourceOutputPath, targetOutputPath); } else if (!targetExists) { return { jobId: normalizedChildJobId, outputPath: sourceOutputPath, updated: false }; } await historyService.updateJob(normalizedChildJobId, { output_path: targetOutputPath }); historyService.addJobOutputFolder(normalizedChildJobId, targetOutputPath).catch((error) => { logger.warn('multipart:child-output:add-output-folder-failed', { childJobId: normalizedChildJobId, outputPath: targetOutputPath, error: errorToMeta(error) }); }); await historyService.appendLog( normalizedChildJobId, 'SYSTEM', `Multipart-Output umbenannt: ${sourceOutputPath} -> ${targetOutputPath}` ); return { jobId: normalizedChildJobId, outputPath: targetOutputPath, updated: true }; } async ensureMultipartChildOutputUsesIncompleteMergePrefix(childJobId, options = {}) { const normalizedChildJobId = this.normalizeQueueJobId(childJobId); if (!normalizedChildJobId) { return null; } const childJob = options?.childJob && Number(options.childJob?.id) === Number(normalizedChildJobId) ? options.childJob : await historyService.getJobById(normalizedChildJobId); if (!childJob || !this.isMultipartMovieDiscChildHistoryJob(childJob)) { return null; } const sourceOutputPath = String(childJob?.output_path || '').trim(); if (!sourceOutputPath) { return { jobId: normalizedChildJobId, outputPath: null, updated: false }; } const parentJobId = resolveMultipartContainerJobIdFromJob( childJob, options?.parentJobId ); if (!parentJobId) { return { jobId: normalizedChildJobId, outputPath: sourceOutputPath, updated: false }; } const parentJob = options?.parentJob && Number(options.parentJob?.id) === Number(parentJobId) ? options.parentJob : await historyService.getJobById(parentJobId).catch(() => null); if (!parentJob || !this.isMultipartMovieContainerHistoryJob(parentJob)) { return { jobId: normalizedChildJobId, outputPath: sourceOutputPath, updated: false }; } const mediaProfile = this.resolveMediaProfileForJob(childJob, { mediaProfile: childJob?.media_type || parentJob?.media_type || null }); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, childJob, normalizedChildJobId); let targetOutputPath = buildIncompleteMergeOutputPathFromJob( settings, childJob, preferredFinalOutputPath, normalizedChildJobId, { containerJobId: parentJobId, containerTitle: parentJob?.title || parentJob?.detected_title || null } ); if (!targetOutputPath || normalizeComparablePath(targetOutputPath) === normalizeComparablePath(sourceOutputPath)) { return { jobId: normalizedChildJobId, outputPath: sourceOutputPath, updated: false }; } const sourceExists = fs.existsSync(sourceOutputPath); const targetExists = fs.existsSync(targetOutputPath); if (sourceExists && targetExists) { targetOutputPath = ensureUniqueOutputPath(targetOutputPath); } if (sourceExists) { ensureDir(path.dirname(targetOutputPath)); movePathWithFallback(sourceOutputPath, targetOutputPath); removeDirectoryIfEmpty(path.dirname(sourceOutputPath)); } else if (!targetExists) { return { jobId: normalizedChildJobId, outputPath: sourceOutputPath, updated: false }; } await historyService.updateJob(normalizedChildJobId, { output_path: targetOutputPath }); historyService.addJobOutputFolder(normalizedChildJobId, targetOutputPath).catch((error) => { logger.warn('multipart:child-output:add-output-folder-failed', { childJobId: normalizedChildJobId, outputPath: targetOutputPath, error: errorToMeta(error) }); }); await historyService.appendLog( normalizedChildJobId, 'SYSTEM', `Multipart-Output in Incomplete-Merge-Ordner verschoben: ${sourceOutputPath} -> ${targetOutputPath}` ); return { jobId: normalizedChildJobId, outputPath: targetOutputPath, updated: true }; } async normalizeMultipartMergeOutputsOnStartup(db = null) { const database = db || await getDb(); const rows = await database.all(` SELECT merge_job.id, merge_job.parent_job_id, merge_job.job_kind, merge_job.media_type, merge_job.output_path, merge_job.status, merge_job.last_state, merge_job.encode_plan_json, parent.job_kind AS parent_job_kind, parent.title AS parent_title, parent.detected_title AS parent_detected_title, parent.media_type AS parent_media_type FROM jobs merge_job LEFT JOIN jobs parent ON parent.id = merge_job.parent_job_id WHERE COALESCE(merge_job.job_kind, '') = 'multipart_movie_merge' AND merge_job.parent_job_id IS NOT NULL AND merge_job.output_path IS NOT NULL AND TRIM(merge_job.output_path) <> '' ORDER BY merge_job.id ASC `); const mergeRows = Array.isArray(rows) ? rows : []; if (mergeRows.length === 0) { return { scanned: 0, updated: 0, unchanged: 0, skipped: 0, failed: 0 }; } let updated = 0; let unchanged = 0; let skipped = 0; let failed = 0; for (const row of mergeRows) { const mergeJobId = this.normalizeQueueJobId(row?.id); const parentJobId = this.normalizeQueueJobId(row?.parent_job_id); const sourceOutputPath = String(row?.output_path || '').trim(); const parentKind = String(row?.parent_job_kind || '').trim().toLowerCase(); if (!mergeJobId || !parentJobId || parentKind !== 'multipart_movie_container' || !sourceOutputPath) { skipped += 1; continue; } if (!fs.existsSync(sourceOutputPath)) { skipped += 1; continue; } try { const mergePlan = this.safeParseJson(row?.encode_plan_json) || {}; const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(mergePlan); const mediaProfile = this.resolveMediaProfileForJob(row, { encodePlan: mergePlan, mediaProfile: row?.media_type || row?.parent_media_type || null }); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const mergeOutputTemplatePath = String(mergePlan?.mergeOutputPath || '').trim() || buildFinalOutputPathFromJob(settings, row, mergeJobId); const preferredFinalOutputPath = buildMultipartMergeOutputPath( mergeOutputTemplatePath, sourceItems ) || mergeOutputTemplatePath; const targetOutputPath = this.isMultipartMergeJobCompletedState(row) ? preferredFinalOutputPath : buildIncompleteMergeOutputPathFromJob( settings, row, preferredFinalOutputPath, mergeJobId, { containerJobId: parentJobId, containerTitle: row?.parent_title || row?.parent_detected_title || null } ); if ( !targetOutputPath || normalizeComparablePath(targetOutputPath) === normalizeComparablePath(sourceOutputPath) ) { unchanged += 1; continue; } const sourceExists = fs.existsSync(sourceOutputPath); const targetExists = fs.existsSync(targetOutputPath); let effectiveTargetPath = targetOutputPath; if (sourceExists && targetExists) { effectiveTargetPath = ensureUniqueOutputPath(targetOutputPath); } if (sourceExists) { ensureDir(path.dirname(effectiveTargetPath)); movePathWithFallback(sourceOutputPath, effectiveTargetPath); removeDirectoryIfEmpty(path.dirname(sourceOutputPath)); } else if (!targetExists) { skipped += 1; continue; } await historyService.updateJob(mergeJobId, { output_path: effectiveTargetPath }); historyService.addJobOutputFolder(mergeJobId, effectiveTargetPath).catch(() => {}); await historyService.appendLog( mergeJobId, 'SYSTEM', `Merge-Output beim Start korrigiert: ${sourceOutputPath} -> ${effectiveTargetPath}` ); updated += 1; } catch (error) { failed += 1; logger.warn('startup:multipart-merge-output-normalize:failed', { mergeJobId, parentJobId, outputPath: sourceOutputPath, error: errorToMeta(error) }); } } return { scanned: mergeRows.length, updated, unchanged, skipped, failed }; } async upsertMultipartMergeJobForContainer(containerJobId, options = {}) { const normalizedContainerJobId = this.normalizeQueueJobId(containerJobId); if (!normalizedContainerJobId) { return null; } const containerJob = options?.containerJob && Number(options.containerJob?.id) === Number(normalizedContainerJobId) ? options.containerJob : await historyService.getJobById(normalizedContainerJobId); if (!containerJob || !this.isMultipartMovieContainerHistoryJob(containerJob)) { return null; } const allChildren = await historyService.listChildJobs(normalizedContainerJobId); const mergeJobs = (Array.isArray(allChildren) ? allChildren : []) .filter((row) => this.isMultipartMovieMergeHistoryJob(row)) .sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0)); const discChildRows = (Array.isArray(allChildren) ? allChildren : []) .filter((row) => this.isMultipartMovieDiscChildHistoryJob(row)) .sort((left, right) => { const leftDisc = resolveDiscNumberFromJobLike(left) || Number.MAX_SAFE_INTEGER; const rightDisc = resolveDiscNumberFromJobLike(right) || Number.MAX_SAFE_INTEGER; if (leftDisc !== rightDisc) { return leftDisc - rightDisc; } return Number(left?.id || 0) - Number(right?.id || 0); }); if (discChildRows.length < 2) { return { handled: false, reason: 'not_enough_disc_children', discChildCount: discChildRows.length }; } const refreshedDiscChildren = []; for (const child of discChildRows) { const normalizedChildId = this.normalizeQueueJobId(child?.id); if (!normalizedChildId) { continue; } await this.ensureMultipartChildOutputHasDiscSuffix(normalizedChildId, { childJob: child }).catch((error) => { logger.warn('multipart:child-output:disc-suffix-failed', { childJobId: normalizedChildId, error: errorToMeta(error) }); }); await this.ensureMultipartChildOutputUsesIncompleteMergePrefix(normalizedChildId, { childJob: child, parentJob: containerJob }).catch((error) => { logger.warn('multipart:child-output:incomplete-merge-prefix-failed', { childJobId: normalizedChildId, error: errorToMeta(error) }); }); const refreshedChild = await historyService.getJobById(normalizedChildId).catch(() => child); if (refreshedChild) { refreshedDiscChildren.push(refreshedChild); } } const missingRequirements = []; for (const child of refreshedDiscChildren) { const childId = this.normalizeQueueJobId(child?.id); const childState = String(child?.status || child?.last_state || '').trim().toUpperCase(); const outputPath = String(child?.output_path || '').trim(); if (childState !== 'FINISHED') { missingRequirements.push({ jobId: childId, reason: 'state_not_finished', state: childState }); continue; } if (!outputPath || !fs.existsSync(outputPath)) { missingRequirements.push({ jobId: childId, reason: 'output_missing', outputPath: outputPath || null }); } } if (missingRequirements.length > 0) { return { handled: false, reason: 'disc_children_not_ready', missingRequirements }; } const settings = await settingsService.getEffectiveSettingsMap( this.resolveMediaProfileForJob(containerJob, { mediaProfile: containerJob?.media_type || null }) ); const templateOutputPath = withPathBaseNameSuffix( buildFinalOutputPathFromJob(settings, containerJob, normalizedContainerJobId), '_merged' ); const existingMergeJob = mergeJobs[0] || null; const createIfMissing = options?.createIfMissing !== false; const existingMergePlan = this.safeParseJson(existingMergeJob?.encode_plan_json) || {}; const defaultOrderIds = refreshedDiscChildren.map((child) => this.normalizeQueueJobId(child?.id)).filter(Boolean); const existingOrderIds = normalizeReviewTitleIdList(existingMergePlan?.orderedSourceJobIds || []); const orderedSourceJobIds = []; const seenOrderIds = new Set(); for (const sourceJobId of existingOrderIds) { if (!defaultOrderIds.includes(Number(sourceJobId))) { continue; } if (seenOrderIds.has(Number(sourceJobId))) { continue; } seenOrderIds.add(Number(sourceJobId)); orderedSourceJobIds.push(Number(sourceJobId)); } for (const sourceJobId of defaultOrderIds) { if (seenOrderIds.has(Number(sourceJobId))) { continue; } seenOrderIds.add(Number(sourceJobId)); orderedSourceJobIds.push(Number(sourceJobId)); } const discByJobId = new Map( refreshedDiscChildren.map((child) => [ Number(child.id), { discNumber: resolveDiscNumberFromJobLike(child), outputPath: String(child?.output_path || '').trim() } ]) ); const sourceItems = orderedSourceJobIds .map((sourceJobId) => { const source = discByJobId.get(Number(sourceJobId)); if (!source || !source.outputPath) { return null; } return { jobId: Number(sourceJobId), discNumber: source.discNumber || null, outputPath: source.outputPath }; }) .filter(Boolean); if (sourceItems.length < 2) { return { handled: false, reason: 'insufficient_merge_inputs', sourceCount: sourceItems.length }; } const mergeOutputPath = buildMultipartMergeOutputPath(templateOutputPath, sourceItems) || templateOutputPath; const mergePlan = { mode: 'multipart_merge', preRip: false, reviewConfirmed: true, reviewConfirmedAt: nowIso(), mediaProfile: this.resolveMediaProfileForJob(containerJob, { mediaProfile: containerJob?.media_type || null }), jobKind: 'multipart_movie_merge', containerJobId: normalizedContainerJobId, orderedSourceJobIds, sourceItems, mergeOutputPath, deleteInputsAfterMerge: Boolean(existingMergePlan?.deleteInputsAfterMerge), mergeStrategy: 'mkvmerge_append', updatedAt: nowIso() }; if (existingMergeJob) { const existingStatus = String(existingMergeJob?.status || existingMergeJob?.last_state || '').trim().toUpperCase(); const isRunning = existingStatus === 'ENCODING'; const existingOutputPath = String(existingMergeJob?.output_path || '').trim(); const existingOrderIds = normalizeReviewTitleIdList(existingMergePlan?.orderedSourceJobIds || []); const existingSourceItems = normalizeMultipartMergeSourceItemsFromPlan(existingMergePlan); const orderedSourceIdsChanged = existingOrderIds.length !== orderedSourceJobIds.length || existingOrderIds.some((sourceJobId, index) => Number(sourceJobId) !== Number(orderedSourceJobIds[index])); const mergeSourcesChanged = orderedSourceIdsChanged || !areMultipartMergeSourceItemsEquivalent(existingSourceItems, sourceItems); const shouldPreserveFinishedResult = ( existingStatus === 'FINISHED' && !mergeSourcesChanged && existingOutputPath && fs.existsSync(existingOutputPath) ); await historyService.updateJob(existingMergeJob.id, { parent_job_id: normalizedContainerJobId, title: containerJob.title || containerJob.detected_title || existingMergeJob.detected_title || null, year: containerJob.year || null, imdb_id: containerJob.imdb_id || null, poster_url: containerJob.poster_url || null, selected_from_omdb: 0, omdb_json: null, media_type: containerJob.media_type || null, job_kind: 'multipart_movie_merge', is_multipart_movie: 1, metadata_fingerprint: containerJob.metadata_fingerprint || null, makemkv_info_json: containerJob.makemkv_info_json || null, encode_plan_json: JSON.stringify(mergePlan), encode_review_confirmed: 1, ...((isRunning || shouldPreserveFinishedResult) ? {} : { status: 'READY_TO_START', last_state: 'READY_TO_START', output_path: null, encode_input_path: null, handbrake_info_json: null, error_message: null, end_time: null }) }); return { handled: true, mergeJobId: Number(existingMergeJob.id), created: false, sourceCount: sourceItems.length }; } if (!createIfMissing) { return { handled: false, reason: 'merge_job_missing', sourceCount: sourceItems.length }; } const createdMergeJob = await historyService.createJob({ discDevice: null, status: 'READY_TO_START', detectedTitle: containerJob.title || containerJob.detected_title || null, mediaType: containerJob.media_type || null, jobKind: 'multipart_movie_merge' }); const createdMergeJobId = this.normalizeQueueJobId(createdMergeJob?.id); if (!createdMergeJobId) { const error = new Error('Merge-Job konnte nicht erstellt werden.'); error.statusCode = 500; throw error; } await historyService.updateJob(createdMergeJobId, { parent_job_id: normalizedContainerJobId, title: containerJob.title || containerJob.detected_title || null, year: containerJob.year || null, imdb_id: containerJob.imdb_id || null, poster_url: containerJob.poster_url || null, selected_from_omdb: 0, omdb_json: null, media_type: containerJob.media_type || null, job_kind: 'multipart_movie_merge', is_multipart_movie: 1, disc_number: null, metadata_fingerprint: containerJob.metadata_fingerprint || null, makemkv_info_json: containerJob.makemkv_info_json || null, encode_plan_json: JSON.stringify(mergePlan), encode_review_confirmed: 1 }); await historyService.appendLog( createdMergeJobId, 'SYSTEM', `Merge-Job vorbereitet (${sourceItems.length} Datei(en)). Reihenfolge: ` + sourceItems.map((item) => `Disc ${item.discNumber || '?'} (Job #${item.jobId})`).join(' | ') ); return { handled: true, mergeJobId: createdMergeJobId, created: true, sourceCount: sourceItems.length }; } async updateMultipartMergeSourceOrder(jobId, orderedSourceJobIds = []) { const normalizedJobId = this.normalizeQueueJobId(jobId); if (!normalizedJobId) { const error = new Error('Ungültige Job-ID.'); error.statusCode = 400; throw error; } const job = await historyService.getJobById(normalizedJobId); if (!job || !this.isMultipartMovieMergeHistoryJob(job)) { const error = new Error('Merge-Job nicht gefunden.'); error.statusCode = 404; throw error; } const currentState = String(job?.status || job?.last_state || '').trim().toUpperCase(); if (currentState === 'ENCODING') { const error = new Error('Merge-Reihenfolge kann während des laufenden Merges nicht geändert werden.'); error.statusCode = 409; throw error; } const plan = this.safeParseJson(job.encode_plan_json) || {}; const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(plan); if (sourceItems.length < 2) { const error = new Error('Merge-Job enthält keine vollständige Source-Liste.'); error.statusCode = 409; throw error; } const normalizedOrder = normalizeReviewTitleIdList( Array.isArray(orderedSourceJobIds) ? orderedSourceJobIds : [] ); if (normalizedOrder.length === 0) { const error = new Error('orderedSourceJobIds darf nicht leer sein.'); error.statusCode = 400; throw error; } const byJobId = new Map(sourceItems.map((item) => [Number(item.jobId), item])); const nextItems = []; const seen = new Set(); for (const sourceJobId of normalizedOrder) { const item = byJobId.get(Number(sourceJobId)); if (!item || seen.has(Number(sourceJobId))) { continue; } seen.add(Number(sourceJobId)); nextItems.push(item); } for (const item of sourceItems) { const sourceJobId = Number(item.jobId); if (seen.has(sourceJobId)) { continue; } seen.add(sourceJobId); nextItems.push(item); } if (nextItems.length < 2) { const error = new Error('Merge-Reihenfolge ist unvollständig.'); error.statusCode = 400; throw error; } const nextPlan = { ...plan, orderedSourceJobIds: nextItems.map((item) => Number(item.jobId)), sourceItems: nextItems, updatedAt: nowIso() }; await historyService.updateJob(normalizedJobId, { status: 'READY_TO_START', last_state: 'READY_TO_START', end_time: null, error_message: null, output_path: null, encode_plan_json: JSON.stringify(nextPlan) }); await historyService.appendLog( normalizedJobId, 'USER_ACTION', `Merge-Reihenfolge aktualisiert: ${nextItems.map((item) => `Job #${item.jobId}`).join(' -> ')}` ); return historyService.getJobById(normalizedJobId); } async updateMultipartMergeSettings(jobId, settings = {}) { const normalizedJobId = this.normalizeQueueJobId(jobId); if (!normalizedJobId) { const error = new Error('Ungültige Job-ID.'); error.statusCode = 400; throw error; } const job = await historyService.getJobById(normalizedJobId); if (!job || !this.isMultipartMovieMergeHistoryJob(job)) { const error = new Error('Merge-Job nicht gefunden.'); error.statusCode = 404; throw error; } const currentState = String(job?.status || job?.last_state || '').trim().toUpperCase(); if (currentState === 'ENCODING') { const error = new Error('Merge-Optionen können während des laufenden Merges nicht geändert werden.'); error.statusCode = 409; throw error; } const plan = this.safeParseJson(job.encode_plan_json) || {}; const nextPlan = { ...plan }; let changed = false; if (Object.prototype.hasOwnProperty.call(settings, 'deleteInputsAfterMerge')) { const nextDeleteInputsAfterMerge = Boolean(settings?.deleteInputsAfterMerge); if (Boolean(plan?.deleteInputsAfterMerge) !== nextDeleteInputsAfterMerge) { nextPlan.deleteInputsAfterMerge = nextDeleteInputsAfterMerge; changed = true; } } if (!changed) { return job; } nextPlan.updatedAt = nowIso(); await historyService.updateJob(normalizedJobId, { encode_plan_json: JSON.stringify(nextPlan) }); await historyService.appendLog( normalizedJobId, 'USER_ACTION', `Merge-Option aktualisiert: Input-Dateien nach erfolgreichem Merge ${nextPlan.deleteInputsAfterMerge ? 'löschen' : 'behalten'}.` ); return historyService.getJobById(normalizedJobId); } async cleanupMultipartMergeSourceOutputs(sourceItems = [], options = {}) { const normalizedMergeJobId = this.normalizeQueueJobId(options?.mergeJobId); const rows = Array.isArray(sourceItems) ? sourceItems : []; const seenPaths = new Set(); const deleted = []; const missing = []; const failed = []; for (const item of rows) { const sourceJobId = this.normalizeQueueJobId(item?.jobId || item?.sourceJobId); const outputPath = normalizeComparablePath(item?.outputPath || item?.path || null); const discNumber = normalizePositiveInteger(item?.discNumber || null); if (!outputPath || seenPaths.has(outputPath)) { continue; } seenPaths.add(outputPath); try { const existedBefore = fs.existsSync(outputPath); if (!existedBefore) { missing.push({ sourceJobId: sourceJobId || null, discNumber: discNumber || null, outputPath }); } else { const stat = fs.lstatSync(outputPath); if (stat.isDirectory()) { fs.rmSync(outputPath, { recursive: true, force: true }); } else { fs.unlinkSync(outputPath); } deleted.push({ sourceJobId: sourceJobId || null, discNumber: discNumber || null, outputPath }); } if (sourceJobId) { const sourceJob = await historyService.getJobById(sourceJobId).catch(() => null); const currentSourceOutputPath = normalizeComparablePath(sourceJob?.output_path || null); if (currentSourceOutputPath && currentSourceOutputPath === outputPath) { await historyService.updateJob(sourceJobId, { output_path: null }).catch(() => {}); } await historyService.removeJobOutputFolderFromJobs([sourceJobId], outputPath).catch(() => {}); if (normalizedMergeJobId) { await historyService.appendLog( sourceJobId, 'SYSTEM', `Merge-Input nach erfolgreichem Merge gelöscht (Merge-Job #${normalizedMergeJobId}): ${outputPath}` ).catch(() => {}); } } } catch (cleanupError) { failed.push({ sourceJobId: sourceJobId || null, discNumber: discNumber || null, outputPath, reason: cleanupError?.message || String(cleanupError) }); } } return { enabled: true, deletedCount: deleted.length, missingCount: missing.length, failedCount: failed.length, deleted, missing, failed }; } async restoreMultipartMergeJobForContainer(containerJobId, options = {}) { const normalizedContainerJobId = this.normalizeQueueJobId(containerJobId); if (!normalizedContainerJobId) { const error = new Error('Ungültige Container-Job-ID.'); error.statusCode = 400; throw error; } const containerJob = options?.preloadedContainerJob && Number(options.preloadedContainerJob?.id) === Number(normalizedContainerJobId) ? options.preloadedContainerJob : await historyService.getJobById(normalizedContainerJobId); if (!containerJob) { const error = new Error(`Container-Job ${normalizedContainerJobId} nicht gefunden.`); error.statusCode = 404; throw error; } if (!this.isMultipartMovieContainerHistoryJob(containerJob)) { const error = new Error(`Job #${normalizedContainerJobId} ist kein Multipart-Movie-Container.`); error.statusCode = 409; throw error; } const allChildren = await historyService.listChildJobs(normalizedContainerJobId); const discChildren = (Array.isArray(allChildren) ? allChildren : []) .filter((child) => this.isMultipartMovieDiscChildHistoryJob(child)); if (discChildren.length < 2) { const error = new Error('Merge-Job kann erst ab mindestens 2 Discs wiederhergestellt werden.'); error.statusCode = 409; throw error; } const missingOutputs = []; for (const child of discChildren) { const outputPath = String(child?.output_path || '').trim(); if (!outputPath || !fs.existsSync(outputPath)) { missingOutputs.push({ jobId: Number(child?.id || 0) || null, discNumber: resolveDiscNumberFromJobLike(child), outputPath: outputPath || null }); } } if (missingOutputs.length > 0) { const detail = missingOutputs .map((item) => `Disc ${item.discNumber || '?'} / Job #${item.jobId || '?'}${item.outputPath ? ` (${item.outputPath})` : ''}`) .join(' | '); const error = new Error( `Merge-Job kann nicht wiederhergestellt werden: Es fehlen Output-Dateien (${detail}).` ); error.statusCode = 409; error.details = missingOutputs; throw error; } const upsertResult = await this.upsertMultipartMergeJobForContainer(normalizedContainerJobId, { containerJob, createIfMissing: true }); if (!upsertResult?.handled || !upsertResult?.mergeJobId) { const error = new Error('Merge-Job konnte nicht wiederhergestellt werden.'); error.statusCode = 409; throw error; } return historyService.getJobById(upsertResult.mergeJobId); } async analyzeMultipartMergeInputs(sourceItems = [], options = {}) { const normalizedSourceItems = Array.isArray(sourceItems) ? sourceItems : []; const ffprobeCommand = String(options?.ffprobeCommand || 'ffprobe').trim() || 'ffprobe'; const sourceAnalyses = []; for (const sourceItem of normalizedSourceItems) { const sourceJobId = this.normalizeQueueJobId(sourceItem?.jobId || sourceItem?.sourceJobId); const discNumber = normalizePositiveInteger(sourceItem?.discNumber); const outputPath = String(sourceItem?.outputPath || sourceItem?.path || '').trim(); if (!sourceJobId || !outputPath) { continue; } const sourceMeta = { sourceJobId, discNumber: discNumber || null, outputPath }; if (!fs.existsSync(outputPath)) { sourceAnalyses.push({ ...sourceMeta, error: 'output_missing' }); continue; } try { const probeResult = await this.runCapturedCommand(ffprobeCommand, [ '-v', 'error', '-show_chapters', '-show_streams', '-show_format', '-print_format', 'json', outputPath ]); const probeJson = this.safeParseJson(probeResult.stdout) || {}; sourceAnalyses.push({ ...sourceMeta, probeJson, formatDurationSeconds: Number.parseFloat(String(probeJson?.format?.duration || '').trim()) }); } catch (error) { sourceAnalyses.push({ ...sourceMeta, error: error?.message || 'ffprobe_failed' }); } } const chapterSourceGroups = sourceAnalyses .filter((entry) => !entry.error && entry.probeJson) .map((entry) => { const probeJson = entry.probeJson || {}; const probeChapters = Array.isArray(probeJson?.chapters) ? probeJson.chapters : []; let fallbackDurationNanoseconds = 0; for (const chapter of probeChapters) { const endNanoseconds = parseDecimalSecondsToNanoseconds(chapter?.end_time ?? chapter?.start_time); if (endNanoseconds != null && endNanoseconds > fallbackDurationNanoseconds) { fallbackDurationNanoseconds = endNanoseconds; } } const hasDuration = Number.isFinite(entry.formatDurationSeconds) && entry.formatDurationSeconds > 0; const durationSeconds = hasDuration ? entry.formatDurationSeconds : (fallbackDurationNanoseconds > 0 ? (fallbackDurationNanoseconds / 1_000_000_000) : null); return { discNumber: entry.discNumber, chapters: probeChapters, durationSeconds }; }); const allInputsAnalyzed = sourceAnalyses.length === normalizedSourceItems.length && sourceAnalyses.every((entry) => !entry.error); const chapterPlan = allInputsAnalyzed ? buildMultipartMergedChapterPlan(chapterSourceGroups) : null; const chapterPlanReason = chapterPlan ? null : (allInputsAnalyzed ? 'chapter_data_unavailable' : 'ffprobe_failed_or_input_missing'); return { sourceAnalyses, chapterPlan, chapterPlanReason, streamSummary: buildMultipartMergeStreamSummary(sourceAnalyses) }; } async getMultipartMergePreview(jobId, options = {}) { const normalizedJobId = this.normalizeQueueJobId(jobId); if (!normalizedJobId) { const error = new Error('Ungültige Job-ID.'); error.statusCode = 400; throw error; } const mergeJob = options?.preloadedJob && Number(options.preloadedJob?.id) === Number(normalizedJobId) ? options.preloadedJob : await historyService.getJobById(normalizedJobId); if (!mergeJob || !this.isMultipartMovieMergeHistoryJob(mergeJob)) { const error = new Error(`Merge-Job ${normalizedJobId} nicht gefunden.`); error.statusCode = 404; throw error; } const mergePlan = this.safeParseJson(mergeJob.encode_plan_json) || {}; const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(mergePlan); if (sourceItems.length < 2) { const error = new Error('Merge-Vorschau nicht möglich: es werden mindestens 2 Source-Dateien benötigt.'); error.statusCode = 409; throw error; } const mediaProfile = this.resolveMediaProfileForJob(mergeJob, { encodePlan: mergePlan, mediaProfile: mergePlan?.mediaProfile || mergeJob?.media_type || null }); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; const mkvmergeCommand = String(settings?.mkvmerge_command || 'mkvmerge').trim() || 'mkvmerge'; const mergeOutputTemplatePath = String(mergePlan?.mergeOutputPath || '').trim() || buildFinalOutputPathFromJob(settings, mergeJob, normalizedJobId); const expectedOutputPath = buildMultipartMergeOutputPath( mergeOutputTemplatePath, sourceItems ) || mergeOutputTemplatePath; const analysis = await this.analyzeMultipartMergeInputs(sourceItems, { ffprobeCommand }); const hasGeneratedChapterPlan = Boolean(analysis?.chapterPlan?.content); const commandArgs = ['-o', expectedOutputPath]; if (hasGeneratedChapterPlan) { commandArgs.push('--chapters', ''); } sourceItems.forEach((item, index) => { if (index > 0) { commandArgs.push('+'); } if (hasGeneratedChapterPlan) { commandArgs.push('--no-chapters'); } commandArgs.push(item.outputPath); }); const missingInputs = sourceItems .filter((item) => !fs.existsSync(item.outputPath)) .map((item) => ({ sourceJobId: item.jobId, discNumber: item.discNumber || null, outputPath: item.outputPath })); return { jobId: normalizedJobId, generatedAt: nowIso(), outputPath: expectedOutputPath, sourceItems, command: { cmd: mkvmergeCommand, args: commandArgs, preview: buildShellCommandPreview(mkvmergeCommand, commandArgs) }, chapters: { strategy: hasGeneratedChapterPlan ? 'generated_prefixed' : 'source_native', reason: analysis?.chapterPlanReason || null, count: Array.isArray(analysis?.chapterPlan?.entries) ? analysis.chapterPlan.entries.length : 0, entries: Array.isArray(analysis?.chapterPlan?.entries) ? analysis.chapterPlan.entries : [] }, media: { ...analysis.streamSummary }, validation: { missingInputs } }; } async startMultipartMergeJob(jobId, options = {}) { const normalizedJobId = this.normalizeQueueJobId(jobId); if (!normalizedJobId) { const error = new Error('Ungültige Job-ID.'); error.statusCode = 400; throw error; } this.ensureNotBusy('startMultipartMergeJob', normalizedJobId); const mergeJob = options?.preloadedJob && Number(options.preloadedJob?.id) === Number(normalizedJobId) ? options.preloadedJob : await historyService.getJobById(normalizedJobId); if (!mergeJob || !this.isMultipartMovieMergeHistoryJob(mergeJob)) { const error = new Error(`Merge-Job ${normalizedJobId} nicht gefunden.`); error.statusCode = 404; throw error; } const mergePlan = this.safeParseJson(mergeJob.encode_plan_json) || {}; const sourceItems = normalizeMultipartMergeSourceItemsFromPlan(mergePlan); const deleteInputsAfterMerge = Boolean(mergePlan?.deleteInputsAfterMerge); if (sourceItems.length < 2) { const error = new Error('Merge-Start nicht möglich: es werden mindestens 2 Source-Dateien benötigt.'); error.statusCode = 409; throw error; } for (const item of sourceItems) { if (!fs.existsSync(item.outputPath)) { const error = new Error(`Merge-Input fehlt: ${item.outputPath}`); error.statusCode = 409; throw error; } } const mediaProfile = this.resolveMediaProfileForJob(mergeJob, { encodePlan: mergePlan, mediaProfile: mergePlan?.mediaProfile || mergeJob?.media_type || null }); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const mkvmergeCommand = String(settings?.mkvmerge_command || 'mkvmerge').trim() || 'mkvmerge'; await this.ensureExternalToolAvailable(mkvmergeCommand, { label: 'mkvmerge' }); const mergeOutputTemplatePath = String(mergePlan?.mergeOutputPath || '').trim() || buildFinalOutputPathFromJob(settings, mergeJob, normalizedJobId); const preferredFinalOutputPath = buildMultipartMergeOutputPath( mergeOutputTemplatePath, sourceItems ) || mergeOutputTemplatePath; const incompleteOutputPath = buildIncompleteMergeOutputPathFromJob( settings, mergeJob, preferredFinalOutputPath, normalizedJobId, { containerJobId: mergeJob?.parent_job_id, containerTitle: mergeJob?.title || mergeJob?.detected_title || null } ); ensureDir(path.dirname(incompleteOutputPath)); await this.setState('ENCODING', { activeJobId: normalizedJobId, progress: 0, eta: null, statusText: `Merge läuft (${sourceItems.length} Teile)`, context: { jobId: normalizedJobId, mode: 'multipart_merge', mediaProfile, deleteInputsAfterMerge, outputPath: incompleteOutputPath, sourceItems } }); await historyService.updateJob(normalizedJobId, { status: 'ENCODING', last_state: 'ENCODING', output_path: incompleteOutputPath, error_message: null, end_time: null }); await historyService.appendLog( normalizedJobId, 'SYSTEM', `Merge gestartet. Quellen: ${sourceItems.map((item) => item.outputPath).join(' | ')}` ); const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; ensureDir(tempDir); const mergeTmpDir = fs.mkdtempSync(path.join(tempDir, 'ripster-merge-')); void (async () => { let chaptersFilePath = null; let runInfo = null; try { let chapterPreparationMode = 'source_native'; let chapterPreparationEntries = 0; const mergeInputAnalysis = await this.analyzeMultipartMergeInputs(sourceItems, { ffprobeCommand }); const erroredSources = Array.isArray(mergeInputAnalysis?.sourceAnalyses) ? mergeInputAnalysis.sourceAnalyses.filter((entry) => Boolean(entry?.error)) : []; if (erroredSources.length > 0) { await historyService.appendLog( normalizedJobId, 'SYSTEM', `Kapitel-Analyse übersprungen (${erroredSources.map((entry) => `${entry.outputPath}: ${entry.error}`).join(' | ')}).` ); } const mergedChapterContent = String(mergeInputAnalysis?.chapterPlan?.content || '').trim(); if (mergedChapterContent) { chaptersFilePath = path.join(mergeTmpDir, `merge-job-${normalizedJobId}-chapters.txt`); fs.writeFileSync(chaptersFilePath, `${mergedChapterContent}\n`, 'utf8'); chapterPreparationMode = 'prefixed_from_inputs'; chapterPreparationEntries = Array.isArray(mergeInputAnalysis?.chapterPlan?.entries) ? mergeInputAnalysis.chapterPlan.entries.length : 0; await historyService.appendLog( normalizedJobId, 'SYSTEM', `Merge-Kapitel vorbereitet (${chapterPreparationEntries} Einträge, Disc-Prefix aktiv).` ); } else { await historyService.appendLog( normalizedJobId, 'SYSTEM', 'Merge-Kapitel konnten nicht normalisiert werden; verwende Kapitel direkt aus den Quellen.' ); } const mkvmergeArgs = ['-o', incompleteOutputPath]; if (chaptersFilePath) { mkvmergeArgs.push('--chapters', chaptersFilePath); } sourceItems.forEach((item, sourceIndex) => { if (sourceIndex > 0) { mkvmergeArgs.push('+'); } if (chaptersFilePath) { mkvmergeArgs.push('--no-chapters'); } mkvmergeArgs.push(item.outputPath); }); runInfo = await this.runCommand({ jobId: normalizedJobId, stage: 'ENCODING', source: 'MKVMERGE', cmd: mkvmergeCommand, args: mkvmergeArgs, parser: parseMkvmergeProgress }); const outputFinalization = finalizeOutputPathForCompletedEncode( incompleteOutputPath, preferredFinalOutputPath ); const finalizedOutputPath = outputFinalization.outputPath; const outputOwner = String(settings?.movie_dir_owner || '').trim(); chownRecursive(path.dirname(finalizedOutputPath), outputOwner); if (outputFinalization.outputPathWithTimestamp) { await historyService.appendLog( normalizedJobId, 'SYSTEM', `Finaler Merge-Output existierte bereits. Neuer Zielpfad: ${finalizedOutputPath}` ); } await historyService.appendLog(normalizedJobId, 'SYSTEM', `Merge-Output finalisiert: ${finalizedOutputPath}`); historyService.addJobOutputFolder(normalizedJobId, finalizedOutputPath).catch(() => {}); let sourceCleanupSummary = { enabled: false, deletedCount: 0, missingCount: 0, failedCount: 0, deleted: [], missing: [], failed: [] }; if (deleteInputsAfterMerge) { sourceCleanupSummary = await this.cleanupMultipartMergeSourceOutputs(sourceItems, { mergeJobId: normalizedJobId }); await historyService.appendLog( normalizedJobId, 'SYSTEM', `Merge-Input-Cleanup: gelöscht ${sourceCleanupSummary.deletedCount}, fehlend ${sourceCleanupSummary.missingCount}, Fehler ${sourceCleanupSummary.failedCount}.` ); if (sourceCleanupSummary.failedCount > 0) { logger.warn('multipart-merge:source-cleanup-partial-failure', { jobId: normalizedJobId, failedCount: sourceCleanupSummary.failedCount, failures: sourceCleanupSummary.failed }); } } const handbrakeInfo = { ...runInfo, mode: 'multipart_merge', tool: 'mkvmerge', chapterPreparation: { mode: chapterPreparationMode, chapterCount: chapterPreparationEntries }, sourceItems, sourceCleanup: sourceCleanupSummary, outputPath: finalizedOutputPath }; await historyService.updateJob(normalizedJobId, { handbrake_info_json: JSON.stringify(handbrakeInfo), status: 'FINISHED', last_state: 'FINISHED', end_time: nowIso(), rip_successful: 1, output_path: finalizedOutputPath, error_message: null }); await historyService.generateJobNfo(normalizedJobId, { mode: 'auto', requireSettingEnabled: true }).catch((nfoError) => { logger.warn('job:nfo:auto-generate-failed', { jobId: normalizedJobId, mode: 'multipart_merge', error: errorToMeta(nfoError) }); }); if (mergeJob?.parent_job_id) { await this.syncSeriesContainerStatusFromChildren(mergeJob.parent_job_id).catch((parentSyncError) => { logger.warn('multipart-merge:parent-status-sync-failed', { parentJobId: mergeJob.parent_job_id, mergeJobId: normalizedJobId, error: errorToMeta(parentSyncError) }); }); } if (Number(this.snapshot.activeJobId) === Number(normalizedJobId)) { await this.setState('FINISHED', { activeJobId: normalizedJobId, progress: 100, eta: null, statusText: 'Merge abgeschlossen', context: { jobId: normalizedJobId, mode: 'multipart_merge', outputPath: finalizedOutputPath } }); setTimeout(async () => { if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === normalizedJobId) { await this.setState('IDLE', { finishingJobId: normalizedJobId, activeJobId: null, progress: 0, eta: null, statusText: 'Bereit', context: {} }); } }, 3000); } else { void this.pumpQueue(); } void this.notifyPushover('job_finished', { title: 'Ripster - Merge abgeschlossen', message: `${mergeJob.title || mergeJob.detected_title || `Job #${normalizedJobId}`} -> ${finalizedOutputPath}` }); } catch (error) { if (runInfo) { await historyService.updateJob(normalizedJobId, { handbrake_info_json: JSON.stringify({ ...runInfo, mode: 'multipart_merge', sourceItems }) }).catch(() => {}); } await this.failJob(normalizedJobId, 'ENCODING', error).catch((failError) => { logger.error('multipart-merge:background-failJob-failed', { jobId: normalizedJobId, error: errorToMeta(failError) }); }); } finally { try { fs.rmSync(mergeTmpDir, { recursive: true, force: true }); } catch (_error) { // ignore tmp cleanup errors } } })(); return { started: true, stage: 'ENCODING', outputPath: incompleteOutputPath }; } isSeriesBatchChildJob(jobLike = null) { const plan = this.extractQueueJobPlan(jobLike); return isSeriesBatchChildPlan(plan); } resolveSeriesBatchParentJobId(jobLike = null) { const plan = this.extractQueueJobPlan(jobLike); const parentJobId = resolveSeriesBatchParentJobIdFromPlan( plan, jobLike?.parent_job_id ); return this.normalizeQueueJobId(parentJobId); } async listSeriesBatchChildJobs(parentJobId) { const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); if (!normalizedParentJobId) { return []; } const db = await getDb(); const rows = await db.all( ` SELECT * FROM jobs WHERE parent_job_id = ? OR CAST(json_extract(encode_plan_json, '$.seriesBatchParentJobId') AS INTEGER) = ? ORDER BY id ASC `, [normalizedParentJobId, normalizedParentJobId] ); return (Array.isArray(rows) ? rows : []).filter((row) => this.isSeriesBatchChildJob(row)); } async updateSeriesBatchParentProgress(parentJobId, options = {}) { const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); if (!normalizedParentJobId) { return null; } const parentJob = options?.parentJob && Number(options.parentJob?.id) === Number(normalizedParentJobId) ? options.parentJob : await historyService.getJobById(normalizedParentJobId); if (!parentJob) { return null; } if (!this.isSeriesBatchParentQueueAnchor(parentJob)) { return null; } const parentPlan = this.safeParseJson(parentJob.encode_plan_json); if (!isSeriesBatchParentPlan(parentPlan) || !Array.isArray(parentPlan?.seriesBatchEpisodes)) { this.seriesBatchParentProgressSyncAt.delete(Number(normalizedParentJobId)); return null; } const selectedTitleIds = normalizeReviewTitleIdList( Array.isArray(parentPlan?.selectedTitleIds) ? parentPlan.selectedTitleIds : parentPlan.seriesBatchEpisodes.map((item) => item?.titleId) ); const episodes = buildSeriesBatchEpisodesFromPlan(parentJob, parentPlan, selectedTitleIds); if (episodes.length === 0) { this.seriesBatchParentProgressSyncAt.delete(Number(normalizedParentJobId)); return null; } const childJobs = await this.listSeriesBatchChildJobs(normalizedParentJobId); const childByJobId = new Map(); const childByTitleId = new Map(); const childByEpisodeIndex = new Map(); const sortedChildJobs = [...childJobs].sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0)); for (const childJob of sortedChildJobs) { const childId = normalizePositiveInteger(childJob?.id); if (!childId) { continue; } childByJobId.set(Number(childId), childJob); const childPlan = this.safeParseJson(childJob?.encode_plan_json); const childTitleId = normalizeReviewTitleId( childPlan?.seriesBatchTitleId ?? childPlan?.encodeInputTitleId ?? null ); const childEpisodeIndex = normalizePositiveInteger( childPlan?.seriesBatchChildIndex ?? null ); if (childTitleId && !childByTitleId.has(Number(childTitleId))) { childByTitleId.set(Number(childTitleId), childJob); } if (childEpisodeIndex && !childByEpisodeIndex.has(Number(childEpisodeIndex))) { childByEpisodeIndex.set(Number(childEpisodeIndex), childJob); } } const normalizeEpisodeStatusFromChild = (childJob, fallbackStatus = 'QUEUED') => { const rawStatus = String(childJob?.status || childJob?.last_state || '').trim().toUpperCase(); if (rawStatus === 'FINISHED') { return 'FINISHED'; } if (rawStatus === 'ERROR') { return 'ERROR'; } if (rawStatus === 'CANCELLED') { return 'CANCELLED'; } if (['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'].includes(rawStatus)) { return 'RUNNING'; } return normalizeSeriesEpisodeStatus(fallbackStatus, 'QUEUED'); }; const syncedEpisodes = episodes.map((episode) => { const episodeJobId = normalizePositiveInteger(episode?.jobId); const episodeTitleId = normalizeReviewTitleId(episode?.titleId); const episodeIndex = normalizePositiveInteger(episode?.episodeIndex); const childJob = ( (episodeJobId ? childByJobId.get(Number(episodeJobId)) : null) || (episodeTitleId ? childByTitleId.get(Number(episodeTitleId)) : null) || (episodeIndex ? childByEpisodeIndex.get(Number(episodeIndex)) : null) || null ); if (!childJob) { return episode; } const childId = normalizePositiveInteger(childJob?.id) || episodeJobId || null; const nextStatus = normalizeEpisodeStatusFromChild(childJob, episode?.status); const liveChildProgress = Number(this.jobProgress.get(Number(childId))?.progress); const liveChildEta = this.jobProgress.get(Number(childId))?.eta ?? null; const fallbackProgress = Number(episode?.progress || 0); let nextProgress = Number.isFinite(fallbackProgress) ? fallbackProgress : 0; if (nextStatus === 'FINISHED') { nextProgress = 100; } else if (nextStatus === 'RUNNING' && Number.isFinite(liveChildProgress)) { nextProgress = Math.max(0, Math.min(100, Number(liveChildProgress))); } else if (nextStatus === 'ERROR' || nextStatus === 'CANCELLED') { nextProgress = Math.max(0, Math.min(100, nextProgress)); } else { nextProgress = Math.max(0, Math.min(100, nextProgress)); } const childHandbrakeInfo = this.safeParseJson(childJob?.handbrake_info_json); return { ...episode, jobId: childId, status: nextStatus, progress: Number(nextProgress.toFixed(2)), eta: nextStatus === 'RUNNING' ? liveChildEta : null, startedAt: String(childJob?.start_time || '').trim() || episode?.startedAt || null, finishedAt: ( nextStatus === 'FINISHED' || nextStatus === 'ERROR' || nextStatus === 'CANCELLED' ) ? (String(childJob?.end_time || '').trim() || episode?.finishedAt || null) : null, outputPath: String(childJob?.output_path || '').trim() || episode?.outputPath || null, error: ( nextStatus === 'ERROR' || nextStatus === 'CANCELLED' ) ? (String(childJob?.error_message || '').trim() || episode?.error || null) : null, handbrakeInfo: childHandbrakeInfo && typeof childHandbrakeInfo === 'object' ? childHandbrakeInfo : (episode?.handbrakeInfo && typeof episode.handbrakeInfo === 'object' ? episode.handbrakeInfo : null) }; }); const summary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, syncedEpisodes); const totalCount = summary.totalCount; const finishedCount = summary.finishedCount; const cancelledCount = summary.cancelledCount; const errorCount = summary.errorCount; const runningCount = summary.runningCount; const totalProgress = summary.overallProgress; const allTerminal = runningCount === 0 && (finishedCount + cancelledCount + errorCount) === totalCount; const summaryText = summary.summaryText; const contextPatch = { seriesBatch: { parentJobId: normalizedParentJobId, totalCount, finishedCount, cancelledCount, errorCount, runningCount, children: summary.children } }; const nextSeriesBatchChildJobIds = normalizeReviewTitleIdList( syncedEpisodes .map((episode) => normalizePositiveInteger(episode?.jobId)) .filter(Boolean) ); const nextCompletedTitleIds = normalizeReviewTitleIdList([ ...(Array.isArray(parentPlan?.seriesBatchCompletedTitleIds) ? parentPlan.seriesBatchCompletedTitleIds : []), ...syncedEpisodes .filter((episode) => normalizeSeriesEpisodeStatus(episode?.status, 'QUEUED') === 'FINISHED') .map((episode) => episode?.titleId) ]); const nextPlan = { ...parentPlan, seriesBatchParent: true, seriesBatchChild: false, seriesBatchEpisodes: syncedEpisodes, seriesBatchChildJobIds: nextSeriesBatchChildJobIds, seriesBatchCompletedTitleIds: nextCompletedTitleIds }; const previousHandbrakeInfo = this.safeParseJson(parentJob?.handbrake_info_json) || {}; const nextHandbrakeInfo = { ...(previousHandbrakeInfo && typeof previousHandbrakeInfo === 'object' ? previousHandbrakeInfo : {}), mode: 'series_batch', seriesBatch: { parentJobId: normalizedParentJobId, totalCount: summary.totalCount, finishedCount: summary.finishedCount, cancelledCount: summary.cancelledCount, errorCount: summary.errorCount, runningCount: summary.runningCount, episodes: syncedEpisodes } }; const containerParentJobId = this.normalizeQueueJobId(parentJob?.parent_job_id); if (allTerminal) { this.seriesBatchParentProgressSyncAt.delete(Number(normalizedParentJobId)); for (const [childJobId, mappedParentJobId] of this.seriesBatchParentByChild.entries()) { if (Number(mappedParentJobId) === Number(normalizedParentJobId)) { this.seriesBatchParentByChild.delete(Number(childJobId)); } } const seriesChildJobIdSet = new Set( nextSeriesBatchChildJobIds .map((value) => Number(value)) .filter((value) => Number.isFinite(value) && value > 0) ); const finalState = errorCount > 0 ? 'ERROR' : (cancelledCount > 0 ? 'CANCELLED' : 'FINISHED'); let finalizedParentRawPath = String(parentJob?.raw_path || '').trim() || null; if (finalState === 'FINISHED' && finalizedParentRawPath) { const completedRawPath = buildCompletedRawPath(finalizedParentRawPath); if (completedRawPath && completedRawPath !== finalizedParentRawPath) { if (fs.existsSync(completedRawPath)) { logger.warn('series-batch:raw-dir-finalize:target-exists', { parentJobId: normalizedParentJobId, sourceRawPath: finalizedParentRawPath, targetRawPath: completedRawPath }); } else { try { fs.renameSync(finalizedParentRawPath, completedRawPath); await historyService.updateRawPathByOldPath(finalizedParentRawPath, completedRawPath); await historyService.appendLog( normalizedParentJobId, 'SYSTEM', `RAW-Ordner nach Abschluss aller Serien-Encodes finalisiert (Prefix entfernt): ${finalizedParentRawPath} -> ${completedRawPath}` ); finalizedParentRawPath = completedRawPath; } catch (rawFinalizeError) { logger.warn('series-batch:raw-dir-finalize:rename-failed', { parentJobId: normalizedParentJobId, sourceRawPath: finalizedParentRawPath, targetRawPath: completedRawPath, error: errorToMeta(rawFinalizeError) }); } } } } const finalErrorMessage = finalState === 'FINISHED' ? null : ( finalState === 'ERROR' ? `Serien-Batch beendet mit ${errorCount} Fehler(n).` : 'Serien-Batch abgebrochen.' ); await historyService.updateJob(normalizedParentJobId, { encode_plan_json: JSON.stringify(nextPlan), handbrake_info_json: JSON.stringify(nextHandbrakeInfo), status: finalState, last_state: finalState, end_time: nowIso(), error_message: finalErrorMessage, ...(finalizedParentRawPath ? { raw_path: finalizedParentRawPath } : {}) }); // Remove stale queue entries that still target this parent batch. this.queueEntries = this.queueEntries.filter((entry) => { const action = String(entry?.action || '').trim().toUpperCase(); const entryJobId = Number(entry?.jobId); const isLegacyParentEntry = action === QUEUE_ACTIONS.START_SERIES_EPISODE && entryJobId === Number(normalizedParentJobId); const isSeriesChildEntry = (!entry?.type || entry.type === 'job') && Number.isFinite(entryJobId) && entryJobId > 0 && seriesChildJobIdSet.has(entryJobId); if (isLegacyParentEntry || isSeriesChildEntry) { return false; } return true; }); if (Number(this.snapshot.activeJobId) === Number(normalizedParentJobId)) { await this.setState(finalState, { activeJobId: normalizedParentJobId, progress: totalProgress, eta: null, statusText: summaryText, context: { ...(this.snapshot.context || {}), jobId: normalizedParentJobId, ...contextPatch } }); } else { await this.updateProgress(finalState, totalProgress, null, summaryText, normalizedParentJobId, { contextPatch }); } if (containerParentJobId) { await this.syncSeriesContainerStatusFromChildren(containerParentJobId).catch((syncError) => { logger.warn('series-container:status-sync-from-batch-parent-final-failed', { containerJobId: containerParentJobId, batchParentJobId: normalizedParentJobId, error: errorToMeta(syncError) }); }); } return { parentJobId: normalizedParentJobId, final: true, state: finalState, progress: totalProgress, summaryText, childCount: totalCount }; } const terminalCount = finishedCount + cancelledCount + errorCount; const hasPendingChildren = terminalCount < totalCount; const hasErrors = errorCount > 0; const hasCancellations = cancelledCount > 0; // Keep parent job in ENCODING while any child is still pending/running. // Partial errors/cancellations are reflected via summary/error counters, // but must not flip the parent state to terminal before all children end. const nextState = hasPendingChildren ? 'ENCODING' : (hasErrors ? 'ERROR' : (hasCancellations ? 'CANCELLED' : 'READY_TO_ENCODE')); const nextLastState = hasPendingChildren ? 'ENCODING' : nextState; const nextErrorMessage = hasErrors ? `Serien-Batch enthält ${errorCount} Fehler-Episode(n).` : ( hasCancellations ? `Serien-Batch enthält ${cancelledCount} abgebrochene Episode(n).` : null ); await historyService.updateJob(normalizedParentJobId, { encode_plan_json: JSON.stringify(nextPlan), handbrake_info_json: JSON.stringify(nextHandbrakeInfo), status: nextState, last_state: nextLastState, end_time: null, error_message: nextErrorMessage }); await this.updateProgress(nextState, totalProgress, null, summaryText, normalizedParentJobId, { contextPatch }); if (containerParentJobId) { await this.syncSeriesContainerStatusFromChildren(containerParentJobId).catch((syncError) => { logger.warn('series-container:status-sync-from-batch-parent-progress-failed', { containerJobId: containerParentJobId, batchParentJobId: normalizedParentJobId, error: errorToMeta(syncError) }); }); } return { parentJobId: normalizedParentJobId, final: false, state: nextState, progress: totalProgress, summaryText, childCount: totalCount }; } async startSeriesBatchFromPrepared(parentJobId, parentJob, options = {}) { const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); if (!normalizedParentJobId) { return { handled: false }; } const sourceJob = parentJob && Number(parentJob?.id) === Number(normalizedParentJobId) ? parentJob : await historyService.getJobById(normalizedParentJobId); if (!sourceJob) { return { handled: false }; } const parentPlan = this.safeParseJson(sourceJob.encode_plan_json); if (isSeriesBatchChildPlan(parentPlan)) { return { handled: false }; } const planMode = String(parentPlan?.mode || '').trim().toLowerCase(); const isPreRipPlan = planMode === 'pre_rip' || Boolean(parentPlan?.preRip); if (isPreRipPlan) { return { handled: false }; } const mediaProfile = this.resolveMediaProfileForJob(sourceJob, { encodePlan: parentPlan }); const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json); const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' ? mkInfo.analyzeContext : {}; const activeContextForJob = Number(this.snapshot?.activeJobId) === Number(normalizedParentJobId) ? (this.snapshot?.context || {}) : null; const selectedMetadata = resolveSelectedMetadataForJob(sourceJob, analyzeContext, activeContextForJob); const isSeriesDvd = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); const selectedTitleIds = resolveSeriesBatchSelectedTitleIdsFromPlan(parentPlan); if (!isSeriesDvd || selectedTitleIds.length <= 1) { return { handled: false }; } const allTitles = Array.isArray(parentPlan?.titles) ? parentPlan.titles : []; const validSelectedTitleIds = selectedTitleIds.filter((titleId) => allTitles.some((title) => Number(title?.id) === Number(titleId)) ); if (validSelectedTitleIds.length <= 1) { return { handled: false }; } const nextEpisodes = buildSeriesBatchEpisodesFromPlan(sourceJob, parentPlan, validSelectedTitleIds); if (nextEpisodes.length <= 1) { return { handled: false }; } const preparedEpisodes = []; for (let index = 0; index < nextEpisodes.length; index += 1) { const episode = nextEpisodes[index]; const childPlan = buildSeriesBatchChildPlan( parentPlan, episode.titleId, normalizedParentJobId, index, nextEpisodes.length ); const episodeTitle = String(episode?.label || '').trim() || resolveSeriesBatchChildDisplayTitle(sourceJob, parentPlan, episode.titleId, index); const childJob = await historyService.createJob({ discDevice: sourceJob.disc_device || null, status: 'READY_TO_ENCODE', detectedTitle: episodeTitle, mediaType: sourceJob.media_type || mediaProfile, jobKind: 'dvd_series_child' }); const childJobId = this.normalizeQueueJobId(childJob?.id); if (!childJobId) { const error = new Error('Serien-Episode konnte nicht als Child-Job angelegt werden.'); error.statusCode = 500; throw error; } await historyService.updateJob(childJobId, { parent_job_id: Number(normalizedParentJobId), media_type: sourceJob.media_type || mediaProfile, job_kind: 'dvd_series_child', title: episodeTitle, detected_title: episodeTitle, year: sourceJob.year ?? null, imdb_id: sourceJob.imdb_id || null, poster_url: sourceJob.poster_url || null, omdb_json: null, selected_from_omdb: 0, status: 'READY_TO_ENCODE', last_state: 'READY_TO_ENCODE', error_message: null, end_time: null, output_path: null, disc_device: sourceJob.disc_device || null, raw_path: sourceJob.raw_path || null, rip_successful: Number(sourceJob.rip_successful || 0), makemkv_info_json: sourceJob.makemkv_info_json || null, handbrake_info_json: null, mediainfo_info_json: sourceJob.mediainfo_info_json || null, encode_plan_json: JSON.stringify({ ...childPlan, reviewConfirmed: true, reviewConfirmedAt: String(childPlan?.reviewConfirmedAt || '').trim() || nowIso() }), encode_input_path: childPlan?.encodeInputPath || sourceJob.encode_input_path || sourceJob.raw_path || null, encode_review_confirmed: 1 }); await historyService.appendLog( childJobId, 'SYSTEM', `Serien-Episode als Queue-Subjob vorbereitet (Parent #${normalizedParentJobId}, Episode ${episode.episodeIndex}/${nextEpisodes.length}).` ); preparedEpisodes.push({ ...episode, jobId: Number(childJobId), status: 'QUEUED', progress: 0, eta: null, startedAt: null, finishedAt: null, outputPath: null, error: null }); } const nextParentPlan = { ...parentPlan, seriesBatchParent: true, seriesBatchChild: false, seriesBatchParentJobId: null, seriesBatchChildJobIds: normalizeReviewTitleIdList(preparedEpisodes.map((episode) => episode?.jobId)), seriesBatchCompletedTitleIds: [], seriesBatchDispatchedAt: nowIso(), seriesBatchTotalChildren: preparedEpisodes.length, seriesBatchEpisodes: preparedEpisodes, reviewConfirmed: true }; const progressSummary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, preparedEpisodes); const parentBatchState = preparedEpisodes.length > 0 ? 'ENCODING' : 'READY_TO_ENCODE'; await historyService.updateJob(normalizedParentJobId, { encode_plan_json: JSON.stringify(nextParentPlan), status: parentBatchState, last_state: parentBatchState, end_time: null, error_message: null, encode_review_confirmed: 1 }); await historyService.appendLog( normalizedParentJobId, 'SYSTEM', `Serien-Batch gestartet: ${preparedEpisodes.length} Episode(n) als reguläre Queue-Subjobs angelegt.` ); let queuedChildren = 0; let startedChildren = 0; for (let index = 0; index < preparedEpisodes.length; index += 1) { const episode = preparedEpisodes[index]; const episodeJobId = this.normalizeQueueJobId(episode?.jobId); if (!episodeJobId) { continue; } const queueResult = await this.enqueueOrStartAction( QUEUE_ACTIONS.START_PREPARED, episodeJobId, () => this.startPreparedJob(episodeJobId, { immediate: true }), { poolType: 'film', allowDuplicateJobEntries: true, forceQueue: true, uniqueEntryKey: `series_episode_child_${episodeJobId}`, entryData: { seriesEpisodeIndex: episode.episodeIndex, seriesTitleId: episode.titleId, seriesEpisodeLabel: episode.label } } ); if (queueResult?.queued) { queuedChildren += 1; } else if (queueResult?.started) { startedChildren += 1; } } await this.updateProgress( parentBatchState, progressSummary.overallProgress, null, progressSummary.summaryText, normalizedParentJobId, { contextPatch: { seriesBatch: { parentJobId: normalizedParentJobId, totalCount: progressSummary.totalCount, finishedCount: progressSummary.finishedCount, cancelledCount: progressSummary.cancelledCount, errorCount: progressSummary.errorCount, runningCount: progressSummary.runningCount, children: progressSummary.children } } } ); const containerParentJobId = this.normalizeQueueJobId(sourceJob?.parent_job_id); if (containerParentJobId) { await this.syncSeriesContainerStatusFromChildren(containerParentJobId).catch((syncError) => { logger.warn('series-container:status-sync-on-batch-start-failed', { containerJobId: containerParentJobId, batchParentJobId: normalizedParentJobId, error: errorToMeta(syncError) }); }); } return { handled: true, result: { started: startedChildren > 0, stage: parentBatchState, seriesBatch: true, childCount: nextEpisodes.length, queuedChildren, startedChildren } }; } async startSeriesBatchEpisode(parentJobId, options = {}) { const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); if (!normalizedParentJobId) { const error = new Error('Ungültige Serien-Job-ID.'); error.statusCode = 400; throw error; } const sourceParentJob = await historyService.getJobById(normalizedParentJobId); if (!sourceParentJob) { const error = new Error(`Job ${normalizedParentJobId} nicht gefunden.`); error.statusCode = 404; throw error; } const sourcePlan = this.safeParseJson(sourceParentJob.encode_plan_json); if (!isSeriesBatchParentPlan(sourcePlan) || !Array.isArray(sourcePlan?.seriesBatchEpisodes)) { const error = new Error(`Job ${normalizedParentJobId} ist kein Serien-Batch-Parent.`); error.statusCode = 409; throw error; } const requestedEpisodeIndex = normalizePositiveInteger(options?.episodeIndex); const requestedTitleId = normalizeReviewTitleId(options?.titleId); const episodes = buildSeriesBatchEpisodesFromPlan( sourceParentJob, sourcePlan, sourcePlan.selectedTitleIds || sourcePlan.seriesBatchEpisodes.map((item) => item?.titleId) ); const targetEpisodeIdx = episodes.findIndex((item) => ( (requestedEpisodeIndex && Number(item?.episodeIndex) === Number(requestedEpisodeIndex)) || (requestedTitleId && Number(item?.titleId) === Number(requestedTitleId)) )); const fallbackEpisodeIdx = episodes.findIndex((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'QUEUED'); const episodeIndex = targetEpisodeIdx >= 0 ? targetEpisodeIdx : fallbackEpisodeIdx; if (episodeIndex < 0) { return { started: false, seriesBatch: true, stage: 'FINISHED', reason: 'no_queued_episode' }; } const episode = episodes[episodeIndex]; const episodeStatus = normalizeSeriesEpisodeStatus(episode?.status, 'QUEUED'); if (episodeStatus === 'FINISHED') { return { started: false, seriesBatch: true, stage: 'ENCODING', reason: 'already_finished' }; } if (this.activeProcesses.has(Number(normalizedParentJobId))) { const error = new Error(`Serien-Episode kann nicht gestartet werden: Job #${normalizedParentJobId} läuft bereits.`); error.statusCode = 409; throw error; } const now = nowIso(); const runningEpisodes = episodes.map((item, idx) => ( idx === episodeIndex ? { ...item, status: 'RUNNING', progress: 0, eta: null, startedAt: now, finishedAt: null, error: null } : item )); const runningPlan = { ...sourcePlan, seriesBatchEpisodes: runningEpisodes, seriesBatchParent: true, seriesBatchChild: false, seriesBatchChildJobIds: [] }; await historyService.updateJob(normalizedParentJobId, { encode_plan_json: JSON.stringify(runningPlan), status: 'ENCODING', last_state: 'ENCODING', end_time: null, error_message: null, encode_review_confirmed: 1 }); await historyService.appendLog( normalizedParentJobId, 'SYSTEM', `Serien-Episode gestartet (${episode.episodeIndex}/${runningEpisodes.length}): ${episode.label}` ); const runningSummary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, runningEpisodes); await this.updateProgress('ENCODING', runningSummary.overallProgress, null, runningSummary.summaryText, normalizedParentJobId, { contextPatch: { seriesBatch: { parentJobId: normalizedParentJobId, totalCount: runningSummary.totalCount, finishedCount: runningSummary.finishedCount, cancelledCount: runningSummary.cancelledCount, errorCount: runningSummary.errorCount, runningCount: runningSummary.runningCount, children: runningSummary.children } } }); const episodePlanRaw = buildSeriesBatchChildPlan( runningPlan, episode.titleId, normalizedParentJobId, episodeIndex, runningEpisodes.length ); const manualSelectionByTitle = runningPlan?.manualTrackSelectionByTitle && typeof runningPlan.manualTrackSelectionByTitle === 'object' ? runningPlan.manualTrackSelectionByTitle : {}; const episodeManualSelection = manualSelectionByTitle[String(episode.titleId)] || manualSelectionByTitle[Number(episode.titleId)] || null; const episodeManualSelectionByTitle = episodeManualSelection && typeof episodeManualSelection === 'object' ? { [String(episode.titleId)]: { ...episodeManualSelection, titleId: Number(episode.titleId) } } : {}; const episodePlan = { ...episodePlanRaw, seriesBatchParent: false, seriesBatchChild: false, seriesBatchParentJobId: null, seriesBatchChildIndex: null, seriesBatchChildCount: null, seriesBatchTitleId: null, seriesBatchVirtualEpisode: true, manualTrackSelection: episodeManualSelection || null, manualTrackSelectionByTitle: episodeManualSelectionByTitle }; try { const episodeResult = await this.startEncodingFromPrepared(normalizedParentJobId, { encodePlanOverride: episodePlan, seriesBatchEpisodeContext: { parentJobId: normalizedParentJobId, episodeIndex: episode.episodeIndex, episodeLabel: episode.label, episodeCount: runningEpisodes.length } }); const postJob = await historyService.getJobById(normalizedParentJobId); const postPlanRaw = this.safeParseJson(postJob?.encode_plan_json); const postPlan = postPlanRaw && typeof postPlanRaw === 'object' ? postPlanRaw : runningPlan; const postEpisodes = buildSeriesBatchEpisodesFromPlan( postJob || sourceParentJob, postPlan, postPlan.selectedTitleIds || runningEpisodes.map((item) => item.titleId) ).map((item, idx) => ( idx === episodeIndex ? { ...item, status: 'FINISHED', progress: 100, eta: null, finishedAt: nowIso(), error: null, outputPath: episodeResult?.outputPath || item?.outputPath || null, trackSelection: episodeResult?.trackSelection || item?.trackSelection || null, handbrakeInfo: episodeResult?.handbrakeInfo || item?.handbrakeInfo || null } : item )); const nextPlan = { ...postPlan, seriesBatchParent: true, seriesBatchChild: false, seriesBatchChildJobIds: [], seriesBatchCompletedTitleIds: normalizeReviewTitleIdList([ ...(Array.isArray(postPlan?.seriesBatchCompletedTitleIds) ? postPlan.seriesBatchCompletedTitleIds : []), episode.titleId, ...postEpisodes .filter((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'FINISHED') .map((item) => item?.titleId) ]), seriesBatchEpisodes: postEpisodes }; const summary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, postEpisodes); const allFinished = postEpisodes.length > 0 && postEpisodes.every((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'FINISHED'); const anyErrors = postEpisodes.some((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'ERROR'); const anyCancelled = postEpisodes.some((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'CANCELLED'); const previousHandbrakeInfo = this.safeParseJson(postJob?.handbrake_info_json) || {}; const nextHandbrakeInfo = { ...(previousHandbrakeInfo && typeof previousHandbrakeInfo === 'object' ? previousHandbrakeInfo : {}), mode: 'series_batch', seriesBatch: { parentJobId: normalizedParentJobId, totalCount: summary.totalCount, finishedCount: summary.finishedCount, cancelledCount: summary.cancelledCount, errorCount: summary.errorCount, runningCount: summary.runningCount, episodes: postEpisodes } }; const nextState = allFinished ? (anyErrors ? 'ERROR' : (anyCancelled ? 'CANCELLED' : 'FINISHED')) : 'READY_TO_ENCODE'; const nextLastState = allFinished ? nextState : 'ENCODING'; await historyService.updateJob(normalizedParentJobId, { encode_plan_json: JSON.stringify(nextPlan), handbrake_info_json: JSON.stringify(nextHandbrakeInfo), status: nextState, last_state: nextLastState, end_time: allFinished ? nowIso() : null, error_message: allFinished && nextState !== 'FINISHED' ? (nextState === 'ERROR' ? 'Serien-Batch beendet mit Fehlern.' : 'Serien-Batch abgebrochen.') : null, output_path: episodeResult?.outputPath || postJob?.output_path || null }); await historyService.appendLog( normalizedParentJobId, 'SYSTEM', `Serien-Episode abgeschlossen (${episode.episodeIndex}/${postEpisodes.length}): ${episode.label}${episodeResult?.outputPath ? ` -> ${episodeResult.outputPath}` : ''}` ); await this.updateProgress(nextState, summary.overallProgress, null, summary.summaryText, normalizedParentJobId, { contextPatch: { seriesBatch: { parentJobId: normalizedParentJobId, totalCount: summary.totalCount, finishedCount: summary.finishedCount, cancelledCount: summary.cancelledCount, errorCount: summary.errorCount, runningCount: summary.runningCount, children: summary.children } } }); if (allFinished && nextState === 'FINISHED') { void this.notifyPushover('job_finished', { title: 'Ripster - Job abgeschlossen', message: `${sourceParentJob.title || sourceParentJob.detected_title || `Job #${normalizedParentJobId}`} (${summary.finishedCount} Episode(n))` }); const parentMediaProfile = this.resolveMediaProfileForJob(sourceParentJob, { encodePlan: nextPlan }); const parentSettings = await settingsService.getEffectiveSettingsMap(parentMediaProfile); void this.ejectDriveIfEnabled(parentSettings); if (Number(this.snapshot.activeJobId) === Number(normalizedParentJobId)) { setTimeout(async () => { if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === normalizedParentJobId) { await this.setState('IDLE', { finishingJobId: normalizedParentJobId, activeJobId: null, progress: 0, eta: null, statusText: 'Bereit', context: {} }); } }, 3000); } } void this.emitQueueChanged(); void this.pumpQueue(); return { started: true, seriesBatch: true, stage: nextState, finished: allFinished, outputPath: episodeResult?.outputPath || null }; } catch (error) { const cancelled = this.cancelRequestedByJob.has(Number(normalizedParentJobId)) || String(error?.runInfo?.status || '').trim().toUpperCase() === 'CANCELLED'; const postJob = await historyService.getJobById(normalizedParentJobId); const postPlanRaw = this.safeParseJson(postJob?.encode_plan_json); const postPlan = postPlanRaw && typeof postPlanRaw === 'object' ? postPlanRaw : runningPlan; const postEpisodes = buildSeriesBatchEpisodesFromPlan( postJob || sourceParentJob, postPlan, postPlan.selectedTitleIds || runningEpisodes.map((item) => item.titleId) ).map((item, idx) => { const itemStatus = normalizeSeriesEpisodeStatus(item?.status, 'QUEUED'); if (idx === episodeIndex) { return { ...item, status: cancelled ? 'CANCELLED' : 'ERROR', progress: itemStatus === 'FINISHED' ? 100 : 0, eta: null, finishedAt: nowIso(), error: error?.message || (cancelled ? 'Vom Benutzer abgebrochen.' : 'Encode fehlgeschlagen.') }; } if (itemStatus === 'QUEUED' || itemStatus === 'RUNNING') { return { ...item, status: cancelled ? 'CANCELLED' : 'ERROR', progress: 0, eta: null, finishedAt: nowIso(), error: cancelled ? 'Batch abgebrochen.' : 'Batch wegen Fehler gestoppt.' }; } return item; }); const nextPlan = { ...postPlan, seriesBatchParent: true, seriesBatchChild: false, seriesBatchChildJobIds: [], seriesBatchCompletedTitleIds: normalizeReviewTitleIdList( Array.isArray(postPlan?.seriesBatchCompletedTitleIds) ? postPlan.seriesBatchCompletedTitleIds : [] ), seriesBatchEpisodes: postEpisodes }; const summary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, postEpisodes); const nextState = cancelled ? 'CANCELLED' : 'ERROR'; const previousHandbrakeInfo = this.safeParseJson(postJob?.handbrake_info_json) || {}; const nextHandbrakeInfo = { ...(previousHandbrakeInfo && typeof previousHandbrakeInfo === 'object' ? previousHandbrakeInfo : {}), mode: 'series_batch', seriesBatch: { parentJobId: normalizedParentJobId, totalCount: summary.totalCount, finishedCount: summary.finishedCount, cancelledCount: summary.cancelledCount, errorCount: summary.errorCount, runningCount: summary.runningCount, episodes: postEpisodes } }; const queueBefore = this.queueEntries.length; this.queueEntries = this.queueEntries.filter((entry) => !( Number(entry?.jobId) === Number(normalizedParentJobId) && String(entry?.action || '').trim().toUpperCase() === QUEUE_ACTIONS.START_SERIES_EPISODE )); const removedSeriesEntries = Math.max(0, queueBefore - this.queueEntries.length); await historyService.updateJob(normalizedParentJobId, { encode_plan_json: JSON.stringify(nextPlan), handbrake_info_json: JSON.stringify(nextHandbrakeInfo), status: nextState, last_state: nextState, end_time: nowIso(), error_message: error?.message || (cancelled ? 'Vom Benutzer abgebrochen.' : 'Serien-Batch fehlgeschlagen.') }); await historyService.appendLog( normalizedParentJobId, 'SYSTEM', `Serien-Batch ${cancelled ? 'abgebrochen' : 'fehlgeschlagen'} nach Episode ${episode.episodeIndex}/${postEpisodes.length} (${episode.label}). Queue-Einträge entfernt: ${removedSeriesEntries}.` ); await this.updateProgress(nextState, summary.overallProgress, null, summary.summaryText, normalizedParentJobId, { contextPatch: { seriesBatch: { parentJobId: normalizedParentJobId, totalCount: summary.totalCount, finishedCount: summary.finishedCount, cancelledCount: summary.cancelledCount, errorCount: summary.errorCount, runningCount: summary.runningCount, children: summary.children } } }); await this.emitQueueChanged(); throw error; } } findQueueEntryIndexByJobId(jobId) { return this.queueEntries.findIndex((entry) => Number(entry?.jobId) === Number(jobId)); } removeDetachedQueueAutomationEntriesForJob(jobId, options = {}) { const normalizedJobId = this.normalizeQueueJobId(jobId); if (!normalizedJobId) { return 0; } const allowedPhases = Array.isArray(options?.phases) ? new Set( options.phases .map((value) => String(value || '').trim().toLowerCase()) .filter((value) => value === 'pre_encode' || value === 'post_encode') ) : null; const hasPhaseFilter = Boolean(allowedPhases && allowedPhases.size > 0); const queueLengthBefore = this.queueEntries.length; this.queueEntries = this.queueEntries.filter((entry) => { const entryType = String(entry?.type || '').trim().toLowerCase(); if (entryType !== 'script' && entryType !== 'chain') { return true; } const sourceJobId = this.normalizeQueueJobId(entry?.sourceJobId); if (sourceJobId !== normalizedJobId) { return true; } if (!hasPhaseFilter) { return false; } const sourcePhase = String(entry?.sourcePhase || '').trim().toLowerCase(); return !allowedPhases.has(sourcePhase); }); return Math.max(0, queueLengthBefore - this.queueEntries.length); } 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; } normalizeQueueAutomationItems(rawItems = []) { const items = Array.isArray(rawItems) ? rawItems : []; const seen = new Set(); const output = []; for (const item of items) { if (!item || typeof item !== 'object') { continue; } const type = String(item.type || '').trim().toLowerCase(); if (type !== 'script' && type !== 'chain') { continue; } const id = Number(item.id ?? item.scriptId ?? item.chainId); if (!Number.isFinite(id) || id <= 0) { continue; } const normalizedId = Math.trunc(id); const key = `${type}:${normalizedId}`; if (seen.has(key)) { continue; } seen.add(key); output.push({ type, id: normalizedId, name: String(item.name || '').trim() || null }); } return output; } extractDetachedQueueAutomationFromPlan(plan = null) { const sourcePlan = plan && typeof plan === 'object' ? plan : {}; const buildNameMap = (items = []) => { const map = new Map(); for (const item of (Array.isArray(items) ? items : [])) { if (!item || typeof item !== 'object') { continue; } const id = Number(item.id ?? item.scriptId ?? item.chainId); const name = String(item.name || item.scriptName || item.chainName || '').trim(); if (!Number.isFinite(id) || id <= 0 || !name) { continue; } map.set(Math.trunc(id), name); } return map; }; const buildFallbackItems = (scriptIds, chainIds, scriptNameMap, chainNameMap) => { const items = []; for (const scriptId of normalizeScriptIdList(scriptIds || [])) { items.push({ type: 'script', id: scriptId, name: scriptNameMap.get(scriptId) || null }); } for (const chainId of this.normalizeQueueChainIdList(chainIds || [])) { items.push({ type: 'chain', id: chainId, name: chainNameMap.get(chainId) || null }); } return this.normalizeQueueAutomationItems(items); }; const preFromItems = this.normalizeQueueAutomationItems(sourcePlan.preEncodeItems || []); const postFromItems = this.normalizeQueueAutomationItems(sourcePlan.postEncodeItems || []); const preScriptNameMap = buildNameMap(sourcePlan.preEncodeScripts || []); const postScriptNameMap = buildNameMap(sourcePlan.postEncodeScripts || []); const preChainNameMap = buildNameMap(sourcePlan.preEncodeChains || []); const postChainNameMap = buildNameMap(sourcePlan.postEncodeChains || []); const preItems = preFromItems.length > 0 ? preFromItems : buildFallbackItems( sourcePlan.preEncodeScriptIds || [], sourcePlan.preEncodeChainIds || [], preScriptNameMap, preChainNameMap ); const postItems = postFromItems.length > 0 ? postFromItems : buildFallbackItems( sourcePlan.postEncodeScriptIds || [], sourcePlan.postEncodeChainIds || [], postScriptNameMap, postChainNameMap ); return { preItems, postItems }; } buildDetachedQueueEntryFromAutomationItem(item, options = {}) { const source = item && typeof item === 'object' ? item : {}; const phase = String(options?.phase || '').trim().toLowerCase() === 'post' ? 'post' : 'pre'; const jobId = Number(options?.jobId); const sourceJobId = Number.isFinite(jobId) && jobId > 0 ? Math.trunc(jobId) : null; if (source.type === 'script') { return { id: this.queueEntrySeq++, type: 'script', scriptId: Number(source.id), scriptName: String(source.name || '').trim() || null, sourceJobId, sourcePhase: phase === 'pre' ? 'pre_encode' : 'post_encode', enqueuedAt: nowIso() }; } return { id: this.queueEntrySeq++, type: 'chain', chainId: Number(source.id), chainName: String(source.name || '').trim() || null, sourceJobId, sourcePhase: phase === 'pre' ? 'pre_encode' : 'post_encode', enqueuedAt: nowIso() }; } 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 visibleRunningJobs = (Array.isArray(runningJobs) ? runningJobs : []).filter((job) => ( !this.isContainerHistoryJob(job) && !this.isSeriesBatchParentQueueAnchor(job) )); 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( visibleRunningJobs .map((job) => Number(job?.id)) .filter((id) => Number.isFinite(id) && id > 0) ); const idleJobs = (Array.isArray(idleJobsRaw) ? idleJobsRaw : []).filter((job) => { if (this.isContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) { return false; } 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 queuedChainNameById = new Map(); const queuedChainIdsNeedingName = this.normalizeQueueChainIdList( this.queueEntries .filter((entry) => String(entry?.type || '').trim().toLowerCase() === 'chain') .filter((entry) => !String(entry?.chainName || '').trim()) .map((entry) => entry?.chainId) ); if (queuedChainIdsNeedingName.length > 0) { const scriptChainService = require('./scriptChainService'); try { const chains = await scriptChainService.getChainsByIds(queuedChainIdsNeedingName); for (const chain of (Array.isArray(chains) ? chains : [])) { const chainId = Number(chain?.id); const chainName = String(chain?.name || '').trim(); if (!Number.isFinite(chainId) || chainId <= 0 || !chainName) { continue; } queuedChainNameById.set(Math.trunc(chainId), chainName); } } catch (error) { logger.warn('queue:snapshot:chain-name-resolve-failed', { chainIds: queuedChainIdsNeedingName, error: errorToMeta(error) }); } } const scriptMetaByJobId = new Map(); const resolveRunningSeriesEpisodeSubtitle = (job) => { if (!this.isSeriesBatchParentQueueAnchor(job)) { return null; } const plan = this.safeParseJson(job?.encode_plan_json); const episodes = Array.isArray(plan?.seriesBatchEpisodes) ? plan.seriesBatchEpisodes : []; if (episodes.length === 0) { return null; } const runningEpisode = episodes.find((episode) => ( normalizeSeriesEpisodeStatus(episode?.status, 'QUEUED') === 'RUNNING' )) || null; const candidate = runningEpisode || episodes.find((episode) => ( normalizeSeriesEpisodeStatus(episode?.status, 'QUEUED') === 'QUEUED' )) || null; const label = String(candidate?.label || '').trim(); return label || null; }; const resolveRunningSeriesSeasonDiscSubtitle = (job) => { const status = String(job?.status || job?.last_state || '').trim().toUpperCase(); if (status !== 'RIPPING') { return null; } const encodePlan = this.safeParseJson(job?.encode_plan_json); const makemkvInfo = this.safeParseJson(job?.makemkv_info_json); const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object' ? makemkvInfo.analyzeContext : {}; const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, null); const mediaProfile = this.resolveMediaProfileForJob(job, { encodePlan, makemkvInfo }); const isSeriesDvd = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); if (!isSeriesDvd) { return null; } const seasonNumber = normalizePositiveInteger( selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null ); const discNumber = normalizePositiveInteger( selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null ); const subtitleParts = []; if (seasonNumber) { subtitleParts.push(`Staffel ${seasonNumber}`); } if (discNumber) { subtitleParts.push(`Disk ${discNumber}`); } return subtitleParts.length > 0 ? subtitleParts.join(' | ') : null; }; const resolveRunningQueueSubtitle = (job) => ( resolveRunningSeriesEpisodeSubtitle(job) || resolveRunningSeriesSeasonDiscSubtitle(job) || null ); const queue = { maxParallelJobs, maxParallelCdEncodes, maxTotalEncodes, cdBypassesQueue, runningCount: runningEncodeCount, runningCdCount, idleCount: idleJobs.length, runningJobs: visibleRunningJobs.map((job) => ({ jobId: Number(job.id), title: job.title || job.detected_title || `Job #${job.id}`, subtitle: resolveRunningQueueSubtitle(job), 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 }; const sourceJobId = Number(entry?.sourceJobId); const sourcePhase = String(entry?.sourcePhase || '').trim().toLowerCase(); const sourcePhaseLabel = sourcePhase === 'post_encode' ? 'Post-Encode' : (sourcePhase === 'pre_encode' ? 'Pre-Encode' : null); const sourceSubtitle = Number.isFinite(sourceJobId) && sourceJobId > 0 ? `${sourcePhaseLabel ? `${sourcePhaseLabel} ` : ''}aus Job #${Math.trunc(sourceJobId)}` : null; if (entryType === 'script') { return { ...base, scriptId: entry.scriptId, sourceJobId: Number.isFinite(sourceJobId) && sourceJobId > 0 ? Math.trunc(sourceJobId) : null, sourcePhase: sourcePhaseLabel, subtitle: sourceSubtitle, title: entry.scriptName || `Skript #${entry.scriptId}`, status: 'QUEUED' }; } if (entryType === 'chain') { const chainId = Number(entry.chainId); const normalizedChainId = Number.isFinite(chainId) && chainId > 0 ? Math.trunc(chainId) : null; const resolvedChainName = String(entry.chainName || '').trim() || (normalizedChainId ? queuedChainNameById.get(normalizedChainId) : null) || null; return { ...base, chainId: normalizedChainId, chainName: resolvedChainName, sourceJobId: Number.isFinite(sourceJobId) && sourceJobId > 0 ? Math.trunc(sourceJobId) : null, sourcePhase: sourcePhaseLabel, subtitle: sourceSubtitle, title: resolvedChainName || `Kette #${normalizedChainId || '-'}`, 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; const seriesEpisodeLabel = String(entry?.seriesEpisodeLabel || '').trim(); const displayTitleBase = row?.title || row?.detected_title || `Job #${entry.jobId}`; const displayTitle = displayTitleBase; const subtitle = seriesEpisodeLabel || null; return { ...base, jobId: Number(entry.jobId), action: entry.action, actionLabel: QUEUE_ACTION_LABELS[entry.action] || entry.action, poolType: this.resolveQueuePoolTypeForEntry(entry, queuedById), title: displayTitle, subtitle, 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(); // Manual script/chain/wait entries must stay queued first so users can // reorder/remove them before execution. They are picked up by later queue // pump cycles (e.g. after running work finishes or another queue trigger). 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; } const optionPreloadedJob = options?.preloadedJob && Number(options.preloadedJob?.id) === normalizedJobId ? options.preloadedJob : null; let resolvedQueueJob = optionPreloadedJob || null; let poolType = this.normalizeQueuePoolType(options?.poolType); if (!poolType) { resolvedQueueJob = resolvedQueueJob || await historyService.getJobById(normalizedJobId).catch(() => null); poolType = this.resolveQueuePoolTypeForJob(resolvedQueueJob); } let detachedPreItems = []; let detachedPostItems = []; if (options?.detachAttachedAutomation !== false) { if (action === QUEUE_ACTIONS.START_PREPARED) { resolvedQueueJob = resolvedQueueJob || await historyService.getJobById(normalizedJobId).catch(() => null); const queueJobPlan = this.safeParseJson(resolvedQueueJob?.encode_plan_json); const detachedItems = this.extractDetachedQueueAutomationFromPlan(queueJobPlan); detachedPreItems = detachedItems.preItems; detachedPostItems = detachedItems.postItems; } else if (action === QUEUE_ACTIONS.START_CD) { const ripConfig = options?.entryData?.ripConfig && typeof options.entryData.ripConfig === 'object' ? options.entryData.ripConfig : {}; detachedPreItems = this.normalizeQueueAutomationItems([ ...normalizeScriptIdList(ripConfig.selectedPreEncodeScriptIds || []).map((id) => ({ type: 'script', id })), ...this.normalizeQueueChainIdList(ripConfig.selectedPreEncodeChainIds || []).map((id) => ({ type: 'chain', id })) ]); detachedPostItems = this.normalizeQueueAutomationItems([ ...normalizeScriptIdList(ripConfig.selectedPostEncodeScriptIds || []).map((id) => ({ type: 'script', id })), ...this.normalizeQueueChainIdList(ripConfig.selectedPostEncodeChainIds || []).map((id) => ({ type: 'chain', id })) ]); } } const allowDuplicateJobEntries = Boolean(options?.allowDuplicateJobEntries); const uniqueEntryKey = String(options?.uniqueEntryKey || '').trim() || null; if (!allowDuplicateJobEntries) { const existingQueueIndex = this.findQueueEntryIndexByJobId(normalizedJobId); if (existingQueueIndex >= 0) { return { queued: true, started: false, queuePosition: existingQueueIndex + 1, action, poolType }; } } else if (uniqueEntryKey) { const existingQueueIndex = this.queueEntries.findIndex((entry) => ( Number(entry?.jobId) === normalizedJobId && String(entry?.action || '').trim().toUpperCase() === String(action || '').trim().toUpperCase() && String(entry?.uniqueEntryKey || '').trim() === uniqueEntryKey )); 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 forceQueue = Boolean(options?.forceQueue); const hasDetachedAutomationEntries = detachedPreItems.length > 0 || detachedPostItems.length > 0; const shouldQueue = forceQueue || hasDetachedAutomationEntries ? true : (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 : {}) }; } const preDetachedEntries = detachedPreItems.map((item) => this.buildDetachedQueueEntryFromAutomationItem(item, { jobId: normalizedJobId, phase: 'pre' })); const postDetachedEntries = detachedPostItems.map((item) => this.buildDetachedQueueEntryFromAutomationItem(item, { jobId: normalizedJobId, phase: 'post' })); const jobQueueEntry = { id: this.queueEntrySeq++, jobId: normalizedJobId, action, poolType, uniqueEntryKey, ...(options?.entryData && typeof options.entryData === 'object' ? options.entryData : {}), enqueuedAt: nowIso() }; this.queueEntries.push(...preDetachedEntries, jobQueueEntry, ...postDetachedEntries); const queuePosition = this.queueEntries.findIndex((entry) => entry.id === jobQueueEntry.id) + 1; const detachedCount = preDetachedEntries.length + postDetachedEntries.length; await historyService.appendLog( normalizedJobId, 'USER_ACTION', `In Queue aufgenommen: ${QUEUE_ACTION_LABELS[action] || action}` + (detachedCount > 0 ? ` (Automationen als eigene Queue-Einträge: Pre=${preDetachedEntries.length}, Post=${postDetachedEntries.length}).` : '') ); await this.emitQueueChanged(); void this.pumpQueue(); return { queued: true, started: false, queuePosition, action, poolType, queuedPoolTypeCount, detachedAutomationQueued: detachedCount }; } 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.START_SERIES_EPISODE: await this.startSeriesBatchEpisode(jobId, { immediate: true, titleId: entry?.seriesTitleId ?? null, episodeIndex: entry?.seriesEpisodeIndex ?? null }); 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 isNonJobQueueEntryReady(entry, options = {}) { const entryType = String(entry?.type || '').trim().toLowerCase(); if (entryType !== 'script' && entryType !== 'chain') { return true; } const sourceJobId = this.normalizeQueueJobId(entry?.sourceJobId); if (!sourceJobId) { return true; } const sourcePhase = String(entry?.sourcePhase || '').trim().toLowerCase(); if (sourcePhase !== 'post_encode') { return true; } const runningJobIds = options?.runningJobIds instanceof Set ? options.runningJobIds : null; if (runningJobIds && runningJobIds.has(sourceJobId)) { return false; } const sourceJobTerminalCache = options?.sourceJobTerminalCache instanceof Map ? options.sourceJobTerminalCache : null; if (sourceJobTerminalCache && sourceJobTerminalCache.has(sourceJobId)) { return Boolean(sourceJobTerminalCache.get(sourceJobId)); } const sourceJob = await historyService.getJobById(sourceJobId).catch(() => null); if (!sourceJob) { if (sourceJobTerminalCache) { sourceJobTerminalCache.set(sourceJobId, true); } return true; } const stateCandidates = [ String(sourceJob?.status || '').trim().toUpperCase(), String(sourceJob?.last_state || '').trim().toUpperCase() ].filter(Boolean); const isTerminal = stateCandidates.some((state) => ( state === 'FINISHED' || state === 'ERROR' || state === 'CANCELLED' )); if (sourceJobTerminalCache) { sourceJobTerminalCache.set(sourceJobId, isTerminal); } return isTerminal; } 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; const runningJobIds = new Set( (Array.isArray(allRunningJobs) ? allRunningJobs : []) .map((row) => this.normalizeQueueJobId(row?.id)) .filter(Boolean) ); const sourceJobTerminalCache = new Map(); // Find next startable entry let entryIndex = -1; for (let i = 0; i < this.queueEntries.length; i++) { const candidate = this.queueEntries[i]; const candidateType = String(candidate?.type || 'job').trim().toLowerCase(); if (candidateType === 'script' || candidateType === 'chain') { // Detached post-encode automations must only run after the source job finished. const nonJobReady = await this.isNonJobQueueEntryReady(candidate, { runningJobIds, sourceJobTerminalCache }); // Non-job queue entries run only when there is no active processing. // This keeps them properly queued instead of running in parallel. if (nonJobReady && this.activeProcesses.size === 0 && totalRunning === 0) { entryIndex = i; break; } continue; } if (candidateType === 'wait') { // Wait keeps queue order and blocks until no tracked job process is active. if (this.activeProcesses.size === 0) { entryIndex = i; } break; } // Job entry: check hierarchical limits (caps apply only to encode jobs). if (totalRunning >= maxTotal) { // Total encode cap reached – nothing can start. break; } const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate); if (candidatePoolType === 'audio') { if (cdRunning < maxCd) { entryIndex = i; break; } // CD/audio encode cap reached if (!cdBypass) break; // Strict FIFO: stop scanning continue; // Bypass mode: skip this blocked CD entry } // Film/video encode job entry if (filmRunning < maxFilm) { entryIndex = i; break; } // Film encode cap reached if (!cdBypass) break; // Strict FIFO: stop scanning // 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}` ); const removedDetachedEntries = this.removeDetachedQueueAutomationEntriesForJob(entry.jobId); if (removedDetachedEntries > 0) { await historyService.appendLog( entry.jobId, 'SYSTEM', `Queue-Cleanup: ${removedDetachedEntries} verknüpfte Skript/Ketten-Eintrag${removedDetachedEntries === 1 ? '' : 'e'} entfernt.` ); await this.emitQueueChanged(); } } } } } 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', 'METADATA_LOOKUP', '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); const normalizedEffectiveJobId = Number(effectiveJobId); const seriesBatchParentJobId = this.seriesBatchParentByChild.get(normalizedEffectiveJobId) || null; if ( seriesBatchParentJobId && Number(seriesBatchParentJobId) !== normalizedEffectiveJobId ) { const normalizedStage = String(stage || '').trim().toUpperCase(); const nowMs = Date.now(); const lastSyncAt = this.seriesBatchParentProgressSyncAt.get(Number(seriesBatchParentJobId)) || 0; const forceSync = normalizedStage === 'FINISHED' || normalizedStage === 'ERROR' || normalizedStage === 'CANCELLED'; if (forceSync || (nowMs - lastSyncAt) >= 1200) { this.seriesBatchParentProgressSyncAt.set(Number(seriesBatchParentJobId), nowMs); void this.updateSeriesBatchParentProgress(seriesBatchParentJobId).catch((error) => { logger.warn('series-batch:parent-progress:sync-from-child-progress-failed', { parentJobId: seriesBatchParentJobId, childJobId: normalizedEffectiveJobId, error: errorToMeta(error) }); }); } } } // 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_LOOKUP' || 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 ensureExternalToolAvailable(cmd, options = {}) { const command = String(cmd || '').trim(); const label = String(options?.label || command || 'Tool').trim() || 'Tool'; const versionArgs = Array.isArray(options?.versionArgs) && options.versionArgs.length > 0 ? options.versionArgs.map((item) => String(item)) : ['--version']; if (!command) { const error = new Error(`${label} ist nicht konfiguriert.`); error.statusCode = 500; throw error; } try { await this.runCapturedCommand(command, versionArgs); } catch (error) { if (String(error?.code || '').trim().toUpperCase() === 'ENOENT') { const resolvedError = new Error( `${label} konnte nicht gestartet werden. Bitte das Kommando in den Settings prüfen oder ${label} auf dem Server installieren.` ); resolvedError.statusCode = 500; resolvedError.code = 'ENOENT'; throw resolvedError; } throw error; } } async ensureMakeMKVRegistration(jobId, stage) { const syncResult = await settingsService.syncMakeMKVRegistrationKeyFromSettings(); if (!syncResult?.applied) { return { applied: false, reason: 'not_configured', path: syncResult?.path || null }; } await historyService.appendLog( jobId, 'SYSTEM', 'Synchronisiere MakeMKV-Key aus den Settings nach ~/.MakeMKV/settings.conf.' ); return { applied: true, path: syncResult?.path || null, changed: Boolean(syncResult?.changed), stage }; } 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 analyzeRawImportJob(jobId, options = {}) { const normalizedJobId = this.normalizeQueueJobId(jobId); if (!normalizedJobId) { const error = new Error('Ungültige Job-ID für RAW-Analyse.'); error.statusCode = 400; throw error; } this.ensureNotBusy('analyzeRawImportJob', normalizedJobId); logger.info('analyze:raw-import:start', { jobId: normalizedJobId, rawPath: options?.rawPath || null }); const activeLockPaths = Array.isArray(diskDetectionService.getActiveLocks?.()) ? diskDetectionService.getActiveLocks() .map((entry) => this.normalizeDrivePath(entry?.path)) .filter((value) => Boolean(value) && !value.startsWith('__virtual__')) : []; if (activeLockPaths.length > 0) { try { await this._forceUnlockStaleDriveLocks(activeLockPaths); } catch (error) { logger.warn('analyze:raw-import:stale-lock-cleanup-failed', { jobId: normalizedJobId, error: errorToMeta(error) }); } } const sourceJob = await historyService.getJobById(normalizedJobId); if (!sourceJob) { const error = new Error(`Job ${normalizedJobId} nicht gefunden.`); error.statusCode = 404; throw error; } const sourceMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json); const requestedRawPath = String(options?.rawPath || sourceJob.raw_path || '').trim(); if (!requestedRawPath) { const error = new Error('RAW-Analyse nicht möglich: raw_path fehlt.'); error.statusCode = 400; throw error; } const globalSettings = await settingsService.getSettingsMap(); const resolvedRawPath = this.resolveCurrentRawPathForSettings( globalSettings, null, requestedRawPath ) || requestedRawPath; if (!resolvedRawPath || !fs.existsSync(resolvedRawPath)) { const error = new Error(`RAW-Analyse nicht möglich: RAW-Pfad nicht gefunden (${requestedRawPath}).`); error.statusCode = 400; throw error; } const inferredRawProfile = normalizeMediaProfile( options?.mediaProfile || inferMediaProfileFromRawPath(resolvedRawPath) ); const fallbackProfile = normalizeMediaProfile( sourceJob?.media_type || inferMediaProfileFromJobKind(sourceJob?.job_kind) || null ); const mediaProfile = isSpecificMediaProfile(inferredRawProfile) ? inferredRawProfile : (isSpecificMediaProfile(fallbackProfile) ? fallbackProfile : null); if (!mediaProfile) { const error = new Error(`RAW-Analyse nicht möglich: Medientyp konnte nicht bestimmt werden (${resolvedRawPath}).`); error.statusCode = 400; throw error; } if (mediaProfile === 'cd') { await historyService.appendLog( normalizedJobId, 'SYSTEM', 'RAW-Import erkannt als CD. Starte CD-Vorprüfung wie nach "Disk analysieren".' ); const cdResult = await this.restartCdReviewFromRaw(normalizedJobId, {}); return { started: true, stage: 'CD_METADATA_SELECTION', mediaProfile: 'cd', ...(cdResult && typeof cdResult === 'object' ? cdResult : {}) }; } if (mediaProfile === 'audiobook' || mediaProfile === 'converter') { const error = new Error(`RAW-Analyse für Medientyp "${mediaProfile}" wird über diesen Einstieg nicht unterstützt.`); error.statusCode = 400; throw error; } const detectedTitle = String( options?.detectedTitle || sourceJob.detected_title || sourceJob.title || path.basename(resolvedRawPath) || 'Unknown Disc' ).trim() || 'Unknown Disc'; const deviceWithProfile = { path: resolvedRawPath, discLabel: detectedTitle, label: detectedTitle, mediaProfile, source: 'raw_import' }; const analyzePlugin = await this.resolveAnalyzePlugin(deviceWithProfile, 'analyze'); await resetProcessLogIfLifecycleAllows(normalizedJobId, sourceJob); await historyService.updateJob(normalizedJobId, { status: 'ANALYZING', last_state: 'ANALYZING', error_message: null, detected_title: detectedTitle, title: null, year: null, imdb_id: null, poster_url: null, omdb_json: null, selected_from_omdb: 0, rip_successful: 0, end_time: null, media_type: mediaProfile, job_kind: resolveJobKindForMediaProfile(mediaProfile), parent_job_id: null, disc_device: null, output_path: null, handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0, ...(resolvedRawPath !== sourceJob.raw_path ? { raw_path: resolvedRawPath } : {}) }); // Safety net: orphan RAW analysis must never hold/restore a physical drive lock. this._releaseDriveLockForJob(normalizedJobId, { reason: 'orphan_raw_analyze' }); await historyService.appendLog( normalizedJobId, 'SYSTEM', `RAW-Import wird wie "Disk analysieren" verarbeitet: ${path.basename(resolvedRawPath)} (Profil: ${mediaProfile}).` ); await this.setState('ANALYZING', { activeJobId: normalizedJobId, progress: 0, eta: null, statusText: 'Disk wird analysiert ...', context: { jobId: normalizedJobId, detectedTitle, mediaProfile, rawPath: resolvedRawPath, rawImport: true } }); try { let effectiveDetectedTitle = detectedTitle; let metadataCandidates = []; const metadataProvider = 'tmdb'; let workflowKind = null; let pluginExecution = null; if (analyzePlugin) { const pluginContext = await this.buildPluginContext(analyzePlugin.id, { discInfo: deviceWithProfile, jobId: normalizedJobId }); try { const pluginResult = await analyzePlugin.analyze(resolvedRawPath, sourceJob, pluginContext); pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); const pluginDetectedTitle = String(pluginResult?.detectedTitle || '').trim(); if (pluginDetectedTitle) { effectiveDetectedTitle = pluginDetectedTitle; } logger.info('plugin:analyze:used', { jobId: normalizedJobId, pluginId: analyzePlugin.id, mediaProfile, source: 'raw_import' }); } catch (error) { pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution()); logger.warn('plugin:analyze:raw-import:fallback-legacy', { jobId: normalizedJobId, pluginId: analyzePlugin.id, mediaProfile, error: errorToMeta(error) }); } } if (isSeriesDiscMediaProfile(mediaProfile)) { await historyService.updateJob(normalizedJobId, { status: 'METADATA_LOOKUP', last_state: 'METADATA_LOOKUP' }); await this.setState('METADATA_LOOKUP', { activeJobId: normalizedJobId, progress: 0, eta: null, statusText: 'TMDb-Metadaten werden gesucht', context: { jobId: normalizedJobId, detectedTitle: effectiveDetectedTitle, mediaProfile, rawPath: resolvedRawPath, rawImport: true, metadataProvider } }); metadataCandidates = await this.searchTmdbMixedMetadata(effectiveDetectedTitle, {}); workflowKind = deriveWorkflowKindFromMetadataCandidates(metadataCandidates); } const prepareInfo = this.withPluginExecutionMeta( this.withAnalyzeContextMediaProfile({ source: 'orphan_raw_import', importContext: sourceMakemkvInfo?.importContext && typeof sourceMakemkvInfo.importContext === 'object' ? sourceMakemkvInfo.importContext : null, rawPath: resolvedRawPath, phase: 'PREPARE', preparedAt: nowIso(), analyzeContext: { playlistAnalysis: null, playlistDecisionRequired: false, selectedPlaylist: null, selectedTitleId: null, workflowKind: workflowKind || null, metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], seriesAnalysis: null, seriesLookupHint: null, seriesDecision: null } }, mediaProfile), pluginExecution ); await historyService.updateJob(normalizedJobId, { status: 'METADATA_SELECTION', last_state: 'METADATA_SELECTION', detected_title: effectiveDetectedTitle, media_type: mediaProfile, job_kind: resolveJobKindForMediaProfile(mediaProfile), parent_job_id: null, makemkv_info_json: JSON.stringify(prepareInfo) }); await historyService.appendLog( normalizedJobId, 'SYSTEM', `${resolveSeriesDiscLabel(mediaProfile)} erkannt. TMDb-Film- und Serien-Suche abgeschlossen (Suchvorschlag: "${effectiveDetectedTitle}").` ); await this.setState('METADATA_SELECTION', { activeJobId: normalizedJobId, progress: 0, eta: null, statusText: 'Metadaten auswählen', context: { jobId: normalizedJobId, detectedTitle: effectiveDetectedTitle, detectedTitleSource: effectiveDetectedTitle !== detectedTitle ? 'plugin' : 'raw_import', metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], mediaProfile, workflowKind: workflowKind || null, seriesAnalysis: null, seriesLookupHint: null, seriesDecision: null, playlistAnalysis: null, playlistDecisionRequired: false, playlistCandidates: [], selectedPlaylist: null, selectedTitleId: null, rawPath: resolvedRawPath, rawImport: true, ...(pluginExecution ? { pluginExecution } : {}) } }); void this.notifyPushover('metadata_ready', { title: 'Ripster - Metadaten bereit', message: `Job #${normalizedJobId}: Metadaten müssen manuell zugeordnet werden` }); return { started: true, stage: 'METADATA_SELECTION', jobId: normalizedJobId, rawPath: resolvedRawPath, mediaProfile, detectedTitle: effectiveDetectedTitle, metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], seriesAnalysis: null, seriesLookupHint: null, seriesDecision: null, workflowKind: workflowKind || null }; } catch (error) { logger.error('metadata:prepare:raw-import:failed', { jobId: normalizedJobId, error: errorToMeta(error) }); await this.failJob(normalizedJobId, 'METADATA_SELECTION', error); throw error; } } async analyzeDisc(devicePath = null) { this.ensureNotBusy('analyze'); logger.info('analyze:start', { devicePath }); const requestedDevicePath = this.normalizeDrivePath(devicePath); if (requestedDevicePath) { await this._ensureDriveUnlockedForAnalyze(requestedDevicePath); } 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: 'ANALYZING', detectedTitle, jobKind: resolveJobKindForMediaProfile(mediaProfile) }); let analyzeDriveLockHeld = false; if (effectiveAnalyzeDevicePath && !String(effectiveAnalyzeDevicePath).startsWith('__virtual__')) { analyzeDriveLockHeld = this._acquireDriveLockForJob(effectiveAnalyzeDevicePath, job.id, { stage: 'ANALYZING', source: 'DRIVE_ANALYZE', mediaProfile, reason: 'drive_analyze_running' }); } // Surface the newly created analyze job immediately in the Ripper UI, // even if metadata provider lookups (TMDb) take longer. await this.setState('ANALYZING', { activeJobId: job.id, progress: 0, eta: null, statusText: 'Disk wird analysiert ...', context: { jobId: job.id, device: deviceWithProfile, detectedTitle, mediaProfile } }); try { let effectiveDetectedTitle = detectedTitle; let metadataCandidates = []; const metadataProvider = 'tmdb'; let workflowKind = 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; } 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 (isSeriesDiscMediaProfile(mediaProfile)) { await historyService.updateJob(job.id, { status: 'METADATA_LOOKUP', last_state: 'METADATA_LOOKUP' }); await this.setState('METADATA_LOOKUP', { activeJobId: job.id, progress: 0, eta: null, statusText: 'TMDb-Metadaten werden gesucht', context: { jobId: job.id, device: deviceWithProfile, detectedTitle: effectiveDetectedTitle, mediaProfile, metadataProvider } }); metadataCandidates = await this.searchTmdbMixedMetadata(effectiveDetectedTitle, {}); workflowKind = deriveWorkflowKindFromMetadataCandidates(metadataCandidates); } logger.info('metadata:prepare:result', { jobId: job.id, detectedTitle: effectiveDetectedTitle, metadataProvider, metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0 }); const prepareInfo = this.withPluginExecutionMeta( this.withAnalyzeContextMediaProfile({ phase: 'PREPARE', preparedAt: nowIso(), analyzeContext: { playlistAnalysis: null, playlistDecisionRequired: false, selectedPlaylist: null, selectedTitleId: null, workflowKind: workflowKind || null, metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], seriesAnalysis: null, seriesLookupHint: null, seriesDecision: 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', `${resolveSeriesDiscLabel(mediaProfile)} erkannt. TMDb-Film- und Serien-Suche abgeschlossen (Suchvorschlag: "${effectiveDetectedTitle}").` ); const keepCurrentPipelineSession = this._hasForeignInteractiveSession(job.id); 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'), metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], mediaProfile, workflowKind: workflowKind || null, seriesAnalysis: null, seriesLookupHint: null, seriesDecision: null, playlistAnalysis: null, playlistDecisionRequired: false, playlistCandidates: [], selectedPlaylist: null, selectedTitleId: null, ...(pluginExecution ? { pluginExecution } : {}) } }); } else { const foreignActiveJobId = this.normalizeQueueJobId(this.snapshot.activeJobId); await historyService.appendLog( job.id, 'SYSTEM', `Metadaten-Auswahl im Hintergrund vorbereitet. Aktive Session bleibt bei interaktivem Job #${foreignActiveJobId || 'unbekannt'}.` ); } void this.notifyPushover('metadata_ready', { title: 'Ripster - Metadaten bereit', message: `Job #${job.id}: Metadaten müssen manuell zugeordnet werden` }); return { jobId: job.id, detectedTitle: effectiveDetectedTitle, metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], seriesAnalysis: null, seriesLookupHint: null, seriesDecision: null, workflowKind: workflowKind || null }; } catch (error) { logger.error('metadata:prepare:failed', { jobId: job.id, error: errorToMeta(error) }); await this.failJob(job.id, 'METADATA_SELECTION', error); throw error; } finally { if (analyzeDriveLockHeld) { this._releaseDriveLockForJob(job.id, { reason: 'drive_analyze_finished' }); } } } async searchTmdbMovies(query) { const rawQuery = String(query || '').trim(); const queryVariants = buildMetadataSearchQueryVariants(rawQuery); if (queryVariants.length === 0) { return []; } logger.info('tmdb:movie-search', { query: rawQuery, variants: queryVariants }); const tmdbConfigured = await tmdbService.isConfigured(); if (!tmdbConfigured) { const error = new Error('TMDb Read Access Token fehlt oder ist ungültig.'); error.statusCode = 412; throw error; } let tmdbLanguage = null; try { const effectiveSettings = await settingsService.getEffectiveSettingsMap('dvd'); tmdbLanguage = String(effectiveSettings?.dvd_series_language || '').trim() || null; } catch (_settingsError) { tmdbLanguage = null; } for (const variant of queryVariants) { const results = await tmdbService.searchMoviesWithDetails(variant, { language: tmdbLanguage }); if (Array.isArray(results) && results.length > 0) { logger.info('tmdb:movie-search:done', { query: rawQuery, matchedVariant: variant, count: results.length }); return results; } } logger.info('tmdb:movie-search:done', { query: rawQuery, matchedVariant: null, count: 0 }); return []; } async searchTmdbSeries(query, seasonNumber = null) { const rawQuery = String(query || '').trim(); const queryVariants = buildMetadataSearchQueryVariants(rawQuery); if (queryVariants.length === 0) { const error = new Error('TMDb-Suche benötigt einen Titel.'); error.statusCode = 400; throw error; } const tmdbConfigured = await tmdbService.isConfigured(); if (!tmdbConfigured) { const error = new Error('TMDb Read Access Token fehlt oder ist ungültig.'); error.statusCode = 412; throw error; } const seasonFilter = String(seasonNumber || '').trim(); const applySeasonFilter = (rows) => { const sourceRows = Array.isArray(rows) ? rows : []; const failureCode = tmdbService.readFailureCode(sourceRows); if (!seasonFilter) { return sourceRows; } const seasonFilterLower = seasonFilter.toLowerCase(); const filteredRows = sourceRows.filter((row) => { const rowSeason = String(row?.seasonNumber ?? '').trim().toLowerCase(); const rowSeasonName = String(row?.seasonName || '').trim().toLowerCase(); return rowSeason.includes(seasonFilterLower) || rowSeasonName.includes(seasonFilterLower); }); return tmdbService.attachFailureCode(filteredRows, failureCode); }; logger.info('tmdb:series-search', { query: rawQuery, seasonNumber: seasonFilter || null, variants: queryVariants }); let tmdbLanguage = null; try { const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd'); tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null; } catch (_settingsError) { tmdbLanguage = null; } const runTmdbSearchForVariant = async (variantQuery) => { const normalizedQuery = String(variantQuery || '').trim(); const cacheKey = [ normalizedQuery.toLowerCase(), seasonFilter.toLowerCase(), String(tmdbLanguage || '').trim().toLowerCase() ].join('::'); const cacheEntry = tmdbSeriesSearchCache.get(cacheKey); const nowTs = Date.now(); if (cacheEntry && cacheEntry.expiresAt > nowTs && Array.isArray(cacheEntry.results)) { logger.info('tmdb:series-search:cache-hit', { query: rawQuery, attemptedVariant: normalizedQuery, seasonNumber: seasonFilter || null, count: cacheEntry.results.length }); return cloneTmdbSeriesSearchResults(cacheEntry.results); } if (cacheEntry && cacheEntry.expiresAt <= nowTs) { tmdbSeriesSearchCache.delete(cacheKey); } let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, { language: tmdbLanguage }); normalizedResults = applySeasonFilter(normalizedResults); const firstPassFailureCode = tmdbService.readFailureCode(normalizedResults); if (normalizedResults.length === 0) { if (firstPassFailureCode) { logger.warn('tmdb:series-search:empty:first-pass:skip-retry', { query: rawQuery, attemptedVariant: normalizedQuery, seasonNumber: seasonFilter || null, failureCode: firstPassFailureCode }); } else { logger.warn('tmdb:series-search:empty:first-pass', { query: rawQuery, attemptedVariant: normalizedQuery, seasonNumber: seasonFilter || null }); let retryResults = []; try { retryResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, { language: tmdbLanguage }); } catch (retryError) { logger.warn('tmdb:series-search:retry:failed', { query: rawQuery, attemptedVariant: normalizedQuery, seasonNumber: seasonFilter || null, error: errorToMeta(retryError) }); } normalizedResults = applySeasonFilter(retryResults); } } if (normalizedResults.length === 0) { return []; } const cacheNowTs = Date.now(); if (tmdbSeriesSearchCache.size >= 200) { for (const [key, entry] of tmdbSeriesSearchCache.entries()) { if (!entry || entry.expiresAt <= cacheNowTs) { tmdbSeriesSearchCache.delete(key); } } if (tmdbSeriesSearchCache.size >= 200) { const oldestKey = tmdbSeriesSearchCache.keys().next().value; if (oldestKey) { tmdbSeriesSearchCache.delete(oldestKey); } } } tmdbSeriesSearchCache.set(cacheKey, { results: cloneTmdbSeriesSearchResults(normalizedResults), expiresAt: cacheNowTs + TMDB_SERIES_SEARCH_CACHE_TTL_MS }); return normalizedResults; }; for (const variant of queryVariants) { const results = await runTmdbSearchForVariant(variant); if (Array.isArray(results) && results.length > 0) { logger.info('tmdb:series-search:done', { query: rawQuery, matchedVariant: variant, count: results.length }); return results; } } logger.info('tmdb:series-search:done', { query: rawQuery, matchedVariant: null, count: 0, empty: true }); return []; } async searchTmdbMixedMetadata(query, options = {}) { const rawQuery = String(query || '').trim(); if (!rawQuery) { return []; } const normalizedSeasonNumber = normalizePositiveInteger(options?.seasonNumber); const [movieResult, seriesResult] = await Promise.allSettled([ this.searchTmdbMovies(rawQuery), this.searchTmdbSeries(rawQuery, normalizedSeasonNumber || null) ]); if (movieResult.status === 'rejected' && seriesResult.status === 'rejected') { throw movieResult.reason || seriesResult.reason; } const movieRows = movieResult.status === 'fulfilled' && Array.isArray(movieResult.value) ? movieResult.value : []; const seriesRows = seriesResult.status === 'fulfilled' && Array.isArray(seriesResult.value) ? seriesResult.value : []; const mixed = [ ...movieRows.map((row) => normalizeMetadataCandidateRow(row, 'movie')), ...seriesRows.map((row) => normalizeMetadataCandidateRow(row, 'series')) ]; const deduped = []; const seen = new Set(); for (const row of mixed) { const rowType = normalizeMetadataCandidateType(row, null); const seasonNumberKey = rowType === 'series' ? (normalizePositiveInteger(row?.seasonNumber) || 'none') : 'none'; const key = String( row?.providerId || `${rowType}:${String(row?.tmdbId || '').trim()}:${seasonNumberKey}:${String(row?.title || '').trim().toLowerCase()}` ).trim(); if (!key || seen.has(key)) { continue; } seen.add(key); deduped.push(row); } logger.info('tmdb:mixed-search:done', { query: rawQuery, movieCount: movieRows.length, seriesCount: seriesRows.length, totalCount: deduped.length }); return deduped; } 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 || {}; let effectiveAnalyzeContext = 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 = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {}); let effectiveIsSeriesDvdReview = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); 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) }); appendLinesInPlace(lines, 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; } if (isSeriesDiscMediaProfile(mediaProfile)) { const seriesScanLines = Array.isArray(pluginScan?.scanAnalysisLines) && pluginScan.scanAnalysisLines.length > 0 ? pluginScan.scanAnalysisLines : lines; const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, seriesScanLines); effectiveAnalyzeContext = seriesScanContextResult.analyzeContext; effectiveIsSeriesDvdReview = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, effectiveAnalyzeContext); } if (effectiveIsSeriesDvdReview) { await historyService.appendLog( jobId, 'SYSTEM', `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt: Mindestlängen-Filter für Vorab-Spurprüfung ist deaktiviert.` ); } const review = buildDiscScanReview({ scanJson: parsed, settings, playlistAnalysis, selectedPlaylistId, selectedMakemkvTitleId, mediaProfile, sourceArg, disableMinLengthFilter: effectiveIsSeriesDvdReview }); 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, makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), analyzeContext: effectiveAnalyzeContext }, mediaProfile)), 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; } options = await this.resolveMultipartReviewLockOptions(job, options); 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); let 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 = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {}); const disableAnalyzeMinLengthFilter = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); const isSeriesBackupReview = disableAnalyzeMinLengthFilter; const preSelectedHandBrakeTitleId = normalizeReviewTitleId(options?.selectedHandBrakeTitleId); const preSelectedHandBrakeTitleIds = normalizeReviewTitleIdList( options?.selectedHandBrakeTitleIds || (preSelectedHandBrakeTitleId ? [preSelectedHandBrakeTitleId] : []) ); let handBrakePlaylistMapScanJson = null; let handBrakePlaylistMapScanLines = []; let handBrakePlaylistMapRunInfo = 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, disableMinLengthFilter: disableAnalyzeMinLengthFilter }); 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 minLengthMinutesForAnalyze = disableAnalyzeMinLengthFilter ? 0 : Number(settings.makemkv_min_length_minutes || 60); const analyzed = analyzePlaylistObfuscation( analyzeLines, minLengthMinutesForAnalyze, {} ); playlistAnalysis = analyzed || null; analyzedFromFreshRun = true; } const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired); let playlistCandidates = buildPlaylistCandidates(playlistAnalysis); let selectedTitleFromPlaylist = selectedPlaylistId ? pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylistId) : null; const selectedTitleFromContextFallback = (!selectedPlaylistId && !isCandidateTitleId(playlistAnalysis, selectedMakemkvTitleId)) ? null : selectedMakemkvTitleId; let selectedTitleForContext = selectedTitleFromPlaylist ?? selectedTitleFromContextFallback ?? 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 ); const shouldPrepareAlternativeFeatureReviewData = Boolean( selectedPlaylistId && !isSeriesBackupReview && playlistCandidates.length > 1 ); const shouldPrepareExpandedHandBrakePlaylistData = ( shouldPrepareHandBrakeDecisionData || shouldPrepareAlternativeFeatureReviewData ); if (shouldPrepareExpandedHandBrakePlaylistData) { const hasCompleteCache = hasCachedHandBrakeDataForPlaylistCandidates(handBrakePlaylistScan, playlistCandidates); if (!hasCompleteCache) { await this.updateProgress( 'MEDIAINFO_CHECK', 25, null, shouldPrepareAlternativeFeatureReviewData ? 'HandBrake Trackdaten für alternative Filmfassungen werden vorbereitet' : '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) }); appendLinesInPlace(resolveScanLines, pluginScan.scanLines); reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, pluginScan.pluginExecution ); const resolveScanJson = parseMediainfoJsonOutput(resolveScanLines.join('\n')); handBrakePlaylistMapScanJson = resolveScanJson; handBrakePlaylistMapScanLines = Array.isArray(resolveScanLines) ? [...resolveScanLines] : []; handBrakePlaylistMapRunInfo = pluginScan.runInfo || null; if (resolveScanJson) { const preparedCache = buildHandBrakePlaylistScanCache(resolveScanJson, playlistCandidates, rawPath); if (preparedCache) { handBrakePlaylistScan = preparedCache; playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); playlistCandidates = buildPlaylistCandidates(playlistAnalysis); await historyService.appendLog( jobId, 'SYSTEM', shouldPrepareAlternativeFeatureReviewData ? `HandBrake Trackdaten für alternative Filmfassungen vorbereitet: ${Object.keys(preparedCache.byPlaylist || {}).length} Kandidaten aus --scan -t 0 analysiert.` : `HandBrake Playlist-Trackdaten vorbereitet: ${Object.keys(preparedCache.byPlaylist || {}).length} Kandidaten aus --scan -t 0 analysiert.` ); } else { await historyService.appendLog( jobId, 'SYSTEM', shouldPrepareAlternativeFeatureReviewData ? 'HandBrake Trackdaten für alternative Filmfassungen konnten aus --scan -t 0 nicht auf Kandidaten abgebildet werden.' : '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', shouldPrepareAlternativeFeatureReviewData ? `HandBrake Vorbereitung für alternative Filmfassungen fehlgeschlagen: ${error.message}` : `HandBrake Voranalyse für Playlist-Auswahl fehlgeschlagen: ${error.message}` ); throw error; } } else { playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); playlistCandidates = buildPlaylistCandidates(playlistAnalysis); await historyService.appendLog( jobId, 'SYSTEM', shouldPrepareAlternativeFeatureReviewData ? 'HandBrake Trackdaten für alternative Filmfassungen aus Cache übernommen (kein erneuter --scan -t 0).' : 'HandBrake Playlist-Trackdaten aus Cache übernommen (kein erneuter --scan -t 0).' ); } } let selectedPlaylistByRuntimeAutoMatch = false; const shouldTryRuntimeAutoMatch = Boolean( playlistDecisionRequired && !selectedPlaylistId && !isSeriesBackupReview && isSeriesDiscMediaProfile(mediaProfile) && playlistCandidates.length > 1 ); if (shouldTryRuntimeAutoMatch) { const runtimeAutoMatch = resolvePlaylistByRuntimeAutoMatch({ playlistCandidates, selectedMetadata }); if (runtimeAutoMatch?.playlistId) { selectedPlaylistId = runtimeAutoMatch.playlistId; selectedTitleFromPlaylist = pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylistId); selectedTitleForContext = selectedTitleFromPlaylist ?? selectedTitleFromContextFallback ?? null; selectedPlaylistByRuntimeAutoMatch = true; await historyService.appendLog( jobId, 'SYSTEM', `TMDb Runtime-Abgleich: ${runtimeAutoMatch.runtimeMinutes} min, Toleranz ±02:00.` ); await historyService.appendLog( jobId, 'SYSTEM', `Automatische Playlist-Auswahl via Runtime-Match: ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId}` + ` (Dauer ${formatDurationClock(runtimeAutoMatch.durationSeconds) || `${runtimeAutoMatch.durationSeconds}s`},` + ` Delta ${runtimeAutoMatch.deltaSeconds}s).` ); } } 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 (isSeriesBackupReview) { const normalizedSeriesPreselection = normalizeReviewTitleIdList(preSelectedHandBrakeTitleIds); let seriesScanJson = handBrakePlaylistMapScanJson; let seriesScanLines = Array.isArray(handBrakePlaylistMapScanLines) ? [...handBrakePlaylistMapScanLines] : []; let seriesScanRunInfo = handBrakePlaylistMapRunInfo; if (!seriesScanJson) { await this.updateProgress( 'MEDIAINFO_CHECK', 30, null, 'HandBrake Serien-Titelanalyse läuft', jobId ); const scanLines = []; const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, { jobId, rawPath, reviewMode: 'rip', source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', silent: !this.isPrimaryJob(jobId) }); appendLinesInPlace(scanLines, pluginScan.scanLines); reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, pluginScan.pluginExecution ); seriesScanRunInfo = pluginScan.runInfo || null; seriesScanJson = parseMediainfoJsonOutput(scanLines.join('\n')); seriesScanLines = scanLines; handBrakePlaylistMapRunInfo = seriesScanRunInfo; handBrakePlaylistMapScanLines = scanLines; handBrakePlaylistMapScanJson = seriesScanJson; if (!seriesScanJson) { const error = new Error('HandBrake Serien-Scan lieferte kein parsebares JSON.'); error.runInfo = seriesScanRunInfo; throw error; } } else { await historyService.appendLog( jobId, 'SYSTEM', 'HandBrake Serien-Titelanalyse aus vorhandenem Scan wiederverwendet.' ); } const seriesScanContextLines = seriesScanLines.length > 0 ? seriesScanLines : null; const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan( updatedMakemkvInfoBase.analyzeContext || {}, seriesScanContextLines ); if (seriesScanContextResult?.analyzeContext && typeof seriesScanContextResult.analyzeContext === 'object') { updatedMakemkvInfoBase.analyzeContext = seriesScanContextResult.analyzeContext; } if (seriesScanContextResult?.derived) { await historyService.appendLog( jobId, 'SYSTEM', 'Serien-Titelklassifizierung aus HandBrake-Scan aktualisiert (PlayAll/Kurz/Duplikate markiert).' ); } const cacheSeedRows = Array.isArray(playlistAnalysis?.candidates) && playlistAnalysis.candidates.length > 0 ? playlistAnalysis.candidates : (Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : []); if (seriesScanJson) { const preparedCache = buildHandBrakePlaylistScanCache(seriesScanJson, cacheSeedRows, rawPath); if (preparedCache) { handBrakePlaylistScan = preparedCache; playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan); playlistCandidates = buildPlaylistCandidates(playlistAnalysis); } } const allSeriesTitles = parseHandBrakeTitleList(seriesScanJson); const filteredSeriesResult = filterSeriesDvdHandBrakeCandidates( allSeriesTitles, updatedMakemkvInfoBase.analyzeContext ); let seriesCandidates = filteredSeriesResult.candidates; if (filteredSeriesResult.applied) { await historyService.appendLog( jobId, 'SYSTEM', `Serien-Filter auf Blu-ray Titel angewendet: ${filteredSeriesResult.sourceCount} -> ${filteredSeriesResult.resultCount} (${filteredSeriesResult.reason || 'heuristik'}).` ); } await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Blu-ray Titel-Scan: ${allSeriesTitles.length} gesamt, ${seriesCandidates.length} Serienkandidaten.` ); if (normalizedSeriesPreselection.length > 0) { const selectedSet = new Set(normalizedSeriesPreselection.map((id) => Number(id))); seriesCandidates = allSeriesTitles.filter((item) => selectedSet.has(Number(item?.handBrakeTitleId))); await historyService.appendLog( jobId, 'SYSTEM', `Manuelle Serien-Titelauswahl übernommen: ${normalizedSeriesPreselection.map((id) => `-t ${id}`).join(', ')}.` ); } if ((!Array.isArray(seriesCandidates) || seriesCandidates.length === 0) && allSeriesTitles.length > 0) { const rowsWithDuration = allSeriesTitles.filter((item) => Number(item?.durationSeconds || 0) > 0); seriesCandidates = rowsWithDuration.length > 0 ? rowsWithDuration : allSeriesTitles; await historyService.appendLog( jobId, 'SYSTEM', 'Serien-Heuristik lieferte keine Kandidaten; Fallback auf gesamte Titelliste.' ); } let seriesSinglePlayAllFallback = null; if (normalizedSeriesPreselection.length === 0) { seriesSinglePlayAllFallback = resolveSeriesSinglePlayAllFallback( seriesCandidates, allSeriesTitles, selectedMetadata ); if (seriesSinglePlayAllFallback?.applied && Array.isArray(seriesSinglePlayAllFallback.candidates)) { seriesCandidates = seriesSinglePlayAllFallback.candidates; await historyService.appendLog( jobId, 'SYSTEM', `Serien-Sonderfall erkannt (ein PlayAll-Titel + Kurzclips): -t ${seriesSinglePlayAllFallback.keptTitleId} ` + `behalten, ${seriesSinglePlayAllFallback.removedCount || 0} Kurzclips ausgeblendet.` ); } } const seriesPlayAllDecision = resolveSeriesPlayAllDoubleEpisodeDecision( seriesCandidates, allSeriesTitles ); if (seriesPlayAllDecision.required) { await historyService.appendLog( jobId, 'SYSTEM', `Serien-Grenzfall erkannt (PlayAll vs. Doppelfolge). Heuristik bleibt bei Episodenkandidaten ` + `(${formatDurationClock(seriesPlayAllDecision.selectedDurationSumSeconds) || '-'} vs ` + `${formatDurationClock(seriesPlayAllDecision.longestCandidateDurationSeconds) || '-'}).` ); } const seriesCandidateIds = normalizeReviewTitleIdList( seriesCandidates.map((item) => item?.handBrakeTitleId) ); if (seriesCandidateIds.length === 0) { await historyService.appendLog( jobId, 'SYSTEM', 'Serien-Heuristik konnte keine verwertbaren Titel-IDs auflösen. Fallback auf generische RAW-Spurprüfung.' ); return this.runMediainfoReviewForJob(jobId, rawPath, { ...options, mediaProfile }); } const selectedReviewTitles = buildSelectedHandBrakeReviewTitles( seriesScanJson, seriesCandidateIds, { encodeInputPath: rawPath } ); let resolvedSelectedTitleIds = normalizeReviewTitleIdList( selectedReviewTitles.map((title) => normalizeReviewTitleId(title?.id)) ); if (resolvedSelectedTitleIds.length === 0) { await historyService.appendLog( jobId, 'SYSTEM', 'Serien-Titel konnten nicht aus HandBrake-Scan übernommen werden. Fallback auf generische RAW-Spurprüfung.' ); return this.runMediainfoReviewForJob(jobId, rawPath, { ...options, mediaProfile }); } let resolvedPrimaryTitleId = normalizeReviewTitleId(preSelectedHandBrakeTitleId) || resolvedSelectedTitleIds[0] || null; if (!resolvedPrimaryTitleId || !resolvedSelectedTitleIds.includes(resolvedPrimaryTitleId)) { resolvedPrimaryTitleId = resolvedSelectedTitleIds[0] || null; } if (!resolvedPrimaryTitleId) { const error = new Error('Serien-Titelprüfung aus RAW fehlgeschlagen: keine primäre Titel-ID auflösbar.'); error.statusCode = 400; throw error; } const primaryTitleInfo = parseHandBrakeSelectedTitleInfo(seriesScanJson, { handBrakeTitleId: resolvedPrimaryTitleId, strictHandBrakeTitleId: true }); if (!primaryTitleInfo) { const error = new Error(`Kein HandBrake-Titel für Serien-Haupttitel -t ${resolvedPrimaryTitleId} gefunden.`); error.statusCode = 400; error.runInfo = seriesScanRunInfo; throw error; } let presetProfile = null; try { presetProfile = await settingsService.buildHandBrakePresetProfile(rawPath, { titleId: resolvedPrimaryTitleId, mediaProfile }); } catch (error) { logger.warn('backup-track-review:series:preset-profile-failed', { jobId, error: errorToMeta(error) }); presetProfile = { source: 'fallback', message: `Preset-Profil konnte nicht geladen werden: ${error.message}` }; } const syntheticFilePath = path.join( rawPath, `handbrake_t${String(Math.trunc(Number(resolvedPrimaryTitleId))).padStart(2, '0')}.mkv` ); const syntheticMediaInfoByPath = { [syntheticFilePath]: buildSyntheticMediaInfoFromMakeMkvTitle(primaryTitleInfo) }; let review = buildMediainfoReview({ mediaFiles: [{ path: syntheticFilePath, size: Number(primaryTitleInfo?.sizeBytes || 0) }], mediaInfoByPath: syntheticMediaInfoByPath, settings, presetProfile, playlistAnalysis, preferredEncodeTitleId: null, selectedPlaylistId: null, selectedMakemkvTitleId: null }); review = remapReviewTrackIdsToSourceIds(review); const selectedSet = new Set(resolvedSelectedTitleIds.map((id) => Number(id))); let normalizedTitles = selectedReviewTitles.map((title) => { const titleId = normalizeReviewTitleId(title?.id); const selectedForEncode = selectedSet.has(Number(titleId)); return { ...title, selectedForEncode, encodeInput: titleId === resolvedPrimaryTitleId }; }); if (normalizedTitles.length === 0) { normalizedTitles = review.titles || []; } review = { ...review, mode, mediaProfile, sourceJobId: options.sourceJobId || null, reviewConfirmed: false, partial: false, processedFiles: normalizedTitles.length, totalFiles: normalizedTitles.length, handBrakeTitleId: resolvedPrimaryTitleId, handBrakeTitleIds: resolvedSelectedTitleIds, selectedPlaylistId: null, selectedMakemkvTitleId: null, titleSelectionRequired: false, titles: normalizedTitles, selectedTitleIds: resolvedSelectedTitleIds, encodeInputTitleId: resolvedPrimaryTitleId, encodeInputPath: rawPath, notes: [ ...(Array.isArray(review.notes) ? review.notes : []), 'MakeMKV Full-Analyse wurde einmal für Playlist-/Titel-Mapping verwendet.', ...(seriesSinglePlayAllFallback?.applied ? [ `Serien-Sonderfall: Ein langer PlayAll-Titel wurde automatisch gewählt (Titel #${seriesSinglePlayAllFallback.keptTitleId}); ` + `${seriesSinglePlayAllFallback.removedCount || 0} kurze Zusatz-Titel wurden ausgeblendet.` ] : []), `HandBrake Serien-Heuristik aktiv: ${resolvedSelectedTitleIds.length} Episoden-Titel aus --scan -t 0 übernommen.` ] }; const shortTitleFilterResult = filterSeriesBackupReviewTitles(review); if (shortTitleFilterResult.applied) { review = shortTitleFilterResult.plan; resolvedSelectedTitleIds = normalizeReviewTitleIdList(review.selectedTitleIds || []); resolvedPrimaryTitleId = normalizeReviewTitleId(review.encodeInputTitleId) || resolvedSelectedTitleIds[0] || resolvedPrimaryTitleId; review = { ...review, handBrakeTitleId: resolvedPrimaryTitleId, handBrakeTitleIds: resolvedSelectedTitleIds }; await historyService.appendLog( jobId, 'SYSTEM', `Serien-Kurztitel-Filter aktiv: ${shortTitleFilterResult.sourceCount} -> ${shortTitleFilterResult.resultCount}` + ` Titel (Schwelle ${formatDurationClock(shortTitleFilterResult.shortThresholdSeconds)}).` ); } const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan( review, options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' ? options.previousEncodePlan : null ); const multipartLockResult = applyMultipartSettingsLockToReviewPlan( reviewPrefillResult.plan, options?.multipartSettingsLock || null ); review = multipartLockResult.plan; const reviewDefaultUserPresetResult = await applyConfiguredDefaultUserPresetToReviewPlan(review, { mediaProfile, isSeriesSelection: true }); review = reviewDefaultUserPresetResult.plan; resolvedSelectedTitleIds = normalizeReviewTitleIdList(review.selectedTitleIds || []); resolvedPrimaryTitleId = normalizeReviewTitleId(review.encodeInputTitleId) || resolvedSelectedTitleIds[0] || resolvedPrimaryTitleId; updatedMakemkvInfoBase.analyzeContext = { ...(updatedMakemkvInfoBase.analyzeContext || {}), mediaProfile, playlistAnalysis: playlistAnalysis || null, playlistDecisionRequired: false, selectedPlaylist: null, selectedTitleId: null, selectedHandBrakeTitleId: resolvedPrimaryTitleId || null, selectedHandBrakeTitleIds: resolvedSelectedTitleIds, handBrakePlaylistScan: handBrakePlaylistScan || null }; const persistedMediainfoInfo = this.withPluginExecutionMeta({ generatedAt: nowIso(), source: 'raw_backup_handbrake_series_scan', makemkvAnalyzeRunInfo: makeMkvAnalyzeRunInfo, makemkvTitleAnalyzeRunInfo: null, handbrakePlaylistResolveRunInfo: seriesScanRunInfo, handbrakeTitleRunInfo: seriesScanRunInfo, handbrakeTitleId: resolvedPrimaryTitleId || null, handbrakeTitleIds: resolvedSelectedTitleIds }, 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', `Serien-Titel-/Spurprüfung aus RAW abgeschlossen: ${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 (multipartLockResult.applied) { await historyService.appendLog( jobId, 'SYSTEM', `Multipart-Lock aktiv: Encode-Einstellungen werden von Job #${multipartLockResult.sourceJobId}` + `${multipartLockResult.sourceDiscNumber ? ` (Disc ${multipartLockResult.sourceDiscNumber})` : ''} übernommen und gesperrt.` ); } 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: false, playlistCandidates, selectedPlaylist: null, selectedTitleId: null, ...(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: false, playlistCandidates, selectedPlaylist: null, selectedTitleId: null } }); } void this.notifyPushover('metadata_ready', { title: 'Ripster - RAW geprüft', message: `Job #${jobId}: bereit zum manuellen Encode-Start` }); return review; } 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, selectedPlaylistByRuntimeAutoMatch ? 'SYSTEM' : 'USER_ACTION', selectedPlaylistByRuntimeAutoMatch ? `Playlist-Auswahl automatisch übernommen: ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId}.` : `Playlist-Auswahl übernommen: ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId}.` ); } const selectedTitleForReview = pickTitleIdForTrackReview(playlistAnalysis, selectedTitleForContext); if (selectedTitleForReview === null) { logger.warn('backup-track-review:no-title-id-fallback-mediainfo', { jobId, selectedPlaylistId: selectedPlaylistId || null, selectedTitleForContext, hasPlaylistAnalysis: Boolean(playlistAnalysis), candidateCount: Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates.length : 0, titleCount: Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles.length : 0 }); await historyService.appendLog( jobId, 'SYSTEM', 'MakeMKV-Playlist-Mapping ohne verwertbare Titel-ID. Fallback auf generische RAW-Spurprüfung (ohne Playlist-Mapping).' ); return this.runMediainfoReviewForJob(jobId, rawPath, { ...options, mediaProfile }); } 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 playlistAliasesForResolve = normalizePlaylistAliasIds( [ ...(Array.isArray(selectedTitleFromAnalysis?.playlistAliases) ? selectedTitleFromAnalysis.playlistAliases : []), ...getPlaylistAliasesForPlaylist(playlistAnalysis, resolvedPlaylistId) ], resolvedPlaylistId ); const hasCachedHandBrakeEntry = Boolean( isHandBrakePlaylistCacheEntryCompatible( cachedHandBrakePlaylistEntry, resolvedPlaylistId, { expectedDurationSeconds: expectedDurationForCache, expectedSizeBytes: expectedSizeForCache, playlistAliases: playlistAliasesForResolve } ) ); 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) }); appendLinesInPlace(resolveScanLines, pluginScan.scanLines); handBrakeResolveRunInfo = pluginScan.runInfo || null; const resolveScanStatus = String(handBrakeResolveRunInfo?.status || '').trim().toUpperCase(); if (resolveScanStatus === 'CANCELLED') { const error = new Error('HandBrake Playlist-Mapping wurde abgebrochen.'); error.statusCode = 409; error.runInfo = handBrakeResolveRunInfo; throw error; } 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; } const knownPlaylists = listAvailableHandBrakePlaylists(resolveScanJson); if (knownPlaylists.length === 0 && resolveScanStatus && resolveScanStatus !== 'SUCCESS') { const error = new Error( `HandBrake Playlist-Mapping unvollständig (${resolveScanStatus}). ` + 'Scan enthält keine erkennbaren Playlist-IDs.' ); error.statusCode = resolveScanStatus === 'CANCELLED' ? 409 : 502; error.runInfo = handBrakeResolveRunInfo; throw error; } resolvedHandBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(resolveScanJson, resolvedPlaylistId, { expectedMakemkvTitleId: selectedTitleForReview, expectedDurationSeconds: expectedDurationForCache, expectedSizeBytes: expectedSizeForCache, playlistAliases: playlistAliasesForResolve }); if (!resolvedHandBrakeTitleId) { 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, playlistAliases: playlistAliasesForResolve })) { 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 alternativeReviewCandidates = collectAlternativeFeatureReviewCandidates({ playlistCandidates, handBrakePlaylistScan, playlistAnalysis, selectedPlaylistId: resolvedPlaylistId, selectedHandBrakeTitleId: resolvedHandBrakeTitleId, selectedTitleInfo: reviewTitleInfo, makeMkvSubtitleTracks: makeMkvSubtitleTracksForSelection }); const reviewCandidateRows = alternativeReviewCandidates.length > 0 ? alternativeReviewCandidates : [{ playlistId: resolvedPlaylistId, handBrakeTitleId: resolvedHandBrakeTitleId, titleInfo: reviewTitleInfo, sourceRow: null, durationSeconds: Number(reviewTitleInfo?.durationSeconds || 0) || 0, sizeBytes: Number(reviewTitleInfo?.sizeBytes || 0) || 0, playlistInfo: resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId), isSelectedDefault: true }]; const buildSyntheticCandidatePath = (handBrakeTitleId, fallbackTitleId) => ( path.join( rawPath, reviewTitleSource === 'handbrake' && Number.isFinite(Number(handBrakeTitleId)) && Number(handBrakeTitleId) > 0 ? `handbrake_t${String(Math.trunc(Number(handBrakeTitleId))).padStart(2, '0')}.mkv` : `makemkv_t${String(fallbackTitleId).padStart(2, '0')}.mkv` ) ); const reviewCandidatesWithPaths = reviewCandidateRows.map((candidate, index) => { const fallbackTitleId = Number(selectedTitleForReview || index + 1) || (index + 1); return { ...candidate, syntheticFilePath: buildSyntheticCandidatePath(candidate.handBrakeTitleId, fallbackTitleId) }; }); const syntheticMediaInfoByPath = Object.fromEntries( reviewCandidatesWithPaths.map((candidate) => [ candidate.syntheticFilePath, buildSyntheticMediaInfoFromMakeMkvTitle(candidate.titleInfo) ]) ); const allowAlternativeTitleSelection = reviewCandidatesWithPaths.length > 1; let review = buildMediainfoReview({ mediaFiles: reviewCandidatesWithPaths.map((candidate) => ({ path: candidate.syntheticFilePath, size: Number(candidate?.titleInfo?.sizeBytes || 0) })), mediaInfoByPath: syntheticMediaInfoByPath, settings, presetProfile, playlistAnalysis, preferredEncodeTitleId: allowAlternativeTitleSelection ? null : selectedTitleForReview, selectedPlaylistId: allowAlternativeTitleSelection ? null : (resolvedPlaylistId || reviewTitleInfo?.playlistId || null), selectedMakemkvTitleId: allowAlternativeTitleSelection ? null : selectedTitleForReview }); review = remapReviewTrackIdsToSourceIds(review); const minLengthMinutesForReview = Number(review?.minLengthMinutes ?? settings?.makemkv_min_length_minutes ?? 0); const minLengthSecondsForReview = Math.max(0, Math.round(minLengthMinutesForReview * 60)); const candidateMetaBySyntheticPath = new Map( reviewCandidatesWithPaths.map((candidate) => [candidate.syntheticFilePath, candidate]) ); const buildNormalizedReviewTitle = (title) => { const candidateMeta = candidateMetaBySyntheticPath.get(String(title?.filePath || '').trim()) || null; const candidateTitleInfo = candidateMeta?.titleInfo || reviewTitleInfo; const candidatePlaylistInfo = candidateMeta?.playlistInfo || resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId); const candidateHandBrakeTitleId = normalizeReviewTitleId( candidateMeta?.handBrakeTitleId ?? resolvedHandBrakeTitleId ?? title?.handBrakeTitleId ?? title?.titleIndex ?? title?.id ?? selectedTitleForReview ); const durationSeconds = Number(candidateTitleInfo?.durationSeconds || title?.durationSeconds || 0); const eligibleForEncode = durationSeconds >= minLengthSecondsForReview; const subtitleTrackMetaBySourceId = new Map( (Array.isArray(candidateTitleInfo?.subtitleTracks) ? candidateTitleInfo.subtitleTracks : []) .map((track) => { const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null; return sourceTrackId ? [sourceTrackId, track] : null; }) .filter(Boolean) ); 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, id: candidateHandBrakeTitleId || title?.id || null, handBrakeTitleId: candidateHandBrakeTitleId || null, filePath: rawPath, fileName: candidateTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`, durationSeconds, durationMinutes: Number((durationSeconds / 60).toFixed(2)), selectedByMinLength: eligibleForEncode, eligibleForEncode, sizeBytes: Number(candidateTitleInfo?.sizeBytes || title?.sizeBytes || 0), playlistId: candidatePlaylistInfo.playlistId || title?.playlistId || null, playlistFile: candidatePlaylistInfo.playlistFile || title?.playlistFile || null, playlistAliases: Array.isArray(candidatePlaylistInfo.playlistAliases) ? candidatePlaylistInfo.playlistAliases : [], playlistRecommended: Boolean(candidatePlaylistInfo.recommended || title?.playlistRecommended), playlistEvaluationLabel: candidatePlaylistInfo.evaluationLabel || title?.playlistEvaluationLabel || null, playlistSegmentCommand: candidatePlaylistInfo.segmentCommand || title?.playlistSegmentCommand || null, playlistSegmentFiles: Array.isArray(candidatePlaylistInfo.segmentFiles) && candidatePlaylistInfo.segmentFiles.length > 0 ? candidatePlaylistInfo.segmentFiles : (Array.isArray(title?.playlistSegmentFiles) ? title.playlistSegmentFiles : []), subtitleTracks }; }; const normalizedTitles = (Array.isArray(review.titles) ? review.titles : []) .map((title) => buildNormalizedReviewTitle(title)) .filter((title) => normalizeReviewTitleId(title?.id)); const encodeInputTitleId = normalizeReviewTitleId( normalizedTitles.find((title) => Boolean(title?.playlistId && normalizePlaylistId(title.playlistId) === normalizePlaylistId(resolvedPlaylistId)))?.id || normalizedTitles.find((title) => normalizeReviewTitleId(title?.handBrakeTitleId) === normalizeReviewTitleId(resolvedHandBrakeTitleId))?.id || normalizedTitles[0]?.id || review?.encodeInputTitleId || null ); review = { ...review, mode, mediaProfile, sourceJobId: options.sourceJobId || null, reviewConfirmed: false, partial: false, processedFiles: normalizedTitles.length, totalFiles: normalizedTitles.length, handBrakeTitleId: resolvedHandBrakeTitleId || null, selectedPlaylistId: resolvedPlaylistId || null, selectedMakemkvTitleId: selectedTitleForReview, titleSelectionRequired: false, titles: normalizedTitles, selectedTitleIds: encodeInputTitleId ? [encodeInputTitleId] : [], encodeInputTitleId, encodeInputPath: rawPath, alternativeTitleSelection: normalizedTitles.length > 1 ? { enabled: true, reason: 'same_or_longer_titles', matchedTitleId: encodeInputTitleId || null, matchedPlaylistId: normalizePlaylistId(resolvedPlaylistId) || null, matchedDurationSeconds: Number(reviewTitleInfo?.durationSeconds || 0) || 0, optionCount: normalizedTitles.length } : null, 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).`, ...(normalizedTitles.length > 1 ? ['Weitere gleichlange oder längere Titel wurden gefunden. In der Review kann zwischen alternativen Schnittfassungen/Playlist-Varianten umgeschaltet werden.'] : []) ] }; review = applyEncodeTitleSelectionToPlan(review, encodeInputTitleId, encodeInputTitleId ? [encodeInputTitleId] : []).plan; const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan( review, options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' ? options.previousEncodePlan : null ); const multipartLockResult = applyMultipartSettingsLockToReviewPlan( reviewPrefillResult.plan, options?.multipartSettingsLock || null ); review = multipartLockResult.plan; const reviewDefaultUserPresetResult = await applyConfiguredDefaultUserPresetToReviewPlan(review, { mediaProfile, isSeriesSelection: isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext) }); review = reviewDefaultUserPresetResult.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 (multipartLockResult.applied) { await historyService.appendLog( jobId, 'SYSTEM', `Multipart-Lock aktiv: Encode-Einstellungen werden von Job #${multipartLockResult.sourceJobId}` + `${multipartLockResult.sourceDiscNumber ? ` (Disc ${multipartLockResult.sourceDiscNumber})` : ''} übernommen und gesperrt.` ); } 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 submitRawDecision(jobId, decision) { const job = await historyService.getJobById(jobId); if (!job) throw new Error('Job nicht gefunden.'); const isWaiting = ( String(job.status || '').trim().toUpperCase() === 'WAITING_FOR_USER_DECISION' || String(job.last_state || '').trim().toUpperCase() === 'WAITING_FOR_USER_DECISION' ); if (!isWaiting) { throw new Error(`Job ist nicht im Status WAITING_FOR_USER_DECISION (${job.status})`); } const mkInfo = this.safeParseJson(job.makemkv_info_json) || {}; const currentAnalyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' ? mkInfo.analyzeContext : {}; const playlistDecisionRequired = Boolean(currentAnalyzeContext.playlistDecisionRequired); const selectedTitleId = currentAnalyzeContext.selectedTitleId ?? null; const requiresManualPlaylistSelection = Boolean(playlistDecisionRequired && selectedTitleId === null); if (decision === 'multipart_orphan_merge') { const mediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: mkInfo }); if (!isSeriesDiscMediaProfile(mediaProfile)) { const error = new Error('Multipart movie via RAW-Entscheidung ist nur fuer DVD/Blu-ray verfuegbar.'); error.statusCode = 409; throw error; } const selectedMetadata = resolveSelectedMetadataForJob(job, currentAnalyzeContext, null); const workflowKind = normalizeDvdMetadataWorkflowKind( selectedMetadata?.workflowKind || currentAnalyzeContext?.workflowKind || null ); if (workflowKind !== 'film') { const error = new Error('Multipart movie via RAW-Entscheidung ist nur fuer Film-Metadaten erlaubt.'); error.statusCode = 409; throw error; } const rawDecisionContext = currentAnalyzeContext?.rawDecisionContext && typeof currentAnalyzeContext.rawDecisionContext === 'object' ? currentAnalyzeContext.rawDecisionContext : {}; if (!rawDecisionContext?.mergeOrphanEligible) { const error = new Error('Dieser Job hat kein geeignetes Orphan-RAW fuer Multipart movie.'); error.statusCode = 409; throw error; } const rawPathCandidate = normalizeComparablePath( rawDecisionContext?.matchedRawPathAfterMetadata || rawDecisionContext?.renamedRawPath || job.raw_path || null ); if (!rawPathCandidate) { const error = new Error('Kein gueltiger RAW-Pfad fuer den Multipart-Import gefunden.'); error.statusCode = 409; throw error; } if (!fs.existsSync(rawPathCandidate) || !fs.statSync(rawPathCandidate).isDirectory()) { const error = new Error(`RAW-Pfad fuer Multipart-Import nicht gefunden: ${rawPathCandidate}`); error.statusCode = 404; throw error; } const pickNextAvailableDiscNumber = (takenValues = new Set(), start = 1) => { let next = normalizePositiveInteger(start) || 1; while (takenValues.has(next)) { next += 1; } return next; }; const filmMetadataFingerprint = String(job?.metadata_fingerprint || '').trim() || buildMovieMetadataFingerprint(mediaProfile, { imdbId: selectedMetadata?.imdbId || job?.imdb_id || null, title: selectedMetadata?.title || job?.title || job?.detected_title || null, year: selectedMetadata?.year || job?.year || null }) || null; let multipartContainerJob = null; const parentJobId = normalizePositiveInteger(job?.parent_job_id); if (parentJobId) { const candidateParent = await historyService.getJobById(parentJobId).catch(() => null); if (candidateParent && this.isMultipartMovieContainerHistoryJob(candidateParent)) { multipartContainerJob = candidateParent; } } if (!multipartContainerJob && filmMetadataFingerprint) { const db = await getDb(); const containerRow = await db.get( ` SELECT id FROM jobs WHERE job_kind = 'multipart_movie_container' AND media_type = ? AND metadata_fingerprint = ? ORDER BY updated_at DESC, id DESC LIMIT 1 `, [mediaProfile, filmMetadataFingerprint] ); if (containerRow?.id) { multipartContainerJob = await historyService.getJobById(containerRow.id).catch(() => null); } } if (!multipartContainerJob) { multipartContainerJob = await historyService.createJob({ discDevice: job?.disc_device || null, status: 'READY_TO_START', detectedTitle: selectedMetadata?.title || job?.title || job?.detected_title || null, mediaType: mediaProfile, jobKind: 'multipart_movie_container' }); } const multipartContainerJobId = normalizePositiveInteger(multipartContainerJob?.id); if (!multipartContainerJobId) { const error = new Error('Multipart-Container konnte nicht angelegt werden.'); error.statusCode = 500; throw error; } const existingContainerChildren = await historyService.listChildJobs(multipartContainerJobId); const takenDiscNumbers = new Set(); for (const child of Array.isArray(existingContainerChildren) ? existingContainerChildren : []) { const childId = normalizePositiveInteger(child?.id); if (!childId || childId === Number(jobId)) { continue; } const childDisc = resolveDiscNumberFromJobLike(child); if (childDisc) { takenDiscNumbers.add(childDisc); } } const suggestedPair = resolveMultipartDiscNumberPair({ preferredCurrentDiscNumber: normalizePositiveInteger( rawDecisionContext?.multipartSuggestion?.currentDiscNumber ?? selectedMetadata?.discNumber ?? job?.disc_number ?? null ), preferredOrphanDiscNumber: normalizePositiveInteger( rawDecisionContext?.multipartSuggestion?.orphanDiscNumber ?? extractMultipartFilmDiscFromRawFolderName(path.basename(rawPathCandidate)) ?? null ) }); let orphanDiscNumber = suggestedPair.orphanDiscNumber; if (takenDiscNumbers.has(orphanDiscNumber)) { orphanDiscNumber = pickNextAvailableDiscNumber(takenDiscNumbers, orphanDiscNumber); } takenDiscNumbers.add(orphanDiscNumber); let currentDiscNumber = suggestedPair.currentDiscNumber; if (!currentDiscNumber || takenDiscNumbers.has(currentDiscNumber)) { currentDiscNumber = pickNextAvailableDiscNumber( takenDiscNumbers, currentDiscNumber || (orphanDiscNumber === 1 ? 2 : 1) ); } if (currentDiscNumber === orphanDiscNumber) { currentDiscNumber = pickNextAvailableDiscNumber(takenDiscNumbers, currentDiscNumber + 1); } takenDiscNumbers.add(currentDiscNumber); const orphanJob = await historyService.createJob({ discDevice: null, status: 'READY_TO_START', detectedTitle: selectedMetadata?.title || job?.title || job?.detected_title || null, mediaType: mediaProfile, jobKind: 'multipart_movie_child' }); const orphanJobId = normalizePositiveInteger(orphanJob?.id); if (!orphanJobId) { const error = new Error('Orphan-Child-Job konnte nicht erstellt werden.'); error.statusCode = 500; throw error; } const currentMultipartSelectedMetadata = normalizeMultipartMovieSelectedMetadata( selectedMetadata, currentDiscNumber ); const orphanMultipartSelectedMetadata = normalizeMultipartMovieSelectedMetadata( selectedMetadata, orphanDiscNumber ); const containerSelectedMetadata = normalizeMultipartMovieSelectedMetadata(selectedMetadata, null); const orphanAnalyzeContext = { ...currentAnalyzeContext, playlistAnalysis: null, playlistDecisionRequired: false, selectedPlaylist: null, selectedTitleId: null, metadataProvider: 'tmdb', workflowKind: 'film', selectedMetadata: orphanMultipartSelectedMetadata, rawDecisionContext: null }; const currentAnalyzeContextPatch = { ...currentAnalyzeContext, metadataProvider: 'tmdb', workflowKind: 'film', selectedMetadata: currentMultipartSelectedMetadata, rawDecisionContext: null }; const sourceRawState = resolveRawFolderStateFromPath(rawPathCandidate); const orphanMetadataBase = buildRawMetadataBase({ title: selectedMetadata?.title || job?.title || job?.detected_title || null, year: selectedMetadata?.year || job?.year || null, detected_title: job?.detected_title || null, media_type: mediaProfile, is_multipart_movie: 1, job_kind: 'multipart_movie_child' }, orphanJobId, { mediaProfile, analyzeContext: orphanAnalyzeContext, selectedMetadata: orphanMultipartSelectedMetadata, isMultipartMovie: true }); const orphanTargetRawPath = path.join( path.dirname(rawPathCandidate), buildRawDirName(orphanMetadataBase, orphanJobId, { state: sourceRawState }) ); let finalOrphanRawPath = rawPathCandidate; if (normalizeComparablePath(orphanTargetRawPath) !== normalizeComparablePath(rawPathCandidate)) { if (fs.existsSync(orphanTargetRawPath)) { const error = new Error(`RAW-Zielpfad fuer Orphan-Child existiert bereits: ${orphanTargetRawPath}`); error.statusCode = 409; throw error; } fs.renameSync(rawPathCandidate, orphanTargetRawPath); await historyService.updateRawPathByOldPath(rawPathCandidate, orphanTargetRawPath); finalOrphanRawPath = orphanTargetRawPath; } const topLevelSelectedMetadata = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object' ? mkInfo.selectedMetadata : {}; const orphanMakemkvInfo = this.withAnalyzeContextMediaProfile({ ...mkInfo, source: 'orphan_raw_import', rawPath: finalOrphanRawPath, analyzeContext: orphanAnalyzeContext, selectedMetadata: { ...topLevelSelectedMetadata, ...orphanMultipartSelectedMetadata } }, mediaProfile); const currentMakemkvInfo = this.withAnalyzeContextMediaProfile({ ...mkInfo, analyzeContext: currentAnalyzeContextPatch, selectedMetadata: { ...topLevelSelectedMetadata, ...currentMultipartSelectedMetadata } }, mediaProfile); const containerMakemkvInfo = this.withAnalyzeContextMediaProfile({ analyzeContext: { metadataProvider: 'tmdb', workflowKind: 'film', selectedMetadata: containerSelectedMetadata }, selectedMetadata: containerSelectedMetadata }, mediaProfile); const commonTitle = selectedMetadata?.title || job?.title || job?.detected_title || null; const commonYear = selectedMetadata?.year || job?.year || null; const commonImdbId = selectedMetadata?.imdbId || job?.imdb_id || null; const commonPoster = selectedMetadata?.poster || job?.poster_url || null; await historyService.updateJob(multipartContainerJobId, { title: commonTitle, year: commonYear, imdb_id: commonImdbId, poster_url: commonPoster, selected_from_omdb: 0, omdb_json: null, status: 'READY_TO_START', last_state: 'READY_TO_START', media_type: mediaProfile, parent_job_id: null, job_kind: 'multipart_movie_container', is_multipart_movie: 1, disc_number: null, metadata_fingerprint: filmMetadataFingerprint, makemkv_info_json: JSON.stringify(containerMakemkvInfo) }); await historyService.updateJob(orphanJobId, { parent_job_id: multipartContainerJobId, title: commonTitle, year: commonYear, imdb_id: commonImdbId, poster_url: commonPoster, selected_from_omdb: 0, omdb_json: null, status: 'READY_TO_START', last_state: 'READY_TO_START', media_type: mediaProfile, job_kind: 'multipart_movie_child', is_multipart_movie: 1, disc_number: orphanDiscNumber, metadata_fingerprint: filmMetadataFingerprint, raw_path: finalOrphanRawPath, output_path: null, rip_successful: 1, makemkv_info_json: JSON.stringify(orphanMakemkvInfo), handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0, error_message: null, end_time: null }); const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; await historyService.updateJob(jobId, { parent_job_id: multipartContainerJobId, title: commonTitle, year: commonYear, imdb_id: commonImdbId, poster_url: commonPoster, selected_from_omdb: 0, omdb_json: null, status: nextStatus, last_state: nextStatus, media_type: mediaProfile, job_kind: 'multipart_movie_child', is_multipart_movie: 1, disc_number: currentDiscNumber, metadata_fingerprint: filmMetadataFingerprint, raw_path: null, rip_successful: 0, makemkv_info_json: JSON.stringify(currentMakemkvInfo), handbrake_info_json: null, mediainfo_info_json: null, encode_plan_json: null, encode_input_path: null, encode_review_confirmed: 0, error_message: null, end_time: null }); await historyService.appendLog( orphanJobId, 'SYSTEM', `Orphan-RAW in Multipart-Container uebernommen: Container #${multipartContainerJobId}, Disc ${orphanDiscNumber}, RAW=${finalOrphanRawPath}` ); await historyService.appendLog( jobId, 'SYSTEM', `Multipart movie vorbereitet: Container #${multipartContainerJobId}, Laufwerk=Disc ${currentDiscNumber}, Orphan=Disc ${orphanDiscNumber} (Job #${orphanJobId}).` ); await historyService.appendLog( multipartContainerJobId, 'SYSTEM', `Multipart movie aus RAW-Entscheidung erstellt: Disc ${currentDiscNumber} (Job #${jobId}) + Disc ${orphanDiscNumber} (Job #${orphanJobId}).` ); await this.upsertMultipartMergeJobForContainer(multipartContainerJobId).catch((mergePrepError) => { logger.warn('multipart:merge:upsert-on-raw-decision-failed', { containerJobId: multipartContainerJobId, jobId, orphanJobId, error: errorToMeta(mergePrepError) }); }); await this.setState(nextStatus, { activeJobId: jobId, progress: 0, eta: null, statusText: requiresManualPlaylistSelection ? 'waiting_for_manual_playlist_selection' : 'Multipart vorbereitet - Laufwerks-Rip startet', context: { ...(this.snapshot.context || {}), jobId, rawPath: null, mediaProfile, selectedMetadata: currentMultipartSelectedMetadata, waitingForRawDecision: false, waitingForManualPlaylistSelection: requiresManualPlaylistSelection, manualDecisionState: requiresManualPlaylistSelection ? 'waiting_for_manual_playlist_selection' : null, rawDecisionOptions: null, multipartContext: { containerJobId: multipartContainerJobId, orphanJobId, currentDiscNumber, orphanDiscNumber } } }); if (requiresManualPlaylistSelection) { await historyService.appendLog( jobId, 'SYSTEM', 'Multipart movie vorbereitet. Bitte selected_playlist setzen (z.B. 00800 oder 00800.mpls), bevor der Laufwerks-Rip gestartet wird.' ); return historyService.getJobById(jobId); } await historyService.appendLog( jobId, 'SYSTEM', `Starte Backup/Rip fuer Laufwerk-Disc ${currentDiscNumber}. Orphan-RAW bleibt als Disc ${orphanDiscNumber} (Job #${orphanJobId}) im Container.` ); await this.startPreparedJob(jobId); return historyService.getJobById(jobId); } if (decision === 'delete') { const fsOptions = { recursive: true, force: true }; if (job.raw_path && require('fs').existsSync(job.raw_path)) { await require('fs').promises.rm(job.raw_path, fsOptions); await historyService.appendLog(jobId, 'SYSTEM', `RAW-Verzeichnis wurde auf Benutzerwunsch gelöscht: ${job.raw_path}`); } } else if (decision === 'continue') { await historyService.appendLog(jobId, 'SYSTEM', `Benutzer hat "Mit vorhandenem RAW weiter machen" gewählt.`); } else if (decision === 'cancel') { await historyService.appendLog(jobId, 'SYSTEM', `Benutzer hat auf Grund vorhandenem RAW abgebrochen.`); await this.cancelJob(jobId); return historyService.getJobById(jobId); } else { throw new Error(`Unbekannte Entscheidung: ${decision}`); } const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; await this.setState(nextStatus, { activeJobId: jobId, progress: 0, eta: null, statusText: requiresManualPlaylistSelection ? 'waiting_for_manual_playlist_selection' : (decision === 'continue' ? 'Metadaten übernommen - vorhandenes RAW erkannt' : 'Metadaten übernommen - bereit zum Start'), context: { ...(this.snapshot.context || {}), waitingForRawDecision: false, rawDecisionOptions: null, manualDecisionState: requiresManualPlaylistSelection ? 'waiting_for_manual_playlist_selection' : null } }); if (requiresManualPlaylistSelection) { 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 (decision === 'continue') { await historyService.appendLog(jobId, 'SYSTEM', `Starte automatische Spur-Ermittlung (Mediainfo) mit vorhandenem RAW: ${job.raw_path}`); await this.startPreparedJob(jobId); return historyService.getJobById(jobId); } await historyService.appendLog(jobId, 'SYSTEM', 'RAW gelöscht oder ignoriert. Starte Backup/Rip automatisch.'); await this.startPreparedJob(jobId); return historyService.getJobById(jobId); } async selectMetadata({ jobId, title, year, imdbId, poster, selectedPlaylist = null, selectedHandBrakeTitleId = null, selectedHandBrakeTitleIds = null, metadataProvider = null, providerId = null, tmdbId = null, metadataKind = null, workflowKind = null, seasonNumber = null, seasonName = null, episodeCount = null, episodes = null, discNumber = null, duplicateAction = null, existingJobId = null, existingDiscNumber = null }) { this.ensureNotBusy('selectMetadata', jobId); logger.info('metadata:selected', { jobId, title, year, imdbId, poster, selectedPlaylist, selectedHandBrakeTitleId, selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null, metadataProvider, providerId, tmdbId, workflowKind, seasonNumber, discNumber, duplicateAction, existingJobId, existingDiscNumber }); 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 normalizedSelectedHandBrakeTitleIds = normalizeReviewTitleIdList(selectedHandBrakeTitleIds); const effectiveSelectedHandBrakeTitleIds = normalizedSelectedHandBrakeTitleIds.length > 0 ? normalizedSelectedHandBrakeTitleIds : (normalizedSelectedHandBrakeTitleId ? [normalizedSelectedHandBrakeTitleId] : []); const effectiveSelectedHandBrakeTitleId = normalizedSelectedHandBrakeTitleId || effectiveSelectedHandBrakeTitleIds[0] || null; 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 ); if ((normalizedSelectedPlaylist || effectiveSelectedHandBrakeTitleId) && 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', effectiveSelectedHandBrakeTitleId ? `Converter Titel-Auswahl gesetzt: -t ${effectiveSelectedHandBrakeTitleId}${effectiveSelectedHandBrakeTitleIds.length > 1 ? ` (Mehrfachauswahl: ${effectiveSelectedHandBrakeTitleIds.map((id) => `-t ${id}`).join(', ')})` : ''}.` : `Converter Playlist-Auswahl gesetzt: ${toPlaylistFile(normalizedSelectedPlaylist) || normalizedSelectedPlaylist}.` ); try { await this.runMediainfoReviewForJob(jobId, converterInputPath, { mode: 'converter', mediaProfile: 'converter', selectedPlaylist: normalizedSelectedPlaylist || null, selectedHandBrakeTitleId: effectiveSelectedHandBrakeTitleId || null, selectedHandBrakeTitleIds: effectiveSelectedHandBrakeTitleIds, 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 selectedFromMetadata = 0; let posterValue = poster === undefined ? (job.poster_url || null) : (poster || null); let shouldQueuePosterCache = false; if (posterValue && !thumbnailService.isLocalUrl(posterValue)) { try { const cacheResult = await historyService.cacheAndPromoteExternalPoster(jobId, posterValue, { source: 'Poster', logFailures: false }); if (cacheResult?.ok && cacheResult?.localUrl) { posterValue = cacheResult.localUrl; } else { shouldQueuePosterCache = true; } } catch (_error) { shouldQueuePosterCache = true; } } const effectiveDiscNumber = normalizePositiveInteger(discNumber); const converterMetadataProvider = String(metadataProvider || 'tmdb').trim().toLowerCase() || 'tmdb'; 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, metadataProvider: converterMetadataProvider, providerId: String(providerId || '').trim() || null, tmdbId: Number(tmdbId || 0) || null, metadataKind: String(metadataKind || '').trim().toLowerCase() || null, seasonNumber: Number(seasonNumber || 0) || null, seasonName: String(seasonName || '').trim() || null, episodeCount: Number(episodeCount || 0) || 0, episodes: Array.isArray(episodes) ? episodes : [], ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}) }, reviewConfirmed: false }; await historyService.updateJob(jobId, { title: effectiveTitle, year: effectiveYear, imdb_id: effectiveImdbId, poster_url: posterValue, selected_from_omdb: selectedFromMetadata, omdb_json: null, migrate_tmdb: 1, 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 (shouldQueuePosterCache && 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 normalizedSelectedHandBrakeTitleIds = normalizeReviewTitleIdList(selectedHandBrakeTitleIds); const effectiveSelectedHandBrakeTitleIds = normalizedSelectedHandBrakeTitleIds.length > 0 ? normalizedSelectedHandBrakeTitleIds : (normalizedSelectedHandBrakeTitleId ? [normalizedSelectedHandBrakeTitleId] : []); const effectiveSelectedHandBrakeTitleId = normalizedSelectedHandBrakeTitleId || effectiveSelectedHandBrakeTitleIds[0] || null; 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 ); if (effectiveSelectedHandBrakeTitleId && waitingForPlaylistSelection && job.raw_path && !hasExplicitMetadataPayload && !normalizedSelectedPlaylist) { const currentMkInfo = mkInfo; const currentAnalyzeContext = currentMkInfo?.analyzeContext || {}; const updatedMkInfo = this.withAnalyzeContextMediaProfile({ ...currentMkInfo, analyzeContext: { ...currentAnalyzeContext, selectedHandBrakeTitleId: effectiveSelectedHandBrakeTitleId, selectedHandBrakeTitleIds: effectiveSelectedHandBrakeTitleIds } }, 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 ${effectiveSelectedHandBrakeTitleId}${effectiveSelectedHandBrakeTitleIds.length > 1 ? ` (Mehrfachauswahl: ${effectiveSelectedHandBrakeTitleIds.map((id) => `-t ${id}`).join(', ')})` : ''}.` ); try { await this.runMediainfoReviewForJob(jobId, job.raw_path, { mode: 'rip', mediaProfile, selectedHandBrakeTitleId: effectiveSelectedHandBrakeTitleId, selectedHandBrakeTitleIds: effectiveSelectedHandBrakeTitleIds }); } catch (error) { logger.error('metadata:handbrake-title-selection:review-failed', { jobId, selectedHandBrakeTitleId: effectiveSelectedHandBrakeTitleId, selectedHandBrakeTitleIds: effectiveSelectedHandBrakeTitleIds, 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; } let effectiveImdbId = imdbId === undefined ? (job.imdb_id || null) : (imdbId || null); const selectedFromMetadata = 0; let posterValue = poster === undefined ? (job.poster_url || null) : (poster || null); let shouldQueuePosterCache = false; if (posterValue && !thumbnailService.isLocalUrl(posterValue)) { try { const cacheResult = await historyService.cacheAndPromoteExternalPoster(jobId, posterValue, { source: 'Poster', logFailures: false }); if (cacheResult?.ok && cacheResult?.localUrl) { posterValue = cacheResult.localUrl; } else { shouldQueuePosterCache = true; } } catch (_error) { shouldQueuePosterCache = true; } } let effectiveMetadataProvider = String( metadataProvider || mkInfo?.analyzeContext?.metadataProvider || 'tmdb' ).trim().toLowerCase() || 'tmdb'; const requestedWorkflowKind = normalizeDvdMetadataWorkflowKind(workflowKind); const existingWorkflowKind = normalizeDvdMetadataWorkflowKind( mkInfo?.analyzeContext?.selectedMetadata?.workflowKind || mkInfo?.analyzeContext?.workflowKind || mkInfo?.selectedMetadata?.workflowKind || null ); const providerWorkflowKind = effectiveMetadataProvider === 'tmdb' ? (isSeriesDiscMediaProfile(mediaProfile) ? 'series' : 'film') : null; const effectiveWorkflowKind = requestedWorkflowKind || existingWorkflowKind || providerWorkflowKind; const normalizedDuplicateAction = String(duplicateAction || '').trim().toLowerCase(); const requestedExistingJobId = normalizePositiveInteger(existingJobId); const requestedExistingDiscNumber = normalizePositiveInteger(existingDiscNumber); if (isSeriesDiscMediaProfile(mediaProfile)) { if (effectiveWorkflowKind === 'series') { effectiveMetadataProvider = 'tmdb'; } } let effectiveProviderId = String( providerId || mkInfo?.analyzeContext?.selectedMetadata?.providerId || '' ).trim() || null; let effectiveTmdbId = Number( tmdbId || mkInfo?.analyzeContext?.selectedMetadata?.tmdbId || 0 ) || null; let effectiveMetadataKind = String( metadataKind || mkInfo?.analyzeContext?.selectedMetadata?.metadataKind || '' ).trim().toLowerCase() || null; let effectiveDiscNumber = normalizePositiveInteger(discNumber); let effectiveSeasonNumber = Number( seasonNumber || mkInfo?.analyzeContext?.selectedMetadata?.seasonNumber || 0 ) || null; let effectiveSeasonName = String( seasonName || mkInfo?.analyzeContext?.selectedMetadata?.seasonName || '' ).trim() || null; let effectiveEpisodeCount = Number( episodeCount || mkInfo?.analyzeContext?.selectedMetadata?.episodeCount || 0 ) || 0; const hasExplicitEpisodePayload = Array.isArray(episodes) && episodes.length > 0; let effectiveEpisodes = hasExplicitEpisodePayload ? episodes : (Array.isArray(mkInfo?.analyzeContext?.selectedMetadata?.episodes) ? mkInfo.analyzeContext.selectedMetadata.episodes : []); let tmdbDetails = null; let tmdbLanguage = null; if (effectiveMetadataProvider === 'tmdb') { try { const seriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile); tmdbLanguage = String(seriesSettings?.dvd_series_language || '').trim() || null; } catch (_settingsError) { tmdbLanguage = null; } } const isDvdTmdbMetadataSelection = isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'series'; if (isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'film') { if (!effectiveProviderId && effectiveTmdbId) { effectiveProviderId = `tmdb:${effectiveTmdbId}`; } effectiveMetadataKind = 'movie'; effectiveSeasonNumber = null; effectiveSeasonName = null; effectiveEpisodeCount = 0; effectiveEpisodes = []; } if (isDvdTmdbMetadataSelection && !effectiveDiscNumber) { const error = new Error(`Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt: Disk-Nummer ist Pflicht (Start bei 1).`); error.statusCode = 400; throw error; } let seasonDetails = null; let seasonCredits = null; if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveWorkflowKind !== 'film') { try { const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, { language: tmdbLanguage, appendToResponse: ['credits', 'external_ids'] }); tmdbDetails = tmdbService.buildSeriesDetailsSummary(seriesDetails); if (!effectiveImdbId && tmdbDetails?.imdbId) { effectiveImdbId = tmdbDetails.imdbId; } if ((!effectiveYear || Number(effectiveYear) <= 0) && tmdbDetails?.firstAirDate) { const tmdbYear = Number(String(tmdbDetails.firstAirDate).slice(0, 4)); if (Number.isFinite(tmdbYear) && tmdbYear > 0) { effectiveYear = Math.trunc(tmdbYear); } } const createdBy = tmdbService.normalizeNameList(seriesDetails?.created_by, { maxItems: 3 }); const genres = tmdbService.normalizeNameList(seriesDetails?.genres, { maxItems: 3 }); tmdbDetails = { ...(tmdbDetails && typeof tmdbDetails === 'object' ? tmdbDetails : {}), createdBy, genres }; } catch (tmdbDetailsErr) { logger.warn('metadata:tmdb-details-fetch-failed', { jobId, tmdbId: effectiveTmdbId, error: errorToMeta(tmdbDetailsErr) }); } } if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveWorkflowKind === 'film') { try { const movieDetails = await tmdbService.getMovieDetailsWithCredits(effectiveTmdbId, { language: tmdbLanguage, appendToResponse: ['credits', 'external_ids'], ensureCredits: true }); const movieSummary = tmdbService.buildMovieDetailsSummary(movieDetails); const genres = tmdbService.normalizeNameList(movieDetails?.genres, { maxItems: 3 }); const seasonCast = tmdbService.normalizeNameList(movieDetails?.credits?.cast, { maxItems: 6 }); tmdbDetails = { ...(movieSummary && typeof movieSummary === 'object' ? movieSummary : {}), genres, seasonCast }; if (!effectiveImdbId && tmdbDetails?.imdbId) { effectiveImdbId = tmdbDetails.imdbId; } if ((!effectiveYear || Number(effectiveYear) <= 0) && tmdbDetails?.releaseDate) { const tmdbYear = Number(String(tmdbDetails.releaseDate).slice(0, 4)); if (Number.isFinite(tmdbYear) && tmdbYear > 0) { effectiveYear = Math.trunc(tmdbYear); } } } catch (tmdbMovieErr) { logger.warn('metadata:tmdb-movie-fetch-failed', { jobId, tmdbId: effectiveTmdbId, error: errorToMeta(tmdbMovieErr) }); } } if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveSeasonNumber) { try { seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber, { language: tmdbLanguage }); seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber, { language: tmdbLanguage }); const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails); if (seasonSummary) { const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : []; if (fetchedEpisodes.length > 0 && !hasExplicitEpisodePayload) { effectiveEpisodes = fetchedEpisodes; } const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0; if (fetchedEpisodeCount > 0) { effectiveEpisodeCount = fetchedEpisodeCount; } if (!effectiveSeasonName && seasonSummary.name) { effectiveSeasonName = String(seasonSummary.name).trim() || null; } } const seasonVoteAverageRaw = Number(seasonDetails?.vote_average || 0); const seasonVoteAverage = Number.isFinite(seasonVoteAverageRaw) && seasonVoteAverageRaw > 0 ? Number(seasonVoteAverageRaw.toFixed(1)) : null; const seasonRuntimeValues = Array.isArray(seasonDetails?.episodes) ? seasonDetails.episodes.map((episode) => Number(episode?.runtime || 0)).filter((val) => Number.isFinite(val) && val > 0) : []; const seasonRuntimeLabel = tmdbService.formatRuntimeLabel(seasonRuntimeValues); const seasonCast = tmdbService.normalizeNameList(seasonCredits?.cast, { maxItems: 6 }); tmdbDetails = { ...(tmdbDetails && typeof tmdbDetails === 'object' ? tmdbDetails : {}), seasonNumber: effectiveSeasonNumber, seasonVoteAverage, seasonRuntime: seasonRuntimeLabel, seasonCast }; } catch (tmdbSeasonErr) { logger.warn('metadata:tmdb-season-fetch-failed', { jobId, tmdbId: effectiveTmdbId, seasonNumber: effectiveSeasonNumber, error: errorToMeta(tmdbSeasonErr) }); } } let selectedMetadata = { title: effectiveTitle, year: effectiveYear, imdbId: effectiveImdbId, poster: posterValue, metadataProvider: effectiveMetadataProvider, ...(effectiveWorkflowKind ? { workflowKind: effectiveWorkflowKind } : {}), providerId: effectiveProviderId, tmdbId: effectiveTmdbId, metadataKind: effectiveMetadataKind, ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}), seasonNumber: effectiveSeasonNumber, seasonName: effectiveSeasonName, episodeCount: effectiveEpisodeCount, episodes: effectiveEpisodes, ...(tmdbDetails && typeof tmdbDetails === 'object' ? { tmdbDetails } : {}) }; const isDiscFilmMetadataSelection = isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'film'; const filmMetadataFingerprint = isDiscFilmMetadataSelection ? buildMovieMetadataFingerprint(mediaProfile, { imdbId: effectiveImdbId, title: effectiveTitle, year: effectiveYear }) : null; let multipartContainerJobId = null; let multipartExistingJobId = null; let multipartCurrentDiscNumber = null; let multipartExistingDiscNumber = null; let multipartExistingSelectedMetadataForRawRename = null; let multipartExistingAnalyzeContextForRawRename = null; let shouldDetachMultipartContainer = false; if (isDiscFilmMetadataSelection) { const currentParentJobId = normalizePositiveInteger(job.parent_job_id || null); if (currentParentJobId) { const currentParentJob = await historyService.getJobById(currentParentJobId).catch(() => null); if (String(currentParentJob?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container') { shouldDetachMultipartContainer = true; } } if (filmMetadataFingerprint) { const db = await getDb(); let duplicateRows = await db.all( ` SELECT * FROM jobs WHERE id != ? AND media_type = ? AND metadata_fingerprint = ? AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') ORDER BY updated_at DESC, id DESC LIMIT 25 `, [Number(jobId), mediaProfile, filmMetadataFingerprint] ); if ((!Array.isArray(duplicateRows) || duplicateRows.length === 0) && effectiveImdbId) { duplicateRows = await db.all( ` SELECT * FROM jobs WHERE id != ? AND media_type = ? AND LOWER(TRIM(COALESCE(imdb_id, ''))) = LOWER(TRIM(?)) AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') ORDER BY updated_at DESC, id DESC LIMIT 25 `, [Number(jobId), mediaProfile, String(effectiveImdbId || '').trim()] ); } if ((!Array.isArray(duplicateRows) || duplicateRows.length === 0) && effectiveTitle) { if (effectiveYear) { duplicateRows = await db.all( ` SELECT * FROM jobs WHERE id != ? AND media_type = ? AND LOWER(TRIM(COALESCE(title, ''))) = LOWER(TRIM(?)) AND CAST(COALESCE(year, 0) AS INTEGER) = ? AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') ORDER BY updated_at DESC, id DESC LIMIT 25 `, [Number(jobId), mediaProfile, String(effectiveTitle || '').trim(), Number(effectiveYear)] ); } else { duplicateRows = await db.all( ` SELECT * FROM jobs WHERE id != ? AND media_type = ? AND LOWER(TRIM(COALESCE(title, ''))) = LOWER(TRIM(?)) AND COALESCE(job_kind, '') NOT IN ('dvd_series_container', 'multipart_movie_container', 'multipart_movie_merge') ORDER BY updated_at DESC, id DESC LIMIT 25 `, [Number(jobId), mediaProfile, String(effectiveTitle || '').trim()] ); } } const duplicateCandidates = Array.isArray(duplicateRows) ? duplicateRows : []; const firstDuplicate = duplicateCandidates[0] || null; const isMultipartAction = normalizedDuplicateAction === 'multipart_movie'; const allowDuplicate = normalizedDuplicateAction === 'allow_new'; let resolvedExistingJob = null; if (requestedExistingJobId && requestedExistingJobId !== Number(jobId)) { resolvedExistingJob = await historyService.getJobById(requestedExistingJobId).catch(() => null); } else if (firstDuplicate) { resolvedExistingJob = await historyService.getJobById(firstDuplicate.id).catch(() => null); } const throwDuplicateConflict = (existingJob) => { const existingDisc = resolveDiscNumberFromJobLike(existingJob); const error = new Error('Metadaten bereits in der Historie gefunden. Bitte Auswahl übernehmen oder Multipart movie wählen.'); error.statusCode = 409; error.details = [ { code: 'METADATA_DUPLICATE_FOUND', fingerprint: filmMetadataFingerprint, mediaProfile, existingJob: existingJob ? { id: Number(existingJob.id || 0) || null, title: String(existingJob.title || existingJob.detected_title || '').trim() || null, year: Number(existingJob.year || 0) || null, status: String(existingJob.status || '').trim() || null, lastState: String(existingJob.last_state || '').trim() || null, mediaType: String(existingJob.media_type || existingJob.mediaType || '').trim().toLowerCase() || null, jobKind: String(existingJob.job_kind || existingJob.jobKind || '').trim().toLowerCase() || null, discNumber: existingDisc, isMultipartMovie: Number(existingJob.is_multipart_movie || 0) === 1 } : null } ]; throw error; }; if (resolvedExistingJob && !allowDuplicate && !isMultipartAction) { throwDuplicateConflict(resolvedExistingJob); } if (isMultipartAction) { if (!resolvedExistingJob || Number(resolvedExistingJob?.id || 0) === Number(jobId)) { throwDuplicateConflict(resolvedExistingJob || firstDuplicate); } const existingMediaProfile = normalizeMediaProfile(resolvedExistingJob?.media_type || resolvedExistingJob?.mediaType || null); if (existingMediaProfile !== normalizeMediaProfile(mediaProfile)) { const error = new Error('Multipart movie benötigt denselben Medientyp (DVD oder Blu-ray).'); error.statusCode = 409; error.details = [{ code: 'MULTIPART_MEDIA_MISMATCH' }]; throw error; } const existingMkInfo = this.safeParseJson(resolvedExistingJob?.makemkv_info_json) || (resolvedExistingJob?.makemkvInfo && typeof resolvedExistingJob.makemkvInfo === 'object' ? resolvedExistingJob.makemkvInfo : {}); const existingAnalyzeContext = existingMkInfo?.analyzeContext && typeof existingMkInfo.analyzeContext === 'object' ? existingMkInfo.analyzeContext : {}; const existingSelectedMetadata = resolveSelectedMetadataForJob( resolvedExistingJob, existingAnalyzeContext, null ); const existingIsSeries = isSeriesDvdMetadataSelection(existingMediaProfile, existingSelectedMetadata, existingAnalyzeContext); if (existingIsSeries) { const error = new Error('Multipart movie ist nur für Film-Jobs erlaubt.'); error.statusCode = 409; error.details = [{ code: 'MULTIPART_SERIES_NOT_ALLOWED' }]; throw error; } const existingFingerprint = buildMovieMetadataFingerprint(existingMediaProfile, { imdbId: existingSelectedMetadata?.imdbId || resolvedExistingJob?.imdb_id || null, title: existingSelectedMetadata?.title || resolvedExistingJob?.title || resolvedExistingJob?.detected_title || null, year: existingSelectedMetadata?.year || resolvedExistingJob?.year || null }); if (existingFingerprint && existingFingerprint !== filmMetadataFingerprint) { const error = new Error('Ausgewählter bestehender Job passt nicht zu den aktuellen Film-Metadaten.'); error.statusCode = 409; error.details = [{ code: 'MULTIPART_METADATA_MISMATCH' }]; throw error; } multipartCurrentDiscNumber = normalizePositiveInteger(effectiveDiscNumber); multipartExistingDiscNumber = requestedExistingDiscNumber || resolveDiscNumberFromJobLike(resolvedExistingJob); if (!multipartCurrentDiscNumber || !multipartExistingDiscNumber) { const error = new Error('Für Multipart movie sind Disc-Nummern für beide Jobs erforderlich (>= 1).'); error.statusCode = 400; error.details = [{ code: 'MULTIPART_DISC_REQUIRED' }]; throw error; } if (multipartCurrentDiscNumber === multipartExistingDiscNumber) { const error = new Error('Disc-Nummern im Multipart-Container müssen eindeutig sein.'); error.statusCode = 409; error.details = [{ code: 'MULTIPART_DISC_ALREADY_EXISTS', discNumber: multipartCurrentDiscNumber }]; throw error; } const loadMultipartContainerById = async (candidateId) => { const normalizedCandidateId = normalizePositiveInteger(candidateId); if (!normalizedCandidateId) { return null; } const row = await historyService.getJobById(normalizedCandidateId).catch(() => null); if (!row) { return null; } return String(row?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container' ? row : null; }; let multipartContainerJob = null; multipartContainerJob = await loadMultipartContainerById(job.parent_job_id); if (!multipartContainerJob) { multipartContainerJob = await loadMultipartContainerById(resolvedExistingJob?.parent_job_id); } if (!multipartContainerJob) { const existingContainerByFingerprint = await db.get( ` SELECT * FROM jobs WHERE job_kind = 'multipart_movie_container' AND media_type = ? AND metadata_fingerprint = ? ORDER BY updated_at DESC, id DESC LIMIT 1 `, [mediaProfile, filmMetadataFingerprint] ); if (existingContainerByFingerprint?.id) { multipartContainerJob = await historyService.getJobById(existingContainerByFingerprint.id).catch(() => null); } } if (!multipartContainerJob) { multipartContainerJob = await historyService.createJob({ discDevice: job.disc_device || resolvedExistingJob?.disc_device || null, status: job.status || 'METADATA_SELECTION', detectedTitle: effectiveTitle || job.detected_title || null, mediaType: mediaProfile, jobKind: 'multipart_movie_container' }); } multipartContainerJobId = normalizePositiveInteger(multipartContainerJob?.id); if (!multipartContainerJobId) { const error = new Error('Multipart-Container konnte nicht angelegt werden.'); error.statusCode = 500; throw error; } const containerChildren = await historyService.listChildJobs(multipartContainerJobId); const conflictingDiscChild = (Array.isArray(containerChildren) ? containerChildren : []).find((child) => { const childId = normalizePositiveInteger(child?.id); if (!childId || childId === Number(jobId) || childId === Number(resolvedExistingJob?.id || 0)) { return false; } const childDisc = resolveDiscNumberFromJobLike(child); return childDisc === multipartCurrentDiscNumber || childDisc === multipartExistingDiscNumber; }); if (conflictingDiscChild) { const conflictDisc = resolveDiscNumberFromJobLike(conflictingDiscChild); const error = new Error(`Disc ${conflictDisc || '?'} ist im Multipart-Container bereits vergeben.`); error.statusCode = 409; error.details = [ { code: 'MULTIPART_DISC_ALREADY_EXISTS', containerJobId: multipartContainerJobId, existingJobId: Number(conflictingDiscChild?.id || 0) || null, discNumber: conflictDisc || null } ]; throw error; } multipartExistingJobId = normalizePositiveInteger(resolvedExistingJob?.id); const patchedExistingSelectedMetadata = { ...existingSelectedMetadata, workflowKind: 'film', metadataProvider: 'tmdb', metadataKind: 'movie', providerId: null, tmdbId: null, seasonNumber: null, seasonName: null, episodeCount: 0, episodes: [], discNumber: multipartExistingDiscNumber }; const existingAnalyzeSelectedMetadata = existingAnalyzeContext?.selectedMetadata && typeof existingAnalyzeContext.selectedMetadata === 'object' ? existingAnalyzeContext.selectedMetadata : {}; const existingTopLevelSelectedMetadata = existingMkInfo?.selectedMetadata && typeof existingMkInfo.selectedMetadata === 'object' ? existingMkInfo.selectedMetadata : {}; const patchedExistingMkInfo = this.withAnalyzeContextMediaProfile({ ...(existingMkInfo && typeof existingMkInfo === 'object' ? existingMkInfo : {}), selectedMetadata: { ...existingTopLevelSelectedMetadata, ...patchedExistingSelectedMetadata }, analyzeContext: { ...existingAnalyzeContext, metadataProvider: 'tmdb', workflowKind: 'film', selectedMetadata: { ...existingAnalyzeSelectedMetadata, ...patchedExistingSelectedMetadata } } }, mediaProfile); multipartExistingSelectedMetadataForRawRename = patchedExistingSelectedMetadata; multipartExistingAnalyzeContextForRawRename = { ...existingAnalyzeContext, metadataProvider: 'tmdb', workflowKind: 'film', selectedMetadata: patchedExistingSelectedMetadata }; if (multipartExistingJobId) { await historyService.updateJob(multipartExistingJobId, { parent_job_id: multipartContainerJobId, media_type: mediaProfile, job_kind: 'multipart_movie_child', is_multipart_movie: 1, disc_number: multipartExistingDiscNumber, metadata_fingerprint: filmMetadataFingerprint, makemkv_info_json: JSON.stringify(patchedExistingMkInfo) }); } const containerSelectedMetadataPatch = { ...selectedMetadata, workflowKind: 'film', metadataProvider: 'tmdb', metadataKind: 'movie', providerId: null, tmdbId: null, seasonNumber: null, seasonName: null, episodeCount: 0, episodes: [], discNumber: null }; const containerMkInfo = this.withAnalyzeContextMediaProfile({ analyzeContext: { metadataProvider: 'tmdb', workflowKind: 'film', selectedMetadata: containerSelectedMetadataPatch }, selectedMetadata: containerSelectedMetadataPatch }, mediaProfile); await historyService.updateJob(multipartContainerJobId, { title: effectiveTitle, year: effectiveYear, imdb_id: effectiveImdbId, poster_url: posterValue, selected_from_omdb: selectedFromMetadata, omdb_json: null, status: job.status || 'METADATA_SELECTION', last_state: job.status || 'METADATA_SELECTION', media_type: mediaProfile, job_kind: 'multipart_movie_container', is_multipart_movie: 1, disc_number: null, metadata_fingerprint: filmMetadataFingerprint, makemkv_info_json: JSON.stringify(containerMkInfo) }); shouldDetachMultipartContainer = false; } } } const shouldDetachSeriesContainer = isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'film'; let parentContainerJobId = shouldDetachSeriesContainer ? null : normalizePositiveInteger(job.parent_job_id || null); const isDvdSeriesSelection = isDvdTmdbMetadataSelection; if (isDvdSeriesSelection) { const normalizeEpisodeRows = (rows) => { const list = Array.isArray(rows) ? rows : []; return list.filter((entry) => entry && typeof entry === 'object'); }; const hasEpisodeRows = (rows) => Array.isArray(rows) && rows.length > 0; const toPositiveIntOrNull = (value) => { const parsed = Number(value || 0); if (!Number.isFinite(parsed) || parsed <= 0) { return null; } return Math.trunc(parsed); }; const extractSelectedMetadataFromJobRow = (row) => { const rowMkInfo = this.safeParseJson(row?.makemkv_info_json) || {}; const rowAnalyzeContext = rowMkInfo?.analyzeContext && typeof rowMkInfo.analyzeContext === 'object' ? rowMkInfo.analyzeContext : {}; const rowSelectedMetadata = rowAnalyzeContext?.selectedMetadata && typeof rowAnalyzeContext.selectedMetadata === 'object' ? rowAnalyzeContext.selectedMetadata : (rowMkInfo?.selectedMetadata && typeof rowMkInfo.selectedMetadata === 'object' ? rowMkInfo.selectedMetadata : {}); return rowSelectedMetadata && typeof rowSelectedMetadata === 'object' ? rowSelectedMetadata : {}; }; const resolveDiscNumberFromJobRow = (row) => { const rowSelectedMetadata = extractSelectedMetadataFromJobRow(row); return normalizePositiveInteger(rowSelectedMetadata?.discNumber); }; let containerJobRow = null; if (!parentContainerJobId) { const existingContainer = await historyService.findSeriesContainerJob(effectiveTmdbId, effectiveSeasonNumber); if (existingContainer) { parentContainerJobId = Number(existingContainer.id); } else { const likelyContainer = await historyService.findLikelySeriesContainerJob({ title: effectiveTitle, year: effectiveYear }); if (likelyContainer) { const likelyMkInfo = this.safeParseJson(likelyContainer?.makemkv_info_json) || {}; const likelySelected = likelyMkInfo?.analyzeContext?.selectedMetadata || likelyMkInfo?.selectedMetadata || {}; const likelyTmdbId = Number(likelySelected?.tmdbId || 0) || null; const likelySeason = Number(likelySelected?.seasonNumber || 0) || null; const seasonCompatible = !likelySeason || likelySeason === Number(effectiveSeasonNumber); const tmdbCompatible = !likelyTmdbId || likelyTmdbId === Number(effectiveTmdbId); if (seasonCompatible && tmdbCompatible) { parentContainerJobId = Number(likelyContainer.id); logger.info('dvd-series:container:assigned-likely', { jobId, parentContainerJobId, tmdbId: effectiveTmdbId, seasonNumber: effectiveSeasonNumber }); } } } if (!parentContainerJobId) { const containerJob = await historyService.createJob({ discDevice: job.disc_device || null, status: job.status || 'METADATA_SELECTION', detectedTitle: effectiveTitle || job.detected_title || null, mediaType: mediaProfile, jobKind: 'dvd_series_container' }); parentContainerJobId = Number(containerJob?.id || 0) || null; } } if (parentContainerJobId) { containerJobRow = await historyService.getJobById(parentContainerJobId).catch(() => null); } if (parentContainerJobId && effectiveDiscNumber) { const containerChildren = await historyService.listChildJobs(parentContainerJobId); const conflictingDiscChild = (Array.isArray(containerChildren) ? containerChildren : []).find((childRow) => { const childId = Number(childRow?.id || 0); if (!childId || childId === Number(jobId)) { return false; } const childDiscNumber = resolveDiscNumberFromJobRow(childRow); return childDiscNumber === effectiveDiscNumber; }); if (conflictingDiscChild) { const existingChildJobId = Number(conflictingDiscChild?.id || 0) || null; const error = new Error( `Disk ${effectiveDiscNumber} ist fuer diese Serie/Staffel bereits vorhanden (Job #${existingChildJobId || '?'}). Bitte andere Disk-Nummer waehlen.` ); error.statusCode = 409; error.details = [ { code: 'SERIES_DISC_ALREADY_EXISTS', containerJobId: Number(parentContainerJobId), existingJobId: existingChildJobId, discNumber: Number(effectiveDiscNumber), tmdbId: Number(effectiveTmdbId || 0) || null, seasonNumber: Number(effectiveSeasonNumber || 0) || null } ]; throw error; } } const currentEpisodes = normalizeEpisodeRows(effectiveEpisodes); const currentEpisodeCount = toPositiveIntOrNull(effectiveEpisodeCount) || 0; const needsEpisodeFallback = !hasEpisodeRows(currentEpisodes) || currentEpisodeCount <= 0; if (needsEpisodeFallback && effectiveTmdbId && effectiveSeasonNumber) { const fallbackRows = []; if (containerJobRow) { fallbackRows.push(containerJobRow); } const siblingRowsForEpisodes = await historyService.listSeriesSiblingJobs(effectiveTmdbId, effectiveSeasonNumber); fallbackRows.push(...(Array.isArray(siblingRowsForEpisodes) ? siblingRowsForEpisodes : [])); for (const fallbackRow of fallbackRows) { const fallbackSelectedMetadata = extractSelectedMetadataFromJobRow(fallbackRow); const fallbackEpisodes = normalizeEpisodeRows(fallbackSelectedMetadata?.episodes); if (!hasEpisodeRows(fallbackEpisodes)) { continue; } effectiveEpisodes = fallbackEpisodes; const fallbackEpisodeCount = toPositiveIntOrNull(fallbackSelectedMetadata?.episodeCount); effectiveEpisodeCount = fallbackEpisodeCount || fallbackEpisodes.length; if (!effectiveSeasonName) { effectiveSeasonName = String(fallbackSelectedMetadata?.seasonName || '').trim() || null; } break; } } selectedMetadata = { ...selectedMetadata, seasonName: effectiveSeasonName, episodeCount: effectiveEpisodeCount, episodes: effectiveEpisodes }; if (parentContainerJobId) { const containerSelectedMetadata = extractSelectedMetadataFromJobRow(containerJobRow); const nextEpisodes = normalizeEpisodeRows(selectedMetadata?.episodes); const fallbackEpisodes = normalizeEpisodeRows(containerSelectedMetadata?.episodes); const mergedEpisodes = hasEpisodeRows(nextEpisodes) ? nextEpisodes : fallbackEpisodes; const selectedEpisodeCount = toPositiveIntOrNull(selectedMetadata?.episodeCount); const fallbackEpisodeCount = toPositiveIntOrNull(containerSelectedMetadata?.episodeCount); const mergedEpisodeCount = selectedEpisodeCount || fallbackEpisodeCount || (hasEpisodeRows(mergedEpisodes) ? mergedEpisodes.length : 0); const mergedSeasonName = String( selectedMetadata?.seasonName || containerSelectedMetadata?.seasonName || '' ).trim() || null; const containerSelectedMetadataPatch = { ...selectedMetadata, seasonName: mergedSeasonName, episodeCount: mergedEpisodeCount, episodes: mergedEpisodes }; selectedMetadata = containerSelectedMetadataPatch; const containerMkInfo = this.withAnalyzeContextMediaProfile({ analyzeContext: { metadataProvider: effectiveMetadataProvider, selectedMetadata: containerSelectedMetadataPatch } }, mediaProfile); await historyService.updateJob(parentContainerJobId, { title: effectiveTitle, year: effectiveYear, imdb_id: effectiveImdbId, poster_url: posterValue, selected_from_omdb: selectedFromMetadata, omdb_json: null, status: job.status || 'METADATA_SELECTION', last_state: job.status || 'METADATA_SELECTION', media_type: mediaProfile, job_kind: 'dvd_series_container', makemkv_info_json: JSON.stringify(containerMkInfo) }); } const siblingRows = await historyService.listSeriesSiblingJobs(effectiveTmdbId, effectiveSeasonNumber); for (const sibling of siblingRows) { const siblingId = Number(sibling?.id || 0); if (!siblingId || siblingId === Number(jobId)) { continue; } const siblingParentId = normalizePositiveInteger(sibling.parent_job_id || null); const needsParentUpdate = siblingParentId !== parentContainerJobId; const needsKindUpdate = String(sibling?.job_kind || '').trim().toLowerCase() !== 'dvd_series_child'; if (!needsParentUpdate && !needsKindUpdate) { continue; } await historyService.updateJob(siblingId, { parent_job_id: parentContainerJobId, job_kind: 'dvd_series_child', ...(sibling?.media_type ? {} : { media_type: mediaProfile }) }); } } const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const currentJobKind = String(job?.job_kind || '').trim().toLowerCase(); const isCurrentJobMultipart = Number(job?.is_multipart_movie || 0) === 1 || currentJobKind === 'multipart_movie_child'; const isMultipartMovieSelection = Boolean( isDiscFilmMetadataSelection && (multipartContainerJobId || normalizedDuplicateAction === 'multipart_movie' || isCurrentJobMultipart) ); const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' ? 'backup' : 'mkv'; const isBackupMode = ripMode === 'backup'; if (isMultipartMovieSelection && multipartExistingJobId && multipartExistingSelectedMetadataForRawRename) { try { const existingJobForRawRename = await historyService.getJobById(multipartExistingJobId).catch(() => null); const existingJobHasActiveProcess = this._isActiveProcessForJob( multipartExistingJobId, { cleanupStale: true } ); if (existingJobHasActiveProcess) { await historyService.appendLog( multipartExistingJobId, 'SYSTEM', 'Multipart-RAW-Rename verschoben: Job ist aktiv, Pfad bleibt waehrend Analyze/Encode stabil.' ); logger.info('metadata:multipart-existing-raw-rename-deferred-active-job', { jobId: multipartExistingJobId, status: String(existingJobForRawRename?.status || '').trim().toUpperCase() || null, lastState: String(existingJobForRawRename?.last_state || '').trim().toUpperCase() || null }); } const existingStoredRawPath = String(existingJobForRawRename?.raw_path || '').trim(); const resolvedExistingRawPath = this.resolveCurrentRawPathForSettings( settings, mediaProfile, existingStoredRawPath ) || existingStoredRawPath || null; if ( !existingJobHasActiveProcess && resolvedExistingRawPath && fs.existsSync(resolvedExistingRawPath) && fs.statSync(resolvedExistingRawPath).isDirectory() ) { const existingRawState = resolveRawFolderStateFromPath(resolvedExistingRawPath); const targetRawState = existingRawState === RAW_FOLDER_STATES.INCOMPLETE ? RAW_FOLDER_STATES.INCOMPLETE : RAW_FOLDER_STATES.RIP_COMPLETE; const existingMetadataBase = buildRawMetadataBase({ title: existingJobForRawRename?.title || effectiveTitle || null, year: existingJobForRawRename?.year || effectiveYear || null, detected_title: existingJobForRawRename?.detected_title || effectiveTitle || null, media_type: mediaProfile, is_multipart_movie: 1, job_kind: 'multipart_movie_child' }, multipartExistingJobId, { mediaProfile, analyzeContext: multipartExistingAnalyzeContextForRawRename, selectedMetadata: multipartExistingSelectedMetadataForRawRename, isMultipartMovie: true }); const existingTargetRawPath = path.join( path.dirname(resolvedExistingRawPath), buildRawDirName(existingMetadataBase, multipartExistingJobId, { state: targetRawState }) ); if ( normalizeComparablePath(resolvedExistingRawPath) !== normalizeComparablePath(existingTargetRawPath) && !fs.existsSync(existingTargetRawPath) ) { fs.renameSync(resolvedExistingRawPath, existingTargetRawPath); await historyService.updateRawPathByOldPath(resolvedExistingRawPath, existingTargetRawPath); await historyService.appendLog( multipartExistingJobId, 'SYSTEM', `Multipart-RAW umbenannt: ${resolvedExistingRawPath} -> ${existingTargetRawPath}` ); logger.info('metadata:multipart-existing-raw-renamed', { jobId: multipartExistingJobId, from: resolvedExistingRawPath, to: existingTargetRawPath }); } } } catch (multipartRawRenameError) { logger.warn('metadata:multipart-existing-raw-rename-failed', { jobId: multipartExistingJobId, error: errorToMeta(multipartRawRenameError) }); } } const metadataBase = buildRawMetadataBase({ title: selectedMetadata.title || job.detected_title || null, year: selectedMetadata.year || null, detected_title: job.detected_title || null, media_type: mediaProfile, is_multipart_movie: isMultipartMovieSelection ? 1 : 0, job_kind: isMultipartMovieSelection ? 'multipart_movie_child' : (job?.job_kind || null) }, jobId, { mediaProfile, analyzeContext: mkInfo?.analyzeContext || null, selectedMetadata, isMultipartMovie: isMultipartMovieSelection }); const rawStorage = resolveSeriesAwareRawStorage( settings, mediaProfile, selectedMetadata, mkInfo?.analyzeContext || null ); const rawBaseDir = String( rawStorage?.rawBaseDir || settings?.raw_dir || settingsService.DEFAULT_RAW_DIR || '' ).trim(); const metadataMatchedRawPath = findExistingRawDirectory(rawBaseDir, metadataBase); const metadataMatchedRawJobId = metadataMatchedRawPath ? extractRawFolderJobId(path.basename(metadataMatchedRawPath)) : null; const existingRawPath = isMultipartMovieSelection ? (metadataMatchedRawJobId === Number(jobId) ? metadataMatchedRawPath : null) : metadataMatchedRawPath; const resolvedCurrentJobRawPath = this.resolveCurrentRawPathForSettings( settings, mediaProfile, job.raw_path ) || String(job.raw_path || '').trim() || null; const currentJobRawPathPresent = Boolean(resolvedCurrentJobRawPath); const currentJobRawPathExists = Boolean( resolvedCurrentJobRawPath && fs.existsSync(resolvedCurrentJobRawPath) ); const normalizedMetadataMatchedRawPath = normalizeComparablePath(metadataMatchedRawPath); const normalizedCurrentJobRawPath = normalizeComparablePath(resolvedCurrentJobRawPath); const metadataMatchedIsCurrentJobRaw = Boolean( normalizedMetadataMatchedRawPath && normalizedCurrentJobRawPath && normalizedMetadataMatchedRawPath === normalizedCurrentJobRawPath ); let metadataMatchedLinkedJobId = null; if (normalizedMetadataMatchedRawPath) { const dbForRawDecision = await getDb(); const linkedRawRows = await dbForRawDecision.all( ` SELECT id, raw_path FROM jobs WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' ` ); const linkedRow = (Array.isArray(linkedRawRows) ? linkedRawRows : []).find((row) => { const rowId = normalizePositiveInteger(row?.id); if (!rowId || rowId === Number(jobId)) { return false; } return normalizeComparablePath(row?.raw_path) === normalizedMetadataMatchedRawPath; }); metadataMatchedLinkedJobId = normalizePositiveInteger(linkedRow?.id); } const metadataMatchedLooksOrphan = Boolean( normalizedMetadataMatchedRawPath && !metadataMatchedIsCurrentJobRaw && !metadataMatchedLinkedJobId ); const orphanDiscHint = metadataMatchedRawPath ? extractMultipartFilmDiscFromRawFolderName(path.basename(metadataMatchedRawPath)) : null; const multipartDiscSuggestions = resolveMultipartDiscNumberPair({ preferredCurrentDiscNumber: normalizePositiveInteger( selectedMetadata?.discNumber ?? effectiveDiscNumber ?? null ), preferredOrphanDiscNumber: orphanDiscHint }); let updatedRawPath = existingRawPath || (currentJobRawPathPresent ? resolvedCurrentJobRawPath : null); const shouldAutoReviewFromRaw = Boolean(existingRawPath || currentJobRawPathExists); 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(rawBaseDir, 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 isRawImportMetadataJob = String(mkInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; const requiresManualPlaylistSelection = Boolean( playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null ); const requiresRawDecision = shouldAutoReviewFromRaw && !isRawImportMetadataJob && !isMultipartMovieSelection; const canMergeWithMatchedOrphanRaw = Boolean( requiresRawDecision && isDiscFilmMetadataSelection && !isMultipartMovieSelection && metadataMatchedLooksOrphan ); const rawDecisionContext = { matchedRawPathBeforeMetadata: existingRawPath || null, matchedRawPathAfterMetadata: updatedRawPath || null, matchedRawFolderJobId: metadataMatchedRawJobId || null, matchedRawLinkedJobId: metadataMatchedLinkedJobId || null, matchedRawLooksOrphan: metadataMatchedLooksOrphan, mergeOrphanEligible: canMergeWithMatchedOrphanRaw, multipartSuggestion: canMergeWithMatchedOrphanRaw ? { orphanDiscNumber: multipartDiscSuggestions.orphanDiscNumber, currentDiscNumber: multipartDiscSuggestions.currentDiscNumber } : null }; const nextStatus = (requiresManualPlaylistSelection || requiresRawDecision) ? '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, metadataProvider: effectiveMetadataProvider, ...(effectiveWorkflowKind ? { workflowKind: effectiveWorkflowKind } : {}), selectedMetadata, rawDecisionContext: requiresRawDecision ? rawDecisionContext : null } }, mediaProfile); const seriesDetachPatch = (shouldDetachSeriesContainer && !multipartContainerJobId) ? { parent_job_id: null, job_kind: resolveJobKindForMediaProfile(mediaProfile), media_type: mediaProfile } : {}; const multipartAttachPatch = multipartContainerJobId ? { parent_job_id: multipartContainerJobId, job_kind: 'multipart_movie_child', media_type: mediaProfile, is_multipart_movie: 1, disc_number: multipartCurrentDiscNumber || normalizePositiveInteger(effectiveDiscNumber), metadata_fingerprint: filmMetadataFingerprint || null } : {}; const multipartDetachPatch = (shouldDetachMultipartContainer && !multipartContainerJobId) ? { parent_job_id: null, job_kind: resolveJobKindForMediaProfile(mediaProfile), media_type: mediaProfile, is_multipart_movie: 0 } : {}; const filmMetadataPatch = isDiscFilmMetadataSelection ? { metadata_fingerprint: filmMetadataFingerprint || null, disc_number: multipartContainerJobId ? (multipartCurrentDiscNumber || normalizePositiveInteger(effectiveDiscNumber)) : (normalizePositiveInteger(effectiveDiscNumber) || null), ...(multipartContainerJobId ? { is_multipart_movie: 1 } : (shouldDetachMultipartContainer ? { is_multipart_movie: 0 } : {})) } : {}; await historyService.updateJob(jobId, { title: effectiveTitle, year: effectiveYear, imdb_id: effectiveImdbId, poster_url: posterValue, selected_from_omdb: selectedFromMetadata, omdb_json: null, migrate_tmdb: 1, status: nextStatus, last_state: nextStatus, raw_path: updatedRawPath, makemkv_info_json: JSON.stringify(updatedMakemkvInfo), ...(parentContainerJobId ? { parent_job_id: parentContainerJobId, job_kind: 'dvd_series_child', media_type: mediaProfile } : {}), ...multipartAttachPatch, ...multipartDetachPatch, ...filmMetadataPatch, ...seriesDetachPatch }); if (multipartContainerJobId) { await historyService.appendLog( jobId, 'SYSTEM', `Multipart movie aktiviert: Container #${multipartContainerJobId}, Disc ${multipartCurrentDiscNumber || '-'}` ); if (multipartExistingJobId && Number(multipartExistingJobId) !== Number(jobId)) { await historyService.appendLog( multipartExistingJobId, 'SYSTEM', `Multipart movie aktiviert: Container #${multipartContainerJobId}, Disc ${multipartExistingDiscNumber || '-'}` ); } await this.upsertMultipartMergeJobForContainer(multipartContainerJobId).catch((mergePrepError) => { logger.warn('multipart:merge:upsert-on-metadata-select-failed', { containerJobId: multipartContainerJobId, jobId, error: errorToMeta(mergePrepError) }); }); } // Bild in Cache laden (async, blockiert nicht) if (shouldQueuePosterCache && posterValue && !thumbnailService.isLocalUrl(posterValue)) { historyService.queuePosterCache(jobId, posterValue, { source: 'Poster', logFailures: true }); } const keepCurrentPipelineSession = this._hasForeignInteractiveSession(jobId); if (existingRawPath) { await historyService.appendLog( jobId, 'SYSTEM', `Vorhandenes RAW-Verzeichnis erkannt: ${existingRawPath}` ); } else if (currentJobRawPathExists) { await historyService.appendLog( jobId, 'SYSTEM', `Metadaten-Match im RAW-Root nicht gefunden (${metadataBase}). Verwende vorhandenes Job-RAW: ${resolvedCurrentJobRawPath}` ); } else if (currentJobRawPathPresent) { await historyService.appendLog( jobId, 'SYSTEM', `Metadaten-Match im RAW-Root nicht gefunden (${metadataBase}). Job-RAW ist aktuell nicht verfügbar: ${resolvedCurrentJobRawPath}` ); } else { await historyService.appendLog( jobId, 'SYSTEM', `Kein bestehendes RAW-Verzeichnis zu den Metadaten gefunden (${metadataBase}).` ); } if (rawStorage.usingSeriesRawPath) { await historyService.appendLog( jobId, 'SYSTEM', `Serien-${resolveSeriesDiscLabel(mediaProfile)} RAW-Pfad aktiv: ${rawBaseDir}` ); } if (!keepCurrentPipelineSession) { await this.setState(nextStatus, { activeJobId: jobId, progress: 0, eta: null, statusText: requiresRawDecision ? 'waiting_for_raw_decision' : (requiresManualPlaylistSelection ? 'waiting_for_manual_playlist_selection' : (shouldAutoReviewFromRaw ? '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, waitingForRawDecision: requiresRawDecision, rawDecisionOptions: requiresRawDecision ? { allowMultipartMergeWithOrphan: Boolean(rawDecisionContext?.mergeOrphanEligible), orphanDiscNumber: normalizePositiveInteger(rawDecisionContext?.multipartSuggestion?.orphanDiscNumber), currentDiscNumber: normalizePositiveInteger(rawDecisionContext?.multipartSuggestion?.currentDiscNumber) } : null, manualDecisionState: requiresRawDecision ? 'waiting_for_raw_decision' : (requiresManualPlaylistSelection ? 'waiting_for_manual_playlist_selection' : null) } }); } else { const foreignActiveJobId = this.normalizeQueueJobId(this.snapshot.activeJobId); await historyService.appendLog( jobId, 'SYSTEM', `Metadaten übernommen. Aktive Session bleibt bei interaktivem Job #${foreignActiveJobId || 'unbekannt'}.` ); } if (requiresRawDecision) { await historyService.appendLog( jobId, 'SYSTEM', `Vorhandenes RAW zur Disk gefunden: ${updatedRawPath}. Warte auf Entscheidung.` ); if (rawDecisionContext?.mergeOrphanEligible) { const orphanDiscSuggestion = normalizePositiveInteger(rawDecisionContext?.multipartSuggestion?.orphanDiscNumber); const currentDiscSuggestion = normalizePositiveInteger(rawDecisionContext?.multipartSuggestion?.currentDiscNumber); await historyService.appendLog( jobId, 'SYSTEM', `Optional verfuegbar: Multipart movie aus Orphan-RAW erstellen (Orphan Disc ${orphanDiscSuggestion || '?'} + Laufwerk Disc ${currentDiscSuggestion || '?'}).` ); } return historyService.getJobById(jobId); } 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 (shouldAutoReviewFromRaw) { await historyService.appendLog( jobId, 'SYSTEM', `Metadaten übernommen. Starte automatische Spur-Ermittlung (Mediainfo) mit vorhandenem RAW: ${updatedRawPath}` ); 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); } if (isRawImportMetadataJob) { const error = new Error('RAW-Import-Job: kein verwertbares RAW gefunden. Laufwerks-Rip ist deaktiviert.'); error.statusCode = 409; throw error; } 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 isRawImportPreparedJob = String(preloadedMakemkvInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; 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 }); } if (this.isMultipartMovieMergeHistoryJob(preloadedJob)) { return this.enqueueOrStartAction( QUEUE_ACTIONS.START_PREPARED, jobId, () => this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob }), { preloadedJob } ); } const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE'; if (isReadyToEncode) { const preloadedAnalyzeContext = preloadedMakemkvInfo?.analyzeContext && typeof preloadedMakemkvInfo.analyzeContext === 'object' ? preloadedMakemkvInfo.analyzeContext : {}; const preloadedActiveContextForJob = Number(this.snapshot?.activeJobId) === Number(jobId) ? (this.snapshot?.context || {}) : null; const preloadedSelectedMetadata = resolveSelectedMetadataForJob( preloadedJob, preloadedAnalyzeContext, preloadedActiveContextForJob ); const preloadedSelectedTitleIds = resolveSeriesBatchSelectedTitleIdsFromPlan(preloadedEncodePlan); const preloadedPlanMode = String(preloadedEncodePlan?.mode || '').trim().toLowerCase(); const preloadedIsPreRipPlan = preloadedPlanMode === 'pre_rip' || Boolean(preloadedEncodePlan?.preRip); const shouldDispatchSeriesBatchNow = ( !preloadedIsPreRipPlan && isSeriesDvdMetadataSelection(preloadedMediaProfile, preloadedSelectedMetadata, preloadedAnalyzeContext) && preloadedSelectedTitleIds.length > 1 ); if (shouldDispatchSeriesBatchNow) { return this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob }); } // 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 }), { preloadedJob } ); } 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) { if (isRawImportPreparedJob) { const error = new Error('RAW-Import-Job: kein nutzbares RAW gefunden. Laufwerks-Rip wird nicht gestartet.'); error.statusCode = 409; throw error; } // 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 isRawImportPreparedJob = String(jobMakemkvInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; 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 resetProcessLogIfLifecycleAllows(jobId, job); if (this.isMultipartMovieMergeHistoryJob(job)) { return this.startMultipartMergeJob(jobId, { ...options, preloadedJob: job }); } 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; } const seriesBatchStart = await this.startSeriesBatchFromPrepared(jobId, job, options); if (seriesBatchStart?.handled) { return seriesBatchStart.result; } 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 }; } if (isRawImportPreparedJob) { const error = new Error('RAW-Import-Job: kein nutzbares RAW gefunden. Laufwerks-Rip ist deaktiviert.'); error.statusCode = 409; throw error; } await historyService.updateJob(jobId, { status: 'RIPPING', last_state: 'RIPPING', error_message: null, end_time: null }); if (job?.parent_job_id) { await historyService.updateJob(job.parent_job_id, { 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.` ); } if (isRawImportPreparedJob) { const error = new Error('RAW-Import-Job: kein verwertbares RAW gefunden. Laufwerks-Rip ist deaktiviert.'); error.statusCode = 409; throw error; } 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 }); if (job?.parent_job_id) { await historyService.updateJob(job.parent_job_id, { status: 'RIPPING', last_state: 'RIPPING', error_message: null, end_time: 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 }), { 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_LOOKUP' || statusUpper === 'METADATA_SELECTION') { await historyService.appendLog( jobId, 'SYSTEM', 'Metadaten-Auswahl übersprungen. Starte Review ohne vorherige Metadaten-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, selectedEncodeTitleIdsCount: Array.isArray(options?.selectedEncodeTitleIds) ? options.selectedEncodeTitleIds.length : 0, selectedTrackSelectionProvided: Boolean(options?.selectedTrackSelection), episodeAssignmentsProvided: Boolean(options?.episodeAssignments && typeof options.episodeAssignments === 'object'), 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 multipartSettingsLock = encodePlan?.multipartSettingsLock && typeof encodePlan.multipartSettingsLock === 'object' ? encodePlan.multipartSettingsLock : null; const multipartSettingsLocked = Boolean( multipartSettingsLock?.enabled && this.isMultipartMovieDiscChildHistoryJob(job) ); const lockedSelectedEncodeTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId); const lockedSelectedEncodeTitleIds = normalizeReviewTitleIdList( Array.isArray(encodePlan?.selectedTitleIds) ? encodePlan.selectedTitleIds : [lockedSelectedEncodeTitleId] ); const selectedEncodeTitleId = multipartSettingsLocked ? (lockedSelectedEncodeTitleId || null) : (options?.selectedEncodeTitleId ?? null); const selectedEncodeTitleIds = multipartSettingsLocked ? lockedSelectedEncodeTitleIds : normalizeReviewTitleIdList(options?.selectedEncodeTitleIds); const effectiveSelectedEncodeTitleIds = selectedEncodeTitleIds.length > 0 ? selectedEncodeTitleIds : normalizeReviewTitleIdList(selectedEncodeTitleId ? [selectedEncodeTitleId] : []); const planWithSelectionResult = applyEncodeTitleSelectionToPlan( encodePlan, selectedEncodeTitleId, effectiveSelectedEncodeTitleIds ); let planForConfirm = planWithSelectionResult.plan; const trackSelectionTargets = normalizeReviewTitleIdList( Array.isArray(planForConfirm?.selectedTitleIds) && planForConfirm.selectedTitleIds.length > 0 ? planForConfirm.selectedTitleIds : [planForConfirm?.encodeInputTitleId] ); let selectedTrackSelectionPayload = options?.selectedTrackSelection && typeof options.selectedTrackSelection === 'object' ? options.selectedTrackSelection : null; if (multipartSettingsLocked) { const manualSelectionByTitle = planForConfirm?.manualTrackSelectionByTitle && typeof planForConfirm.manualTrackSelectionByTitle === 'object' ? planForConfirm.manualTrackSelectionByTitle : {}; const lockedTrackSelectionPayload = {}; for (const trackSelectionTitleId of trackSelectionTargets) { const lockedEntry = manualSelectionByTitle[trackSelectionTitleId] || manualSelectionByTitle[String(trackSelectionTitleId)] || null; if (lockedEntry && typeof lockedEntry === 'object') { lockedTrackSelectionPayload[trackSelectionTitleId] = { subtitleSelectionMode: normalizeSubtitleSelectionMode( lockedEntry.subtitleSelectionMode, lockedEntry.subtitleVariantSelection ? 'variants' : 'track_ids' ), audioTrackIds: Array.isArray(lockedEntry.audioTrackIds) ? lockedEntry.audioTrackIds : [], subtitleTrackIds: Array.isArray(lockedEntry.subtitleTrackIds) ? lockedEntry.subtitleTrackIds : [], subtitleVariantSelection: lockedEntry.subtitleVariantSelection || {}, subtitleLanguageOrder: Array.isArray(lockedEntry.subtitleLanguageOrder) ? lockedEntry.subtitleLanguageOrder : [] }; continue; } const fallbackSelection = extractManualSelectionPayloadFromPlan({ ...planForConfirm, encodeInputTitleId: trackSelectionTitleId }); if (fallbackSelection && typeof fallbackSelection === 'object') { lockedTrackSelectionPayload[trackSelectionTitleId] = { subtitleSelectionMode: normalizeSubtitleSelectionMode( fallbackSelection.subtitleSelectionMode, fallbackSelection.subtitleVariantSelection ? 'variants' : 'track_ids' ), audioTrackIds: Array.isArray(fallbackSelection.audioTrackIds) ? fallbackSelection.audioTrackIds : [], subtitleTrackIds: Array.isArray(fallbackSelection.subtitleTrackIds) ? fallbackSelection.subtitleTrackIds : [], subtitleVariantSelection: fallbackSelection.subtitleVariantSelection || {}, subtitleLanguageOrder: Array.isArray(fallbackSelection.subtitleLanguageOrder) ? fallbackSelection.subtitleLanguageOrder : [] } } } selectedTrackSelectionPayload = Object.keys(lockedTrackSelectionPayload).length > 0 ? lockedTrackSelectionPayload : null; } const trackSelectionResults = []; if (selectedTrackSelectionPayload && trackSelectionTargets.length > 0) { for (const trackSelectionTitleId of trackSelectionTargets) { const perTitleSelection = selectedTrackSelectionPayload?.[trackSelectionTitleId] || selectedTrackSelectionPayload?.[String(trackSelectionTitleId)] || null; if (!perTitleSelection || typeof perTitleSelection !== 'object') { continue; } const selectionResult = applyManualTrackSelectionToPlan( { ...planForConfirm, encodeInputTitleId: trackSelectionTitleId }, { [trackSelectionTitleId]: perTitleSelection } ); if ( Array.isArray(selectionResult.subtitleSelectionValidationErrors) && selectionResult.subtitleSelectionValidationErrors.length > 0 ) { const error = new Error( `Subtitle-Auswahl ungültig (Titel #${trackSelectionTitleId}): ${selectionResult.subtitleSelectionValidationErrors.join(' | ')}` ); error.statusCode = 400; throw error; } planForConfirm = { ...selectionResult.plan, encodeInputTitleId: planWithSelectionResult?.plan?.encodeInputTitleId || planForConfirm?.encodeInputTitleId || null, encodeInputPath: planWithSelectionResult?.plan?.encodeInputPath || planForConfirm?.encodeInputPath || null }; trackSelectionResults.push({ titleId: trackSelectionTitleId, audioTrackIds: selectionResult.audioTrackIds, subtitleTrackIds: selectionResult.subtitleTrackIds, subtitleForcedTrackIndexes: selectionResult.subtitleForcedTrackIndexes, subtitleSelectionValidationErrors: selectionResult.subtitleSelectionValidationErrors }); } } const primaryTrackSelectionResult = trackSelectionResults.find((entry) => Number(entry?.titleId) === Number(planForConfirm?.encodeInputTitleId) ) || trackSelectionResults[0] || { audioTrackIds: [], subtitleTrackIds: [], subtitleForcedTrackIndexes: [], subtitleSelectionValidationErrors: [] }; const normalizedEpisodeAssignments = normalizeEpisodeAssignmentsPayload( multipartSettingsLocked ? (planForConfirm?.episodeAssignments || null) : options?.episodeAssignments, planForConfirm?.selectedTitleIds || [] ); const titleById = new Map( (Array.isArray(planForConfirm?.titles) ? planForConfirm.titles : []) .map((title) => [Number(title?.id), title]) ); const remappedTitlesWithEpisodes = (Array.isArray(planForConfirm?.titles) ? planForConfirm.titles : []).map((title) => { const titleId = normalizeReviewTitleId(title?.id); const assignment = titleId ? normalizedEpisodeAssignments[String(titleId)] : null; if (!assignment) { return title; } const episodeTitle = String(assignment?.episodeTitle || '').trim() || null; const episodeNumberStart = assignment?.episodeNumberStart ?? assignment?.episodeNoStart ?? assignment?.episodeNumber ?? null; const episodeNumberEnd = assignment?.episodeNumberEnd ?? assignment?.episodeNoEnd ?? null; const normalizedEpisodeSpan = normalizePositiveInteger( assignment?.episodeSpan ?? ( normalizeEpisodeNumberValue(episodeNumberStart) !== null && normalizeEpisodeNumberValue(episodeNumberEnd) !== null && normalizeEpisodeNumberValue(episodeNumberEnd) >= normalizeEpisodeNumberValue(episodeNumberStart) ? (normalizeEpisodeNumberValue(episodeNumberEnd) - normalizeEpisodeNumberValue(episodeNumberStart) + 1) : null ) ); return { ...title, episodeId: assignment?.episodeId || assignment?.episodeIdStart || null, episodeIdStart: assignment?.episodeIdStart || assignment?.episodeId || null, episodeIdEnd: assignment?.episodeIdEnd || null, episodeNumber: assignment?.episodeNumber ?? episodeNumberStart ?? null, episodeNumberStart: episodeNumberStart ?? null, episodeNumberEnd: episodeNumberEnd ?? episodeNumberStart ?? null, episodeSpan: normalizedEpisodeSpan, episodeRange: assignment?.episodeRange || null, seasonNumber: assignment?.seasonNumber ?? title?.seasonNumber ?? null, episodeTitleStart: assignment?.episodeTitleStart || null, episodeTitleEnd: assignment?.episodeTitleEnd || null, episodeTitle, fileName: episodeTitle || title?.fileName || `Title #${titleId}` }; }); const normalizedEpisodeAssignmentsWithPath = Object.fromEntries( Object.entries(normalizedEpisodeAssignments).map(([titleIdKey, assignment]) => { const resolvedTitle = titleById.get(Number(titleIdKey)) || null; return [ titleIdKey, { ...assignment, filePath: resolvedTitle?.filePath || null } ]; }) ); planForConfirm = { ...planForConfirm, titles: remappedTitlesWithEpisodes, episodeAssignments: normalizedEpisodeAssignmentsWithPath }; const hasExplicitPostScriptSelection = !multipartSettingsLocked && options?.selectedPostEncodeScriptIds !== undefined; const selectedPostEncodeScriptIds = hasExplicitPostScriptSelection ? normalizeScriptIdList(options?.selectedPostEncodeScriptIds || []) : normalizeScriptIdList(planForConfirm?.postEncodeScriptIds || encodePlan?.postEncodeScriptIds || []); const selectedPostEncodeScripts = await scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, { strict: true }); const hasExplicitPreScriptSelection = !multipartSettingsLocked && options?.selectedPreEncodeScriptIds !== undefined; const selectedPreEncodeScriptIds = hasExplicitPreScriptSelection ? normalizeScriptIdList(options?.selectedPreEncodeScriptIds || []) : normalizeScriptIdList(planForConfirm?.preEncodeScriptIds || encodePlan?.preEncodeScriptIds || []); const selectedPreEncodeScripts = await scriptService.resolveScriptsByIds(selectedPreEncodeScriptIds, { strict: true }); const hasExplicitPostChainSelection = !multipartSettingsLocked && options?.selectedPostEncodeChainIds !== undefined; const selectedPostEncodeChainIds = hasExplicitPostChainSelection ? normalizeChainIdList(options?.selectedPostEncodeChainIds || []) : normalizeChainIdList(planForConfirm?.postEncodeChainIds || encodePlan?.postEncodeChainIds || []); const hasExplicitPreChainSelection = !multipartSettingsLocked && 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; } const readyMediaProfile = this.resolveMediaProfileForJob(job, { encodePlan: planForConfirm }); const confirmSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile); const settingsExtraArgsForProfile = String(confirmSettings?.handbrake_extra_args || '').trim(); // Resolve user preset: explicit payload wins, otherwise preserve currently selected preset from encode plan. const hasUserPresetIdField = Object.prototype.hasOwnProperty.call(options || {}, 'selectedUserPresetId'); const hasHandBrakePresetField = Object.prototype.hasOwnProperty.call(options || {}, 'selectedHandBrakePreset'); const explicitUserPresetId = hasUserPresetIdField ? normalizePositiveInteger(options?.selectedUserPresetId) : null; const rawHandBrakePreset = hasHandBrakePresetField ? String(options?.selectedHandBrakePreset || '').trim() : ''; const hasExplicitUserPresetSelection = !multipartSettingsLocked && explicitUserPresetId !== null; const hasExplicitHandBrakePresetSelection = !multipartSettingsLocked && Boolean(rawHandBrakePreset); const explicitPresetClearRequested = !multipartSettingsLocked && (hasUserPresetIdField || hasHandBrakePresetField) && explicitUserPresetId === null && !rawHandBrakePreset; let resolvedUserPreset = null; if (hasExplicitUserPresetSelection) { resolvedUserPreset = await userPresetService.getPresetById(explicitUserPresetId); if (!resolvedUserPreset) { const error = new Error(`User-Preset ${explicitUserPresetId} nicht gefunden.`); error.statusCode = 404; throw error; } } if (!resolvedUserPreset && hasExplicitHandBrakePresetSelection) { resolvedUserPreset = rawHandBrakePreset ? { name: `HandBrake: ${rawHandBrakePreset}`, handbrakePreset: rawHandBrakePreset, extraArgs: settingsExtraArgsForProfile } : null; } if (!resolvedUserPreset && explicitPresetClearRequested && settingsExtraArgsForProfile) { resolvedUserPreset = { name: 'Settings CLI-Parameter', handbrakePreset: null, extraArgs: settingsExtraArgsForProfile }; } if (!resolvedUserPreset && !explicitPresetClearRequested) { resolvedUserPreset = normalizeUserPresetForPlan( planForConfirm?.userPreset || 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 confirmedConverterMediaType = String( confirmedPlan?.converterMediaType || encodePlan?.converterMediaType || '' ).trim().toLowerCase(); const requiresExplicitPreset = readyMediaProfile === 'dvd' || readyMediaProfile === 'bluray' || (readyMediaProfile === 'converter' && confirmedConverterMediaType !== 'audio'); const planPresetName = explicitPresetClearRequested ? '' : String( planForConfirm?.userPreset?.handbrakePreset || encodePlan?.userPreset?.handbrakePreset || planForConfirm?.selectors?.preset || encodePlan?.selectors?.preset || '' ).trim(); const planExtraArgs = explicitPresetClearRequested ? '' : String( planForConfirm?.userPreset?.extraArgs || encodePlan?.userPreset?.extraArgs || planForConfirm?.selectors?.extraArgs || encodePlan?.selectors?.extraArgs || '' ).trim(); const effectivePresetName = explicitPresetClearRequested ? '' : String( resolvedUserPreset?.handbrakePreset || rawHandBrakePreset || planPresetName || confirmSettings?.handbrake_preset || '' ).trim(); const effectiveExtraArgs = explicitPresetClearRequested ? String( resolvedUserPreset?.extraArgs || settingsExtraArgsForProfile || '' ).trim() : String( resolvedUserPreset?.extraArgs || planExtraArgs || settingsExtraArgsForProfile || '' ).trim(); if (requiresExplicitPreset && !effectivePresetName && !effectiveExtraArgs) { const error = new Error('Bestätigung nicht möglich: Kein Preset oder Extra-Args gesetzt. Bitte User-/HandBrake-Preset wählen oder passende Settings hinterlegen.'); error.statusCode = 400; throw error; } const actionDisplaySettings = { ...(confirmSettings && typeof confirmSettings === 'object' ? confirmSettings : {}), handbrake_preset: effectivePresetName || null, handbrake_extra_args: effectiveExtraArgs }; confirmedPlan.titles = refreshAudioTrackActionsForPlanTitles( confirmedPlan.titles, actionDisplaySettings, null ); 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 ? ` Primär-Titel #${confirmedPlan.encodeInputTitleId}.` : ''}` + ` Gewählte Titel: ${Array.isArray(confirmedPlan?.selectedTitleIds) && confirmedPlan.selectedTitleIds.length > 0 ? confirmedPlan.selectedTitleIds.map((id) => `#${id}`).join(', ') : 'none'}.` + ` Audio-Spuren (Primär): ${primaryTrackSelectionResult.audioTrackIds.length > 0 ? primaryTrackSelectionResult.audioTrackIds.join(',') : 'none'}.` + ` Subtitle-Spuren (Primär): ${primaryTrackSelectionResult.subtitleTrackIds.length > 0 ? primaryTrackSelectionResult.subtitleTrackIds.join(',') : 'none'}.` + ` Subtitle-Forced-Indizes (Primär): ${Array.isArray(primaryTrackSelectionResult.subtitleForcedTrackIndexes) && primaryTrackSelectionResult.subtitleForcedTrackIndexes.length > 0 ? primaryTrackSelectionResult.subtitleForcedTrackIndexes.join(',') : 'none'}.` + ` Episoden-Zuordnung: ${Object.keys(confirmedPlan?.episodeAssignments || {}).length > 0 ? Object.keys(confirmedPlan.episodeAssignments).length : 0}.` + ` 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'}.` + (multipartSettingsLocked ? ` Multipart-Lock aktiv${multipartSettingsLock?.sourceJobId ? ` (Quelle Job #${multipartSettingsLock.sourceJobId}` : ''}${multipartSettingsLock?.sourceDiscNumber ? `${multipartSettingsLock?.sourceJobId ? ', ' : ' ('}Disc ${multipartSettingsLock.sourceDiscNumber}` : ''}${multipartSettingsLock?.sourceJobId || multipartSettingsLock?.sourceDiscNumber ? ')' : ''}.` : '') + (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', 'METADATA_LOOKUP', '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); 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 resetProcessLogIfLifecycleAllows(sourceJobId, sourceJob); 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 resetProcessLogIfLifecycleAllows(sourceJobId, sourceJob); 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 resetProcessLogIfLifecycleAllows(sourceJobId, sourceJob); // 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', 'METADATA_LOOKUP', '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 previousSelectedMetadata = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object' ? mkInfo.selectedMetadata : {}; const resolvedMbId = String( previousSelectedMetadata?.mbId || previousSelectedMetadata?.musicBrainzId || previousSelectedMetadata?.musicbrainzId || previousSelectedMetadata?.musicbrainz_id || previousSelectedMetadata?.music_brainz_id || previousSelectedMetadata?.musicbrainz || previousSelectedMetadata?.mbid || '' ).trim() || null; const selectedMetadata = { title: normalizeCdTrackText(previousSelectedMetadata?.title) || normalizeCdTrackText(sourceJob?.title) || normalizeCdTrackText(sourceJob?.detected_title) || 'Audio CD', artist: normalizeCdTrackText(previousSelectedMetadata?.artist) || null, year: Number.isFinite(Number(previousSelectedMetadata?.year)) ? Math.trunc(Number(previousSelectedMetadata.year)) : (Number.isFinite(Number(sourceJob?.year)) ? Math.trunc(Number(sourceJob.year)) : null), mbId: resolvedMbId, coverUrl: normalizeCdTrackText( previousSelectedMetadata?.coverUrl || previousSelectedMetadata?.poster || previousSelectedMetadata?.posterUrl || sourceJob?.poster_url ) || null }; 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: normalizeCdTrackText(track?.title) || `Track ${normalizedPosition}`, artist: normalizeCdTrackText(track?.artist) || selectedMetadata.artist || null, selected: track?.selected !== false }; }); 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_READY_TO_RIP', last_state: 'CD_READY_TO_RIP', 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_READY_TO_RIP', jobId, device: null, progress: 0, eta: null, statusText: 'CD bereit zum Encodieren', 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, Metadaten aus letztem Lauf übernommen). ${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; } options = await this.resolveMultipartReviewLockOptions(job, options); 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 || {}; let effectiveAnalyzeContext = 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 selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {}); let effectiveIsSeriesDvdReview = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); let reviewSettings = isConverterReview ? { ...settings, makemkv_min_length_minutes: 0, handbrake_preset: '', handbrake_extra_args: '' } : (effectiveIsSeriesDvdReview ? { ...settings, makemkv_min_length_minutes: 0 } : 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}` }; } if (effectiveIsSeriesDvdReview) { await historyService.appendLog( jobId, 'SYSTEM', `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt: Mindestlängen-Filter für Titelprüfung ist deaktiviert.` ); } 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 let dvdAllTitles = null; // full DVD title list from initial scan 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) }); appendLinesInPlace(dvdScanLines, pluginScan.scanLines); dvdScanRunInfo = pluginScan.runInfo || null; reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, pluginScan.pluginExecution ); dvdHandBrakeScanJson = parseMediainfoJsonOutput(dvdScanLines.join('\n')); if (dvdHandBrakeScanJson) { const allDvdTitles = parseHandBrakeTitleList(dvdHandBrakeScanJson); dvdAllTitles = allDvdTitles; const seriesScanLines = Array.isArray(pluginScan?.scanAnalysisLines) && pluginScan.scanAnalysisLines.length > 0 ? pluginScan.scanAnalysisLines : dvdScanLines; const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, seriesScanLines); effectiveAnalyzeContext = seriesScanContextResult.analyzeContext; const wasSeriesDvdReview = effectiveIsSeriesDvdReview; effectiveIsSeriesDvdReview = isSeriesDvdMetadataSelection( mediaProfile, selectedMetadata, effectiveAnalyzeContext ); if (effectiveIsSeriesDvdReview && !wasSeriesDvdReview) { reviewSettings = { ...reviewSettings, makemkv_min_length_minutes: 0 }; await historyService.appendLog( jobId, 'SYSTEM', `Serien-${resolveSeriesDiscLabel(mediaProfile)} nachträglich im HandBrake-Scan erkannt: Mindestlängen-Filter für Titelprüfung wurde deaktiviert.` ); } if (seriesScanContextResult.derived) { await historyService.appendLog( jobId, 'SYSTEM', 'Serien-Titelklassifizierung aus HandBrake-Scan aktualisiert (PlayAll/Kurz/Duplikate markiert).' ); } const minLengthMinutes = Number(reviewSettings?.makemkv_min_length_minutes || 0); const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60)); const filteredDvdCandidatesRaw = minLengthSeconds > 0 ? allDvdTitles.filter((t) => t.durationSeconds >= minLengthSeconds) : allDvdTitles; const filteredSeriesResult = filterSeriesDvdHandBrakeCandidates(filteredDvdCandidatesRaw, effectiveAnalyzeContext); const filteredDvdCandidates = filteredSeriesResult.candidates; const minLengthLabel = minLengthSeconds > 0 ? `≥ ${minLengthMinutes} min` : 'ohne Mindestlänge'; await historyService.appendLog( jobId, 'SYSTEM', `HandBrake DVD-Scan: ${allDvdTitles.length} Titel gesamt, ${filteredDvdCandidates.length} ${minLengthLabel}.` ); if (filteredSeriesResult.applied) { await historyService.appendLog( jobId, 'SYSTEM', `Serien-Filter auf DVD-Titel angewendet: ${filteredSeriesResult.sourceCount} -> ${filteredSeriesResult.resultCount} (${filteredSeriesResult.reason || 'heuristik'}).` ); } // 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 shouldApplySeriesBackupShortTitleFilter = Boolean( effectiveIsSeriesDvdReview && hasBluRayBackupStructure(rawPath) && (!Array.isArray(playlistAnalysis?.titles) || playlistAnalysis.titles.length === 0) ); if (shouldApplySeriesBackupShortTitleFilter) { const shortTitleFilterResult = filterSeriesBackupReviewTitles(enrichedReview); if (shortTitleFilterResult.applied) { enrichedReview = shortTitleFilterResult.plan; await historyService.appendLog( jobId, 'SYSTEM', `Serien-Kurztitel-Filter aktiv (Backup-Fallback): ${shortTitleFilterResult.sourceCount} -> ${shortTitleFilterResult.resultCount}` + ` Titel (Schwelle ${formatDurationClock(shortTitleFilterResult.shortThresholdSeconds)}).` ); } } const shouldEnrichSeriesBackupLanguages = Boolean( effectiveIsSeriesDvdReview && hasBluRayBackupStructure(rawPath) && Array.isArray(enrichedReview?.titles) && enrichedReview.titles.length > 0 ); if (shouldEnrichSeriesBackupLanguages) { const hasUnknownTrackLanguages = (Array.isArray(enrichedReview?.titles) ? enrichedReview.titles : []) .some((title) => { const audioUnknown = (Array.isArray(title?.audioTracks) ? title.audioTracks : []) .some((track) => isUnknownTrackLanguage(track?.language || track?.languageLabel)); const subtitleUnknown = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []) .some((track) => isUnknownTrackLanguage(track?.language || track?.languageLabel)); return audioUnknown || subtitleUnknown; }); if (hasUnknownTrackLanguages) { try { let languageCandidates = buildSeriesBackupLanguageEnrichmentCandidatesFromPlaylistCache( effectiveAnalyzeContext?.handBrakePlaylistScan || null ); if (languageCandidates.length <= 0) { const handBrakeScan = await this.runPluginReviewScan(reviewPlugin, job, { jobId, rawPath, reviewMode: 'rip', source: 'HANDBRAKE_SCAN_SERIES_LANG_MAP', silent: !this.isPrimaryJob(jobId) }); reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, handBrakeScan.pluginExecution ); const handBrakeScanRunInfo = handBrakeScan.runInfo || null; mediaInfoRuns.push({ filePath: rawPath, runInfo: handBrakeScanRunInfo, fallbackRunInfo: null }); const handBrakeScanJson = parseMediainfoJsonOutput((handBrakeScan.scanLines || []).join('\n')); if (handBrakeScanJson) { languageCandidates = buildSeriesBackupLanguageEnrichmentCandidatesFromHandBrakeScan(handBrakeScanJson); } } const languageEnrichment = enrichSeriesBackupReviewLanguagesFromCandidates( enrichedReview, languageCandidates ); if (languageEnrichment.applied) { enrichedReview = languageEnrichment.plan; await historyService.appendLog( jobId, 'SYSTEM', `Serien-${resolveSeriesDiscLabel(mediaProfile)} Sprachabgleich aus HandBrake-Scan: ` + `${languageEnrichment.matchedTitles} Titel gemappt, ` + `Audio ${languageEnrichment.enrichedAudioTracks}, ` + `Untertitel ${languageEnrichment.enrichedSubtitleTracks} aktualisiert.` ); } } catch (error) { logger.warn('mediainfo:review:series-backup-language-enrichment-failed', { jobId, rawPath, error: errorToMeta(error) }); await historyService.appendLog( jobId, 'SYSTEM', `Serien-${resolveSeriesDiscLabel(mediaProfile)} Sprachabgleich aus HandBrake-Scan fehlgeschlagen: ${error?.message || 'Unbekannter Fehler'}` ); } } } const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan( enrichedReview, options?.previousEncodePlan && typeof options.previousEncodePlan === 'object' ? options.previousEncodePlan : null ); const multipartLockResult = applyMultipartSettingsLockToReviewPlan( reviewPrefillResult.plan, options?.multipartSettingsLock || null ); enrichedReview = multipartLockResult.plan; const reviewDefaultUserPresetResult = await applyConfiguredDefaultUserPresetToReviewPlan(enrichedReview, { mediaProfile, isSeriesSelection: effectiveIsSeriesDvdReview }); enrichedReview = reviewDefaultUserPresetResult.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); const preSelectedHandBrakeTitleIds = normalizeReviewTitleIdList( options?.selectedHandBrakeTitleIds || analyzeContext?.selectedHandBrakeTitleIds ); const effectivePreSelectedHandBrakeTitleIds = preSelectedHandBrakeTitleIds.length > 0 ? preSelectedHandBrakeTitleIds : (preSelectedHandBrakeTitleId ? [preSelectedHandBrakeTitleId] : []); if (effectivePreSelectedHandBrakeTitleIds.length > 0) { const selectedReviewTitles = buildSelectedHandBrakeReviewTitles( dvdHandBrakeScanJson, effectivePreSelectedHandBrakeTitleIds, { encodeInputPath: dvdScanInputPath } ); const resolvedSelectedTitleIds = selectedReviewTitles .map((title) => normalizeReviewTitleId(title?.id)) .filter(Boolean); const resolvedPrimaryTitleId = normalizeReviewTitleId(preSelectedHandBrakeTitleId) || resolvedSelectedTitleIds[0] || null; if (selectedReviewTitles.length > 0 && resolvedPrimaryTitleId) { const selectedSet = new Set(resolvedSelectedTitleIds.map((id) => Number(id))); const normalizedTitles = selectedReviewTitles.map((title) => { const titleId = normalizeReviewTitleId(title?.id); const selectedForEncode = selectedSet.has(Number(titleId)); return { ...title, selectedForEncode, encodeInput: titleId === resolvedPrimaryTitleId }; }); enrichedReview = { ...enrichedReview, titles: normalizedTitles, selectedTitleIds: resolvedSelectedTitleIds, encodeInputTitleId: resolvedPrimaryTitleId, handBrakeTitleId: resolvedPrimaryTitleId, handBrakeTitleIds: resolvedSelectedTitleIds, encodeInputPath: dvdScanInputPath, titleSelectionRequired: false }; } else { enrichedReview = { ...enrichedReview, handBrakeTitleId: resolvedPrimaryTitleId, handBrakeTitleIds: effectivePreSelectedHandBrakeTitleIds, encodeInputPath: dvdScanInputPath }; } await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Titel-Auswahl (manuell) übernommen: -t ${resolvedPrimaryTitleId || '-'}${effectivePreSelectedHandBrakeTitleIds.length > 1 ? ` (Mehrfachauswahl: ${effectivePreSelectedHandBrakeTitleIds.map((id) => `-t ${id}`).join(', ')})` : ''}` ); } 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, handBrakeTitleIds: [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) { if (effectiveIsSeriesDvdReview) { const seriesTitleIds = normalizeReviewTitleIdList( dvdScannedCandidates.map((item) => item?.handBrakeTitleId) ); const seriesPlayAllDecision = resolveSeriesPlayAllDoubleEpisodeDecision( dvdScannedCandidates, Array.isArray(dvdAllTitles) && dvdAllTitles.length > 0 ? dvdAllTitles : dvdScannedCandidates ); if (seriesPlayAllDecision.required) { const manualTitleSourceRows = Array.isArray(dvdAllTitles) && dvdAllTitles.length > 0 ? dvdAllTitles : dvdScannedCandidates; const manualCandidateIds = normalizeReviewTitleIdList([ ...(Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds) ? seriesPlayAllDecision.defaultSelectedTitleIds : []), seriesPlayAllDecision.longestCandidateTitleId ]); const candidatesData = manualCandidateIds .map((titleId) => { const matched = manualTitleSourceRows.find((item) => normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id) === titleId ) || null; const durationSeconds = Number(matched?.durationSeconds || 0); return { handBrakeTitleId: titleId, durationSeconds, durationMinutes: Number((durationSeconds / 60).toFixed(1)), audioTrackCount: Number(matched?.audioTrackCount || 0), subtitleTrackCount: Number(matched?.subtitleTrackCount || 0), sizeBytes: Number(matched?.sizeBytes || 0) }; }) .filter((row) => Number.isFinite(Number(row?.handBrakeTitleId)) && Number(row.handBrakeTitleId) > 0); const allTitlesData = manualTitleSourceRows .map((item) => { const titleId = normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id); if (!titleId) { return null; } const durationSeconds = Number(item?.durationSeconds || 0); return { handBrakeTitleId: titleId, durationSeconds, durationMinutes: Number((durationSeconds / 60).toFixed(1)), audioTrackCount: Number(item?.audioTrackCount || 0), subtitleTrackCount: Number(item?.subtitleTrackCount || 0), sizeBytes: Number(item?.sizeBytes || 0) }; }) .filter((row) => Number.isFinite(Number(row?.handBrakeTitleId)) && Number(row.handBrakeTitleId) > 0) .sort((a, b) => a.handBrakeTitleId - b.handBrakeTitleId); const decisionSelectableTitleIds = normalizeReviewTitleIdList([ seriesPlayAllDecision.longestCandidateTitleId ]); const preselectedTitleIds = normalizeReviewTitleIdList( (Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds) ? seriesPlayAllDecision.defaultSelectedTitleIds : [] ).filter((titleId) => candidatesData.some((row) => Number(row.handBrakeTitleId) === Number(titleId))) ); const effectivePreselectedTitleIds = preselectedTitleIds.length > 0 ? preselectedTitleIds : (candidatesData[0]?.handBrakeTitleId ? [candidatesData[0].handBrakeTitleId] : []); enrichedReview = { ...enrichedReview, handBrakeTitleDecisionRequired: true, handBrakeTitleCandidates: candidatesData, handBrakeAllTitles: allTitlesData, handBrakeDvdInputPath: dvdScanInputPath, handBrakeDecisionMode: 'series_playall_or_double', handBrakeDecisionSummary: { reason: seriesPlayAllDecision.reason, longestCandidateTitleId: seriesPlayAllDecision.longestCandidateTitleId, selectedDurationSumSeconds: seriesPlayAllDecision.selectedDurationSumSeconds, longestCandidateDurationSeconds: seriesPlayAllDecision.longestCandidateDurationSeconds, durationDeltaSeconds: seriesPlayAllDecision.durationDeltaSeconds, toleranceSeconds: seriesPlayAllDecision.toleranceSeconds, longestVsEpisodeRatio: seriesPlayAllDecision.longestVsEpisodeRatio }, handBrakeDecisionSelectableTitleIds: decisionSelectableTitleIds, selectedHandBrakeTitleIds: effectivePreselectedTitleIds, selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null, 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, makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), analyzeContext: effectiveAnalyzeContext }, mediaProfile)), 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', `Serien-Grenzfall erkannt: Längster Titel -t ${seriesPlayAllDecision.longestCandidateTitleId || '-'} ` + `(${formatDurationClock(seriesPlayAllDecision.longestCandidateDurationSeconds) || '-'}) liegt innerhalb der ` + `PlayAll-Toleranz (Summe Episoden=${formatDurationClock(seriesPlayAllDecision.selectedDurationSumSeconds) || '-'}, ` + `Delta=${seriesPlayAllDecision.durationDeltaSeconds || 0}s, Toleranz=${seriesPlayAllDecision.toleranceSeconds || 0}s). ` + 'Bitte manuell entscheiden: PlayAll oder Doppelfolge.' ); const dvdWaitingStatusText = 'Serien-Grenzfall: PlayAll oder Doppelfolge?'; const dvdWaitingContextPatch = { jobId, rawPath, handBrakeTitleDecisionRequired: true, handBrakeTitleCandidates: candidatesData, handBrakeAllTitles: allTitlesData, handBrakeDvdInputPath: dvdScanInputPath, handBrakeDecisionMode: 'series_playall_or_double', handBrakeDecisionSummary: enrichedReview.handBrakeDecisionSummary, handBrakeDecisionSelectableTitleIds: decisionSelectableTitleIds, selectedHandBrakeTitleIds: effectivePreselectedTitleIds, selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null, 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 - Serien-Grenzfall', message: `Job #${jobId}: Bitte entscheiden, ob Titel ${seriesPlayAllDecision.longestCandidateTitleId || '-'} PlayAll oder Doppelfolge ist` }); return enrichedReview; } const selectedReviewTitles = buildSelectedHandBrakeReviewTitles( dvdHandBrakeScanJson, seriesTitleIds, { encodeInputPath: dvdScanInputPath } ); const resolvedSelectedTitleIds = normalizeReviewTitleIdList( selectedReviewTitles.map((title) => normalizeReviewTitleId(title?.id)) ); const resolvedPrimaryTitleId = resolvedSelectedTitleIds[0] || null; if (selectedReviewTitles.length > 0 && resolvedPrimaryTitleId) { const selectedSet = new Set(resolvedSelectedTitleIds.map((id) => Number(id))); const normalizedTitles = selectedReviewTitles.map((title) => { const titleId = normalizeReviewTitleId(title?.id); const selectedForEncode = selectedSet.has(Number(titleId)); return { ...title, selectedForEncode, encodeInput: titleId === resolvedPrimaryTitleId }; }); enrichedReview = { ...enrichedReview, titles: normalizedTitles, selectedTitleIds: resolvedSelectedTitleIds, encodeInputTitleId: resolvedPrimaryTitleId, handBrakeTitleId: resolvedPrimaryTitleId, handBrakeTitleIds: resolvedSelectedTitleIds, encodeInputPath: dvdScanInputPath, titleSelectionRequired: false, handBrakeTitleDecisionRequired: false, handBrakeTitleCandidates: [] }; await historyService.appendLog( jobId, 'SYSTEM', `Serien-${resolveSeriesDiscLabel(mediaProfile)}: ${resolvedSelectedTitleIds.length} Episoden-Titel aus initialem HandBrake-Scan übernommen (ohne erneuten Titelscan).` ); } } else { // 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, makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), analyzeContext: effectiveAnalyzeContext }, mediaProfile)), 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) }); appendLinesInPlace(dvdTitleScanLines, pluginScan.scanLines); dvdTitleScanRunInfo = pluginScan.runInfo || null; reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, pluginScan.pluginExecution ); dvdTitleScanJson = parseMediainfoJsonOutput(dvdTitleScanLines.join('\n')); if (dvdTitleScanJson) { const allTitles = parseHandBrakeTitleList(dvdTitleScanJson); const seriesScanLines = Array.isArray(pluginScan?.scanAnalysisLines) && pluginScan.scanAnalysisLines.length > 0 ? pluginScan.scanAnalysisLines : dvdTitleScanLines; const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, seriesScanLines); effectiveAnalyzeContext = seriesScanContextResult.analyzeContext; const wasSeriesDvdReview = effectiveIsSeriesDvdReview; effectiveIsSeriesDvdReview = isSeriesDvdMetadataSelection( mediaProfile, selectedMetadata, effectiveAnalyzeContext ); if (effectiveIsSeriesDvdReview && !wasSeriesDvdReview) { reviewSettings = { ...reviewSettings, makemkv_min_length_minutes: 0 }; await historyService.appendLog( jobId, 'SYSTEM', `Serien-${resolveSeriesDiscLabel(mediaProfile)} nachträglich im HandBrake-Titelscan erkannt: Mindestlängen-Filter für Titelprüfung wurde deaktiviert.` ); } if (seriesScanContextResult.derived) { await historyService.appendLog( jobId, 'SYSTEM', 'Serien-Titelklassifizierung aus HandBrake-Titelscan aktualisiert (PlayAll/Kurz/Duplikate markiert).' ); } const minLengthMinutes = Number(reviewSettings?.makemkv_min_length_minutes || 0); const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60)); const titleCandidatesRaw = allTitles.filter((t) => t.durationSeconds >= minLengthSeconds); const filteredSeriesResult = filterSeriesDvdHandBrakeCandidates(titleCandidatesRaw, effectiveAnalyzeContext); const titleCandidates = filteredSeriesResult.candidates; const minLengthLabel = minLengthSeconds > 0 ? `≥ ${minLengthMinutes} min` : 'ohne Mindestlänge'; await historyService.appendLog( jobId, 'SYSTEM', `HandBrake DVD Titel-Scan: ${allTitles.length} gesamt, ${titleCandidates.length} ${minLengthLabel}.` ); if (filteredSeriesResult.applied) { await historyService.appendLog( jobId, 'SYSTEM', `Serien-Filter auf DVD-Titel angewendet: ${filteredSeriesResult.sourceCount} -> ${filteredSeriesResult.resultCount} (${filteredSeriesResult.reason || 'heuristik'}).` ); } if (titleCandidates.length === 1) { enrichedReview = { ...enrichedReview, handBrakeTitleId: titleCandidates[0].handBrakeTitleId, handBrakeTitleIds: [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) { if (effectiveIsSeriesDvdReview) { const seriesTitleIds = normalizeReviewTitleIdList( titleCandidates.map((item) => item?.handBrakeTitleId) ); const seriesPlayAllDecision = resolveSeriesPlayAllDoubleEpisodeDecision( titleCandidates, allTitles ); if (seriesPlayAllDecision.required) { const manualCandidateIds = normalizeReviewTitleIdList([ ...(Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds) ? seriesPlayAllDecision.defaultSelectedTitleIds : []), seriesPlayAllDecision.longestCandidateTitleId ]); const candidatesData = manualCandidateIds .map((titleId) => { const matched = allTitles.find((item) => normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id) === titleId ) || null; const durationSeconds = Number(matched?.durationSeconds || 0); return { handBrakeTitleId: titleId, durationSeconds, durationMinutes: Number((durationSeconds / 60).toFixed(1)), audioTrackCount: Number(matched?.audioTrackCount || 0), subtitleTrackCount: Number(matched?.subtitleTrackCount || 0), sizeBytes: Number(matched?.sizeBytes || 0) }; }) .filter((row) => Number.isFinite(Number(row?.handBrakeTitleId)) && Number(row.handBrakeTitleId) > 0); const allTitlesData = allTitles .map((item) => { const titleId = normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id); if (!titleId) { return null; } const durationSeconds = Number(item?.durationSeconds || 0); return { handBrakeTitleId: titleId, durationSeconds, durationMinutes: Number((durationSeconds / 60).toFixed(1)), audioTrackCount: Number(item?.audioTrackCount || 0), subtitleTrackCount: Number(item?.subtitleTrackCount || 0), sizeBytes: Number(item?.sizeBytes || 0) }; }) .filter((row) => Number.isFinite(Number(row?.handBrakeTitleId)) && Number(row.handBrakeTitleId) > 0) .sort((a, b) => a.handBrakeTitleId - b.handBrakeTitleId); const decisionSelectableTitleIds = normalizeReviewTitleIdList([ seriesPlayAllDecision.longestCandidateTitleId ]); const preselectedTitleIds = normalizeReviewTitleIdList( (Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds) ? seriesPlayAllDecision.defaultSelectedTitleIds : [] ).filter((titleId) => candidatesData.some((row) => Number(row.handBrakeTitleId) === Number(titleId))) ); const effectivePreselectedTitleIds = preselectedTitleIds.length > 0 ? preselectedTitleIds : (candidatesData[0]?.handBrakeTitleId ? [candidatesData[0].handBrakeTitleId] : []); enrichedReview = { ...enrichedReview, handBrakeTitleDecisionRequired: true, handBrakeTitleCandidates: candidatesData, handBrakeAllTitles: allTitlesData, handBrakeDvdInputPath: dvdScanInputPath, handBrakeDecisionMode: 'series_playall_or_double', handBrakeDecisionSummary: { reason: seriesPlayAllDecision.reason, longestCandidateTitleId: seriesPlayAllDecision.longestCandidateTitleId, selectedDurationSumSeconds: seriesPlayAllDecision.selectedDurationSumSeconds, longestCandidateDurationSeconds: seriesPlayAllDecision.longestCandidateDurationSeconds, durationDeltaSeconds: seriesPlayAllDecision.durationDeltaSeconds, toleranceSeconds: seriesPlayAllDecision.toleranceSeconds, longestVsEpisodeRatio: seriesPlayAllDecision.longestVsEpisodeRatio }, handBrakeDecisionSelectableTitleIds: decisionSelectableTitleIds, selectedHandBrakeTitleIds: effectivePreselectedTitleIds, selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null, 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, makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), analyzeContext: effectiveAnalyzeContext }, mediaProfile)), 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', `Serien-Grenzfall erkannt: Längster Titel -t ${seriesPlayAllDecision.longestCandidateTitleId || '-'} ` + `(${formatDurationClock(seriesPlayAllDecision.longestCandidateDurationSeconds) || '-'}) liegt innerhalb der ` + `PlayAll-Toleranz (Summe Episoden=${formatDurationClock(seriesPlayAllDecision.selectedDurationSumSeconds) || '-'}, ` + `Delta=${seriesPlayAllDecision.durationDeltaSeconds || 0}s, Toleranz=${seriesPlayAllDecision.toleranceSeconds || 0}s). ` + 'Bitte manuell entscheiden: PlayAll oder Doppelfolge.' ); const dvdWaitingStatusText = 'Serien-Grenzfall: PlayAll oder Doppelfolge?'; const dvdWaitingContextPatch = { jobId, rawPath, handBrakeTitleDecisionRequired: true, handBrakeTitleCandidates: candidatesData, handBrakeAllTitles: allTitlesData, handBrakeDvdInputPath: dvdScanInputPath, handBrakeDecisionMode: 'series_playall_or_double', handBrakeDecisionSummary: enrichedReview.handBrakeDecisionSummary, handBrakeDecisionSelectableTitleIds: decisionSelectableTitleIds, selectedHandBrakeTitleIds: effectivePreselectedTitleIds, selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null, 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 - Serien-Grenzfall', message: `Job #${jobId}: Bitte entscheiden, ob Titel ${seriesPlayAllDecision.longestCandidateTitleId || '-'} PlayAll oder Doppelfolge ist` }); return enrichedReview; } const selectedReviewTitles = buildSelectedHandBrakeReviewTitles( dvdTitleScanJson, seriesTitleIds, { encodeInputPath: dvdScanInputPath } ); const resolvedSelectedTitleIds = normalizeReviewTitleIdList( selectedReviewTitles.map((title) => normalizeReviewTitleId(title?.id)) ); const resolvedPrimaryTitleId = resolvedSelectedTitleIds[0] || null; if (selectedReviewTitles.length > 0 && resolvedPrimaryTitleId) { const selectedSet = new Set(resolvedSelectedTitleIds.map((id) => Number(id))); const normalizedTitles = selectedReviewTitles.map((title) => { const titleId = normalizeReviewTitleId(title?.id); const selectedForEncode = selectedSet.has(Number(titleId)); return { ...title, selectedForEncode, encodeInput: titleId === resolvedPrimaryTitleId }; }); enrichedReview = { ...enrichedReview, titles: normalizedTitles, selectedTitleIds: resolvedSelectedTitleIds, encodeInputTitleId: resolvedPrimaryTitleId, handBrakeTitleId: resolvedPrimaryTitleId, handBrakeTitleIds: resolvedSelectedTitleIds, encodeInputPath: dvdScanInputPath, titleSelectionRequired: false, handBrakeTitleDecisionRequired: false, handBrakeTitleCandidates: [] }; await historyService.appendLog( jobId, 'SYSTEM', `Serien-${resolveSeriesDiscLabel(mediaProfile)}: ${resolvedSelectedTitleIds.length} Episoden-Titel aus HandBrake-Scan übernommen (kein separater Titelauswahl-Schritt).` ); } } else { 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, makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), analyzeContext: effectiveAnalyzeContext }, mediaProfile)), 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', minLengthSeconds > 0 ? `HandBrake DVD Titel-Scan: Kein Titel ≥ ${minLengthMinutes} min. Ohne Titel-ID fortgefahren.` : 'HandBrake DVD Titel-Scan: Kein verwertbarer Titel gefunden. 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, makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({ ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), analyzeContext: effectiveAnalyzeContext }, mediaProfile)), 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'}.` ); } if (multipartLockResult.applied) { await historyService.appendLog( jobId, 'SYSTEM', `Multipart-Lock aktiv: Encode-Einstellungen werden von Job #${multipartLockResult.sourceJobId}` + `${multipartLockResult.sourceDiscNumber ? ` (Disc ${multipartLockResult.sourceDiscNumber})` : ''} übernommen und gesperrt.` ); } 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 }; } buildPostEncodeScriptsSummaryPlaceholder(encodePlan) { const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []); const chainIds = normalizeChainIdList(encodePlan?.postEncodeChainIds || []); return { configured: scriptIds.length + chainIds.length, attempted: 0, succeeded: 0, failed: 0, skipped: 0, aborted: false, abortReason: null, failedScriptId: null, failedScriptName: null, pending: scriptIds.length + chainIds.length > 0, results: [] }; } async persistPostEncodeScriptsSummary(jobId, summary) { try { const job = await historyService.getJobById(jobId); if (!job) { return; } const handbrakeInfo = this.safeParseJson(job.handbrake_info_json); const nextInfo = handbrakeInfo && typeof handbrakeInfo === 'object' ? handbrakeInfo : {}; await historyService.updateJob(jobId, { handbrake_info_json: JSON.stringify({ ...nextInfo, postEncodeScripts: { ...(summary && typeof summary === 'object' ? summary : {}), pending: false } }) }); } catch (error) { logger.warn('encode:post-script:persist-summary-failed', { jobId, error: errorToMeta(error) }); } } runPostEncodeScriptsDetached(jobId, encodePlan, context = {}, options = {}) { const placeholderSummary = this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan); if (placeholderSummary.configured <= 0) { return { started: false, summary: placeholderSummary }; } const phaseLabel = String(options?.phaseLabel || 'Post-Encode').trim() || 'Post-Encode'; void historyService.appendLog( jobId, 'SYSTEM', `${phaseLabel} Skripte/Ketten werden unabhängig im Hintergrund ausgeführt.` ); void (async () => { let finalSummary = { ...placeholderSummary, pending: false }; try { finalSummary = { ...(await this.runPostEncodeScripts(jobId, encodePlan, context, null)), pending: false }; } catch (error) { logger.warn('encode:post-script:detached-failed', { jobId, error: errorToMeta(error) }); finalSummary = { ...placeholderSummary, attempted: placeholderSummary.configured, failed: placeholderSummary.configured, aborted: true, abortReason: error?.message || 'unknown', pending: false }; } await historyService.appendLog( jobId, 'SYSTEM', `${phaseLabel} Skripte/Ketten abgeschlossen: ${finalSummary.succeeded} erfolgreich, ` + `${finalSummary.failed} fehlgeschlagen, ${finalSummary.skipped} übersprungen.` ); await this.persistPostEncodeScriptsSummary(jobId, finalSummary); })(); return { started: true, summary: placeholderSummary }; } async startEncodingFromPrepared(jobId, options = {}) { 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 encodePlanOverride = options?.encodePlanOverride && typeof options.encodePlanOverride === 'object' ? options.encodePlanOverride : null; const encodePlan = encodePlanOverride || this.safeParseJson(job.encode_plan_json); const seriesBatchEpisodeContext = options?.seriesBatchEpisodeContext && typeof options.seriesBatchEpisodeContext === 'object' ? options.seriesBatchEpisodeContext : null; const isSeriesBatchEpisodeRun = Boolean(seriesBatchEpisodeContext); const seriesBatchParentJobId = this.resolveSeriesBatchParentJobId({ ...job, encodePlan }); if (seriesBatchParentJobId && !isSeriesBatchEpisodeRun) { this.seriesBatchParentByChild.set(Number(jobId), Number(seriesBatchParentJobId)); } 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 = isSeriesBatchEpisodeRun ? (encodePlan?.encodeInputPath || job.encode_input_path || this.snapshot.context?.inputPath || null) : (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 outputPathJobView = encodePlanOverride ? { ...job, encode_plan_json: JSON.stringify(encodePlanOverride) } : job; const incompleteOutputPath = buildIncompleteOutputPathFromJob(settings, outputPathJobView, jobId); let preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, outputPathJobView, jobId); if (this.isMultipartMovieDiscChildHistoryJob(outputPathJobView)) { const containerJobId = resolveMultipartContainerJobIdFromJob(outputPathJobView); preferredFinalOutputPath = buildIncompleteMergeOutputPathFromJob( settings, outputPathJobView, preferredFinalOutputPath, jobId, { containerJobId } ); } ensureDir(path.dirname(incompleteOutputPath)); const liveJobContext = this.jobProgress.get(Number(jobId))?.context; const existingSeriesBatchContext = isSeriesBatchEpisodeRun ? ( (liveJobContext?.seriesBatch && typeof liveJobContext.seriesBatch === 'object' ? liveJobContext.seriesBatch : null) || (this.snapshot.context?.seriesBatch && typeof this.snapshot.context.seriesBatch === 'object' ? this.snapshot.context.seriesBatch : null) ) : null; await this.setState('ENCODING', { activeJobId: jobId, progress: 0, eta: null, statusText: isSeriesBatchEpisodeRun ? `Serien-Episode läuft: ${seriesBatchEpisodeContext?.episodeLabel || `Episode ${seriesBatchEpisodeContext?.episodeIndex || '?'}`}` : (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 }, ...(existingSeriesBatchContext ? { seriesBatch: existingSeriesBatchContext } : {}) } }); await historyService.updateJob(jobId, { status: 'ENCODING', last_state: 'ENCODING', output_path: incompleteOutputPath, encode_input_path: inputPath, ...(activeRawPath ? { raw_path: activeRawPath } : {}) }); if (seriesBatchParentJobId) { await this.updateSeriesBatchParentProgress(seriesBatchParentJobId).catch((error) => { logger.warn('series-batch:parent-progress:update-on-child-start-failed', { parentJobId: seriesBatchParentJobId, childJobId: jobId, error: errorToMeta(error) }); }); } await historyService.appendLog( jobId, 'SYSTEM', `${isSeriesBatchEpisodeRun ? `[Episode ${seriesBatchEpisodeContext?.episodeIndex || '?'}] ` : ''}Temporärer Encode-Output: ${incompleteOutputPath} (wird nach erfolgreichem Encode in den finalen Zielordner verschoben).` ); if (!isSeriesBatchEpisodeRun && mode === 'reencode') { void this.notifyPushover('reencode_started', { title: 'Ripster - Re-Encode gestartet', message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` }); } else if (!isSeriesBatchEpisodeRun) { void this.notifyPushover('encoding_started', { title: 'Ripster - Encoding gestartet', message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` }); } 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 preAutomationCount = preScriptIds.length + normalizedPreChainIds.length; const postAutomationCount = postScriptIds.length + normalizedPostChainIds.length; const encodeScriptProgressTracker = createEncodeScriptProgressTracker({ jobId, preSteps: 0, postSteps: 0, updateProgress: this.updateProgress.bind(this) }); const preEncodeScriptsSummary = { configured: preAutomationCount, attempted: 0, succeeded: 0, failed: 0, skipped: 0, queued: preAutomationCount > 0, detachedQueue: preAutomationCount > 0, results: [] }; if (preAutomationCount > 0) { await historyService.appendLog( jobId, 'SYSTEM', `Pre-Encode Automationen laufen als eigene Queue-Einträge (Pre=${preAutomationCount}).` ); } 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; } const primaryPlanTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId); const primaryPlanTitle = Array.isArray(encodePlan?.titles) ? ( encodePlan.titles.find((title) => normalizeReviewTitleId(title?.id) === primaryPlanTitleId) || encodePlan.titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) || null ) : null; 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( primaryPlanTitle?.handBrakeTitleId ?? primaryPlanTitle?.titleIndex ?? encodePlan?.handBrakeTitleId ?? null ); if (reviewMappedTitleId) { handBrakeTitleId = reviewMappedTitleId; await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Titel-Mapping aus Vorbereitung übernommen: -t ${handBrakeTitleId}` ); } if (!handBrakeTitleId && isSeriesBatchEpisodeRun) { const seriesEpisodeFallbackTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId); if (seriesEpisodeFallbackTitleId) { handBrakeTitleId = seriesEpisodeFallbackTitleId; await historyService.appendLog( jobId, 'SYSTEM', `Serien-Episode nutzt Titel-ID aus Encode-Plan: -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; const encodePlanPlaylistAnalysis = encodePlan?.playlistAnalysis && typeof encodePlan.playlistAnalysis === 'object' ? encodePlan.playlistAnalysis : null; const selectedPlaylistAliasesForResolve = selectedPlaylistId ? normalizePlaylistAliasIds( [ ...(Array.isArray(selectedEncodeTitle?.playlistAliases) ? selectedEncodeTitle.playlistAliases : []), ...getPlaylistAliasesForPlaylist( encodePlanPlaylistAnalysis || playlistDecision?.playlistAnalysis || this.snapshot.context?.playlistAnalysis || this.safeParseJson(job?.makemkv_info_json)?.analyzeContext?.playlistAnalysis || null, selectedPlaylistId ) ], selectedPlaylistId ) : []; 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, playlistAliases: selectedPlaylistAliasesForResolve }); 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 primaryTitleMappedHandBrakeId = normalizeReviewTitleId( primaryPlanTitle?.handBrakeTitleId ?? primaryPlanTitle?.titleIndex ?? null ); if (primaryTitleMappedHandBrakeId) { handBrakeTitleId = primaryTitleMappedHandBrakeId; await historyService.appendLog( jobId, 'SYSTEM', `HandBrake Titel-ID aus Primär-Titel übernommen: -t ${handBrakeTitleId}` ); } } 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 buildSeriesBatchLiveContextPatch = (episodeProgressPercent, episodeEta = null) => { if (!isSeriesBatchEpisodeRun) { return null; } const liveContext = this.jobProgress.get(Number(jobId))?.context; const snapshotContext = Number(this.snapshot?.activeJobId) === Number(jobId) ? this.snapshot?.context : null; const currentSeriesBatch = ( liveContext?.seriesBatch && typeof liveContext.seriesBatch === 'object' ? liveContext.seriesBatch : null ) || ( snapshotContext?.seriesBatch && typeof snapshotContext.seriesBatch === 'object' ? snapshotContext.seriesBatch : null ); if (!currentSeriesBatch) { return null; } const rawChildren = Array.isArray(currentSeriesBatch.children) ? currentSeriesBatch.children : []; if (rawChildren.length === 0) { return null; } const normalizedEpisodeIndex = normalizePositiveInteger(seriesBatchEpisodeContext?.episodeIndex); const normalizedEpisodeTitleId = normalizeReviewTitleId( seriesBatchEpisodeContext?.titleId ?? encodePlan?.encodeInputTitleId ?? null ); let targetChildIndex = rawChildren.findIndex((child) => ( (normalizedEpisodeIndex && normalizePositiveInteger(child?.episodeIndex) === normalizedEpisodeIndex) || (normalizedEpisodeTitleId && normalizeReviewTitleId(child?.titleId) === normalizedEpisodeTitleId) )); if (targetChildIndex < 0 && normalizedEpisodeIndex && normalizedEpisodeIndex <= rawChildren.length) { targetChildIndex = normalizedEpisodeIndex - 1; } if (targetChildIndex < 0) { targetChildIndex = rawChildren.findIndex((child) => normalizeSeriesEpisodeStatus(child?.status, 'QUEUED') === 'RUNNING'); } if (targetChildIndex < 0) { targetChildIndex = 0; } const clampedEpisodeProgress = Number.isFinite(Number(episodeProgressPercent)) ? Math.max(0, Math.min(100, Number(episodeProgressPercent))) : 0; const normalizedEpisodeEta = episodeEta !== undefined ? episodeEta : null; let finishedCount = 0; let cancelledCount = 0; let errorCount = 0; let runningCount = 0; const nextChildren = rawChildren.map((child, childIndex) => { const currentStatus = normalizeSeriesEpisodeStatus(child?.status, 'QUEUED'); const isTargetChild = childIndex === targetChildIndex; const nextStatus = ( isTargetChild && currentStatus !== 'FINISHED' && currentStatus !== 'ERROR' && currentStatus !== 'CANCELLED' ) ? 'RUNNING' : currentStatus; let nextProgress = Number(child?.progress); if (nextStatus === 'FINISHED') { nextProgress = 100; } else if (isTargetChild && nextStatus === 'RUNNING') { nextProgress = clampedEpisodeProgress; } else if (!Number.isFinite(nextProgress)) { nextProgress = 0; } const normalizedProgress = Math.max(0, Math.min(100, Number(nextProgress) || 0)); if (nextStatus === 'FINISHED') { finishedCount += 1; } else if (nextStatus === 'CANCELLED') { cancelledCount += 1; } else if (nextStatus === 'ERROR') { errorCount += 1; } else if (nextStatus === 'RUNNING') { runningCount += 1; } return { ...child, status: nextStatus, progress: Number(normalizedProgress.toFixed(2)), eta: isTargetChild ? normalizedEpisodeEta : (child?.eta ?? null) }; }); const totalCountRaw = Number(currentSeriesBatch.totalCount || 0); const totalCount = Number.isFinite(totalCountRaw) && totalCountRaw > 0 ? Math.trunc(totalCountRaw) : nextChildren.length; return { seriesBatch: { ...currentSeriesBatch, parentJobId: Number(currentSeriesBatch.parentJobId || jobId), totalCount, finishedCount, cancelledCount, errorCount, runningCount, children: nextChildren } }; }; const handBrakeProgressParser = (line) => { const parsed = parseHandBrakeProgress(line); if (!parsed || parsed.percent === null || parsed.percent === undefined) { return parsed; } const mappedPercent = encodeScriptProgressTracker.hasScriptSteps ? encodeScriptProgressTracker.mapHandBrakePercent(parsed.percent) : parsed.percent; const nextParsed = { ...parsed, percent: mappedPercent }; const seriesBatchContextPatch = buildSeriesBatchLiveContextPatch(mappedPercent, parsed.eta); if (seriesBatchContextPatch) { nextParsed.contextPatch = seriesBatchContextPatch; } return nextParsed; }; 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; const seriesOutputParts = resolveSeriesOutputPathParts(settings, outputPathJobView, jobId); const outputOwner = String( seriesOutputParts?.rootOwner || settings.movie_dir_owner || '' ).trim(); chownRecursive(path.dirname(finalizedOutputPath), outputOwner); 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 }); }); const postEncodeScriptsSummary = { ...this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan), pending: false, queued: postAutomationCount > 0, detachedQueue: postAutomationCount > 0 }; let finalizedRawPath = activeRawPath || null; const isMultipartDiscChildRun = this.isMultipartMovieDiscChildHistoryJob(job); const deferRawFinalizeUntilSeriesBatchDone = ( isSeriesBatchEpisodeRun || Boolean(seriesBatchParentJobId) ) && !isMultipartDiscChildRun; if (activeRawPath && !deferRawFinalizeUntilSeriesBatchDone) { const currentRawPath = String(activeRawPath || '').trim(); const completedRawPath = buildCompletedRawPath(currentRawPath); if (completedRawPath && completedRawPath !== currentRawPath) { const currentRawExists = fs.existsSync(currentRawPath); const completedRawExists = fs.existsSync(completedRawPath); if (!currentRawExists && completedRawExists) { finalizedRawPath = completedRawPath; await historyService.updateRawPathByOldPath(currentRawPath, completedRawPath); await historyService.appendLog( jobId, 'SYSTEM', `RAW-Pfad nach Encode korrigiert (bereits finalisiert): ${currentRawPath} -> ${completedRawPath}` ); } else if (!currentRawExists) { await historyService.appendLog( jobId, 'SYSTEM', `RAW-Ordner konnte nach Encode nicht finalisiert werden (Quellpfad fehlt): ${currentRawPath}` ); } else if (completedRawExists) { 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); if (isSeriesBatchEpisodeRun) { await historyService.updateJob(jobId, { handbrake_info_json: JSON.stringify(handbrakeInfoWithPostScripts), status: 'ENCODING', last_state: 'ENCODING', end_time: null, raw_path: finalizedRawPath, rip_successful: 1, output_path: finalizedOutputPath, error_message: null }); await historyService.generateJobNfo(jobId, { mode: 'auto', requireSettingEnabled: true }).catch((nfoError) => { logger.warn('job:nfo:auto-generate-failed', { jobId, mode: 'series_batch_episode', error: errorToMeta(nfoError) }); }); return { outputPath: finalizedOutputPath, handbrakeInfo: handbrakeInfoWithPostScripts, trackSelection, rawPath: finalizedRawPath }; } 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 }); await historyService.generateJobNfo(jobId, { mode: 'auto', requireSettingEnabled: true }).catch((nfoError) => { logger.warn('job:nfo:auto-generate-failed', { jobId, mode: 'encode', error: errorToMeta(nfoError) }); }); if (job?.parent_job_id) { const parentJobId = this.normalizeQueueJobId(job.parent_job_id); const parentJob = parentJobId ? await historyService.getJobById(parentJobId).catch(() => null) : null; if (parentJob && this.isMultipartMovieContainerHistoryJob(parentJob) && this.isMultipartMovieDiscChildHistoryJob(job)) { await this.upsertMultipartMergeJobForContainer(parentJobId, { containerJob: parentJob }).catch((mergePrepError) => { logger.warn('multipart:merge:upsert-on-child-finish-failed', { parentJobId, childJobId: jobId, error: errorToMeta(mergePrepError) }); }); } await this.syncSeriesContainerStatusFromChildren(parentJobId).catch((parentSyncError) => { logger.warn('series-container:status-sync-on-child-finish-failed', { parentJobId, childJobId: jobId, error: errorToMeta(parentSyncError) }); }); } // 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 (postAutomationCount > 0) { await historyService.appendLog( jobId, 'SYSTEM', `Post-Encode Automationen laufen als eigene Queue-Einträge (Post=${postAutomationCount}).` ); } 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); } if (seriesBatchParentJobId) { await this.updateSeriesBatchParentProgress(seriesBatchParentJobId).catch((error) => { logger.warn('series-batch:parent-progress:update-on-child-finish-failed', { parentJobId: seriesBatchParentJobId, childJobId: jobId, error: errorToMeta(error) }); }); } 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) }); if (isSeriesBatchEpisodeRun) { throw 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 analyzeContext = mkInfo?.analyzeContext || {}; 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 selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {}); const isSeriesDvdRip = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); const rawStorage = resolveSeriesAwareRawStorage(settings, mediaProfile, selectedMetadata, analyzeContext); const effectiveRawBaseDir = String( rawStorage?.rawBaseDir || rawBaseDir || settingsService.DEFAULT_RAW_DIR || '' ).trim(); const effectiveRawOwner = String(rawStorage?.rawOwner || settings.raw_dir_owner || '').trim(); const disableMinLengthFilter = ripMode === 'mkv' && isSeriesDvdRip; 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: effectiveRawBaseDir }); ensureDir(effectiveRawBaseDir); const metadataBase = buildRawMetadataBase({ title: job.title || job.detected_title || null, year: job.year || null, detected_title: job.detected_title || null, media_type: mediaProfile, is_multipart_movie: Number(job?.is_multipart_movie || 0) === 1 ? 1 : 0, job_kind: job?.job_kind || null }, jobId, { mediaProfile, analyzeContext, selectedMetadata, isMultipartMovie: Number(job?.is_multipart_movie || 0) === 1 || String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_child' || String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container' }); const rawDirName = buildRawDirName(metadataBase, jobId, { state: RAW_FOLDER_STATES.INCOMPLETE }); const rawJobDir = path.join(effectiveRawBaseDir, rawDirName); ensureDir(rawJobDir); chownRecursive(rawJobDir, effectiveRawOwner); 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 } }); 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 }); if (job?.parent_job_id) { await historyService.updateJob(job.parent_job_id, { status: 'RIPPING', last_state: 'RIPPING', 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.` ); } if (disableMinLengthFilter) { await historyService.appendLog( jobId, 'SYSTEM', `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt: MakeMKV-Rip läuft ohne Mindestlängen-Filter (--minlength deaktiviert).` ); } if (rawStorage.usingSeriesRawPath) { await historyService.appendLog( jobId, 'SYSTEM', `Serien-${resolveSeriesDiscLabel(mediaProfile)} RAW-Pfad aktiv: ${effectiveRawBaseDir}` ); } const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'rip').catch(() => null); if (devicePath) { const isOrphanRawImportRipJob = String(mkInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; if (isOrphanRawImportRipJob) { this._releaseDriveLockForJob(jobId, { reason: 'orphan_raw_rip_bypass' }); } else { 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, disableMinLengthFilter, backupOutputBase, sourceArgOverride: null, makeMkvLineFilter: createMakeMkvForeignDriveLineFilter(devicePath), 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, effectiveRawOwner); 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 starts processing immediately (Rip/Merge) and bypasses the encode queue. 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 sourceJobKind = this.resolveJobKindForJob(sourceJob, { encodePlan: sourceEncodePlan }); 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'; const isMultipartMergeRetry = sourceJobKind === 'multipart_movie_merge'; const isVideoDiscRetry = mediaProfile === 'dvd' || mediaProfile === 'bluray'; const sourceParentJobId = this.normalizeQueueJobId(sourceJob?.parent_job_id); let mergeContainerJobId = null; if (isMultipartMergeRetry) { mergeContainerJobId = this.normalizeQueueJobId(sourceEncodePlan?.containerJobId); if (!mergeContainerJobId && sourceParentJobId) { const directParentJob = await historyService.getJobById(sourceParentJobId).catch(() => null); if (directParentJob && this.isMultipartMovieContainerHistoryJob(directParentJob)) { mergeContainerJobId = sourceParentJobId; } else if (directParentJob && this.isMultipartMovieMergeHistoryJob(directParentJob)) { const nestedContainerJobId = this.normalizeQueueJobId(directParentJob?.parent_job_id); if (nestedContainerJobId) { const nestedParentJob = await historyService.getJobById(nestedContainerJobId).catch(() => null); if (nestedParentJob && this.isMultipartMovieContainerHistoryJob(nestedParentJob)) { mergeContainerJobId = nestedContainerJobId; } } } } if (mergeContainerJobId) { const mergeContainerJob = await historyService.getJobById(mergeContainerJobId).catch(() => null); if (!mergeContainerJob || !this.isMultipartMovieContainerHistoryJob(mergeContainerJob)) { mergeContainerJobId = null; } } if (!mergeContainerJobId) { const error = new Error( 'Retry für Merge-Job nicht möglich: Multipart-Container konnte nicht aufgelöst werden.' ); error.statusCode = 409; throw error; } } // CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden. const retryableByStatus = ['ERROR', 'CANCELLED'].includes(sourceStatus) || ['ERROR', 'CANCELLED'].includes(sourceLastState) || (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus)); const incompleteRipRetryableStates = ['WAITING_FOR_USER_DECISION', 'READY_TO_ENCODE', 'MEDIAINFO_CHECK']; const retryableByIncompleteVideoRipState = isVideoDiscRetry && !isMultipartMergeRetry && ( incompleteRipRetryableStates.includes(sourceStatus) || incompleteRipRetryableStates.includes(sourceLastState) ); const retryable = retryableByStatus || retryableByIncompleteVideoRipState; if (!retryable) { const currentStateLabel = sourceStatus || sourceLastState || '-'; const error = new Error( `Retry nicht möglich: Job ${jobId} ist nicht in einem retry-fähigen Status (aktuell ${currentStateLabel}).` ); 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) } const shouldResetOldRawBeforeRetry = (isVideoDiscRetry || isCdRetry) && !isAudiobookRetry && !isMultipartMergeRetry; if (shouldResetOldRawBeforeRetry) { 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 retryInitialStatus = isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : (isMultipartMergeRetry ? 'READY_TO_START' : 'RIPPING')); const createNewJob = Boolean(options?.createNewJob); const retryParentJobId = isMultipartMergeRetry ? mergeContainerJobId : this.normalizeQueueJobId(jobId); let retryEncodePlanJson = (isCdRetry || isAudiobookRetry || isMultipartMergeRetry) ? (sourceJob.encode_plan_json || null) : null; if (isMultipartMergeRetry && sourceEncodePlan && typeof sourceEncodePlan === 'object' && retryParentJobId) { retryEncodePlanJson = JSON.stringify({ ...sourceEncodePlan, mode: 'multipart_merge', jobKind: 'multipart_movie_merge', containerJobId: Number(retryParentJobId), updatedAt: nowIso() }); } let retryJobId = Number(jobId); let replacedSourceJob = false; if (createNewJob) { const retryJob = await historyService.createJob({ discDevice: sourceJob.disc_device || null, status: retryInitialStatus, detectedTitle: sourceJob.detected_title || sourceJob.title || null, jobKind: this.resolveJobKindForJob(sourceJob, { encodePlan: sourceEncodePlan }) }); retryJobId = Number(retryJob?.id || 0); if (!Number.isFinite(retryJobId) || retryJobId <= 0) { throw new Error('Retry fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); } replacedSourceJob = true; } let retryRawPath = isAudiobookRetry ? (sourceJob.raw_path || null) : null; let retryEncodeInputPath = isAudiobookRetry ? ( sourceJob.encode_input_path || sourceEncodePlan?.encodeInputPath || sourceMakemkvInfo?.rawFilePath || null ) : null; if (retryRawPath && replacedSourceJob) { const previousRetryRawPath = retryRawPath; retryRawPath = await this.alignRawFolderJobId(retryRawPath, retryJobId); if ( previousRetryRawPath && retryRawPath && normalizeComparablePath(previousRetryRawPath) !== normalizeComparablePath(retryRawPath) ) { retryEncodeInputPath = remapPathToRetargetedRawRoot( retryEncodeInputPath, previousRetryRawPath, retryRawPath ); } } const retryUpdatePayload = { 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: null, 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: retryEncodePlanJson, encode_input_path: retryEncodeInputPath, encode_review_confirmed: isAudiobookRetry ? 1 : 0, output_path: null, raw_path: retryRawPath, status: retryInitialStatus, last_state: retryInitialStatus }; if (replacedSourceJob) { retryUpdatePayload.parent_job_id = retryParentJobId ? Number(retryParentJobId) : null; } await historyService.updateJob(retryJobId, retryUpdatePayload); // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist if (replacedSourceJob && 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' : (isMultipartMergeRetry ? 'Merge' : 'Disc'))}).${replacedSourceJob ? ' Neuer Job wurde angelegt.' : ' Bestehender Job wird weiterverwendet.'}` ); if (preservedRetryMediaType && !explicitSourceMediaType) { await historyService.updateJob(jobId, { media_type: preservedRetryMediaType }).catch(() => {}); } if (replacedSourceJob) { this._releaseDriveLockForJob(jobId, { reason: 'retry_replaced' }); await historyService.retireJobInFavorOf(jobId, retryJobId, { reason: isCdRetry ? 'cd_retry' : (isAudiobookRetry ? 'audiobook_retry' : (isMultipartMergeRetry ? 'merge_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, started: false, queued: false, stage: 'READY_TO_START' }; } else if (isMultipartMergeRetry) { this.startMultipartMergeJob(retryJobId).catch((error) => { logger.error('retry:multipart-merge:background-failed', { jobId: retryJobId, sourceJobId: jobId, error: errorToMeta(error) }); }); } 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 }; } 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); const requestedRestartMode = String(options?.restartMode || 'all').trim().toLowerCase() === 'from_abort' ? 'from_abort' : 'all'; logger.info('restartEncodeWithLastSettings:requested', { jobId, restartMode: requestedRestartMode }); this.cancelRequestedByJob.delete(Number(jobId)); const triggerReason = String(options?.triggerReason || 'manual').trim().toLowerCase(); const AUTO_RECOVERY_TRIGGER_REASONS = new Set([ 'failed_encode', 'cancelled_encode', 'server_restart', 'confirm_auto_prepare' ]); const isAutoRecoveryTrigger = AUTO_RECOVERY_TRIGGER_REASONS.has(triggerReason); const preserveJobIdOnAutoRecovery = isAutoRecoveryTrigger; const shouldCreateReplacementJob = !preserveJobIdOnAutoRecovery && Boolean(options?.createNewJob); const job = resolvedRestartEncodeJob.job || await historyService.getJobById(jobId); const currentStatus = String(job.status || '').trim().toUpperCase(); if (['ANALYZING', 'METADATA_LOOKUP', '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 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); const encodePreviouslySuccessful = String(this.safeParseJson(job.handbrake_info_json)?.status || '').trim().toUpperCase() === 'SUCCESS'; const allowReviewBypass = !reviewConfirmed && encodePreviouslySuccessful && Array.isArray(encodePlan?.titles) && encodePlan.titles.length > 0; if (!reviewConfirmed && !allowReviewBypass) { 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 effectiveRestartDeleteIncompleteOutput = restartDeleteIncompleteOutput && !isAutoRecoveryTrigger; const handBrakeInfo = this.safeParseJson(job.handbrake_info_json); 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 && effectiveRestartDeleteIncompleteOutput && !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) }); } } let effectiveEncodePlan = encodePlan; let effectiveRestartMode = requestedRestartMode; let seriesBatchRestartSummary = null; if (isSeriesBatchParentPlan(encodePlan)) { const parentSelectedTitleIds = normalizeReviewTitleIdList( Array.isArray(encodePlan?.selectedTitleIds) ? encodePlan.selectedTitleIds : [encodePlan?.encodeInputTitleId] ); const orderedEpisodes = buildSeriesBatchEpisodesFromPlan(job, encodePlan, parentSelectedTitleIds) .map((entry, index) => ({ episodeIndex: normalizePositiveInteger(entry?.episodeIndex) || (index + 1), titleId: normalizeReviewTitleId(entry?.titleId) || parentSelectedTitleIds[index] || null, status: normalizeSeriesEpisodeStatus(entry?.status, 'QUEUED'), outputPath: String(entry?.outputPath || '').trim() || null })) .filter((entry) => Boolean(entry.titleId)) .sort((left, right) => left.episodeIndex - right.episodeIndex); let selectedTitleIdsForRestart = parentSelectedTitleIds; let restartFromEntry = null; if (requestedRestartMode === 'from_abort' && orderedEpisodes.length > 0) { restartFromEntry = orderedEpisodes.find((entry) => entry.status !== 'FINISHED') || null; if (restartFromEntry) { const startIndex = orderedEpisodes.findIndex((entry) => entry === restartFromEntry); selectedTitleIdsForRestart = orderedEpisodes .slice(startIndex >= 0 ? startIndex : 0) .map((entry) => entry.titleId) .filter(Boolean); if (selectedTitleIdsForRestart.length === 0) { selectedTitleIdsForRestart = parentSelectedTitleIds; } } else { effectiveRestartMode = 'all'; selectedTitleIdsForRestart = parentSelectedTitleIds; } } else { effectiveRestartMode = 'all'; } if (selectedTitleIdsForRestart.length > 0) { const selectedTitleSet = new Set(selectedTitleIdsForRestart.map((id) => String(id))); const selectionResult = applyEncodeTitleSelectionToPlan( encodePlan, selectedTitleIdsForRestart[0], selectedTitleIdsForRestart ); let nextSeriesPlan = selectionResult.plan; const episodeAssignmentsSource = nextSeriesPlan?.episodeAssignments && typeof nextSeriesPlan.episodeAssignments === 'object' ? nextSeriesPlan.episodeAssignments : {}; const filteredEpisodeAssignments = Object.fromEntries( Object.entries(episodeAssignmentsSource).filter(([titleId]) => selectedTitleSet.has(String(titleId))) ); const manualByTitleSource = nextSeriesPlan?.manualTrackSelectionByTitle && typeof nextSeriesPlan.manualTrackSelectionByTitle === 'object' ? nextSeriesPlan.manualTrackSelectionByTitle : {}; const filteredManualByTitle = Object.fromEntries( Object.entries(manualByTitleSource).filter(([titleId]) => selectedTitleSet.has(String(titleId))) ); const activeRestartTitleId = normalizeReviewTitleId(nextSeriesPlan?.encodeInputTitleId) || selectedTitleIdsForRestart[0] || null; const activeManualSelection = activeRestartTitleId ? (filteredManualByTitle[String(activeRestartTitleId)] || null) : null; nextSeriesPlan = { ...nextSeriesPlan, episodeAssignments: filteredEpisodeAssignments, manualTrackSelectionByTitle: filteredManualByTitle, manualTrackSelection: activeManualSelection || nextSeriesPlan?.manualTrackSelection || null, seriesBatchParent: false, seriesBatchChild: false, seriesBatchParentJobId: null, seriesBatchChildJobIds: [], seriesBatchDispatchedAt: null, seriesBatchTotalChildren: selectedTitleIdsForRestart.length }; effectiveEncodePlan = nextSeriesPlan; } if ( effectiveRestartMode === 'from_abort' && restartFromEntry && effectiveRestartDeleteIncompleteOutput && !keepBoth ) { const restartChildOutputPath = String(restartFromEntry?.outputPath || '').trim(); const restartChildWasFinished = restartFromEntry.status === 'FINISHED'; if (restartChildOutputPath && !restartChildWasFinished && fs.existsSync(restartChildOutputPath)) { try { fs.rmSync(restartChildOutputPath, { recursive: true, force: true }); await historyService.appendLog( jobId, 'USER_ACTION', `Serien-Neustart ab Abbruch: Unvollständigen Episoden-Output entfernt (${restartChildOutputPath}).` ); } catch (deleteChildOutputError) { logger.warn('restartEncodeWithLastSettings:series-child-output-delete-failed', { jobId, episodeIndex: restartFromEntry?.episodeIndex || null, titleId: restartFromEntry?.titleId || null, outputPath: restartChildOutputPath, error: errorToMeta(deleteChildOutputError) }); } } } seriesBatchRestartSummary = { mode: effectiveRestartMode, selectedTitleIds: normalizeReviewTitleIdList( Array.isArray(effectiveEncodePlan?.selectedTitleIds) ? effectiveEncodePlan.selectedTitleIds : [effectiveEncodePlan?.encodeInputTitleId] ), restartFromEpisodeIndex: restartFromEntry ? Number(restartFromEntry?.episodeIndex || 0) : null, restartFromTitleId: restartFromEntry ? Number(restartFromEntry?.titleId || 0) : null }; } const restartPlan = { ...effectiveEncodePlan, 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); let replacementJobId = Number(jobId); let replacedSourceJob = false; if (shouldCreateReplacementJob) { 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 }) }); replacementJobId = Number(replacementJob?.id || 0); if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { throw new Error('Encode-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.'); } replacedSourceJob = true; const previousRestartRawPath = activeRestartRawPath; activeRestartRawPath = await this.alignRawFolderJobId(activeRestartRawPath, replacementJobId); if ( previousRestartRawPath && activeRestartRawPath && normalizeComparablePath(previousRestartRawPath) !== normalizeComparablePath(activeRestartRawPath) ) { inputPath = remapPathToRetargetedRawRoot(inputPath, previousRestartRawPath, activeRestartRawPath); restartPlan.encodeInputPath = inputPath; } } const replacementJobPatch = { 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: null, 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 }; if (replacedSourceJob) { replacementJobPatch.parent_job_id = Number(jobId); } else { replacementJobPatch.start_time = null; } await historyService.updateJob(replacementJobId, replacementJobPatch); // Keep local poster thumbnails valid for the replacement job id. if (replacedSourceJob && thumbnailService.isLocalUrl(job.poster_url)) { const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId); if (copiedUrl) { await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {}); } } const restartModeDetails = seriesBatchRestartSummary ? ( seriesBatchRestartSummary.mode === 'from_abort' ? ` Serienmodus: Neu starten ab Abbruch (ab Episode #${seriesBatchRestartSummary.restartFromEpisodeIndex || '-'}${seriesBatchRestartSummary.restartFromTitleId ? ` | Titel ${seriesBatchRestartSummary.restartFromTitleId}` : ''}).` : ` Serienmodus: Alle neu starten (${seriesBatchRestartSummary.selectedTitleIds.length} Episode(n)).` ) : ''; const loadedSelectionText = ( previousOutputPath ? `Letzte bestätigte Auswahl wurde geladen und kann angepasst werden. Vorheriger Output-Pfad: ${previousOutputPath}. autoDeleteIncomplete=${effectiveRestartDeleteIncompleteOutput ? 'on' : 'off'}.${restartModeDetails}` : `Letzte bestätigte Auswahl wurde geladen und kann angepasst werden.${restartModeDetails}` ); 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 (replacedSourceJob && preservedRestartMediaType && !explicitJobMediaType) { await historyService.updateJob(jobId, { media_type: preservedRestartMediaType }).catch(() => {}); } if (replacedSourceJob) { 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${seriesBatchRestartSummary?.mode === 'from_abort' ? ' (ab Abbruch)' : ''}` )) : (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, restartMode: seriesBatchRestartSummary?.mode || effectiveRestartMode }; } 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', 'METADATA_LOOKUP', '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 = Object.prototype.hasOwnProperty.call(options || {}, 'reuseCurrentJob') ? Boolean(options?.reuseCurrentJob) : !Boolean(options?.createNewJob); 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 resetProcessLogIfLifecycleAllows(targetJobId, sourceJob); 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: null, 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 cancelSeriesBatchParentJob(parentJobId, parentJob = null) { const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); if (!normalizedParentJobId) { const error = new Error('Ungültige Serien-Batch Job-ID.'); error.statusCode = 400; throw error; } const sourceParentJob = parentJob && Number(parentJob?.id) === Number(normalizedParentJobId) ? parentJob : await historyService.getJobById(normalizedParentJobId); if (!sourceParentJob || !this.isSeriesBatchParentQueueAnchor(sourceParentJob)) { return { cancelled: false, seriesBatch: false, jobId: normalizedParentJobId }; } const reason = 'Serien-Batch vom Benutzer abgebrochen.'; const seriesChildJobs = await this.listSeriesBatchChildJobs(normalizedParentJobId); const seriesChildJobIds = normalizeReviewTitleIdList( seriesChildJobs.map((childJob) => normalizePositiveInteger(childJob?.id)).filter(Boolean) ); const seriesChildJobIdSet = new Set(seriesChildJobIds.map((value) => Number(value))); const queueEntriesBefore = this.queueEntries.length; this.queueEntries = this.queueEntries.filter((entry) => { const action = String(entry?.action || '').trim().toUpperCase(); const entryType = String(entry?.type || 'job').trim().toLowerCase(); const entryJobId = Number(entry?.jobId); const entrySourceJobId = this.normalizeQueueJobId(entry?.sourceJobId); const isLegacyParentEntry = ( entryJobId === Number(normalizedParentJobId) && action === QUEUE_ACTIONS.START_SERIES_EPISODE ); const isSeriesChildJobEntry = ( (!entry?.type || entry.type === 'job') && Number.isFinite(entryJobId) && entryJobId > 0 && seriesChildJobIdSet.has(entryJobId) ); const isDetachedSeriesAutomationEntry = ( (entryType === 'script' || entryType === 'chain') && entrySourceJobId && ( Number(entrySourceJobId) === Number(normalizedParentJobId) || seriesChildJobIdSet.has(Number(entrySourceJobId)) ) ); return !(isLegacyParentEntry || isSeriesChildJobEntry || isDetachedSeriesAutomationEntry); }); const removedQueueEntries = Math.max(0, queueEntriesBefore - this.queueEntries.length); const sourcePlan = this.safeParseJson(sourceParentJob.encode_plan_json); const selectedTitleIds = normalizeReviewTitleIdList( Array.isArray(sourcePlan?.selectedTitleIds) ? sourcePlan.selectedTitleIds : (Array.isArray(sourcePlan?.seriesBatchEpisodes) ? sourcePlan.seriesBatchEpisodes.map((entry) => entry?.titleId) : []) ); const sourceEpisodes = buildSeriesBatchEpisodesFromPlan( sourceParentJob, sourcePlan, selectedTitleIds ); const cancelledEpisodes = sourceEpisodes.map((entry) => { const status = normalizeSeriesEpisodeStatus(entry?.status, 'QUEUED'); if (status === 'FINISHED' || status === 'ERROR' || status === 'CANCELLED') { return entry; } return { ...entry, status: 'CANCELLED', eta: null, finishedAt: nowIso(), error: reason }; }); const nextPlan = { ...(sourcePlan && typeof sourcePlan === 'object' ? sourcePlan : {}), seriesBatchParent: true, seriesBatchChild: false, seriesBatchChildJobIds: [], seriesBatchEpisodes: cancelledEpisodes }; const summary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, cancelledEpisodes); const previousHandbrakeInfo = this.safeParseJson(sourceParentJob.handbrake_info_json) || {}; const nextHandbrakeInfo = { ...(previousHandbrakeInfo && typeof previousHandbrakeInfo === 'object' ? previousHandbrakeInfo : {}), mode: 'series_batch', seriesBatch: { parentJobId: normalizedParentJobId, totalCount: summary.totalCount, finishedCount: summary.finishedCount, cancelledCount: summary.cancelledCount, errorCount: summary.errorCount, runningCount: summary.runningCount, episodes: cancelledEpisodes } }; let cancelledChildRunning = 0; let cancelledChildQueued = 0; const softCancelableChildStates = new Set([ 'READY_TO_START', 'READY_TO_ENCODE', 'METADATA_LOOKUP', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION' ]); const currentChildJobsById = new Map( seriesChildJobs.map((childJob) => [Number(childJob?.id), childJob]) ); for (const childJobIdRaw of seriesChildJobIds) { const childJobId = this.normalizeQueueJobId(childJobIdRaw); if (!childJobId) { continue; } const childJob = currentChildJobsById.get(Number(childJobId)) || null; const childStatus = String( childJob?.status || childJob?.last_state || '' ).trim().toUpperCase(); if (isTerminalStatus(childStatus)) { continue; } const hasActiveHandle = this.activeProcesses.has(Number(childJobId)); if (hasActiveHandle || RUNNING_STATES.has(childStatus)) { try { await this.cancel(childJobId); cancelledChildRunning += 1; } catch (cancelChildError) { logger.warn('series-batch:cancel:child-cancel-failed', { parentJobId: normalizedParentJobId, childJobId, error: errorToMeta(cancelChildError) }); } continue; } if (softCancelableChildStates.has(childStatus)) { await historyService.updateJob(childJobId, { status: 'CANCELLED', last_state: childStatus || 'CANCELLED', end_time: nowIso(), error_message: reason }); await historyService.appendLog( childJobId, 'USER_ACTION', reason ); this.jobProgress.delete(Number(childJobId)); this.cancelRequestedByJob.delete(Number(childJobId)); this.seriesBatchParentByChild.delete(Number(childJobId)); cancelledChildQueued += 1; } } const processHandle = this.activeProcesses.get(normalizedParentJobId) || null; if (processHandle) { this.cancelRequestedByJob.add(Number(normalizedParentJobId)); try { processHandle.cancel(); } catch (_error) { // ignore cancel race errors } } this.seriesBatchParentProgressSyncAt.delete(Number(normalizedParentJobId)); for (const childJobId of seriesChildJobIdSet) { this.seriesBatchParentByChild.delete(Number(childJobId)); } await historyService.updateJob(normalizedParentJobId, { encode_plan_json: JSON.stringify(nextPlan), handbrake_info_json: JSON.stringify(nextHandbrakeInfo), status: 'CANCELLED', last_state: 'CANCELLED', end_time: nowIso(), error_message: reason }); await historyService.appendLog( normalizedParentJobId, 'USER_ACTION', `${reason} Episoden: ${summary.totalCount}, Queue-Einträge entfernt: ${removedQueueEntries}, aktive Child-Abbrüche: ${cancelledChildRunning}, queued Child-Abbrüche: ${cancelledChildQueued}, aktiver Episode-Run am Parent: ${processHandle ? 'ja' : 'nein'}.` ); await this.updateProgress('CANCELLED', summary.overallProgress, null, summary.summaryText, normalizedParentJobId, { contextPatch: { seriesBatch: { parentJobId: normalizedParentJobId, totalCount: summary.totalCount, finishedCount: summary.finishedCount, cancelledCount: summary.cancelledCount, errorCount: summary.errorCount, runningCount: summary.runningCount, children: summary.children } } }); await this.emitQueueChanged(); return { cancelled: true, queuedOnly: false, seriesBatch: true, jobId: normalizedParentJobId, childCount: summary.totalCount, removedQueueEntries }; } 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 targetJob = await historyService.getJobById(normalizedJobId).catch(() => null); if (targetJob && this.isSeriesBatchParentQueueAnchor(targetJob)) { return this.cancelSeriesBatchParentJob(normalizedJobId, targetJob); } const processHandle = this.activeProcesses.get(normalizedJobId) || null; if (!processHandle) { const queuedIndex = this.findQueueEntryIndexByJobId(normalizedJobId); if (queuedIndex >= 0) { const [removed] = this.queueEntries.splice(queuedIndex, 1); const removedDetachedEntries = this.removeDetachedQueueAutomationEntriesForJob(normalizedJobId); await historyService.appendLog( normalizedJobId, 'USER_ACTION', `Aus Queue entfernt: ${QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'}` + (removedDetachedEntries > 0 ? ` (inkl. ${removedDetachedEntries} verknüpften Skript/Ketten-Eintrag${removedDetachedEntries === 1 ? '' : 'en'}).` : '') ); 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_LOOKUP', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP' ]); if (SOFT_CANCELABLE_STATES.has(runningStatus)) { // Review-/Ready-Phasen werden immer soft-cancelled, selbst wenn // versehentlich ein veralteter Process-Handle vorhanden ist. const cancelMessage = 'Vom Benutzer abgebrochen.'; if (processHandle) { try { processHandle.cancel(); } catch (_error) { // ignore stale cancel race } this.activeProcesses.delete(normalizedJobId); this.syncPrimaryActiveProcess(); } 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 }); // Soft-cancel must always drop any stale drive lock for this job. this._releaseDriveLockForJob(normalizedJobId, { reason: 'soft_cancel' }); 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); } this.cancelRequestedByJob.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 (!processHandle) { 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, lineFilter = null, 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); const contextPatch = progress.contextPatch && typeof progress.contextPatch === 'object' && !Array.isArray(progress.contextPatch) ? progress.contextPatch : null; void this.updateProgress( stage, progress.percent, progress.eta, statusText, normalizedJobId, contextPatch ? { contextPatch } : undefined ); } 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) => { const keepLine = typeof lineFilter === 'function' ? lineFilter(line, 'stdout') !== false : true; if (!keepLine) { return; } 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) => { const keepLine = typeof lineFilter === 'function' ? lineFilter(line, 'stderr') !== false : true; if (!keepLine) { return; } 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'; const removedDetachedQueueEntries = this.removeDetachedQueueAutomationEntriesForJob(jobId); if (removedDetachedQueueEntries > 0) { await historyService.appendLog( jobId, 'SYSTEM', `Queue-Cleanup: ${removedDetachedQueueEntries} verknüpfte Skript/Ketten-Eintrag${removedDetachedQueueEntries === 1 ? '' : 'e'} entfernt.` ); await this.emitQueueChanged(); } 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 isOrphanRawImportJob = String(makemkvInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; const shouldAutoRecoverEncode = normalizedStage === 'ENCODING' && hasConfirmedPlan && resolvedMediaProfile !== 'audiobook' && !isOrphanRawImportJob; 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 }); if (job?.parent_job_id) { await this.syncSeriesContainerStatusFromChildren(job.parent_job_id).catch((parentSyncError) => { logger.warn('series-container:status-sync-on-child-fail-failed', { parentJobId: job.parent_job_id, childJobId: jobId, error: errorToMeta(parentSyncError) }); }); } await historyService.appendLog( jobId, 'SYSTEM', `${isCancelled ? 'Abbruch' : 'Fehler'} in ${stage}: ${message}` ); if (finalState === 'CANCELLED') { const incompleteOutputDir = resolveIncompleteDirectoryFromOutputPath(job?.output_path, jobId); if (incompleteOutputDir) { try { const existedBeforeCleanup = fs.existsSync(incompleteOutputDir); fs.rmSync(incompleteOutputDir, { recursive: true, force: true }); if (existedBeforeCleanup) { logger.info('job:cancelled:incomplete-output-cleanup', { jobId, stage: normalizedStage || null, incompleteOutputDir }); } } catch (cleanupError) { logger.warn('job:cancelled:incomplete-output-cleanup-failed', { jobId, stage: normalizedStage || null, incompleteOutputDir, error: errorToMeta(cleanupError) }); } } } 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)); const shouldRemoveCancelledOrphanImportJob = Boolean( isCancelled && normalizedStage === 'ENCODING' && isOrphanRawImportJob && !job?.parent_job_id ); if (shouldRemoveCancelledOrphanImportJob) { try { await historyService.appendLog( jobId, 'SYSTEM', 'Abgebrochener Orphan-RAW-Encode wird aus der Historie entfernt. RAW bleibt erhalten und ist wieder unter /database verfügbar.' ); await historyService.deleteJob(jobId, 'none', { includeRelated: false, preserveRawForImportJobs: true }); await this.onJobsDeleted([jobId], { suppressQueueRefresh: false }); if (Number(this.snapshot.activeJobId || 0) === Number(jobId)) { await this.setState('IDLE', { activeJobId: null, progress: 0, eta: null, statusText: 'Bereit', context: {} }); } } catch (cleanupError) { logger.warn('job:cancelled:orphan-import-cleanup-failed', { jobId, stage: normalizedStage, error: errorToMeta(cleanupError) }); } return; } const seriesBatchParentJobId = this.resolveSeriesBatchParentJobId({ ...job, encodePlan }); if (seriesBatchParentJobId) { await this.updateSeriesBatchParentProgress(seriesBatchParentJobId).catch((seriesError) => { logger.warn('series-batch:parent-progress:update-on-child-fail-failed', { parentJobId: seriesBatchParentJobId, childJobId: jobId, error: errorToMeta(seriesError) }); }); } 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 resetProcessLogIfLifecycleAllows(job.id, job); 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 selectedPreEncodeScriptIds = normalizeScriptIdList(config?.selectedPreEncodeScriptIds || []); const selectedPostEncodeScriptIds = normalizeScriptIdList(config?.selectedPostEncodeScriptIds || []); const selectedPreEncodeChainIds = normalizeChainIdList(config?.selectedPreEncodeChainIds || []); const selectedPostEncodeChainIds = normalizeChainIdList(config?.selectedPostEncodeChainIds || []); 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 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, 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), 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} | ` + `Pre-Skripte: ${selectedPreEncodeScriptIds.length} | Post-Skripte: ${selectedPostEncodeScriptIds.length} | ` + `Pre-Ketten: ${selectedPreEncodeChainIds.length} | Post-Ketten: ${selectedPostEncodeChainIds.length}` ); 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 }), { preloadedJob: options?.preloadedJob || null } ); } 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 resetProcessLogIfLifecycleAllows(jobId, job); 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 }); await historyService.generateJobNfo(jobId, { mode: 'auto', requireSettingEnabled: true }).catch((nfoError) => { logger.warn('job:nfo:auto-generate-failed', { jobId, mode: 'audiobook', error: errorToMeta(nfoError) }); }); // 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, preloadedJob: queuedJob } ); } 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 resetProcessLogIfLifecycleAllows(jobId, job); 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 }); await historyService.generateJobNfo(jobId, { mode: 'auto', requireSettingEnabled: true }).catch((nfoError) => { logger.warn('job:nfo:auto-generate-failed', { jobId, mode: 'converter', error: errorToMeta(nfoError) }); }); // 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, options = {}) { const expectedTrackCountRaw = Number(options?.trackCount); const expectedTrackCount = Number.isFinite(expectedTrackCountRaw) && expectedTrackCountRaw > 0 ? Math.trunc(expectedTrackCountRaw) : null; logger.info('musicbrainz:search', { query, expectedTrackCount }); const results = await musicBrainzService.searchByTitle(query, { trackCount: expectedTrackCount }); logger.info('musicbrainz:search:done', { query, expectedTrackCount, 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) || {}; const encodePlan = this.safeParseJson(job.encode_plan_json) || {}; const mediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: cdInfo, encodePlan }); if (mediaProfile !== 'cd') { const error = new Error(`Job ${jobId} ist kein Audio-CD-Job.`); error.statusCode = 400; throw error; } // 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 }; }); let effectiveCoverUrl = String(coverUrl || '').trim() || null; let shouldQueueCoverCache = false; if (effectiveCoverUrl && !thumbnailService.isLocalUrl(effectiveCoverUrl)) { try { const cacheResult = await historyService.cacheAndPromoteExternalPoster(jobId, effectiveCoverUrl, { source: 'Coverart', logFailures: false }); if (cacheResult?.ok && cacheResult?.localUrl) { effectiveCoverUrl = cacheResult.localUrl; } else { shouldQueueCoverCache = true; } } catch (_error) { shouldQueueCoverCache = true; } } const updatedCdInfo = { ...cdInfo, tracks: mergedTracks, selectedMetadata: { title, artist, year, mbId, coverUrl: effectiveCoverUrl } }; await historyService.updateJob(jobId, { title: title || null, year: year ? Number(year) : null, poster_url: effectiveCoverUrl || null, media_type: 'cd', job_kind: 'cd', status: 'CD_READY_TO_RIP', last_state: 'CD_READY_TO_RIP', makemkv_info_json: JSON.stringify(updatedCdInfo) }); // Fallback: async retry when sync cache was not successful. if (shouldQueueCoverCache && effectiveCoverUrl && !thumbnailService.isLocalUrl(effectiveCoverUrl)) { historyService.queuePosterCache(jobId, effectiveCoverUrl, { source: 'Coverart', logFailures: true }); } await historyService.appendLog( jobId, 'SYSTEM', `Metadaten gesetzt: "${title}" (${artist || '-'}, ${year || '-'}).` ); const resolvedDevicePath = String(job?.disc_device || '').trim() || null; const resolvedRawWavDir = String(job?.raw_path || '').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) { const isVirtualDevicePath = resolvedDevicePath.startsWith('__virtual__'); this._setCdDriveState(resolvedDevicePath, { state: 'CD_READY_TO_RIP', jobId, progress: 0, eta: null, statusText: isVirtualDevicePath ? 'CD bereit zum Encodieren' : 'CD bereit zum Rippen', context: { ...(existingDrive?.context || {}), jobId, mediaProfile: 'cd', tracks: mergedTracks, selectedMetadata: { title, artist, year, mbId, coverUrl: effectiveCoverUrl }, devicePath: resolvedDevicePath, cdparanoiaCmd: resolvedCdparanoiaCmd, cdparanoiaCommandPreview, ...(isVirtualDevicePath ? { virtualDrivePath: resolvedDevicePath, skipRip: true, rawWavDir: resolvedRawWavDir } : {}) } }); } else { // No real device — create/update virtual drive (orphan CD job) const virtualDrivePath = `__virtual__${jobId}`; const existingVirtualDrive = this.cdDrives.get(virtualDrivePath); 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: effectiveCoverUrl }, devicePath: null, virtualDrivePath, skipRip: true, rawWavDir: resolvedRawWavDir, cdparanoiaCmd: resolvedCdparanoiaCmd } }); } await historyService.appendLog( jobId, 'SYSTEM', 'Metadaten bestätigt. Rip/Encode kann jetzt manuell über den Start-Button ausgelöst werden.' ); 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) || {}; const selectedMetadata = resolveSelectedMetadataForJob(job, mkInfo?.analyzeContext || null, null); // 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 rawStorage = resolveSeriesAwareRawStorage( settings || {}, mediaProfile, selectedMetadata, mkInfo?.analyzeContext || null ); const rawBaseDir = String(rawStorage?.rawBaseDir || '').trim() || path.dirname(currentRawPath); const newMetadataBase = buildRawMetadataBase({ title: job.title || job.detected_title || null, year: job.year || null, detected_title: job.detected_title || null, media_type: mediaProfile, is_multipart_movie: Number(job?.is_multipart_movie || 0) === 1 ? 1 : 0, job_kind: job?.job_kind || null }, jobId, { mediaProfile, analyzeContext: mkInfo?.analyzeContext || null, selectedMetadata, isMultipartMovie: Number(job?.is_multipart_movie || 0) === 1 || String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_child' || String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container' }); 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 { let newOutputPath = isAudiobook ? buildAudiobookOutputConfig(settings, job, mkInfo, encodePlan, jobId).preferredFinalOutputPath : buildFinalOutputPathFromJob(settings, job, jobId); if (!isAudiobook && this.isMultipartMovieDiscChildHistoryJob(job)) { const containerJobId = resolveMultipartContainerJobIdFromJob(job); newOutputPath = buildIncompleteMergeOutputPathFromJob( settings, job, newOutputPath, jobId, { containerJobId } ); } 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 = Boolean(ripConfig?.createNewJob) && (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: null, 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, artist: effectiveSelectedMeta?.artist || null, year: effectiveSelectedMeta?.year || null }, activeJobId, { mediaProfile: 'cd', selectedMetadata: effectiveSelectedMeta, artist: effectiveSelectedMeta?.artist || null, album: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null, year: effectiveSelectedMeta?.year || null }); 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 } }); const isOrphanRawImportCdJob = String(cdInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; if (!skipRip && !isOrphanRawImportCdJob && !String(effectiveDevicePath || '').startsWith('__virtual__')) { this._acquireDriveLockForJob(effectiveDevicePath, activeJobId, { stage: 'CD_RIPPING', source: 'CDPARANOIA_RIP', mediaProfile: 'cd', reason: 'rip_running' }); } else { this._releaseDriveLockForJob(activeJobId, { reason: isOrphanRawImportCdJob ? 'orphan_raw_rip_bypass' : '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 || []); const preAutomationCount = preScriptIds.length + preChainIds.length; const postAutomationCount = postScriptIds.length + postChainIds.length; const preEncodeScriptsSummary = { configured: preAutomationCount, attempted: 0, succeeded: 0, failed: 0, skipped: 0, queued: preAutomationCount > 0, detachedQueue: preAutomationCount > 0, results: [] }; let postEncodeScriptsSummary = { configured: postAutomationCount, attempted: 0, succeeded: 0, failed: 0, skipped: 0, queued: postAutomationCount > 0, detachedQueue: postAutomationCount > 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 (preAutomationCount > 0) { await historyService.appendLog( jobId, 'SYSTEM', `Pre-Rip Automationen laufen als eigene Queue-Einträge (Pre=${preAutomationCount}).` ); } let encodeStateApplied = false; let lastRipProgressPercent = 0; let lastEncodeProgressPercent = 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 (normalizedPhase === 'rip') { if (clampedPercent < lastRipProgressPercent) { clampedPercent = lastRipProgressPercent; } lastRipProgressPercent = clampedPercent; } else { if (clampedPercent < lastEncodeProgressPercent) { clampedPercent = lastEncodeProgressPercent; } lastEncodeProgressPercent = clampedPercent; } clampedPercent = Number(clampedPercent.toFixed(2)); 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 fallbackPercentRaw = Math.max(0, Math.min(100, Number(progressPercent) || 0)); const fallbackStage = currentPhase === 'encode' ? 'CD_ENCODING' : 'CD_RIPPING'; const fallbackPercent = currentPhase === 'encode' ? Math.max(lastEncodeProgressPercent, fallbackPercentRaw) : Math.max(lastRipProgressPercent, fallbackPercentRaw); if (currentPhase === 'encode') { lastEncodeProgressPercent = fallbackPercent; } else { lastRipProgressPercent = fallbackPercent; } 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(); postEncodeScriptsSummary = { ...this.buildPostEncodeScriptsSummaryPlaceholder(normalizedEncodePlan), pending: false, queued: postAutomationCount > 0, detachedQueue: postAutomationCount > 0 }; // 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)); } } if (postAutomationCount > 0) { await historyService.appendLog( jobId, 'SYSTEM', `Post-Rip Automationen laufen als eigene Queue-Einträge (Post=${postAutomationCount}).` ); } } 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; TMDb-Zuordnung ist jederzeit nachträglich möglich.' ); } 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_LOOKUP', '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 selectedFromMetadata = 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 = selectedFromMetadata; } 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 liveProgressRaw = this.jobProgress.get(Number(row?.id)); const liveProgress = liveProgressRaw && typeof liveProgressRaw === 'object' ? this.safeParseJson(JSON.stringify(liveProgressRaw)) : null; 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, liveProgress }; }); } /** * 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();