const fs = require('fs');
const path = require('path');
const { EventEmitter } = require('events');
const { execFile } = require('child_process');
const { getDb } = require('../db/database');
const settingsService = require('./settingsService');
const historyService = require('./historyService');
const omdbService = require('./omdbService');
const 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 } = require('../utils/progressParsers');
const { ensureDir, sanitizeFileName, sanitizeFileNameWithExtension, renderTemplate, findMediaFiles } = require('../utils/files');
const { buildMediainfoReview } = require('../utils/encodePlan');
const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis');
const { errorToMeta } = require('../utils/errorMeta');
const userPresetService = require('./userPresetService');
const thumbnailService = require('./thumbnailService');
const activationBytesService = require('./activationBytesService');

const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']);
const REVIEW_REFRESH_SETTING_PREFIXES = [
  'handbrake_',
  'mediainfo_',
  'makemkv_rip_',
  'makemkv_analyze_',
  'output_extension_',
  'output_template_'
];
const REVIEW_REFRESH_SETTING_KEYS = new Set([
  'makemkv_min_length_minutes',
  'handbrake_preset',
  'handbrake_extra_args',
  'mediainfo_extra_args',
  'makemkv_rip_mode',
  'makemkv_analyze_extra_args',
  'makemkv_rip_extra_args',
  'output_extension',
  'output_template'
]);
const QUEUE_ACTIONS = {
  START_PREPARED: 'START_PREPARED',
  START_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 SUBTITLE_CONFIDENCE_SCORES = Object.freeze({
  low: 1,
  medium: 2,
  high: 3
});
const FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD = 0.35;
const FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD = 0.35;
const FORCED_SUBTITLE_MAX_EVENT_COUNT = 220;
const FORCED_SUBTITLE_MIN_EVENT_GAP = 12;
const FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES = 64 * 1024;
const CONVERTER_SUPPORTED_SCAN_EXTENSIONS = Object.freeze([
  'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov',
  'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
]);
const CONVERTER_SUPPORTED_SCAN_EXTENSION_SET = new Set(CONVERTER_SUPPORTED_SCAN_EXTENSIONS);

function nowIso() {
  return new Date().toISOString();
}

function normalizeCdTrackText(value) {
  return String(value || '')
    .normalize('NFC')
    // Keep umlauts/special letters, but strip heart symbols from imported metadata.
    .replace(/[♥❤♡❥❣❦❧]/gu, ' ')
    .replace(/\p{C}+/gu, ' ')
    .replace(/\s+/g, ' ')
    .trim();
}

function normalizePositiveInteger(value) {
  const parsed = Number(value);
  if (!Number.isFinite(parsed) || parsed <= 0) {
    return null;
  }
  return Math.trunc(parsed);
}

function resolveSelectedMetadataForJob(job = null, analyzeContext = null, activeContext = null) {
  const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
    ? analyzeContext.selectedMetadata
    : {};
  const activeSelectedMetadata = activeContext?.selectedMetadata && typeof activeContext.selectedMetadata === 'object'
    ? activeContext.selectedMetadata
    : {};
  const merged = {
    ...analyzeSelectedMetadata,
    ...activeSelectedMetadata
  };

  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,
    metadataProvider: String(
      merged.metadataProvider
      || analyzeContext?.metadataProvider
      || activeContext?.metadataProvider
      || 'omdb'
    ).trim().toLowerCase() || 'omdb'
  };
}

function isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata = {}, analyzeContext = null) {
  if (normalizeMediaProfile(mediaProfile) !== 'dvd') {
    return false;
  }

  const metadata = selectedMetadata && typeof selectedMetadata === 'object'
    ? selectedMetadata
    : {};
  const provider = String(
    metadata.metadataProvider
    || analyzeContext?.metadataProvider
    || ''
  ).trim().toLowerCase();

  const metadataKind = String(metadata.metadataKind || '').trim().toLowerCase();
  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 tmdbId = Number(metadata.tmdbId || 0) || null;
  const episodeCount = Number(metadata.episodeCount || 0) || 0;
  const hasEpisodeList = Array.isArray(metadata.episodes) && metadata.episodes.length > 0;
  const hasSeriesLookupHint = Boolean(
    String(analyzeContext?.seriesLookupHint?.query || '').trim()
    || Number(analyzeContext?.seriesLookupHint?.seasonNumber || 0) > 0
  );
  const seriesLikeByAnalysis = 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
    || tmdbId !== null
    || 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 seriesRawCandidate = String(
    settings?.series_raw_dir
    || settings?.raw_dir_dvd_series
    || ''
  ).trim();
  const seriesRawOwnerCandidate = String(
    settings?.series_raw_dir_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') {
    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}';

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 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 = mediaProfile === 'dvd'
    && (
      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 seasonNumber = normalizePositiveInteger(
    assignment?.seasonNumber
    ?? selectedTitle?.seasonNumber
    ?? selectedMetadata?.seasonNumber
    ?? analyzeContext?.seriesLookupHint?.seasonNumber
    ?? null
  ) || 1;
  const episodeTemplateValue = normalizeTemplateNumberValue(
    assignment?.episodeNumber
    ?? selectedTitle?.episodeNumber
    ?? null,
    primaryTitleId || 1
  );
  const discNumber = normalizePositiveInteger(
    selectedMetadata?.discNumber
    ?? assignment?.discNumber
    ?? analyzeContext?.seriesLookupHint?.discNumber
    ?? null
  );
  const seriesTitle = normalizeCdTrackText(
    selectedMetadata?.title
    || selectedMetadata?.seriesTitle
    || job?.title
    || job?.detected_title
    || (fallbackJobId ? `job-${fallbackJobId}` : 'series')
  ) || (fallbackJobId ? `job-${fallbackJobId}` : 'series');
  const episodeTitle = normalizeCdTrackText(
    assignment?.episodeTitle
    || selectedTitle?.episodeTitle
    || selectedTitle?.fileName
    || `Episode ${String(episodeTemplateValue)}`
  ) || `Episode ${String(episodeTemplateValue)}`;
  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 template = String(
    settings?.output_template_dvd_series_episode
    || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE
  ).trim() || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE;
  const rendered = renderTemplate(template, {
    seriesTitle,
    seasonNr: seasonNumber,
    seasonNo: String(seasonNumber),
    episodeNr: episodeTemplateValue,
    episodeNo: String(episodeTemplateValue),
    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 buildFinalOutputPathFromJob(settings, job, fallbackJobId = null) {
  const seriesParts = resolveSeriesOutputPathParts(settings, job, fallbackJobId);
  if (seriesParts?.rootDir) {
    if (seriesParts.folderPath) {
      return path.join(seriesParts.rootDir, seriesParts.folderPath, `${seriesParts.baseName}.${seriesParts.ext}`);
    }
    return path.join(seriesParts.rootDir, `${seriesParts.baseName}.${seriesParts.ext}`);
  }
  const movieDir = settings.movie_dir;
  const values = resolveOutputTemplateValues(job, fallbackJobId);
  const { folderPath, baseName } = resolveOutputPathParts(settings, values);
  const ext = String(settings.output_extension || 'mkv').trim() || 'mkv';
  if (folderPath) {
    return path.join(movieDir, folderPath, `${baseName}.${ext}`);
  }
  return path.join(movieDir, `${baseName}.${ext}`);
}

function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) {
  const 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 = seriesParts?.baseName || defaultBaseName;
  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 ensureUniqueOutputPath(outputPath) {
  if (!fs.existsSync(outputPath)) {
    return outputPath;
  }

  let stat = null;
  try {
    stat = fs.statSync(outputPath);
  } catch (_error) {
    stat = null;
  }
  const isDirectory = Boolean(stat?.isDirectory?.());

  // Use sequential numbering (_2, _3, ...) for directories and parent-folder of files
  if (isDirectory) {
    for (let i = 2; i < 200; i++) {
      const attempt = `${outputPath}_${i}`;
      if (!fs.existsSync(attempt)) return attempt;
    }
  } else {
    const parentDir = path.dirname(outputPath);
    const fileName = path.basename(outputPath);
    for (let i = 2; i < 200; i++) {
      const attempt = path.join(`${parentDir}_${i}`, fileName);
      if (!fs.existsSync(attempt)) return attempt;
    }
  }

  // Fallback to timestamp if sequential limit exceeded
  const ts = fileTimestamp();
  return isDirectory
    ? withTimestampSuffix(outputPath, ts)
    : withTimestampBeforeExtension(outputPath, ts);
}

function chownRecursive(targetPath, ownerSpec) {
  const spec = String(ownerSpec || '').trim();
  if (!spec || !targetPath) {
    return;
  }
  try {
    const { spawnSync } = require('child_process');
    const result = spawnSync('chown', ['-R', spec, targetPath], { timeout: 15000 });
    if (result.status !== 0) {
      logger.warn('chown:failed', { targetPath, spec, stderr: String(result.stderr || '') });
    }
  } catch (error) {
    logger.warn('chown:error', { targetPath, spec, error: error?.message });
  }
}

function moveFileWithFallback(sourcePath, targetPath) {
  try {
    fs.renameSync(sourcePath, targetPath);
  } catch (error) {
    if (error?.code !== 'EXDEV') {
      throw error;
    }
    fs.copyFileSync(sourcePath, targetPath);
    fs.unlinkSync(sourcePath);
  }
}

function movePathWithFallback(sourcePath, targetPath) {
  try {
    fs.renameSync(sourcePath, targetPath);
  } catch (error) {
    if (error?.code !== 'EXDEV') {
      throw error;
    }
    const stat = fs.statSync(sourcePath);
    if (stat.isDirectory()) {
      fs.cpSync(sourcePath, targetPath, { recursive: true });
      fs.rmSync(sourcePath, { recursive: true, force: true });
      return;
    }
    fs.copyFileSync(sourcePath, targetPath);
    fs.unlinkSync(sourcePath);
  }
}

function removeDirectoryIfEmpty(directoryPath) {
  try {
    const entries = fs.readdirSync(directoryPath);
    if (entries.length === 0) {
      fs.rmdirSync(directoryPath);
    }
  } catch (_error) {
    // Best effort cleanup.
  }
}

function finalizeOutputPathForCompletedEncode(incompleteOutputPath, preferredFinalOutputPath) {
  const sourcePath = String(incompleteOutputPath || '').trim();
  if (!sourcePath) {
    throw new Error('Encode-Finalisierung fehlgeschlagen: temporärer Output-Pfad fehlt.');
  }
  if (!fs.existsSync(sourcePath)) {
    throw new Error(`Encode-Finalisierung fehlgeschlagen: temporäre Datei fehlt (${sourcePath}).`);
  }

  const plannedTargetPath = String(preferredFinalOutputPath || '').trim();
  if (!plannedTargetPath) {
    throw new Error('Encode-Finalisierung fehlgeschlagen: finaler Output-Pfad fehlt.');
  }

  const sourceResolved = path.resolve(sourcePath);
  const targetPath = ensureUniqueOutputPath(plannedTargetPath);
  const targetResolved = path.resolve(targetPath);
  const outputPathWithTimestamp = targetPath !== plannedTargetPath;

  if (sourceResolved === targetResolved) {
    return {
      outputPath: targetPath,
      outputPathWithTimestamp
    };
  }

  ensureDir(path.dirname(targetPath));
  movePathWithFallback(sourcePath, targetPath);
  removeDirectoryIfEmpty(path.dirname(sourcePath));

  return {
    outputPath: targetPath,
    outputPathWithTimestamp
  };
}

function buildAudiobookMetadataForJob(job, makemkvInfo = null, encodePlan = null) {
  const mkInfo = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {};
  const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
  const metadataSource = plan?.metadata && typeof plan.metadata === 'object'
    ? plan.metadata
    : (
      mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object'
        ? mkInfo.selectedMetadata
        : (mkInfo?.detectedMetadata && typeof mkInfo.detectedMetadata === 'object' ? mkInfo.detectedMetadata : {})
    );
  const title = String(metadataSource?.title || job?.title || job?.detected_title || 'Audiobook').trim() || 'Audiobook';
  const durationMs = Number.isFinite(Number(metadataSource?.durationMs))
    ? Number(metadataSource.durationMs)
    : 0;
  const chaptersSource = Array.isArray(metadataSource?.chapters)
    ? metadataSource.chapters
    : (Array.isArray(mkInfo?.chapters) ? mkInfo.chapters : []);
  const chapters = audiobookService.normalizeChapterList(chaptersSource, {
    durationMs,
    fallbackTitle: title,
    createFallback: false
  });

  return {
    title,
    author: String(metadataSource?.author || metadataSource?.artist || '').trim() || null,
    asin: String(metadataSource?.asin || '').trim() || null,
    chapterSource: String(metadataSource?.chapterSource || '').trim() || null,
    narrator: String(metadataSource?.narrator || '').trim() || null,
    description: String(metadataSource?.description || '').trim() || null,
    series: String(metadataSource?.series || '').trim() || null,
    part: String(metadataSource?.part || '').trim() || null,
    year: Number.isFinite(Number(metadataSource?.year))
      ? Math.trunc(Number(metadataSource.year))
      : (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : null),
    durationMs,
    chapters,
    poster: String(metadataSource?.poster || job?.poster_url || '').trim() || null
  };
}

function buildAudiobookOutputConfig(settings, job, makemkvInfo = null, encodePlan = null, fallbackJobId = null) {
  const metadata = buildAudiobookMetadataForJob(job, makemkvInfo, encodePlan);
  const movieDir = String(
    settings?.movie_dir
    || settings?.raw_dir
    || settingsService.DEFAULT_AUDIOBOOK_DIR
    || settingsService.DEFAULT_AUDIOBOOK_RAW_DIR
    || ''
  ).trim();
  const outputTemplate = String(
    settings?.output_template
    || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE
  ).trim() || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE;
  const chapterOutputTemplate = String(
    settings?.output_chapter_template_audiobook
    || audiobookService.DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE
  ).trim() || audiobookService.DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE;
  const outputFormat = audiobookService.normalizeOutputFormat(
    encodePlan?.format || 'm4b'
  );
  const numericJobId = Number(fallbackJobId || job?.id || 0);
  const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0
    ? `Incomplete_job-${numericJobId}`
    : 'Incomplete_job-unknown';
  const incompleteBaseDir = path.join(movieDir, incompleteFolder);

  if (outputFormat === 'm4b') {
    const preferredFinalOutputPath = audiobookService.buildOutputPath(
      metadata,
      movieDir,
      outputTemplate,
      outputFormat
    );
    const incompleteOutputPath = path.join(incompleteBaseDir, path.basename(preferredFinalOutputPath));
    return {
      metadata,
      outputFormat,
      preferredFinalOutputPath,
      incompleteOutputPath,
      preferredChapterPlan: null,
      incompleteChapterPlan: null
    };
  }

  const preferredChapterPlan = audiobookService.buildChapterOutputPlan(
    metadata,
    metadata.chapters,
    movieDir,
    chapterOutputTemplate,
    outputFormat
  );
  const incompleteChapterPlan = audiobookService.buildChapterOutputPlan(
    metadata,
    preferredChapterPlan.chapters,
    incompleteBaseDir,
    chapterOutputTemplate,
    outputFormat
  );

  return {
    metadata: {
      ...metadata,
      chapters: preferredChapterPlan.chapters
    },
    outputFormat,
    preferredFinalOutputPath: preferredChapterPlan.outputDir,
    incompleteOutputPath: incompleteChapterPlan.outputDir,
    preferredChapterPlan,
    incompleteChapterPlan
  };
}

function truncateLine(value, max = 180) {
  const raw = String(value || '').replace(/\s+/g, ' ').trim();
  if (raw.length <= max) {
    return raw;
  }
  return `${raw.slice(0, max)}...`;
}

function appendTailText(currentValue, nextChunk, maxChars = 12000) {
  const chunk = String(nextChunk || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
  if (!chunk) {
    return {
      value: currentValue || '',
      truncated: false
    };
  }
  const normalizedChunk = chunk.endsWith('\n') ? chunk : `${chunk}\n`;
  const combined = `${String(currentValue || '')}${normalizedChunk}`;
  if (combined.length <= maxChars) {
    return {
      value: combined,
      truncated: false
    };
  }
  return {
    value: combined.slice(-maxChars),
    truncated: true
  };
}

function extractProgressDetail(source, line) {
  const text = truncateLine(line, 220);
  if (!text) {
    return null;
  }

  if (source.startsWith('MAKEMKV')) {
    const prgc = text.match(/^PRGC:\d+,\d+,\"([^\"]+)\"/i);
    if (prgc) {
      return truncateLine(prgc[1], 160);
    }
    if (/Title\s+#?\d+/i.test(text)) {
      return text;
    }
    if (/copying|saving|writing|decrypt/i.test(text)) {
      return text;
    }
    if (/operation|progress|processing/i.test(text)) {
      return text;
    }
  }

  if (source === 'HANDBRAKE') {
    if (/Encoding:\s*task/i.test(text)) {
      return text;
    }
    if (/Muxing|work result|subtitle scan|frame/i.test(text)) {
      return text;
    }
  }

  return null;
}

function composeStatusText(stage, percent, detail) {
  const base = percent !== null && percent !== undefined
    ? `${stage} ${percent.toFixed(2)}%`
    : stage;

  if (detail) {
    return `${base} - ${detail}`;
  }

  return base;
}

function clampProgressPercent(value) {
  const parsed = Number(value);
  if (!Number.isFinite(parsed)) {
    return null;
  }
  return Math.max(0, Math.min(100, parsed));
}

function composeEncodeScriptStatusText(percent, phase, itemType, index, total, label, statusWord = null) {
  const phaseLabel = phase === 'pre' ? 'Pre-Encode' : 'Post-Encode';
  const itemLabel = itemType === 'chain' ? 'Kette' : 'Skript';
  const position = Number.isFinite(index) && Number.isFinite(total) && total > 0
    ? ` ${index}/${total}`
    : '';
  const status = statusWord ? ` ${statusWord}` : '';
  const detail = String(label || '').trim();
  return `ENCODING ${percent.toFixed(2)}% - ${phaseLabel} ${itemLabel}${position}${status}${detail ? `: ${detail}` : ''}`;
}

function parseMakeMkvMessageCode(line) {
  const match = String(line || '').match(/\bMSG:(\d+),/i);
  if (!match) {
    return null;
  }
  const code = Number(match[1]);
  if (!Number.isFinite(code)) {
    return null;
  }
  return Math.trunc(code);
}

function isMakeMkvBackupFailureMarker(line) {
  const text = String(line || '').trim();
  if (!text) {
    return false;
  }
  const code = parseMakeMkvMessageCode(text);
  if (code !== null && MAKEMKV_BACKUP_FAILURE_MSG_CODES.has(code)) {
    return true;
  }
  return /backup\s+failed/i.test(text) || /backup\s+fehlgeschlagen/i.test(text);
}

function findMakeMkvBackupFailureMarker(lines) {
  if (!Array.isArray(lines)) {
    return null;
  }
  return lines.find((line) => isMakeMkvBackupFailureMarker(line)) || null;
}

function createEncodeScriptProgressTracker({
  jobId,
  preSteps = 0,
  postSteps = 0,
  updateProgress
}) {
  const preTotal = Math.max(0, Math.trunc(Number(preSteps) || 0));
  const postTotal = Math.max(0, Math.trunc(Number(postSteps) || 0));
  const hasPre = preTotal > 0;
  const hasPost = postTotal > 0;
  const preReserve = hasPre ? PRE_ENCODE_PROGRESS_RESERVE : 0;
  const postReserve = hasPost ? POST_ENCODE_PROGRESS_RESERVE : 0;
  const finalPercentBeforeFinish = hasPost ? (100 - POST_ENCODE_FINISH_BUFFER) : 100;
  const handBrakeStart = preReserve;
  const handBrakeEnd = Math.max(handBrakeStart, finalPercentBeforeFinish - postReserve);

  let preCompleted = 0;
  let postCompleted = 0;

  const clampPhasePercent = (value) => {
    const clamped = clampProgressPercent(value);
    if (clamped === null) {
      return 0;
    }
    return Number(clamped.toFixed(2));
  };

  const calculatePrePercent = () => {
    if (preTotal <= 0) {
      return clampPhasePercent(handBrakeStart);
    }
    return clampPhasePercent((preCompleted / preTotal) * preReserve);
  };

  const calculatePostPercent = () => {
    if (postTotal <= 0) {
      return clampPhasePercent(handBrakeEnd);
    }
    return clampPhasePercent(handBrakeEnd + ((postCompleted / postTotal) * postReserve));
  };

  const callProgress = async (percent, statusText) => {
    if (typeof updateProgress !== 'function') {
      return;
    }
    await updateProgress('ENCODING', percent, null, statusText, jobId);
  };

  return {
    hasScriptSteps: hasPre || hasPost,
    handBrakeStart,
    handBrakeEnd,

    mapHandBrakePercent(percent) {
      if (!this.hasScriptSteps) {
        return percent;
      }
      const normalized = clampProgressPercent(percent);
      if (normalized === null) {
        return percent;
      }
      const ratio = normalized / 100;
      return clampPhasePercent(handBrakeStart + ((handBrakeEnd - handBrakeStart) * ratio));
    },

    async onStepStart(phase, itemType, index, total, label) {
      if (phase === 'pre' && preTotal <= 0) {
        return;
      }
      if (phase === 'post' && postTotal <= 0) {
        return;
      }
      const percent = phase === 'pre'
        ? calculatePrePercent()
        : calculatePostPercent();
      await callProgress(percent, composeEncodeScriptStatusText(percent, phase, itemType, index, total, label, 'startet'));
    },

    async onStepComplete(phase, itemType, index, total, label, success = true) {
      if (phase === 'pre' && preTotal <= 0) {
        return;
      }
      if (phase === 'post' && postTotal <= 0) {
        return;
      }

      if (phase === 'pre') {
        preCompleted = Math.min(preTotal, preCompleted + 1);
      } else {
        postCompleted = Math.min(postTotal, postCompleted + 1);
      }

      const percent = phase === 'pre'
        ? calculatePrePercent()
        : calculatePostPercent();
      await callProgress(
        percent,
        composeEncodeScriptStatusText(
          percent,
          phase,
          itemType,
          index,
          total,
          label,
          success ? 'OK' : 'Fehler'
        )
      );
    }
  };
}

function shouldKeepHighlight(line) {
  return /error|fail|warn|fehl|title\s+#|saving|encoding:|muxing|copying|decrypt/i.test(line)
    || isMakeMkvBackupFailureMarker(line);
}

function normalizeNonNegativeInteger(rawValue) {
  if (rawValue === null || rawValue === undefined) {
    return null;
  }
  if (typeof rawValue === 'string' && rawValue.trim() === '') {
    return null;
  }
  const parsed = Number(rawValue);
  if (!Number.isFinite(parsed) || parsed < 0) {
    return null;
  }
  return Math.trunc(parsed);
}

function parseDetectedTitle(lines) {
  const candidates = [];
  const blockedPatterns = [
    /evaluierungsversion/i,
    /evaluation version/i,
    /es verbleiben noch/i,
    /days remaining/i,
    /makemkv/i,
    /www\./i,
    /beta/i
  ];

  const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();

  for (const line of lines) {
    const cinfoMatch = line.match(/CINFO:2,0,"([^"]+)"/i);
    if (cinfoMatch) {
      candidates.push(cinfoMatch[1]);
    }

    const tinfoMatch = line.match(/TINFO:\d+,2,\d+,"([^"]+)"/i);
    if (tinfoMatch) {
      candidates.push(tinfoMatch[1]);
    }
  }

  const clean = candidates
    .map(normalize)
    .filter((value) => value.length > 2 && !value.startsWith('/'))
    .filter((value) => !blockedPatterns.some((pattern) => pattern.test(value)))
    .filter((value) => !/^disc\s*\d*$/i.test(value))
    .filter((value) => !/^unknown/i.test(value));

  if (clean.length === 0) {
    return null;
  }

  clean.sort((a, b) => b.length - a.length);
  return clean[0];
}

function parseMediainfoJsonOutput(rawOutput) {
  const text = String(rawOutput || '').trim();
  if (!text) {
    return null;
  }

  const extractJsonObjects = (value) => {
    const source = String(value || '');
    const objects = [];
    let start = -1;
    let depth = 0;
    let inString = false;
    let escaped = false;
    for (let i = 0; i < source.length; i += 1) {
      const ch = source[i];
      if (inString) {
        if (escaped) {
          escaped = false;
        } else if (ch === '\\') {
          escaped = true;
        } else if (ch === '"') {
          inString = false;
        }
        continue;
      }

      if (ch === '"') {
        inString = true;
        continue;
      }
      if (ch === '{') {
        if (depth === 0) {
          start = i;
        }
        depth += 1;
        continue;
      }
      if (ch === '}' && depth > 0) {
        depth -= 1;
        if (depth === 0 && start >= 0) {
          objects.push(source.slice(start, i + 1));
          start = -1;
        }
      }
    }
    return objects;
  };

  const parsedObjects = [];
  const rawObjects = extractJsonObjects(text);
  for (const candidate of rawObjects) {
    try {
      parsedObjects.push(JSON.parse(candidate));
    } catch (_error) {
      // ignore malformed blocks and continue
    }
  }

  if (parsedObjects.length === 0) {
    try {
      return JSON.parse(text);
    } catch (_error) {
      return null;
    }
  }

  const hasTitleList = (entry) =>
    Array.isArray(entry?.TitleList)
    || Array.isArray(entry?.Scan?.TitleList)
    || Array.isArray(entry?.title_list);

  const hasMediaTrack = (entry) =>
    Array.isArray(entry?.media?.track)
    || Array.isArray(entry?.Media?.track);

  const getTitleList = (entry) => {
    if (Array.isArray(entry?.TitleList)) {
      return entry.TitleList;
    }
    if (Array.isArray(entry?.Scan?.TitleList)) {
      return entry.Scan.TitleList;
    }
    if (Array.isArray(entry?.title_list)) {
      return entry.title_list;
    }
    return [];
  };

  const titleSets = parsedObjects
    .map((entry, index) => ({ entry, index }))
    .filter(({ entry }) => hasTitleList(entry))
    .map(({ entry, index }) => {
      const titles = getTitleList(entry);
      let audioTracks = 0;
      let subtitleTracks = 0;
      let validAudioTracks = 0;
      let validSubtitleTracks = 0;

      for (const title of titles) {
        const audioList = Array.isArray(title?.AudioList) ? title.AudioList : [];
        const subtitleList = Array.isArray(title?.SubtitleList) ? title.SubtitleList : [];
        audioTracks += audioList.length;
        subtitleTracks += subtitleList.length;
        validAudioTracks += audioList.filter((track) => Number.isFinite(Number(track?.TrackNumber)) && Number(track.TrackNumber) > 0).length;
        validSubtitleTracks += subtitleList.filter((track) => Number.isFinite(Number(track?.TrackNumber)) && Number(track.TrackNumber) > 0).length;
      }

      return {
        entry,
        index,
        titleCount: titles.length,
        audioTracks,
        subtitleTracks,
        validAudioTracks,
        validSubtitleTracks
      };
    });

  if (titleSets.length > 0) {
    titleSets.sort((a, b) =>
      b.validAudioTracks - a.validAudioTracks
      || b.validSubtitleTracks - a.validSubtitleTracks
      || b.audioTracks - a.audioTracks
      || b.subtitleTracks - a.subtitleTracks
      || b.titleCount - a.titleCount
      || b.index - a.index
    );
    return titleSets[0].entry;
  }

  const mediaSets = parsedObjects
    .map((entry, index) => ({ entry, index }))
    .filter(({ entry }) => hasMediaTrack(entry))
    .map(({ entry, index }) => {
      const tracks = Array.isArray(entry?.media?.track)
        ? entry.media.track
        : (Array.isArray(entry?.Media?.track) ? entry.Media.track : []);
      return {
        entry,
        index,
        trackCount: tracks.length
      };
    });

  if (mediaSets.length > 0) {
    mediaSets.sort((a, b) => b.trackCount - a.trackCount || b.index - a.index);
    return mediaSets[0].entry;
  }

  return parsedObjects[parsedObjects.length - 1] || null;
}

function getMediaInfoTrackList(mediaInfoJson) {
  if (Array.isArray(mediaInfoJson?.media?.track)) {
    return mediaInfoJson.media.track;
  }
  if (Array.isArray(mediaInfoJson?.Media?.track)) {
    return mediaInfoJson.Media.track;
  }
  return [];
}

function countMediaInfoTrackTypes(mediaInfoJson) {
  const tracks = getMediaInfoTrackList(mediaInfoJson);
  let audioCount = 0;
  let subtitleCount = 0;
  for (const track of tracks) {
    const type = String(track?.['@type'] || '').trim().toLowerCase();
    if (type === 'audio') {
      audioCount += 1;
      continue;
    }
    if (type === 'text' || type === 'subtitle') {
      subtitleCount += 1;
    }
  }
  return {
    audioCount,
    subtitleCount
  };
}

function shouldRunDvdTrackFallback(parsedMediaInfo, mediaProfile, inputPath) {
  if (normalizeMediaProfile(mediaProfile) !== 'dvd') {
    return false;
  }
  if (path.extname(String(inputPath || '')).toLowerCase() !== '') {
    return false;
  }
  const counts = countMediaInfoTrackTypes(parsedMediaInfo);
  return counts.audioCount === 0 && counts.subtitleCount === 0;
}

function parseHmsDurationToSeconds(raw) {
  const value = String(raw || '').trim();
  if (!value) {
    return 0;
  }
  const match = value.match(/^(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?$/);
  if (!match) {
    return 0;
  }
  const hours = Number(match[1]);
  const minutes = Number(match[2]);
  const seconds = Number(match[3]);
  if (!Number.isFinite(hours) || !Number.isFinite(minutes) || !Number.isFinite(seconds)) {
    return 0;
  }
  return (hours * 3600) + (minutes * 60) + seconds;
}

function parseHandBrakeDurationSeconds(rawDuration) {
  if (rawDuration && typeof rawDuration === 'object') {
    const hours = Number(rawDuration.Hours ?? rawDuration.hours ?? 0);
    const minutes = Number(rawDuration.Minutes ?? rawDuration.minutes ?? 0);
    const seconds = Number(rawDuration.Seconds ?? rawDuration.seconds ?? 0);
    if (Number.isFinite(hours) && Number.isFinite(minutes) && Number.isFinite(seconds)) {
      return Math.max(0, Math.trunc((hours * 3600) + (minutes * 60) + seconds));
    }
  }

  const parsedHms = parseHmsDurationToSeconds(rawDuration);
  if (parsedHms > 0) {
    return parsedHms;
  }

  const asNumber = Number(rawDuration);
  if (Number.isFinite(asNumber) && asNumber > 0) {
    return Math.max(0, Math.trunc(asNumber));
  }

  return 0;
}

function formatDurationClock(seconds) {
  const total = Number(seconds || 0);
  if (!Number.isFinite(total) || total <= 0) {
    return null;
  }
  const rounded = Math.max(0, Math.trunc(total));
  const h = Math.floor(rounded / 3600);
  const m = Math.floor((rounded % 3600) / 60);
  const s = rounded % 60;
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}

function normalizeTrackLanguage(raw) {
  const value = String(raw || '').trim();
  if (!value) {
    return 'und';
  }
  return value.toLowerCase().slice(0, 3);
}

function normalizePositiveTrackId(rawValue) {
  const parsed = Number(rawValue);
  if (!Number.isFinite(parsed) || parsed <= 0) {
    return null;
  }
  return Math.trunc(parsed);
}

function normalizeSubtitleConfidence(raw) {
  const value = String(raw || '').trim().toLowerCase();
  if (value === 'high' || value === 'medium' || value === 'low') {
    return value;
  }
  return 'low';
}

function subtitleConfidenceScore(raw) {
  const normalized = normalizeSubtitleConfidence(raw);
  return SUBTITLE_CONFIDENCE_SCORES[normalized] || 0;
}

function parseSubtitleEventCount(track) {
  const candidates = [
    track?.eventCount,
    track?.EventCount,
    track?.countOfEvents,
    track?.CountOfEvents,
    track?.elementCount,
    track?.ElementCount
  ];
  for (const candidate of candidates) {
    const numeric = Number(candidate);
    if (Number.isFinite(numeric) && numeric >= 0) {
      return Math.trunc(numeric);
    }
  }
  return null;
}

function parseSubtitleStreamSizeBytes(track) {
  const numericCandidates = [
    track?.streamSizeBytes,
    track?.streamSize,
    track?.StreamSize,
    track?.sizeBytes,
    track?.bytes,
    track?.Bytes
  ];
  for (const candidate of numericCandidates) {
    const numeric = Number(candidate);
    if (Number.isFinite(numeric) && numeric > 0) {
      return Math.trunc(numeric);
    }
  }

  const textCandidates = [
    track?.streamSizeString,
    track?.StreamSize_String,
    track?.sizeString,
    track?.Size_String
  ];
  for (const candidate of textCandidates) {
    const parsed = parseSizeToBytes(candidate);
    if (parsed > 0) {
      return parsed;
    }
  }
  return null;
}

function parseSubtitleDefaultFlag(track) {
  if (typeof track?.defaultFlag === 'boolean') {
    return track.defaultFlag;
  }
  const candidates = [
    track?.default,
    track?.Default,
    track?.isDefault,
    track?.IsDefault,
    track?.subtitlePreviewDefaultTrack,
    track?.defaultTrack
  ];
  for (const candidate of candidates) {
    if (typeof candidate === 'boolean') {
      return candidate;
    }
    const raw = String(candidate || '').trim().toLowerCase();
    if (!raw) {
      continue;
    }
    if (raw === 'yes' || raw === 'true' || raw === '1') {
      return true;
    }
    if (raw === 'no' || raw === 'false' || raw === '0') {
      return false;
    }
  }
  return false;
}

function parseLooseBoolean(value) {
  if (typeof value === 'boolean') {
    return value;
  }
  if (typeof value === 'number') {
    if (value === 1) {
      return true;
    }
    if (value === 0) {
      return false;
    }
  }
  const raw = String(value || '').trim().toLowerCase();
  if (!raw) {
    return null;
  }
  if (raw === 'yes' || raw === 'true' || raw === '1' || raw === 'y') {
    return true;
  }
  if (raw === 'no' || raw === 'false' || raw === '0' || raw === 'n') {
    return false;
  }
  return null;
}

function parseSubtitleForcedFlag(track) {
  if (!track || typeof track !== 'object') {
    return null;
  }
  const candidates = [
    track?.forcedFlag,
    track?.forced,
    track?.Forced,
    track?.isForced,
    track?.IsForced,
    track?.forced_only,
    track?.forcedOnly,
    track?.Attributes?.Forced
  ];
  for (const candidate of candidates) {
    const parsed = parseLooseBoolean(candidate);
    if (parsed === true || parsed === false) {
      return parsed;
    }
  }
  return null;
}

function parseSubtitleSdhFlag(track) {
  if (!track || typeof track !== 'object') {
    return null;
  }
  const candidates = [
    track?.sdhFlag,
    track?.hearingImpaired,
    track?.HearingImpaired,
    track?.isHearingImpaired,
    track?.IsHearingImpaired,
    track?.Hearing_Impaired,
    track?.closedCaptions,
    track?.ClosedCaptions,
    track?.Attributes?.HearingImpaired,
    track?.Attributes?.ClosedCaptions
  ];
  for (const candidate of candidates) {
    const parsed = parseLooseBoolean(candidate);
    if (parsed === true || parsed === false) {
      return parsed;
    }
  }
  return null;
}

function collectSubtitleText(track) {
  return [
    track?.title,
    track?.description,
    track?.name,
    track?.format,
    track?.label
  ]
    .map((value) => String(value || '').trim().toLowerCase())
    .filter(Boolean)
    .join(' ');
}

function isLikelyBitmapSubtitleFormat(track) {
  const text = [
    track?.format,
    track?.codec,
    track?.codecName,
    track?.title,
    track?.description,
    track?.name,
    track?.label
  ]
    .map((value) => String(value || '').trim().toLowerCase())
    .filter(Boolean)
    .join(' ');
  if (!text) {
    return false;
  }
  return (
    /\bpgs\b/.test(text)
    || /\bhdmv\b/.test(text)
    || /\bsup\b/.test(text)
    || /\bvobsub\b/.test(text)
    || /\bdvd[-_\s]?sub/.test(text)
    || /\bdvb[-_\s]?sub/.test(text)
  );
}

function isLikelySdhSubtitleTrack(track) {
  const text = collectSubtitleText(track);
  if (!text) {
    return false;
  }
  return (
    /\bsdh\b/.test(text)
    || /\bcc\b/.test(text)
    || /\bhoh\b/.test(text)
    || /\bcaptions?\b/.test(text)
    || /hard[-\s]?of[-\s]?hearing/.test(text)
    || /hearing\s+impaired/.test(text)
    || /(?:gehörlos|hoergeschaedigt|hörgeschädigt)/.test(text)
  );
}

function isLikelyForcedSubtitleTrack(track) {
  const text = collectSubtitleText(track);
  if (!text) {
    return false;
  }
  if (isLikelySdhSubtitleTrack(track) || /\bnot forced\b/.test(text)) {
    return false;
  }
  return (
    /\bforced(?:\s+only)?\b/.test(text)
    || /nur\s+erzwungen/.test(text)
    || /\berzwungen\b/.test(text)
  );
}

function compareSubtitleTracksByForcedHeuristic(a, b) {
  const aSdh = Number(Boolean(a?.sdhLikely));
  const bSdh = Number(Boolean(b?.sdhLikely));
  if (aSdh !== bSdh) {
    return aSdh - bSdh;
  }

  const aDefault = Number(Boolean(a?.defaultFlag));
  const bDefault = Number(Boolean(b?.defaultFlag));
  if (aDefault !== bDefault) {
    return aDefault - bDefault;
  }

  const aEventKnown = Number.isFinite(a?.eventCount) ? 0 : 1;
  const bEventKnown = Number.isFinite(b?.eventCount) ? 0 : 1;
  if (aEventKnown !== bEventKnown) {
    return aEventKnown - bEventKnown;
  }
  if (aEventKnown === 0 && a.eventCount !== b.eventCount) {
    return a.eventCount - b.eventCount;
  }

  const aSizeKnown = Number.isFinite(a?.streamSizeBytes) ? 0 : 1;
  const bSizeKnown = Number.isFinite(b?.streamSizeBytes) ? 0 : 1;
  if (aSizeKnown !== bSizeKnown) {
    return aSizeKnown - bSizeKnown;
  }
  if (aSizeKnown === 0 && a.streamSizeBytes !== b.streamSizeBytes) {
    return a.streamSizeBytes - b.streamSizeBytes;
  }

  const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER;
  const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER;
  if (aTrackId !== bTrackId) {
    return aTrackId - bTrackId;
  }
  return a.originalIndex - b.originalIndex;
}

function isHeuristicForcedSubtitleCandidate(entries, candidate) {
  if (!candidate) {
    return false;
  }
  if (!isLikelyBitmapSubtitleFormat(candidate)) {
    return false;
  }
  const comparable = (Array.isArray(entries) ? entries : [])
    .filter((entry) => entry !== candidate && !entry.sdhLikely && isLikelyBitmapSubtitleFormat(entry));
  if (comparable.length === 0) {
    return false;
  }

  const candidateEventCount = Number(candidate?.eventCount);
  const comparableEventCounts = comparable
    .map((entry) => Number(entry?.eventCount))
    .filter((value) => Number.isFinite(value) && value > 0);
  const maxComparableEventCount = comparableEventCounts.length > 0
    ? Math.max(...comparableEventCounts)
    : null;
  const hasEventSignal = Number.isFinite(candidateEventCount)
    && candidateEventCount >= 0
    && Number.isFinite(maxComparableEventCount)
    && maxComparableEventCount > 0
    && candidateEventCount <= FORCED_SUBTITLE_MAX_EVENT_COUNT
    && (candidateEventCount / maxComparableEventCount) <= FORCED_SUBTITLE_EVENT_RATIO_THRESHOLD
    && (maxComparableEventCount - candidateEventCount) >= FORCED_SUBTITLE_MIN_EVENT_GAP;

  const candidateStreamSize = Number(candidate?.streamSizeBytes);
  const comparableSizes = comparable
    .map((entry) => Number(entry?.streamSizeBytes))
    .filter((value) => Number.isFinite(value) && value > 0);
  const maxComparableSize = comparableSizes.length > 0
    ? Math.max(...comparableSizes)
    : null;
  const hasSizeSignal = Number.isFinite(candidateStreamSize)
    && candidateStreamSize > 0
    && Number.isFinite(maxComparableSize)
    && maxComparableSize > 0
    && (candidateStreamSize / maxComparableSize) <= FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD
    && (maxComparableSize - candidateStreamSize) >= FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES;

  const signalCount = Number(hasEventSignal) + Number(hasSizeSignal);
  if (signalCount === 0) {
    return false;
  }
  if (candidate.defaultFlag && signalCount < 2) {
    return false;
  }
  return true;
}

function compareSubtitleTracksForDedup(a, b) {
  const aSdh = Number(Boolean(a?.sdhLikely));
  const bSdh = Number(Boolean(b?.sdhLikely));
  if (aSdh !== bSdh) {
    return aSdh - bSdh;
  }

  const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
  if (defaultDiff !== 0) {
    return defaultDiff;
  }

  const confidenceDiff = subtitleConfidenceScore(b?.confidence) - subtitleConfidenceScore(a?.confidence);
  if (confidenceDiff !== 0) {
    return confidenceDiff;
  }

  const aTrackId = Number.isFinite(a?.id) && a.id > 0 ? a.id : Number.MAX_SAFE_INTEGER;
  const bTrackId = Number.isFinite(b?.id) && b.id > 0 ? b.id : Number.MAX_SAFE_INTEGER;
  if (aTrackId !== bTrackId) {
    return aTrackId - bTrackId;
  }
  return a.originalIndex - b.originalIndex;
}

function annotateSubtitleForcedAvailability(handBrakeSubtitleTracks, makeMkvSubtitleTracks) {
  const hbTracks = Array.isArray(handBrakeSubtitleTracks) ? handBrakeSubtitleTracks : [];
  if (hbTracks.length === 0) {
    return [];
  }

  const mkTracks = Array.isArray(makeMkvSubtitleTracks) ? makeMkvSubtitleTracks : [];
  const normalizedMkTracks = mkTracks.map((track, index) => {
    const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und');
    const sourceTrackId = normalizePositiveTrackId(track?.sourceTrackId ?? track?.id);
    return {
      ...track,
      language,
      sourceTrackId,
      originalIndex: index
    };
  });

  const mkBySourceTrackId = new Map();
  const mkByLanguage = new Map();
  for (const track of normalizedMkTracks) {
    if (track.sourceTrackId) {
      const key = String(track.sourceTrackId);
      if (!mkBySourceTrackId.has(key)) {
        mkBySourceTrackId.set(key, track);
      }
    }
    if (!mkByLanguage.has(track.language)) {
      mkByLanguage.set(track.language, []);
    }
    mkByLanguage.get(track.language).push(track);
  }

  const normalizedHbTracks = hbTracks.map((track, index) => {
    const id = normalizePositiveTrackId(track?.id) || normalizeScanTrackId(track?.id, index);
    const forcedFlag = parseSubtitleForcedFlag(track);
    const sdhFlag = parseSubtitleSdhFlag(track);
    return {
      track,
      originalIndex: index,
      id,
      sourceTrackId: normalizePositiveTrackId(track?.sourceTrackId ?? track?.id),
      language: normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'),
      defaultFlag: parseSubtitleDefaultFlag(track),
      forcedFlag,
      sdhFlag,
      eventCount: parseSubtitleEventCount(track),
      streamSizeBytes: parseSubtitleStreamSizeBytes(track),
      mkTrack: null,
      sdhLikely: (sdhFlag === true) || isLikelySdhSubtitleTrack(track),
      forced: false,
      confidence: null,
      confidenceSource: 'heuristic',
      duplicate: false,
      selected: false
    };
  });

  const hbByLanguage = new Map();
  for (const entry of normalizedHbTracks) {
    if (!hbByLanguage.has(entry.language)) {
      hbByLanguage.set(entry.language, []);
    }
    hbByLanguage.get(entry.language).push(entry);
  }

  for (const [language, entries] of hbByLanguage.entries()) {
    const sortedEntries = [...entries].sort((a, b) => (
      (a.sourceTrackId || Number.MAX_SAFE_INTEGER) - (b.sourceTrackId || Number.MAX_SAFE_INTEGER)
      || a.originalIndex - b.originalIndex
    ));
    const mkLanguageTracks = [...(mkByLanguage.get(language) || [])].sort((a, b) => (
      (a.sourceTrackId || Number.MAX_SAFE_INTEGER) - (b.sourceTrackId || Number.MAX_SAFE_INTEGER)
      || a.originalIndex - b.originalIndex
    ));

    for (let index = 0; index < sortedEntries.length; index += 1) {
      const entry = sortedEntries[index];
      let mkMatch = null;
      if (entry.sourceTrackId) {
        mkMatch = mkBySourceTrackId.get(String(entry.sourceTrackId)) || null;
        if (mkMatch && mkMatch.language !== language) {
          mkMatch = null;
        }
      }
      if (!mkMatch) {
        if (mkLanguageTracks.length === sortedEntries.length) {
          mkMatch = mkLanguageTracks[index] || null;
        } else if (mkLanguageTracks.length === 1) {
          mkMatch = mkLanguageTracks[0];
        }
      }
      entry.mkTrack = mkMatch;
      if (mkMatch && ((parseSubtitleSdhFlag(mkMatch) === true) || isLikelySdhSubtitleTrack(mkMatch))) {
        entry.sdhLikely = true;
      }
    }
  }

  for (const entry of normalizedHbTracks) {
    const forcedByExplicitFlag = entry.forcedFlag === true;
    const forcedBlockedByExplicitFlag = entry.forcedFlag === false;
    const forcedFromTitle = !entry.sdhLikely
      && !forcedBlockedByExplicitFlag
      && (isLikelyForcedSubtitleTrack(entry.track) || isLikelyForcedSubtitleTrack(entry.mkTrack));

    if (forcedByExplicitFlag) {
      entry.forced = true;
      entry.confidence = 'high';
      entry.confidenceSource = 'explicit_flag';
    } else if (forcedFromTitle) {
      entry.forced = true;
      entry.confidence = 'medium';
      entry.confidenceSource = 'title';
    } else {
      entry.forced = false;
      entry.confidence = null;
      entry.confidenceSource = 'heuristic';
    }
  }

  for (const [language, entries] of hbByLanguage.entries()) {
    if (!entries.some((entry) => entry.forced)) {
      const heuristicCandidate = [...entries]
        .filter((entry) => !entry.sdhLikely && entry.forcedFlag !== false)
        .sort(compareSubtitleTracksByForcedHeuristic)[0] || null;

      if (!entries.some((entry) => entry.forced) && heuristicCandidate
        && isHeuristicForcedSubtitleCandidate(entries, heuristicCandidate)) {
        // Last-resort: infer forced by comparatively tiny event/size profile.
        // Avoid auto-detection for SDH/CC tracks and weak one-track cases.
        heuristicCandidate.forced = true;
        heuristicCandidate.confidence = 'low';
        heuristicCandidate.confidenceSource = 'heuristic';
      }
    }

    for (const entry of entries) {
      if (!entry.forced) {
        continue;
      }
      if (entry.confidenceSource === 'explicit_flag') {
        entry.confidence = 'high';
      } else if (entry.confidenceSource === 'title') {
        entry.confidence = 'medium';
      } else {
        entry.confidence = 'low';
      }
    }

    const forcedEntries = entries.filter((entry) => entry.forced);
    const fullEntries = entries.filter((entry) => !entry.forced && !entry.sdhLikely);
    const sdhEntries = entries.filter((entry) => !entry.forced && entry.sdhLikely);
    const forcedWinner = forcedEntries.length > 0 ? [...forcedEntries].sort(compareSubtitleTracksForDedup)[0] : null;
    const fullWinner = fullEntries.length > 0 ? [...fullEntries].sort(compareSubtitleTracksForDedup)[0] : null;
    const sdhWinner = sdhEntries.length > 0 ? [...sdhEntries].sort(compareSubtitleTracksForDedup)[0] : null;
    const forcedSourceTrackIds = normalizeTrackIdList([
      ...(forcedWinner?.sourceTrackId ? [forcedWinner.sourceTrackId] : [])
    ]);
    const forcedAvailable = Boolean(forcedWinner);

    for (const entry of entries) {
      const typeWinner = entry.forced
        ? forcedWinner
        : (entry.sdhLikely ? sdhWinner : fullWinner);
      entry.duplicate = Boolean(typeWinner && typeWinner !== entry);
      if (entry.forced) {
        entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate);
      } else if (entry.sdhLikely) {
        // Keep SDH as an available variant, but auto-select it only when no regular full track exists.
        entry.selected = Boolean(!fullWinner && typeWinner && typeWinner === entry && !entry.duplicate);
      } else {
        entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate);
      }
      const subtitleType = entry.forced ? 'forced' : 'full';
      const confidence = entry.forced
        ? normalizeSubtitleConfidence(entry.confidence || 'low')
        : null;
      const burned = Boolean(entry.track?.subtitlePreviewBurnIn || entry.track?.burnIn);
      const defaultTrack = Boolean(entry.defaultFlag);
      const forcedOnly = Boolean(
        entry.track?.isForcedOnly
        ?? entry.track?.forcedOnly
        ?? entry.track?.subtitlePreviewForcedOnly
        ?? (entry.forced && !entry.sdhLikely)
      );
      const fullHasForced = !forcedOnly && Boolean(
        entry.track?.fullHasForced
        ?? entry.track?.subtitleFullHasForced
        ?? entry.track?.hasForcedVariant
      );
      const flags = [];
      if (burned) {
        flags.push('burned');
      }
      if (entry.selected && entry.forced) {
        flags.push('forced');
      }
      if (forcedOnly) {
        flags.push('forced-only');
      }
      if (entry.selected && defaultTrack) {
        flags.push('default');
      }

      entry.track = {
        ...entry.track,
        id: entry.id,
        sourceTrackId: entry.sourceTrackId || entry.id,
        language: entry.language,
        forcedTrack: entry.forced,
        forcedAvailable,
        forcedSourceTrackIds,
        isForcedOnly: forcedOnly,
        fullHasForced,
        subtitleType,
        sourceConfidence: confidence,
        confidenceSource: entry.confidenceSource,
        sdhLikely: Boolean(entry.sdhLikely),
        duplicate: entry.duplicate,
        selected: entry.selected,
        selectedByRule: entry.selected,
        subtitlePreviewSummary: entry.selected ? 'Übernehmen' : (entry.duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen'),
        subtitlePreviewFlags: entry.selected ? flags : [],
        subtitlePreviewBurnIn: entry.selected ? burned : false,
        subtitlePreviewForced: entry.selected ? entry.forced : false,
        subtitlePreviewForcedOnly: entry.selected ? forcedOnly : false,
        subtitlePreviewDefaultTrack: entry.selected ? defaultTrack : false
      };
    }
  }

  return normalizedHbTracks
    .sort((a, b) => a.originalIndex - b.originalIndex)
    .map((entry) => entry.track);
}

function enrichTitleInfoWithForcedSubtitleAvailability(titleInfo, makeMkvSubtitleTracks) {
  if (!titleInfo || typeof titleInfo !== 'object') {
    return titleInfo;
  }
  return {
    ...titleInfo,
    subtitleTracks: annotateSubtitleForcedAvailability(
      Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : [],
      makeMkvSubtitleTracks
    )
  };
}

function pickScanTitleList(scanJson) {
  if (!scanJson || typeof scanJson !== 'object') {
    return [];
  }

  const direct = Array.isArray(scanJson.TitleList) ? scanJson.TitleList : null;
  if (direct) {
    return direct;
  }

  const scanNode = scanJson.Scan && typeof scanJson.Scan === 'object' ? scanJson.Scan : null;
  if (scanNode) {
    const scanTitles = Array.isArray(scanNode.TitleList) ? scanNode.TitleList : null;
    if (scanTitles) {
      return scanTitles;
    }
  }

  const alt = Array.isArray(scanJson.title_list) ? scanJson.title_list : null;
  return alt || [];
}

// Returns the HandBrake-selected main feature title ID (the "1 valid title" HandBrake reports).
// Present in the JSON as MainFeature at the top level or inside the Scan node.
function pickScanMainFeatureTitleId(scanJson) {
  if (!scanJson || typeof scanJson !== 'object') {
    return null;
  }
  const direct = scanJson.MainFeature ?? scanJson.mainFeature ?? null;
  if (direct != null) {
    const id = Number(direct);
    return Number.isFinite(id) && id > 0 ? id : null;
  }
  const scanNode = scanJson.Scan && typeof scanJson.Scan === 'object' ? scanJson.Scan : null;
  if (scanNode) {
    const nested = scanNode.MainFeature ?? scanNode.mainFeature ?? null;
    if (nested != null) {
      const id = Number(nested);
      return Number.isFinite(id) && id > 0 ? id : null;
    }
  }
  return null;
}

function resolvePlaylistInfoFromAnalysis(playlistAnalysis, playlistIdRaw) {
  const playlistId = normalizePlaylistId(playlistIdRaw);
  if (!playlistId || !playlistAnalysis) {
    return {
      playlistId: playlistId || null,
      playlistFile: playlistId ? `${playlistId}.mpls` : null,
      recommended: false,
      evaluationLabel: null,
      segmentCommand: playlistId ? `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts` : null,
      segmentFiles: []
    };
  }

  const recommended = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId) === playlistId;
  const evaluated = (Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates : [])
    .find((item) => normalizePlaylistId(item?.playlistId) === playlistId) || null;
  const segmentMap = playlistAnalysis.playlistSegments && typeof playlistAnalysis.playlistSegments === 'object'
    ? playlistAnalysis.playlistSegments
    : {};
  const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null;
  const segmentFiles = Array.isArray(segmentEntry?.segmentFiles)
    ? segmentEntry.segmentFiles.filter((item) => String(item || '').trim().length > 0)
    : [];

  return {
    playlistId,
    playlistFile: `${playlistId}.mpls`,
    recommended,
    evaluationLabel: evaluated?.evaluationLabel || (recommended ? 'wahrscheinlich korrekt (Heuristik)' : null),
    segmentCommand: segmentEntry?.segmentCommand || `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
    segmentFiles
  };
}

function normalizeScanTrackId(rawValue, fallbackIndex) {
  const parsed = Number(rawValue);
  if (Number.isFinite(parsed) && parsed > 0) {
    return Math.trunc(parsed);
  }
  return Math.max(1, Math.trunc(fallbackIndex) + 1);
}

function parseSizeToBytes(rawValue) {
  const text = String(rawValue || '').trim();
  if (!text) {
    return 0;
  }

  if (/^\d+$/.test(text)) {
    const numeric = Number(text);
    if (Number.isFinite(numeric) && numeric > 0) {
      return Math.trunc(numeric);
    }
  }

  const match = text.match(/([0-9]+(?:[.,][0-9]+)?)\s*([kmgt]?b)/i);
  if (!match) {
    return 0;
  }

  const value = Number(String(match[1]).replace(',', '.'));
  if (!Number.isFinite(value) || value <= 0) {
    return 0;
  }

  const unit = String(match[2] || 'b').toLowerCase();
  const factorMap = {
    b: 1,
    kb: 1024,
    mb: 1024 ** 2,
    gb: 1024 ** 3,
    tb: 1024 ** 4
  };
  const factor = factorMap[unit] || 1;
  return Math.max(0, Math.trunc(value * factor));
}

function parseMakeMkvDurationSeconds(rawValue) {
  const hms = parseHmsDurationToSeconds(rawValue);
  if (hms > 0) {
    return hms;
  }

  const text = String(rawValue || '').trim();
  if (!text) {
    return 0;
  }

  const hours = Number((text.match(/(\d+)\s*h/i) || [])[1] || 0);
  const minutes = Number((text.match(/(\d+)\s*m/i) || [])[1] || 0);
  const seconds = Number((text.match(/(\d+)\s*s/i) || [])[1] || 0);
  if (hours || minutes || seconds) {
    return (hours * 3600) + (minutes * 60) + seconds;
  }

  const numeric = Number(text);
  if (Number.isFinite(numeric) && numeric > 0) {
    return Math.trunc(numeric);
  }

  return 0;
}

function buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo) {
  const durationSeconds = Math.max(0, Math.trunc(Number(titleInfo?.durationSeconds || 0)));
  const tracks = [];
  tracks.push({
    '@type': 'General',
    // MediaInfo reports numeric Duration as milliseconds. Keep this format so
    // parseDurationSeconds() does not misinterpret long titles.
    Duration: String(durationSeconds * 1000),
    Duration_String3: formatDurationClock(durationSeconds) || null
  });

  const audioTracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : [];
  const subtitleTracks = Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : [];

  for (const track of audioTracks) {
    tracks.push({
      '@type': 'Audio',
      ID: String(track?.sourceTrackId ?? track?.id ?? ''),
      Language: track?.language || 'und',
      Language_String3: track?.language || 'und',
      Title: track?.title || null,
      Format: track?.codecName || track?.format || null,
      Channels: track?.channels || null
    });
  }

  for (const track of subtitleTracks) {
    tracks.push({
      '@type': 'Text',
      ID: String(track?.sourceTrackId ?? track?.id ?? ''),
      Language: track?.language || 'und',
      Language_String3: track?.language || 'und',
      Title: track?.title || null,
      Format: track?.format || null,
      Default: track?.defaultFlag ? 'Yes' : 'No',
      CountOfEvents: Number.isFinite(Number(track?.eventCount)) ? Math.trunc(Number(track.eventCount)) : null,
      StreamSize: Number.isFinite(Number(track?.streamSizeBytes)) ? Math.trunc(Number(track.streamSizeBytes)) : null
    });
  }

  return {
    media: {
      track: tracks
    }
  };
}

function remapReviewTrackIdsToSourceIds(review) {
  if (!review || !Array.isArray(review.titles)) {
    return review;
  }

  const normalizeSourceId = (track) => {
    const normalized = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
    return normalized;
  };

  const titles = review.titles.map((title) => ({
    ...title,
    audioTracks: (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => {
      const sourceTrackId = normalizeSourceId(track);
      return {
        ...track,
        id: sourceTrackId || track?.id || null,
        sourceTrackId: sourceTrackId || track?.sourceTrackId || track?.id || null
      };
    }),
    subtitleTracks: (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => {
      const sourceTrackId = normalizeSourceId(track);
      return {
        ...track,
        id: sourceTrackId || track?.id || null,
        sourceTrackId: sourceTrackId || track?.sourceTrackId || track?.id || null
      };
    })
  }));

  return {
    ...review,
    titles
  };
}

function extractPlaylistIdFromHandBrakeTitle(title) {
  const directCandidates = [
    title?.Playlist,
    title?.playlist,
    title?.PlaylistName,
    title?.playlistName,
    title?.SourcePlaylist,
    title?.sourcePlaylist
  ];
  for (const candidate of directCandidates) {
    const normalized = normalizePlaylistId(candidate);
    if (normalized) {
      return normalized;
    }
  }

  const textCandidates = [
    title?.Path,
    title?.path,
    title?.Name,
    title?.name,
    title?.File,
    title?.file,
    title?.TitleName,
    title?.titleName,
    title?.SourceName,
    title?.sourceName
  ];
  for (const candidate of textCandidates) {
    const text = String(candidate || '').trim();
    if (!text) {
      continue;
    }
    const match = text.match(/(\d{1,5})\.mpls\b/i);
    if (!match) {
      continue;
    }
    const normalized = normalizePlaylistId(match[1]);
    if (normalized) {
      return normalized;
    }
  }

  return null;
}

function parseHandBrakeScanSizeBytes(title) {
  const numeric = Number(title?.Size?.Bytes ?? title?.Bytes ?? 0);
  if (!Number.isFinite(numeric) || numeric <= 0) {
    return 0;
  }
  return Math.trunc(numeric);
}

function buildHandBrakeScanTitleRows(scanJson) {
  const titleList = pickScanTitleList(scanJson);
  return titleList
    .map((title, idx) => {
      const handBrakeTitleId = normalizeScanTrackId(
        title?.Index ?? title?.index ?? title?.Title ?? title?.title,
        idx
      );
      const playlist = extractPlaylistIdFromHandBrakeTitle(title);
      const durationSeconds = parseHandBrakeDurationSeconds(
        title?.Duration ?? title?.duration ?? title?.Length ?? title?.length
      );
      const sizeBytes = parseHandBrakeScanSizeBytes(title);
      const audioTrackCount = Array.isArray(title?.AudioList) ? title.AudioList.length : 0;
      const subtitleTrackCount = Array.isArray(title?.SubtitleList) ? title.SubtitleList.length : 0;
      return {
        handBrakeTitleId,
        playlist,
        durationSeconds,
        sizeBytes,
        audioTrackCount,
        subtitleTrackCount
      };
    })
    .filter((item) => Number.isFinite(item.handBrakeTitleId) && item.handBrakeTitleId > 0);
}

function listAvailableHandBrakePlaylists(scanJson) {
  const rows = buildHandBrakeScanTitleRows(scanJson);
  return Array.from(new Set(
    rows
      .map((item) => normalizePlaylistId(item?.playlist))
      .filter(Boolean)
  )).sort();
}

function resolveHandBrakeTitleIdForPlaylist(scanJson, playlistIdRaw, options = {}) {
  const playlistId = normalizePlaylistId(playlistIdRaw);
  if (!playlistId) {
    return null;
  }

  const expectedMakemkvTitleIdRaw = Number(options?.expectedMakemkvTitleId);
  const expectedMakemkvTitleId = Number.isFinite(expectedMakemkvTitleIdRaw) && expectedMakemkvTitleIdRaw >= 0
    ? Math.trunc(expectedMakemkvTitleIdRaw)
    : null;
  const expectedDurationRaw = Number(options?.expectedDurationSeconds);
  const expectedDurationSeconds = Number.isFinite(expectedDurationRaw) && expectedDurationRaw > 0
    ? Math.trunc(expectedDurationRaw)
    : null;
  const expectedSizeRaw = Number(options?.expectedSizeBytes);
  const expectedSizeBytes = Number.isFinite(expectedSizeRaw) && expectedSizeRaw > 0
    ? Math.trunc(expectedSizeRaw)
    : null;
  const durationToleranceRaw = Number(options?.durationToleranceSeconds);
  const durationToleranceSeconds = Number.isFinite(durationToleranceRaw) && durationToleranceRaw >= 0
    ? Math.trunc(durationToleranceRaw)
    : 5;

  const rows = buildHandBrakeScanTitleRows(scanJson);
  const matches = rows.filter((item) => item.playlist === playlistId);

  const scoreForExpected = (row) => {
    const durationDelta = expectedDurationSeconds !== null
      ? Math.abs(Number(row?.durationSeconds || 0) - expectedDurationSeconds)
      : Number.MAX_SAFE_INTEGER;
    const sizeDelta = expectedSizeBytes !== null
      ? Math.abs(Number(row?.sizeBytes || 0) - expectedSizeBytes)
      : Number.MAX_SAFE_INTEGER;
    const trackRichness = Number(row?.audioTrackCount || 0) + Number(row?.subtitleTrackCount || 0);
    return {
      row,
      durationDelta,
      sizeDelta,
      trackRichness
    };
  };

  const sortByExpectedScore = (a, b) =>
    a.durationDelta - b.durationDelta
    || a.sizeDelta - b.sizeDelta
    || b.trackRichness - a.trackRichness
    || b.row.durationSeconds - a.row.durationSeconds
    || b.row.sizeBytes - a.row.sizeBytes
    || a.row.handBrakeTitleId - b.row.handBrakeTitleId;

  if (matches.length > 0) {
    if (expectedDurationSeconds !== null || expectedSizeBytes !== null) {
      const scored = matches.map(scoreForExpected).sort(sortByExpectedScore);
      if (expectedDurationSeconds !== null) {
        const withinTolerance = scored.filter((item) => item.durationDelta <= durationToleranceSeconds);
        if (withinTolerance.length > 0) {
          return withinTolerance[0].row.handBrakeTitleId;
        }
      }
      return scored[0].row.handBrakeTitleId;
    }
    const best = matches.sort((a, b) =>
      b.durationSeconds - a.durationSeconds
      || b.sizeBytes - a.sizeBytes
      || a.handBrakeTitleId - b.handBrakeTitleId
    )[0];
    return best?.handBrakeTitleId || null;
  }

  // Fallback 1: choose closest duration/size if playlist metadata is absent in scan JSON.
  if ((expectedDurationSeconds !== null || expectedSizeBytes !== null) && rows.length > 0) {
    const scored = rows.map(scoreForExpected).sort(sortByExpectedScore);
    if (expectedDurationSeconds !== null) {
      const withinTolerance = scored.filter((item) => item.durationDelta <= durationToleranceSeconds);
      if (withinTolerance.length > 0) {
        return withinTolerance[0].row.handBrakeTitleId;
      }
    }
    return scored[0].row.handBrakeTitleId;
  }

  // Fallback 2: map MakeMKV title-id to HandBrake title-id if ordering matches.
  if (expectedMakemkvTitleId !== null) {
    const byPlusOne = rows.find((item) => item.handBrakeTitleId === (expectedMakemkvTitleId + 1));
    if (byPlusOne) {
      return byPlusOne.handBrakeTitleId;
    }
    const byEqual = rows.find((item) => item.handBrakeTitleId === expectedMakemkvTitleId);
    if (byEqual) {
      return byEqual.handBrakeTitleId;
    }
  }

  if (rows.length === 1) {
    return rows[0].handBrakeTitleId;
  }

  return null;
}

function isHandBrakePlaylistCacheEntryCompatible(entry, playlistIdRaw, options = {}) {
  const playlistId = normalizePlaylistId(playlistIdRaw);
  if (!playlistId) {
    return false;
  }
  if (!entry || typeof entry !== 'object') {
    return false;
  }
  const handBrakeTitleId = Number(entry?.handBrakeTitleId);
  if (!Number.isFinite(handBrakeTitleId) || handBrakeTitleId <= 0) {
    return false;
  }
  const titleInfo = entry?.titleInfo && typeof entry.titleInfo === 'object' ? entry.titleInfo : null;
  if (!titleInfo) {
    return false;
  }

  const cachedPlaylistId = normalizePlaylistId(titleInfo?.playlistId || null);
  if (cachedPlaylistId && cachedPlaylistId !== playlistId) {
    return false;
  }

  const expectedDurationRaw = Number(options?.expectedDurationSeconds);
  const expectedDurationSeconds = Number.isFinite(expectedDurationRaw) && expectedDurationRaw > 0
    ? Math.trunc(expectedDurationRaw)
    : null;
  const cachedDurationRaw = Number(titleInfo?.durationSeconds);
  const cachedDurationSeconds = Number.isFinite(cachedDurationRaw) && cachedDurationRaw > 0
    ? Math.trunc(cachedDurationRaw)
    : null;
  if (expectedDurationSeconds !== null && cachedDurationSeconds !== null) {
    // Reject clearly wrong cache mappings (e.g. 30s instead of 6681s movie title).
    if (Math.abs(expectedDurationSeconds - cachedDurationSeconds) > 120) {
      return false;
    }
  }

  const expectedSizeRaw = Number(options?.expectedSizeBytes);
  const expectedSizeBytes = Number.isFinite(expectedSizeRaw) && expectedSizeRaw > 0
    ? Math.trunc(expectedSizeRaw)
    : null;
  const cachedSizeRaw = Number(titleInfo?.sizeBytes);
  const cachedSizeBytes = Number.isFinite(cachedSizeRaw) && cachedSizeRaw > 0
    ? Math.trunc(cachedSizeRaw)
    : null;
  if (expectedSizeBytes !== null && cachedSizeBytes !== null) {
    const delta = Math.abs(expectedSizeBytes - cachedSizeBytes);
    if (delta > (512 * 1024 * 1024)) {
      return false;
    }
  }

  return true;
}

function normalizeCodecNumber(value) {
  const numeric = Number(value);
  if (!Number.isFinite(numeric)) {
    return null;
  }
  return Math.trunc(numeric);
}

function hasDtsHdMarker(track) {
  const text = `${track?.description || ''} ${track?.title || ''} ${track?.format || ''} ${track?.codecName || ''}`
    .toLowerCase();
  const codec = normalizeCodecNumber(track?.codec);
  return text.includes('dts-hd') || text.includes('dts hd') || codec === 262144;
}

function isLikelyDtsCoreTrack(track) {
  const text = `${track?.description || ''} ${track?.title || ''} ${track?.format || ''} ${track?.codecName || ''}`
    .toLowerCase();
  const codec = normalizeCodecNumber(track?.codec);
  const looksDts = text.includes('dts') || text.includes('dca');
  const looksHd = text.includes('dts-hd') || text.includes('dts hd') || codec === 262144;
  if (!looksDts || looksHd) {
    return false;
  }

  // HandBrake uses 8192 for DTS core in scan JSON.
  if (codec !== null && codec !== 8192) {
    return false;
  }
  return true;
}

function filterDtsCoreFallbackTracks(audioTracks) {
  const tracks = Array.isArray(audioTracks) ? audioTracks : [];
  if (tracks.length === 0) {
    return [];
  }

  const hdLanguages = new Set(
    tracks
      .filter((track) => hasDtsHdMarker(track))
      .map((track) => String(track?.language || 'und'))
  );

  if (hdLanguages.size === 0) {
    return tracks;
  }

  return tracks.filter((track) => {
    const language = String(track?.language || 'und');
    if (!hdLanguages.has(language)) {
      return true;
    }
    return !isLikelyDtsCoreTrack(track);
  });
}

function parseHandBrakeTitleList(scanJson) {
  const titleList = pickScanTitleList(scanJson);
  if (!Array.isArray(titleList) || titleList.length === 0) {
    return [];
  }
  return titleList.map((title, idx) => {
    const handBrakeTitleId = normalizeScanTrackId(
      title?.Index ?? title?.index ?? title?.Title ?? title?.title,
      idx
    );
    const durationSeconds = parseHandBrakeDurationSeconds(
      title?.Duration ?? title?.duration ?? title?.Length ?? title?.length
    );
    const audioTrackCount = Array.isArray(title?.AudioList) ? title.AudioList.length : 0;
    const subtitleTrackCount = Array.isArray(title?.SubtitleList) ? title.SubtitleList.length : 0;
    const sizeBytes = Number(title?.Size?.Bytes ?? title?.Bytes ?? 0) || 0;
    return { handBrakeTitleId, durationSeconds, audioTrackCount, subtitleTrackCount, sizeBytes };
  });
}

function normalizeSeriesScanTitleKind(rawKind) {
  return String(rawKind || '').trim().toLowerCase();
}

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'
    };
  }

  try {
    const analysisResult = dvdSeriesScanService.analyzeHandBrakeScan(scanText, options?.seriesOptions || {});
    const analysis = analysisResult?.analysis || null;
    const analysisTitles = Array.isArray(analysis?.titles) ? analysis.titles : [];
    if (analysisTitles.length === 0) {
      return {
        analyzeContext: baseAnalyzeContext,
        derived: false,
        reason: 'no_titles'
      };
    }

    return {
      analyzeContext: {
        ...baseAnalyzeContext,
        seriesAnalysis: analysis
      },
      derived: true,
      reason: 'scan_classification'
    };
  } catch (_error) {
    return {
      analyzeContext: baseAnalyzeContext,
      derived: false,
      reason: 'scan_parse_failed'
    };
  }
}

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 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 byEpisodeIds = sourceCandidates.filter((item) => episodeCandidateIds.has(Number(item?.handBrakeTitleId)));
    if (byEpisodeIds.length > 0) {
      filtered = byEpisodeIds;
      reason = '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 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 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,
      playlistRecommended: Boolean(title.playlistMatch?.recommended),
      playlistEvaluationLabel: title.playlistMatch?.evaluationLabel || null,
      playlistSegmentCommand: title.playlistMatch?.segmentCommand || null,
      playlistSegmentFiles: Array.isArray(title.playlistMatch?.segmentFiles) ? title.playlistMatch.segmentFiles : [],
      audioTracks: title.audioTracks.map((track) => {
        const selectedForEncode = isEncodeInput && Boolean(track.selectedByRule);
        return {
          ...track,
          selectedForEncode,
          encodeActions: [],
          encodeActionSummary: selectedForEncode ? 'Übernehmen' : 'Nicht übernommen'
        };
      }),
      subtitleTracks: title.subtitleTracks.map((track) => {
        const selectedForEncode = isEncodeInput && Boolean(track.selectedByRule);
        const previewFlags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : [];
        const previewSummary = track?.subtitlePreviewSummary || 'Nicht übernommen';
        return {
          ...track,
          selectedForEncode,
          burnIn: selectedForEncode ? Boolean(track?.subtitlePreviewBurnIn) : false,
          forced: selectedForEncode ? Boolean(track?.subtitlePreviewForced) : false,
          forcedOnly: selectedForEncode ? Boolean(track?.subtitlePreviewForcedOnly) : false,
          defaultTrack: selectedForEncode ? Boolean(track?.subtitlePreviewDefaultTrack) : false,
          flags: selectedForEncode ? previewFlags : [],
          subtitleActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen'
        };
      })
    };
  });

  const selectedTitleIds = normalizedTitles.filter((item) => item.selectedByMinLength).map((item) => item.id);
  const recommendedPlaylistId = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId || null);
  const recommendedReviewTitle = normalizedTitles.find((item) => item.playlistId === recommendedPlaylistId) || null;

  return {
    generatedAt: nowIso(),
    mode,
    mediaProfile: normalizeMediaProfile(mediaProfile) || null,
    preRip: Boolean(preRip),
    reviewConfirmed: false,
    minLengthMinutes,
    minLengthSeconds,
    selectedTitleIds,
    selectors: {
      preset: settings?.handbrake_preset || '-',
      extraArgs: settings?.handbrake_extra_args || '',
      presetProfileSource: 'disc-scan',
      audio: {
        mode: 'manual',
        encoders: [],
        copyMask: [],
        fallbackEncoder: '-'
      },
      subtitle: {
        mode: 'manual',
        forcedOnly: false,
        burnBehavior: 'none'
      }
    },
    notes: [
      preRip
        ? `Vorab-Spurprüfung von Disc-Quelle ${sourceArg || '-'}.`
        : `Titel-/Spurprüfung aus RAW-Quelle ${sourceArg || '-'}.`,
      preRip
        ? 'Backup/Rip startet erst nach manueller Bestätigung und CTA.'
        : 'Encode startet erst nach manueller Bestätigung und CTA.'
    ],
    titles: normalizedTitles,
    encodeInputPath: encodeInputTitle ? (encodeInputPath || `disc-track-scan://title-${encodeInputTitle.id}`) : null,
    encodeInputTitleId: encodeInputTitle ? encodeInputTitle.id : null,
    playlistDecisionRequired,
    playlistRecommendation: recommendedPlaylistId
      ? {
        playlistId: recommendedPlaylistId,
        playlistFile: `${recommendedPlaylistId}.mpls`,
        reviewTitleId: recommendedReviewTitle?.id || null,
        reason: playlistAnalysis?.recommendation?.reason || null
      }
      : null,
    titleSelectionRequired: Boolean(playlistDecisionRequired && !encodeInputTitle)
  };
}

function findExistingRawDirectory(rawBaseDir, metadataBase) {
  if (!rawBaseDir || !metadataBase) {
    return null;
  }

  if (!fs.existsSync(rawBaseDir)) {
    return null;
  }

  let entries;
  try {
    entries = fs.readdirSync(rawBaseDir, { withFileTypes: true });
  } catch (_error) {
    return null;
  }

  const normalizedBase = sanitizeFileName(metadataBase);
  const escapedBase = normalizedBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const escapedIncompletePrefix = RAW_INCOMPLETE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const escapedRipCompletePrefix = RAW_RIP_COMPLETE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const folderPattern = new RegExp(
    `^(?:(?:${escapedIncompletePrefix}|${escapedRipCompletePrefix}))?${escapedBase}(?:\\s\\[tt\\d{6,12}\\])?\\s-\\sRAW\\s-\\sjob-\\d+\\s*$`,
    'i'
  );
  const candidates = entries
    .filter((entry) => entry.isDirectory() && folderPattern.test(entry.name))
    .map((entry) => {
      const absPath = path.join(rawBaseDir, entry.name);
      try {
        const dirEntries = fs.readdirSync(absPath);
        const stat = fs.statSync(absPath);
        return {
          path: absPath,
          entryCount: dirEntries.length,
          mtimeMs: Number(stat.mtimeMs || 0)
        };
      } catch (_error) {
        return null;
      }
    })
    .filter((item) => item && item.entryCount > 0)
    .sort((a, b) => b.mtimeMs - a.mtimeMs);

  return candidates.length > 0 ? candidates[0].path : null;
}

function buildRawMetadataBase(jobLike = {}, fallbackJobId = null) {
  const normalizedJobId = Number(fallbackJobId || jobLike?.id || 0);
  const fallbackTitle = Number.isFinite(normalizedJobId) && normalizedJobId > 0
    ? `job-${Math.trunc(normalizedJobId)}`
    : 'job-unknown';
  const rawYear = Number(jobLike?.year ?? jobLike?.fallbackYear ?? null);
  const yearValue = Number.isFinite(rawYear) && rawYear > 0
    ? Math.trunc(rawYear)
    : new Date().getFullYear();
  return sanitizeFileName(
    renderTemplate('${title} (${year})', {
      title: jobLike?.title || jobLike?.detected_title || jobLike?.detectedTitle || fallbackTitle,
      year: yearValue
    })
  );
}

function normalizeRawFolderState(rawState, fallback = RAW_FOLDER_STATES.INCOMPLETE) {
  const state = String(rawState || '').trim().toLowerCase();
  if (!state) {
    return fallback;
  }
  if (state === RAW_FOLDER_STATES.INCOMPLETE) {
    return RAW_FOLDER_STATES.INCOMPLETE;
  }
  if (state === RAW_FOLDER_STATES.RIP_COMPLETE || state === 'ripcomplete' || state === 'rip-complete') {
    return RAW_FOLDER_STATES.RIP_COMPLETE;
  }
  if (state === RAW_FOLDER_STATES.COMPLETE || state === 'none' || state === 'final') {
    return RAW_FOLDER_STATES.COMPLETE;
  }
  return fallback;
}

function stripRawStatePrefix(folderName) {
  const rawName = String(folderName || '').trim();
  if (!rawName) {
    return '';
  }
  return rawName
    .replace(/^Incomplete_/i, '')
    .replace(/^Rip_Complete_/i, '')
    .trim();
}

function applyRawFolderStateToName(folderName, state) {
  const baseName = stripRawStatePrefix(folderName);
  if (!baseName) {
    return baseName;
  }
  const normalizedState = normalizeRawFolderState(state, RAW_FOLDER_STATES.COMPLETE);
  if (normalizedState === RAW_FOLDER_STATES.INCOMPLETE) {
    return `${RAW_INCOMPLETE_PREFIX}${baseName}`;
  }
  if (normalizedState === RAW_FOLDER_STATES.RIP_COMPLETE) {
    return `${RAW_RIP_COMPLETE_PREFIX}${baseName}`;
  }
  return baseName;
}

function resolveRawFolderStateFromPath(rawPath) {
  const sourcePath = String(rawPath || '').trim();
  if (!sourcePath) {
    return RAW_FOLDER_STATES.COMPLETE;
  }
  const folderName = path.basename(sourcePath);
  if (/^Incomplete_/i.test(folderName)) {
    return RAW_FOLDER_STATES.INCOMPLETE;
  }
  if (/^Rip_Complete_/i.test(folderName)) {
    return RAW_FOLDER_STATES.RIP_COMPLETE;
  }
  return RAW_FOLDER_STATES.COMPLETE;
}

function resolveRawFolderStateFromOptions(options = {}) {
  if (options && Object.prototype.hasOwnProperty.call(options, 'state')) {
    return normalizeRawFolderState(options.state, RAW_FOLDER_STATES.INCOMPLETE);
  }
  if (options && options.ripComplete) {
    return RAW_FOLDER_STATES.RIP_COMPLETE;
  }
  if (options && Object.prototype.hasOwnProperty.call(options, 'incomplete')) {
    return options.incomplete ? RAW_FOLDER_STATES.INCOMPLETE : RAW_FOLDER_STATES.COMPLETE;
  }
  return RAW_FOLDER_STATES.INCOMPLETE;
}

function buildRawDirName(metadataBase, jobId, options = {}) {
  const state = resolveRawFolderStateFromOptions(options);
  const baseName = sanitizeFileName(`${metadataBase} - RAW - job-${jobId}`);
  return applyRawFolderStateToName(baseName, state);
}

function buildRawPathForState(rawPath, state) {
  const sourcePath = String(rawPath || '').trim();
  if (!sourcePath) {
    return null;
  }
  const folderName = path.basename(sourcePath);
  const nextFolderName = applyRawFolderStateToName(folderName, state);
  if (!nextFolderName) {
    return sourcePath;
  }
  return path.join(path.dirname(sourcePath), nextFolderName);
}

function buildRipCompleteRawPath(rawPath) {
  return buildRawPathForState(rawPath, RAW_FOLDER_STATES.RIP_COMPLETE);
}

function buildCompletedRawPath(rawPath) {
  return buildRawPathForState(rawPath, RAW_FOLDER_STATES.COMPLETE);
}

function normalizeComparablePath(inputPath) {
  const source = String(inputPath || '').trim();
  if (!source) {
    return '';
  }
  return path.resolve(source).replace(/[\\/]+$/, '');
}

function isPathInsideDirectory(parentPath, candidatePath) {
  const parent = normalizeComparablePath(parentPath);
  const candidate = normalizeComparablePath(candidatePath);
  if (!parent || !candidate) {
    return false;
  }
  if (candidate === parent) {
    return true;
  }
  const parentWithSep = parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`;
  return candidate.startsWith(parentWithSep);
}

function remapPathToRetargetedRawRoot(inputPath, previousRawPath, nextRawPath) {
  const sourceInputPath = String(inputPath || '').trim();
  if (!sourceInputPath) {
    return null;
  }
  const previousRaw = normalizeComparablePath(previousRawPath);
  const nextRaw = normalizeComparablePath(nextRawPath);
  const input = normalizeComparablePath(sourceInputPath);
  if (!previousRaw || !nextRaw || !input) {
    return sourceInputPath;
  }
  if (input === previousRaw) {
    return nextRaw;
  }
  if (!isPathInsideDirectory(previousRaw, input)) {
    return sourceInputPath;
  }
  const relative = path.relative(previousRaw, input);
  if (!relative || relative === '.') {
    return nextRaw;
  }
  return path.join(nextRaw, relative);
}

function isEncodeInputMismatchedWithRaw(rawPath, encodeInputPath) {
  const raw = normalizeComparablePath(rawPath);
  const input = normalizeComparablePath(encodeInputPath);
  if (!raw || !input) {
    return true;
  }
  if (raw === input) {
    return false;
  }
  return !isPathInsideDirectory(raw, input);
}

function isJobFinished(jobLike = null) {
  const status = String(jobLike?.status || '').trim().toUpperCase();
  const lastState = String(jobLike?.last_state || '').trim().toUpperCase();
  return status === 'FINISHED' || lastState === 'FINISHED';
}

function toPlaylistFile(playlistId) {
  const normalized = normalizePlaylistId(playlistId);
  return normalized ? `${normalized}.mpls` : null;
}

function describePlaylistManualDecision(playlistAnalysis) {
  const obfuscationDetected = Boolean(playlistAnalysis?.obfuscationDetected);
  const candidateCount = Array.isArray(playlistAnalysis?.candidates)
    ? playlistAnalysis.candidates.length
    : 0;
  const reasonCodeRaw = String(playlistAnalysis?.manualDecisionReason || '').trim();
  const reasonCode = reasonCodeRaw || (
    obfuscationDetected
      ? 'multiple_similar_candidates'
      : (candidateCount > 1 ? 'multiple_candidates_after_min_length' : 'manual_selection_required')
  );
  const detailText = obfuscationDetected
    ? 'Blu-ray verwendet Playlist-Obfuscation (mehrere gleichlange Kandidaten).'
    : (candidateCount > 1
      ? `Mehrere Playlists erfüllen MIN_LENGTH_MINUTES (${candidateCount} Kandidaten).`
      : 'Manuelle Playlist-Auswahl erforderlich.');

  return {
    obfuscationDetected,
    candidateCount,
    reasonCode,
    detailText
  };
}

function buildPlaylistCandidates(playlistAnalysis) {
  const rawList = Array.isArray(playlistAnalysis?.candidatePlaylists)
    ? playlistAnalysis.candidatePlaylists
    : [];
  const sourceRows = [
    ...(Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates : []),
    ...(Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates : []),
    ...(Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : [])
  ];
  const segmentMap = playlistAnalysis?.playlistSegments && typeof playlistAnalysis.playlistSegments === 'object'
    ? playlistAnalysis.playlistSegments
    : {};

  return rawList
    .map((playlistId) => normalizePlaylistId(playlistId))
    .filter(Boolean)
    .map((playlistId) => {
      const source = sourceRows.find((item) => normalizePlaylistId(item?.playlistId || item?.playlistFile) === playlistId) || null;
      const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null;
      const score = Number(source?.score);
      const sequenceCoherence = Number(source?.structuralMetrics?.sequenceCoherence);
      const titleId = Number(source?.titleId ?? source?.id);
      const handBrakeTitleId = Number(source?.handBrakeTitleId);
      const durationSecondsRaw = Number(source?.durationSeconds ?? source?.duration ?? 0);
      const durationSeconds = Number.isFinite(durationSecondsRaw) && durationSecondsRaw > 0
        ? Math.trunc(durationSecondsRaw)
        : 0;
      const sizeBytesRaw = Number(source?.sizeBytes ?? source?.size ?? 0);
      const sizeBytes = Number.isFinite(sizeBytesRaw) && sizeBytesRaw > 0
        ? Math.trunc(sizeBytesRaw)
        : 0;
      const durationLabelRaw = String(source?.durationLabel || '').trim();
      const durationLabel = durationLabelRaw || formatDurationClock(durationSeconds);
      const sourceAudioTracks = Array.isArray(source?.audioTracks) ? source.audioTracks : [];
      const fallbackAudioTrackPreview = sourceAudioTracks
        .slice(0, 8)
        .map((track) => {
          const rawTrackId = Number(track?.sourceTrackId ?? track?.id);
          const trackId = Number.isFinite(rawTrackId) && rawTrackId > 0 ? Math.trunc(rawTrackId) : null;
          const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und');
          const languageLabel = String(track?.languageLabel || track?.language || language).trim() || language;
          const format = String(track?.format || '').trim();
          const channels = String(track?.channels || '').trim();
          const parts = [];
          if (trackId !== null) {
            parts.push(`#${trackId}`);
          }
          parts.push(language);
          parts.push(languageLabel);
          if (format) {
            parts.push(format);
          }
          if (channels) {
            parts.push(channels);
          }
          return parts.join(' | ');
        })
        .filter((line) => line.length > 0);
      const sourceAudioTrackPreview = Array.isArray(source?.audioTrackPreview)
        ? source.audioTrackPreview.map((line) => String(line || '').trim()).filter((line) => line.length > 0)
        : [];
      const audioTrackPreview = sourceAudioTrackPreview.length > 0 ? sourceAudioTrackPreview : fallbackAudioTrackPreview;
      const audioSummary = String(source?.audioSummary || '').trim() || buildHandBrakeAudioSummary(audioTrackPreview);

      return {
        playlistId,
        playlistFile: toPlaylistFile(playlistId),
        titleId: Number.isFinite(titleId) ? Math.trunc(titleId) : null,
        durationSeconds,
        durationLabel: durationLabel || null,
        sizeBytes,
        score: Number.isFinite(score) ? score : null,
        recommended: Boolean(source?.recommended),
        evaluationLabel: source?.evaluationLabel || null,
        sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null,
        segmentCommand: source?.segmentCommand
          || segmentEntry?.segmentCommand
          || `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
        segmentFiles: Array.isArray(source?.segmentFiles) && source.segmentFiles.length > 0
          ? source.segmentFiles
          : (Array.isArray(segmentEntry?.segmentFiles) ? segmentEntry.segmentFiles : []),
        handBrakeTitleId: Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0
          ? Math.trunc(handBrakeTitleId)
          : null,
        audioSummary: audioSummary || null,
        audioTrackPreview
      };
    });
}

function buildHandBrakeAudioTrackPreview(titleInfo) {
  const tracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : [];
  return tracks
    .map((track) => {
      const rawTrackId = Number(track?.sourceTrackId ?? track?.id);
      const trackId = Number.isFinite(rawTrackId) && rawTrackId > 0 ? Math.trunc(rawTrackId) : null;
      const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und');
      const description = String(track?.description || track?.title || '').trim();
      const codec = String(track?.codecName || track?.format || '').trim();
      const channels = String(track?.channels || '').trim();

      const parts = [];
      if (trackId !== null) {
        parts.push(`#${trackId}`);
      }
      parts.push(language);
      if (description) {
        parts.push(description);
      } else {
        if (codec) {
          parts.push(codec);
        }
        if (channels) {
          parts.push(channels);
        }
      }
      return parts.join(' | ').trim();
    })
    .filter((line) => line.length > 0);
}

function buildHandBrakeAudioSummary(previewLines) {
  const lines = Array.isArray(previewLines)
    ? previewLines.filter((line) => String(line || '').trim().length > 0)
    : [];
  if (lines.length === 0) {
    return null;
  }
  return lines.slice(0, 3).join(' || ');
}

function normalizeHandBrakePlaylistScanCache(rawCache) {
  if (!rawCache || typeof rawCache !== 'object') {
    return null;
  }

  const inputPath = String(rawCache?.inputPath || '').trim() || null;
  const source = String(rawCache?.source || '').trim() || 'HANDBRAKE_SCAN_PLAYLIST_MAP';
  const generatedAt = String(rawCache?.generatedAt || '').trim() || null;

  const rawEntries = [];
  if (rawCache?.byPlaylist && typeof rawCache.byPlaylist === 'object') {
    for (const [key, value] of Object.entries(rawCache.byPlaylist)) {
      rawEntries.push({ key, value });
    }
  } else if (Array.isArray(rawCache?.playlists)) {
    for (const item of rawCache.playlists) {
      rawEntries.push({ key: item?.playlistId || item?.playlistFile || null, value: item });
    }
  }

  const byPlaylist = {};
  for (const entry of rawEntries) {
    const row = entry?.value && typeof entry.value === 'object' ? entry.value : null;
    const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || entry?.key || null);
    if (!playlistId) {
      continue;
    }
    const rawHandBrakeTitleId = Number(row?.handBrakeTitleId ?? row?.titleId);
    const handBrakeTitleId = Number.isFinite(rawHandBrakeTitleId) && rawHandBrakeTitleId > 0
      ? Math.trunc(rawHandBrakeTitleId)
      : null;
    const titleInfo = row?.titleInfo && typeof row.titleInfo === 'object' ? row.titleInfo : null;
    const audioTrackPreview = Array.isArray(row?.audioTrackPreview)
      ? row.audioTrackPreview.map((line) => String(line || '').trim()).filter((line) => line.length > 0)
      : buildHandBrakeAudioTrackPreview(titleInfo);
    const audioSummary = String(row?.audioSummary || '').trim() || buildHandBrakeAudioSummary(audioTrackPreview);

    byPlaylist[playlistId] = {
      playlistId,
      handBrakeTitleId,
      titleInfo,
      audioTrackPreview,
      audioSummary: audioSummary || null
    };
  }

  if (Object.keys(byPlaylist).length === 0) {
    return null;
  }

  return {
    generatedAt,
    source,
    inputPath,
    byPlaylist
  };
}

function getCachedHandBrakePlaylistEntry(scanCache, playlistIdRaw) {
  const playlistId = normalizePlaylistId(playlistIdRaw);
  if (!playlistId) {
    return null;
  }
  const normalized = normalizeHandBrakePlaylistScanCache(scanCache);
  if (!normalized) {
    return null;
  }
  return normalized.byPlaylist[playlistId] || null;
}

function hasCachedHandBrakeDataForPlaylistCandidates(scanCache, playlistCandidates = []) {
  const normalized = normalizeHandBrakePlaylistScanCache(scanCache);
  if (!normalized) {
    return false;
  }

  const candidateIds = (Array.isArray(playlistCandidates) ? playlistCandidates : [])
    .map((item) => normalizePlaylistId(item?.playlistId || item?.playlistFile || item))
    .filter(Boolean);
  if (candidateIds.length === 0) {
    return false;
  }

  return candidateIds.every((playlistId) => {
    const row = normalized.byPlaylist[playlistId];
    return Boolean(row && row.handBrakeTitleId && row.titleInfo);
  });
}

function buildHandBrakePlaylistScanCache(scanJson, playlistCandidates = [], rawPath = null) {
  const candidateMetaByPlaylist = new Map();
  for (const row of (Array.isArray(playlistCandidates) ? playlistCandidates : [])) {
    const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || row);
    if (!playlistId || candidateMetaByPlaylist.has(playlistId)) {
      continue;
    }
    candidateMetaByPlaylist.set(playlistId, {
      expectedMakemkvTitleId: normalizeNonNegativeInteger(row?.titleId),
      expectedDurationSeconds: Number(row?.durationSeconds || 0) || null,
      expectedSizeBytes: Number(row?.sizeBytes || 0) || null
    });
  }

  const candidateIds = Array.from(new Set(
    (Array.isArray(playlistCandidates) ? playlistCandidates : [])
      .map((item) => normalizePlaylistId(item?.playlistId || item?.playlistFile || item))
      .filter(Boolean)
  ));

  const byPlaylist = {};
  for (const playlistId of candidateIds) {
    const expected = candidateMetaByPlaylist.get(playlistId) || {};
    const handBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(scanJson, playlistId, expected);
    if (!handBrakeTitleId) {
      continue;
    }
    const titleInfo = parseHandBrakeSelectedTitleInfo(scanJson, {
      playlistId,
      handBrakeTitleId
    });
    if (!titleInfo) {
      continue;
    }
    if (!isHandBrakePlaylistCacheEntryCompatible({
      playlistId,
      handBrakeTitleId,
      titleInfo
    }, playlistId, expected)) {
      continue;
    }
    const audioTrackPreview = buildHandBrakeAudioTrackPreview(titleInfo);
    byPlaylist[playlistId] = {
      playlistId,
      handBrakeTitleId,
      titleInfo,
      audioTrackPreview,
      audioSummary: buildHandBrakeAudioSummary(audioTrackPreview)
    };
  }

  return normalizeHandBrakePlaylistScanCache({
    generatedAt: nowIso(),
    source: 'HANDBRAKE_SCAN_PLAYLIST_MAP',
    inputPath: rawPath || null,
    byPlaylist
  });
}

function enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, scanCache) {
  const analysis = playlistAnalysis && typeof playlistAnalysis === 'object' ? playlistAnalysis : null;
  const normalizedCache = normalizeHandBrakePlaylistScanCache(scanCache);
  if (!analysis || !normalizedCache) {
    return analysis;
  }

  const enrichRow = (row) => {
    const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || null);
    if (!playlistId) {
      return row;
    }
    const cached = normalizedCache.byPlaylist[playlistId];
    if (!cached) {
      return row;
    }
    return {
      ...row,
      handBrakeTitleId: cached.handBrakeTitleId || null,
      audioSummary: cached.audioSummary || null,
      audioTrackPreview: Array.isArray(cached.audioTrackPreview) ? cached.audioTrackPreview : []
    };
  };

  const recommendationPlaylistId = normalizePlaylistId(analysis?.recommendation?.playlistId);
  const recommendationCached = recommendationPlaylistId
    ? normalizedCache.byPlaylist[recommendationPlaylistId] || null
    : null;

  return {
    ...analysis,
    evaluatedCandidates: Array.isArray(analysis?.evaluatedCandidates)
      ? analysis.evaluatedCandidates.map((row) => enrichRow(row))
      : [],
    candidates: Array.isArray(analysis?.candidates)
      ? analysis.candidates.map((row) => enrichRow(row))
      : [],
    titles: Array.isArray(analysis?.titles)
      ? analysis.titles.map((row) => enrichRow(row))
      : [],
    recommendation: analysis?.recommendation && typeof analysis.recommendation === 'object'
      ? {
        ...analysis.recommendation,
        handBrakeTitleId: recommendationCached?.handBrakeTitleId || null,
        audioSummary: recommendationCached?.audioSummary || null,
        audioTrackPreview: Array.isArray(recommendationCached?.audioTrackPreview)
          ? recommendationCached.audioTrackPreview
          : []
      }
      : analysis?.recommendation || null
  };
}

function pickTitleIdForPlaylist(playlistAnalysis, playlistId) {
  const normalized = normalizePlaylistId(playlistId);
  if (!normalized || !playlistAnalysis) {
    return null;
  }

  const playlistMap = playlistAnalysis?.playlistToTitleId
    && typeof playlistAnalysis.playlistToTitleId === 'object'
    ? playlistAnalysis.playlistToTitleId
    : null;
  if (playlistMap) {
    const byFile = Number(playlistMap[`${normalized}.mpls`]);
    if (Number.isFinite(byFile) && byFile >= 0) {
      return Math.trunc(byFile);
    }
    const byId = Number(playlistMap[normalized]);
    if (Number.isFinite(byId) && byId >= 0) {
      return Math.trunc(byId);
    }
  }

  const sources = [
    ...(Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates : []),
    ...(Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates : []),
    ...(Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : [])
  ];

  const matches = sources
    .filter((item) => normalizePlaylistId(item?.playlistId) === normalized)
    .map((item) => ({
      titleId: Number(item?.titleId ?? item?.id),
      durationSeconds: Number(item?.durationSeconds || 0),
      sizeBytes: Number(item?.sizeBytes || 0)
    }))
    .filter((item) => Number.isFinite(item.titleId) && item.titleId >= 0)
    .sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);

  return matches.length > 0 ? matches[0].titleId : null;
}

function normalizeReviewTitleId(rawValue) {
  const value = Number(rawValue);
  if (!Number.isFinite(value) || value <= 0) {
    return null;
  }
  return Math.trunc(value);
}

function 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 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 episodeNumber = normalizeEpisodeNumberValue(assignment?.episodeNumber ?? assignment?.number ?? null);
    const seasonNumber = normalizePositiveInteger(assignment?.seasonNumber ?? assignment?.season ?? null);
    const episodeTitle = String(
      assignment?.episodeTitle
      ?? assignment?.title
      ?? assignment?.name
      ?? ''
    ).trim() || null;
    if (!episodeId && episodeNumber === null && seasonNumber === null && !episodeTitle) {
      continue;
    }
    output[String(titleId)] = {
      titleId,
      episodeId,
      episodeNumber,
      seasonNumber,
      episodeTitle
    };
  }
  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 episodeNumber = normalizeEpisodeNumberValue(
    assignment?.episodeNumber
    ?? selectedTitle?.episodeNumber
    ?? null
  );
  const episodeLabel = String(
    assignment?.episodeTitle
    || selectedTitle?.episodeTitle
    || selectedTitle?.fileName
    || ''
  ).trim();

  const seasonToken = seasonNumber !== null ? String(seasonNumber).padStart(2, '0') : null;
  const episodeToken = episodeNumber !== null
    ? (
      Number.isInteger(episodeNumber)
        ? String(Math.trunc(episodeNumber)).padStart(2, '0')
        : String(episodeNumber)
    )
    : null;
  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;

  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: selectedAssignment
      ? {
        [String(titleId)]: {
          ...selectedAssignment,
          titleId: Number(titleId),
          filePath: selectedTitle?.filePath || selectedAssignment?.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,
      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 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 explicitSubtitleVariants = normalizeSubtitleVariantSelection(rawSelection.subtitleVariantSelection);
  const explicitSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(rawSelection.subtitleLanguageOrder);
  let effectiveSubtitleVariantSelection = explicitSubtitleVariants;
  let forceExplicitEmptySubtitleSelection = false;
  const hasRawSubtitleTrackIds = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleTrackIds');
  const hasRawSubtitleVariantSelection = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleVariantSelection');
  if (effectiveSubtitleVariantSelection.map.size === 0 && !hasRawSubtitleVariantSelection && hasRawSubtitleTrackIds) {
    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 && !hasRawSubtitleVariantSelection && requestedSubtitleTrackIds.length > 0) {
    effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds(encodeTitle.subtitleTracks, requestedSubtitleTrackIds);
  }

  const resolvedSubtitleSelection = resolveDeterministicSubtitleSelection({
    subtitleTracks: encodeTitle.subtitleTracks,
    subtitleVariantSelection: Object.fromEntries(effectiveSubtitleVariantSelection.map.entries()),
    subtitleLanguageOrder: explicitSubtitleLanguageOrder.length > 0
      ? explicitSubtitleLanguageOrder
      : effectiveSubtitleVariantSelection.order,
    fallbackSelectedSubtitleTrackIds: requestedSubtitleTrackIds,
    fallbackSelectedSubtitleTrackIdsOrdered: requestedSubtitleTrackIdsOrdered,
    hasExplicitVariantSelection: hasRawSubtitleVariantSelection || forceExplicitEmptySubtitleSelection
  });

  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,
    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 hasManualSubtitleVariantSelection = Boolean(
    manualMatchesTitle
    && manualSelection
    && Object.prototype.hasOwnProperty.call(manualSelection, 'subtitleVariantSelection')
  );
  const manualSubtitleLanguageOrder = manualMatchesTitle && Array.isArray(manualSelection?.subtitleLanguageOrder)
    ? manualSelection.subtitleLanguageOrder
    : [];

  const resolvedSubtitleSelection = resolveDeterministicSubtitleSelection({
    subtitleTracks: allSubtitleTracks,
    subtitleVariantSelection: manualSubtitleVariantSelection,
    subtitleLanguageOrder: manualSubtitleLanguageOrder,
    fallbackSelectedSubtitleTrackIds: fallbackSubtitleTrackIds,
    fallbackSelectedSubtitleTrackIdsOrdered: fallbackSubtitleTrackIdsOrdered,
    hasExplicitVariantSelection: hasManualSubtitleVariantSelection
  });

  const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIdsOrdered.length > 0
    ? resolvedSubtitleSelection.subtitleTrackIdsOrdered
    : (hasManualSubtitleVariantSelection
      ? []
      : (fallbackSubtitleTrackIdsOrdered.length > 0
        ? fallbackSubtitleTrackIdsOrdered
        : fallbackSubtitleTrackIds));
  const subtitleForcedTrackIndexes = resolvedSubtitleSelection.subtitleForcedSelectionIndexes;

  // Resolve burn/default/forced attributes from the actual track objects, matched by track?.id.
  const subtitleTrackIdSet = new Set(normalizeTrackIdList(subtitleTrackIds).map(String));
  const selectedSubtitleTracks = allSubtitleTracks.filter((t) => {
    const tid = normalizeTrackIdList([t?.id])[0];
    return tid !== undefined && subtitleTrackIdSet.has(String(tid));
  });

  const subtitleBurnTrackId = normalizeTrackIdList(
    selectedSubtitleTracks.filter((track) => Boolean(track?.burnIn)).map((track) => track?.id)
  )[0] || null;
  const subtitleDefaultTrackId = normalizeTrackIdList(
    selectedSubtitleTracks.filter((track) => Boolean(track?.defaultTrack)).map((track) => track?.id)
  )[0] || null;
  const subtitleForcedTrackId = subtitleForcedTrackIndexes.length === 1
    ? (subtitleTrackIds[subtitleForcedTrackIndexes[0] - 1] || null)
    : null;
  const subtitleForcedOnly = false;

  return {
    titleId: Number(encodeTitle?.id) || null,
    audioTrackIds,
    subtitleTrackIds,
    subtitleForcedTrackIndexes,
    subtitleVariantSelection: resolvedSubtitleSelection.subtitleVariantSelection,
    subtitleLanguageOrder: resolvedSubtitleSelection.subtitleLanguageOrder,
    subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors,
    subtitleBurnTrackId,
    subtitleDefaultTrackId,
    subtitleForcedTrackId,
    subtitleForcedOnly
  };
}

function buildPlaylistSegmentFileSet(playlistAnalysis, selectedPlaylistId = null) {
  const analysis = playlistAnalysis && typeof playlistAnalysis === 'object' ? playlistAnalysis : null;
  if (!analysis) {
    return new Set();
  }

  const segmentMap = analysis.playlistSegments && typeof analysis.playlistSegments === 'object'
    ? analysis.playlistSegments
    : {};

  const set = new Set();
  const appendSegments = (playlistIdRaw) => {
    const playlistId = normalizePlaylistId(playlistIdRaw);
    if (!playlistId) {
      return;
    }
    const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null;
    const segmentFiles = Array.isArray(segmentEntry?.segmentFiles) ? segmentEntry.segmentFiles : [];
    for (const file of segmentFiles) {
      const name = path.basename(String(file || '').trim()).toLowerCase();
      if (!name) {
        continue;
      }
      set.add(name);
    }
  };

  if (selectedPlaylistId) {
    appendSegments(selectedPlaylistId);
    return set;
  }

  appendSegments(analysis?.recommendation?.playlistId || null);
  if (set.size > 0) {
    return set;
  }

  const candidates = Array.isArray(analysis.evaluatedCandidates) ? analysis.evaluatedCandidates : [];
  for (const candidate of candidates) {
    appendSegments(candidate?.playlistId || null);
  }
  return set;
}


function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedPlaylistId = null } = {}) {
  const sourcePath = String(rawPath || '').trim();
  if (!sourcePath) {
    return {
      mediaFiles: [],
      source: 'none'
    };
  }

  try {
    const sourceStat = fs.statSync(sourcePath);
    if (sourceStat.isFile()) {
      const ext = path.extname(sourcePath).toLowerCase();
      const supportedSingleFileExts = new Set([
        '.aax', '.m4b', '.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts', '.flv', '.webm', '.iso'
      ]);
      if (
        supportedSingleFileExts.has(ext)
        || isLikelyExtensionlessDvdImageFile(sourcePath, sourceStat.size)
      ) {
        return {
          mediaFiles: [{ path: sourcePath, size: Number(sourceStat.size || 0) }],
          source: ext === '' ? 'single_extensionless' : 'single_file'
        };
      }
      return {
        mediaFiles: [],
        source: 'none'
      };
    }
  } catch (_error) {
    return {
      mediaFiles: [],
      source: 'none'
    };
  }

  const topLevelExtensionlessImages = listTopLevelExtensionlessDvdImages(sourcePath);
  if (topLevelExtensionlessImages.length > 0) {
    return {
      mediaFiles: topLevelExtensionlessImages,
      source: 'dvd_image'
    };
  }

  const primary = findMediaFiles(sourcePath, [
    '.aax', '.m4b', '.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.m2ts', '.flv', '.webm', '.iso'
  ]);
  if (primary.length > 0) {
    return {
      mediaFiles: primary,
      source: path.extname(primary[0]?.path || '').toLowerCase() === '.aax' ? 'audiobook' : 'mkv'
    };
  }

  const streamDir = path.join(sourcePath, 'BDMV', 'STREAM');
  const backupRoot = fs.existsSync(streamDir) ? streamDir : sourcePath;
  let backupFiles = findMediaFiles(backupRoot, ['.m2ts']);
  if (backupFiles.length === 0) {
    const vobFiles = findMediaFiles(sourcePath, ['.vob']);
    if (vobFiles.length > 0) {
      return {
        mediaFiles: vobFiles,
        source: 'dvd'
      };
    }
    return {
      mediaFiles: [],
      source: 'none'
    };
  }

  const allowedSegments = buildPlaylistSegmentFileSet(playlistAnalysis, selectedPlaylistId);
  if (allowedSegments.size > 0) {
    const filtered = backupFiles.filter((file) => allowedSegments.has(path.basename(file.path).toLowerCase()));
    if (filtered.length > 0) {
      backupFiles = filtered;
    }
  }

  return {
    mediaFiles: backupFiles,
    source: 'backup'
  };
}

function hasBluRayBackupStructure(rawPath) {
  if (!rawPath) {
    return false;
  }

  const bdmvDir = path.join(rawPath, 'BDMV');
  const streamDir = path.join(bdmvDir, 'STREAM');

  try {
    return fs.existsSync(bdmvDir) && fs.existsSync(streamDir);
  } catch (_error) {
    return false;
  }
}

function findPreferredRawInput(rawPath, options = {}) {
  const { mediaFiles } = collectRawMediaCandidates(rawPath, options);
  if (!Array.isArray(mediaFiles) || mediaFiles.length === 0) {
    return null;
  }
  return mediaFiles[0];
}

function extractManualSelectionPayloadFromPlan(encodePlan) {
  const selection = extractHandBrakeTrackSelectionFromPlan(encodePlan);
  if (!selection) {
    return null;
  }
  return {
    audioTrackIds: normalizeTrackIdList(selection.audioTrackIds),
    subtitleTrackIds: normalizeTrackIdList(selection.subtitleTrackIds),
    subtitleTrackIdsOrdered: normalizeTrackIdSequence(selection.subtitleTrackIds, { dedupe: false }),
    subtitleForcedTrackIndexes: normalizeTrackIdList(selection.subtitleForcedTrackIndexes),
    subtitleVariantSelection: selection.subtitleVariantSelection || null,
    subtitleLanguageOrder: Array.isArray(selection.subtitleLanguageOrder) ? selection.subtitleLanguageOrder : []
  };
}

function normalizeChainIdList(rawList) {
  const list = Array.isArray(rawList) ? rawList : [];
  const seen = new Set();
  const output = [];
  for (const item of list) {
    const value = Number(item);
    if (!Number.isFinite(value) || value <= 0) {
      continue;
    }
    const normalized = Math.trunc(value);
    const key = String(normalized);
    if (seen.has(key)) {
      continue;
    }
    seen.add(key);
    output.push(normalized);
  }
  return output;
}

function normalizeUserPresetForPlan(rawPreset) {
  if (!rawPreset || typeof rawPreset !== 'object') {
    return null;
  }
  const rawId = Number(rawPreset.id);
  const presetId = Number.isFinite(rawId) && rawId > 0 ? Math.trunc(rawId) : null;
  const name = String(rawPreset.name || '').trim();
  const handbrakePreset = String(rawPreset.handbrakePreset || '').trim();
  const extraArgs = String(rawPreset.extraArgs || '').trim();
  if (!presetId && !name && !handbrakePreset && !extraArgs) {
    return null;
  }
  return {
    id: presetId,
    name: name || (presetId ? `Preset #${presetId}` : 'User-Preset'),
    handbrakePreset: handbrakePreset || null,
    extraArgs: extraArgs || null
  };
}

function buildScriptDescriptorList(scriptIds, sourceScripts = []) {
  const normalizedIds = normalizeScriptIdList(scriptIds);
  if (normalizedIds.length === 0) {
    return [];
  }
  const source = Array.isArray(sourceScripts) ? sourceScripts : [];
  const namesById = new Map(
    source
      .map((item) => {
        const id = Number(item?.id ?? item?.scriptId);
        const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null;
        const name = String(item?.name || '').trim();
        if (!normalizedId || !name) {
          return null;
        }
        return [normalizedId, name];
      })
      .filter(Boolean)
  );
  return normalizedIds.map((id) => ({
    id,
    name: namesById.get(id) || `Skript #${id}`
  }));
}

function findSelectedTitleInPlan(encodePlan) {
  if (!encodePlan || !Array.isArray(encodePlan.titles) || encodePlan.titles.length === 0) {
    return null;
  }
  const preferredTitleId = normalizeReviewTitleId(encodePlan.encodeInputTitleId);
  if (preferredTitleId) {
    const byId = encodePlan.titles.find((title) => normalizeReviewTitleId(title?.id) === preferredTitleId) || null;
    if (byId) {
      return byId;
    }
  }
  return encodePlan.titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) || null;
}

function resolvePrefillEncodeTitleId(reviewPlan, previousPlan) {
  const reviewTitles = Array.isArray(reviewPlan?.titles) ? reviewPlan.titles : [];
  if (reviewTitles.length === 0) {
    return null;
  }

  const previousSelectedTitle = findSelectedTitleInPlan(previousPlan);
  if (!previousSelectedTitle) {
    return null;
  }

  const previousPlaylistId = normalizePlaylistId(
    previousSelectedTitle?.playlistId
    || previousPlan?.selectedPlaylistId
    || null
  );
  if (previousPlaylistId) {
    const byPlaylist = reviewTitles.find((title) => normalizePlaylistId(title?.playlistId) === previousPlaylistId) || null;
    const id = normalizeReviewTitleId(byPlaylist?.id);
    if (id) {
      return id;
    }
  }

  const previousMakemkvTitleId = normalizeNonNegativeInteger(
    previousSelectedTitle?.makemkvTitleId
    ?? previousPlan?.selectedMakemkvTitleId
    ?? null
  );
  if (previousMakemkvTitleId !== null) {
    const byMakemkvTitleId = reviewTitles.find((title) => (
      normalizeNonNegativeInteger(title?.makemkvTitleId) === previousMakemkvTitleId
    )) || null;
    const id = normalizeReviewTitleId(byMakemkvTitleId?.id);
    if (id) {
      return id;
    }
  }

  const previousFileName = path.basename(
    String(previousSelectedTitle?.filePath || previousSelectedTitle?.fileName || '').trim()
  ).toLowerCase();
  if (previousFileName) {
    const byFileName = reviewTitles.find((title) => {
      const candidate = path.basename(
        String(title?.filePath || title?.fileName || '').trim()
      ).toLowerCase();
      return candidate && candidate === previousFileName;
    }) || null;
    const id = normalizeReviewTitleId(byFileName?.id);
    if (id) {
      return id;
    }
  }

  const previousTitleId = normalizeReviewTitleId(previousPlan?.encodeInputTitleId);
  if (!previousTitleId) {
    return null;
  }
  const fallback = reviewTitles.find((title) => normalizeReviewTitleId(title?.id) === previousTitleId) || null;
  return normalizeReviewTitleId(fallback?.id);
}

function mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds, { excludeBurned = false } = {}) {
  const tracks = Array.isArray(targetTracks) ? targetTracks : [];
  const allowedTracks = excludeBurned
    ? tracks.filter((track) => !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate))
    : tracks;
  const requested = normalizeTrackIdList(sourceTrackIds);
  if (requested.length === 0 || allowedTracks.length === 0) {
    return [];
  }

  const mapped = [];
  const seen = new Set();
  for (const sourceTrackId of requested) {
    const match = allowedTracks.find((track) => {
      const sourceId = normalizeTrackIdList([track?.sourceTrackId])[0] || null;
      const reviewId = normalizeTrackIdList([track?.id])[0] || null;
      return sourceId === sourceTrackId || reviewId === sourceTrackId;
    }) || null;
    const targetId = normalizeTrackIdList([match?.id])[0] || null;
    if (targetId === null) {
      continue;
    }
    const key = String(targetId);
    if (seen.has(key)) {
      continue;
    }
    seen.add(key);
    mapped.push(targetId);
  }
  return mapped;
}

function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = null) {
  const hasReviewTitles = reviewPlan && Array.isArray(reviewPlan?.titles) && reviewPlan.titles.length > 0;
  const hasPreviousTitles = previousPlan && Array.isArray(previousPlan?.titles) && previousPlan.titles.length > 0;
  if (!hasReviewTitles || !hasPreviousTitles) {
    return {
      plan: reviewPlan,
      applied: false,
      selectedEncodeTitleId: normalizeReviewTitleId(reviewPlan?.encodeInputTitleId),
      preEncodeScriptCount: 0,
      postEncodeScriptCount: 0,
      preEncodeChainCount: 0,
      postEncodeChainCount: 0,
      userPresetApplied: false
    };
  }

  let nextPlan = reviewPlan;
  const prefillTitleId = resolvePrefillEncodeTitleId(nextPlan, previousPlan);
  let selectedTitleApplied = false;
  if (prefillTitleId) {
    try {
      const remapped = applyEncodeTitleSelectionToPlan(nextPlan, prefillTitleId);
      nextPlan = remapped.plan;
      selectedTitleApplied = true;
    } catch (_error) {
      // Keep calculated review defaults when title from previous run is no longer available.
    }
  }

  const previousSelectedTitle = findSelectedTitleInPlan(previousPlan);
  const nextSelectedTitle = findSelectedTitleInPlan(nextPlan);
  let trackSelectionApplied = false;
  if (previousSelectedTitle && nextSelectedTitle) {
    const previousAudioSourceIds = normalizeTrackIdList(
      (Array.isArray(previousSelectedTitle?.audioTracks) ? previousSelectedTitle.audioTracks : [])
        .filter((track) => Boolean(track?.selectedForEncode))
        .map((track) => track?.sourceTrackId ?? track?.id)
    );
    const previousSubtitleSourceIds = normalizeTrackIdList(
      (Array.isArray(previousSelectedTitle?.subtitleTracks) ? previousSelectedTitle.subtitleTracks : [])
        .filter((track) => Boolean(track?.selectedForEncode))
        .map((track) => track?.sourceTrackId ?? track?.id)
    );

    const mappedAudioTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds(
      nextSelectedTitle?.audioTracks,
      previousAudioSourceIds
    );
    const mappedSubtitleTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds(
      nextSelectedTitle?.subtitleTracks,
      previousSubtitleSourceIds,
      { excludeBurned: true }
    );
    const fallbackAudioTrackIds = normalizeTrackIdList(
      (Array.isArray(nextSelectedTitle?.audioTracks) ? nextSelectedTitle.audioTracks : [])
        .filter((track) => Boolean(track?.selectedByRule))
        .map((track) => track?.id)
    );
    const fallbackSubtitleTrackIds = normalizeTrackIdList(
      (Array.isArray(nextSelectedTitle?.subtitleTracks) ? nextSelectedTitle.subtitleTracks : [])
        .filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate))
        .map((track) => track?.id)
    );
    const effectiveAudioTrackIds = previousAudioSourceIds.length > 0 && mappedAudioTrackIds.length === 0
      ? fallbackAudioTrackIds
      : mappedAudioTrackIds;
    const effectiveSubtitleTrackIds = previousSubtitleSourceIds.length > 0 && mappedSubtitleTrackIds.length === 0
      ? fallbackSubtitleTrackIds
      : mappedSubtitleTrackIds;

    const targetTitleId = normalizeReviewTitleId(nextSelectedTitle?.id || nextPlan?.encodeInputTitleId);
    if (targetTitleId) {
      const trackSelectionResult = applyManualTrackSelectionToPlan(nextPlan, {
        [targetTitleId]: {
          audioTrackIds: effectiveAudioTrackIds,
          subtitleTrackIds: effectiveSubtitleTrackIds
        }
      });
      nextPlan = trackSelectionResult.plan;
      trackSelectionApplied = Boolean(trackSelectionResult.selectionApplied);
    }
  }

  const preEncodeScriptIds = normalizeScriptIdList(previousPlan?.preEncodeScriptIds || []);
  const postEncodeScriptIds = normalizeScriptIdList(previousPlan?.postEncodeScriptIds || []);
  const preEncodeChainIds = normalizeChainIdList(previousPlan?.preEncodeChainIds || []);
  const postEncodeChainIds = normalizeChainIdList(previousPlan?.postEncodeChainIds || []);
  const userPreset = normalizeUserPresetForPlan(previousPlan?.userPreset || null);

  nextPlan = {
    ...nextPlan,
    preEncodeScriptIds,
    postEncodeScriptIds,
    preEncodeScripts: buildScriptDescriptorList(preEncodeScriptIds, previousPlan?.preEncodeScripts || []),
    postEncodeScripts: buildScriptDescriptorList(postEncodeScriptIds, previousPlan?.postEncodeScripts || []),
    preEncodeChainIds,
    postEncodeChainIds,
    userPreset,
    reviewConfirmed: false,
    reviewConfirmedAt: null,
    prefilledFromPreviousRun: true,
    prefilledFromPreviousRunAt: nowIso()
  };

  const applied = selectedTitleApplied
    || trackSelectionApplied
    || preEncodeScriptIds.length > 0
    || postEncodeScriptIds.length > 0
    || preEncodeChainIds.length > 0
    || postEncodeChainIds.length > 0
    || Boolean(userPreset);

  return {
    plan: nextPlan,
    applied,
    selectedEncodeTitleId: normalizeReviewTitleId(nextPlan?.encodeInputTitleId),
    preEncodeScriptCount: preEncodeScriptIds.length,
    postEncodeScriptCount: postEncodeScriptIds.length,
    preEncodeChainCount: preEncodeChainIds.length,
    postEncodeChainCount: postEncodeChainIds.length,
    userPresetApplied: Boolean(userPreset)
  };
}

class PipelineService extends EventEmitter {
  constructor() {
    super();
    this.snapshot = {
      state: 'IDLE',
      activeJobId: null,
      progress: 0,
      eta: null,
      statusText: null,
      context: {}
    };
    this.detectedDisc = null;
    this.cdDrives = new Map(); // devicePath → per-drive CD state object
    this.driveLocksByJob = new Map(); // jobId -> { devicePath, owner }
    this.activeProcess = null;
    this.activeProcesses = new Map();
    this.cancelRequestedByJob = new Set();
    this.jobProgress = new Map();
    this.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);
    return {
      scanLines: Array.isArray(pluginResult?.scanLines) ? pluginResult.scanLines : [],
      runInfo: pluginResult?.runInfo && typeof pluginResult.runInfo === 'object'
        ? pluginResult.runInfo
        : null,
      sourceArg: String(pluginResult?.sourceArg || '').trim() || null,
      pluginExecution: this.sanitizePluginExecutionState(pluginCtx.getPluginExecution())
    };
  }

  isRipSuccessful(job = null) {
    if (Number(job?.rip_successful || 0) === 1) {
      return true;
    }
    if (isJobFinished(job)) {
      return true;
    }
    const mkInfo = this.safeParseJson(job?.makemkv_info_json);
    return String(mkInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
  }

  isEncodeSuccessful(job = null) {
    const handBrakeInfo = this.safeParseJson(job?.handbrake_info_json);
    return String(handBrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
  }

  resolveDesiredRawFolderState(job = null) {
    if (!this.isRipSuccessful(job)) {
      return RAW_FOLDER_STATES.INCOMPLETE;
    }
    if (this.isEncodeSuccessful(job)) {
      return RAW_FOLDER_STATES.COMPLETE;
    }
    return RAW_FOLDER_STATES.RIP_COMPLETE;
  }

  resolveCurrentRawPath(rawBaseDir, storedRawPath, extraBaseDirs = []) {
    const stored = String(storedRawPath || '').trim();
    if (!stored) {
      return null;
    }
    const folderName = path.basename(stored);
    const currentBaseDir = path.dirname(stored);
    const allBaseDirs = [currentBaseDir, rawBaseDir, ...extraBaseDirs].filter(Boolean);
    const uniqueBaseDirs = Array.from(new Set(allBaseDirs.map((item) => String(item).trim()).filter(Boolean)));
    const variantFolderNames = Array.from(
      new Set(
        [
          folderName,
          applyRawFolderStateToName(folderName, RAW_FOLDER_STATES.RIP_COMPLETE),
          applyRawFolderStateToName(folderName, RAW_FOLDER_STATES.INCOMPLETE),
          applyRawFolderStateToName(folderName, RAW_FOLDER_STATES.COMPLETE)
        ].map((item) => String(item || '').trim()).filter(Boolean)
      )
    );
    const candidates = [];
    const pushCandidate = (candidatePath) => {
      const normalized = String(candidatePath || '').trim();
      if (!normalized || candidates.includes(normalized)) {
        return;
      }
      candidates.push(normalized);
    };
    pushCandidate(stored);
    for (const baseDir of uniqueBaseDirs) {
      for (const variantFolderName of variantFolderNames) {
        pushCandidate(path.join(baseDir, variantFolderName));
      }
    }
    const existingDirectories = [];
    for (const candidate of candidates) {
      try {
        if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
          existingDirectories.push(candidate);
        }
      } catch (_error) {
        // ignore fs errors
      }
    }
    if (existingDirectories.length === 0) {
      return null;
    }

    for (const candidate of existingDirectories) {
      try {
        if (hasBluRayBackupStructure(candidate) || findPreferredRawInput(candidate)) {
          return candidate;
        }
      } catch (_error) {
        // ignore fs errors
      }
    }

    return existingDirectories[0];
  }

  buildRawPathLookupConfig(settingsMap = {}, mediaProfile = null) {
    const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
    const normalizedMediaProfile = normalizeMediaProfile(mediaProfile);
    const effectiveSettings = settingsService.resolveEffectiveToolSettings(sourceMap, normalizedMediaProfile);
    const preferredDefaultRawDir = normalizedMediaProfile === 'cd'
      ? settingsService.DEFAULT_CD_DIR
      : normalizedMediaProfile === 'audiobook'
        ? settingsService.DEFAULT_AUDIOBOOK_RAW_DIR
      : settingsService.DEFAULT_RAW_DIR;
    const uniqueRawDirs = Array.from(
      new Set(
        [
          effectiveSettings?.raw_dir,
          effectiveSettings?.series_raw_dir,
          sourceMap?.raw_dir,
          sourceMap?.raw_dir_bluray,
          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,
        inputPath: null,
        hasUsableRawInput: false
      };
    }

    let inputPath = null;
    try {
      if (hasBluRayBackupStructure(resolvedRawPath)) {
        inputPath = resolvedRawPath;
      } else {
        const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job);
        inputPath = findPreferredRawInput(resolvedRawPath, {
          playlistAnalysis: playlistDecision.playlistAnalysis,
          selectedPlaylistId: playlistDecision.selectedPlaylist
        })?.path || null;
      }
    } catch (_error) {
      inputPath = null;
    }

    return {
      mediaProfile,
      resolvedRawPath,
      inputPath,
      hasUsableRawInput: Boolean(inputPath)
    };
  }

  async alignRawFolderJobId(rawPath, targetJobId) {
    const sourceRawPath = String(rawPath || '').trim();
    const normalizedTargetJobId = this.normalizeQueueJobId(targetJobId);
    if (!sourceRawPath || !normalizedTargetJobId) {
      return sourceRawPath || null;
    }

    const normalizedSourceRawPath = normalizeComparablePath(sourceRawPath);
    const folderName = path.basename(normalizedSourceRawPath);
    const baseName = stripRawStatePrefix(folderName);
    if (!/\s-\sRAW\s-\sjob-\d+\s*$/i.test(baseName)) {
      return normalizedSourceRawPath;
    }

    const metadataBase = baseName.replace(/\s-\sRAW\s-\sjob-\d+\s*$/i, '').trim();
    if (!metadataBase) {
      return normalizedSourceRawPath;
    }

    const rawState = resolveRawFolderStateFromPath(normalizedSourceRawPath);
    const nextFolderName = buildRawDirName(metadataBase, normalizedTargetJobId, { state: rawState });
    const normalizedTargetRawPath = normalizeComparablePath(
      path.join(path.dirname(normalizedSourceRawPath), nextFolderName)
    );
    if (!normalizedTargetRawPath || normalizedTargetRawPath === normalizedSourceRawPath) {
      return normalizedSourceRawPath;
    }

    try {
      if (!fs.existsSync(normalizedSourceRawPath) || !fs.statSync(normalizedSourceRawPath).isDirectory()) {
        return normalizedSourceRawPath;
      }
      if (fs.existsSync(normalizedTargetRawPath)) {
        logger.warn('raw-path:align-job-id:target-exists', {
          targetJobId: normalizedTargetJobId,
          sourceRawPath: normalizedSourceRawPath,
          targetRawPath: normalizedTargetRawPath
        });
        return normalizedSourceRawPath;
      }

      fs.renameSync(normalizedSourceRawPath, normalizedTargetRawPath);
      await historyService.updateRawPathByOldPath(normalizedSourceRawPath, normalizedTargetRawPath);
      logger.info('raw-path:align-job-id:renamed', {
        targetJobId: normalizedTargetJobId,
        from: normalizedSourceRawPath,
        to: normalizedTargetRawPath
      });
      return normalizedTargetRawPath;
    } catch (error) {
      logger.warn('raw-path:align-job-id:failed', {
        targetJobId: normalizedTargetJobId,
        sourceRawPath: normalizedSourceRawPath,
        targetRawPath: normalizedTargetRawPath,
        error: errorToMeta(error)
      });
      return normalizedSourceRawPath;
    }
  }

  async migrateRawFolderNamingOnStartup(db) {
    const settings = await settingsService.getSettingsMap();
    const rawBaseDir = String(settings?.raw_dir || settingsService.DEFAULT_RAW_DIR || '').trim();
    const rawExtraDirs = [
      settings?.raw_dir_bluray,
      settings?.raw_dir_dvd,
      settings?.raw_dir_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
      FROM jobs
      WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> ''
        AND (media_type IS NULL OR media_type <> 'converter')
        AND (job_kind IS NULL OR job_kind NOT LIKE 'converter_%')
    `);
    if (!Array.isArray(rows) || rows.length === 0) {
      return;
    }

    let renamedCount = 0;
    let pathUpdateCount = 0;
    let ripFlagUpdateCount = 0;
    let conflictCount = 0;
    let missingCount = 0;
    const discoveredByJobId = new Map();

    for (const scanDir of allRawDirs) {
      try {
        const dirEntries = fs.readdirSync(scanDir, { withFileTypes: true });
        for (const entry of dirEntries) {
          if (!entry.isDirectory()) {
            continue;
          }
          const match = String(entry.name || '').match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i);
          if (!match) {
            continue;
          }
          const mappedJobId = Number(match[1]);
          if (!Number.isFinite(mappedJobId) || mappedJobId <= 0) {
            continue;
          }
          const candidatePath = path.join(scanDir, entry.name);
          let mtimeMs = 0;
          try {
            mtimeMs = Number(fs.statSync(candidatePath).mtimeMs || 0);
          } catch (_error) {
            // ignore fs errors and keep zero mtime
          }
          const current = discoveredByJobId.get(mappedJobId);
          if (!current || mtimeMs > current.mtimeMs) {
            discoveredByJobId.set(mappedJobId, {
              path: candidatePath,
              mtimeMs
            });
          }
        }
      } catch (scanError) {
        logger.warn('startup:raw-dir-migrate:scan-failed', {
          scanDir,
          error: errorToMeta(scanError)
        });
      }
    }

    for (const row of rows) {
      const jobId = Number(row?.id);
      if (!Number.isFinite(jobId) || jobId <= 0) {
        continue;
      }

      const ripSuccessful = this.isRipSuccessful(row);
      if (ripSuccessful && Number(row?.rip_successful || 0) !== 1) {
        await historyService.updateJob(jobId, { rip_successful: 1 });
        ripFlagUpdateCount += 1;
      }

      const currentRawPath = this.resolveCurrentRawPath(rawBaseDir, row.raw_path, rawExtraDirs)
        || discoveredByJobId.get(jobId)?.path
        || null;
      if (!currentRawPath) {
        missingCount += 1;
        continue;
      }

      // Keep renamed folder in the same base dir as the current path
      const currentBaseDir = path.dirname(currentRawPath);
      const currentFolderName = stripRawStatePrefix(path.basename(currentRawPath));
      const folderYearMatch = currentFolderName.match(/\((19|20)\d{2}\)/);
      const fallbackYear = folderYearMatch
        ? Number(String(folderYearMatch[0]).replace(/[()]/g, ''))
        : null;
      const metadataBase = buildRawMetadataBase({
        title: row.title || row.detected_title || null,
        year: row.year || null,
        fallbackYear
      }, jobId);
      const desiredRawFolderState = this.resolveDesiredRawFolderState(row);
      const desiredRawPath = path.join(
        currentBaseDir,
        buildRawDirName(metadataBase, jobId, { state: desiredRawFolderState })
      );

      let finalRawPath = currentRawPath;
      if (normalizeComparablePath(currentRawPath) !== normalizeComparablePath(desiredRawPath)) {
        if (fs.existsSync(desiredRawPath)) {
          conflictCount += 1;
          logger.warn('startup:raw-dir-migrate:target-exists', {
            jobId,
            currentRawPath,
            desiredRawPath
          });
        } else {
          try {
            fs.renameSync(currentRawPath, desiredRawPath);
            finalRawPath = desiredRawPath;
            renamedCount += 1;
          } catch (renameError) {
            logger.warn('startup:raw-dir-migrate:rename-failed', {
              jobId,
              currentRawPath,
              desiredRawPath,
              error: errorToMeta(renameError)
            });
            continue;
          }
        }
      }

      if (normalizeComparablePath(row.raw_path) !== normalizeComparablePath(finalRawPath)) {
        await historyService.updateRawPathByOldPath(row.raw_path, finalRawPath);
        pathUpdateCount += 1;
      }
    }

    if (renamedCount > 0 || pathUpdateCount > 0 || ripFlagUpdateCount > 0 || conflictCount > 0 || missingCount > 0) {
      logger.info('startup:raw-dir-migrate:done', {
        renamedCount,
        pathUpdateCount,
        ripFlagUpdateCount,
        conflictCount,
        missingCount,
        scannedDirs: allRawDirs
      });
    }
  }

  async init() {
    const db = await getDb();
    try {
      await this.migrateRawFolderNamingOnStartup(db);
    } catch (migrationError) {
      logger.warn('init:raw-dir-migrate-failed', {
        error: errorToMeta(migrationError)
      });
    }
    const row = await db.get('SELECT * FROM pipeline_state WHERE id = 1');

    if (row) {
      this.snapshot = {
        state: row.state,
        activeJobId: row.active_job_id,
        progress: Number(row.progress || 0),
        eta: row.eta,
        statusText: row.status_text,
        context: this.safeParseJson(row.context_json)
      };
      logger.info('init:loaded-snapshot', { snapshot: this.snapshot });
    }

    try {
      await this.recoverStaleRunningJobsOnStartup(db);
    } catch (recoveryError) {
      logger.warn('init:stale-running-recovery-failed', {
        error: errorToMeta(recoveryError)
      });
    }

    try {
      await this.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)
      });
    }

    // Always start with a clean ripper/session snapshot after server restart.
    const hasContextKeys = this.snapshot.context
      && typeof this.snapshot.context === 'object'
      && Object.keys(this.snapshot.context).length > 0;
    if (this.snapshot.state !== 'IDLE' || this.snapshot.activeJobId || hasContextKeys) {
      await this.resetFrontendState('server_restart', {
        force: true,
        keepDetectedDevice: false
      });
    }
    await this.emitQueueChanged();
    void this.pumpQueue();
  }

  async recoverStaleRunningJobsOnStartup(db) {
    const staleRows = await db.all(`
      SELECT id, status, last_state
      FROM jobs
      WHERE COALESCE(job_kind, '') != 'dvd_series_container'
        AND status IN ('ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING',
                       'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING')
      ORDER BY updated_at ASC, id ASC
    `);
    const rows = Array.isArray(staleRows) ? staleRows : [];
    if (rows.length === 0) {
      return {
        scanned: 0,
        preparedReadyToEncode: 0,
        markedError: 0,
        skipped: 0
      };
    }

    let preparedReadyToEncode = 0;
    let markedError = 0;
    let skipped = 0;

    for (const row of rows) {
      const jobId = this.normalizeQueueJobId(row?.id);
      if (!jobId) {
        skipped += 1;
        continue;
      }
      const rawStage = String(row?.status || row?.last_state || '').trim().toUpperCase();
      const stage = RUNNING_STATES.has(rawStage) ? rawStage : 'ENCODING';
      const message = `Server-Neustart erkannt während ${stage}. Laufender Prozess wurde beendet.`;

      if (stage === 'ENCODING') {
        try {
          await historyService.appendLog(jobId, 'SYSTEM', message);
        } catch (_error) {
          // keep recovery path even if log append fails
        }
        try {
          await this.restartEncodeWithLastSettings(jobId, {
            immediate: true,
            triggerReason: 'server_restart'
          });
          preparedReadyToEncode += 1;
          continue;
        } catch (error) {
          logger.warn('startup:recover-stale-encoding:restart-failed', {
            jobId,
            error: errorToMeta(error)
          });
          try {
            await historyService.appendLog(
              jobId,
              'SYSTEM',
              `Startup-Recovery Encode fehlgeschlagen, setze Job auf ERROR: ${error?.message || 'unknown'}`
            );
          } catch (_logError) {
            // ignore logging fallback errors
          }
        }
      }

      if (stage === 'CD_ENCODING') {
        // CD was encoding WAVs — try to restart the encode from existing WAV files
        try {
          await historyService.appendLog(jobId, 'SYSTEM', message);
        } catch (_error) {
          // keep recovery path even if log append fails
        }
        try {
          await this.reencodeFromRaw(jobId, { immediate: true });
          preparedReadyToEncode += 1;
          continue;
        } catch (error) {
          logger.warn('startup:recover-stale-cd-encoding:restart-failed', {
            jobId,
            error: errorToMeta(error)
          });
          try {
            await historyService.appendLog(
              jobId,
              'SYSTEM',
              `Startup-Recovery CD-Encode fehlgeschlagen, setze Job auf ERROR: ${error?.message || 'unknown'}`
            );
          } catch (_logError) {
            // ignore logging fallback errors
          }
        }
      }

      await historyService.updateJob(jobId, {
        status: 'ERROR',
        last_state: stage,
        end_time: nowIso(),
        error_message: message
      });
      try {
        await historyService.appendLog(jobId, 'SYSTEM', message);
      } catch (_error) {
        // ignore logging failures during startup recovery
      }
      markedError += 1;
    }

    logger.warn('startup:recover-stale-running-jobs', {
      scanned: rows.length,
      preparedReadyToEncode,
      markedError,
      skipped
    });
    return {
      scanned: rows.length,
      preparedReadyToEncode,
      markedError,
      skipped
    };
  }

  safeParseJson(raw) {
    if (!raw) {
      return {};
    }

    try {
      return JSON.parse(raw);
    } catch (error) {
      logger.warn('safeParseJson:failed', { raw, error: errorToMeta(error) });
      return {};
    }
  }

  getSnapshot() {
    const jobProgress = {};
    for (const [id, data] of this.jobProgress) {
      jobProgress[id] = data;
    }
    const cdDrives = {};
    for (const [devicePath, driveState] of this.cdDrives) {
      cdDrives[devicePath] = driveState;
    }
    const detectedDiscs = {};
    for (const [path, device] of diskDetectionService.detectedDiscs) {
      detectedDiscs[path] = device;
    }
    const driveLocks = diskDetectionService.getActiveLocks();
    return {
      ...this.snapshot,
      jobProgress,
      queue: this.lastQueueSnapshot,
      cdDrives,
      detectedDiscs,
      driveLocks
    };
  }

  _broadcastPipelineStateChanged() {
    const snapshotPayload = this.getSnapshot();
    wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload);
    this.emit('stateChanged', snapshotPayload);
    return snapshotPayload;
  }

  _setCdDriveState(devicePath, updates) {
    const existing = this.cdDrives.get(devicePath) || {
      devicePath,
      state: 'DISC_DETECTED',
      jobId: null,
      device: null,
      progress: 0,
      eta: null,
      statusText: null,
      context: {}
    };
    const next = {
      ...existing,
      ...updates,
      devicePath
    };
    this.cdDrives.set(devicePath, next);

    // If a drive is detached from a job (or reassigned), clear stale live
    // progress for the previous job so ripper views do not stay in CD_ENCODING.
    const previousJobId = Number(existing?.jobId || 0);
    const nextJobId = Number(next?.jobId || 0);
    if (Number.isFinite(previousJobId) && previousJobId > 0) {
      const previousJobStillBound = Number.isFinite(nextJobId) && nextJobId > 0 && nextJobId === previousJobId;
      if (!previousJobStillBound) {
        this.jobProgress.delete(previousJobId);
      }
    }

    // Sync jobProgress so per-job queries work
    const resolvedJobId = Number(next.jobId || 0);
    if (Number.isFinite(resolvedJobId) && resolvedJobId > 0) {
      const prev = this.jobProgress.get(resolvedJobId) || {};
      const contextPatch = next.context && typeof next.context === 'object' ? next.context : null;
      const mergedContext = contextPatch
        ? { ...(prev.context || {}), ...contextPatch }
        : (prev.context || null);
      const nextJobProgress = {
        ...prev,
        state: next.state,
        progress: next.progress ?? prev.progress ?? 0,
        eta: next.eta ?? null,
        statusText: next.statusText ?? null
      };
      if (mergedContext && Object.keys(mergedContext).length > 0) {
        nextJobProgress.context = mergedContext;
      }
      this.jobProgress.set(resolvedJobId, nextJobProgress);
    }

    // Manage disc-polling suspension while any CD drive is actively ripping
    const CD_ACTIVE_STATES = new Set(['CD_ANALYZING', 'CD_RIPPING']);
    const anyActive = Array.from(this.cdDrives.values()).some((d) => CD_ACTIVE_STATES.has(d.state));
    if (anyActive) {
      diskDetectionService.suspendPolling();
    } else {
      diskDetectionService.resumePolling();
    }

    logger.info('cd:drive:state', { devicePath, state: next.state, jobId: next.jobId });
    const snapshotPayload = this.getSnapshot();
    wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload);
    this.emit('stateChanged', snapshotPayload);
    void this.emitQueueChanged();
  }

  _removeCdDrive(devicePath) {
    const drive = this.cdDrives.get(devicePath);
    if (!drive) {
      return;
    }
    const jobId = Number(drive.jobId || 0);
    this.cdDrives.delete(devicePath);
    if (Number.isFinite(jobId) && jobId > 0) {
      this.jobProgress.delete(jobId);
    }
    // Resume polling if no other active drives
    const CD_ACTIVE_STATES = new Set(['CD_ANALYZING', 'CD_RIPPING']);
    const anyActive = Array.from(this.cdDrives.values()).some((d) => CD_ACTIVE_STATES.has(d.state));
    if (!anyActive) {
      diskDetectionService.resumePolling();
    }
    logger.info('cd:drive:removed', { devicePath });
    const snapshotPayload = this.getSnapshot();
    wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload);
    this.emit('stateChanged', snapshotPayload);
  }

  _getCdDriveByJobId(jobId) {
    const normalizedId = Number(jobId);
    if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
      return null;
    }
    for (const [devicePath, drive] of this.cdDrives) {
      if (Number(drive.jobId) === normalizedId) {
        return { ...drive, devicePath };
      }
    }
    return null;
  }

  _releaseCdDrive(devicePath, options = {}) {
    const normalizedPath = String(devicePath || '').trim();
    if (!normalizedPath) {
      return;
    }

    const existing = this.cdDrives.get(normalizedPath) || null;
    const fallbackDevice = options?.device && typeof options.device === 'object'
      ? options.device
      : (existing?.device && typeof existing.device === 'object'
          ? existing.device
          : { path: normalizedPath, mediaProfile: 'cd' });
    const releasedDevice = {
      ...fallbackDevice,
      path: normalizedPath,
      mediaProfile: 'cd'
    };

    this._setCdDriveState(normalizedPath, {
      state: 'DISC_DETECTED',
      jobId: null,
      device: releasedDevice,
      progress: 0,
      eta: null,
      statusText: null,
      context: {
        device: releasedDevice,
        devicePath: normalizedPath,
        mediaProfile: 'cd'
      }
    });
  }

  normalizeDrivePath(value) {
    return String(value || '').trim();
  }

  _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) || '<unbekannt>';
    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_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;
    if (!this.normalizeDrivePath(job?.disc_device)) {
      return false;
    }
    if (status === 'RIPPING' || status === 'CD_RIPPING') {
      return true;
    }
    if ((status === 'ERROR' || status === 'CANCELLED') && !ripSuccessful) {
      return lastState === 'RIPPING' || lastState === 'CD_RIPPING';
    }
    return false;
  }

  async _restoreDriveLocksFromJobs() {
    const db = await getDb();
    const rows = await db.all(
      `
        SELECT id, disc_device, status, last_state, rip_successful, job_kind
        FROM jobs
        WHERE disc_device IS NOT NULL
          AND TRIM(disc_device) <> ''
          AND COALESCE(job_kind, '') != 'dvd_series_container'
          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_SELECTION',
      'WAITING_FOR_USER_DECISION',
      'READY_TO_START',
      'MEDIAINFO_CHECK',
      // READY_TO_ENCODE intentionally excluded: user has confirmed encoding,
      // a new analyze must not silently delete a queued encode job.
      'CD_ANALYZING',
      'CD_METADATA_SELECTION',
      // CD_READY_TO_RIP intentionally excluded: user has confirmed track
      // selection, a new analyze must not discard that.
      'ERROR',
      'CANCELLED'
    ]);
    const deleted = [];
    for (const row of (Array.isArray(rows) ? rows : [])) {
      const jobId = Number(row?.id);
      if (!Number.isFinite(jobId) || jobId <= 0) {
        continue;
      }
      const normalizedJobId = Math.trunc(jobId);
      const rowState = String(row?.status || row?.last_state || '').trim().toUpperCase();
      if (!replaceableStates.has(rowState)) {
        continue;
      }
      if (this._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);
    }

    if (deleted.length > 0) {
      logger.info('analyze:drive:replace-existing-jobs', {
        devicePath: normalizedPath,
        deletedJobIds: deleted
      });
    }
    return deleted;
  }

  _forceUnlockDriveLocksForDeletedJobs(devicePaths = [], deletedJobIds = []) {
    const normalizedPaths = Array.from(new Set(
      (Array.isArray(devicePaths) ? devicePaths : [])
        .map((value) => this.normalizeDrivePath(value))
        .filter((value) => Boolean(value) && !value.startsWith('__virtual__'))
    ));
    if (normalizedPaths.length === 0 || typeof diskDetectionService.forceUnlockDevice !== 'function') {
      return [];
    }

    const deletedSet = new Set(
      (Array.isArray(deletedJobIds) ? deletedJobIds : [])
        .map((value) => Number(value))
        .filter((value) => Number.isFinite(value) && value > 0)
        .map((value) => Math.trunc(value))
    );
    const activeLocks = diskDetectionService.getActiveLocks();
    const forceUnlockedPaths = [];

    for (const devicePath of normalizedPaths) {
      const lock = activeLocks.find((entry) => this.normalizeDrivePath(entry?.path) === devicePath) || null;
      if (!lock) {
        continue;
      }
      const owners = Array.isArray(lock?.owners) ? lock.owners : [];
      const ownerJobIds = owners
        .map((owner) => Number(owner?.jobId))
        .filter((value) => Number.isFinite(value) && value > 0)
        .map((value) => Math.trunc(value));
      const hasForeignOwner = ownerJobIds.some((jobId) => !deletedSet.has(jobId));
      if (hasForeignOwner) {
        logger.warn('drive-lock:force-unlock:skipped-foreign-owner', {
          devicePath,
          ownerJobIds
        });
        continue;
      }
      const clearedCount = Number(diskDetectionService.forceUnlockDevice(devicePath, {
        reason: 'job_deleted',
        deletedJobIds: Array.from(deletedSet)
      }) || 0);
      if (clearedCount > 0) {
        forceUnlockedPaths.push(devicePath);
      }
    }

    if (forceUnlockedPaths.length > 0) {
      logger.warn('drive-lock:force-unlocked-after-delete', {
        devicePaths: forceUnlockedPaths
      });
    }
    return forceUnlockedPaths;
  }

  async _rescanDrivesAfterDelete(devicePaths = []) {
    const normalizedPaths = Array.from(new Set(
      (Array.isArray(devicePaths) ? devicePaths : [])
        .map((value) => this.normalizeDrivePath(value))
        .filter((value) => Boolean(value) && !value.startsWith('__virtual__'))
    ));
    if (normalizedPaths.length === 0) {
      return;
    }

    const retryDelaysMs = [0, 450, 1250];
    for (const devicePath of normalizedPaths) {
      let lastResult = null;
      for (let attempt = 0; attempt < retryDelaysMs.length; attempt += 1) {
        const waitMs = retryDelaysMs[attempt];
        if (waitMs > 0) {
          await new Promise((resolve) => setTimeout(resolve, waitMs));
        }
        try {
          lastResult = await diskDetectionService.rescanDriveAndEmit(devicePath);
        } catch (error) {
          logger.warn('job-delete:drive-rescan:failed', {
            devicePath,
            attempt: attempt + 1,
            error: errorToMeta(error)
          });
          continue;
        }

        const detectedProfile = String(lastResult?.device?.mediaProfile || '').trim().toLowerCase();
        const present = Boolean(lastResult?.present);
        const locked = Boolean(lastResult?.locked);
        if (locked) {
          continue;
        }
        if (!present) {
          continue;
        }
        if (detectedProfile && detectedProfile !== 'other' && detectedProfile !== 'unknown') {
          break;
        }
      }
      logger.info('job-delete:drive-rescan:done', {
        devicePath,
        emitted: lastResult?.emitted || 'none',
        present: Boolean(lastResult?.present),
        mediaProfile: lastResult?.device?.mediaProfile || null,
        locked: Boolean(lastResult?.locked)
      });
    }
  }

  _forceStopActiveProcessForJob(jobId, options = {}) {
    const normalizedJobId = Number(jobId);
    if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
      return false;
    }

    const targetJobId = Math.trunc(normalizedJobId);
    const processHandle = this.activeProcesses.get(targetJobId) || null;
    if (!processHandle) {
      return false;
    }

    const childPid = Number(processHandle?.child?.pid);
    try {
      processHandle?.cancel?.();
    } catch (_error) {
      // ignore cancellation race errors
    }

    if (Number.isFinite(childPid) && childPid > 0) {
      try { process.kill(-childPid, 'SIGKILL'); } catch (_error) { /* noop */ }
      try { process.kill(childPid, 'SIGKILL'); } catch (_error) { /* noop */ }
    }
    try {
      processHandle?.child?.kill?.('SIGKILL');
    } catch (_error) {
      // noop
    }

    logger.warn('job-delete:active-process-killed', {
      jobId: targetJobId,
      reason: String(options?.reason || '').trim() || null,
      pid: Number.isFinite(childPid) && childPid > 0 ? childPid : null
    });
    return true;
  }

  async onJobsDeleted(jobIds = [], options = {}) {
    const normalizedIds = Array.isArray(jobIds)
      ? jobIds
        .map((value) => Number(value))
        .filter((value) => Number.isFinite(value) && value > 0)
        .map((value) => Math.trunc(value))
      : [];
    if (normalizedIds.length === 0) {
      return { cleaned: 0, removedQueueEntries: 0 };
    }
    const resetDriveState = Boolean(options?.resetDriveState);
    const extraDevicePaths = Array.isArray(options?.devicePaths)
      ? options.devicePaths
        .map((value) => this.normalizeDrivePath(value))
        .filter((value) => Boolean(value))
      : [];
    const affectedDevicePaths = new Set(extraDevicePaths);

    const queueIds = new Set(normalizedIds.map((value) => String(value)));
    const previousQueueLength = this.queueEntries.length;
    this.queueEntries = this.queueEntries.filter((entry) => !queueIds.has(String(Number(entry?.jobId || 0))));
    const removedQueueEntries = previousQueueLength - this.queueEntries.length;

    for (const jobId of normalizedIds) {
      this.cancelRequestedByJob.add(jobId);
      this._forceStopActiveProcessForJob(jobId, { reason: 'job_deleted' });
      const lockForJob = this._getDriveLockByJobId(jobId);
      if (lockForJob?.devicePath) {
        affectedDevicePaths.add(this.normalizeDrivePath(lockForJob.devicePath));
      }
      this._releaseDriveLockForJob(jobId, { reason: 'job_deleted' });
      const cdDriveForJob = this._getCdDriveByJobId(jobId);
      if (cdDriveForJob?.devicePath) {
        affectedDevicePaths.add(this.normalizeDrivePath(cdDriveForJob.devicePath));
        if (String(cdDriveForJob.devicePath).startsWith('__virtual__')) {
          this._removeCdDrive(cdDriveForJob.devicePath);
        } else {
          if (resetDriveState) {
            this._removeCdDrive(cdDriveForJob.devicePath);
          } else {
            this._releaseCdDrive(cdDriveForJob.devicePath, { device: cdDriveForJob.device || null });
          }
        }
      } else {
        this.jobProgress.delete(jobId);
      }
      this.activeProcesses.delete(jobId);
    }
    // Keep the cancellation marker briefly so any late async step in the old
    // pipeline thread aborts before spawning follow-up processes.
    for (const jobId of normalizedIds) {
      setTimeout(() => {
        this.cancelRequestedByJob.delete(jobId);
      }, 120000);
    }
    const forceUnlockedPaths = this._forceUnlockDriveLocksForDeletedJobs(Array.from(affectedDevicePaths), normalizedIds);
    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 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 activeLocks = diskDetectionService.getActiveLocks();
    const locksToCheck = normalizedPaths
      .map((devicePath) => {
        const lock = activeLocks.find((entry) => this.normalizeDrivePath(entry?.path) === devicePath) || null;
        return lock ? { devicePath, lock } : null;
      })
      .filter(Boolean);
    if (locksToCheck.length === 0) {
      return [];
    }

    const ownerJobIds = new Set();
    for (const entry of locksToCheck) {
      const owners = Array.isArray(entry.lock?.owners) ? entry.lock.owners : [];
      for (const owner of owners) {
        const jobId = Number(owner?.jobId);
        if (Number.isFinite(jobId) && jobId > 0) {
          ownerJobIds.add(Math.trunc(jobId));
        }
      }
    }
    if (ownerJobIds.size === 0) {
      return [];
    }

    let existingJobIds = new Set();
    try {
      const db = await getDb();
      const placeholders = Array.from(ownerJobIds).map(() => '?').join(', ');
      const rows = await db.all(`SELECT id FROM jobs WHERE id IN (${placeholders})`, Array.from(ownerJobIds));
      existingJobIds = new Set(
        (Array.isArray(rows) ? rows : [])
          .map((row) => Number(row?.id))
          .filter((value) => Number.isFinite(value) && value > 0)
          .map((value) => Math.trunc(value))
      );
    } catch (error) {
      logger.warn('drive-lock:stale-check:failed', { error: errorToMeta(error) });
      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;
        }
        return existingJobIds.has(Math.trunc(jobId));
      });
      if (hasActiveOwner) {
        continue;
      }
      const clearedCount = Number(diskDetectionService.forceUnlockDevice(entry.devicePath, {
        reason: 'stale_lock',
        deletedJobIds: []
      }) || 0);
      if (clearedCount > 0) {
        cleared.push(entry.devicePath);
      }
    }

    if (cleared.length > 0) {
      logger.warn('drive-lock:stale-unlocked', { devicePaths: cleared });
    }
    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.isSeriesContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) {
        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';
  }

  _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', '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]
    );
    if (!parentJob || !this.isSeriesContainerHistoryJob(parentJob)) {
      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.isSeriesContainerHistoryJob(row));
    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, '') = 'dvd_series_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
    };
  }

  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 = ?
        ORDER BY id ASC
      `,
      [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', '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');
      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
      });
      // 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;
    const nextState = hasErrors
      ? 'ERROR'
      : (hasCancellations ? 'CANCELLED' : (hasPendingChildren ? 'ENCODING' : 'READY_TO_ENCODE'));
    const nextLastState = hasPendingChildren && nextState === 'ENCODING'
      ? '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 selectedMetadata = resolveSelectedMetadataForJob(sourceJob, analyzeContext, this.snapshot.context || {});
    const isSeriesDvd = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext);
    const selectedTitleIds = normalizeReviewTitleIdList(
      Array.isArray(parentPlan?.selectedTitleIds)
        ? parentPlan.selectedTitleIds
        : [parentPlan?.encodeInputTitleId]
    );
    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: sourceJob.omdb_json || null,
        selected_from_omdb: Number(sourceJob.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));
  }

  normalizeQueueChainIdList(rawList) {
    const list = Array.isArray(rawList) ? rawList : [];
    const seen = new Set();
    const output = [];
    for (const item of list) {
      const value = Number(item);
      if (!Number.isFinite(value) || value <= 0) {
        continue;
      }
      const normalized = Math.trunc(value);
      const key = String(normalized);
      if (seen.has(key)) {
        continue;
      }
      seen.add(key);
      output.push(normalized);
    }
    return output;
  }

  extractQueueJobPlan(row) {
    const source = row && typeof row === 'object' ? row : null;
    if (!source) {
      return null;
    }
    if (source.encodePlan && typeof source.encodePlan === 'object') {
      return source.encodePlan;
    }
    if (source.encode_plan_json) {
      try {
        const parsed = JSON.parse(source.encode_plan_json);
        if (parsed && typeof parsed === 'object') {
          return parsed;
        }
      } catch (_) {
        // ignore parse errors for queue decorations
      }
    }
    return null;
  }

  async buildQueueJobScriptMeta(rows = []) {
    const list = Array.isArray(rows) ? rows : [];
    const byJobId = new Map();
    const allScriptIds = new Set();
    const allChainIds = new Set();
    const scriptNameHints = new Map();
    const chainNameHints = new Map();

    const addScriptHints = (items) => {
      for (const item of (Array.isArray(items) ? items : [])) {
        if (!item || typeof item !== 'object') {
          continue;
        }
        const id = normalizeScriptIdList([item.id ?? item.scriptId])[0] || null;
        const name = String(item.name || item.scriptName || '').trim();
        if (!id) {
          continue;
        }
        allScriptIds.add(id);
        if (name) {
          scriptNameHints.set(id, name);
        }
      }
    };

    const addChainHints = (items) => {
      for (const item of (Array.isArray(items) ? items : [])) {
        if (!item || typeof item !== 'object') {
          continue;
        }
        const id = this.normalizeQueueChainIdList([item.id ?? item.chainId])[0] || null;
        const name = String(item.name || item.chainName || '').trim();
        if (!id) {
          continue;
        }
        allChainIds.add(id);
        if (name) {
          chainNameHints.set(id, name);
        }
      }
    };

    for (const row of list) {
      const jobId = this.normalizeQueueJobId(row?.id);
      if (!jobId) {
        continue;
      }
      const plan = this.extractQueueJobPlan(row);
      if (!plan) {
        continue;
      }

      const preScriptIds = normalizeScriptIdList([
        ...normalizeScriptIdList(plan?.preEncodeScriptIds || []),
        ...normalizeScriptIdList((Array.isArray(plan?.preEncodeScripts) ? plan.preEncodeScripts : []).map((item) => item?.id ?? item?.scriptId))
      ]);
      const postScriptIds = normalizeScriptIdList([
        ...normalizeScriptIdList(plan?.postEncodeScriptIds || []),
        ...normalizeScriptIdList((Array.isArray(plan?.postEncodeScripts) ? plan.postEncodeScripts : []).map((item) => item?.id ?? item?.scriptId))
      ]);
      const preChainIds = this.normalizeQueueChainIdList([
        ...this.normalizeQueueChainIdList(plan?.preEncodeChainIds || []),
        ...this.normalizeQueueChainIdList((Array.isArray(plan?.preEncodeChains) ? plan.preEncodeChains : []).map((item) => item?.id ?? item?.chainId))
      ]);
      const postChainIds = this.normalizeQueueChainIdList([
        ...this.normalizeQueueChainIdList(plan?.postEncodeChainIds || []),
        ...this.normalizeQueueChainIdList((Array.isArray(plan?.postEncodeChains) ? plan.postEncodeChains : []).map((item) => item?.id ?? item?.chainId))
      ]);

      addScriptHints(plan?.preEncodeScripts);
      addScriptHints(plan?.postEncodeScripts);
      addChainHints(plan?.preEncodeChains);
      addChainHints(plan?.postEncodeChains);

      for (const id of preScriptIds) allScriptIds.add(id);
      for (const id of postScriptIds) allScriptIds.add(id);
      for (const id of preChainIds) allChainIds.add(id);
      for (const id of postChainIds) allChainIds.add(id);

      byJobId.set(jobId, {
        preScriptIds,
        postScriptIds,
        preChainIds,
        postChainIds
      });
    }

    if (byJobId.size === 0) {
      return new Map();
    }

    const scriptNameById = new Map();
    const chainNameById = new Map();
    for (const [id, name] of scriptNameHints.entries()) {
      scriptNameById.set(id, name);
    }
    for (const [id, name] of chainNameHints.entries()) {
      chainNameById.set(id, name);
    }

    if (allScriptIds.size > 0) {
      const scriptService = require('./scriptService');
      try {
        const scripts = await scriptService.resolveScriptsByIds(Array.from(allScriptIds), { strict: false });
        for (const script of scripts) {
          const id = Number(script?.id);
          const name = String(script?.name || '').trim();
          if (Number.isFinite(id) && id > 0 && name) {
            scriptNameById.set(id, name);
          }
        }
      } catch (error) {
        logger.warn('queue:script-summary:resolve-failed', { error: errorToMeta(error) });
      }
    }

    if (allChainIds.size > 0) {
      const scriptChainService = require('./scriptChainService');
      try {
        const chains = await scriptChainService.getChainsByIds(Array.from(allChainIds));
        for (const chain of chains) {
          const id = Number(chain?.id);
          const name = String(chain?.name || '').trim();
          if (Number.isFinite(id) && id > 0 && name) {
            chainNameById.set(id, name);
          }
        }
      } catch (error) {
        logger.warn('queue:chain-summary:resolve-failed', { error: errorToMeta(error) });
      }
    }

    const output = new Map();
    for (const [jobId, data] of byJobId.entries()) {
      const preScripts = data.preScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`);
      const postScripts = data.postScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`);
      const preChains = data.preChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`);
      const postChains = data.postChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`);
      const hasScripts = preScripts.length > 0 || postScripts.length > 0;
      const hasChains = preChains.length > 0 || postChains.length > 0;
      output.set(jobId, {
        hasScripts,
        hasChains,
        summary: {
          preScripts,
          postScripts,
          preChains,
          postChains
        }
      });
    }
    return output;
  }

  async getQueueSnapshot() {
    const [maxParallelJobs, maxParallelCdEncodes, maxTotalEncodes, cdBypassesQueue] = await Promise.all([
      this.getMaxParallelJobs(),
      this.getMaxParallelCdEncodes(),
      this.getMaxTotalEncodes(),
      this.getCdBypassesQueue()
    ]);
    const [runningJobs, idleJobsRaw] = await Promise.all([
      historyService.getRunningJobs(),
      historyService.getQueueIdleJobs()
    ]);
    const visibleRunningJobs = (Array.isArray(runningJobs) ? runningJobs : []).filter((job) => (
      !this.isSeriesContainerHistoryJob(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.isSeriesContainerHistoryJob(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 scriptMetaByJobId = await this.buildQueueJobScriptMeta(
      Array.from(
        new Map(
          [...visibleRunningJobs, ...queuedRows, ...idleJobs].map((row) => [Number(row?.id), row])
        ).values()
      )
    );
    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
        };

        if (entryType === 'script') {
          return { ...base, scriptId: entry.scriptId, title: entry.scriptName || `Skript #${entry.scriptId}`, status: 'QUEUED' };
        }
        if (entryType === 'chain') {
          return { ...base, chainId: entry.chainId, title: entry.chainName || `Kette #${entry.chainId}`, status: 'QUEUED' };
        }
        if (entryType === 'wait') {
          return { ...base, waitSeconds: entry.waitSeconds, title: `Warten ${entry.waitSeconds}s`, status: 'QUEUED' };
        }

        // type === 'job'
        const row = queuedById.get(Number(entry.jobId));
        const scriptMeta = scriptMetaByJobId.get(Number(entry.jobId)) || null;
        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();
    void this.pumpQueue();
    return { entryId: entry.id, type, position: this.queueEntries.indexOf(entry) + 1 };
  }

  async removeQueueEntry(entryId) {
    const normalizedId = Number(entryId);
    if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
      const error = new Error('Ungültige entryId.');
      error.statusCode = 400;
      throw error;
    }
    const idx = this.queueEntries.findIndex((e) => e.id === normalizedId);
    if (idx < 0) {
      const error = new Error(`Queue-Eintrag #${normalizedId} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }
    this.queueEntries.splice(idx, 1);
    await this.emitQueueChanged();
    return this.lastQueueSnapshot;
  }

  async enqueueOrStartAction(action, jobId, startNow, options = {}) {
    const normalizedJobId = this.normalizeQueueJobId(jobId);
    if (!normalizedJobId) {
      const error = new Error('Ungültige Job-ID für Queue-Aktion.');
      error.statusCode = 400;
      throw error;
    }
    if (!Object.values(QUEUE_ACTIONS).includes(action)) {
      const error = new Error(`Unbekannte Queue-Aktion '${action}'.`);
      error.statusCode = 400;
      throw error;
    }
    if (typeof startNow !== 'function') {
      const error = new Error('Queue-Aktion kann nicht gestartet werden (startNow fehlt).');
      error.statusCode = 500;
      throw error;
    }

    let poolType = this.normalizeQueuePoolType(options?.poolType);
    if (!poolType) {
      const poolJob = await historyService.getJobById(normalizedJobId).catch(() => null);
      poolType = this.resolveQueuePoolTypeForJob(poolJob);
    }

    const 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 shouldQueue = forceQueue
      ? 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 : {})
      };
    }

    this.queueEntries.push({
      id: this.queueEntrySeq++,
      jobId: normalizedJobId,
      action,
      poolType,
      uniqueEntryKey,
      ...(options?.entryData && typeof options.entryData === 'object' ? options.entryData : {}),
      enqueuedAt: nowIso()
    });
    await historyService.appendLog(
      normalizedJobId,
      'USER_ACTION',
      `In Queue aufgenommen: ${QUEUE_ACTION_LABELS[action] || action}`
    );
    await this.emitQueueChanged();
    void this.pumpQueue();

    return {
      queued: true,
      started: false,
      queuePosition: this.queueEntries.length,
      action,
      poolType,
      queuedPoolTypeCount
    };
  }

  async enqueueOrStartCdAction(jobId, ripConfig, startNow) {
    return this.enqueueOrStartAction(
      QUEUE_ACTIONS.START_CD,
      jobId,
      startNow,
      {
        poolType: 'audio',
        entryData: { ripConfig: ripConfig || {} }
      }
    );
  }

  async dispatchNonJobEntry(entry) {
    const type = entry?.type;
    logger.info('queue:non-job:dispatch', { type, entryId: entry?.id });

    if (type === 'wait') {
      const seconds = Math.max(1, Number(entry.waitSeconds || 1));
      logger.info('queue:wait:start', { seconds });
      await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
      logger.info('queue:wait:done', { seconds });
      return;
    }

    if (type === 'script') {
      const scriptService = require('./scriptService');
      let script;
      try { script = await scriptService.getScriptById(entry.scriptId); } catch (_) { /* ignore */ }
      if (!script) {
        logger.warn('queue:script:not-found', { scriptId: entry.scriptId });
        return;
      }
      const activityId = runtimeActivityService.startActivity('script', {
        name: script.name,
        source: 'queue',
        scriptId: script.id,
        currentStep: 'Queue-Ausfuehrung'
      });
      let prepared = null;
      try {
        prepared = await scriptService.createExecutableScriptFile(script, { source: 'queue', scriptId: script.id, scriptName: script.name });
        let stdout = '';
        let stderr = '';
        let stdoutTruncated = false;
        let stderrTruncated = false;
        const processHandle = spawnTrackedProcess({
          cmd: prepared.cmd,
          args: prepared.args,
          context: { source: 'queue', scriptId: script.id },
          onStdoutLine: (line) => {
            const next = appendTailText(stdout, line);
            stdout = next.value;
            stdoutTruncated = stdoutTruncated || next.truncated;
            runtimeActivityService.appendActivityOutput(activityId, { stdout: line });
          },
          onStderrLine: (line) => {
            const next = appendTailText(stderr, line);
            stderr = next.value;
            stderrTruncated = stderrTruncated || next.truncated;
            runtimeActivityService.appendActivityOutput(activityId, { stderr: line });
          }
        });
        let exitCode = 0;
        let runError = null;
        try {
          const result = await processHandle.promise;
          exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0;
        } catch (error) {
          runError = error;
          exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null;
          if (exitCode === null) {
            throw error;
          }
        }

        logger.info('queue:script:done', { scriptId: script.id, exitCode });
        const output = [stdout, stderr].filter(Boolean).join('\n').trim();
        const success = Number(exitCode) === 0;
        runtimeActivityService.completeActivity(activityId, {
          status: success ? 'success' : 'error',
          success,
          outcome: success ? 'success' : 'error',
          exitCode: Number.isFinite(Number(exitCode)) ? Number(exitCode) : null,
          message: success ? 'Queue-Skript abgeschlossen' : `Queue-Skript fehlgeschlagen (Exit ${exitCode ?? 'n/a'})`,
          output: output || null,
          stdout: stdout || null,
          stderr: stderr || null,
          stdoutTruncated,
          stderrTruncated,
          errorMessage: success ? null : `Queue-Skript fehlgeschlagen (Exit ${exitCode ?? 'n/a'})`
        });
        if (runError && !success) {
          logger.warn('queue:script:exit-nonzero', { scriptId: script.id, exitCode });
        }
      } catch (err) {
        runtimeActivityService.completeActivity(activityId, {
          status: 'error',
          success: false,
          outcome: 'error',
          message: err?.message || 'Queue-Skript Fehler',
          errorMessage: err?.message || 'Queue-Skript Fehler'
        });
        logger.error('queue:script:error', { scriptId: entry.scriptId, error: errorToMeta(err) });
      } finally {
        if (prepared?.cleanup) await prepared.cleanup();
      }
      return;
    }

    if (type === 'chain') {
      const scriptChainService = require('./scriptChainService');
      try {
        await scriptChainService.executeChain(entry.chainId, { source: 'queue' });
      } catch (err) {
        logger.error('queue:chain:error', { chainId: entry.chainId, error: errorToMeta(err) });
      }
    }
  }

  async dispatchQueuedEntry(entry) {
    const action = entry?.action;
    const jobId = Number(entry?.jobId);
    if (!Number.isFinite(jobId) || jobId <= 0) {
      return;
    }
    switch (action) {
      case QUEUE_ACTIONS.START_PREPARED:
        await this.startPreparedJob(jobId, { immediate: true });
        break;
      case QUEUE_ACTIONS.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 pumpQueue() {
    if (this.queuePumpRunning) {
      return;
    }
    this.queuePumpRunning = true;
    try {
      while (this.queueEntries.length > 0) {
        // Get current running counts and limits
        const [allRunningJobs, maxFilm, maxCd, maxTotal, cdBypass] = await Promise.all([
          historyService.getRunningJobs(),
          this.getMaxParallelJobs(),
          this.getMaxParallelCdEncodes(),
          this.getMaxTotalEncodes(),
          this.getCdBypassesQueue()
        ]);
        const runningUsage = this.buildRunningPoolUsage(allRunningJobs);
        const filmRunning = runningUsage.filmRunning;
        const cdRunning = runningUsage.audioRunning;
        const totalRunning = runningUsage.totalRunning;
        // Counts all actively processing jobs (excludes waiting states like READY_TO_ENCODE).
        const anyActiveJobs = totalRunning;

        // Find next startable entry
        let entryIndex = -1;
        for (let i = 0; i < this.queueEntries.length; i++) {
          const candidate = this.queueEntries[i];
          const isNonJob = candidate.type && candidate.type !== 'job';

          if (isNonJob) {
            // Non-job entries (script, chain, wait) only start when no jobs are actively running.
            // anyActiveJobs covers all active states (ANALYZING, RIPPING, ENCODING, MEDIAINFO_CHECK etc.)
            // activeProcesses provides a second safety net for race conditions during state transitions.
            if (anyActiveJobs === 0 && this.activeProcesses.size === 0) {
              entryIndex = i;
            }
            break; // FIFO: stop scanning regardless (non-job blocks everything behind it)
          }

          // Job entry: check hierarchical limits
          if (totalRunning >= maxTotal) {
            // Total limit reached – nothing can start
            break;
          }

          const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate);
          if (candidatePoolType === 'audio') {
            if (cdRunning < maxCd) {
              entryIndex = i;
              break;
            }
            // CD limit reached
            if (!cdBypass) break; // Strict FIFO: stop scanning
            continue; // Bypass mode: skip this blocked CD entry
          } else {
            // Film/video job entry
            if (filmRunning < maxFilm) {
              entryIndex = i;
              break;
            }
            // Film limit reached
            if (!cdBypass) break; // Strict FIFO: stop scanning
            continue; // Bypass mode: skip this blocked film entry
          }
        }

        if (entryIndex < 0) {
          break; // Nothing can start right now
        }

        const entry = this.queueEntries.splice(entryIndex, 1)[0];
        if (!entry) {
          break;
        }

        const isNonJob = entry.type && entry.type !== 'job';
        await this.emitQueueChanged();
        try {
          if (isNonJob) {
            await this.dispatchNonJobEntry(entry);
            continue;
          }
          await historyService.appendLog(
            entry.jobId,
            'SYSTEM',
            `Queue-Start: ${QUEUE_ACTION_LABELS[entry.action] || entry.action}`
          );
          await this.dispatchQueuedEntry(entry);
        } catch (error) {
          if (Number(error?.statusCode || 0) === 409) {
            this.queueEntries.splice(entryIndex, 0, entry);
            await this.emitQueueChanged();
            break;
          }
          logger.error('queue:entry:failed', {
            type: entry.type || 'job',
            action: entry.action,
            jobId: entry.jobId,
            error: errorToMeta(error)
          });
          if (entry.jobId) {
            await historyService.appendLog(
              entry.jobId,
              'SYSTEM',
              `Queue-Start fehlgeschlagen (${QUEUE_ACTION_LABELS[entry.action] || entry.action}): ${error.message}`
            );
          }
        }
      }
    } finally {
      this.queuePumpRunning = false;
      await this.emitQueueChanged();
    }
  }

  async resetFrontendState(reason = 'manual', options = {}) {
    const force = Boolean(options?.force);
    const keepDetectedDevice = options?.keepDetectedDevice !== false;

    if (!force && (this.activeProcesses.size > 0 || RUNNING_STATES.has(this.snapshot.state))) {
      logger.warn('ui:reset:skipped-busy', {
        reason,
        state: this.snapshot.state,
        activeJobId: this.snapshot.activeJobId
      });
      return {
        reset: false,
        skipped: 'busy'
      };
    }

    const device = keepDetectedDevice ? (this.detectedDisc || null) : null;
    const nextState = device ? 'DISC_DETECTED' : 'IDLE';
    const statusText = device ? 'Neue Disk erkannt' : 'Bereit';

    logger.warn('ui:reset', {
      reason,
      previousState: this.snapshot.state,
      previousActiveJobId: this.snapshot.activeJobId,
      nextState,
      keepDetectedDevice
    });

    await this.setState(nextState, {
      activeJobId: null,
      progress: 0,
      eta: null,
      statusText,
      context: device ? { device } : {}
    });

    return {
      reset: true,
      state: nextState
    };
  }

  async notifyPushover(eventKey, payload = {}) {
    try {
      const result = await notificationService.notify(eventKey, payload);
      logger.debug('notify:event', {
        eventKey,
        sent: Boolean(result?.sent),
        reason: result?.reason || null
      });
    } catch (error) {
      logger.warn('notify:event:failed', {
        eventKey,
        error: errorToMeta(error)
      });
    }
  }

  async ejectDriveIfEnabled(settingsMap, devicePath = null) {
    try {
      const enabled = String(settingsMap?.auto_eject_after_rip || '').trim().toLowerCase();
      if (enabled !== 'true' && enabled !== '1') {
        return;
      }
      // Collect the list of drives to eject
      const devicesToEject = [];
      if (devicePath) {
        devicesToEject.push(devicePath);
      } else if (settingsMap?.drive_mode === 'explicit') {
        try {
          const parsed = JSON.parse(settingsMap?.drive_devices || '[]');
          if (Array.isArray(parsed)) {
            parsed
            .map((e) => (typeof e === 'string' ? e.trim() : String(e?.path || '').trim()))
            .filter(Boolean)
            .forEach((p) => devicesToEject.push(p));
          }
        } catch (_error) {
          // ignore
        }
        if (devicesToEject.length === 0) {
          const legacy = String(settingsMap?.drive_device || '').trim();
          if (legacy) devicesToEject.push(legacy);
        }
      } else {
        devicesToEject.push(String(settingsMap?.drive_device || '').trim() || '/dev/sr0');
      }
      for (const device of devicesToEject) {
        logger.info('eject:drive', { device });
        await new Promise((resolve) => {
          execFile('eject', [device], { timeout: 10000 }, (error) => {
            if (error) {
              logger.warn('eject:drive:failed', { device, error: errorToMeta(error) });
            } else {
              logger.info('eject:drive:ok', { device });
            }
            resolve();
          });
        });
      }
    } catch (error) {
      logger.warn('eject:drive:error', { error: errorToMeta(error) });
    }
  }

  normalizeDiscValue(value) {
    return String(value || '').trim().toLowerCase();
  }

  isSameDisc(a, b) {
    const aDiscLabel = this.normalizeDiscValue(a?.discLabel);
    const bDiscLabel = this.normalizeDiscValue(b?.discLabel);
    if (aDiscLabel && bDiscLabel) {
      return aDiscLabel === bDiscLabel;
    }

    const aPath = this.normalizeDiscValue(a?.path);
    const bPath = this.normalizeDiscValue(b?.path);
    if (aPath && bPath) {
      return aPath === bPath;
    }

    const aLabel = this.normalizeDiscValue(a?.label);
    const bLabel = this.normalizeDiscValue(b?.label);
    if (aLabel && bLabel) {
      return aLabel === bLabel;
    }

    return false;
  }

  shouldSuspendDrivePollingForState(state, context = null) {
    const normalizedState = String(state || '').trim().toUpperCase();
    const DRIVE_ACTIVE_STATES = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING']);
    if (DRIVE_ACTIVE_STATES.has(normalizedState)) {
      return true;
    }

    // RAW-based review/restart states should not touch optical drives.
    if (!['READY_TO_ENCODE', 'WAITING_FOR_USER_DECISION'].includes(normalizedState)) {
      return false;
    }

    const resolvedContext = context && typeof context === 'object' ? context : {};
    const review = resolvedContext.mediaInfoReview && typeof resolvedContext.mediaInfoReview === 'object'
      ? resolvedContext.mediaInfoReview
      : null;
    const mode = String(resolvedContext.mode || review?.mode || '').trim().toLowerCase();
    const isPreRip = mode === 'pre_rip' || Boolean(review?.preRip);
    if (isPreRip) {
      return false;
    }

    const rawPath = String(resolvedContext.rawPath || '').trim();
    const inputPath = String(resolvedContext.inputPath || review?.encodeInputPath || '').trim();
    const hasFilesystemInput = [rawPath, inputPath].some((candidate) =>
      candidate && !candidate.startsWith('disc-track-scan://')
    );
    return hasFilesystemInput;
  }

  async setState(state, patch = {}) {
    const previous = this.snapshot.state;
    const previousContext = this.snapshot.context;
    const previousActiveJobId = this.snapshot.activeJobId;
    const contextPatch = patch.context && typeof patch.context === 'object' && !Array.isArray(patch.context)
      ? patch.context
      : null;
    this.snapshot = {
      ...this.snapshot,
      state,
      activeJobId: patch.activeJobId !== undefined ? patch.activeJobId : this.snapshot.activeJobId,
      progress: patch.progress !== undefined ? patch.progress : this.snapshot.progress,
      eta: patch.eta !== undefined ? patch.eta : this.snapshot.eta,
      statusText: patch.statusText !== undefined ? patch.statusText : this.snapshot.statusText,
      context: patch.context !== undefined ? patch.context : this.snapshot.context
    };

    // Keep per-job progress map in sync when a job starts or finishes.
    if (patch.activeJobId != null) {
      const activeJobId = Number(patch.activeJobId);
      const previousJobProgress = this.jobProgress.get(activeJobId) || {};
      const mergedContext = contextPatch
        ? {
          ...(previousJobProgress.context && typeof previousJobProgress.context === 'object'
            ? previousJobProgress.context
            : {}),
          ...contextPatch
        }
        : (previousJobProgress.context && typeof previousJobProgress.context === 'object'
            ? previousJobProgress.context
            : null);
      const nextProgress = {
        ...previousJobProgress,
        state,
        progress: patch.progress ?? 0,
        eta: patch.eta ?? null,
        statusText: patch.statusText ?? null
      };
      if (mergedContext && Object.keys(mergedContext).length > 0) {
        nextProgress.context = mergedContext;
      }
      this.jobProgress.set(activeJobId, nextProgress);
    } else if (patch.activeJobId === null) {
      // Job slot cleared – remove the finished job's live entry so it falls
      // back to DB data in the frontend.
      // Use patch.finishingJobId when provided (parallel-safe); fall back to
      // previousActiveJobId only when no parallel job has overwritten the slot.
      const finishingJobId = patch.finishingJobId != null
        ? Number(patch.finishingJobId)
        : (previousActiveJobId != null ? Number(previousActiveJobId) : null);
      if (finishingJobId != null) {
        this.jobProgress.delete(finishingJobId);
      }
    }
    logger.info('state:changed', {
      from: previous,
      to: state,
      activeJobId: this.snapshot.activeJobId,
      statusText: this.snapshot.statusText
    });

    const shouldSuspendCurrentPolling = this.shouldSuspendDrivePollingForState(state, this.snapshot.context);
    const shouldSuspendPreviousPolling = this.shouldSuspendDrivePollingForState(previous, previousContext);
    if (shouldSuspendCurrentPolling) {
      diskDetectionService.suspendPolling();
    } else if (shouldSuspendPreviousPolling) {
      diskDetectionService.resumePolling();
    }

    await this.persistSnapshot();
    const snapshotPayload = this.getSnapshot();
    wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload);
    this.emit('stateChanged', snapshotPayload);
    void this.emitQueueChanged();
    void this.pumpQueue();
  }

  async persistSnapshot(force = true) {
    if (!force) {
      const now = Date.now();
      if (now - this.lastPersistAt < 300) {
        return;
      }
      this.lastPersistAt = now;
    }

    const db = await getDb();
    await db.run(
      `
        UPDATE pipeline_state
        SET
          state = ?,
          active_job_id = ?,
          progress = ?,
          eta = ?,
          status_text = ?,
          context_json = ?,
          updated_at = CURRENT_TIMESTAMP
        WHERE id = 1
      `,
      [
        this.snapshot.state,
        this.snapshot.activeJobId,
        this.snapshot.progress,
        this.snapshot.eta,
        this.snapshot.statusText,
        JSON.stringify(this.snapshot.context || {})
      ]
    );
  }

  async updateProgress(stage, percent, eta, statusText, jobIdOverride = null, options = {}) {
    const effectiveJobId = jobIdOverride != null ? Number(jobIdOverride) : this.snapshot.activeJobId;
    const previousJobProgress = effectiveJobId != null
      ? (this.jobProgress.get(effectiveJobId) || {})
      : null;
    const effectiveProgress = percent ?? this.snapshot.progress;
    let effectiveEta;
    if (eta !== undefined) {
      effectiveEta = eta;
    } else if (previousJobProgress && previousJobProgress.eta !== undefined) {
      effectiveEta = previousJobProgress.eta ?? null;
    } else {
      effectiveEta = this.snapshot.eta ?? null;
    }
    const effectiveStatusText = statusText ?? this.snapshot.statusText;
    const progressOptions = options && typeof options === 'object' ? options : {};
    const contextPatch = progressOptions.contextPatch && typeof progressOptions.contextPatch === 'object'
      && !Array.isArray(progressOptions.contextPatch)
      ? progressOptions.contextPatch
      : null;

    // Update per-job progress so concurrent jobs don't overwrite each other.
    if (effectiveJobId != null) {
      const mergedContext = contextPatch
        ? {
          ...(previousJobProgress.context && typeof previousJobProgress.context === 'object'
            ? previousJobProgress.context
            : {}),
          ...contextPatch
        }
        : (previousJobProgress.context && typeof previousJobProgress.context === 'object'
            ? previousJobProgress.context
            : null);
      const nextProgress = {
        ...previousJobProgress,
        state: stage,
        progress: effectiveProgress,
        eta: effectiveEta,
        statusText: effectiveStatusText
      };
      if (mergedContext && Object.keys(mergedContext).length > 0) {
        nextProgress.context = mergedContext;
      }
      this.jobProgress.set(effectiveJobId, nextProgress);

      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_SELECTION' || previousState === 'READY_TO_START' || previousState === 'WAITING_FOR_USER_DECISION')) {
        await historyService.updateJob(previousJobId, {
          status: 'ERROR',
          last_state: 'ERROR',
          end_time: nowIso(),
          error_message: message
        });
        await historyService.appendLog(previousJobId, 'SYSTEM', message);
      }

      await this.setState('DISC_DETECTED', {
        activeJobId: null,
        progress: 0,
        eta: null,
        statusText: 'Neue Disk erkannt',
        context: {
          device: resolvedDevice
        }
      });
      return;
    }

    if (this.snapshot.state === 'IDLE' || this.snapshot.state === 'FINISHED' || this.snapshot.state === 'ERROR' || this.snapshot.state === 'DISC_DETECTED') {
      await this.setState('DISC_DETECTED', {
        activeJobId: null,
        progress: 0,
        eta: null,
        statusText: 'Neue Disk erkannt',
        context: {
          device: resolvedDevice
        }
      });
    }
  }

  async onDiscRemoved(deviceInfo) {
    logger.info('disc:removed', { deviceInfo });
    wsService.broadcast('DISC_REMOVED', { device: deviceInfo });

    const removedPath = String(deviceInfo?.path || '').trim();

    // If it's a tracked CD drive, remove or leave it depending on active state
    if (removedPath && this.cdDrives.has(removedPath)) {
      const driveState = this.cdDrives.get(removedPath);
      const ACTIVE_CD_STATES = new Set(['CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING']);
      if (!ACTIVE_CD_STATES.has(driveState?.state)) {
        this._removeCdDrive(removedPath);
      }
      // If actively ripping, leave the entry – rip completion will clean it up
      return;
    }

    this.detectedDisc = null;
    if (this.snapshot.state === 'DISC_DETECTED') {
      await this.setState('IDLE', {
        activeJobId: null,
        progress: 0,
        eta: null,
        statusText: 'Keine Disk erkannt',
        context: {}
      });
    }
  }

  ensureNotBusy(action, jobId = null) {
    const normalizedJobId = this.normalizeQueueJobId(jobId);
    if (!normalizedJobId) {
      return;
    }
    if (this.activeProcesses.has(normalizedJobId)) {
      const error = new Error(`Job #${normalizedJobId} ist bereits aktiv. Aktion '${action}' aktuell nicht möglich.`);
      error.statusCode = 409;
      logger.warn('busy:blocked-action', {
        action,
        jobId: normalizedJobId,
        activeState: this.snapshot.state,
        activeJobId: this.snapshot.activeJobId
      });
      throw error;
    }
  }

  isPrimaryJob(jobId) {
    const activeState = String(this.snapshot.state || '').toUpperCase();
    if (!['ENCODING', 'RIPPING'].includes(activeState)) {
      return true;
    }
    return Number(this.snapshot.activeJobId) === Number(jobId);
  }

  withAnalyzeContextMediaProfile(makemkvInfo, mediaProfile) {
    const normalizedProfile = normalizeMediaProfile(mediaProfile);
    const base = makemkvInfo && typeof makemkvInfo === 'object'
      ? makemkvInfo
      : {};
    return {
      ...base,
      analyzeContext: {
        ...(base.analyzeContext || {}),
        mediaProfile: normalizedProfile || null
      }
    };
  }

  resolveMediaProfileForJob(job, options = {}) {
    const pickSpecificProfile = (value) => {
      const normalized = normalizeMediaProfile(value);
      if (!normalized) {
        return null;
      }
      if (isSpecificMediaProfile(normalized)) {
        return normalized;
      }
      return null;
    };

    const explicitProfile = pickSpecificProfile(options?.mediaProfile);
    if (explicitProfile) {
      return explicitProfile;
    }

    const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object'
      ? options.encodePlan
      : null;
    const profileFromJobKind = inferMediaProfileFromJobKind(
      options?.jobKind
      || job?.job_kind
      || encodePlan?.jobKind
    );
    if (profileFromJobKind) {
      return profileFromJobKind;
    }
    const profileFromPlan = pickSpecificProfile(encodePlan?.mediaProfile);
    if (profileFromPlan) {
      return profileFromPlan;
    }
    const converterMediaTypeHint = String(encodePlan?.converterMediaType || '').trim().toLowerCase();
    if (converterMediaTypeHint === 'video' || converterMediaTypeHint === 'audio' || converterMediaTypeHint === 'iso') {
      return 'converter';
    }
    if (String(encodePlan?.mode || '').trim().toLowerCase() === 'converter') {
      return 'converter';
    }
    if (
      hasConverterPathSegment(encodePlan?.inputPath)
      || hasConverterPathSegment(encodePlan?.encodeInputPath)
      || hasConverterPathSegment(job?.raw_path)
      || hasConverterPathSegment(options?.rawPath)
      || hasConverterPathSegment(job?.encode_input_path)
    ) {
      return 'converter';
    }

    const mkInfo = options?.makemkvInfo && typeof options.makemkvInfo === 'object'
      ? options.makemkvInfo
      : this.safeParseJson(job?.makemkv_info_json);
    const analyzeContext = mkInfo?.analyzeContext || {};
    const profileFromAnalyze = pickSpecificProfile(
      analyzeContext.mediaProfile || mkInfo?.mediaProfile
    );
    if (profileFromAnalyze) {
      return profileFromAnalyze;
    }

    const currentContextProfile = (
      Number(this.snapshot.context?.jobId) === Number(job?.id)
        ? pickSpecificProfile(this.snapshot.context?.mediaProfile)
        : null
    );
    if (currentContextProfile) {
      return currentContextProfile;
    }

    const deviceProfile = inferMediaProfileFromDeviceInfo(
      options?.deviceInfo
      || this.detectedDisc
      || this.snapshot.context?.device
      || null
    );
    if (isSpecificMediaProfile(deviceProfile)) {
      return deviceProfile;
    }

    const rawPathProfile = inferMediaProfileFromRawPath(options?.rawPath || job?.raw_path || null);
    if (rawPathProfile) {
      return rawPathProfile;
    }

    return 'other';
  }

  resolveJobKindForJob(job, options = {}) {
    const explicitKind = normalizeJobKind(options?.jobKind);
    if (explicitKind) {
      return explicitKind;
    }
    const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object'
      ? options.encodePlan
      : this.safeParseJson(job?.encode_plan_json);
    const kindFromPlan = normalizeJobKind(encodePlan?.jobKind);
    if (kindFromPlan) {
      return kindFromPlan;
    }
    const kindFromJob = normalizeJobKind(job?.job_kind);
    if (kindFromJob) {
      return kindFromJob;
    }
    const mediaProfile = this.resolveMediaProfileForJob(job, { ...options, encodePlan });
    return resolveJobKindForMediaProfile(mediaProfile, {
      converterMediaType: options?.converterMediaType || encodePlan?.converterMediaType || null
    });
  }

  async getEffectiveSettingsForJob(job, options = {}) {
    const mediaProfile = this.resolveMediaProfileForJob(job, options);
    const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
    return {
      settings,
      mediaProfile
    };
  }

  async runCapturedCommand(cmd, args = []) {
    const command = String(cmd || '').trim();
    const argv = Array.isArray(args) ? args.map((item) => String(item)) : [];
    if (!command) {
      throw new Error('Kommando fehlt.');
    }

    return new Promise((resolve, reject) => {
      execFile(command, argv, { maxBuffer: 32 * 1024 * 1024 }, (error, stdout, stderr) => {
        if (error) {
          error.stdout = stdout;
          error.stderr = stderr;
          reject(error);
          return;
        }
        resolve({
          stdout: String(stdout || ''),
          stderr: String(stderr || '')
        });
      });
    });
  }

  async ensureMakeMKVRegistration(jobId, stage) {
    const registrationConfig = await settingsService.buildMakeMKVRegisterConfig();
    if (!registrationConfig) {
      return { applied: false, reason: 'not_configured' };
    }

    await historyService.appendLog(
      jobId,
      'SYSTEM',
      'Setze MakeMKV-Registrierungsschlüssel aus den Settings (makemkvcon reg).'
    );

    await this.runCommand({
      jobId,
      stage,
      source: 'MAKEMKV_REG',
      cmd: registrationConfig.cmd,
      args: registrationConfig.args,
      argsForLog: registrationConfig.argsForLog
    });

    return { applied: true };
  }

  isReviewRefreshSettingKey(key) {
    const normalized = String(key || '').trim().toLowerCase();
    if (!normalized) {
      return false;
    }

    if (REVIEW_REFRESH_SETTING_KEYS.has(normalized)) {
      return true;
    }

    return REVIEW_REFRESH_SETTING_PREFIXES.some((prefix) => normalized.startsWith(prefix));
  }

  async refreshEncodeReviewAfterSettingsSave(changedKeys = []) {
    const keys = Array.isArray(changedKeys)
      ? changedKeys.map((item) => String(item || '').trim()).filter(Boolean)
      : [];
    const queueLimitKeys = ['pipeline_max_parallel_jobs', 'pipeline_max_parallel_cd_encodes', 'pipeline_max_total_encodes', 'pipeline_cd_bypasses_queue'];
    if (keys.some((k) => queueLimitKeys.includes(k))) {
      await this.emitQueueChanged();
      void this.pumpQueue();
    }
    const relevantKeys = keys.filter((key) => this.isReviewRefreshSettingKey(key));
    if (relevantKeys.length === 0) {
      return {
        triggered: false,
        reason: 'no_relevant_setting_changes',
        relevantKeys: []
      };
    }

    if (this.activeProcesses.size > 0 || RUNNING_STATES.has(this.snapshot.state)) {
      return {
        triggered: false,
        reason: 'pipeline_busy',
        relevantKeys
      };
    }

    const rawJobId = Number(this.snapshot.activeJobId || this.snapshot.context?.jobId || null);
    const activeJobId = Number.isFinite(rawJobId) && rawJobId > 0 ? Math.trunc(rawJobId) : null;
    if (!activeJobId) {
      return {
        triggered: false,
        reason: 'no_active_job',
        relevantKeys
      };
    }

    const job = await historyService.getJobById(activeJobId);
    if (!job) {
      return {
        triggered: false,
        reason: 'active_job_not_found',
        relevantKeys,
        jobId: activeJobId
      };
    }

    if (job.status !== 'READY_TO_ENCODE' && job.last_state !== 'READY_TO_ENCODE') {
      return {
        triggered: false,
        reason: 'active_job_not_ready_to_encode',
        relevantKeys,
        jobId: activeJobId,
        status: job.status,
        lastState: job.last_state
      };
    }

    const existingPlan = this.safeParseJson(job.encode_plan_json);
    const refreshSettings = await settingsService.getSettingsMap();
    const refreshMediaProfile = this.resolveMediaProfileForJob(job, {
      encodePlan: existingPlan,
      rawPath: job.raw_path
    });
    const resolvedRefreshRawPath = this.resolveCurrentRawPathForSettings(
      refreshSettings,
      refreshMediaProfile,
      job.raw_path
    );

    if (!resolvedRefreshRawPath) {
      return {
        triggered: false,
        reason: 'raw_path_missing',
        relevantKeys,
        jobId: activeJobId,
        rawPath: job.raw_path || null
      };
    }

    if (resolvedRefreshRawPath !== job.raw_path) {
      await historyService.updateJob(activeJobId, { raw_path: resolvedRefreshRawPath });
    }

    const mode = existingPlan?.mode || this.snapshot.context?.mode || 'rip';
    const sourceJobId = existingPlan?.sourceJobId || this.snapshot.context?.sourceJobId || null;

    await historyService.appendLog(
      activeJobId,
      'SYSTEM',
      `Settings gespeichert (${relevantKeys.join(', ')}). Titel-/Spurprüfung wird mit aktueller Konfiguration neu gestartet.`
    );

    this.runReviewForRawJob(activeJobId, resolvedRefreshRawPath, { mode, sourceJobId }).catch((error) => {
      logger.error('settings:refresh-review:failed', {
        jobId: activeJobId,
        relevantKeys,
        error: errorToMeta(error)
      });
    });

    return {
      triggered: true,
      reason: 'refresh_started',
      relevantKeys,
      jobId: activeJobId,
      mode
    };
  }

  resolvePlaylistDecisionForJob(jobId, job, selectionOverride = null) {
    const activeContext = this.snapshot.context?.jobId === jobId
      ? (this.snapshot.context || {})
      : {};

    const mkInfo = this.safeParseJson(job?.makemkv_info_json);
    const analyzeContext = mkInfo?.analyzeContext || {};
    const playlistAnalysis = activeContext.playlistAnalysis || analyzeContext.playlistAnalysis || mkInfo?.playlistAnalysis || null;

    const playlistDecisionRequired = Boolean(
      activeContext.playlistDecisionRequired !== undefined
        ? activeContext.playlistDecisionRequired
        : (analyzeContext.playlistDecisionRequired !== undefined
          ? analyzeContext.playlistDecisionRequired
          : playlistAnalysis?.manualDecisionRequired)
    );

    const rawSelection = selectionOverride
      || activeContext.selectedPlaylist
      || analyzeContext.selectedPlaylist
      || null;
    const selectedPlaylist = normalizePlaylistId(rawSelection);

    const rawSelectedTitleId = activeContext.selectedTitleId ?? analyzeContext.selectedTitleId ?? null;
    let selectedTitleId = null;
    if (selectedPlaylist) {
      selectedTitleId = pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylist);
    }
    if (selectedTitleId === null) {
      const parsedSelectedTitleId = normalizeNonNegativeInteger(rawSelectedTitleId);
      if (parsedSelectedTitleId !== null) {
        selectedTitleId = parsedSelectedTitleId;
      }
    }
    if (!selectedPlaylist && selectedTitleId !== null && !isCandidateTitleId(playlistAnalysis, selectedTitleId)) {
      selectedTitleId = null;
    }

    const candidatePlaylists = buildPlaylistCandidates(playlistAnalysis);
    const recommendation = playlistAnalysis?.recommendation || null;

    return {
      playlistAnalysis,
      playlistDecisionRequired,
      candidatePlaylists,
      selectedPlaylist,
      selectedTitleId,
      recommendation
    };
  }

  async analyzeDisc(devicePath = null) {
    this.ensureNotBusy('analyze');
    logger.info('analyze:start', { devicePath });
    const requestedDevicePath = this.normalizeDrivePath(devicePath);
    if (requestedDevicePath) {
      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: 'METADATA_SELECTION',
      detectedTitle,
      jobKind: resolveJobKindForMediaProfile(mediaProfile)
    });

    // Surface the newly created analyze job immediately in the Ripper UI,
    // even if metadata provider lookups (TMDb/OMDb) 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 omdbCandidates = null;
      let metadataCandidates = null;
      let metadataProvider = 'omdb';
      let seriesAnalysis = null;
      let seriesLookupHint = null;
      let pluginExecution = null;
      if (analyzePlugin) {
        const pluginContext = await this.buildPluginContext(analyzePlugin.id, {
          discInfo: deviceWithProfile,
          jobId: job.id
        });
        try {
          const pluginResult = await analyzePlugin.analyze(device.path, job, pluginContext);
          pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution());
          const pluginDetectedTitle = String(pluginResult?.detectedTitle || '').trim();
          if (pluginDetectedTitle) {
            effectiveDetectedTitle = pluginDetectedTitle;
          }
          if (Array.isArray(pluginResult?.omdbCandidates)) {
            omdbCandidates = pluginResult.omdbCandidates;
          }
          logger.info('plugin:analyze:used', {
            jobId: job.id,
            pluginId: analyzePlugin.id,
            mediaProfile
          });
        } catch (error) {
          pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution());
          logger.warn('plugin:analyze:fallback-legacy', {
            jobId: job.id,
            pluginId: analyzePlugin.id,
            mediaProfile,
            error: errorToMeta(error)
          });
        }
      }
      if (mediaProfile === 'dvd' && analyzePlugin?.id === 'dvd') {
        try {
          const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd');
          const dvdSeriesPluginEnabled = ['1', 'true', 'yes', 'on'].includes(
            String(dvdSettings?.dvd_series_plugin_enabled || '').trim().toLowerCase()
          );
          const dvdSeriesPrescanEnabled = ['1', 'true', 'yes', 'on'].includes(
            String(dvdSettings?.dvd_series_prescan_enabled || '').trim().toLowerCase()
          );

          if (dvdSeriesPluginEnabled && dvdSeriesPrescanEnabled) {
            const reviewResult = await this.runPluginReviewScan(analyzePlugin, job, {
              jobId: job.id,
              deviceInfo: deviceWithProfile,
              reviewMode: 'pre_rip',
              source: 'HANDBRAKE_SCAN',
              silent: true
            });
            pluginExecution = this.mergePluginExecutionState(pluginExecution, reviewResult?.pluginExecution || null);

            const scanText = Array.isArray(reviewResult?.scanLines)
              ? reviewResult.scanLines.join('\n')
              : '';
            if (scanText) {
              const { DVDSeriesPlugin } = require('../plugins/DVDSeriesPlugin');
              const dvdSeriesPlugin = new DVDSeriesPlugin();
              const dvdSeriesContext = await this.buildPluginContext(dvdSeriesPlugin.id, {
                discInfo: deviceWithProfile,
                jobId: job.id,
                scanText,
                detectedTitle: effectiveDetectedTitle
              });
              const seriesPluginResult = await dvdSeriesPlugin.analyze(device.path, job, dvdSeriesContext);
              pluginExecution = this.mergePluginExecutionState(
                pluginExecution,
                this.sanitizePluginExecutionState(dvdSeriesContext.getPluginExecution())
              );

              seriesAnalysis = seriesPluginResult?.seriesAnalysis || null;
              seriesLookupHint = seriesPluginResult?.seriesLookupHint || null;
              const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object'
                ? seriesAnalysis.summary
                : null;
              const seriesLike = Boolean(rawSeriesSummary?.seriesLike);
              seriesAnalysis = rawSeriesSummary
                ? {
                    source: String(seriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text',
                    generatedAt: seriesAnalysis?.generatedAt || nowIso(),
                    summary: {
                      seriesLike,
                      confidence: String(rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low',
                      reasons: Array.isArray(rawSeriesSummary?.reasons) ? rawSeriesSummary.reasons : [],
                      titleCount: Number(rawSeriesSummary?.titleCount || 0) || 0
                    }
                  }
                : null;

              if (seriesLike) {
                metadataProvider = 'tmdb';
                metadataCandidates = [];
                const fallbackQuery = String(seriesLookupHint?.query || effectiveDetectedTitle || '').trim() || null;
                if (fallbackQuery) {
                  seriesLookupHint = {
                    ...(seriesLookupHint && typeof seriesLookupHint === 'object' ? seriesLookupHint : {}),
                    query: fallbackQuery,
                    seriesTitle: String(seriesLookupHint?.seriesTitle || fallbackQuery).trim() || fallbackQuery
                  };
                }

                if (seriesPluginResult?.providerConfigured && seriesLookupHint?.query) {
                  metadataCandidates = await tmdbService.searchSeriesWithSeasons(
                    seriesLookupHint.query,
                    { language: dvdSettings?.dvd_series_language || null }
                  ).catch((error) => {
                    logger.warn('dvd-series:tmdb-search-failed', {
                      jobId: job.id,
                      query: seriesLookupHint?.query || null,
                      seasonNumber: seriesLookupHint?.seasonNumber || null,
                      error: errorToMeta(error)
                    });
                    return [];
                  });
                }
              }

              logger.info('dvd-series:analyze:done', {
                jobId: job.id,
                seriesLike: Boolean(seriesAnalysis?.summary?.seriesLike),
                confidence: seriesAnalysis?.summary?.confidence || null,
                metadataProvider,
                metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0,
                seriesLookupHint
              });
            }
          }
        } catch (error) {
          logger.warn('dvd-series:analyze:fallback-legacy', {
            jobId: job.id,
            error: errorToMeta(error)
          });
        }
      }
      if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) {
        omdbCandidates = await omdbService.search(effectiveDetectedTitle).catch(() => []);
      }
      if (metadataProvider !== 'tmdb') {
        metadataCandidates = Array.isArray(omdbCandidates) ? omdbCandidates : [];
      }
      logger.info('metadata:prepare:result', {
        jobId: job.id,
        detectedTitle: effectiveDetectedTitle,
        metadataProvider,
        metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0,
        omdbCandidateCount: Array.isArray(omdbCandidates) ? omdbCandidates.length : 0
      });

      const prepareInfo = this.withPluginExecutionMeta(
        this.withAnalyzeContextMediaProfile({
          phase: 'PREPARE',
          preparedAt: nowIso(),
          analyzeContext: {
            playlistAnalysis: null,
            playlistDecisionRequired: false,
            selectedPlaylist: null,
            selectedTitleId: null,
            metadataProvider,
            metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [],
            seriesAnalysis,
            seriesLookupHint
          }
        }, 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',
        metadataProvider === 'tmdb'
          ? `Serien-DVD erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.`
          : `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${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 : [],
            omdbCandidates,
            mediaProfile,
            seriesAnalysis,
            seriesLookupHint,
            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}: ${effectiveDetectedTitle} (${Array.isArray(metadataCandidates) ? metadataCandidates.length : 0} ${metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'} Treffer)`
      });

      return {
        jobId: job.id,
        detectedTitle: effectiveDetectedTitle,
        metadataProvider,
        metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [],
        seriesAnalysis,
        seriesLookupHint,
        omdbCandidates
      };
    } catch (error) {
      logger.error('metadata:prepare:failed', { jobId: job.id, error: errorToMeta(error) });
      await this.failJob(job.id, 'METADATA_SELECTION', error);
      throw error;
    }
  }

  async searchOmdb(query) {
    logger.info('omdb:search', { query });
    const results = await omdbService.search(query);
    logger.info('omdb:search:done', { query, count: results.length });
    return results;
  }

  async searchTmdbSeries(query, seasonNumber = null) {
    const normalizedQuery = String(query || '').trim();
    if (!normalizedQuery) {
      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();
    logger.info('tmdb:series-search', {
      query: normalizedQuery,
      seasonNumber: seasonFilter || null
    });

    let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery);
    if (!Array.isArray(normalizedResults)) {
      normalizedResults = [];
    }

    if (seasonFilter) {
      const seasonFilterLower = seasonFilter.toLowerCase();
      normalizedResults = normalizedResults.filter((row) => {
        const rowSeason = String(row?.seasonNumber ?? '').trim().toLowerCase();
        const rowSeasonName = String(row?.seasonName || '').trim().toLowerCase();
        return rowSeason.includes(seasonFilterLower) || rowSeasonName.includes(seasonFilterLower);
      });
    }

    logger.info('tmdb:series-search:done', { query: normalizedQuery, count: normalizedResults.length });

    if (normalizedResults.length === 0) {
      const seasonInfo = seasonFilter
        ? ` (Staffel-Filter ${seasonFilter})`
        : '';
      const error = new Error(`Keine TMDb-Treffer für "${normalizedQuery}"${seasonInfo}.`);
      error.statusCode = 404;
      throw error;
    }

    return normalizedResults;
  }

  async runDiscTrackReviewForJob(jobId, deviceInfo = null, options = {}) {
    this.ensureNotBusy('runDiscTrackReviewForJob', jobId);
    logger.info('disc-track-review:start', { jobId, deviceInfo, options });

    const job = await historyService.getJobById(jobId);
    if (!job) {
      const error = new Error(`Job ${jobId} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }
    const mkInfo = this.safeParseJson(job.makemkv_info_json);
    const mediaProfile = this.resolveMediaProfileForJob(job, {
      mediaProfile: options?.mediaProfile,
      deviceInfo,
      makemkvInfo: mkInfo
    });
    const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
    const reviewPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'review').catch(() => null);
    let reviewPluginExecution = null;
    const analyzeContext = mkInfo?.analyzeContext || {};
    const playlistAnalysis = analyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null;
    const selectedPlaylistId = normalizePlaylistId(
      options?.selectedPlaylist
      || analyzeContext.selectedPlaylist
      || this.snapshot.context?.selectedPlaylist
      || null
    );
    const selectedMakemkvTitleIdRaw =
      options?.selectedTitleId
      ?? analyzeContext.selectedTitleId
      ?? this.snapshot.context?.selectedTitleId
      ?? null;
    const selectedMakemkvTitleId = normalizeNonNegativeInteger(selectedMakemkvTitleIdRaw);
    const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {});
    const isSeriesDvdReview = 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)
    });
    lines.push(...pluginScan.scanLines);
    runInfo = pluginScan.runInfo || null;
    sourceArg = pluginScan.sourceArg || null;
    reviewPluginExecution = this.mergePluginExecutionState(
      reviewPluginExecution,
      pluginScan.pluginExecution
    );

    const parsed = parseMediainfoJsonOutput(lines.join('\n'));
    if (!parsed) {
      const error = new Error('HandBrake Scan-Ausgabe konnte nicht als JSON gelesen werden.');
      error.runInfo = runInfo;
      throw error;
    }
    if (isSeriesDvdReview) {
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        'Serien-DVD erkannt: Mindestlängen-Filter für Vorab-Spurprüfung ist deaktiviert.'
      );
    }

    const review = buildDiscScanReview({
      scanJson: parsed,
      settings,
      playlistAnalysis,
      selectedPlaylistId,
      selectedMakemkvTitleId,
      mediaProfile,
      sourceArg,
      disableMinLengthFilter: isSeriesDvdReview
    });

    if (!Array.isArray(review.titles) || review.titles.length === 0) {
      const error = new Error('Vorab-Spurprüfung lieferte keine Titel.');
      error.statusCode = 400;
      throw error;
    }

    const persistedMediainfoInfo = this.withPluginExecutionMeta({
      generatedAt: nowIso(),
      source: 'disc_scan',
      runInfo
    }, reviewPluginExecution);
    await historyService.updateJob(jobId, {
      status: 'READY_TO_ENCODE',
      last_state: 'READY_TO_ENCODE',
      error_message: null,
      mediainfo_info_json: JSON.stringify(persistedMediainfoInfo),
      encode_plan_json: JSON.stringify(review),
      encode_input_path: review.encodeInputPath || null,
      encode_review_confirmed: 0
    });

    await historyService.appendLog(
      jobId,
      'SYSTEM',
      `Vorab-Spurprüfung abgeschlossen: ${review.titles.length} Titel, Auswahl=${review.encodeInputTitleId ? `Titel #${review.encodeInputTitleId}` : 'keine'}.`
    );

    await this.setState('READY_TO_ENCODE', {
      activeJobId: jobId,
      progress: 0,
      eta: null,
      statusText: review.titleSelectionRequired
        ? 'Vorab-Spurprüfung fertig - Titel per Checkbox wählen'
        : 'Vorab-Spurprüfung fertig - Auswahl bestätigen, dann Backup/Encode starten',
      context: {
        ...(this.snapshot.context || {}),
        jobId,
        inputPath: review.encodeInputPath || null,
        hasEncodableTitle: Boolean(review.encodeInputTitleId),
        reviewConfirmed: false,
        mode: 'pre_rip',
        mediaProfile,
        mediaInfoReview: review,
        selectedMetadata,
        ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {})
      }
    });

    return review;
  }

  async handleDiscTrackReviewFailure(jobId, error, context = {}) {
    const message = error?.message || String(error);
    const runInfo = error?.runInfo && typeof error.runInfo === 'object'
      ? error.runInfo
      : null;
    const isDiscScanFailure = String(runInfo?.source || '').toUpperCase() === 'HANDBRAKE_SCAN'
      || /no title found/i.test(message);

    if (!isDiscScanFailure) {
      await this.failJob(jobId, 'MEDIAINFO_CHECK', error);
      return;
    }

    logger.warn('disc-track-review:fallback-to-manual-rip', {
      jobId,
      message,
      runInfo: runInfo || null
    });

    await historyService.updateJob(jobId, {
      status: 'READY_TO_START',
      last_state: 'READY_TO_START',
      error_message: null,
      mediainfo_info_json: JSON.stringify({
        source: 'disc_scan',
        failedAt: nowIso(),
        error: message,
        runInfo
      }),
      encode_plan_json: null,
      encode_input_path: null,
      encode_review_confirmed: 0
    });

    await historyService.appendLog(
      jobId,
      'SYSTEM',
      `Vorab-Spurprüfung fehlgeschlagen (${message}). Fallback: Backup/Rip kann manuell gestartet werden; Spurauswahl erfolgt danach.`
    );

    await this.setState('READY_TO_START', {
      activeJobId: jobId,
      progress: 0,
      eta: null,
      statusText: 'Vorab-Spurprüfung fehlgeschlagen - Backup manuell starten',
      context: {
        ...(this.snapshot.context || {}),
        jobId,
        selectedMetadata: context.selectedMetadata || this.snapshot.context?.selectedMetadata || null,
        playlistAnalysis: context.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null,
        playlistDecisionRequired: Boolean(context.playlistDecisionRequired ?? this.snapshot.context?.playlistDecisionRequired),
        playlistCandidates: context.playlistCandidates || this.snapshot.context?.playlistCandidates || [],
        selectedPlaylist: context.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null,
        selectedTitleId: context.selectedTitleId ?? this.snapshot.context?.selectedTitleId ?? null,
        preRipScanFailed: true,
        preRipScanError: message
      }
    });
  }

  async runBackupTrackReviewForJob(jobId, rawPath, options = {}) {
    this.ensureNotBusy('runBackupTrackReviewForJob', jobId);
    logger.info('backup-track-review:start', { jobId, rawPath, options });

    const job = await historyService.getJobById(jobId);
    if (!job) {
      const error = new Error(`Job ${jobId} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }

    if (!rawPath || !fs.existsSync(rawPath)) {
      const error = new Error(`RAW-Pfad nicht gefunden (${rawPath || '-'})`);
      error.statusCode = 400;
      throw error;
    }

    const mode = String(options?.mode || 'rip').trim().toLowerCase() || 'rip';
    const forcePlaylistReselection = Boolean(options?.forcePlaylistReselection);
    const forceFreshAnalyze = Boolean(options?.forceFreshAnalyze);
    const mkInfo = this.safeParseJson(job.makemkv_info_json);
    const mediaProfile = this.resolveMediaProfileForJob(job, {
      mediaProfile: options?.mediaProfile,
      rawPath,
      makemkvInfo: mkInfo
    });
    const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
    const reviewPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'review').catch(() => null);
    let reviewPluginExecution = null;
    const analyzeContext = mkInfo?.analyzeContext || {};
    let playlistAnalysis = forceFreshAnalyze
      ? null
      : (analyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null);
    let handBrakePlaylistScan = forceFreshAnalyze
      ? null
      : normalizeHandBrakePlaylistScanCache(analyzeContext.handBrakePlaylistScan || null);
    if (playlistAnalysis && handBrakePlaylistScan) {
      playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
    }
    const selectedPlaylistSource = (forcePlaylistReselection || forceFreshAnalyze)
      ? (options?.selectedPlaylist || null)
      : (options?.selectedPlaylist || analyzeContext.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null);
    const selectedPlaylistId = normalizePlaylistId(
      selectedPlaylistSource
    );
    const selectedTitleSource = (forcePlaylistReselection || forceFreshAnalyze)
      ? (options?.selectedTitleId ?? null)
      : (options?.selectedTitleId ?? analyzeContext.selectedTitleId ?? this.snapshot.context?.selectedTitleId ?? null);
    const selectedMakemkvTitleId = normalizeNonNegativeInteger(selectedTitleSource);
    const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {});

    if (this.isPrimaryJob(jobId)) {
      await this.setState('MEDIAINFO_CHECK', {
        activeJobId: jobId,
        progress: 0,
        eta: null,
        statusText: 'Titel-/Spurprüfung aus RAW-Backup läuft',
        context: {
          ...(this.snapshot.context || {}),
          jobId,
          rawPath,
          inputPath: null,
          hasEncodableTitle: false,
          reviewConfirmed: false,
          mode,
          mediaProfile,
          sourceJobId: options.sourceJobId || null,
          selectedMetadata
        }
      });
    }

    await historyService.updateJob(jobId, {
      status: 'MEDIAINFO_CHECK',
      last_state: 'MEDIAINFO_CHECK',
      error_message: null,
      makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile(mkInfo, mediaProfile)),
      mediainfo_info_json: null,
      encode_plan_json: null,
      encode_input_path: null,
      encode_review_confirmed: 0
    });

    if (forcePlaylistReselection && !selectedPlaylistId) {
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        'Re-Encode: gespeicherte Playlist-Auswahl wird ignoriert. Bitte Playlist manuell neu auswählen.'
      );
    }
    if (forceFreshAnalyze) {
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        'Review-Neustart erzwingt frische MakeMKV Full-Analyse (kein Reuse von Playlist-/HandBrake-Cache).'
      );
    }

    // Build playlist->TITLE_ID mapping once from MakeMKV full robot scan on RAW backup.
    let makeMkvAnalyzeRunInfo = null;
    let analyzedFromFreshRun = false;
    const existingPostBackupAnalyze = mkInfo?.postBackupAnalyze && typeof mkInfo.postBackupAnalyze === 'object'
      ? mkInfo.postBackupAnalyze
      : null;

    await this.ensureMakeMKVRegistration(jobId, 'MEDIAINFO_CHECK');
    if (selectedPlaylistId) {
      if (!playlistAnalysis || !Array.isArray(playlistAnalysis?.titles) || playlistAnalysis.titles.length === 0) {
        const error = new Error(
          'Playlist-Auswahl kann nicht fortgesetzt werden: MakeMKV-Mapping fehlt. Bitte zuerst die RAW-Analyse erneut starten.'
        );
        error.statusCode = 409;
        throw error;
      }
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        'Verwende vorhandenes MakeMKV-Playlist-Mapping aus dem letzten Full-Scan (kein erneuter Full-Scan).'
      );
    } else {
      const analyzeLines = [];
      const analyzeConfig = await settingsService.buildMakeMKVAnalyzePathConfig(rawPath, { mediaProfile });
      logger.info('backup-track-review:makemkv-analyze-command', {
        jobId,
        cmd: analyzeConfig.cmd,
        args: analyzeConfig.args,
        sourceArg: analyzeConfig.sourceArg
      });

      makeMkvAnalyzeRunInfo = await this.runCommand({
        jobId,
        stage: 'MEDIAINFO_CHECK',
        source: 'MAKEMKV_ANALYZE_BACKUP',
        cmd: analyzeConfig.cmd,
        args: analyzeConfig.args,
        parser: parseMakeMkvProgress,
        collectLines: analyzeLines,
        silent: !this.isPrimaryJob(jobId)
      });

      const analyzed = analyzePlaylistObfuscation(
        analyzeLines,
        Number(settings.makemkv_min_length_minutes || 60),
        {}
      );
      playlistAnalysis = analyzed || null;
      analyzedFromFreshRun = true;
    }

    const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired);
    let playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
    const selectedTitleFromPlaylist = selectedPlaylistId
      ? pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylistId)
      : null;
    const selectedTitleFromContext = (!selectedPlaylistId && !isCandidateTitleId(playlistAnalysis, selectedMakemkvTitleId))
      ? null
      : selectedMakemkvTitleId;
    const selectedTitleForContext = selectedTitleFromPlaylist ?? selectedTitleFromContext ?? null;
    if (selectedPlaylistId && playlistCandidates.length > 0) {
      const isKnownPlaylist = playlistCandidates.some((item) => item.playlistId === selectedPlaylistId);
      if (!isKnownPlaylist) {
        const error = new Error(`Playlist ${selectedPlaylistId}.mpls ist nicht in den erkannten Kandidaten enthalten.`);
        error.statusCode = 400;
        throw error;
      }
    }

    const shouldPrepareHandBrakeDecisionData = Boolean(
      playlistDecisionRequired
      && !selectedPlaylistId
      && playlistCandidates.length > 0
    );
    if (shouldPrepareHandBrakeDecisionData) {
      const hasCompleteCache = hasCachedHandBrakeDataForPlaylistCandidates(handBrakePlaylistScan, playlistCandidates);
      if (!hasCompleteCache) {
        await this.updateProgress(
          'MEDIAINFO_CHECK',
          25,
          null,
          'HandBrake Trackdaten für Playlist-Auswahl werden vorbereitet',
          jobId
        );
        try {
          const resolveScanLines = [];
          const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, {
            jobId,
            rawPath,
            reviewMode: 'rip',
            source: 'HANDBRAKE_SCAN_PLAYLIST_MAP',
            silent: !this.isPrimaryJob(jobId)
          });
          resolveScanLines.push(...pluginScan.scanLines);
          reviewPluginExecution = this.mergePluginExecutionState(
            reviewPluginExecution,
            pluginScan.pluginExecution
          );

          const resolveScanJson = parseMediainfoJsonOutput(resolveScanLines.join('\n'));
          if (resolveScanJson) {
            const preparedCache = buildHandBrakePlaylistScanCache(resolveScanJson, playlistCandidates, rawPath);
            if (preparedCache) {
              handBrakePlaylistScan = preparedCache;
              playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
              playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
              await historyService.appendLog(
                jobId,
                'SYSTEM',
                `HandBrake Playlist-Trackdaten vorbereitet: ${Object.keys(preparedCache.byPlaylist || {}).length} Kandidaten aus --scan -t 0 analysiert.`
              );
            } else {
              await historyService.appendLog(
                jobId,
                'SYSTEM',
                'HandBrake Playlist-Trackdaten konnten aus --scan -t 0 nicht auf Kandidaten abgebildet werden.'
              );
            }
          } else {
            const error = new Error('HandBrake Playlist-Trackdaten konnten nicht geparst werden.');
            error.runInfo = null;
            throw error;
          }
        } catch (error) {
          logger.warn('backup-track-review:handbrake-predecision-failed', {
            jobId,
            error: errorToMeta(error)
          });
          await historyService.appendLog(
            jobId,
            'SYSTEM',
            `HandBrake Voranalyse für Playlist-Auswahl fehlgeschlagen: ${error.message}`
          );
          throw error;
        }
      } else {
        playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
        playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          'HandBrake Playlist-Trackdaten aus Cache übernommen (kein erneuter --scan -t 0).'
        );
      }
    }

    const updatedMakemkvInfoBase = {
      ...mkInfo,
      analyzeContext: {
        ...(mkInfo?.analyzeContext || {}),
        mediaProfile,
        playlistAnalysis: playlistAnalysis || null,
        playlistDecisionRequired,
        selectedPlaylist: selectedPlaylistId || null,
        selectedTitleId: selectedTitleForContext,
        handBrakePlaylistScan: handBrakePlaylistScan || null
      },
      postBackupAnalyze: analyzedFromFreshRun
        ? {
          analyzedAt: nowIso(),
          source: 'MAKEMKV_ANALYZE_BACKUP',
          runInfo: makeMkvAnalyzeRunInfo,
          error: null
        }
        : {
          analyzedAt: existingPostBackupAnalyze?.analyzedAt || nowIso(),
          source: 'MAKEMKV_ANALYZE_BACKUP',
          runInfo: existingPostBackupAnalyze?.runInfo || null,
          reused: true,
          error: null
        }
    };
    const buildUpdatedMakemkvInfo = () => this.withPluginExecutionMeta(updatedMakemkvInfoBase, reviewPluginExecution);
    const getMergedReviewPluginExecution = () => this.sanitizePluginExecutionState(
      buildUpdatedMakemkvInfo()?.pluginExecution
    );

    if (playlistDecisionRequired && !selectedPlaylistId) {
      const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates)
        ? playlistAnalysis.evaluatedCandidates
        : [];
      const recommendationFile = toPlaylistFile(playlistAnalysis?.recommendation?.playlistId);
      const decisionContext = describePlaylistManualDecision(playlistAnalysis);
      const waitingMediainfoInfo = this.withPluginExecutionMeta({
        generatedAt: nowIso(),
        source: 'makemkv_backup_robot',
        runInfo: makeMkvAnalyzeRunInfo
      }, reviewPluginExecution);

      await historyService.updateJob(jobId, {
        status: 'WAITING_FOR_USER_DECISION',
        last_state: 'WAITING_FOR_USER_DECISION',
        error_message: null,
        makemkv_info_json: JSON.stringify(buildUpdatedMakemkvInfo()),
        mediainfo_info_json: JSON.stringify(waitingMediainfoInfo),
        encode_plan_json: null,
        encode_input_path: null,
        encode_review_confirmed: 0
      });

      await historyService.appendLog(jobId, 'SYSTEM', 'Mehrere mögliche Haupttitel erkannt!');
      await historyService.appendLog(jobId, 'SYSTEM', decisionContext.detailText);
      for (const candidate of evaluated) {
        const playlistFile = toPlaylistFile(candidate?.playlistId) || `Titel #${candidate?.titleId || '-'}`;
        const score = Number(candidate?.score);
        const scoreLabel = Number.isFinite(score) ? score.toFixed(0) : '-';
        const durationLabel = String(candidate?.durationLabel || '').trim() || formatDurationClock(candidate?.durationSeconds) || '-';
        const recommendedLabel = candidate?.recommended ? ' (empfohlen)' : '';
        const evaluationLabel = candidate?.evaluationLabel ? ` | ${candidate.evaluationLabel}` : '';
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `${playlistFile} -> Dauer ${durationLabel} | Score ${scoreLabel}${recommendedLabel}${evaluationLabel}`
        );
      }
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `Status=awaiting_playlist_selection${recommendationFile ? ` | Empfehlung=${recommendationFile}` : ''}`
      );

      const mergedPluginExecutionForWaiting = getMergedReviewPluginExecution();
      await this.setState('WAITING_FOR_USER_DECISION', {
        activeJobId: jobId,
        progress: 0,
        eta: null,
        statusText: 'awaiting_playlist_selection',
        context: {
          ...(this.snapshot.context || {}),
          jobId,
          rawPath,
          inputPath: null,
          hasEncodableTitle: false,
          reviewConfirmed: false,
          mode,
          mediaProfile,
          sourceJobId: options.sourceJobId || null,
          selectedMetadata,
          playlistAnalysis: playlistAnalysis || null,
          playlistDecisionRequired: true,
          playlistCandidates,
          selectedPlaylist: null,
          selectedTitleId: null,
          waitingForManualPlaylistSelection: true,
          manualDecisionState: 'awaiting_playlist_selection',
          mediaInfoReview: null,
          ...(mergedPluginExecutionForWaiting ? { pluginExecution: mergedPluginExecutionForWaiting } : {})
        }
      });

      const notificationMessage = [
        '⚠️ Manuelle Prüfung erforderlich!',
        decisionContext.obfuscationDetected
          ? 'Mehrere gleichlange Playlists erkannt.'
          : 'Mehrere Playlists erfüllen MIN_LENGTH_MINUTES.',
        '',
        'Empfehlung:',
        recommendationFile || '(keine eindeutige Empfehlung)',
        '',
        'Bitte Titel manuell bestätigen,',
        'bevor Encoding gestartet wird.'
      ].join('\n');
      void this.notifyPushover('metadata_ready', {
        title: 'Ripster - Playlist-Auswahl erforderlich',
        message: notificationMessage,
        priority: 1
      });

      return {
        awaitingPlaylistSelection: true,
        playlistAnalysis,
        playlistCandidates,
        recommendation: playlistAnalysis?.recommendation || null
      };
    }

    if (selectedPlaylistId) {
      await historyService.appendLog(
        jobId,
        'USER_ACTION',
        `Playlist-Auswahl übernommen: ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId}.`
      );
    }

    const selectedTitleForReview = pickTitleIdForTrackReview(playlistAnalysis, selectedTitleForContext);
    if (selectedTitleForReview === null) {
      const error = new Error('Titel-/Spurprüfung aus RAW nicht möglich: keine auflösbare Titel-ID vorhanden.');
      error.statusCode = 400;
      throw error;
    }

    const selectedTitleFromAnalysis = Array.isArray(playlistAnalysis?.titles)
      ? (playlistAnalysis.titles.find((item) => Number(item?.titleId) === Number(selectedTitleForReview)) || null)
      : null;
    const resolvedPlaylistId = normalizePlaylistId(
      selectedPlaylistId
      || selectedTitleFromAnalysis?.playlistId
      || playlistAnalysis?.recommendation?.playlistId
      || null
    );
    if (!resolvedPlaylistId) {
      const error = new Error(
        `Playlist konnte für MakeMKV Titel #${selectedTitleForReview} nicht aufgelöst werden.`
      );
      error.statusCode = 400;
      throw error;
    }

    if (updatedMakemkvInfoBase && updatedMakemkvInfoBase.analyzeContext) {
      updatedMakemkvInfoBase.analyzeContext.selectedTitleId = selectedTitleForReview;
      updatedMakemkvInfoBase.analyzeContext.selectedPlaylist = resolvedPlaylistId;
    }

    const cachedHandBrakePlaylistEntry = getCachedHandBrakePlaylistEntry(handBrakePlaylistScan, resolvedPlaylistId);
    const expectedDurationForCache = Number(selectedTitleFromAnalysis?.durationSeconds || 0) || null;
    const expectedSizeForCache = Number(selectedTitleFromAnalysis?.sizeBytes || 0) || null;
    const hasCachedHandBrakeEntry = Boolean(
      isHandBrakePlaylistCacheEntryCompatible(
        cachedHandBrakePlaylistEntry,
        resolvedPlaylistId,
        {
          expectedDurationSeconds: expectedDurationForCache,
          expectedSizeBytes: expectedSizeForCache
        }
      )
    );
    if (cachedHandBrakePlaylistEntry && !hasCachedHandBrakeEntry) {
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `HandBrake Cache für ${toPlaylistFile(resolvedPlaylistId)} verworfen (inkompatible Playlist-/Dauerdaten).`
      );
    }

    await this.updateProgress(
      'MEDIAINFO_CHECK',
      30,
      null,
      hasCachedHandBrakeEntry
        ? `HandBrake Trackdaten aus Cache (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})`
        : `HandBrake Titel-/Spurscan läuft (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})`,
      jobId
    );

    let handBrakeResolveRunInfo = null;
    let handBrakeTitleRunInfo = null;
    let resolvedHandBrakeTitleId = null;
    const reviewTitleSource = 'handbrake';
    const makeMkvSubtitleTracksForSelection = Array.isArray(selectedTitleFromAnalysis?.subtitleTracks)
      ? selectedTitleFromAnalysis.subtitleTracks
      : [];
    let reviewTitleInfo = null;
    if (hasCachedHandBrakeEntry) {
      resolvedHandBrakeTitleId = Math.trunc(Number(cachedHandBrakePlaylistEntry.handBrakeTitleId));
      reviewTitleInfo = enrichTitleInfoWithForcedSubtitleAvailability(
        cachedHandBrakePlaylistEntry.titleInfo,
        makeMkvSubtitleTracksForSelection
      );
      handBrakeResolveRunInfo = {
        source: 'HANDBRAKE_SCAN_PLAYLIST_MAP_CACHE',
        stage: 'MEDIAINFO_CHECK',
        status: 'CACHED',
        exitCode: 0,
        startedAt: handBrakePlaylistScan?.generatedAt || null,
        endedAt: nowIso(),
        durationMs: 0,
        cmd: 'cache',
        args: [`playlist=${resolvedPlaylistId}`, `title=${resolvedHandBrakeTitleId}`],
        highlights: [
          `Cache verwendet: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId}`
        ]
      };
      handBrakeTitleRunInfo = handBrakeResolveRunInfo;
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `HandBrake Track-Analyse aus Cache: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (kein erneuter --scan).`
      );
    } else {
      try {
        const resolveScanLines = [];
        const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, {
          jobId,
          rawPath,
          reviewMode: 'rip',
          source: 'HANDBRAKE_SCAN_PLAYLIST_MAP',
          silent: !this.isPrimaryJob(jobId)
        });
        resolveScanLines.push(...pluginScan.scanLines);
        handBrakeResolveRunInfo = pluginScan.runInfo || null;
        reviewPluginExecution = this.mergePluginExecutionState(
          reviewPluginExecution,
          pluginScan.pluginExecution
        );

        const resolveScanJson = parseMediainfoJsonOutput(resolveScanLines.join('\n'));
        if (!resolveScanJson) {
          const error = new Error('HandBrake Playlist-Mapping lieferte kein parsebares JSON.');
          error.runInfo = handBrakeResolveRunInfo;
          throw error;
        }

        resolvedHandBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(resolveScanJson, resolvedPlaylistId, {
          expectedMakemkvTitleId: selectedTitleForReview,
          expectedDurationSeconds: expectedDurationForCache,
          expectedSizeBytes: expectedSizeForCache
        });
        if (!resolvedHandBrakeTitleId) {
          const knownPlaylists = listAvailableHandBrakePlaylists(resolveScanJson);
          const error = new Error(
            `Kein HandBrake-Titel für ${toPlaylistFile(resolvedPlaylistId)} gefunden.`
            + ` ${knownPlaylists.length > 0 ? `Scan-Playlists: ${knownPlaylists.map((id) => `${id}.mpls`).join(', ')}` : 'Scan enthält keine erkennbaren Playlist-IDs.'}`
          );
          error.statusCode = 400;
          error.runInfo = handBrakeResolveRunInfo;
          throw error;
        }

        reviewTitleInfo = parseHandBrakeSelectedTitleInfo(resolveScanJson, {
          playlistId: resolvedPlaylistId,
          handBrakeTitleId: resolvedHandBrakeTitleId,
          makeMkvSubtitleTracks: makeMkvSubtitleTracksForSelection
        });
        if (!reviewTitleInfo) {
          const error = new Error(
            `HandBrake lieferte keine verwertbaren Trackdaten für ${toPlaylistFile(resolvedPlaylistId)} (-t ${resolvedHandBrakeTitleId}).`
          );
          error.statusCode = 400;
          error.runInfo = handBrakeResolveRunInfo;
          throw error;
        }
        if (!isHandBrakePlaylistCacheEntryCompatible({
          playlistId: resolvedPlaylistId,
          handBrakeTitleId: resolvedHandBrakeTitleId,
          titleInfo: reviewTitleInfo
        }, resolvedPlaylistId, {
          expectedDurationSeconds: expectedDurationForCache,
          expectedSizeBytes: expectedSizeForCache
        })) {
          const error = new Error(
            `HandBrake Titel-Mapping inkonsistent für ${toPlaylistFile(resolvedPlaylistId)} (-t ${resolvedHandBrakeTitleId}).`
          );
          error.statusCode = 400;
          error.runInfo = handBrakeResolveRunInfo;
          throw error;
        }

        handBrakeTitleRunInfo = handBrakeResolveRunInfo;
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `HandBrake Track-Analyse aktiv: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (aus --scan -t 0).`
        );

        const audioTrackPreview = buildHandBrakeAudioTrackPreview(reviewTitleInfo);
        const fallbackCache = normalizeHandBrakePlaylistScanCache(handBrakePlaylistScan) || {
          generatedAt: nowIso(),
          source: 'HANDBRAKE_SCAN_PLAYLIST_MAP',
          inputPath: rawPath,
          byPlaylist: {}
        };
        fallbackCache.byPlaylist[resolvedPlaylistId] = {
          playlistId: resolvedPlaylistId,
          handBrakeTitleId: Math.trunc(Number(resolvedHandBrakeTitleId)),
          titleInfo: reviewTitleInfo,
          audioTrackPreview,
          audioSummary: buildHandBrakeAudioSummary(audioTrackPreview)
        };
        handBrakePlaylistScan = normalizeHandBrakePlaylistScanCache(fallbackCache);
        playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
      } catch (error) {
        logger.warn('backup-track-review:handbrake-scan-failed', {
          jobId,
          selectedPlaylistId: resolvedPlaylistId,
          selectedTitleForReview,
          error: errorToMeta(error)
        });
        throw error;
      }
    }

    if (updatedMakemkvInfoBase && updatedMakemkvInfoBase.analyzeContext) {
      updatedMakemkvInfoBase.analyzeContext.handBrakePlaylistScan = handBrakePlaylistScan || null;
    }
    playlistCandidates = buildPlaylistCandidates(playlistAnalysis);

    let presetProfile = null;
    try {
      presetProfile = await settingsService.buildHandBrakePresetProfile(rawPath, {
        titleId: resolvedHandBrakeTitleId,
        mediaProfile
      });
    } catch (error) {
      logger.warn('backup-track-review:preset-profile-failed', {
        jobId,
        error: errorToMeta(error)
      });
      presetProfile = {
        source: 'fallback',
        message: `Preset-Profil konnte nicht geladen werden: ${error.message}`
      };
    }

    const syntheticFilePath = path.join(
      rawPath,
      reviewTitleSource === 'handbrake' && Number.isFinite(Number(resolvedHandBrakeTitleId)) && Number(resolvedHandBrakeTitleId) > 0
        ? `handbrake_t${String(Math.trunc(Number(resolvedHandBrakeTitleId))).padStart(2, '0')}.mkv`
        : `makemkv_t${String(selectedTitleForReview).padStart(2, '0')}.mkv`
    );
    const syntheticMediaInfoByPath = {
      [syntheticFilePath]: buildSyntheticMediaInfoFromMakeMkvTitle(reviewTitleInfo)
    };
    let review = buildMediainfoReview({
      mediaFiles: [{
        path: syntheticFilePath,
        size: Number(reviewTitleInfo?.sizeBytes || 0)
      }],
      mediaInfoByPath: syntheticMediaInfoByPath,
      settings,
      presetProfile,
      playlistAnalysis,
      preferredEncodeTitleId: selectedTitleForReview,
      selectedPlaylistId: resolvedPlaylistId || reviewTitleInfo?.playlistId || null,
      selectedMakemkvTitleId: selectedTitleForReview
    });
    review = remapReviewTrackIdsToSourceIds(review);

    const resolvedPlaylistInfo = resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId);
    const minLengthMinutesForReview = Number(review?.minLengthMinutes ?? settings?.makemkv_min_length_minutes ?? 0);
    const minLengthSecondsForReview = Math.max(0, Math.round(minLengthMinutesForReview * 60));
    const subtitleTrackMetaBySourceId = new Map(
      (Array.isArray(reviewTitleInfo?.subtitleTracks) ? reviewTitleInfo.subtitleTracks : [])
        .map((track) => {
          const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
          return sourceTrackId ? [sourceTrackId, track] : null;
        })
        .filter(Boolean)
    );
    const normalizedTitles = (Array.isArray(review.titles) ? review.titles : [])
      .slice(0, 1)
      .map((title) => {
        const durationSeconds = Number(reviewTitleInfo?.durationSeconds || title?.durationSeconds || 0);
        const eligibleForEncode = durationSeconds >= minLengthSecondsForReview;
        const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => {
          const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
          const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null;
          const selectedByRule = Boolean(sourceMeta?.selectedByRule ?? track?.selectedByRule);
          const duplicate = Boolean(sourceMeta?.duplicate ?? track?.duplicate);
          const subtitleType = String(
            sourceMeta?.subtitleType
            || track?.subtitleType
            || (sourceMeta?.forcedTrack ? 'forced' : (track?.forcedTrack ? 'forced' : 'full'))
          ).trim().toLowerCase() === 'forced'
            ? 'forced'
            : 'full';
          const rawConfidence = String(sourceMeta?.sourceConfidence || track?.sourceConfidence || '')
            .trim()
            .toLowerCase();
          const confidenceSource = String(sourceMeta?.confidenceSource || track?.confidenceSource || '')
            .trim()
            .toLowerCase();
          let sourceConfidence = null;
          if (subtitleType === 'forced') {
            if (rawConfidence === 'high' || rawConfidence === 'medium' || rawConfidence === 'low') {
              sourceConfidence = rawConfidence;
            } else if (confidenceSource === 'title') {
              sourceConfidence = 'medium';
            } else {
              sourceConfidence = 'low';
            }
          }
          const subtitlePreviewBurnIn = Boolean(sourceMeta?.subtitlePreviewBurnIn ?? track?.subtitlePreviewBurnIn);
          const subtitlePreviewForced = Boolean(
            sourceMeta?.subtitlePreviewForced
            ?? track?.subtitlePreviewForced
            ?? (selectedByRule && subtitleType === 'forced')
          );
          const subtitlePreviewForcedOnly = Boolean(
            sourceMeta?.subtitlePreviewForcedOnly
            ?? track?.subtitlePreviewForcedOnly
          );
          const isForcedOnly = Boolean(
            sourceMeta?.isForcedOnly
            ?? track?.isForcedOnly
            ?? sourceMeta?.forcedOnly
            ?? track?.forcedOnly
            ?? subtitlePreviewForcedOnly
            ?? subtitleType === 'forced'
          );
          const fullHasForced = !isForcedOnly && Boolean(
            sourceMeta?.fullHasForced
            ?? track?.fullHasForced
            ?? sourceMeta?.subtitleFullHasForced
            ?? track?.subtitleFullHasForced
          );
          const subtitlePreviewDefaultTrack = Boolean(
            sourceMeta?.subtitlePreviewDefaultTrack
            ?? track?.subtitlePreviewDefaultTrack
            ?? sourceMeta?.defaultFlag
            ?? track?.defaultFlag
          );
          const subtitlePreviewFlags = Array.isArray(sourceMeta?.subtitlePreviewFlags)
            ? sourceMeta.subtitlePreviewFlags
            : (Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []);
          const subtitlePreviewSummary = String(
            sourceMeta?.subtitlePreviewSummary
            || track?.subtitlePreviewSummary
            || (selectedByRule
              ? 'Übernehmen'
              : (duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen'))
          ).trim() || 'Nicht übernommen';
          const selectedForEncode = selectedByRule;
          return {
            ...track,
            id: sourceTrackId || track?.id || null,
            sourceTrackId: sourceTrackId || track?.sourceTrackId || track?.id || null,
            language: sourceMeta?.language || track?.language || 'und',
            languageLabel: sourceMeta?.languageLabel || track?.languageLabel || track?.language || 'und',
            title: sourceMeta?.title ?? track?.title ?? null,
            format: sourceMeta?.format || track?.format || null,
            defaultFlag: Boolean(sourceMeta?.defaultFlag ?? track?.defaultFlag),
            eventCount: Number.isFinite(Number(sourceMeta?.eventCount))
              ? Math.trunc(Number(sourceMeta.eventCount))
              : (Number.isFinite(Number(track?.eventCount)) ? Math.trunc(Number(track.eventCount)) : null),
            streamSizeBytes: Number.isFinite(Number(sourceMeta?.streamSizeBytes))
              ? Math.trunc(Number(sourceMeta.streamSizeBytes))
              : (Number.isFinite(Number(track?.streamSizeBytes)) ? Math.trunc(Number(track.streamSizeBytes)) : null),
            forcedTrack: Boolean(sourceMeta?.forcedTrack ?? subtitlePreviewForced),
            forcedAvailable: Boolean(sourceMeta?.forcedAvailable),
            forcedSourceTrackIds: normalizeTrackIdList(sourceMeta?.forcedSourceTrackIds || []),
            isForcedOnly,
            fullHasForced,
            subtitleType,
            sourceConfidence,
            confidenceSource,
            duplicate,
            selected: selectedByRule,
            selectedByRule,
            selectedForEncode,
            subtitlePreviewSummary,
            subtitlePreviewFlags,
            subtitlePreviewBurnIn,
            subtitlePreviewForced,
            subtitlePreviewForcedOnly,
            subtitlePreviewDefaultTrack,
            burnIn: selectedForEncode ? subtitlePreviewBurnIn : false,
            forced: selectedForEncode ? subtitlePreviewForced : false,
            forcedOnly: selectedForEncode ? subtitlePreviewForcedOnly : false,
            defaultTrack: selectedForEncode ? subtitlePreviewDefaultTrack : false,
            flags: selectedForEncode ? subtitlePreviewFlags : [],
            subtitleActionSummary: selectedForEncode ? subtitlePreviewSummary : 'Nicht übernommen'
          };
        });

        return {
          ...title,
          filePath: rawPath,
          fileName: reviewTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`,
          durationSeconds,
          durationMinutes: Number(((durationSeconds / 60)).toFixed(2)),
          selectedByMinLength: eligibleForEncode,
          eligibleForEncode,
          sizeBytes: Number(reviewTitleInfo?.sizeBytes || title?.sizeBytes || 0),
          playlistId: resolvedPlaylistInfo.playlistId || title?.playlistId || null,
          playlistFile: resolvedPlaylistInfo.playlistFile || title?.playlistFile || null,
          playlistRecommended: Boolean(resolvedPlaylistInfo.recommended || title?.playlistRecommended),
          playlistEvaluationLabel: resolvedPlaylistInfo.evaluationLabel || title?.playlistEvaluationLabel || null,
          playlistSegmentCommand: resolvedPlaylistInfo.segmentCommand || title?.playlistSegmentCommand || null,
          playlistSegmentFiles: Array.isArray(resolvedPlaylistInfo.segmentFiles) && resolvedPlaylistInfo.segmentFiles.length > 0
            ? resolvedPlaylistInfo.segmentFiles
            : (Array.isArray(title?.playlistSegmentFiles) ? title.playlistSegmentFiles : []),
          subtitleTracks
        };
      });

    const encodeInputTitleId = Number(normalizedTitles[0]?.id || review.encodeInputTitleId || null) || null;
    review = {
      ...review,
      mode,
      mediaProfile,
      sourceJobId: options.sourceJobId || null,
      reviewConfirmed: false,
      partial: false,
      processedFiles: 1,
      totalFiles: 1,
      handBrakeTitleId: resolvedHandBrakeTitleId || null,
      selectedPlaylistId: resolvedPlaylistId || null,
      selectedMakemkvTitleId: selectedTitleForReview,
      titleSelectionRequired: false,
      titles: normalizedTitles,
      selectedTitleIds: encodeInputTitleId ? [encodeInputTitleId] : [],
      encodeInputTitleId,
      encodeInputPath: rawPath,
      notes: [
        ...(Array.isArray(review.notes) ? review.notes : []),
        'MakeMKV Full-Analyse wurde einmal für Playlist-/Titel-Mapping verwendet.',
        `HandBrake Track-Analyse aktiv: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (aus --scan -t 0).`
      ]
    };

    const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan(
      review,
      options?.previousEncodePlan && typeof options.previousEncodePlan === 'object'
        ? options.previousEncodePlan
        : null
    );
    review = reviewPrefillResult.plan;

    if (!Array.isArray(review.titles) || review.titles.length === 0) {
      const error = new Error('Titel-/Spurprüfung aus RAW lieferte keine Titel.');
      error.statusCode = 400;
      throw error;
    }

    const persistedMediainfoInfo = this.withPluginExecutionMeta({
      generatedAt: nowIso(),
      source: 'raw_backup_handbrake_playlist_scan',
      makemkvAnalyzeRunInfo: makeMkvAnalyzeRunInfo,
      makemkvTitleAnalyzeRunInfo: null,
      handbrakePlaylistResolveRunInfo: handBrakeResolveRunInfo,
      handbrakeTitleRunInfo: handBrakeTitleRunInfo,
      handbrakeTitleId: resolvedHandBrakeTitleId || null
    }, reviewPluginExecution);

    await historyService.updateJob(jobId, {
      status: 'READY_TO_ENCODE',
      last_state: 'READY_TO_ENCODE',
      error_message: null,
      makemkv_info_json: JSON.stringify(buildUpdatedMakemkvInfo()),
      mediainfo_info_json: JSON.stringify(persistedMediainfoInfo),
      encode_plan_json: JSON.stringify(review),
      encode_input_path: review.encodeInputPath || null,
      encode_review_confirmed: 0
    });

    await historyService.appendLog(
      jobId,
      'SYSTEM',
      `Titel-/Spurprüfung aus RAW abgeschlossen (MakeMKV Titel #${selectedTitleForReview}): ${review.titles.length} Titel, Vorauswahl=${review.encodeInputTitleId ? `Titel #${review.encodeInputTitleId}` : 'keine'}.`
    );
    if (reviewPrefillResult.applied) {
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `Vorherige Encode-Auswahl als Standard übernommen: Titel #${reviewPrefillResult.selectedEncodeTitleId || '-'}, `
        + `Pre-Skripte=${reviewPrefillResult.preEncodeScriptCount}, Pre-Ketten=${reviewPrefillResult.preEncodeChainCount}, `
        + `Post-Skripte=${reviewPrefillResult.postEncodeScriptCount}, Post-Ketten=${reviewPrefillResult.postEncodeChainCount}, `
        + `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.`
      );
    }
    if (playlistDecisionRequired) {
      const playlistFiles = playlistCandidates.map((item) => item.playlistFile).filter(Boolean);
      const recommendationFile = toPlaylistFile(playlistAnalysis?.recommendation?.playlistId);
      const decisionContext = describePlaylistManualDecision(playlistAnalysis);
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `${decisionContext.detailText} (RAW). Kandidaten: ${playlistFiles.join(', ') || 'keine'}.`
      );
      if (recommendationFile) {
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `Playlist-Empfehlung: ${recommendationFile}`
        );
      }
    }

    const hasEncodableTitle = Boolean(review.encodeInputPath && review.encodeInputTitleId);
    const readyStatusText = review.titleSelectionRequired
      ? 'Titel-/Spurprüfung fertig - Titel per Checkbox wählen'
      : (hasEncodableTitle
        ? 'Titel-/Spurprüfung fertig - Auswahl bestätigen, dann Encode manuell starten'
        : 'Titel-/Spurprüfung fertig - kein Titel erfüllt MIN_LENGTH_MINUTES');
    if (this.isPrimaryJob(jobId)) {
      const mergedPluginExecutionForReady = getMergedReviewPluginExecution();
      await this.setState('READY_TO_ENCODE', {
        activeJobId: jobId,
        progress: 0,
        eta: null,
        statusText: readyStatusText,
        context: {
          ...(this.snapshot.context || {}),
          jobId,
          rawPath,
          inputPath: review.encodeInputPath || null,
          hasEncodableTitle,
          reviewConfirmed: false,
          mode,
          mediaProfile,
          sourceJobId: options.sourceJobId || null,
          mediaInfoReview: review,
          selectedMetadata,
          playlistAnalysis: playlistAnalysis || null,
          playlistDecisionRequired,
          playlistCandidates,
          selectedPlaylist: resolvedPlaylistId || null,
          selectedTitleId: selectedTitleForReview,
          ...(mergedPluginExecutionForReady ? { pluginExecution: mergedPluginExecutionForReady } : {})
        }
      });
    } else {
      await this.updateProgress('READY_TO_ENCODE', 0, null, readyStatusText, jobId, {
        contextPatch: {
          jobId,
          rawPath,
          inputPath: review.encodeInputPath || null,
          hasEncodableTitle,
          reviewConfirmed: false,
          mode,
          mediaProfile,
          sourceJobId: options.sourceJobId || null,
          mediaInfoReview: review,
          selectedMetadata,
          playlistAnalysis: playlistAnalysis || null,
          playlistDecisionRequired,
          playlistCandidates,
          selectedPlaylist: resolvedPlaylistId || null,
          selectedTitleId: selectedTitleForReview
        }
      });
    }

    void this.notifyPushover('metadata_ready', {
      title: 'Ripster - RAW geprüft',
      message: `Job #${jobId}: bereit zum manuellen Encode-Start`
    });

    return review;
  }

  async runReviewForRawJob(jobId, rawPath, options = {}) {
    const useBackupReview = hasBluRayBackupStructure(rawPath);
    logger.info('review:dispatch', {
      jobId,
      rawPath,
      mode: options?.mode || 'rip',
      useBackupReview
    });

    if (useBackupReview) {
      return this.runBackupTrackReviewForJob(jobId, rawPath, options);
    }
    return this.runMediainfoReviewForJob(jobId, rawPath, options);
  }

  async selectMetadata({
    jobId,
    title,
    year,
    imdbId,
    poster,
    fromOmdb = null,
    selectedPlaylist = null,
    selectedHandBrakeTitleId = null,
    selectedHandBrakeTitleIds = null,
    metadataProvider = null,
    providerId = null,
    tmdbId = null,
    metadataKind = null,
    seasonNumber = null,
    seasonName = null,
    episodeCount = null,
    episodes = null,
    discNumber = null
  }) {
    this.ensureNotBusy('selectMetadata', jobId);
    logger.info('metadata:selected', {
      jobId,
      title,
      year,
      imdbId,
      poster,
      fromOmdb,
      selectedPlaylist,
      selectedHandBrakeTitleId,
      selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null,
      metadataProvider,
      providerId,
      tmdbId,
      seasonNumber,
      discNumber
    });

    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
        || (fromOmdb !== null && fromOmdb !== 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 selectedFromOmdb = fromOmdb === null || fromOmdb === undefined
        ? Number(job.selected_from_omdb || 0)
        : (fromOmdb ? 1 : 0);
      const posterValue = poster === undefined
        ? (job.poster_url || null)
        : (poster || null);
      const effectiveDiscNumber = normalizePositiveInteger(discNumber);

      const converterMetadataProvider = String(metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
      let omdbJsonValue = converterMetadataProvider === 'omdb'
        ? (job.omdb_json || null)
        : null;
      if (fromOmdb && effectiveImdbId && converterMetadataProvider === 'omdb') {
        try {
          const omdbFull = await omdbService.fetchByImdbId(effectiveImdbId);
          if (omdbFull?.raw) {
            omdbJsonValue = JSON.stringify(omdbFull.raw);
          }
        } catch (omdbErr) {
          logger.warn('metadata:converter:omdb-fetch-failed', { jobId, imdbId: effectiveImdbId, error: errorToMeta(omdbErr) });
        }
      }

      const nextEncodePlan = {
        ...converterPlan,
        mediaProfile: 'converter',
        converterMediaType: String(converterPlan?.converterMediaType || '').trim().toLowerCase() || 'video',
        inputPath: converterInputPath,
        metadata: {
          ...(converterPlan?.metadata && typeof converterPlan.metadata === 'object' ? converterPlan.metadata : {}),
          title: effectiveTitle,
          year: effectiveYear,
          imdbId: effectiveImdbId,
          poster: posterValue,
          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: selectedFromOmdb,
        omdb_json: omdbJsonValue,
        status: 'READY_TO_START',
        last_state: 'READY_TO_START',
        raw_path: converterInputPath,
        media_type: 'converter',
        encode_plan_json: JSON.stringify(nextEncodePlan),
        encode_review_confirmed: 0,
        error_message: null
      });

      if (posterValue && !thumbnailService.isLocalUrl(posterValue)) {
        historyService.queuePosterCache(jobId, posterValue, {
          source: 'Poster',
          logFailures: true
        });
      }

      await historyService.appendLog(
        jobId,
        'SYSTEM',
        'Converter-Metadaten übernommen. Starte Mediainfo-Prüfung mit Titel-/Spurauswahl.'
      );

      await this.startPreparedJob(jobId);
      return historyService.getJobById(jobId);
    }

    const normalizedSelectedPlaylist = normalizePlaylistId(selectedPlaylist);
    const normalizedSelectedHandBrakeTitleId = normalizeReviewTitleId(selectedHandBrakeTitleId);
    const 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
      || (fromOmdb !== null && fromOmdb !== 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 selectedFromOmdb = fromOmdb === null || fromOmdb === undefined
      ? Number(job.selected_from_omdb || 0)
      : (fromOmdb ? 1 : 0);
    const posterValue = poster === undefined
      ? (job.poster_url || null)
      : (poster || null);
    const effectiveMetadataProvider = String(
      metadataProvider
      || mkInfo?.analyzeContext?.metadataProvider
      || 'omdb'
    ).trim().toLowerCase() || 'omdb';
    const effectiveProviderId = String(
      providerId
      || mkInfo?.analyzeContext?.selectedMetadata?.providerId
      || ''
    ).trim() || null;
    const effectiveTmdbId = Number(
      tmdbId
      || mkInfo?.analyzeContext?.selectedMetadata?.tmdbId
      || 0
    ) || null;
    const effectiveMetadataKind = String(
      metadataKind
      || mkInfo?.analyzeContext?.selectedMetadata?.metadataKind
      || ''
    ).trim().toLowerCase() || null;
    const effectiveDiscNumber = normalizePositiveInteger(discNumber);
    const 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;
    let effectiveEpisodes = Array.isArray(episodes)
      ? episodes
      : (Array.isArray(mkInfo?.analyzeContext?.selectedMetadata?.episodes)
          ? mkInfo.analyzeContext.selectedMetadata.episodes
          : []);
    let tmdbDetails = null;

    const isDvdTmdbMetadataSelection = mediaProfile === 'dvd' && effectiveMetadataProvider === 'tmdb';
    if (isDvdTmdbMetadataSelection && !effectiveDiscNumber) {
      const error = new Error('Serien-DVD erkannt: Disk-Nummer ist Pflicht (Start bei 1).');
      error.statusCode = 400;
      throw error;
    }

    // Fetch full OMDb details when selecting from OMDb with a valid IMDb ID.
    let omdbJsonValue = effectiveMetadataProvider === 'omdb'
      ? (job.omdb_json || null)
      : null;
    if (fromOmdb && effectiveImdbId && effectiveMetadataProvider === 'omdb') {
      try {
        const omdbFull = await omdbService.fetchByImdbId(effectiveImdbId);
        if (omdbFull?.raw) {
          omdbJsonValue = JSON.stringify(omdbFull.raw);
        }
      } catch (omdbErr) {
        logger.warn('metadata:omdb-fetch-failed', { jobId, imdbId: effectiveImdbId, error: errorToMeta(omdbErr) });
      }
    }

    let seasonDetails = null;
    let seasonCredits = null;
    if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId) {
      try {
        const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, {
          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 && effectiveSeasonNumber) {
      try {
        seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber);
        seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber);
        const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails);
        if (seasonSummary) {
          const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : [];
          if (fetchedEpisodes.length > 0) {
            if (!Array.isArray(effectiveEpisodes) || effectiveEpisodes.length === 0) {
              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)
        });
      }
    }

    const selectedMetadata = {
      title: effectiveTitle,
      year: effectiveYear,
      imdbId: effectiveImdbId,
      poster: posterValue,
      metadataProvider: effectiveMetadataProvider,
      providerId: effectiveProviderId,
      tmdbId: effectiveTmdbId,
      metadataKind: effectiveMetadataKind,
      ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}),
      seasonNumber: effectiveSeasonNumber,
      seasonName: effectiveSeasonName,
      episodeCount: effectiveEpisodeCount,
      episodes: effectiveEpisodes,
      ...(tmdbDetails && typeof tmdbDetails === 'object' ? { tmdbDetails } : {})
    };

    let parentContainerJobId = normalizePositiveInteger(job.parent_job_id || null);
    const isDvdSeriesSelection = isDvdTmdbMetadataSelection;
    if (isDvdSeriesSelection) {
      const resolveDiscNumberFromJobRow = (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 normalizePositiveInteger(rowSelectedMetadata?.discNumber);
      };

      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 && 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;
        }
      }

      if (parentContainerJobId) {
        const containerMkInfo = this.withAnalyzeContextMediaProfile({
          analyzeContext: {
            metadataProvider: effectiveMetadataProvider,
            selectedMetadata
          }
        }, mediaProfile);
        await historyService.updateJob(parentContainerJobId, {
          title: effectiveTitle,
          year: effectiveYear,
          imdb_id: effectiveImdbId,
          poster_url: posterValue,
          selected_from_omdb: selectedFromOmdb,
          omdb_json: omdbJsonValue,
          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 ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup'
      ? 'backup'
      : 'mkv';
    const isBackupMode = ripMode === 'backup';
    const rawTitleBase = isDvdSeriesSelection && effectiveDiscNumber
      ? `${selectedMetadata.title || job.detected_title || 'Serie'} - Disc ${effectiveDiscNumber}`
      : (selectedMetadata.title || job.detected_title || null);
    const metadataBase = buildRawMetadataBase({
      title: rawTitleBase,
      year: selectedMetadata.year || null
    }, jobId);
    const rawStorage = resolveSeriesAwareRawStorage(
      settings,
      mediaProfile,
      selectedMetadata,
      mkInfo?.analyzeContext || null
    );
    const rawBaseDir = String(
      rawStorage?.rawBaseDir
      || settings?.raw_dir
      || settingsService.DEFAULT_RAW_DIR
      || ''
    ).trim();
    const existingRawPath = findExistingRawDirectory(rawBaseDir, metadataBase);
    let updatedRawPath = existingRawPath || null;
    if (existingRawPath) {
      const existingRawState = resolveRawFolderStateFromPath(existingRawPath);
      const renameState = existingRawState === RAW_FOLDER_STATES.INCOMPLETE
        ? RAW_FOLDER_STATES.INCOMPLETE
        : RAW_FOLDER_STATES.RIP_COMPLETE;
      const renamedDirName = buildRawDirName(metadataBase, jobId, { state: renameState });
      const renamedRawPath = path.join(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 requiresManualPlaylistSelection = Boolean(
      playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null
    );
    const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START';

    const updatedMakemkvInfo = this.withAnalyzeContextMediaProfile({
      ...mkInfo,
      analyzeContext: {
        ...(mkInfo?.analyzeContext || {}),
        playlistAnalysis: playlistDecision.playlistAnalysis || mkInfo?.analyzeContext?.playlistAnalysis || null,
        playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired),
        selectedPlaylist: playlistDecision.selectedPlaylist || null,
        selectedTitleId: playlistDecision.selectedTitleId ?? null,
        metadataProvider: effectiveMetadataProvider,
        selectedMetadata
      }
    }, mediaProfile);

    await historyService.updateJob(jobId, {
      title: effectiveTitle,
      year: effectiveYear,
      imdb_id: effectiveImdbId,
      poster_url: posterValue,
      selected_from_omdb: selectedFromOmdb,
      omdb_json: omdbJsonValue,
      status: nextStatus,
      last_state: nextStatus,
      raw_path: updatedRawPath,
      makemkv_info_json: JSON.stringify(updatedMakemkvInfo),
      ...(parentContainerJobId ? { parent_job_id: parentContainerJobId, job_kind: 'dvd_series_child', media_type: mediaProfile } : {})
    });

    // Bild in Cache laden (async, blockiert nicht)
    if (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 {
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `Kein bestehendes RAW-Verzeichnis zu den Metadaten gefunden (${metadataBase}).`
      );
    }
    if (rawStorage.usingSeriesRawPath) {
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `Serien-DVD RAW-Pfad aktiv: ${rawBaseDir}`
      );
    }

    if (!keepCurrentPipelineSession) {
      await this.setState(nextStatus, {
        activeJobId: jobId,
        progress: 0,
        eta: null,
        statusText: requiresManualPlaylistSelection
          ? 'waiting_for_manual_playlist_selection'
          : (existingRawPath
            ? 'Metadaten übernommen - vorhandenes RAW erkannt'
            : 'Metadaten übernommen - bereit zum Start'),
        context: {
          ...(this.snapshot.context || {}),
          jobId,
          rawPath: updatedRawPath,
          mediaProfile,
          selectedMetadata,
          playlistAnalysis: playlistDecision.playlistAnalysis || null,
          playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired),
          playlistCandidates: playlistDecision.candidatePlaylists,
          selectedPlaylist: playlistDecision.selectedPlaylist || null,
          selectedTitleId: playlistDecision.selectedTitleId ?? null,
          waitingForManualPlaylistSelection: requiresManualPlaylistSelection,
          manualDecisionState: requiresManualPlaylistSelection
            ? 'waiting_for_manual_playlist_selection'
            : null
        }
      });
    } else {
      const foreignActiveJobId = this.normalizeQueueJobId(this.snapshot.activeJobId);
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `Metadaten übernommen. Aktive Session bleibt bei interaktivem Job #${foreignActiveJobId || 'unbekannt'}.`
      );
    }

    if (requiresManualPlaylistSelection) {
      const playlistFiles = playlistDecision.candidatePlaylists
        .map((item) => item.playlistFile)
        .filter(Boolean);
      const recommendationFile = toPlaylistFile(playlistDecision.recommendation?.playlistId);
      const decisionContext = describePlaylistManualDecision(playlistDecision.playlistAnalysis);
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `${decisionContext.detailText} Status=waiting_for_manual_playlist_selection. Kandidaten: ${playlistFiles.join(', ') || 'keine'}.`
      );
      if (recommendationFile) {
        await historyService.appendLog(jobId, 'SYSTEM', `Empfehlung laut MakeMKV-TINFO-Analyse: ${recommendationFile}`);
      }
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        'Bitte selected_playlist setzen (z.B. 00800 oder 00800.mpls), bevor Backup/Encoding gestartet wird.'
      );
      return historyService.getJobById(jobId);
    }

    if (playlistDecision.playlistDecisionRequired && playlistDecision.selectedPlaylist) {
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `Manuelle Playlist-Auswahl übernommen: ${toPlaylistFile(playlistDecision.selectedPlaylist) || playlistDecision.selectedPlaylist}`
      );
    }

    if (existingRawPath) {
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        'Metadaten übernommen. Starte automatische Spur-Ermittlung (Mediainfo) mit vorhandenem RAW.'
      );
      const startResult = await this.startPreparedJob(jobId);
      logger.info('metadata:auto-track-review-started', {
        jobId,
        stage: startResult?.stage || null,
        reusedRaw: Boolean(startResult?.reusedRaw),
        selectedPlaylist: playlistDecision.selectedPlaylist || null,
        selectedTitleId: playlistDecision.selectedTitleId ?? null
      });
      return historyService.getJobById(jobId);
    }

    await historyService.appendLog(
      jobId,
      'SYSTEM',
      'Metadaten übernommen. Starte Backup/Rip automatisch.'
    );
    const startResult = await this.startPreparedJob(jobId);
    logger.info('metadata:auto-start', {
      jobId,
      stage: startResult?.stage || null,
      reusedRaw: Boolean(startResult?.reusedRaw),
      selectedPlaylist: playlistDecision.selectedPlaylist || null,
      selectedTitleId: playlistDecision.selectedTitleId ?? null
    });

    return historyService.getJobById(jobId);
  }

  async startPreparedJob(jobId, options = {}) {
    const resolvedPreparedJob = await this.resolveExistingJobForAction(jobId, 'start_prepared');
    jobId = resolvedPreparedJob.resolvedJobId;
    const optionPreloadedJob = options?.preloadedJob && Number(options.preloadedJob?.id) === Number(jobId)
      ? options.preloadedJob
      : null;
    const immediate = Boolean(options?.immediate);
    if (!immediate) {
      const preloadedJob = optionPreloadedJob || resolvedPreparedJob.job || await historyService.getJobById(jobId);
      if (!preloadedJob.title && !preloadedJob.detected_title) {
        const error = new Error('Start nicht möglich: keine Metadaten vorhanden.');
        error.statusCode = 400;
        throw error;
      }

      const preloadedMakemkvInfo = this.safeParseJson(preloadedJob.makemkv_info_json);
      const preloadedEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json);
      const preloadedMediaProfile = this.resolveMediaProfileForJob(preloadedJob, {
        makemkvInfo: preloadedMakemkvInfo,
        encodePlan: preloadedEncodePlan
      });
      if (preloadedMediaProfile === 'audiobook') {
        return this.startAudiobookEncode(jobId, { ...options, preloadedJob });
      }
      if (preloadedMediaProfile === 'converter') {
        const preloadedPlan = this.safeParseJson(preloadedJob.encode_plan_json) || {};
        const converterMediaType = String(preloadedPlan?.converterMediaType || '').trim().toLowerCase();
        if (converterMediaType === 'audio') {
          return this.startConverterEncode(jobId, { ...options, preloadedJob });
        }
        return this.startPreparedConverterVideoJob(jobId, { ...options, preloadedJob });
      }

      const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE';
      if (isReadyToEncode) {
        // Check whether this confirmed job will rip first (pre_rip mode) or encode directly.
        // Pre-rip jobs bypass the encode queue because the next step is a rip, not an encode.
        const jobEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json);
        const jobMode = String(jobEncodePlan?.mode || '').trim().toLowerCase();
        let willRipFirst = jobMode === 'pre_rip' || Boolean(jobEncodePlan?.preRip);
        if (willRipFirst) {
          const rawReuse = await this.resolveUsableRawInputForReadyJob(jobId, preloadedJob, jobEncodePlan);
          if (rawReuse.hasUsableRawInput) {
            willRipFirst = false;
          }
        }
        if (willRipFirst) {
          return this.startPreparedJob(jobId, { ...options, immediate: true });
        }
        return this.enqueueOrStartAction(
          QUEUE_ACTIONS.START_PREPARED,
          jobId,
          () => this.startPreparedJob(jobId, { ...options, immediate: true })
        );
      }

      let hasUsableRawInput = false;
      if (preloadedJob.raw_path) {
        try {
          if (fs.existsSync(preloadedJob.raw_path)) {
            const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, preloadedJob);
            hasUsableRawInput = Boolean(findPreferredRawInput(preloadedJob.raw_path, {
              playlistAnalysis: playlistDecision.playlistAnalysis,
              selectedPlaylistId: playlistDecision.selectedPlaylist
            }));
          }
        } catch (_error) {
          hasUsableRawInput = false;
        }
      }

      if (!hasUsableRawInput) {
        // No raw input yet → will rip from disc. Bypass the encode queue entirely.
        return this.startPreparedJob(jobId, { ...options, immediate: true });
      }

      return this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob });
    }

    const job = optionPreloadedJob || resolvedPreparedJob.job || await historyService.getJobById(jobId);

    const jobMakemkvInfo = this.safeParseJson(job.makemkv_info_json);
    const jobEncodePlan = this.safeParseJson(job.encode_plan_json);
    const jobMediaProfile = this.resolveMediaProfileForJob(job, {
      makemkvInfo: jobMakemkvInfo,
      encodePlan: jobEncodePlan
    });
    if (jobMediaProfile === 'audiobook') {
      return this.startAudiobookEncode(jobId, { ...options, immediate: true, preloadedJob: job });
    }
    if (jobMediaProfile === 'converter') {
      const converterPlan = this.safeParseJson(job.encode_plan_json) || {};
      const converterMediaType = String(converterPlan?.converterMediaType || '').trim().toLowerCase();
      if (converterMediaType === 'audio') {
        return this.startConverterEncode(jobId, { ...options, immediate: true, preloadedJob: job });
      }
      return this.startPreparedConverterVideoJob(jobId, { ...options, immediate: true, preloadedJob: job });
    }

    this.ensureNotBusy('startPreparedJob', jobId);
    logger.info('startPreparedJob:requested', { jobId });
    this.cancelRequestedByJob.delete(Number(jobId));

    if (!job.title && !job.detected_title) {
      const error = new Error('Start nicht möglich: keine Metadaten vorhanden.');
      error.statusCode = 400;
      throw error;
    }

    await historyService.resetProcessLog(jobId);

    const encodePlanForReadyState = this.safeParseJson(job.encode_plan_json);
    const readyMode = String(encodePlanForReadyState?.mode || '').trim().toLowerCase();
    const isPreRipReadyState = readyMode === 'pre_rip' || Boolean(encodePlanForReadyState?.preRip);
    const isReadyToEncode = job.status === 'READY_TO_ENCODE' || job.last_state === 'READY_TO_ENCODE';
    if (isReadyToEncode) {
      if (!Number(job.encode_review_confirmed || 0)) {
        const error = new Error('Encode-Start nicht erlaubt: Mediainfo-Prüfung muss zuerst bestätigt werden.');
        error.statusCode = 409;
        throw error;
      }

      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 };
        }

        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.`
      );
    }

    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 })
        );
      }

      await historyService.updateJob(jobId, {
        status: 'ENCODING',
        last_state: 'ENCODING',
        error_message: null,
        end_time: null
      });

      this.startConverterEncode(jobId, { immediate: true, preloadedJob: job }).catch((error) => {
        logger.error('startPreparedConverterVideoJob:encode-background-failed', {
          jobId,
          error: errorToMeta(error)
        });
        if (error?.jobAlreadyFailed) {
          return;
        }
        this.failJob(jobId, 'ENCODING', error).catch((failError) => {
          logger.error('startPreparedConverterVideoJob:encode-background-failJob-failed', {
            jobId,
            error: errorToMeta(failError)
          });
        });
      });

      return { started: true, stage: 'ENCODING' };
    }

    if (statusUpper === 'METADATA_SELECTION') {
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        'Metadaten-Auswahl übersprungen. Starte Review ohne OMDb-Zuordnung.'
      );
    }
    if (statusUpper === 'WAITING_FOR_USER_DECISION') {
      const error = new Error('Start nicht möglich: Bitte zuerst die erforderliche Titel/Playlist-Auswahl treffen.');
      error.statusCode = 409;
      throw error;
    }

    const inputPath = String(encodePlan?.inputPath || job?.raw_path || '').trim();
    if (!inputPath || !fs.existsSync(inputPath)) {
      const error = new Error(`Mediainfo-Prüfung nicht möglich: Eingabedatei fehlt (${inputPath || '-'})`);
      error.statusCode = 400;
      throw error;
    }

    await historyService.updateJob(jobId, {
      status: 'MEDIAINFO_CHECK',
      last_state: 'MEDIAINFO_CHECK',
      start_time: nowIso(),
      end_time: null,
      error_message: null,
      output_path: null,
      handbrake_info_json: null,
      mediainfo_info_json: null,
      encode_input_path: null,
      encode_review_confirmed: 0
    });

    await historyService.appendLog(
      jobId,
      'SYSTEM',
      `Converter-Review gestartet (Video): ${path.basename(inputPath)}`
    );

    this.runMediainfoReviewForJob(jobId, inputPath, {
      mode: 'converter',
      mediaProfile: 'converter',
      previousEncodePlan: encodePlan
    }).catch((error) => {
      logger.error('startPreparedConverterVideoJob:review-background-failed', {
        jobId,
        error: errorToMeta(error)
      });
      this.failJob(jobId, 'MEDIAINFO_CHECK', error).catch((failError) => {
        logger.error('startPreparedConverterVideoJob:review-background-failJob-failed', {
          jobId,
          error: errorToMeta(failError)
        });
      });
    });

    return {
      started: true,
      stage: 'MEDIAINFO_CHECK',
      reusedRaw: true,
      rawPath: inputPath
    };
  }

  async confirmEncodeReview(jobId, options = {}) {
    this.ensureNotBusy('confirmEncodeReview', jobId);
    const skipPipelineStateUpdate = Boolean(options?.skipPipelineStateUpdate);
    logger.info('confirmEncodeReview:requested', {
      jobId,
      selectedEncodeTitleId: options?.selectedEncodeTitleId ?? null,
      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 selectedEncodeTitleId = options?.selectedEncodeTitleId ?? null;
    const selectedEncodeTitleIds = normalizeReviewTitleIdList(options?.selectedEncodeTitleIds);
    const effectiveSelectedEncodeTitleIds = selectedEncodeTitleIds.length > 0
      ? selectedEncodeTitleIds
      : normalizeReviewTitleIdList(selectedEncodeTitleId ? [selectedEncodeTitleId] : []);
    const planWithSelectionResult = applyEncodeTitleSelectionToPlan(
      encodePlan,
      selectedEncodeTitleId,
      effectiveSelectedEncodeTitleIds
    );
    let planForConfirm = planWithSelectionResult.plan;
    const selectedTrackSelectionPayload = options?.selectedTrackSelection && typeof options.selectedTrackSelection === 'object'
      ? options.selectedTrackSelection
      : null;
    const trackSelectionTargets = normalizeReviewTitleIdList(
      Array.isArray(planForConfirm?.selectedTitleIds) && planForConfirm.selectedTitleIds.length > 0
        ? planForConfirm.selectedTitleIds
        : [planForConfirm?.encodeInputTitleId]
    );
    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(
      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;
      return {
        ...title,
        episodeId: assignment?.episodeId || null,
        episodeNumber: assignment?.episodeNumber ?? null,
        seasonNumber: assignment?.seasonNumber ?? title?.seasonNumber ?? 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 = options?.selectedPostEncodeScriptIds !== undefined;
    const selectedPostEncodeScriptIds = hasExplicitPostScriptSelection
      ? normalizeScriptIdList(options?.selectedPostEncodeScriptIds || [])
      : normalizeScriptIdList(planForConfirm?.postEncodeScriptIds || encodePlan?.postEncodeScriptIds || []);
    const selectedPostEncodeScripts = await scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, {
      strict: true
    });

    const hasExplicitPreScriptSelection = options?.selectedPreEncodeScriptIds !== undefined;
    const selectedPreEncodeScriptIds = hasExplicitPreScriptSelection
      ? normalizeScriptIdList(options?.selectedPreEncodeScriptIds || [])
      : normalizeScriptIdList(planForConfirm?.preEncodeScriptIds || encodePlan?.preEncodeScriptIds || []);
    const selectedPreEncodeScripts = await scriptService.resolveScriptsByIds(selectedPreEncodeScriptIds, { strict: true });

    const hasExplicitPostChainSelection = options?.selectedPostEncodeChainIds !== undefined;
    const selectedPostEncodeChainIds = hasExplicitPostChainSelection
      ? normalizeChainIdList(options?.selectedPostEncodeChainIds || [])
      : normalizeChainIdList(planForConfirm?.postEncodeChainIds || encodePlan?.postEncodeChainIds || []);

    const hasExplicitPreChainSelection = options?.selectedPreEncodeChainIds !== undefined;
    const selectedPreEncodeChainIds = hasExplicitPreChainSelection
      ? normalizeChainIdList(options?.selectedPreEncodeChainIds || [])
      : normalizeChainIdList(planForConfirm?.preEncodeChainIds || encodePlan?.preEncodeChainIds || []);

    const confirmedMode = String(planForConfirm?.mode || encodePlan?.mode || 'rip').trim().toLowerCase();
    const isPreRipMode = confirmedMode === 'pre_rip' || Boolean(planForConfirm?.preRip);

    if (planForConfirm?.playlistDecisionRequired && !planForConfirm?.encodeInputPath && !planForConfirm?.encodeInputTitleId) {
      const error = new Error('Bestätigung nicht möglich: Bitte zuerst einen Titel per Checkbox auswählen.');
      error.statusCode = 400;
      throw error;
    }

    // Resolve user preset: explicit payload wins, otherwise preserve currently selected preset from encode plan.
    const hasExplicitUserPresetSelection = Object.prototype.hasOwnProperty.call(options || {}, 'selectedUserPresetId');
    const hasExplicitHandBrakePresetSelection = Object.prototype.hasOwnProperty.call(options || {}, 'selectedHandBrakePreset');
    const rawHandBrakePreset = hasExplicitHandBrakePresetSelection
      ? String(options?.selectedHandBrakePreset || '').trim()
      : '';
    let resolvedUserPreset = null;
    if (hasExplicitUserPresetSelection) {
      const rawUserPresetId = options?.selectedUserPresetId;
      const userPresetId = rawUserPresetId !== null && rawUserPresetId !== undefined && String(rawUserPresetId).trim() !== ''
        ? Number(rawUserPresetId)
        : null;
      if (Number.isFinite(userPresetId) && userPresetId > 0) {
        resolvedUserPreset = await userPresetService.getPresetById(userPresetId);
        if (!resolvedUserPreset) {
          const error = new Error(`User-Preset ${userPresetId} nicht gefunden.`);
          error.statusCode = 404;
          throw error;
        }
      }
    }
    if ((!resolvedUserPreset || !hasExplicitUserPresetSelection) && hasExplicitHandBrakePresetSelection) {
      resolvedUserPreset = rawHandBrakePreset
        ? {
          name: `HandBrake: ${rawHandBrakePreset}`,
          handbrakePreset: rawHandBrakePreset,
          extraArgs: ''
        }
        : null;
    }
    if (!hasExplicitUserPresetSelection && !hasExplicitHandBrakePresetSelection) {
      resolvedUserPreset = normalizeUserPresetForPlan(encodePlan?.userPreset || null);
    }

    const confirmedPlan = {
      ...planForConfirm,
      postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)),
      postEncodeScripts: selectedPostEncodeScripts.map((item) => ({
        id: Number(item.id),
        name: item.name
      })),
      preEncodeScriptIds: selectedPreEncodeScripts.map((item) => Number(item.id)),
      preEncodeScripts: selectedPreEncodeScripts.map((item) => ({
        id: Number(item.id),
        name: item.name
      })),
      postEncodeChainIds: selectedPostEncodeChainIds,
      preEncodeChainIds: selectedPreEncodeChainIds,
      reviewConfirmed: true,
      reviewConfirmedAt: nowIso(),
      userPreset: normalizeUserPresetForPlan(resolvedUserPreset)
    };
    const readyMediaProfile = this.resolveMediaProfileForJob(job, {
      encodePlan: confirmedPlan
    });
    if (readyMediaProfile === 'converter' && !confirmedPlan.userPreset) {
      const error = new Error('Bestätigung nicht möglich: Bitte ein User- oder HandBrake-Preset auswählen.');
      error.statusCode = 400;
      throw error;
    }
    const confirmSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile);
    const resolvedConfirmRawPath = this.resolveCurrentRawPathForSettings(
      confirmSettings,
      readyMediaProfile,
      job.raw_path
    );
    const activeConfirmRawPath = resolvedConfirmRawPath || String(job.raw_path || '').trim() || null;

    let inputPath = isPreRipMode
      ? null
      : (job.encode_input_path || confirmedPlan.encodeInputPath || this.snapshot.context?.inputPath || null);
    if (!isPreRipMode && activeConfirmRawPath) {
      const needsInputRefresh = !inputPath
        || !fs.existsSync(inputPath)
        || !isPathInsideDirectory(activeConfirmRawPath, inputPath);
      if (needsInputRefresh) {
        const selectedPlaylistId = normalizePlaylistId(
          confirmedPlan?.selectedPlaylistId
          || confirmedPlan?.selectedPlaylist
          || null
        );
        if (hasBluRayBackupStructure(activeConfirmRawPath)) {
          inputPath = activeConfirmRawPath;
        } else {
          const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job, selectedPlaylistId);
          inputPath = findPreferredRawInput(activeConfirmRawPath, {
            playlistAnalysis: playlistDecision.playlistAnalysis,
            selectedPlaylistId: selectedPlaylistId || playlistDecision.selectedPlaylist
          })?.path || null;
        }
      }
    }
    confirmedPlan.encodeInputPath = inputPath;
    const hasEncodableTitle = isPreRipMode
      ? Boolean(confirmedPlan?.encodeInputTitleId)
      : Boolean(inputPath);

    await historyService.updateJob(jobId, {
      encode_review_confirmed: 1,
      encode_plan_json: JSON.stringify(confirmedPlan),
      encode_input_path: inputPath,
      ...(activeConfirmRawPath ? { raw_path: activeConfirmRawPath } : {})
    });
    await historyService.appendLog(
      jobId,
      'USER_ACTION',
      `Mediainfo-Prüfung bestätigt.${isPreRipMode ? ' Backup/Rip darf gestartet werden.' : ' Encode darf gestartet werden.'}${confirmedPlan.encodeInputTitleId ? ` 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'}.`
      + (resolvedUserPreset
        ? ` User-Preset: "${resolvedUserPreset.name}"${resolvedUserPreset.id ? ` (ID ${resolvedUserPreset.id})` : ''}.`
        : '')
    );

    if (!skipPipelineStateUpdate) {
      await this.setState('READY_TO_ENCODE', {
        activeJobId: jobId,
        progress: 0,
        eta: null,
        statusText: hasEncodableTitle
          ? (isPreRipMode
            ? 'Spurauswahl bestätigt - Backup/Rip + Encode manuell starten'
            : 'Mediainfo bestätigt - Encode manuell starten')
          : (isPreRipMode
            ? 'Spurauswahl bestätigt - kein passender Titel gewählt'
            : 'Mediainfo bestätigt - kein Titel erfüllt MIN_LENGTH_MINUTES'),
        context: {
          ...(this.snapshot.context || {}),
          jobId,
          inputPath,
          hasEncodableTitle,
          mediaProfile: readyMediaProfile,
          mediaInfoReview: confirmedPlan,
          reviewConfirmed: true
        }
      });
    }

    return historyService.getJobById(jobId);
  }

  async reencodeFromRaw(sourceJobId, options = {}) {
    this.ensureNotBusy('reencodeFromRaw', sourceJobId);
    logger.info('reencodeFromRaw:requested', { sourceJobId });
    this.cancelRequestedByJob.delete(Number(sourceJobId));

    const sourceJob = await historyService.getJobById(sourceJobId);
    if (!sourceJob) {
      const error = new Error(`Quelle-Job ${sourceJobId} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }

    if (!sourceJob.raw_path) {
      const error = new Error('Re-Encode nicht möglich: raw_path fehlt.');
      error.statusCode = 400;
      throw error;
    }

    if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(sourceJob.status)) {
      const error = new Error('Re-Encode nicht möglich: Quelljob ist noch aktiv.');
      error.statusCode = 409;
      throw error;
    }

    const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json);

    const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json);
    const reencodeMediaProfile = this.resolveMediaProfileForJob(sourceJob, {
      makemkvInfo: mkInfo,
      encodePlan: sourceEncodePlan,
      rawPath: sourceJob.raw_path
    });
    if (reencodeMediaProfile === 'audiobook') {
      const reencodeSettings = await settingsService.getSettingsMap();
      const resolvedAudiobookRawPath = this.resolveCurrentRawPathForSettings(
        reencodeSettings,
        reencodeMediaProfile,
        sourceJob.raw_path
      );
      if (!resolvedAudiobookRawPath) {
        const error = new Error(`Re-Encode nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`);
        error.statusCode = 400;
        throw error;
      }

      const rawInput = findPreferredRawInput(resolvedAudiobookRawPath);
      if (!rawInput) {
        const error = new Error('Re-Encode nicht möglich: keine AAX-Datei im RAW-Pfad gefunden.');
        error.statusCode = 400;
        throw error;
      }

      const refreshedPlan = {
        ...(sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {}),
        mediaProfile: 'audiobook',
        jobKind: 'audiobook',
        mode: 'audiobook',
        encodeInputPath: rawInput.path
      };

      const reencodeDeleteFolders = Array.isArray(options?.deleteFolders)
        ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim())
        : [];
      if (reencodeDeleteFolders.length > 0) {
        try {
          const specificDeleteResult = await historyService.deleteSpecificOutputFolders(sourceJobId, reencodeDeleteFolders);
          await historyService.appendLog(
            sourceJobId,
            'USER_ACTION',
            `Audiobook-Re-Encode: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).`
          );
        } catch (error) {
          logger.warn('reencodeFromRaw:audiobook:delete-specific-folders-failed', {
            jobId: sourceJobId,
            error: errorToMeta(error)
          });
        }
      }

      await historyService.resetProcessLog(sourceJobId);
      await historyService.updateJob(sourceJobId, {
        media_type: 'audiobook',
        job_kind: 'audiobook',
        status: 'READY_TO_START',
        last_state: 'READY_TO_START',
        start_time: null,
        end_time: null,
        error_message: null,
        output_path: null,
        handbrake_info_json: null,
        mediainfo_info_json: null,
        encode_plan_json: JSON.stringify(refreshedPlan),
        encode_input_path: rawInput.path,
        encode_review_confirmed: 1,
        raw_path: resolvedAudiobookRawPath
      });
      await historyService.appendLog(
        sourceJobId,
        'USER_ACTION',
        `Audiobook-Re-Encode angefordert. Bestehender Job wird wiederverwendet. Input: ${rawInput.path}`
      );

      return this.startPreparedJob(sourceJobId);
    }

    if (reencodeMediaProfile === 'cd') {
      const cdReencodeSettings = await settingsService.getEffectiveSettingsMap('cd');
      const resolvedCdRawPath = this.resolveCurrentRawPathForSettings(
        cdReencodeSettings,
        'cd',
        sourceJob.raw_path
      );
      if (!resolvedCdRawPath) {
        const error = new Error(`CD-Encode nicht möglich: RAW-Verzeichnis nicht gefunden (${sourceJob.raw_path}).`);
        error.statusCode = 400;
        throw error;
      }

      // WAV-Dateien im RAW-Verzeichnis suchen
      const wavFiles = fs.existsSync(resolvedCdRawPath)
        ? fs.readdirSync(resolvedCdRawPath).filter((f) => /\.wav$/i.test(f))
        : [];
      if (wavFiles.length === 0) {
        const error = new Error('CD-Encode nicht möglich: keine WAV-Dateien im RAW-Verzeichnis gefunden.');
        error.statusCode = 400;
        throw error;
      }

      const cdEncodePlan = sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {};
      const cdMkInfo = mkInfo && typeof mkInfo === 'object' ? mkInfo : {};
      const cdHandbrakeInfo = this.safeParseJson(sourceJob.handbrake_info_json) || {};
      const selectedMeta = cdMkInfo?.selectedMetadata && typeof cdMkInfo.selectedMetadata === 'object'
        ? cdMkInfo.selectedMetadata
        : {};
      const tocTracks = Array.isArray(cdMkInfo?.tracks) && cdMkInfo.tracks.length > 0
        ? cdMkInfo.tracks
        : (Array.isArray(cdEncodePlan?.tracks) ? cdEncodePlan.tracks : []);
      const selectedTrackPositions = normalizeCdTrackPositionList(
        Array.isArray(cdEncodePlan?.selectedTracks) && cdEncodePlan.selectedTracks.length > 0
          ? cdEncodePlan.selectedTracks
          : tocTracks.map((t) => Number(t?.position)).filter((p) => Number.isFinite(p) && p > 0)
      );
      const format = String(cdEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac';
      const formatOptions = cdEncodePlan?.formatOptions && typeof cdEncodePlan.formatOptions === 'object'
        ? cdEncodePlan.formatOptions
        : {};
      const directReencodeEligibility = evaluateCdDirectReencodeEligibility({
        job: sourceJob,
        encodePlan: cdEncodePlan,
        mkInfo: cdMkInfo,
        handbrakeInfo: cdHandbrakeInfo
      });
      if (!directReencodeEligibility.eligible) {
        const reasonText = directReencodeEligibility.reasons.join(' | ');
        await historyService.appendLog(
          sourceJobId,
          'SYSTEM',
          `CD-Encode aus RAW blockiert. Bitte zuerst "Vorprüfung starten". Gründe: ${reasonText}`
        ).catch(() => {});
        const error = new Error(
          `Direktes CD-Encode ist nicht erlaubt: ${reasonText} Bitte zuerst "Vorprüfung starten".`
        );
        error.statusCode = 409;
        error.details = directReencodeEligibility.reasons.map((message) => ({
          field: 'cd_reencode',
          message
        }));
        throw error;
      }
      const cdOutputTemplate = String(
        cdReencodeSettings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE
      ).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
      const cdOutputBaseDir = String(cdReencodeSettings.movie_dir || '').trim()
        || String(cdReencodeSettings.raw_dir || '').trim()
        || settingsService.DEFAULT_CD_DIR;
      const cdOutputOwner = String(cdReencodeSettings.movie_dir_owner || cdReencodeSettings.raw_dir_owner || '').trim();
      const baseOutputDir = cdRipService.buildOutputDir(selectedMeta, cdOutputBaseDir, cdOutputTemplate);

      // Handle output conflict resolution
      const keepBoth = Boolean(options?.keepBoth);
      const deleteFolders = Array.isArray(options?.deleteFolders)
        ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim())
        : [];
      if (deleteFolders.length > 0) {
        await historyService.deleteSpecificOutputFolders(sourceJobId, deleteFolders);
      }
      const baseOutputStillExists = (() => {
        try {
          return fs.existsSync(baseOutputDir);
        } catch (_error) {
          return false;
        }
      })();
      // Conflict strategy:
      // - keepBoth => always number (_X)
      // - replace + remaining sibling folders => continue numbering (_X)
      // - replace + all removed => reuse base folder without numbering
      const needsNumberedOutput = keepBoth || baseOutputStillExists;
      const outputDir = needsNumberedOutput ? ensureUniqueOutputPath(baseOutputDir) : baseOutputDir;

      const reencodeVirtualDrivePath = `__virtual__${sourceJobId}`;
      const cdLiveTrackRowsReenc = buildCdLiveTrackRows(selectedTrackPositions, tocTracks, selectedMeta?.artist);
      const initialCdLiveReenc = buildCdLiveProgressSnapshot({
        trackRows: cdLiveTrackRowsReenc,
        phase: 'encode',
        trackIndex: cdLiveTrackRowsReenc.length > 0 ? 1 : 0,
        trackTotal: cdLiveTrackRowsReenc.length,
        trackPosition: cdLiveTrackRowsReenc[0]?.position || null,
        ripCompletedCount: cdLiveTrackRowsReenc.length,
        encodeCompletedCount: 0
      });

      await historyService.resetProcessLog(sourceJobId);
      await historyService.updateJob(sourceJobId, {
        status: 'CD_RIPPING',
        last_state: 'CD_RIPPING',
        start_time: new Date().toISOString(),
        end_time: null,
        error_message: null,
        output_path: outputDir
      });
      await historyService.appendLog(
        sourceJobId,
        'USER_ACTION',
        `CD-Encode aus RAW angefordert (skipRip). Format=${format}, Tracks=${selectedTrackPositions.join(',') || 'alle'}, RAW=${resolvedCdRawPath}`
      );

      this._setCdDriveState(reencodeVirtualDrivePath, {
        state: 'CD_RIPPING',
        jobId: sourceJobId,
        progress: 0,
        eta: null,
        statusText: 'Tracks werden encodiert …',
        context: {
          jobId: sourceJobId,
          mediaProfile: 'cd',
          tracks: tocTracks,
          selectedMetadata: selectedMeta,
          devicePath: null,
          virtualDrivePath: reencodeVirtualDrivePath,
          skipRip: true,
          cdparanoiaCmd: 'cdparanoia',
          rawWavDir: resolvedCdRawPath,
          outputPath: outputDir,
          outputTemplate: cdOutputTemplate,
          cdRipConfig: cdEncodePlan,
          cdLive: initialCdLiveReenc
        }
      });

      this._runCdRip({
        jobId: sourceJobId,
        devicePath: reencodeVirtualDrivePath,
        cdparanoiaCmd: 'cdparanoia',
        rawWavDir: resolvedCdRawPath,
        rawBaseDir: null,
        cdMetadataBase: null,
        outputDir,
        format,
        formatOptions,
        outputTemplate: cdOutputTemplate,
        rawOwner: null,
        outputOwner: cdOutputOwner,
        selectedTrackPositions,
        tocTracks,
        selectedMeta,
        encodePlan: cdEncodePlan,
        skipRip: true
      }).catch((error) => {
        logger.error('reencodeFromRaw:cd:background-failed', { jobId: sourceJobId, error: errorToMeta(error) });
        this.failJob(sourceJobId, 'CD_ENCODING', error).catch((failError) => {
          logger.error('reencodeFromRaw:cd:failJob-failed', { jobId: sourceJobId, error: errorToMeta(failError) });
        });
      });

      return { jobId: sourceJobId, started: true, queued: false };
    }

    const ripSuccessful = this.isRipSuccessful(sourceJob);
    if (!ripSuccessful) {
      const error = new Error(
        `Re-Encode nicht möglich: RAW-Rip ist nicht abgeschlossen (MakeMKV Status ${mkInfo?.status || 'unknown'}).`
      );
      error.statusCode = 400;
      throw error;
    }
    const reencodeSettings = await settingsService.getSettingsMap();
    const resolvedReencodeRawPath = this.resolveCurrentRawPathForSettings(
      reencodeSettings,
      reencodeMediaProfile,
      sourceJob.raw_path
    );
    if (!resolvedReencodeRawPath) {
      const error = new Error(`Re-Encode nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`);
      error.statusCode = 400;
      throw error;
    }

    await historyService.resetProcessLog(sourceJobId);

    // Handle explicit folder deletion requested by the user (video/audiobook)
    const reencodeDeleteFolders = Array.isArray(options?.deleteFolders) ? options.deleteFolders : [];
    if (reencodeDeleteFolders.length > 0) {
      try {
        const specificDeleteResult = await historyService.deleteSpecificOutputFolders(sourceJobId, reencodeDeleteFolders);
        await historyService.appendLog(
          sourceJobId,
          'USER_ACTION',
          `Re-Encode: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).`
        );
      } catch (error) {
        logger.warn('reencodeFromRaw:delete-specific-folders-failed', {
          jobId: sourceJobId,
          error: errorToMeta(error)
        });
      }
    }

    const rawInput = findPreferredRawInput(resolvedReencodeRawPath);
    if (!rawInput) {
      const error = new Error('Re-Encode nicht möglich: keine Datei im RAW-Pfad gefunden.');
      error.statusCode = 400;
      throw error;
    }

    const resetMakemkvInfoJson = (mkInfo && typeof mkInfo === 'object')
      ? JSON.stringify({
        ...mkInfo,
        analyzeContext: {
          ...(mkInfo?.analyzeContext || {}),
          selectedPlaylist: null,
          selectedTitleId: null
        }
      })
      : (sourceJob.makemkv_info_json || null);

    const reencodeJobUpdate = {
      status: 'MEDIAINFO_CHECK',
      last_state: 'MEDIAINFO_CHECK',
      start_time: nowIso(),
      end_time: null,
      error_message: null,
      output_path: null,
      handbrake_info_json: null,
      mediainfo_info_json: null,
      encode_plan_json: null,
      encode_input_path: null,
      encode_review_confirmed: 0,
      makemkv_info_json: resetMakemkvInfoJson
    };
    if (resolvedReencodeRawPath !== sourceJob.raw_path) {
      reencodeJobUpdate.raw_path = resolvedReencodeRawPath;
    }
    await historyService.updateJob(sourceJobId, reencodeJobUpdate);
    await historyService.appendLog(
      sourceJobId,
      'USER_ACTION',
      `Re-Encode angefordert. Bestehender Job wird wiederverwendet. Input-Kandidat: ${rawInput.path}`
    );

    this.runReviewForRawJob(sourceJobId, resolvedReencodeRawPath, {
      mode: 'reencode',
      sourceJobId,
      forcePlaylistReselection: true,
      mediaProfile: this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: mkInfo, rawPath: resolvedReencodeRawPath })
    }).catch((error) => {
      logger.error('reencodeFromRaw:background-failed', { jobId: sourceJobId, sourceJobId, error: errorToMeta(error) });
      this.failJob(sourceJobId, 'MEDIAINFO_CHECK', error).catch((failError) => {
        logger.error('reencodeFromRaw:background-failJob-failed', {
          jobId: sourceJobId,
          sourceJobId,
          error: errorToMeta(failError)
        });
      });
    });

    return {
      started: true,
      stage: 'MEDIAINFO_CHECK',
      sourceJobId,
      jobId: sourceJobId
    };
  }

  async restartCdReviewFromRaw(jobId, options = {}) {
    const resolvedCdReviewJob = await this.resolveExistingJobForAction(jobId, 'restart_cd_review');
    jobId = resolvedCdReviewJob.resolvedJobId;
    this.ensureNotBusy('restartCdReviewFromRaw', jobId);
    logger.info('restartCdReviewFromRaw:requested', { jobId, options });
    this.cancelRequestedByJob.delete(Number(jobId));

    const sourceJob = resolvedCdReviewJob.job || await historyService.getJobById(jobId);

    const currentStatus = String(sourceJob.status || '').trim().toUpperCase();
    if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(currentStatus)) {
      const error = new Error(`CD-Vorprüfung nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`);
      error.statusCode = 409;
      throw error;
    }

    const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json) || {};
    const encodePlan = this.safeParseJson(sourceJob.encode_plan_json) || {};
    const keepBoth = Boolean(options?.keepBoth);
    const reviewDeleteFolders = Array.isArray(options?.deleteFolders)
      ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim())
      : [];
    const mediaProfile = this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: mkInfo, encodePlan });

    if (mediaProfile !== 'cd') {
      const error = new Error(`CD-Vorprüfung nicht möglich: Job ${jobId} ist kein Audio-CD-Job (erkanntes Profil: ${mediaProfile || 'unbekannt'}).`);
      error.statusCode = 400;
      throw error;
    }

    const cdSettings = await settingsService.getEffectiveSettingsMap('cd');
    const resolvedRawPath = this.resolveCurrentRawPathForSettings(cdSettings, 'cd', sourceJob.raw_path);
    if (!resolvedRawPath) {
      const error = new Error(`CD-Vorprüfung nicht möglich: RAW-Verzeichnis nicht erreichbar (${sourceJob.raw_path}).`);
      error.statusCode = 400;
      throw error;
    }

    const wavFiles = fs.existsSync(resolvedRawPath)
      ? fs.readdirSync(resolvedRawPath).filter((f) => /\.wav$/i.test(f))
      : [];
    if (wavFiles.length === 0) {
      const error = new Error('CD-Vorprüfung nicht möglich: keine WAV-Dateien im RAW-Verzeichnis gefunden.');
      error.statusCode = 400;
      throw error;
    }

    if (reviewDeleteFolders.length > 0) {
      try {
        const specificDeleteResult = await historyService.deleteSpecificOutputFolders(jobId, reviewDeleteFolders);
        await historyService.appendLog(
          jobId,
          'USER_ACTION',
          `CD-Vorprüfung: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).`
        );
      } catch (error) {
        logger.warn('restartCdReviewFromRaw:delete-specific-folders-failed', {
          jobId,
          error: errorToMeta(error)
        });
      }
    }

    const shouldPersistOutputConflict = keepBoth || reviewDeleteFolders.length > 0;
    const nextEncodePlan = encodePlan && typeof encodePlan === 'object'
      ? { ...encodePlan }
      : {};

    const tocTracks = Array.isArray(mkInfo.tracks) && mkInfo.tracks.length > 0
      ? mkInfo.tracks
      : (Array.isArray(encodePlan.tracks) && encodePlan.tracks.length > 0 ? encodePlan.tracks : []);
    // If no track info available (e.g. orphan import with plain WAV files), derive tracks from sorted WAV files
    const effectiveTocTracks = tocTracks.length > 0
      ? tocTracks
      : wavFiles
          .filter((f) => /\.wav$/i.test(f))
          .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
          .map((f, i) => ({ position: i + 1, title: `Track ${i + 1}`, artist: null, selected: true }));
    const resetTracks = effectiveTocTracks.map((track, index) => {
      const position = Number(track?.position);
      const normalizedPosition = Number.isFinite(position) && position > 0 ? Math.trunc(position) : (index + 1);
      return {
        ...track,
        position: normalizedPosition,
        title: `Track ${normalizedPosition}`,
        artist: null,
        selected: track?.selected !== false
      };
    });
    const selectedMetadata = {};
    nextEncodePlan.tracks = resetTracks;
    nextEncodePlan.selectedTracks = resetTracks
      .filter((track) => track.selected !== false)
      .map((track) => Number(track.position))
      .filter((position) => Number.isFinite(position) && position > 0);
    nextEncodePlan.directReencodeReady = false;
    nextEncodePlan.directReencodeReadyAt = null;
    if (shouldPersistOutputConflict) {
      nextEncodePlan.outputConflict = {
        strategy: keepBoth ? 'keep_both' : 'replace',
        keepBoth,
        requestedAt: nowIso(),
        deletedFolderCount: reviewDeleteFolders.length
      };
    } else {
      delete nextEncodePlan.outputConflict;
    }
    const updatedMkInfo = {
      ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}),
      tracks: resetTracks,
      selectedMetadata: {}
    };
    const cdparanoiaCmd = String(mkInfo.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia';

    const jobUpdatePayload = {
      status: 'CD_METADATA_SELECTION',
      last_state: 'CD_METADATA_SELECTION',
      start_time: null,
      end_time: null,
      error_message: null,
      encode_plan_json: JSON.stringify(nextEncodePlan),
      makemkv_info_json: JSON.stringify(updatedMkInfo)
    };
    await historyService.updateJob(jobId, jobUpdatePayload);

    const virtualDrivePath = `__virtual__${jobId}`;
    this._setCdDriveState(virtualDrivePath, {
      state: 'CD_METADATA_SELECTION',
      jobId,
      device: null,
      progress: 0,
      eta: null,
      statusText: 'Vorprüfung (kein Laufwerk)',
      context: {
        jobId,
        mediaProfile: 'cd',
        devicePath: null,
        virtualDrivePath,
        skipRip: true,
        rawWavDir: resolvedRawPath,
        cdparanoiaCmd,
        tracks: resetTracks,
        selectedMetadata,
        detectedTitle: sourceJob.detected_title || sourceJob.title || 'Audio CD'
      }
    });

    const snapshotPayload = this.getSnapshot();
    wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload);
    this.emit('stateChanged', snapshotPayload);

    await historyService.appendLog(
      jobId,
      'USER_ACTION',
      `CD-Vorprüfung aus RAW gestartet (skipRip). ${wavFiles.length} WAV-Datei(en) in: ${resolvedRawPath}`
    );

    return { jobId, tracks: resetTracks, selectedMetadata };
  }

  async runMediainfoForFile(jobId, inputPath, options = {}) {
    const lines = [];
    const config = await settingsService.buildMediaInfoConfig(inputPath, {
      mediaProfile: options?.mediaProfile || null,
      settingsMap: options?.settingsMap || null
    });
    logger.info('mediainfo:command', { jobId, inputPath, cmd: config.cmd, args: config.args });

    const runInfo = await this.runCommand({
      jobId,
      stage: 'MEDIAINFO_CHECK',
      source: 'MEDIAINFO',
      cmd: config.cmd,
      args: config.args,
      collectLines: lines,
      collectStderrLines: false
    });

    const parsed = parseMediainfoJsonOutput(lines.join('\n'));
    if (!parsed) {
      const error = new Error(`Mediainfo-Ausgabe konnte nicht als JSON gelesen werden (${path.basename(inputPath)}).`);
      error.runInfo = runInfo;
      throw error;
    }

    return {
      runInfo,
      parsed
    };
  }

  async runDvdTrackFallbackForFile(jobId, inputPath, options = {}) {
    const lines = [];
    const scanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, {
      mediaProfile: options?.mediaProfile || null,
      settingsMap: options?.settingsMap || null
    });
    logger.info('mediainfo:track-fallback:handbrake-scan:command', {
      jobId,
      inputPath,
      cmd: scanConfig.cmd,
      args: scanConfig.args
    });

    const runInfo = await this.runCommand({
      jobId,
      stage: 'MEDIAINFO_CHECK',
      source: 'HANDBRAKE_SCAN_DVD_TRACK_FALLBACK',
      cmd: scanConfig.cmd,
      args: scanConfig.args,
      collectLines: lines,
      collectStderrLines: false
    });

    const parsedScan = parseMediainfoJsonOutput(lines.join('\n'));
    if (!parsedScan) {
      return {
        runInfo,
        parsedMediaInfo: null,
        titleInfo: null
      };
    }

    const titleInfo = parseHandBrakeSelectedTitleInfo(parsedScan);
    if (!titleInfo) {
      return {
        runInfo,
        parsedMediaInfo: null,
        titleInfo: null
      };
    }

    return {
      runInfo,
      parsedMediaInfo: buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo),
      titleInfo
    };
  }

  async runMediainfoReviewForJob(jobId, rawPath, options = {}) {
    this.ensureNotBusy('runMediainfoReviewForJob', jobId);
    logger.info('mediainfo:review:start', { jobId, rawPath, options });

    const job = await historyService.getJobById(jobId);
    if (!job) {
      const error = new Error(`Job ${jobId} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }

    const mkInfo = this.safeParseJson(job.makemkv_info_json);
    const mediaProfile = this.resolveMediaProfileForJob(job, {
      mediaProfile: options?.mediaProfile,
      makemkvInfo: mkInfo,
      rawPath
    });
    const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
    const reviewPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'review').catch(() => null);
    let reviewPluginExecution = null;
    const analyzeContext = mkInfo?.analyzeContext || {};
    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 || {});
    const isSeriesDvdReview = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext);
    const reviewSettings = isConverterReview
      ? {
          ...settings,
          makemkv_min_length_minutes: 0,
          handbrake_preset: '',
          handbrake_extra_args: ''
        }
      : (isSeriesDvdReview
        ? {
            ...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 (isSeriesDvdReview) {
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        'Serien-DVD 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

    if (isDvdSource && mediaFiles.length > 0) {
      // For 'dvd': scan the folder that contains VIDEO_TS (parent of the VIDEO_TS dir).
      // For image/extensionless: scan the file directly.
      dvdHandBrakeScanInputPath = rawMedia.source === 'dvd'
        ? path.dirname(path.dirname(mediaFiles[0].path))
        : mediaFiles[0].path;

      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `DVD-Analyse via HandBrake Scan: ${path.basename(dvdHandBrakeScanInputPath)}`
      );
      await this.updateProgress('MEDIAINFO_CHECK', 10, null, 'HandBrake DVD-Scan läuft', jobId);

      const dvdScanLines = [];
      let dvdScanRunInfo = null;
      const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, {
        jobId,
        rawPath: dvdHandBrakeScanInputPath,
        reviewMode: 'rip',
        source: 'HANDBRAKE_SCAN_DVD',
        silent: !this.isPrimaryJob(jobId)
      });
      dvdScanLines.push(...pluginScan.scanLines);
      dvdScanRunInfo = pluginScan.runInfo || null;
      reviewPluginExecution = this.mergePluginExecutionState(
        reviewPluginExecution,
        pluginScan.pluginExecution
      );

      dvdHandBrakeScanJson = parseMediainfoJsonOutput(dvdScanLines.join('\n'));
      if (dvdHandBrakeScanJson) {
        const minLengthMinutes = Number(reviewSettings?.makemkv_min_length_minutes || 0);
        const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60));
        const allDvdTitles = parseHandBrakeTitleList(dvdHandBrakeScanJson);
        const filteredDvdCandidatesRaw = minLengthSeconds > 0
          ? allDvdTitles.filter((t) => t.durationSeconds >= minLengthSeconds)
          : allDvdTitles;
        const seriesScanContextResult = isSeriesDvdReview
          ? enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, dvdScanLines)
          : { analyzeContext: effectiveAnalyzeContext, derived: false, reason: 'not_series' };
        effectiveAnalyzeContext = seriesScanContextResult.analyzeContext;
        if (seriesScanContextResult.derived) {
          await historyService.appendLog(
            jobId,
            'SYSTEM',
            'Serien-Titelklassifizierung aus HandBrake-Scan aktualisiert (PlayAll/Kurz/Duplikate markiert).'
          );
        }
        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 reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan(
      enrichedReview,
      options?.previousEncodePlan && typeof options.previousEncodePlan === 'object'
        ? options.previousEncodePlan
        : null
    );
    enrichedReview = reviewPrefillResult.plan;
    const previousEncodePlan = options?.previousEncodePlan && typeof options.previousEncodePlan === 'object'
      ? options.previousEncodePlan
      : null;
    const converterReviewMode = String(options?.mode || '').trim().toLowerCase() === 'converter'
      || String(options?.mediaProfile || '').trim().toLowerCase() === 'converter'
      || String(previousEncodePlan?.mediaProfile || '').trim().toLowerCase() === 'converter';
    if (converterReviewMode && previousEncodePlan) {
      const normalizeConverterItem = (item) => {
        if (!item || typeof item !== 'object') return null;
        const type = String(item?.type || '').trim().toLowerCase();
        const id = Number(item?.id);
        if ((type !== 'script' && type !== 'chain') || !Number.isFinite(id) || id <= 0) {
          return null;
        }
        return { type, id: Math.trunc(id) };
      };
      const preservedPreItems = Array.isArray(previousEncodePlan?.preEncodeItems)
        ? previousEncodePlan.preEncodeItems.map(normalizeConverterItem).filter(Boolean)
        : [];
      const preservedPostItems = Array.isArray(previousEncodePlan?.postEncodeItems)
        ? previousEncodePlan.postEncodeItems.map(normalizeConverterItem).filter(Boolean)
        : [];
      enrichedReview = {
        ...enrichedReview,
        mode: 'converter',
        mediaProfile: 'converter',
        converterMediaType: String(previousEncodePlan?.converterMediaType || '').trim().toLowerCase() || 'video',
        inputPath: String(previousEncodePlan?.inputPath || job?.raw_path || rawPath || '').trim() || null,
        inputPaths: Array.isArray(previousEncodePlan?.inputPaths) ? previousEncodePlan.inputPaths : null,
        outputPath: String(previousEncodePlan?.outputPath || '').trim() || null,
        outputDir: String(previousEncodePlan?.outputDir || '').trim() || null,
        outputFormat: String(previousEncodePlan?.outputFormat || '').trim().toLowerCase() || null,
        userPreset: previousEncodePlan?.userPreset || null,
        audioFormatOptions: previousEncodePlan?.audioFormatOptions && typeof previousEncodePlan.audioFormatOptions === 'object'
          ? previousEncodePlan.audioFormatOptions
          : {},
        audioTrackTemplate: String(previousEncodePlan?.audioTrackTemplate || '').trim() || null,
        audioFiles: Array.isArray(previousEncodePlan?.audioFiles) ? previousEncodePlan.audioFiles : null,
        metadata: previousEncodePlan?.metadata && typeof previousEncodePlan.metadata === 'object'
          ? previousEncodePlan.metadata
          : null,
        tracks: Array.isArray(previousEncodePlan?.tracks) ? previousEncodePlan.tracks : null,
        isFolder: Boolean(previousEncodePlan?.isFolder),
        isSharedAudio: Boolean(previousEncodePlan?.isSharedAudio),
        preEncodeItems: preservedPreItems,
        postEncodeItems: preservedPostItems,
        preEncodeScriptIds: normalizeScriptIdList(previousEncodePlan?.preEncodeScriptIds || []),
        postEncodeScriptIds: normalizeScriptIdList(previousEncodePlan?.postEncodeScriptIds || []),
        preEncodeChainIds: normalizeChainIdList(previousEncodePlan?.preEncodeChainIds || []),
        postEncodeChainIds: normalizeChainIdList(previousEncodePlan?.postEncodeChainIds || []),
        reviewConfirmed: false
      };
    }

    // HandBrake title scan for DVD structures (VIDEO_TS directories / ISO images)
    const needsHandBrakeTitleScan = rawMedia.source === 'dvd'
      || rawMedia.source === 'dvd_image'
      || rawMedia.source === 'single_extensionless';
    if (needsHandBrakeTitleScan) {
      // Use the same path that was used for the DVD HandBrake scan above, or fall back
      // to the existing logic for cases where the scan was not yet performed.
      const dvdScanInputPath = dvdHandBrakeScanInputPath
        || (rawMedia.source === 'dvd' ? rawPath : mediaFiles[0].path);
      const preSelectedHandBrakeTitleId = normalizeReviewTitleId(options?.selectedHandBrakeTitleId);
      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 (isSeriesDvdReview) {
          const seriesTitleIds = normalizeReviewTitleIdList(
            dvdScannedCandidates.map((item) => item?.handBrakeTitleId)
          );
          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-DVD: ${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,
          mediainfo_info_json: JSON.stringify(waitingMediainfoInfo),
          encode_plan_json: JSON.stringify(enrichedReview),
          encode_input_path: null,
          encode_review_confirmed: 0
        });
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `HandBrake DVD Titelauswahl erforderlich: ${candidatesData.length} Kandidaten. Nutzer muss Titel wählen.`
        );
        const dvdWaitingStatusText = `DVD Titelauswahl: ${candidatesData.length} Kandidaten gefunden`;
        const dvdWaitingContextPatch = {
          jobId,
          rawPath,
          handBrakeTitleDecisionRequired: true,
          handBrakeTitleCandidates: candidatesData,
          handBrakeDvdInputPath: dvdScanInputPath,
          reviewConfirmed: false,
          mode: options.mode || 'rip',
          mediaProfile,
          sourceJobId: options.sourceJobId || null,
          mediaInfoReview: enrichedReview,
          selectedMetadata
        };
        if (this.isPrimaryJob(jobId)) {
          await this.setState('WAITING_FOR_USER_DECISION', {
            activeJobId: jobId,
            progress: 0,
            eta: null,
            statusText: dvdWaitingStatusText,
            context: {
              ...dvdWaitingContextPatch,
              ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {})
            }
          });
        } else {
          await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, {
            contextPatch: dvdWaitingContextPatch
          });
        }
        void this.notifyPushover('metadata_ready', {
          title: 'Ripster - DVD Titelauswahl',
          message: `Job #${jobId}: ${candidatesData.length} DVD-Titel gefunden, manuelle Auswahl erforderlich`
        });
        return enrichedReview;
        }
      } else {
        // No pre-scanned result available — run a fresh HandBrake scan for title selection.
        let dvdTitleScanJson = null;
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          'HandBrake Titel-Scan für DVD-Struktur wird gestartet...'
        );
        await this.updateProgress('MEDIAINFO_CHECK', 100, null, 'HandBrake DVD Titel-Scan läuft', jobId);
        const dvdTitleScanLines = [];
        let dvdTitleScanRunInfo = null;
        const pluginScan = await this.runPluginReviewScan(reviewPlugin, job, {
          jobId,
          rawPath: dvdScanInputPath,
          reviewMode: 'rip',
          source: 'HANDBRAKE_SCAN_DVD_TITLES',
          silent: !this.isPrimaryJob(jobId)
        });
        dvdTitleScanLines.push(...pluginScan.scanLines);
        dvdTitleScanRunInfo = pluginScan.runInfo || null;
        reviewPluginExecution = this.mergePluginExecutionState(
          reviewPluginExecution,
          pluginScan.pluginExecution
        );
        dvdTitleScanJson = parseMediainfoJsonOutput(dvdTitleScanLines.join('\n'));
        if (dvdTitleScanJson) {
          const allTitles = parseHandBrakeTitleList(dvdTitleScanJson);
          const minLengthMinutes = Number(reviewSettings?.makemkv_min_length_minutes || 0);
          const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60));
          const titleCandidatesRaw = allTitles.filter((t) => t.durationSeconds >= minLengthSeconds);
          const seriesScanContextResult = isSeriesDvdReview
            ? enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, dvdTitleScanLines)
            : { analyzeContext: effectiveAnalyzeContext, derived: false, reason: 'not_series' };
          effectiveAnalyzeContext = seriesScanContextResult.analyzeContext;
          if (seriesScanContextResult.derived) {
            await historyService.appendLog(
              jobId,
              'SYSTEM',
              'Serien-Titelklassifizierung aus HandBrake-Titelscan aktualisiert (PlayAll/Kurz/Duplikate markiert).'
            );
          }
          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 (isSeriesDvdReview) {
              const seriesTitleIds = normalizeReviewTitleIdList(
                titleCandidates.map((item) => item?.handBrakeTitleId)
              );
              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-DVD: ${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,
                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,
      mediainfo_info_json: JSON.stringify(persistedMediainfoInfo),
      encode_plan_json: JSON.stringify(enrichedReview),
      encode_input_path: enrichedReview.encodeInputPath || null,
      encode_review_confirmed: 0
    });

    await historyService.appendLog(
      jobId,
      'SYSTEM',
      `Mediainfo-Prüfung abgeschlossen: ${enrichedReview.titles.length} Titel, Input=${enrichedReview.encodeInputPath || (titleSelectionRequired ? 'Titelauswahl erforderlich' : 'kein passender Titel')}`
    );
    if (reviewPrefillResult.applied) {
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `Vorherige Encode-Auswahl als Standard übernommen: Titel #${reviewPrefillResult.selectedEncodeTitleId || '-'}, `
        + `Pre-Skripte=${reviewPrefillResult.preEncodeScriptCount}, Pre-Ketten=${reviewPrefillResult.preEncodeChainCount}, `
        + `Post-Skripte=${reviewPrefillResult.postEncodeScriptCount}, Post-Ketten=${reviewPrefillResult.postEncodeChainCount}, `
        + `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.`
      );
    }

    const mediainfoReadyStatusText = titleSelectionRequired
      ? 'Mediainfo geprüft - Titelauswahl per Checkbox erforderlich'
      : (hasEncodableTitle
      ? 'Mediainfo geprüft - Encode manuell starten'
      : 'Mediainfo geprüft - kein Titel erfüllt MIN_LENGTH_MINUTES');
    const mediainfoReadyContextPatch = {
      jobId,
      rawPath,
      inputPath: enrichedReview.encodeInputPath || null,
      hasEncodableTitle,
      reviewConfirmed: false,
      mode: options.mode || 'rip',
      mediaProfile,
      sourceJobId: options.sourceJobId || null,
      mediaInfoReview: enrichedReview,
      selectedMetadata
    };
    if (this.isPrimaryJob(jobId)) {
      await this.setState('READY_TO_ENCODE', {
        activeJobId: jobId,
        progress: 0,
        eta: null,
        statusText: mediainfoReadyStatusText,
        context: {
          ...mediainfoReadyContextPatch,
          ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {})
        }
      });
    } else {
      await this.updateProgress('READY_TO_ENCODE', 0, null, mediainfoReadyStatusText, jobId, {
        contextPatch: mediainfoReadyContextPatch
      });
    }

    void this.notifyPushover('metadata_ready', {
      title: 'Ripster - Mediainfo geprüft',
      message: `Job #${jobId}: bereit zum manuellen Encode-Start`
    });

    return enrichedReview;
  }

  async runEncodeChains(jobId, chainIds, context = {}, phase = 'post', progressTracker = null) {
    const ids = Array.isArray(chainIds) ? chainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0) : [];
    if (ids.length === 0) {
      return { configured: 0, succeeded: 0, failed: 0, results: [] };
    }
    const results = [];
    let succeeded = 0;
    let failed = 0;
    for (let index = 0; index < ids.length; index += 1) {
      const chainId = ids[index];
      const chainLabel = `#${chainId}`;
      if (progressTracker?.onStepStart) {
        await progressTracker.onStepStart(phase, 'chain', index + 1, ids.length, chainLabel);
      }
      await historyService.appendLog(jobId, 'SYSTEM', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette startet (ID ${chainId})...`);
      try {
        const chainResult = await scriptChainService.executeChain(chainId, {
          ...context,
          jobId,
          source: phase === 'pre' ? 'pre_encode_chain' : 'post_encode_chain'
        }, {
          appendLog: (src, msg) => historyService.appendLog(jobId, src, msg)
        });
        if (chainResult.aborted || chainResult.failed > 0) {
          failed += 1;
          await historyService.appendLog(jobId, 'ERROR', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette "${chainResult.chainName}" fehlgeschlagen.`);
        } else {
          succeeded += 1;
          await historyService.appendLog(jobId, 'SYSTEM', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette "${chainResult.chainName}" erfolgreich.`);
        }
        if (progressTracker?.onStepComplete) {
          await progressTracker.onStepComplete(
            phase,
            'chain',
            index + 1,
            ids.length,
            chainResult.chainName || chainLabel,
            !(chainResult.aborted || chainResult.failed > 0)
          );
        }
        results.push({ chainId, ...chainResult });
      } catch (error) {
        failed += 1;
        results.push({ chainId, success: false, error: error.message });
        await historyService.appendLog(jobId, 'ERROR', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette ${chainId} Fehler: ${error.message}`);
        logger.warn(`encode:${phase}-chain:failed`, { jobId, chainId, error: errorToMeta(error) });
        if (progressTracker?.onStepComplete) {
          await progressTracker.onStepComplete(phase, 'chain', index + 1, ids.length, chainLabel, false);
        }
      }
    }
    return { configured: ids.length, succeeded, failed, results };
  }

  async runPreEncodeScripts(jobId, encodePlan, context = {}, progressTracker = null) {
    const scriptIds = normalizeScriptIdList(encodePlan?.preEncodeScriptIds || []);
    const chainIds = Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [];
    const executionStage = String(context?.pipelineStage || 'ENCODING').trim().toUpperCase() || 'ENCODING';
    if (scriptIds.length === 0 && chainIds.length === 0) {
      return { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] };
    }

    const scripts = await scriptService.resolveScriptsByIds(scriptIds, { strict: false });
    const scriptById = new Map(scripts.map((item) => [Number(item.id), item]));
    const results = [];
    let succeeded = 0;
    let failed = 0;
    let skipped = 0;
    let aborted = false;

    for (let index = 0; index < scriptIds.length; index += 1) {
      const scriptId = scriptIds[index];
      const script = scriptById.get(Number(scriptId));
      const scriptLabel = script?.name || `#${scriptId}`;
      if (progressTracker?.onStepStart) {
        await progressTracker.onStepStart('pre', 'script', index + 1, scriptIds.length, scriptLabel);
      }
      if (!script) {
        failed += 1;
        aborted = true;
        results.push({ scriptId, scriptName: null, status: 'ERROR', error: 'missing' });
        await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript #${scriptId} nicht gefunden. Kette abgebrochen.`);
        if (progressTracker?.onStepComplete) {
          await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, scriptLabel, false);
        }
        break;
      }
      await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}`);
      const activityId = runtimeActivityService.startActivity('script', {
        name: script.name,
        source: 'pre_encode',
        scriptId: script.id,
        jobId,
        currentStep: `Pre-Encode ${index + 1}/${scriptIds.length}`
      });
      let prepared = null;
      try {
        prepared = await scriptService.createExecutableScriptFile(script, {
          source: 'pre_encode',
          mode: context?.mode || null,
          jobId,
          jobTitle: context?.jobTitle || null,
          inputPath: context?.inputPath || null,
          outputPath: context?.outputPath || null,
          rawPath: context?.rawPath || null
        });
        const runInfo = await this.runCommand({
          jobId,
          stage: executionStage,
          source: 'PRE_ENCODE_SCRIPT',
          cmd: prepared.cmd,
          args: prepared.args,
          argsForLog: prepared.argsForLog,
          onStdoutLine: (line) => {
            runtimeActivityService.appendActivityOutput(activityId, { stdout: line });
          },
          onStderrLine: (line) => {
            runtimeActivityService.appendActivityOutput(activityId, { stderr: line });
          }
        });
        succeeded += 1;
        results.push({ scriptId: script.id, scriptName: script.name, status: 'SUCCESS', runInfo });
        const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : '';
        runtimeActivityService.completeActivity(activityId, {
          status: 'success',
          success: true,
          outcome: 'success',
          exitCode: Number.isFinite(Number(runInfo?.exitCode)) ? Number(runInfo.exitCode) : null,
          message: 'Pre-Encode Skript erfolgreich',
          output: runOutput || null,
          stdout: runInfo?.stdoutTail || null,
          stderr: runInfo?.stderrTail || null,
          stdoutTruncated: Boolean(runInfo?.stdoutTruncated),
          stderrTruncated: Boolean(runInfo?.stderrTruncated)
        });
        await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript erfolgreich: ${script.name}`);
        if (progressTracker?.onStepComplete) {
          await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, script.name, true);
        }
      } catch (error) {
        const runInfo = error?.runInfo && typeof error.runInfo === 'object' ? error.runInfo : null;
        const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : '';
        const runStatus = String(runInfo?.status || '').trim().toUpperCase();
        const cancelled = runStatus === 'CANCELLED';
        runtimeActivityService.completeActivity(activityId, {
          status: 'error',
          success: false,
          outcome: cancelled ? 'cancelled' : 'error',
          cancelled,
          message: error?.message || 'Pre-Encode Skriptfehler',
          errorMessage: error?.message || 'Pre-Encode Skriptfehler',
          output: runOutput || null,
          stdout: runInfo?.stdoutTail || null,
          stderr: runInfo?.stderrTail || null,
          stdoutTruncated: Boolean(runInfo?.stdoutTruncated),
          stderrTruncated: Boolean(runInfo?.stderrTruncated)
        });
        failed += 1;
        aborted = true;
        results.push({ scriptId: script.id, scriptName: script.name, status: 'ERROR', error: error?.message || 'unknown' });
        await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript fehlgeschlagen: ${script.name} (${error?.message || 'unknown'})`);
        logger.warn('encode:pre-script:failed', { jobId, scriptId: script.id, error: errorToMeta(error) });
        if (progressTracker?.onStepComplete) {
          await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, script.name, false);
        }
        break;
      } finally {
        if (prepared?.cleanup) {
          await prepared.cleanup();
        }
      }
    }

    if (!aborted && chainIds.length > 0) {
      const chainResult = await this.runEncodeChains(jobId, chainIds, context, 'pre', progressTracker);
      if (chainResult.failed > 0) {
        aborted = true;
        failed += chainResult.failed;
      }
      succeeded += chainResult.succeeded;
      results.push(...chainResult.results);
    }

    if (aborted) {
      const pendingScripts = scriptIds.slice(results.filter((r) => r.scriptId != null).length);
      for (const pendingId of pendingScripts) {
        const s = scriptById.get(Number(pendingId));
        skipped += 1;
        results.push({ scriptId: Number(pendingId), scriptName: s?.name || null, status: 'SKIPPED_ABORTED' });
      }
      throw Object.assign(new Error('Pre-Encode Skripte fehlgeschlagen - Encode wird nicht gestartet.'), { statusCode: 500, preEncodeFailed: true });
    }

    return {
      configured: scriptIds.length + chainIds.length,
      attempted: scriptIds.length - skipped + chainIds.length,
      succeeded,
      failed,
      skipped,
      aborted,
      results
    };
  }

  async runPostEncodeScripts(jobId, encodePlan, context = {}, progressTracker = null) {
    const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []);
    const chainIds = Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : [];
    const executionStage = String(context?.pipelineStage || 'ENCODING').trim().toUpperCase() || 'ENCODING';
    if (scriptIds.length === 0 && chainIds.length === 0) {
      return {
        configured: 0,
        attempted: 0,
        succeeded: 0,
        failed: 0,
        skipped: 0,
        results: []
      };
    }

    const scripts = await scriptService.resolveScriptsByIds(scriptIds, { strict: false });
    const scriptById = new Map(scripts.map((item) => [Number(item.id), item]));
    const results = [];
    let succeeded = 0;
    let failed = 0;
    let skipped = 0;
    let aborted = false;
    let abortReason = null;
    let failedScriptName = null;
    let failedScriptId = null;
    const titleForPush = context?.jobTitle || `Job #${jobId}`;

    for (let index = 0; index < scriptIds.length; index += 1) {
      const scriptId = scriptIds[index];
      const script = scriptById.get(Number(scriptId));
      const scriptLabel = script?.name || `#${scriptId}`;
      if (progressTracker?.onStepStart) {
        await progressTracker.onStepStart('post', 'script', index + 1, scriptIds.length, scriptLabel);
      }
      if (!script) {
        failed += 1;
        aborted = true;
        failedScriptId = Number(scriptId);
        failedScriptName = `Script #${scriptId}`;
        abortReason = `Post-Encode Skript #${scriptId} wurde nicht gefunden (${index + 1}/${scriptIds.length}).`;
        await historyService.appendLog(jobId, 'SYSTEM', abortReason);
        results.push({
          scriptId,
          scriptName: null,
          status: 'ERROR',
          error: 'missing'
        });
        if (progressTracker?.onStepComplete) {
          await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, scriptLabel, false);
        }
        break;
      }

      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `Post-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}`
      );
      const activityId = runtimeActivityService.startActivity('script', {
        name: script.name,
        source: 'post_encode',
        scriptId: script.id,
        jobId,
        currentStep: `Post-Encode ${index + 1}/${scriptIds.length}`
      });

      let prepared = null;
      try {
        prepared = await scriptService.createExecutableScriptFile(script, {
          source: 'post_encode',
          mode: context?.mode || null,
          jobId,
          jobTitle: context?.jobTitle || null,
          inputPath: context?.inputPath || null,
          outputPath: context?.outputPath || null,
          rawPath: context?.rawPath || null
        });
        const runInfo = await this.runCommand({
          jobId,
          stage: executionStage,
          source: 'POST_ENCODE_SCRIPT',
          cmd: prepared.cmd,
          args: prepared.args,
          argsForLog: prepared.argsForLog,
          onStdoutLine: (line) => {
            runtimeActivityService.appendActivityOutput(activityId, { stdout: line });
          },
          onStderrLine: (line) => {
            runtimeActivityService.appendActivityOutput(activityId, { stderr: line });
          }
        });

        succeeded += 1;
        results.push({
          scriptId: script.id,
          scriptName: script.name,
          status: 'SUCCESS',
          runInfo
        });
        const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : '';
        runtimeActivityService.completeActivity(activityId, {
          status: 'success',
          success: true,
          outcome: 'success',
          exitCode: Number.isFinite(Number(runInfo?.exitCode)) ? Number(runInfo.exitCode) : null,
          message: 'Post-Encode Skript erfolgreich',
          output: runOutput || null,
          stdout: runInfo?.stdoutTail || null,
          stderr: runInfo?.stderrTail || null,
          stdoutTruncated: Boolean(runInfo?.stdoutTruncated),
          stderrTruncated: Boolean(runInfo?.stderrTruncated)
        });
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `Post-Encode Skript erfolgreich: ${script.name}`
        );
        if (progressTracker?.onStepComplete) {
          await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, script.name, true);
        }
      } catch (error) {
        const runInfo = error?.runInfo && typeof error.runInfo === 'object' ? error.runInfo : null;
        const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : '';
        const runStatus = String(runInfo?.status || '').trim().toUpperCase();
        const cancelled = runStatus === 'CANCELLED';
        runtimeActivityService.completeActivity(activityId, {
          status: 'error',
          success: false,
          outcome: cancelled ? 'cancelled' : 'error',
          cancelled,
          message: error?.message || 'Post-Encode Skriptfehler',
          errorMessage: error?.message || 'Post-Encode Skriptfehler',
          output: runOutput || null,
          stdout: runInfo?.stdoutTail || null,
          stderr: runInfo?.stderrTail || null,
          stdoutTruncated: Boolean(runInfo?.stdoutTruncated),
          stderrTruncated: Boolean(runInfo?.stderrTruncated)
        });
        failed += 1;
        aborted = true;
        failedScriptId = Number(script.id);
        failedScriptName = script.name;
        abortReason = error?.message || 'unknown';
        results.push({
          scriptId: script.id,
          scriptName: script.name,
          status: 'ERROR',
          error: abortReason
        });
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `Post-Encode Skript fehlgeschlagen: ${script.name} (${abortReason})`
        );
        logger.warn('encode:post-script:failed', {
          jobId,
          scriptId: script.id,
          scriptName: script.name,
          error: errorToMeta(error)
        });
        if (progressTracker?.onStepComplete) {
          await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, script.name, false);
        }
        break;
      } finally {
        if (prepared?.cleanup) {
          await prepared.cleanup();
        }
      }
    }

    if (aborted) {
      const executedScriptIds = new Set(results.map((item) => Number(item?.scriptId)));
      for (const pendingScriptId of scriptIds) {
        const numericId = Number(pendingScriptId);
        if (executedScriptIds.has(numericId)) {
          continue;
        }
        const pendingScript = scriptById.get(numericId);
        skipped += 1;
        results.push({
          scriptId: numericId,
          scriptName: pendingScript?.name || null,
          status: 'SKIPPED_ABORTED'
        });
      }

      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `Post-Encode Skriptkette abgebrochen nach Fehler in ${failedScriptName || `Script #${failedScriptId || 'unknown'}`}.`
      );
      void this.notifyPushover('job_error', {
        title: 'Ripster - Post-Encode Skriptfehler',
        message: `${titleForPush}: ${failedScriptName || `Script #${failedScriptId || 'unknown'}`} fehlgeschlagen (${abortReason || 'unknown'}). Skriptkette abgebrochen.`
      });
    }

    if (!aborted && chainIds.length > 0) {
      const chainResult = await this.runEncodeChains(jobId, chainIds, context, 'post', progressTracker);
      if (chainResult.failed > 0) {
        aborted = true;
        failed += chainResult.failed;
        abortReason = `Post-Encode Kette fehlgeschlagen`;
        void this.notifyPushover('job_error', {
          title: 'Ripster - Post-Encode Kettenfehler',
          message: `${context?.jobTitle || `Job #${jobId}`}: Eine Post-Encode Kette ist fehlgeschlagen.`
        });
      }
      succeeded += chainResult.succeeded;
      results.push(...chainResult.results);
    }

    return {
      configured: scriptIds.length + chainIds.length,
      attempted: scriptIds.length - skipped + chainIds.length,
      succeeded,
      failed,
      skipped,
      aborted,
      abortReason,
      failedScriptId,
      failedScriptName,
      results
    };
  }

  async startEncodingFromPrepared(jobId, 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);
    const preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, outputPathJobView, jobId);
    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 preEncodeContext = {
      mode,
      jobId,
      jobTitle: job.title || job.detected_title || null,
      inputPath,
      rawPath: activeRawPath,
      mediaProfile
    };
    const preScriptIds = normalizeScriptIdList(encodePlan?.preEncodeScriptIds || []);
    const preChainIds = Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [];
    const postScriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []);
    const postChainIds = Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : [];
    const normalizedPreChainIds = Array.isArray(preChainIds)
      ? preChainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0)
      : [];
    const normalizedPostChainIds = Array.isArray(postChainIds)
      ? postChainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0)
      : [];
    const encodeScriptProgressTracker = createEncodeScriptProgressTracker({
      jobId,
      preSteps: preScriptIds.length + normalizedPreChainIds.length,
      postSteps: postScriptIds.length + normalizedPostChainIds.length,
      updateProgress: this.updateProgress.bind(this)
    });
    let preEncodeScriptsSummary = { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] };
    if (preScriptIds.length > 0 || preChainIds.length > 0) {
      await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Encode Skripte/Ketten werden ausgeführt...');
      try {
        preEncodeScriptsSummary = await this.runPreEncodeScripts(
          jobId,
          encodePlan,
          preEncodeContext,
          encodeScriptProgressTracker
        );
      } catch (preError) {
        if (preError.preEncodeFailed) {
          await this.failJob(jobId, 'ENCODING', preError);
          throw preError;
        }
        throw preError;
      }
      await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Encode Skripte/Ketten abgeschlossen.');
    }

    try {
      const trackSelection = extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath);
      if (Array.isArray(trackSelection?.subtitleSelectionValidationErrors) && trackSelection.subtitleSelectionValidationErrors.length > 0) {
        const error = new Error(
          `Subtitle-Auswahl ungültig: ${trackSelection.subtitleSelectionValidationErrors.join(' | ')}`
        );
        error.statusCode = 400;
        throw error;
      }
      let handBrakeTitleId = null;
      let directoryInput = false;
      try {
        if (fs.existsSync(inputPath) && fs.statSync(inputPath).isDirectory()) {
          directoryInput = true;
        }
      } catch (_error) {
        directoryInput = false;
        handBrakeTitleId = null;
      }
      if (directoryInput) {
        const reviewMappedTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId);
        if (reviewMappedTitleId) {
          handBrakeTitleId = reviewMappedTitleId;
          await historyService.appendLog(
            jobId,
            'SYSTEM',
            `HandBrake Titel-Mapping aus Vorbereitung übernommen: -t ${handBrakeTitleId}`
          );
        }
        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;
        if (!handBrakeTitleId && selectedPlaylistId) {
          const titleResolveScanLines = [];
          const titleResolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, {
            mediaProfile,
            settingsMap: settings
          });
          logger.info('encoding:title-resolve-scan:command', {
            jobId,
            cmd: titleResolveScanConfig.cmd,
            args: titleResolveScanConfig.args,
            sourceArg: titleResolveScanConfig.sourceArg,
            selectedPlaylistId
          });
          const titleResolveRunInfo = await this.runCommand({
            jobId,
            stage: 'ENCODING',
            source: 'HANDBRAKE_SCAN_TITLE_RESOLVE',
            cmd: titleResolveScanConfig.cmd,
            args: titleResolveScanConfig.args,
            collectLines: titleResolveScanLines,
            collectStderrLines: false
          });
          const titleResolveParsed = parseMediainfoJsonOutput(titleResolveScanLines.join('\n'));
          if (!titleResolveParsed) {
            const error = new Error('HandBrake Scan-Ausgabe für Titel-Mapping konnte nicht als JSON gelesen werden.');
            error.runInfo = titleResolveRunInfo;
            throw error;
          }
          handBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(titleResolveParsed, selectedPlaylistId, {
            expectedMakemkvTitleId: expectedMakemkvTitleIdForResolve,
            expectedDurationSeconds: expectedDurationSecondsForResolve,
            expectedSizeBytes: expectedSizeBytesForResolve
          });
          if (!handBrakeTitleId) {
            const knownPlaylists = listAvailableHandBrakePlaylists(titleResolveParsed);
            const error = new Error(
              `Kein HandBrake-Titel für Playlist ${selectedPlaylistId}.mpls gefunden.`
              + ` ${knownPlaylists.length > 0 ? `Scan-Playlists: ${knownPlaylists.map((id) => `${id}.mpls`).join(', ')}` : 'Scan enthält keine erkennbaren Playlist-IDs.'}`
            );
            error.statusCode = 400;
            throw error;
          }
          await historyService.appendLog(
            jobId,
            'SYSTEM',
            `HandBrake Titel-Mapping: ${selectedPlaylistId}.mpls -> -t ${handBrakeTitleId}`
          );
        } else if (!handBrakeTitleId) {
          const dvdResolveScanLines = [];
          const dvdResolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, {
            mediaProfile,
            settingsMap: settings
          });
          await historyService.appendLog(
            jobId,
            'SYSTEM',
            'HandBrake Titel-Scan für Verzeichnis ohne Playlist wird gestartet...'
          );
          logger.info('encoding:dvd-title-resolve-scan:command', {
            jobId,
            cmd: dvdResolveScanConfig.cmd,
            args: dvdResolveScanConfig.args
          });
          const dvdResolveRunInfo = await this.runCommand({
            jobId,
            stage: 'ENCODING',
            source: 'HANDBRAKE_SCAN_TITLE_RESOLVE',
            cmd: dvdResolveScanConfig.cmd,
            args: dvdResolveScanConfig.args,
            collectLines: dvdResolveScanLines,
            collectStderrLines: false
          });
          const dvdResolveParsed = parseMediainfoJsonOutput(dvdResolveScanLines.join('\n'));
          if (dvdResolveParsed) {
            const dvdTitleInfo = parseHandBrakeSelectedTitleInfo(dvdResolveParsed, {
              handBrakeTitleId: null,
              playlistId: null
            });
            if (dvdTitleInfo?.handBrakeTitleId) {
              handBrakeTitleId = dvdTitleInfo.handBrakeTitleId;
              await historyService.appendLog(
                jobId,
                'SYSTEM',
                `HandBrake Titel-Scan: längster Titel gefunden -> -t ${handBrakeTitleId}`
              );
            }
          } else {
            logger.warn('encoding:dvd-title-resolve-scan:parse-failed', {
              jobId,
              runInfo: dvdResolveRunInfo
            });
          }
        }
      }
      if (!handBrakeTitleId) {
        const fallbackTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId);
        if (fallbackTitleId) {
          handBrakeTitleId = fallbackTitleId;
          await historyService.appendLog(
            jobId,
            'SYSTEM',
            `HandBrake Titel-ID aus Encode-Plan übernommen: -t ${handBrakeTitleId}`
          );
        }
      }
      const effectiveEncodePlan = {
        ...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}),
        trackSelection: trackSelection || null,
        handBrakeTitleId: handBrakeTitleId || null
      };
      if (trackSelection) {
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `HandBrake Track-Override: audio=${trackSelection.audioTrackIds.length > 0 ? trackSelection.audioTrackIds.join(',') : 'none'}, subtitles=${trackSelection.subtitleTrackIds.length > 0 ? trackSelection.subtitleTrackIds.join(',') : 'none'}, subtitle-burned=${trackSelection.subtitleBurnTrackId ?? 'none'}, subtitle-default=${trackSelection.subtitleDefaultTrackId ?? 'none'}, subtitle-forced-index=${Array.isArray(trackSelection.subtitleForcedTrackIndexes) && trackSelection.subtitleForcedTrackIndexes.length > 0 ? trackSelection.subtitleForcedTrackIndexes.join(',') : 'none'}`
        );
      }
      if (handBrakeTitleId) {
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `HandBrake Titel-Selektion aktiv: -t ${handBrakeTitleId}`
        );
      }
      const 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 });
      });
      let postEncodeScriptsSummary = {
        configured: 0,
        attempted: 0,
        succeeded: 0,
        failed: 0,
        skipped: 0,
        results: []
      };
      try {
        postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, encodePlan, {
          mode,
          jobTitle: job.title || job.detected_title || null,
          inputPath,
          outputPath: finalizedOutputPath,
          rawPath: activeRawPath
        }, encodeScriptProgressTracker);
      } catch (error) {
        logger.warn('encode:post-script:summary-failed', {
          jobId,
          error: errorToMeta(error)
        });
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `Post-Encode Skripte konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}`
        );
      }
      if (postEncodeScriptsSummary.configured > 0) {
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `Post-Encode Skripte abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.${postEncodeScriptsSummary.aborted ? ' Kette wurde abgebrochen.' : ''}`
        );
      }
      let finalizedRawPath = activeRawPath || null;
      if (activeRawPath) {
        const currentRawPath = String(activeRawPath || '').trim();
        const completedRawPath = buildCompletedRawPath(currentRawPath);
        if (completedRawPath && completedRawPath !== currentRawPath) {
          if (fs.existsSync(completedRawPath)) {
            logger.warn('encoding:raw-dir-finalize:target-exists', {
              jobId,
              sourceRawPath: currentRawPath,
              targetRawPath: completedRawPath
            });
            await historyService.appendLog(
              jobId,
              'SYSTEM',
              `RAW-Ordner konnte nicht finalisiert werden (Ziel existiert bereits): ${completedRawPath}`
            );
          } else {
            try {
              fs.renameSync(currentRawPath, completedRawPath);
              await historyService.updateRawPathByOldPath(currentRawPath, completedRawPath);
              finalizedRawPath = completedRawPath;
              await historyService.appendLog(
                jobId,
                'SYSTEM',
                `RAW-Ordner nach erfolgreichem Encode finalisiert (Prefix entfernt): ${currentRawPath} -> ${completedRawPath}`
              );
            } catch (rawRenameError) {
              logger.warn('encoding:raw-dir-finalize:rename-failed', {
                jobId,
                sourceRawPath: currentRawPath,
                targetRawPath: completedRawPath,
                error: errorToMeta(rawRenameError)
              });
              await historyService.appendLog(
                jobId,
                'SYSTEM',
                `RAW-Ordner konnte nach Encode nicht finalisiert werden: ${rawRenameError.message}`
              );
            }
          }
        }
      }

      const handbrakeInfoWithPostScripts = this.withPluginExecutionMeta({
        ...handbrakeInfo,
        preEncodeScripts: preEncodeScriptsSummary,
        postEncodeScripts: postEncodeScriptsSummary
      }, encodePluginExecution);

      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
        });
        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
      });

      if (job?.parent_job_id) {
        await this.syncSeriesContainerStatusFromChildren(job.parent_job_id).catch((parentSyncError) => {
          logger.warn('series-container:status-sync-on-child-finish-failed', {
            parentJobId: job.parent_job_id,
            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 (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
    }, jobId);
    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-DVD erkannt: MakeMKV-Rip läuft ohne Mindestlängen-Filter (--minlength deaktiviert).'
        );
      }
      if (rawStorage.usingSeriesRawPath) {
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `Serien-DVD RAW-Pfad aktiv: ${effectiveRawBaseDir}`
        );
      }
      const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'rip').catch(() => null);
      if (devicePath) {
        const existingLock = this._getDriveLockByPath(devicePath);
        const existingLockJobId = Number(existingLock?.jobId || existingLock?.owner?.jobId || 0) || null;
        const lockedByOtherJob = (
          (diskDetectionService.isDeviceLocked(devicePath) || Boolean(existingLock))
          && existingLockJobId !== Number(jobId)
        );
        if (lockedByOtherJob) {
          throw this._buildDriveLockedError(devicePath, existingLock);
        }
        this._acquireDriveLockForJob(devicePath, jobId, {
          stage: 'RIPPING',
          source: 'MAKEMKV_RIP',
          mediaProfile,
          reason: 'rip_running'
        });
      }
      let ripCommandSucceeded = false;
      try {
        // Plugin-Pfad: VideoDiscPlugin.rip() baut eigene ripConfig + ruft ctx.extra.runCommand()
        const ripCtx = await this.buildPluginContext(ripPlugin.id, {
          jobId,
          rawJobDir,
          deviceInfo: device,
          selectedTitleId: effectiveSelectedTitleId,
          disableMinLengthFilter,
          backupOutputBase,
          runCommand: this.runCommand.bind(this)
        });
        makemkvInfo = await ripPlugin.rip(job, ripCtx);
        ripCommandSucceeded = true;
      } finally {
        if (devicePath && !ripCommandSucceeded) {
          logger.warn('drive-lock:retained-after-rip-failure', {
            devicePath,
            jobId
          });
        }
      }

      // Check for MakeMKV backup failure even when exit code is 0.
      // MakeMKV can emit localized failure text while still exiting with 0.
      const backupFailureLine = ripMode === 'backup'
        ? findMakeMkvBackupFailureMarker(makemkvInfo?.highlights)
        : null;
      if (backupFailureLine) {
        const msgCode = parseMakeMkvMessageCode(backupFailureLine);
        throw Object.assign(
          new Error(`MakeMKV Backup fehlgeschlagen${msgCode !== null ? ` (MSG:${msgCode})` : ''}: ${backupFailureLine}`),
          { runInfo: makemkvInfo }
        );
      }

      const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json);
      // Merge bestehende Analyze-Marker aus der DB mit den Live-Markern aus dem Rip-Lauf,
      // damit analyze nicht verloren geht, wenn jobProgress nur die neueste Rip-Phase enthält.
      const postRipPluginExecution = this.mergePluginExecutionState(
        mkInfoBeforeRip?.pluginExecution,
        this.jobProgress.get(Number(jobId))?.context?.pluginExecution
      );
      await historyService.updateJob(jobId, {
        makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({
          ...makemkvInfo,
          analyzeContext: mkInfoBeforeRip?.analyzeContext || null,
          pluginExecution: postRipPluginExecution || null
        }, mediaProfile)),
        rip_successful: 1
      });

      // Mark RAW as rip-complete until encode succeeds.
      let activeRawJobDir = rawJobDir;
      const ripCompleteRawJobDir = buildRipCompleteRawPath(rawJobDir);
      if (ripCompleteRawJobDir && ripCompleteRawJobDir !== rawJobDir) {
        if (fs.existsSync(ripCompleteRawJobDir)) {
          logger.warn('rip:raw-complete:rename-skip', { jobId, rawJobDir, ripCompleteRawJobDir });
          await historyService.appendLog(
            jobId,
            'SYSTEM',
            `RAW-Ordner konnte nach Rip nicht als Rip_Complete markiert werden (Zielordner existiert): ${ripCompleteRawJobDir}`
          );
        } else {
          try {
            fs.renameSync(rawJobDir, ripCompleteRawJobDir);
            activeRawJobDir = ripCompleteRawJobDir;
            chownRecursive(activeRawJobDir, 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 always starts a rip → bypass the encode queue entirely.
      return this.retry(jobId, { ...options, immediate: true });
    }

    const resolvedRetryJob = await this.resolveExistingJobForAction(jobId, 'retry');
    jobId = resolvedRetryJob.resolvedJobId;
    this.ensureNotBusy('retry', jobId);
    logger.info('retry:start', { jobId });
    this.cancelRequestedByJob.delete(Number(jobId));

    let sourceJob = resolvedRetryJob.job || await historyService.getJobById(jobId);

    if (!sourceJob.title && !sourceJob.detected_title) {
      const error = new Error('Retry nicht möglich: keine Metadaten vorhanden.');
      error.statusCode = 400;
      throw error;
    }

    const sourceStatus = String(sourceJob.status || '').trim().toUpperCase();
    const sourceLastState = String(sourceJob.last_state || '').trim().toUpperCase();

    const sourceMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json);
    const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json);
    const mediaProfile = this.resolveMediaProfileForJob(sourceJob, {
      makemkvInfo: sourceMakemkvInfo,
      encodePlan: sourceEncodePlan
    });
    const explicitSourceMediaType = String(sourceJob?.media_type || '').trim().toLowerCase();
    const preservedRetryMediaType = explicitSourceMediaType || (
      ['converter', 'cd', 'audiobook'].includes(mediaProfile) ? mediaProfile : null
    );
    const isCdRetry = mediaProfile === 'cd';

    // CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden.
    const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus)
      || ['ERROR', 'CANCELLED'].includes(sourceLastState)
      || (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus));
    if (!retryable) {
      const error = new Error(
        `Retry nicht möglich: Job ${jobId} ist nicht im Status ERROR/CANCELLED (aktuell ${sourceStatus || sourceLastState || '-'}).`
      );
      error.statusCode = 409;
      throw error;
    }

    let cdRetryConfig = null;
    if (isCdRetry) {
      const normalizeTrackPosition = (value) => {
        const parsed = Number(value);
        if (!Number.isFinite(parsed) || parsed <= 0) {
          return null;
        }
        return Math.trunc(parsed);
      };
      const sourceTracks = Array.isArray(sourceMakemkvInfo?.tracks)
        ? sourceMakemkvInfo.tracks
        : (Array.isArray(sourceEncodePlan?.tracks) ? sourceEncodePlan.tracks : []);
      // Keine Trackdaten → Neustart ohne Vorkonfiguration (TOC wird von der CD gelesen)
      if (sourceTracks.length === 0) {
        cdRetryConfig = {
          format: String(sourceEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac',
          formatOptions: sourceEncodePlan?.formatOptions && typeof sourceEncodePlan.formatOptions === 'object'
            ? sourceEncodePlan.formatOptions
            : {},
          selectedTracks: [],
          tracks: [],
          metadata: {
            title: sourceMakemkvInfo?.selectedMetadata?.title || sourceJob.title || sourceJob.detected_title || 'Audio CD',
            artist: sourceMakemkvInfo?.selectedMetadata?.artist || null,
            year: sourceMakemkvInfo?.selectedMetadata?.year ?? sourceJob.year ?? null,
            mbId: sourceMakemkvInfo?.selectedMetadata?.mbId
              || sourceMakemkvInfo?.selectedMetadata?.musicBrainzId
              || sourceMakemkvInfo?.selectedMetadata?.musicbrainzId
              || sourceMakemkvInfo?.selectedMetadata?.musicbrainz_id
              || sourceMakemkvInfo?.selectedMetadata?.music_brainz_id
              || sourceMakemkvInfo?.selectedMetadata?.musicbrainz
              || sourceMakemkvInfo?.selectedMetadata?.mbid
              || null,
            coverUrl: sourceMakemkvInfo?.selectedMetadata?.coverUrl || sourceJob.poster_url || null
          },
          selectedPreEncodeScriptIds: [],
          selectedPostEncodeScriptIds: [],
          selectedPreEncodeChainIds: [],
          selectedPostEncodeChainIds: []
        };
      } else {
      const selectedTracks = normalizeCdTrackPositionList(
        Array.isArray(sourceEncodePlan?.selectedTracks)
          ? sourceEncodePlan.selectedTracks
          : sourceTracks.filter((track) => track?.selected !== false).map((track) => normalizeTrackPosition(track?.position))
      );
      const selectedMetadata = sourceMakemkvInfo?.selectedMetadata && typeof sourceMakemkvInfo.selectedMetadata === 'object'
        ? sourceMakemkvInfo.selectedMetadata
        : {};
      cdRetryConfig = {
        format: String(sourceEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac',
        formatOptions: sourceEncodePlan?.formatOptions && typeof sourceEncodePlan.formatOptions === 'object'
          ? sourceEncodePlan.formatOptions
          : {},
        selectedTracks: selectedTracks.length > 0
          ? selectedTracks
          : sourceTracks
            .map((track) => normalizeTrackPosition(track?.position))
            .filter((value) => Number.isFinite(value) && value > 0),
        tracks: sourceTracks,
        metadata: {
          title: selectedMetadata?.title || sourceJob.title || sourceJob.detected_title || 'Audio CD',
          artist: selectedMetadata?.artist || null,
          year: selectedMetadata?.year ?? sourceJob.year ?? null,
          mbId: selectedMetadata?.mbId
            || selectedMetadata?.musicBrainzId
            || selectedMetadata?.musicbrainzId
            || selectedMetadata?.musicbrainz_id
            || selectedMetadata?.music_brainz_id
            || selectedMetadata?.musicbrainz
            || selectedMetadata?.mbid
            || null,
          coverUrl: selectedMetadata?.coverUrl
            || selectedMetadata?.poster
            || selectedMetadata?.posterUrl
            || sourceJob.poster_url
            || null
        },
        selectedPreEncodeScriptIds: normalizeScriptIdList(sourceEncodePlan?.preEncodeScriptIds || []),
        selectedPostEncodeScriptIds: normalizeScriptIdList(sourceEncodePlan?.postEncodeScriptIds || []),
        selectedPreEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.preEncodeChainIds || []),
        selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || [])
      };
      } // end else (sourceTracks.length > 0)
    } else if (!isAudiobookRetry) {
      const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
      const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig(
        retrySettings,
        mediaProfile
      );
      const resolvedOldRawPath = this.resolveCurrentRawPathForSettings(
        retrySettings,
        mediaProfile,
        sourceJob.raw_path
      );

      if (resolvedOldRawPath) {
        const oldRawFolderName = path.basename(resolvedOldRawPath);
        const oldRawLooksLikeJobFolder = /\s-\sRAW\s-\sjob-\d+\s*$/i.test(stripRawStatePrefix(oldRawFolderName));
        if (!oldRawLooksLikeJobFolder) {
          const error = new Error(`Retry nicht möglich: alter RAW-Pfad ist kein Job-RAW-Ordner (${resolvedOldRawPath}).`);
          error.statusCode = 400;
          throw error;
        }

        const rawDeletionRoots = Array.from(new Set(
          [
            retryRawBaseDir,
            ...retryRawExtraDirs,
            path.dirname(String(sourceJob.raw_path || '').trim())
          ]
            .map((dirPath) => normalizeComparablePath(dirPath))
            .filter(Boolean)
        ));
        const oldRawPathAllowed = rawDeletionRoots.some((rootPath) => isPathInsideDirectory(rootPath, resolvedOldRawPath));
        if (!oldRawPathAllowed) {
          const error = new Error(
            `Retry nicht möglich: alter RAW-Pfad liegt außerhalb der erlaubten RAW-Verzeichnisse (${resolvedOldRawPath}).`
          );
          error.statusCode = 400;
          throw error;
        }

        try {
          fs.rmSync(resolvedOldRawPath, { recursive: true, force: true });
        } catch (deleteError) {
          const error = new Error(`Retry nicht möglich: alter RAW-Ordner konnte nicht gelöscht werden (${deleteError.message}).`);
          error.statusCode = 500;
          throw error;
        }
        await historyService.appendLog(
          jobId,
          'USER_ACTION',
          `Retry: alter RAW-Ordner wurde entfernt: ${resolvedOldRawPath}`
        );
        sourceJob = await historyService.updateJob(jobId, {
          raw_path: null,
          rip_successful: 0
        });
      } else if (sourceJob.raw_path) {
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `Retry: alter RAW-Pfad ist nicht mehr vorhanden und wird aus dem Job entfernt (${sourceJob.raw_path}).`
        );
        sourceJob = await historyService.updateJob(jobId, {
          raw_path: null,
          rip_successful: 0
        });
      }
    }

    const retryJob = await historyService.createJob({
      discDevice: sourceJob.disc_device || null,
      status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'),
      detectedTitle: sourceJob.detected_title || sourceJob.title || null,
      jobKind: this.resolveJobKindForJob(sourceJob, {
        encodePlan: sourceEncodePlan
      })
    });
    const retryJobId = Number(retryJob?.id || 0);
    if (!Number.isFinite(retryJobId) || retryJobId <= 0) {
      throw new Error('Retry fehlgeschlagen: neuer Job konnte nicht erstellt werden.');
    }

    let retryRawPath = isAudiobookRetry ? (sourceJob.raw_path || null) : null;
    let retryEncodeInputPath = isAudiobookRetry
      ? (
        sourceJob.encode_input_path
        || sourceEncodePlan?.encodeInputPath
        || sourceMakemkvInfo?.rawFilePath
        || null
      )
      : null;
    if (retryRawPath) {
      const previousRetryRawPath = retryRawPath;
      retryRawPath = await this.alignRawFolderJobId(retryRawPath, retryJobId);
      if (
        previousRetryRawPath
        && retryRawPath
        && normalizeComparablePath(previousRetryRawPath) !== normalizeComparablePath(retryRawPath)
      ) {
        retryEncodeInputPath = remapPathToRetargetedRawRoot(
          retryEncodeInputPath,
          previousRetryRawPath,
          retryRawPath
        );
      }
    }

    const retryUpdatePayload = {
      parent_job_id: Number(jobId),
      media_type: preservedRetryMediaType,
      title: sourceJob.title || null,
      year: sourceJob.year ?? null,
      imdb_id: sourceJob.imdb_id || null,
      poster_url: sourceJob.poster_url || null,
      omdb_json: sourceJob.omdb_json || null,
      selected_from_omdb: Number(sourceJob.selected_from_omdb || 0),
      makemkv_info_json: sourceJob.makemkv_info_json || null,
      rip_successful: isAudiobookRetry ? 1 : 0,
      error_message: null,
      end_time: null,
      handbrake_info_json: null,
      mediainfo_info_json: null,
      encode_plan_json: (isCdRetry || isAudiobookRetry)
        ? (sourceJob.encode_plan_json || null)
        : null,
      encode_input_path: retryEncodeInputPath,
      encode_review_confirmed: isAudiobookRetry ? 1 : 0,
      output_path: null,
      raw_path: retryRawPath,
      status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'),
      last_state: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING')
    };
    await historyService.updateJob(retryJobId, retryUpdatePayload);

    // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist
    if (thumbnailService.isLocalUrl(sourceJob.poster_url)) {
      const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), retryJobId);
      if (copiedUrl) {
        await historyService.updateJob(retryJobId, { poster_url: copiedUrl }).catch(() => {});
      }
    }

    await historyService.appendLog(
      retryJobId,
      'USER_ACTION',
      `Retry aus Job #${jobId} gestartet (${isCdRetry ? 'CD' : (isAudiobookRetry ? 'Audiobook' : 'Disc')}).`
    );
    if (preservedRetryMediaType && !explicitSourceMediaType) {
      await historyService.updateJob(jobId, { media_type: preservedRetryMediaType }).catch(() => {});
    }
    this._releaseDriveLockForJob(jobId, { reason: 'retry_replaced' });
    await historyService.retireJobInFavorOf(jobId, retryJobId, {
      reason: isCdRetry ? 'cd_retry' : (isAudiobookRetry ? 'audiobook_retry' : 'retry')
    });
    this.cancelRequestedByJob.delete(retryJobId);

    if (isCdRetry) {
      this.startCdRip(retryJobId, cdRetryConfig || {}).catch((error) => {
        logger.error('retry:cd:background-failed', {
          jobId: retryJobId,
          sourceJobId: jobId,
          error: errorToMeta(error)
        });
      });
    } else if (isAudiobookRetry) {
      return {
        jobId: retryJobId,
        sourceJobId: Number(jobId),
        replacedSourceJob: true,
        started: false,
        queued: false,
        stage: 'READY_TO_START'
      };
    } else {
      this.startRipEncode(retryJobId).catch((error) => {
        logger.error('retry:background-failed', { jobId: retryJobId, sourceJobId: jobId, error: errorToMeta(error) });
      });
    }

    return {
      started: true,
      sourceJobId: Number(jobId),
      jobId: retryJobId,
      replacedSourceJob: true
    };
  }

  async resumeReadyToEncodeJob(jobId) {
    const resolvedReadyJob = await this.resolveExistingJobForAction(jobId, 'resume_ready_to_encode');
    jobId = resolvedReadyJob.resolvedJobId;
    this.ensureNotBusy('resumeReadyToEncodeJob', jobId);
    logger.info('resumeReadyToEncodeJob:requested', { jobId });

    const job = resolvedReadyJob.job || await historyService.getJobById(jobId);

    const isReadyToEncode = job.status === 'READY_TO_ENCODE' || job.last_state === 'READY_TO_ENCODE';
    if (!isReadyToEncode) {
      const error = new Error(`Job ${jobId} ist nicht im Status READY_TO_ENCODE.`);
      error.statusCode = 409;
      throw error;
    }

    const encodePlan = this.safeParseJson(job.encode_plan_json);
    if (!encodePlan || !Array.isArray(encodePlan.titles)) {
      const error = new Error('READY_TO_ENCODE Job kann nicht geladen werden: encode_plan fehlt.');
      error.statusCode = 400;
      throw error;
    }

    const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
    const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
    const reviewConfirmed = Boolean(Number(job.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed);
    const readyMediaProfile = this.resolveMediaProfileForJob(job, { encodePlan });
    const resumeSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile);
    const resolvedResumeRawPath = this.resolveCurrentRawPathForSettings(
      resumeSettings,
      readyMediaProfile,
      job.raw_path
    );
    const activeResumeRawPath = resolvedResumeRawPath || String(job.raw_path || '').trim() || null;

    let inputPath = isPreRipMode
      ? null
      : (job.encode_input_path || encodePlan?.encodeInputPath || null);
    if (!isPreRipMode && activeResumeRawPath) {
      const needsInputRefresh = !inputPath
        || !fs.existsSync(inputPath)
        || !isPathInsideDirectory(activeResumeRawPath, inputPath);
      if (needsInputRefresh) {
        const selectedPlaylistId = normalizePlaylistId(
          encodePlan?.selectedPlaylistId
          || encodePlan?.selectedPlaylist
          || null
        );
        if (hasBluRayBackupStructure(activeResumeRawPath)) {
          inputPath = activeResumeRawPath;
        } else {
          const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job, selectedPlaylistId);
          inputPath = findPreferredRawInput(activeResumeRawPath, {
            playlistAnalysis: playlistDecision.playlistAnalysis,
            selectedPlaylistId: selectedPlaylistId || playlistDecision.selectedPlaylist
          })?.path || null;
        }
      }
    }
    const hasEncodableTitle = isPreRipMode
      ? Boolean(encodePlan?.encodeInputTitleId)
      : Boolean(inputPath);
    const selectedMetadata = {
      title: job.title || job.detected_title || null,
      year: job.year || null,
      imdbId: job.imdb_id || null,
      poster: job.poster_url || null
    };
    await this.setState('READY_TO_ENCODE', {
      activeJobId: jobId,
      progress: 0,
      eta: null,
      statusText: hasEncodableTitle
        ? (reviewConfirmed
          ? (isPreRipMode
            ? 'Spurauswahl geladen - Backup/Rip + Encode startbereit'
            : 'Mediainfo geladen - Encode startbereit')
          : (isPreRipMode
            ? 'Spurauswahl geladen - bitte bestätigen'
            : 'Mediainfo geladen - bitte bestätigen'))
        : (isPreRipMode
          ? 'Spurauswahl geladen - kein passender Titel gewählt'
          : 'Mediainfo geladen - kein Titel erfüllt MIN_LENGTH_MINUTES'),
      context: {
        ...(this.snapshot.context || {}),
        jobId,
        rawPath: activeResumeRawPath,
        inputPath,
        hasEncodableTitle,
        reviewConfirmed,
        mode,
        mediaProfile: readyMediaProfile,
        sourceJobId: encodePlan?.sourceJobId || null,
        selectedMetadata,
        mediaInfoReview: encodePlan
      }
    });

    await historyService.appendLog(
      jobId,
      'USER_ACTION',
      'READY_TO_ENCODE Job nach Neustart in den Ripper geladen.'
    );

    if (
      (activeResumeRawPath && normalizeComparablePath(activeResumeRawPath) !== normalizeComparablePath(job.raw_path))
      || (!isPreRipMode && inputPath && normalizeComparablePath(inputPath) !== normalizeComparablePath(job.encode_input_path))
    ) {
      const resumeUpdatePayload = {};
      if (activeResumeRawPath && normalizeComparablePath(activeResumeRawPath) !== normalizeComparablePath(job.raw_path)) {
        resumeUpdatePayload.raw_path = activeResumeRawPath;
      }
      if (!isPreRipMode) {
        resumeUpdatePayload.encode_input_path = inputPath;
      }
      await historyService.updateJob(jobId, resumeUpdatePayload);
    }

    return historyService.getJobById(jobId);
  }

  async restartEncodeWithLastSettings(jobId, options = {}) {
    const immediate = Boolean(options?.immediate);
    if (!immediate) {
      // Restart-Encode now prepares an editable READY_TO_ENCODE state first.
      // No queue slot is needed because encoding is not started automatically here.
      return this.restartEncodeWithLastSettings(jobId, { ...options, immediate: true });
    }

    const resolvedRestartEncodeJob = await this.resolveExistingJobForAction(jobId, 'restart_encode');
    jobId = resolvedRestartEncodeJob.resolvedJobId;
    this.ensureNotBusy('restartEncodeWithLastSettings', jobId);
    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 job = resolvedRestartEncodeJob.job || await historyService.getJobById(jobId);

    const currentStatus = String(job.status || '').trim().toUpperCase();
    if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK'].includes(currentStatus)) {
      const error = new Error(`Encode-Neustart nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`);
      error.statusCode = 409;
      throw error;
    }

    const 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 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 && restartDeleteIncompleteOutput && !encodePreviouslySuccessful && !keepBoth) {
      try {
        const deleteResult = await historyService.deleteJobFiles(jobId, 'movie');
        await historyService.appendLog(
          jobId,
          'USER_ACTION',
          `Encode-Neustart: unvollständigen Output vor Start entfernt (movie files=${deleteResult?.summary?.movie?.filesDeleted ?? 0}, dirs=${deleteResult?.summary?.movie?.dirsRemoved ?? 0}).`
        );
      } catch (error) {
        logger.warn('restartEncodeWithLastSettings:delete-incomplete-output-failed', {
          jobId,
          outputPath: previousOutputPath,
          error: errorToMeta(error)
        });
      }
    }

    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
        && restartDeleteIncompleteOutput
        && !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);

    const replacementJob = await historyService.createJob({
      discDevice: job.disc_device || null,
      status: 'READY_TO_ENCODE',
      detectedTitle: job.detected_title || job.title || null,
      jobKind: this.resolveJobKindForJob(job, {
        encodePlan: restartPlan
      })
    });
    const replacementJobId = Number(replacementJob?.id || 0);
    if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) {
      throw new Error('Encode-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.');
    }

    const previousRestartRawPath = activeRestartRawPath;
    activeRestartRawPath = await this.alignRawFolderJobId(activeRestartRawPath, replacementJobId);
    if (
      previousRestartRawPath
      && activeRestartRawPath
      && normalizeComparablePath(previousRestartRawPath) !== normalizeComparablePath(activeRestartRawPath)
    ) {
      inputPath = remapPathToRetargetedRawRoot(inputPath, previousRestartRawPath, activeRestartRawPath);
      restartPlan.encodeInputPath = inputPath;
    }

    await historyService.updateJob(replacementJobId, {
      parent_job_id: Number(jobId),
      media_type: preservedRestartMediaType,
      title: job.title || null,
      year: job.year ?? null,
      imdb_id: job.imdb_id || null,
      poster_url: job.poster_url || null,
      omdb_json: job.omdb_json || null,
      selected_from_omdb: Number(job.selected_from_omdb || 0),
      status: 'READY_TO_ENCODE',
      last_state: 'READY_TO_ENCODE',
      error_message: null,
      end_time: null,
      output_path: null,
      disc_device: job.disc_device || null,
      raw_path: activeRestartRawPath || null,
      rip_successful: Number(job.rip_successful || 0),
      makemkv_info_json: job.makemkv_info_json || null,
      handbrake_info_json: null,
      mediainfo_info_json: job.mediainfo_info_json || null,
      encode_plan_json: JSON.stringify(restartPlan),
      encode_input_path: inputPath,
      encode_review_confirmed: 0
    });

    // Keep local poster thumbnails valid for the replacement job id.
    if (thumbnailService.isLocalUrl(job.poster_url)) {
      const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId);
      if (copiedUrl) {
        await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {});
      }
    }
    const 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=${restartDeleteIncompleteOutput ? '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 (preservedRestartMediaType && !explicitJobMediaType) {
      await historyService.updateJob(jobId, { media_type: preservedRestartMediaType }).catch(() => {});
    }
    await historyService.retireJobInFavorOf(jobId, replacementJobId, {
      reason: 'restart_encode'
    });

    await this.setState('READY_TO_ENCODE', {
      activeJobId: replacementJobId,
      progress: 0,
      eta: null,
      statusText: hasEncodableTitle
        ? (isPreRipMode
          ? 'Vorherige Spurauswahl geladen - anpassen und Backup/Rip + Encode starten'
          : (
            `Vorherige Encode-Auswahl geladen - anpassen und Encoding starten${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: true,
      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', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(currentStatus)) {
      const error = new Error(`Review-Neustart nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`);
      error.statusCode = 409;
      throw error;
    }

    const reviewDeleteFolders = Array.isArray(options?.deleteFolders)
      ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim())
      : [];
    if (reviewDeleteFolders.length > 0) {
      try {
        const specificDeleteResult = await historyService.deleteSpecificOutputFolders(jobId, reviewDeleteFolders);
        await historyService.appendLog(
          jobId,
          'USER_ACTION',
          `Review-Neustart: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).`
        );
      } catch (error) {
        logger.warn('restartReviewFromRaw:delete-specific-folders-failed', {
          jobId,
          error: errorToMeta(error)
        });
      }
    }

    const staleQueueIndex = this.findQueueEntryIndexByJobId(Number(jobId));
    let removedQueueActionLabel = null;
    if (staleQueueIndex >= 0) {
      const [removed] = this.queueEntries.splice(staleQueueIndex, 1);
      removedQueueActionLabel = QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion';
      await this.emitQueueChanged();
    }

    const forcePlaylistReselection = Boolean(options?.forcePlaylistReselection);
    const previousEncodePlan = this.safeParseJson(sourceJob.encode_plan_json);
    const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json);
    const nextMakemkvInfoJson = mkInfo && typeof mkInfo === 'object'
      ? JSON.stringify({
        ...mkInfo,
        rawPath: resolvedReviewRawPath,
        analyzeContext: {
          ...(mkInfo?.analyzeContext || {}),
          playlistAnalysis: null,
          playlistDecisionRequired: false,
          selectedPlaylist: null,
          selectedTitleId: null,
          handBrakePlaylistScan: null
        },
        postBackupAnalyze: null
      })
      : sourceJob.makemkv_info_json;

    const jobUpdatePayload = {
      status: 'MEDIAINFO_CHECK',
      last_state: 'MEDIAINFO_CHECK',
      start_time: nowIso(),
      end_time: null,
      error_message: null,
      output_path: null,
      handbrake_info_json: null,
      mediainfo_info_json: null,
      encode_plan_json: null,
      encode_input_path: normalizedReviewInputPath || null,
      encode_review_confirmed: 0,
      makemkv_info_json: nextMakemkvInfoJson,
      raw_path: resolvedReviewRawPath
    };

    const reuseCurrentJob = Boolean(options?.reuseCurrentJob);
    const inheritedSourceJobId = this.normalizeQueueJobId(previousEncodePlan?.sourceJobId)
      || this.normalizeQueueJobId(sourceJob.parent_job_id)
      || null;
    const reviewSourceJobId = reuseCurrentJob
      ? inheritedSourceJobId
      : Number(jobId);
    let targetJobId = Number(jobId);
    let replacedSourceJob = false;
    let activeReviewRawPath = resolvedReviewRawPath;
    let activeReviewInputPath = normalizedReviewInputPath || null;

    if (reuseCurrentJob) {
      await historyService.resetProcessLog(targetJobId);
      await historyService.updateJob(targetJobId, {
        ...jobUpdatePayload,
        media_type: preservedReviewMediaType,
        output_path: null,
        handbrake_info_json: null,
        mediainfo_info_json: null,
        encode_plan_json: null,
        encode_input_path: activeReviewInputPath,
        encode_review_confirmed: 0
      });
    } else {
      const replacementJob = await historyService.createJob({
        discDevice: sourceJob.disc_device || null,
        status: 'MEDIAINFO_CHECK',
        detectedTitle: sourceJob.detected_title || sourceJob.title || null,
        jobKind: this.resolveJobKindForJob(sourceJob, {
          encodePlan: previousEncodePlan
        })
      });
      const replacementJobId = Number(replacementJob?.id || 0);
      if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) {
        throw new Error('Review-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.');
      }

      const previousReviewRawPath = activeReviewRawPath;
      activeReviewRawPath = await this.alignRawFolderJobId(activeReviewRawPath, replacementJobId);
      if (
        previousReviewRawPath
        && activeReviewRawPath
        && normalizeComparablePath(previousReviewRawPath) !== normalizeComparablePath(activeReviewRawPath)
      ) {
        activeReviewInputPath = remapPathToRetargetedRawRoot(
          activeReviewInputPath,
          previousReviewRawPath,
          activeReviewRawPath
        );
      }

      await historyService.updateJob(replacementJobId, {
        parent_job_id: Number(jobId),
        media_type: preservedReviewMediaType,
        title: sourceJob.title || null,
        year: sourceJob.year ?? null,
        imdb_id: sourceJob.imdb_id || null,
        poster_url: sourceJob.poster_url || null,
        omdb_json: sourceJob.omdb_json || null,
        selected_from_omdb: Number(sourceJob.selected_from_omdb || 0),
        disc_device: sourceJob.disc_device || null,
        rip_successful: Number(sourceJob.rip_successful || 0),
        output_path: null,
        handbrake_info_json: null,
        mediainfo_info_json: null,
        encode_plan_json: null,
        encode_input_path: activeReviewInputPath,
        encode_review_confirmed: 0,
        ...jobUpdatePayload,
        raw_path: activeReviewRawPath,
        encode_input_path: activeReviewInputPath
      });

      // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist
      if (thumbnailService.isLocalUrl(sourceJob.poster_url)) {
        const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId);
        if (copiedUrl) {
          await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {});
        }
      }

      targetJobId = replacementJobId;
      replacedSourceJob = true;
    }

    if (removedQueueActionLabel) {
      await historyService.appendLog(
        targetJobId,
        'USER_ACTION',
        `Queue-Eintrag entfernt (Review-Neustart): ${removedQueueActionLabel}`
      );
    }
    if (shouldRealignEncodeInput) {
      await historyService.appendLog(
        targetJobId,
        'SYSTEM',
        `Review-Neustart: Encode-Input auf aktuellen RAW-Pfad abgeglichen: ${existingEncodeInputPath || '-'} -> ${activeReviewInputPath || '-'}`
      );
    }
    await historyService.appendLog(
      targetJobId,
      'USER_ACTION',
      `Review-Neustart aus RAW angefordert.${reuseCurrentJob ? ' Bestehende Job-ID wird weiterverwendet.' : ''}${forcePlaylistReselection ? ' Playlist-Auswahl wird zurückgesetzt.' : ''} MakeMKV Full-Analyse wird vollständig neu ausgeführt.`
    );
    if (!reuseCurrentJob && preservedReviewMediaType && !explicitSourceMediaType) {
      await historyService.updateJob(jobId, { media_type: preservedReviewMediaType }).catch(() => {});
    }
    if (!reuseCurrentJob) {
      await historyService.retireJobInFavorOf(jobId, targetJobId, {
        reason: 'restart_review'
      });
    }

    await this.setState('MEDIAINFO_CHECK', {
      activeJobId: targetJobId,
      progress: 0,
      eta: null,
      statusText: 'Titel-/Spurprüfung wird neu gestartet...',
      context: {
        ...(this.snapshot.context || {}),
        jobId: targetJobId,
        reviewConfirmed: false,
        mediaInfoReview: null
      }
    });

    this.runReviewForRawJob(targetJobId, activeReviewRawPath, {
      mode: options?.mode || 'reencode',
      sourceJobId: reviewSourceJobId,
      forcePlaylistReselection,
      forceFreshAnalyze: true,
      previousEncodePlan
    }).catch((error) => {
      logger.error('restartReviewFromRaw:background-failed', { jobId: targetJobId, sourceJobId: jobId, error: errorToMeta(error) });
      this.failJob(targetJobId, 'MEDIAINFO_CHECK', error).catch((failError) => {
        logger.error('restartReviewFromRaw:background-failJob-failed', {
          jobId: targetJobId,
          error: errorToMeta(failError)
        });
      });
    });

    return {
      restarted: true,
      started: true,
      stage: 'MEDIAINFO_CHECK',
      sourceJobId: Number(jobId),
      jobId: targetJobId,
      replacedSourceJob
    };
  }

  async 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 entryJobId = Number(entry?.jobId);
      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)
      );
      return !(isLegacyParentEntry || isSeriesChildJobEntry);
    });
    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_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);
        await historyService.appendLog(
          normalizedJobId,
          'USER_ACTION',
          `Aus Queue entfernt: ${QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'}`
        );
        await this.emitQueueChanged();
        return {
          cancelled: true,
          queuedOnly: true,
          jobId: normalizedJobId
        };
      }
    }

    const buildForcedCancelError = (message) => {
      const reason = String(message || 'Vom Benutzer hart abgebrochen.').trim() || 'Vom Benutzer hart abgebrochen.';
      const endedAt = nowIso();
      const error = new Error(reason);
      error.statusCode = 409;
      error.runInfo = {
        source: 'USER_CANCEL',
        stage: this.snapshot.state || null,
        cmd: null,
        args: [],
        startedAt: endedAt,
        endedAt,
        durationMs: 0,
        status: 'CANCELLED',
        exitCode: null,
        stdoutLines: 0,
        stderrLines: 0,
        lastProgress: 0,
        eta: null,
        lastDetail: null,
        highlights: []
      };
      return error;
    };

    const forceFinalizeCancelledJob = async (reason, stageHint = null) => {
      const rawStage = String(stageHint || this.snapshot.state || '').trim().toUpperCase();
      const effectiveStage = RUNNING_STATES.has(rawStage)
        ? rawStage
        : (
          RUNNING_STATES.has(String(this.snapshot.state || '').trim().toUpperCase())
            ? String(this.snapshot.state || '').trim().toUpperCase()
            : 'ENCODING'
        );
      try {
        await historyService.appendLog(normalizedJobId, 'USER_ACTION', reason);
      } catch (_error) {
        // continue with force-cancel even if logging failed
      }
      try {
        await this.failJob(normalizedJobId, effectiveStage, buildForcedCancelError(reason));
      } catch (forceError) {
        logger.error('cancel:force-finalize:failed', {
          jobId: normalizedJobId,
          stage: effectiveStage,
          reason,
          error: errorToMeta(forceError)
        });
        const fallbackJob = await historyService.getJobById(normalizedJobId);
        await historyService.updateJob(normalizedJobId, {
          status: 'CANCELLED',
          last_state: 'CANCELLED',
          end_time: nowIso(),
          error_message: reason
        });
        await this.setState('CANCELLED', {
          activeJobId: normalizedJobId,
          progress: this.snapshot.progress,
          eta: null,
          statusText: reason,
          context: {
            jobId: normalizedJobId,
            rawPath: fallbackJob?.raw_path || null,
            error: reason,
            canRestartReviewFromRaw: Boolean(fallbackJob?.raw_path)
          }
        });
      } finally {
        this.cancelRequestedByJob.delete(normalizedJobId);
        this.activeProcesses.delete(normalizedJobId);
        this.syncPrimaryActiveProcess();
      }
      return {
        cancelled: true,
        queuedOnly: false,
        forced: true,
        jobId: normalizedJobId
      };
    };

    const runningJob = await historyService.getJobById(normalizedJobId);
    const runningStatus = String(
      runningJob?.status
      || runningJob?.last_state
      || this.snapshot.state
      || ''
    ).trim().toUpperCase();
    const SOFT_CANCELABLE_STATES = new Set([
      'READY_TO_START',
      'READY_TO_ENCODE',
      'METADATA_SELECTION',
      'WAITING_FOR_USER_DECISION',
      'CD_METADATA_SELECTION',
      'CD_READY_TO_RIP'
    ]);

    if (!processHandle) {
      if (SOFT_CANCELABLE_STATES.has(runningStatus)) {
        // Kein laufender Prozess – Job direkt abbrechen (Review-/Ready-Phasen)
        const cancelMessage = 'Vom Benutzer abgebrochen.';
        await historyService.updateJob(normalizedJobId, {
          status: 'CANCELLED',
          // Preserve origin state so ripper/history can distinguish review-cancelled rows.
          last_state: runningStatus,
          end_time: nowIso(),
          error_message: cancelMessage
        });

        const cdDriveForJob = this._getCdDriveByJobId(normalizedJobId);
        if (cdDriveForJob?.devicePath) {
          if (String(cdDriveForJob.devicePath).startsWith('__virtual__')) {
            this._removeCdDrive(cdDriveForJob.devicePath);
          } else {
            this._releaseCdDrive(cdDriveForJob.devicePath, { device: cdDriveForJob.device || null });
          }
        } else {
          this.jobProgress.delete(normalizedJobId);
        }

        await historyService.appendLog(normalizedJobId, 'USER_ACTION', `Abbruch im Status ${runningStatus}.`);
        await this.setState('CANCELLED', {
          activeJobId: normalizedJobId,
          progress: 0,
          eta: null,
          statusText: cancelMessage,
          context: {
            jobId: normalizedJobId,
            rawPath: runningJob?.raw_path || null,
            error: cancelMessage,
            cancelledFromState: runningStatus,
            canRestartReviewFromRaw: Boolean(runningJob?.raw_path)
          }
        });
        return { cancelled: true, queuedOnly: false, jobId: normalizedJobId };
      }

      if (RUNNING_STATES.has(runningStatus)) {
        return forceFinalizeCancelledJob(
          `Abbruch erzwungen: kein aktiver Prozess-Handle gefunden (Status ${runningStatus}).`,
          runningStatus
        );
      }

      const error = new Error(`Kein laufender Prozess für Job #${normalizedJobId} zum Abbrechen.`);
      error.statusCode = 409;
      throw error;
    }

    let removedQueuedActionLabel = null;
    const staleQueueIndex = this.findQueueEntryIndexByJobId(normalizedJobId);
    if (staleQueueIndex >= 0) {
      const [removed] = this.queueEntries.splice(staleQueueIndex, 1);
      removedQueuedActionLabel = QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion';
      await this.emitQueueChanged();
      try {
        await historyService.appendLog(
          normalizedJobId,
          'SYSTEM',
          `Veralteter Queue-Eintrag beim Abbruch entfernt: ${removedQueuedActionLabel}`
        );
      } catch (_error) {
        // keep cancel flow even if stale queue entry logging fails
      }
    }

    logger.warn('cancel:requested', {
      state: this.snapshot.state,
      activeJobId: this.snapshot.activeJobId,
      requestedJobId: normalizedJobId,
      pid: processHandle?.child?.pid || null,
      removedQueuedAction: removedQueuedActionLabel
    });
    this.cancelRequestedByJob.add(normalizedJobId);
    processHandle.cancel();
    try {
      await historyService.appendLog(
        normalizedJobId,
        'USER_ACTION',
        `Abbruch angefordert (hard-cancel). Status=${runningStatus || '-'}.`
      );
    } catch (_error) {
      // keep hard-cancel flow even if logging fails
    }

    const settleResult = await Promise.race([
      Promise.resolve(processHandle.promise)
        .then(() => 'settled')
        .catch(() => 'settled'),
      new Promise((resolve) => setTimeout(() => resolve('timeout'), 2200))
    ]);
    const stillActive = this.activeProcesses.has(normalizedJobId);
    if (settleResult === 'settled' && !stillActive) {
      return {
        cancelled: true,
        queuedOnly: false,
        jobId: normalizedJobId
      };
    }

    logger.error('cancel:hard-timeout', {
      jobId: normalizedJobId,
      runningStatus,
      settleResult,
      stillActive,
      pid: processHandle?.child?.pid || null
    });
    try {
      processHandle.cancel();
    } catch (_error) {
      // ignore second cancel errors
    }
    const childPid = Number(processHandle?.child?.pid);
    if (Number.isFinite(childPid) && childPid > 0) {
      try { process.kill(-childPid, 'SIGKILL'); } catch (_error) { /* noop */ }
      try { process.kill(childPid, 'SIGKILL'); } catch (_error) { /* noop */ }
    }
    try {
      processHandle?.child?.kill?.('SIGKILL');
    } catch (_error) {
      // noop
    }
    this.activeProcesses.delete(normalizedJobId);
    this.syncPrimaryActiveProcess();
    return forceFinalizeCancelledJob(
      `Abbruch erzwungen: Prozess reagierte nicht rechtzeitig auf Kill-Signal (Status ${runningStatus || '-'}).`,
      runningStatus
    );
  }

  async runCommand({
    jobId,
    stage,
    source,
    cmd,
    args,
    parser,
    collectLines = null,
    collectStdoutLines = true,
    collectStderrLines = true,
    argsForLog = null,
    silent = false,
    onStdoutLine = null,
    onStderrLine = null
  }) {
    const normalizedJobId = this.normalizeQueueJobId(jobId) || Number(jobId) || jobId;
    const loggableArgs = Array.isArray(argsForLog) ? argsForLog : args;
    if (this.cancelRequestedByJob.has(Number(normalizedJobId))) {
      const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
      cancelError.statusCode = 409;
      const endedAt = nowIso();
      cancelError.runInfo = {
        source,
        stage,
        cmd,
        args: loggableArgs,
        startedAt: endedAt,
        endedAt,
        durationMs: 0,
        status: 'CANCELLED',
        exitCode: null,
        stdoutLines: 0,
        stderrLines: 0,
        stdoutTail: '',
        stderrTail: '',
        stdoutTruncated: false,
        stderrTruncated: false,
        lastProgress: 0,
        eta: null,
        lastDetail: null,
        highlights: []
      };
      logger.warn('command:cancelled-before-spawn', { jobId: normalizedJobId, stage, source });
      throw cancelError;
    }

    await historyService.appendLog(jobId, 'SYSTEM', `Spawn ${cmd} ${loggableArgs.join(' ')}`);
    logger.info('command:spawn', { jobId, stage, source, cmd, args: loggableArgs });

    const runInfo = {
      source,
      stage,
      cmd,
      args: loggableArgs,
      startedAt: nowIso(),
      endedAt: null,
      durationMs: null,
      status: 'RUNNING',
      exitCode: null,
      stdoutLines: 0,
      stderrLines: 0,
      stdoutTail: '',
      stderrTail: '',
      stdoutTruncated: false,
      stderrTruncated: false,
      lastProgress: 0,
      eta: null,
      lastDetail: null,
      highlights: []
    };

    const applyLine = (line, isStderr) => {
      const text = truncateLine(line, 400);
      if (isStderr) {
        runInfo.stderrLines += 1;
      } else {
        runInfo.stdoutLines += 1;
      }

      const detail = extractProgressDetail(source, text);
      if (detail) {
        runInfo.lastDetail = detail;
      }

      if (runInfo.highlights.length < 120 && shouldKeepHighlight(text)) {
        runInfo.highlights.push(text);
      }

      if (parser && !silent) {
        const progress = parser(text);
        if (progress && progress.percent !== null) {
          runInfo.lastProgress = progress.percent;
          runInfo.eta = progress.eta || runInfo.eta;
          const statusText = composeStatusText(stage, progress.percent, runInfo.lastDetail);
          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) => {
        if (collectLines && collectStdoutLines) {
          collectLines.push(line);
        }
        void historyService.appendProcessLog(jobId, source, line);
        const nextStdout = appendTailText(runInfo.stdoutTail, line);
        runInfo.stdoutTail = nextStdout.value;
        runInfo.stdoutTruncated = runInfo.stdoutTruncated || nextStdout.truncated;
        if (typeof onStdoutLine === 'function') {
          try {
            onStdoutLine(line);
          } catch (_error) {
            // ignore observer failures for live runtime mirroring
          }
        }
        applyLine(line, false);
      },
      onStderrLine: (line) => {
        if (collectLines && collectStderrLines) {
          collectLines.push(line);
        }
        void historyService.appendProcessLog(jobId, `${source}_ERR`, line);
        const nextStderr = appendTailText(runInfo.stderrTail, line);
        runInfo.stderrTail = nextStderr.value;
        runInfo.stderrTruncated = runInfo.stderrTruncated || nextStderr.truncated;
        if (typeof onStderrLine === 'function') {
          try {
            onStderrLine(line);
          } catch (_error) {
            // ignore observer failures for live runtime mirroring
          }
        }
        applyLine(line, true);
      }
    });
    const normalizedProcessKey = Number(normalizedJobId);
    const previousProcessHandle = this.activeProcesses.get(normalizedProcessKey) || null;
    this.activeProcesses.set(normalizedProcessKey, processHandle);
    this.syncPrimaryActiveProcess();

    try {
      const procResult = await processHandle.promise;
      runInfo.status = 'SUCCESS';
      runInfo.exitCode = procResult.code;
      runInfo.endedAt = nowIso();
      runInfo.durationMs = new Date(runInfo.endedAt).getTime() - new Date(runInfo.startedAt).getTime();
      await historyService.appendLog(jobId, 'SYSTEM', `${source} abgeschlossen.`);
      logger.info('command:completed', { jobId, stage, source });
      return runInfo;
    } catch (error) {
      if (this.cancelRequestedByJob.has(Number(normalizedJobId))) {
        const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
        cancelError.statusCode = 409;
        runInfo.status = 'CANCELLED';
        runInfo.exitCode = null;
        runInfo.endedAt = nowIso();
        runInfo.durationMs = new Date(runInfo.endedAt).getTime() - new Date(runInfo.startedAt).getTime();
        cancelError.runInfo = runInfo;
        logger.warn('command:cancelled', { jobId, stage, source });
        throw cancelError;
      }
      runInfo.status = 'ERROR';
      runInfo.exitCode = error.code ?? null;
      runInfo.endedAt = nowIso();
      runInfo.durationMs = new Date(runInfo.endedAt).getTime() - new Date(runInfo.startedAt).getTime();
      runInfo.errorMessage = error.message;
      error.runInfo = runInfo;
      logger.error('command:failed', { jobId, stage, source, error: errorToMeta(error) });
      throw error;
    } finally {
      const activeHandleForJob = this.activeProcesses.get(normalizedProcessKey) || null;
      if (activeHandleForJob === processHandle) {
        if (previousProcessHandle && previousProcessHandle !== processHandle) {
          this.activeProcesses.set(normalizedProcessKey, previousProcessHandle);
        } else {
          this.activeProcesses.delete(normalizedProcessKey);
        }
      }
      // Preserve cancellation marker for outer/orchestrating handles (e.g. CD shared handle).
      if (!(previousProcessHandle && previousProcessHandle !== processHandle)) {
        this.cancelRequestedByJob.delete(normalizedProcessKey);
      }
      this.syncPrimaryActiveProcess();
      await historyService.closeProcessLog(jobId);
      await this.emitQueueChanged();
      void this.pumpQueue();
    }
  }

  async failJob(jobId, stage, error) {
    const message = error?.message || String(error);
    const isCancelled = /abgebrochen|cancelled/i.test(message)
      || String(error?.runInfo?.status || '').trim().toUpperCase() === 'CANCELLED';
    const normalizedStage = String(stage || '').trim().toUpperCase();
    const job = await historyService.getJobById(jobId);
    const title = job?.title || job?.detected_title || `Job #${jobId}`;
    const finalState = isCancelled ? 'CANCELLED' : 'ERROR';
    logger[isCancelled ? 'warn' : 'error']('job:failed', { jobId, stage, error: errorToMeta(error) });
    const makemkvInfo = this.safeParseJson(job?.makemkv_info_json);
    const encodePlan = this.safeParseJson(job?.encode_plan_json);
    const resolvedMediaProfile = this.resolveMediaProfileForJob(job, {
      encodePlan,
      makemkvInfo,
      mediaProfile: normalizedStage.startsWith('CD_') ? 'cd' : null
    });
    const isCdFailure = resolvedMediaProfile === 'cd'
      || normalizedStage.startsWith('CD_')
      || String(job?.status || '').trim().toUpperCase().startsWith('CD_')
      || String(job?.last_state || '').trim().toUpperCase().startsWith('CD_')
      || (Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0);
    const mode = String(encodePlan?.mode || '').trim().toLowerCase();
    const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
    const hasEncodableInput = isPreRipMode
      ? Boolean(encodePlan?.encodeInputTitleId)
      : Boolean(job?.encode_input_path || encodePlan?.encodeInputPath || job?.raw_path);
    const hasConfirmedPlan = Boolean(
      encodePlan
      && Array.isArray(encodePlan?.titles)
      && encodePlan.titles.length > 0
      && (Number(job?.encode_review_confirmed || 0) === 1 || Boolean(encodePlan?.reviewConfirmed))
      && hasEncodableInput
    );
    let hasRawPath = false;
    try {
      hasRawPath = Boolean(
        job?.raw_path
        && fs.existsSync(job.raw_path)
        && (hasBluRayBackupStructure(job.raw_path) || findPreferredRawInput(job.raw_path))
      );
    } catch (_error) {
      hasRawPath = false;
    }

    const shouldAutoRecoverEncode = normalizedStage === 'ENCODING'
      && hasConfirmedPlan
      && (!isCancelled || resolvedMediaProfile === 'converter');
    if (shouldAutoRecoverEncode) {
      try {
        const recoveryReasonLabel = isCancelled ? 'Abbruch' : 'Fehler';
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `${recoveryReasonLabel} in ${stage}: ${message}. Letzte Encode-Auswahl wird zur direkten Anpassung geladen.`
        );
        await this.restartEncodeWithLastSettings(jobId, {
          immediate: true,
          triggerReason: isCancelled ? 'cancelled_encode' : 'failed_encode'
        });
        this.cancelRequestedByJob.delete(Number(jobId));
        return;
      } catch (recoveryError) {
        logger.error('job:encoding:auto-recover-failed', {
          jobId,
          stage,
          error: errorToMeta(recoveryError)
        });
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `Auto-Recovery nach Encode-Abbruch fehlgeschlagen: ${recoveryError?.message || 'unknown'}`
        );
      }
    }

    const normalizedJobStatus = String(job?.status || '').trim().toUpperCase();
    const normalizedJobLastState = String(job?.last_state || '').trim().toUpperCase();
    const ripStageForLockRecovery = (
      normalizedStage === 'CD_RIPPING'
      || normalizedJobStatus === 'CD_RIPPING'
      || normalizedJobLastState === 'CD_RIPPING'
    )
      ? 'CD_RIPPING'
      : (
        normalizedStage === 'RIPPING'
        || normalizedJobStatus === 'RIPPING'
        || normalizedJobLastState === 'RIPPING'
      )
        ? 'RIPPING'
        : null;
    const shouldPreserveRipStageForLock = Boolean(
      ripStageForLockRecovery
      && Number(job?.rip_successful || 0) !== 1
      && (finalState === 'ERROR' || finalState === 'CANCELLED')
    );
    const persistedLastState = shouldPreserveRipStageForLock
      ? ripStageForLockRecovery
      : finalState;

    await historyService.updateJob(jobId, {
      status: finalState,
      last_state: persistedLastState,
      end_time: nowIso(),
      error_message: message
    });
    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}`
    );
    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 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 historyService.resetProcessLog(job.id);
      await historyService.appendLog(job.id, 'SYSTEM', `AAX-Upload empfangen: ${originalName}`);
      logger.info('audiobook:upload:received', {
        jobId: job.id,
        originalName,
        tempFilePath,
        rawBaseDir,
        outputFormat,
        startImmediately
      });

      const probeConfig = audiobookService.buildProbeCommand(ffprobeCommand, tempFilePath);
      const captured = await this.runCapturedCommand(probeConfig.cmd, probeConfig.args);
      const probe = audiobookService.parseProbeOutput(captured.stdout);
      if (!probe) {
        const error = new Error('FFprobe-Ausgabe konnte nicht gelesen werden.');
        error.statusCode = 500;
        throw error;
      }

      const metadata = audiobookService.buildMetadataFromProbe(probe, originalName);
      const storagePaths = audiobookService.buildRawStoragePaths(
        metadata,
        job.id,
        rawBaseDir,
        rawTemplate,
        originalName
      );
      logger.info('audiobook:upload:staging', {
        jobId: job.id,
        tempFilePath,
        rawDir: storagePaths.rawDir,
        rawFilePath: storagePaths.rawFilePath,
        rawTemplate,
        outputTemplate
      });

      ensureDir(storagePaths.rawDir);
      moveFileWithFallback(tempFilePath, storagePaths.rawFilePath);
      stagedRawDir = storagePaths.rawDir;
      stagedRawFilePath = storagePaths.rawFilePath;
      chownRecursive(storagePaths.rawDir, settings?.raw_dir_owner);
      logger.info('audiobook:upload:staged', {
        jobId: job.id,
        stagedRawDir,
        stagedRawFilePath
      });

      // Activation Bytes: Cache prüfen und Checksum am Job speichern
      let aaxChecksum = null;
      let aaxNeedsActivationBytes = false;
      try {
        const abResult = await activationBytesService.resolveActivationBytes(stagedRawFilePath);
        aaxChecksum = abResult.checksum;
        await historyService.updateJob(job.id, { aax_checksum: aaxChecksum });
        if (abResult.activationBytes) {
          await historyService.appendLog(job.id, 'SYSTEM', `Activation Bytes im Cache gefunden: checksum=${abResult.checksum}`);
          logger.info('audiobook:upload:activation-bytes', { jobId: job.id, checksum: abResult.checksum, source: 'cache' });
        } else {
          aaxNeedsActivationBytes = true;
          logger.info('audiobook:upload:activation-bytes-needed', { jobId: job.id, checksum: abResult.checksum });
        }
      } catch (abError) {
        logger.warn('audiobook:upload:activation-bytes-failed', { jobId: job.id, error: errorToMeta(abError) });
      }

      let detectedAsin = null;
      let audnexChapters = [];
      let audnexBook = null;
      try {
        detectedAsin = await audnexService.extractAsinFromAaxFile(storagePaths.rawFilePath);
        if (detectedAsin) {
          await historyService.appendLog(job.id, 'SYSTEM', `ASIN erkannt: ${detectedAsin}`);
          const [chapters, book] = await Promise.allSettled([
            audnexService.fetchChaptersByAsin(detectedAsin, 'de'),
            audnexService.fetchBookByAsin(detectedAsin, 'de')
          ]);
          if (chapters.status === 'fulfilled') {
            audnexChapters = chapters.value;
            if (audnexChapters.length > 0) {
              await historyService.appendLog(job.id, 'SYSTEM', `Audnex-Kapitel geladen: ${audnexChapters.length}`);
            } else {
              await historyService.appendLog(job.id, 'SYSTEM', `Keine Audnex-Kapitel fuer ASIN ${detectedAsin} gefunden.`);
            }
          } else {
            logger.warn('audiobook:upload:audnex-chapters-failed', { jobId: job.id, asin: detectedAsin, error: errorToMeta(chapters.reason) });
            await historyService.appendLog(job.id, 'SYSTEM', `Audnex-Kapitel konnten nicht geladen werden: ${chapters.reason?.message || 'unknown'}`).catch(() => {});
          }
          if (book.status === 'fulfilled' && book.value) {
            audnexBook = book.value;
            await historyService.appendLog(job.id, 'SYSTEM', `Audnex-Buchmetadaten geladen: ${audnexBook.narrator ? `Sprecher: ${audnexBook.narrator}` : 'kein Sprecher'}`);
          } else if (book.status === 'rejected') {
            logger.warn('audiobook:upload:audnex-book-failed', { jobId: job.id, asin: detectedAsin, error: errorToMeta(book.reason) });
          }
        } else {
          await historyService.appendLog(job.id, 'SYSTEM', 'Keine ASIN in der AAX-Datei gefunden, verwende eingebettete Kapitel.');
        }
      } catch (audnexError) {
        logger.warn('audiobook:upload:audnex-failed', {
          jobId: job.id,
          stagedRawFilePath: storagePaths.rawFilePath,
          asin: detectedAsin,
          error: errorToMeta(audnexError)
        });
        await historyService.appendLog(
          job.id,
          'SYSTEM',
          `Audnex-Daten konnten nicht geladen werden: ${audnexError?.message || 'unknown'}`
        ).catch(() => {});
      }

      let posterUrl = null;
      if (metadata?.hasEmbeddedCover && metadata?.cover) {
        const coverTempPath = path.join(storagePaths.rawDir, `.job-${job.id}-cover.jpg`);
        try {
          const coverCommand = audiobookService.buildCoverExtractionCommand(
            ffmpegCommand,
            storagePaths.rawFilePath,
            coverTempPath,
            metadata.cover
          );
          await this.runCapturedCommand(coverCommand.cmd, coverCommand.args);
          posterUrl = thumbnailService.storeLocalThumbnail(job.id, coverTempPath);
          if (posterUrl) {
            await historyService.appendLog(job.id, 'SYSTEM', 'Eingebettetes AAX-Cover erkannt und gespeichert.');
          }
        } catch (coverError) {
          logger.warn('audiobook:upload:cover-extract-failed', {
            jobId: job.id,
            stagedRawFilePath: storagePaths.rawFilePath,
            error: errorToMeta(coverError)
          });
        } finally {
          try {
            fs.rmSync(coverTempPath, { force: true });
          } catch (_error) {
            // best effort cleanup
          }
        }
      }

      const resolvedMetadata = {
        ...metadata,
        // Audnex book metadata takes precedence over embedded tags where available
        ...(audnexBook?.narrator ? { narrator: audnexBook.narrator } : {}),
        ...(audnexBook?.author ? { author: audnexBook.author, artist: audnexBook.author } : {}),
        ...(audnexBook?.title ? { title: audnexBook.title, album: audnexBook.title } : {}),
        ...(audnexBook?.description ? { description: audnexBook.description } : {}),
        ...(audnexBook?.series ? { series: audnexBook.series } : {}),
        ...(audnexBook?.part ? { part: audnexBook.part } : {}),
        ...(audnexBook?.year ? { year: audnexBook.year } : {}),
        asin: detectedAsin || null,
        chapterSource: audnexChapters.length > 0 ? 'audnex' : 'probe',
        chapters: audnexChapters.length > 0
          ? audiobookService.normalizeChapterList(audnexChapters, {
            durationMs: metadata.durationMs,
            fallbackTitle: metadata.title,
            createFallback: false
          })
          : metadata.chapters,
        poster: posterUrl || null
      };

      const makemkvInfo = this.withAnalyzeContextMediaProfile({
        status: 'SUCCESS',
        source: 'aax_upload',
        importedAt: nowIso(),
        mediaProfile: 'audiobook',
        rawFileName: storagePaths.rawFileName,
        rawFilePath: storagePaths.rawFilePath,
        chapters: resolvedMetadata.chapters,
        detectedMetadata: resolvedMetadata,
        selectedMetadata: resolvedMetadata,
        probeSummary: {
          durationMs: resolvedMetadata.durationMs,
          tagKeys: Object.keys(resolvedMetadata.tags || {}),
          asin: detectedAsin || null,
          chapterSource: resolvedMetadata.chapterSource || 'probe'
        }
      }, 'audiobook');

      const encodePlan = {
        mediaProfile: 'audiobook',
        jobKind: 'audiobook',
        mode: 'audiobook',
        sourceType: 'upload',
        uploadedAt: nowIso(),
        format: outputFormat,
        formatOptions,
        rawTemplate,
        outputTemplate,
        encodeInputPath: storagePaths.rawFilePath,
        metadata: resolvedMetadata,
        reviewConfirmed: true
      };

      await historyService.updateJob(job.id, {
        media_type: 'audiobook',
        job_kind: 'audiobook',
        status: 'READY_TO_START',
        last_state: 'READY_TO_START',
        title: resolvedMetadata.title || detectedTitle,
        detected_title: resolvedMetadata.title || detectedTitle,
        year: resolvedMetadata.year ?? null,
        raw_path: storagePaths.rawDir,
        rip_successful: 1,
        makemkv_info_json: JSON.stringify(makemkvInfo),
        handbrake_info_json: null,
        mediainfo_info_json: null,
        encode_plan_json: JSON.stringify(encodePlan),
        encode_input_path: storagePaths.rawFilePath,
        encode_review_confirmed: 1,
        output_path: null,
        poster_url: posterUrl || null,
        error_message: null,
        end_time: null
      });

      await historyService.appendLog(
        job.id,
        'SYSTEM',
        `Audiobook analysiert: ${resolvedMetadata.title || detectedTitle} | Autor: ${resolvedMetadata.author || '-'} | Format: ${outputFormat.toUpperCase()}`
      );

      if (!startImmediately) {
        return {
          jobId: job.id,
          started: false,
          queued: false,
          stage: 'READY_TO_START',
          ...(aaxNeedsActivationBytes ? { needsActivationBytes: true, checksum: aaxChecksum } : {})
        };
      }

      const startResult = await this.startPreparedJob(job.id);
      return {
        jobId: job.id,
        ...(startResult && typeof startResult === 'object' ? startResult : {})
      };
    } catch (error) {
      logger.error('audiobook:upload:failed', {
        jobId: job.id,
        originalName,
        tempFilePath,
        stagedRawDir,
        stagedRawFilePath,
        error: errorToMeta(error)
      });
      const updatePayload = {
        media_type: 'audiobook',
        job_kind: 'audiobook',
        status: 'ERROR',
        last_state: 'ERROR',
        end_time: nowIso(),
        error_message: error?.message || 'Audiobook-Upload fehlgeschlagen.'
      };
      if (stagedRawDir) {
        updatePayload.raw_path = stagedRawDir;
      }
      if (stagedRawFilePath) {
        updatePayload.encode_input_path = stagedRawFilePath;
      }
      await historyService.updateJob(job.id, updatePayload).catch(() => {});
      await historyService.appendLog(
        job.id,
        'SYSTEM',
        `Audiobook-Upload fehlgeschlagen: ${error?.message || 'unknown'}`
      ).catch(() => {});
      throw error;
    } finally {
      if (tempFilePath && fs.existsSync(tempFilePath)) {
        try {
          fs.rmSync(tempFilePath, { force: true });
        } catch (_error) {
          // best effort cleanup
        }
      }
    }
  }

  async startAudiobookWithConfig(jobId, config = {}) {
    const normalizedJobId = Number(jobId);
    if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
      const error = new Error('Ungültige Job-ID für Audiobook-Start.');
      error.statusCode = 400;
      throw error;
    }

    const job = await historyService.getJobById(normalizedJobId);
    if (!job) {
      const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }

    const encodePlan = this.safeParseJson(job.encode_plan_json);
    const makemkvInfo = this.safeParseJson(job.makemkv_info_json);
    const mediaProfile = this.resolveMediaProfileForJob(job, {
      encodePlan,
      makemkvInfo,
      mediaProfile: 'audiobook'
    });
    if (mediaProfile !== 'audiobook') {
      const error = new Error(`Job ${normalizedJobId} ist kein Audiobook-Job.`);
      error.statusCode = 400;
      throw error;
    }

    const format = audiobookService.normalizeOutputFormat(
      config?.format || encodePlan?.format || 'm4b'
    );
    const formatOptions = audiobookService.normalizeFormatOptions(
      format,
      config?.formatOptions || encodePlan?.formatOptions || {}
    );
    const metadata = buildAudiobookMetadataForJob(job, makemkvInfo, encodePlan);
    const chapters = audiobookService.normalizeChapterList(
      Array.isArray(config?.chapters) ? config.chapters : metadata.chapters,
      {
        durationMs: metadata.durationMs,
        fallbackTitle: metadata.title,
        createFallback: false
      }
    );
    const resolvedMetadata = {
      ...metadata,
      chapters
    };
    const nextMakemkvInfo = {
      ...(makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}),
      chapters,
      selectedMetadata: {
        ...(makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
          ? makemkvInfo.selectedMetadata
          : {}),
        ...resolvedMetadata,
        poster: metadata.poster || job.poster_url || null
      }
    };

    const nextEncodePlan = {
      ...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}),
      mediaProfile: 'audiobook',
      jobKind: 'audiobook',
      mode: 'audiobook',
      format,
      formatOptions,
      metadata: resolvedMetadata,
      reviewConfirmed: true
    };

    await historyService.updateJob(normalizedJobId, {
      media_type: 'audiobook',
      job_kind: 'audiobook',
      status: 'READY_TO_START',
      last_state: 'READY_TO_START',
      title: resolvedMetadata.title || job.title || job.detected_title || 'Audiobook',
      year: resolvedMetadata.year ?? job.year ?? null,
      makemkv_info_json: JSON.stringify(nextMakemkvInfo),
      encode_plan_json: JSON.stringify(nextEncodePlan),
      encode_review_confirmed: 1,
      error_message: null,
      handbrake_info_json: null,
      end_time: null
    });

    await historyService.appendLog(
      normalizedJobId,
      'USER_ACTION',
      `Audiobook-Encoding konfiguriert: Format ${format.toUpperCase()} | Kapitel: ${chapters.length || 0}`
    );

    const startResult = await this.startPreparedJob(normalizedJobId);
    return {
      jobId: normalizedJobId,
      ...(startResult && typeof startResult === 'object' ? startResult : {})
    };
  }

  async startAudiobookEncode(jobId, options = {}) {
    const immediate = Boolean(options?.immediate);
    if (!immediate) {
      return this.enqueueOrStartAction(
        QUEUE_ACTIONS.START_PREPARED,
        jobId,
        () => this.startAudiobookEncode(jobId, { ...options, immediate: true })
      );
    }

    this.ensureNotBusy('startAudiobookEncode', jobId);
    logger.info('audiobook:encode:start', { jobId });
    this.cancelRequestedByJob.delete(Number(jobId));

    const job = options?.preloadedJob || await historyService.getJobById(jobId);
    if (!job) {
      const error = new Error(`Job ${jobId} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }

    const encodePlan = this.safeParseJson(job.encode_plan_json);
    const makemkvInfo = this.safeParseJson(job.makemkv_info_json);
    const mediaProfile = this.resolveMediaProfileForJob(job, {
      encodePlan,
      makemkvInfo,
      mediaProfile: 'audiobook'
    });
    if (mediaProfile !== 'audiobook') {
      const error = new Error(`Job ${jobId} ist kein Audiobook-Job.`);
      error.statusCode = 400;
      throw error;
    }

    const settings = await settingsService.getEffectiveSettingsMap('audiobook');
    const resolvedRawPath = this.resolveCurrentRawPathForSettings(
      settings,
      'audiobook',
      job.raw_path
    ) || String(job.raw_path || '').trim() || null;

    let inputPath = String(
      job.encode_input_path
      || encodePlan?.encodeInputPath
      || makemkvInfo?.rawFilePath
      || ''
    ).trim();

    if ((!inputPath || !fs.existsSync(inputPath)) && resolvedRawPath) {
      inputPath = findPreferredRawInput(resolvedRawPath)?.path || '';
    }

    if (!inputPath) {
      const error = new Error('Audiobook-Encode nicht möglich: keine Input-Datei gefunden.');
      error.statusCode = 400;
      throw error;
    }
    if (!fs.existsSync(inputPath)) {
      const error = new Error(`Audiobook-Encode nicht möglich: Input-Datei fehlt (${inputPath}).`);
      error.statusCode = 400;
      throw error;
    }

    const {
      metadata,
      outputFormat,
      preferredFinalOutputPath,
      incompleteOutputPath,
      preferredChapterPlan,
      incompleteChapterPlan
    } = buildAudiobookOutputConfig(settings, job, makemkvInfo, encodePlan, jobId);
    const formatOptions = audiobookService.normalizeFormatOptions(
      outputFormat,
      encodePlan?.formatOptions || {}
    );
    const isSplitOutput = outputFormat !== 'm4b';
    const activeChapters = isSplitOutput
      ? (Array.isArray(incompleteChapterPlan?.chapters) ? incompleteChapterPlan.chapters : [])
      : (Array.isArray(metadata.chapters) ? metadata.chapters : []);

    if (isSplitOutput) {
      try {
        fs.rmSync(incompleteOutputPath, { recursive: true, force: true });
      } catch (_error) {
        // best effort cleanup
      }
      ensureDir(incompleteOutputPath);
    } else {
      ensureDir(path.dirname(incompleteOutputPath));
    }

    await historyService.resetProcessLog(jobId);
    await this.setState('ENCODING', {
      activeJobId: jobId,
      progress: 0,
      eta: null,
      statusText: `Audiobook-Encoding (${outputFormat.toUpperCase()})`,
      context: {
        jobId,
        mode: 'audiobook',
        mediaProfile: 'audiobook',
        inputPath,
        outputPath: incompleteOutputPath,
        format: outputFormat,
        formatOptions,
        chapters: activeChapters,
        selectedMetadata: {
          title: metadata.title || job.title || job.detected_title || null,
          year: metadata.year ?? job.year ?? null,
          author: metadata.author || null,
          narrator: metadata.narrator || null,
          description: metadata.description || null,
          series: metadata.series || null,
          part: metadata.part || null,
          chapters: activeChapters,
          durationMs: metadata.durationMs || 0,
          poster: metadata.poster || job.poster_url || null
        },
        audiobookConfig: {
          format: outputFormat,
          formatOptions
        },
        canRestartEncodeFromLastSettings: false,
        canRestartReviewFromRaw: false
      }
    });

    await historyService.updateJob(jobId, {
      media_type: 'audiobook',
      job_kind: 'audiobook',
      status: 'ENCODING',
      last_state: 'ENCODING',
      start_time: nowIso(),
      end_time: null,
      error_message: null,
      raw_path: resolvedRawPath || job.raw_path || null,
      output_path: incompleteOutputPath,
      encode_input_path: inputPath
    });

    await historyService.appendLog(
      jobId,
      'SYSTEM',
      isSplitOutput
        ? `Audiobook-Encoding gestartet: ${path.basename(inputPath)} -> ${outputFormat.toUpperCase()} | Kapitel-Dateien: ${activeChapters.length || 0}`
        : `Audiobook-Encoding gestartet: ${path.basename(inputPath)} -> ${outputFormat.toUpperCase()}`
    );

    void this.notifyPushover('encoding_started', {
      title: 'Ripster - Audiobook-Encoding gestartet',
      message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}`
    });

    // Activation Bytes für AAX-Dateien aus Cache lesen (vor dem Background-Start,
    // damit ein Fehler hier den HTTP-Request direkt abbricht).
    let encodeActivationBytes = null;
    if (path.extname(inputPath).toLowerCase() === '.aax') {
      try {
        const abResult = await activationBytesService.resolveActivationBytes(inputPath);
        encodeActivationBytes = abResult.activationBytes || null;
        if (!encodeActivationBytes) {
          throw new Error('Activation Bytes nicht im Cache – bitte zuerst über den Upload-Dialog eintragen');
        }
        logger.info('audiobook:encode:activation-bytes', { jobId, checksum: abResult.checksum });
      } catch (abError) {
        logger.error('audiobook:encode:activation-bytes-failed', { jobId, error: errorToMeta(abError) });
        throw abError;
      }
    }

    // Den eigentlichen ffmpeg-Encode im Hintergrund ausführen, damit der HTTP-Request
    // sofort nach dem Setzen des ENCODING-Status zurückkehrt (kein Blockieren bis FINISHED).
    void (async () => {
      let temporaryChapterMetadataPath = null;
      try {
      let ffmpegRunInfo = null;
      if (isSplitOutput) {
        const outputFiles = Array.isArray(incompleteChapterPlan?.outputFiles)
          ? incompleteChapterPlan.outputFiles
          : [];
        if (outputFiles.length === 0) {
          throw new Error('Keine Audiobook-Kapitel für den Encode verfügbar.');
        }

        const chapterRunInfos = [];
        for (let index = 0; index < outputFiles.length; index += 1) {
          const entry = outputFiles[index];
          const chapter = entry?.chapter || {};
          const chapterTitle = String(chapter?.title || `Kapitel ${index + 1}`).trim() || `Kapitel ${index + 1}`;
          const startPercent = Number(((index / outputFiles.length) * 100).toFixed(2));
          const endPercent = Number((((index + 1) / outputFiles.length) * 100).toFixed(2));

          ensureDir(path.dirname(entry.outputPath));
          await historyService.appendLog(
            jobId,
            'SYSTEM',
            `Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle} -> ${path.basename(entry.outputPath)}`
          );
          await this.updateProgress(
            'ENCODING',
            startPercent,
            null,
            `Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`,
            jobId,
            {
              contextPatch: {
                outputPath: incompleteOutputPath,
                completedChapterCount: index,
                currentChapter: {
                  index: index + 1,
                  total: outputFiles.length,
                  title: chapterTitle
                }
              }
            }
          );

          const ffmpegConfig = audiobookService.buildChapterEncodeCommand(
            settings?.ffmpeg_command || 'ffmpeg',
            inputPath,
            entry.outputPath,
            outputFormat,
            formatOptions,
            metadata,
            chapter,
            outputFiles.length,
            { activationBytes: encodeActivationBytes }
          );
          const baseParser = audiobookService.buildProgressParser(chapter?.durationMs || 0);
          const scaledParser = baseParser
            ? (line) => {
              const progress = baseParser(line);
              if (!progress || progress.percent == null) {
                return null;
              }
              const scaledPercent = startPercent + ((endPercent - startPercent) * (progress.percent / 100));
              return {
                percent: Number(scaledPercent.toFixed(2)),
                eta: null
              };
            }
            : null;

          logger.info('audiobook:encode:chapter-command', {
            jobId,
            chapterIndex: index + 1,
            cmd: ffmpegConfig.cmd,
            args: ffmpegConfig.args
          });
          const chapterRunInfo = await this.runCommand({
            jobId,
            stage: 'ENCODING',
            source: 'FFMPEG',
            cmd: ffmpegConfig.cmd,
            args: ffmpegConfig.args,
            parser: scaledParser
          });

          chapterRunInfos.push({
            ...chapterRunInfo,
            chapterIndex: index + 1,
            chapterTitle,
            outputPath: entry.outputPath
          });

          await this.updateProgress(
            'ENCODING',
            endPercent,
            null,
            `Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length} abgeschlossen`,
            jobId,
            {
              contextPatch: {
                completedChapterCount: index + 1,
                currentChapter: {
                  index: index + 1,
                  total: outputFiles.length,
                  title: chapterTitle
                }
              }
            }
          );
        }

        ffmpegRunInfo = {
          source: 'FFMPEG',
          stage: 'ENCODING',
          cmd: String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg',
          args: ['<split-by-chapter>'],
          startedAt: chapterRunInfos[0]?.startedAt || nowIso(),
          endedAt: chapterRunInfos[chapterRunInfos.length - 1]?.endedAt || nowIso(),
          durationMs: chapterRunInfos.reduce((sum, item) => sum + Number(item?.durationMs || 0), 0),
          status: 'SUCCESS',
          exitCode: 0,
          stdoutLines: chapterRunInfos.reduce((sum, item) => sum + Number(item?.stdoutLines || 0), 0),
          stderrLines: chapterRunInfos.reduce((sum, item) => sum + Number(item?.stderrLines || 0), 0),
          lastProgress: 100,
          eta: null,
          lastDetail: `${chapterRunInfos.length} Kapitel abgeschlossen`,
          highlights: chapterRunInfos.flatMap((item) => (Array.isArray(item?.highlights) ? item.highlights : [])).slice(0, 120),
          steps: chapterRunInfos
        };
      } else {
        temporaryChapterMetadataPath = path.join(path.dirname(inputPath), `.job-${jobId}-chapters.ffmeta`);
        fs.writeFileSync(
          temporaryChapterMetadataPath,
          audiobookService.buildChapterMetadataContent(activeChapters, metadata),
          'utf8'
        );

        const ffmpegConfig = audiobookService.buildEncodeCommand(
          settings?.ffmpeg_command || 'ffmpeg',
          inputPath,
          incompleteOutputPath,
          outputFormat,
          formatOptions,
          {
            chapterMetadataPath: temporaryChapterMetadataPath,
            metadata,
            activationBytes: encodeActivationBytes
          }
        );
        logger.info('audiobook:encode:command', { jobId, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args });
        ffmpegRunInfo = await this.runCommand({
          jobId,
          stage: 'ENCODING',
          source: 'FFMPEG',
          cmd: ffmpegConfig.cmd,
          args: ffmpegConfig.args,
          parser: audiobookService.buildProgressParser(metadata.durationMs)
        });
      }

      const outputFinalization = finalizeOutputPathForCompletedEncode(
        incompleteOutputPath,
        preferredFinalOutputPath
      );
      const finalizedOutputPath = outputFinalization.outputPath;
      let ownershipTarget = path.dirname(finalizedOutputPath);
      try {
        const finalizedStat = fs.statSync(finalizedOutputPath);
        if (finalizedStat.isDirectory()) {
          ownershipTarget = finalizedOutputPath;
        }
      } catch (_error) {
        ownershipTarget = path.dirname(finalizedOutputPath);
      }
      chownRecursive(ownershipTarget, settings?.movie_dir_owner);

      if (outputFinalization.outputPathWithTimestamp) {
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `Finaler Audiobook-Output existierte bereits. Zielpfad nummeriert: ${finalizedOutputPath}`
        );
      }

      await historyService.appendLog(
        jobId,
        'SYSTEM',
        `Audiobook-Output finalisiert: ${finalizedOutputPath}`
      );
      historyService.addJobOutputFolder(jobId, finalizedOutputPath).catch((e) => {
        logger.warn('audiobook:record-output-folder-failed', { jobId, error: e?.message });
      });

      const finalizedOutputFiles = isSplitOutput
        ? (Array.isArray(preferredChapterPlan?.outputFiles)
          ? preferredChapterPlan.outputFiles.map((entry) => {
            const relativePath = path.relative(preferredFinalOutputPath, entry.outputPath);
            return path.join(finalizedOutputPath, relativePath);
          })
          : [])
        : null;
      const ffmpegInfo = {
        ...ffmpegRunInfo,
        mode: isSplitOutput ? 'audiobook_encode_split' : 'audiobook_encode',
        format: outputFormat,
        formatOptions,
        metadata: {
          ...metadata,
          chapters: activeChapters
        },
        inputPath,
        outputPath: finalizedOutputPath,
        chapterCount: activeChapters.length,
        outputFiles: finalizedOutputFiles
      };

      await historyService.updateJob(jobId, {
        handbrake_info_json: JSON.stringify(ffmpegInfo),
        status: 'FINISHED',
        last_state: 'FINISHED',
        end_time: nowIso(),
        rip_successful: 1,
        raw_path: resolvedRawPath || job.raw_path || null,
        output_path: finalizedOutputPath,
        error_message: null
      });

      // Only switch the pipeline UI to FINISHED if this job is still the active one.
      if (Number(this.snapshot.activeJobId) === Number(jobId)) {
        await this.setState('FINISHED', {
          activeJobId: jobId,
          progress: 100,
          eta: null,
          statusText: 'Audiobook abgeschlossen',
          context: {
            jobId,
            mode: 'audiobook',
            mediaProfile: 'audiobook',
            outputPath: finalizedOutputPath
          }
        });
      } else {
        logger.info('audiobook:finished:background', { jobId, activeJobId: this.snapshot.activeJobId });
        void this.pumpQueue();
      }

      void this.notifyPushover('job_finished', {
        title: 'Ripster - Audiobook abgeschlossen',
        message: `${metadata.title || job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}`
      });

      setTimeout(async () => {
        if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) {
          await this.setState('IDLE', {
            finishingJobId: jobId,
            activeJobId: null,
            progress: 0,
            eta: null,
            statusText: 'Bereit',
            context: {}
          });
        }
      }, 3000);

      return {
        started: true,
        stage: 'ENCODING',
        outputPath: finalizedOutputPath
      };
    } catch (error) {
      if (temporaryChapterMetadataPath) {
        try {
          fs.rmSync(temporaryChapterMetadataPath, { force: true });
        } catch (_error) {
          // best effort cleanup
        }
      }
      if (error.runInfo && error.runInfo.source === 'FFMPEG') {
        await historyService.updateJob(jobId, {
          handbrake_info_json: JSON.stringify({
            ...error.runInfo,
            mode: isSplitOutput ? 'audiobook_encode_split' : 'audiobook_encode',
            format: outputFormat,
            formatOptions,
            inputPath
          })
        }).catch(() => {});
      }
      logger.error('audiobook:encode:failed', { jobId, error: errorToMeta(error) });
      await this.failJob(jobId, 'ENCODING', error);
    } finally {
      if (temporaryChapterMetadataPath) {
        try {
          fs.rmSync(temporaryChapterMetadataPath, { force: true });
        } catch (_error) {
          // best effort cleanup
        }
      }
    }
    })();

    return { started: true, stage: 'ENCODING', outputPath: incompleteOutputPath };
  }

  // ── Converter Pipeline ───────────────────────────────────────────────────────

  async startConverterEncode(jobId, options = {}) {
    const immediate = Boolean(options?.immediate);
    if (!immediate) {
      const queuedJob = options?.preloadedJob || await historyService.getJobById(jobId);
      const queuedPlan = this.safeParseJson(queuedJob?.encode_plan_json) || {};
      const queuedConverterMediaType = String(queuedPlan?.converterMediaType || '').trim().toLowerCase();
      const queuePoolType = queuedConverterMediaType === 'audio' ? 'audio' : 'film';
      return this.enqueueOrStartAction(
        QUEUE_ACTIONS.START_PREPARED,
        jobId,
        () => this.startConverterEncode(jobId, { ...options, immediate: true }),
        { poolType: queuePoolType }
      );
    }

    this.ensureNotBusy('startConverterEncode', jobId);
    logger.info('converter:encode:start', { jobId });
    this.cancelRequestedByJob.delete(Number(jobId));

    const job = options?.preloadedJob || await historyService.getJobById(jobId);
    if (!job) {
      const error = new Error(`Job ${jobId} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }

    const encodePlan = this.safeParseJson(job.encode_plan_json) || {};
    const inputPath = encodePlan.inputPath || job.raw_path;

    if (!inputPath || !fs.existsSync(inputPath)) {
      const error = new Error(`Converter-Eingabedatei nicht gefunden: ${inputPath}`);
      error.statusCode = 404;
      throw error;
    }

    let outputPath = encodePlan.outputPath || null;
    let outputDir = encodePlan.outputDir || null;
    let outputFormat = String(encodePlan.outputFormat || '').trim().toLowerCase() || null;

    if (!outputPath && !outputDir) {
      const converterSettings = await settingsService.getSettingsMap();
      const converterMediaType = String(encodePlan.converterMediaType || 'video').trim().toLowerCase();
      outputFormat = outputFormat || (converterMediaType === 'audio' ? 'flac' : 'mkv');

      const parseTemplateSegments = (template) => String(template || '')
        .replace(/\\/g, '/')
        .replace(/\/+/g, '/')
        .replace(/^\/+|\/+$/g, '')
        .split('/')
        .map((segment) => String(segment || '').trim())
        .filter(Boolean);
      const renderTemplateSegment = (segment, values, fallback = 'unknown') => {
        const rendered = renderTemplate(segment, values);
        return sanitizeFileName(rendered) || fallback;
      };

      if (converterMediaType === 'video' || converterMediaType === 'iso') {
        const movieDir = String(
          converterSettings?.converter_movie_dir || require('../config').defaultConverterMovieDir || ''
        ).trim();
        const baseName = sanitizeFileName(path.basename(inputPath, path.extname(inputPath))) || 'output';
        const title = String(
          encodePlan?.metadata?.title || job.title || job.detected_title || baseName
        ).trim() || baseName;
        const safeTitle = sanitizeFileName(title) || baseName;
        const yearValue = Number.isFinite(Number(encodePlan?.metadata?.year))
          ? Math.trunc(Number(encodePlan.metadata.year))
          : (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : new Date().getFullYear());
        const videoTemplateRaw = String(
          converterSettings?.converter_output_template_video || '{title}'
        ).trim() || '{title}';
        const videoSegmentsRaw = parseTemplateSegments(videoTemplateRaw);
        const videoSegments = (videoSegmentsRaw.length > 0 ? videoSegmentsRaw : ['{title}'])
          .map((segment) => renderTemplateSegment(segment, {
            title: safeTitle,
            year: yearValue
          }, safeTitle));
        const videoBaseName = videoSegments[videoSegments.length - 1] || safeTitle;
        const videoFolders = videoSegments.slice(0, -1);
        outputPath = path.join(movieDir, ...(videoFolders.length > 0 ? videoFolders : []), `${videoBaseName}.${outputFormat}`);
      }

      encodePlan.outputFormat = outputFormat;
      encodePlan.outputPath = outputPath || null;
      encodePlan.outputDir = outputDir || null;
      await historyService.updateJob(jobId, {
        encode_plan_json: JSON.stringify(encodePlan),
        output_path: outputPath || outputDir || null
      });
    }

    await historyService.resetProcessLog(jobId);
    await this.setState('ANALYZING', {
      activeJobId: jobId,
      progress: 0,
      eta: null,
      statusText: 'Converter: Analyse läuft …',
      context: {
        jobId,
        mode: 'converter',
        mediaProfile: 'converter',
        inputPath,
        converterMediaType: encodePlan.converterMediaType || 'video'
      }
    });

    await historyService.updateJob(jobId, {
      status: 'ANALYZING',
      last_state: 'ANALYZING',
      start_time: nowIso(),
      end_time: null,
      error_message: null
    });

    void (async () => {
      try {
        const { ConverterPlugin } = require('../plugins/ConverterPlugin');
        const converterPlugin = new ConverterPlugin();
        const appendConverterProcessLine = (source, line, stream = 'stderr') => {
          const normalizedSource = String(source || '').trim().toUpperCase() || 'PROCESS';
          const rawLine = String(line || '').trim();
          if (!rawLine) {
            return;
          }
          const streamPrefix = stream === 'stdout' ? '[OUT] ' : '';
          void historyService.appendLog(jobId, normalizedSource, `${streamPrefix}${rawLine}`);
        };

        const analyzeCtx = await this.buildPluginContext('converter', {
          jobId,
          filePath: inputPath,
          encodePlan,
          emitProgress: (pct, statusText) => {
            void this.updateProgress('ANALYZING', pct, null, statusText, jobId);
          },
          appendLogLine: appendConverterProcessLine
        });
        await converterPlugin.analyze(inputPath, job, analyzeCtx);

        if (this.cancelRequestedByJob.has(Number(jobId))) {
          const cancelErr = new Error('Job wurde vom Benutzer abgebrochen.');
          cancelErr.runInfo = { status: 'CANCELLED' };
          throw cancelErr;
        }

        await this.setState('ENCODING', {
          activeJobId: jobId,
          progress: 0,
          eta: null,
          statusText: 'Converter: Encoding läuft …',
          context: {
            jobId,
            mode: 'converter',
            mediaProfile: 'converter',
            inputPath,
            outputPath: outputPath || outputDir,
            converterMediaType: encodePlan.converterMediaType || 'video'
          }
        });

        await historyService.updateJob(jobId, {
          status: 'ENCODING',
          last_state: 'ENCODING'
        });

        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `Converter-Encoding gestartet: ${path.basename(inputPath)} → ${encodePlan.converterMediaType || 'video'} / ${encodePlan.outputFormat || '?'}`
        );

        void this.notifyPushover('encoding_started', {
          title: 'Ripster - Converter gestartet',
          message: `${job.title || job.detected_title || `Job #${jobId}`} → ${outputPath || outputDir || '?'}`
        });

        const encodeCtx = await this.buildPluginContext('converter', {
          jobId,
          inputPath,
          outputPath,
          outputDir,
          encodePlan,
          isCancelled: () => this.cancelRequestedByJob.has(Number(jobId)),
          onProcessHandle: (handle) => {
            if (handle) {
              this.activeProcesses.set(Number(jobId), handle);
            }
          },
          emitProgress: (pct, statusText, eta) => {
            void this.updateProgress('ENCODING', pct, eta ?? null, statusText, jobId);
          },
          appendLogLine: appendConverterProcessLine
        });

        await converterPlugin.encode(job, encodeCtx);
        this.activeProcesses.delete(Number(jobId));

        const finalOutputPath = outputPath || outputDir || null;

        // Eigentümer der Ausgabedateien setzen
        if (finalOutputPath) {
          const converterSettings = await settingsService.getSettingsMap();
          const converterMediaType = encodePlan.converterMediaType || 'video';
          const outputOwner = converterMediaType === 'audio'
            ? String(converterSettings?.converter_audio_dir_owner || converterSettings?.converter_movie_dir_owner || '').trim()
            : String(converterSettings?.converter_movie_dir_owner || '').trim();
          if (outputOwner) {
            chownRecursive(finalOutputPath, outputOwner);
          }
        }

        await historyService.updateJob(jobId, {
          status: 'FINISHED',
          last_state: 'FINISHED',
          end_time: nowIso(),
          rip_successful: 1,
          output_path: finalOutputPath,
          error_message: null
        });

        // Datei-Zuweisungen im Explorer freigeben
        try {
          await require('./converterScanService').clearAssignmentsForJob(jobId);
        } catch (_clearErr) {
          // nicht kritisch
        }

        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `Converter-Encoding abgeschlossen: ${finalOutputPath}`
        );

        if (Number(this.snapshot.activeJobId) === Number(jobId)) {
          await this.setState('FINISHED', {
            activeJobId: jobId,
            progress: 100,
            eta: null,
            statusText: 'Converter abgeschlossen',
            context: {
              jobId,
              mode: 'converter',
              mediaProfile: 'converter',
              outputPath: finalOutputPath
            }
          });
        } else {
          void this.pumpQueue();
        }

        void this.notifyPushover('job_finished', {
          title: 'Ripster - Converter abgeschlossen',
          message: `${job.title || job.detected_title || `Job #${jobId}`} → ${finalOutputPath}`
        });

        setTimeout(async () => {
          if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) {
            await this.setState('IDLE', {
              finishingJobId: jobId,
              activeJobId: null,
              progress: 0,
              eta: null,
              statusText: 'Bereit',
              context: {}
            });
          }
        }, 3000);
      } catch (error) {
        this.activeProcesses.delete(Number(jobId));
        logger.error('converter:encode:failed', { jobId, error: errorToMeta(error) });
        // Datei-Zuweisungen im Explorer freigeben (auch bei Abbruch/Fehler)
        try {
          await require('./converterScanService').clearAssignmentsForJob(jobId);
        } catch (_clearErr) {
          // nicht kritisch
        }
        await this.failJob(jobId, 'ENCODING', error);
      }
    })();

    return { started: true, stage: 'ANALYZING', outputPath: outputPath || outputDir };
  }

  // ── CD Pipeline ─────────────────────────────────────────────────────────────

  async analyzeCd(device, options = {}) {
    const devicePath = String(device?.path || '').trim();
    const detectedTitle = String(
      device?.discLabel || device?.label || 'Audio CD'
    ).trim();

    logger.info('cd:analyze:start', { devicePath, detectedTitle });

    const job = await historyService.createJob({
      discDevice: devicePath,
      status: 'CD_METADATA_SELECTION',
      detectedTitle,
      jobKind: 'cd'
    });

    try {
      const settings = await settingsService.getSettingsMap();
      let effectiveDetectedTitle = detectedTitle;
      let cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';

      // Read TOC
      this._setCdDriveState(devicePath, {
        state: 'CD_ANALYZING',
        jobId: job.id,
        device,
        progress: 0,
        eta: null,
        statusText: 'CD wird analysiert …',
        context: { jobId: job.id, device, mediaProfile: 'cd' }
      });

      let tracks = null;
      let pluginExecution = null;
      const analyzePlugin = options?.plugin && typeof options.plugin === 'object'
        ? options.plugin
        : null;
      if (analyzePlugin) {
        const pluginContext = await this.buildPluginContext(analyzePlugin.id, {
          discInfo: device,
          jobId: job.id
        });
        try {
          const pluginResult = await analyzePlugin.analyze(devicePath, job, pluginContext);
          pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution());
          if (Array.isArray(pluginResult?.tracks) && pluginResult.tracks.length > 0) {
            tracks = pluginResult.tracks;
          }
          const pluginCmd = String(pluginResult?.cdparanoiaCmd || '').trim();
          if (pluginCmd) {
            cdparanoiaCmd = pluginCmd;
          }
          const pluginDetectedTitle = String(pluginResult?.detectedTitle || '').trim();
          if (pluginDetectedTitle) {
            effectiveDetectedTitle = pluginDetectedTitle;
          }
          logger.info('plugin:analyze:used', {
            jobId: job.id,
            pluginId: analyzePlugin.id,
            mediaProfile: 'cd'
          });
        } catch (error) {
          pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution());
          logger.warn('plugin:analyze:cd:fallback-legacy', {
            jobId: job.id,
            pluginId: analyzePlugin.id,
            error: errorToMeta(error)
          });
        }
      }

      if (!Array.isArray(tracks) || tracks.length === 0) {
        tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd);
      }
      logger.info('cd:analyze:toc', { jobId: job.id, trackCount: tracks.length });
      if (!tracks.length) {
        const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).');
        error.statusCode = 400;
        throw error;
      }

      const cdInfo = {
        phase: 'PREPARE',
        mediaProfile: 'cd',
        preparedAt: nowIso(),
        cdparanoiaCmd,
        tracks,
        detectedTitle: effectiveDetectedTitle
      };
      const persistedCdInfo = this.withPluginExecutionMeta(cdInfo, pluginExecution);

      await historyService.updateJob(job.id, {
        status: 'CD_METADATA_SELECTION',
        last_state: 'CD_METADATA_SELECTION',
        detected_title: effectiveDetectedTitle,
        makemkv_info_json: JSON.stringify(persistedCdInfo)
      });
      await historyService.appendLog(
        job.id,
        'SYSTEM',
        `CD analysiert: ${tracks.length} Track(s) gefunden.`
      );

      const previewTrackPos = tracks[0]?.position ? Number(tracks[0].position) : null;
      const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} <temp>/trackNN.cdda.wav`;
      this._setCdDriveState(devicePath, {
        state: 'CD_METADATA_SELECTION',
        jobId: job.id,
        device,
        progress: 0,
        eta: null,
        statusText: 'CD-Metadaten auswählen',
        context: {
          jobId: job.id,
          device,
          mediaProfile: 'cd',
          devicePath,
          cdparanoiaCmd,
          cdparanoiaCommandPreview,
          detectedTitle: effectiveDetectedTitle,
          tracks,
          ...(pluginExecution ? { pluginExecution } : {})
        }
      });

      return { jobId: job.id, detectedTitle: effectiveDetectedTitle, tracks };
    } catch (error) {
      logger.error('cd:analyze:failed', { jobId: job.id, error: errorToMeta(error) });
      await this.failJob(job.id, 'CD_ANALYZING', error);
      throw error;
    }
  }

  async searchMusicBrainz(query) {
    logger.info('musicbrainz:search', { query });
    const results = await musicBrainzService.searchByTitle(query);
    logger.info('musicbrainz:search:done', { query, count: results.length });
    return results;
  }

  async getMusicBrainzReleaseById(mbId) {
    const id = String(mbId || '').trim();
    if (!id) {
      const error = new Error('mbId fehlt.');
      error.statusCode = 400;
      throw error;
    }
    logger.info('musicbrainz:get-by-id', { mbId: id });
    const release = await musicBrainzService.getReleaseById(id);
    if (!release) {
      const error = new Error(`MusicBrainz Release ${id} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }
    logger.info('musicbrainz:get-by-id:done', { mbId: id, trackCount: Array.isArray(release.tracks) ? release.tracks.length : 0 });
    return release;
  }

  async selectCdMetadata(payload) {
    const {
      jobId,
      title,
      artist,
      year,
      mbId,
      coverUrl,
      tracks: selectedTracks
    } = payload || {};

    if (!jobId) {
      const error = new Error('jobId fehlt.');
      error.statusCode = 400;
      throw error;
    }

    const job = await historyService.getJobById(jobId);
    if (!job) {
      const error = new Error(`Job ${jobId} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }

    logger.info('cd:select-metadata', { jobId, title, artist, year, mbId });

    const cdInfo = this.safeParseJson(job.makemkv_info_json) || {};

    // Merge track metadata from selection into existing TOC tracks
    const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : [];
    const mergedTracks = tocTracks.map((t) => {
      const selected = Array.isArray(selectedTracks)
        ? selectedTracks.find((st) => Number(st.position) === Number(t.position))
        : null;
      const resolvedTitle = normalizeCdTrackText(selected?.title) || t.title || `Track ${t.position}`;
      const resolvedArtist = normalizeCdTrackText(selected?.artist) || t.artist || artist || null;
      return {
        ...t,
        title: resolvedTitle,
        artist: resolvedArtist,
        selected: selected ? Boolean(selected.selected) : true
      };
    });

    const updatedCdInfo = {
      ...cdInfo,
      tracks: mergedTracks,
      selectedMetadata: { title, artist, year, mbId, coverUrl }
    };

    await historyService.updateJob(jobId, {
      title: title || null,
      year: year ? Number(year) : null,
      poster_url: coverUrl || null,
      status: 'CD_READY_TO_RIP',
      last_state: 'CD_READY_TO_RIP',
      makemkv_info_json: JSON.stringify(updatedCdInfo)
    });

    // Bild in Cache laden (async, blockiert nicht)
    if (coverUrl) {
      historyService.queuePosterCache(jobId, coverUrl, {
        source: 'Coverart',
        logFailures: true
      });
    }

    await historyService.appendLog(
      jobId,
      'SYSTEM',
      `Metadaten gesetzt: "${title}" (${artist || '-'}, ${year || '-'}).`
    );

    const resolvedDevicePath = String(job?.disc_device || '').trim() || null;
    const resolvedCdparanoiaCmd = String(cdInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia';
    const previewTrackPos = mergedTracks[0]?.position ? Number(mergedTracks[0].position) : null;
    const cdparanoiaCommandPreview = `${resolvedCdparanoiaCmd} -d ${resolvedDevicePath || '<device>'} ${previewTrackPos || '<trackNr>'} <temp>/trackNN.cdda.wav`;
    const existingDrive = resolvedDevicePath ? this.cdDrives.get(resolvedDevicePath) : null;
    if (resolvedDevicePath) {
      this._setCdDriveState(resolvedDevicePath, {
        state: 'CD_READY_TO_RIP',
        jobId,
        progress: 0,
        eta: null,
        statusText: 'CD bereit zum Rippen',
        context: {
          ...(existingDrive?.context || {}),
          jobId,
          mediaProfile: 'cd',
          tracks: mergedTracks,
          selectedMetadata: { title, artist, year, mbId, coverUrl },
          devicePath: resolvedDevicePath,
          cdparanoiaCmd: resolvedCdparanoiaCmd,
          cdparanoiaCommandPreview
        }
      });
    } else {
      // No real device — update virtual drive if present (orphan CD job)
      const virtualDrivePath = `__virtual__${jobId}`;
      const existingVirtualDrive = this.cdDrives.get(virtualDrivePath);
      if (existingVirtualDrive) {
        this._setCdDriveState(virtualDrivePath, {
          state: 'CD_READY_TO_RIP',
          jobId,
          progress: 0,
          eta: null,
          statusText: 'CD bereit zum Encodieren',
          context: {
            ...(existingVirtualDrive.context || {}),
            jobId,
            mediaProfile: 'cd',
            tracks: mergedTracks,
            selectedMetadata: { title, artist, year, mbId, coverUrl },
            devicePath: null,
            virtualDrivePath,
            skipRip: true,
            cdparanoiaCmd: resolvedCdparanoiaCmd
          }
        });
        const snapshotPayload = this.getSnapshot();
        wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload);
        this.emit('stateChanged', snapshotPayload);
      }
    }

    const canAutoStartRawRip = Boolean(
      resolvedDevicePath
      && !String(resolvedDevicePath).startsWith('__virtual__')
      && Number(job?.rip_successful || 0) !== 1
    );
    if (canAutoStartRawRip) {
      const autoRawRipConfig = {
        selectedTracks: mergedTracks
          .filter((track) => track?.selected !== false)
          .map((track) => Number(track?.position))
          .filter((value) => Number.isFinite(value) && value > 0),
        tracks: mergedTracks,
        metadata: { title, artist, year, mbId, coverUrl },
        skipEncode: true
      };
      await historyService.appendLog(
        jobId,
        'SYSTEM',
        'Metadaten bestätigt. Starte automatisch RAW-Rip (Encode-Auswahl folgt danach).'
      );
      this.startCdRip(Number(jobId), autoRawRipConfig).then((result) => {
        logger.info('cd:auto-raw-rip:started', {
          jobId: Number(jobId),
          replacedSourceJob: Boolean(result?.replacedSourceJob),
          replacementJobId: Number(result?.jobId || 0) || null
        });
      }).catch((error) => {
        logger.error('cd:auto-raw-rip:failed', {
          jobId: Number(jobId),
          error: errorToMeta(error)
        });
        historyService.appendLog(
          Number(jobId),
          'SYSTEM',
          `Automatischer RAW-Rip konnte nicht gestartet werden: ${error?.message || 'unknown'}`
        ).catch(() => {});
      });
    }

    return historyService.getJobById(jobId);
  }

  async renameJobFolders(jobId) {
    const job = await historyService.getJobById(jobId);
    if (!job) {
      return { renamed: [] };
    }

    const renamed = [];
    const mediaProfile = this.resolveMediaProfileForJob(job);
    const isCd = mediaProfile === 'cd';
    const isAudiobook = mediaProfile === 'audiobook';
    const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
    const mkInfo = this.safeParseJson(job.makemkv_info_json) || {};
    const encodePlan = this.safeParseJson(job.encode_plan_json) || {};

    // Rename raw folder
    const currentRawPath = job.raw_path ? path.resolve(job.raw_path) : null;
    if (currentRawPath && fs.existsSync(currentRawPath)) {
      let newRawPath;
      if (isAudiobook) {
        const audiobookMeta = buildAudiobookMetadataForJob(job, mkInfo, encodePlan);
        const rawTemplate = String(
          settings?.audiobook_raw_template || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE
        ).trim() || audiobookService.DEFAULT_AUDIOBOOK_RAW_TEMPLATE;
        const currentInputPath = findPreferredRawInput(currentRawPath)?.path
          || String(job.encode_input_path || mkInfo?.rawFilePath || '').trim()
          || 'input.aax';
        const nextRaw = audiobookService.buildRawStoragePaths(
          audiobookMeta,
          jobId,
          settings?.raw_dir || settingsService.DEFAULT_AUDIOBOOK_RAW_DIR,
          rawTemplate,
          path.basename(currentInputPath)
        );
        newRawPath = nextRaw.rawDir;
      } else {
        const rawBaseDir = path.dirname(currentRawPath);
        const newMetadataBase = buildRawMetadataBase({
          title: job.title || job.detected_title || null,
          year: job.year || null
        }, jobId);
        const currentState = resolveRawFolderStateFromPath(currentRawPath);
        const newRawDirName = buildRawDirName(newMetadataBase, jobId, { state: currentState });
        newRawPath = path.join(rawBaseDir, newRawDirName);
      }

      if (normalizeComparablePath(currentRawPath) !== normalizeComparablePath(newRawPath) && !fs.existsSync(newRawPath)) {
        try {
          fs.mkdirSync(path.dirname(newRawPath), { recursive: true });
          fs.renameSync(currentRawPath, newRawPath);
          const updatePayload = { raw_path: newRawPath };
          if (isAudiobook) {
            const previousInputPath = String(job.encode_input_path || mkInfo?.rawFilePath || '').trim();
            if (previousInputPath && previousInputPath.startsWith(`${currentRawPath}${path.sep}`)) {
              updatePayload.encode_input_path = path.join(newRawPath, path.basename(previousInputPath));
            }
          }
          await historyService.updateJob(jobId, updatePayload);
          renamed.push({ type: 'raw', from: currentRawPath, to: newRawPath });
          logger.info('rename-job-folders:raw', { jobId, from: currentRawPath, to: newRawPath });
        } catch (err) {
          logger.warn('rename-job-folders:raw-failed', { jobId, error: err.message });
        }
      }
    }

    // Rename output file (film) or output directory (CD)
    const currentOutputPath = job.output_path ? path.resolve(job.output_path) : null;
    if (currentOutputPath && fs.existsSync(currentOutputPath)) {
      try {
        if (isCd) {
          const cdInfo = this.safeParseJson(job.makemkv_info_json) || {};
          const selectedMeta = cdInfo.selectedMetadata && typeof cdInfo.selectedMetadata === 'object'
            ? cdInfo.selectedMetadata
            : {};
          const cdMeta = {
            artist: String(selectedMeta.artist || '').trim() || String(job.title || '').trim() || null,
            album: String(job.title || selectedMeta.title || '').trim() || null,
            year: job.year || selectedMeta.year || null
          };
          const cdOutputBaseDir = String(settings.movie_dir || '').trim();
          const cdOutputTemplate = String(settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE).trim();
          if (cdOutputBaseDir) {
            const newCdOutputDir = cdRipService.buildOutputDir(cdMeta, cdOutputBaseDir, cdOutputTemplate);
            if (normalizeComparablePath(currentOutputPath) !== normalizeComparablePath(newCdOutputDir) && !fs.existsSync(newCdOutputDir)) {
              fs.mkdirSync(path.dirname(newCdOutputDir), { recursive: true });
              fs.renameSync(currentOutputPath, newCdOutputDir);
              await historyService.updateJob(jobId, { output_path: newCdOutputDir });
              renamed.push({ type: 'output', from: currentOutputPath, to: newCdOutputDir });
              logger.info('rename-job-folders:cd-output', { jobId, from: currentOutputPath, to: newCdOutputDir });
            }
          }
        } else {
          const newOutputPath = isAudiobook
            ? buildAudiobookOutputConfig(settings, job, mkInfo, encodePlan, jobId).preferredFinalOutputPath
            : buildFinalOutputPathFromJob(settings, job, jobId);
          if (normalizeComparablePath(currentOutputPath) !== normalizeComparablePath(newOutputPath) && !fs.existsSync(newOutputPath)) {
            fs.mkdirSync(path.dirname(newOutputPath), { recursive: true });
            moveFileWithFallback(currentOutputPath, newOutputPath);
            try {
              const oldParentDir = path.dirname(currentOutputPath);
              if (fs.readdirSync(oldParentDir).length === 0) {
                fs.rmdirSync(oldParentDir);
              }
            } catch (_ignoreErr) {}
            await historyService.updateJob(jobId, { output_path: newOutputPath });
            renamed.push({ type: 'output', from: currentOutputPath, to: newOutputPath });
            logger.info(isAudiobook ? 'rename-job-folders:audiobook-output' : 'rename-job-folders:film-output', {
              jobId,
              from: currentOutputPath,
              to: newOutputPath
            });
          }
        }
      } catch (err) {
        logger.warn('rename-job-folders:output-failed', { jobId, isCd, error: err.message });
      }
    }

    return { renamed };
  }

  async startCdRip(jobId, ripConfig) {
    const resolvedCdRipJob = await this.resolveExistingJobForAction(jobId, 'start_cd_rip');
    jobId = resolvedCdRipJob.resolvedJobId;
    this.ensureNotBusy('startCdRip', jobId);
    this.cancelRequestedByJob.delete(Number(jobId));

    const sourceJob = resolvedCdRipJob.job || await historyService.getJobById(jobId);

    let activeJobId = Number(jobId);
    let activeJob = sourceJob;
    const cdSettings = await settingsService.getEffectiveSettingsMap('cd');
    const sourceResolvedRawPath = this.resolveCurrentRawPathForSettings(
      cdSettings,
      'cd',
      sourceJob.raw_path
    );
    const sourceHasSuccessfulRawRip = Number(sourceJob?.rip_successful || 0) === 1
      && Boolean(sourceResolvedRawPath);
    const sourceStatus = String(sourceJob.status || sourceJob.last_state || '').trim().toUpperCase();
    const shouldReplaceSourceJob = sourceStatus === 'CANCELLED' || sourceStatus === 'ERROR';
    if (shouldReplaceSourceJob) {
      const replacementJob = await historyService.createJob({
        discDevice: sourceJob.disc_device || null,
        status: 'CD_READY_TO_RIP',
        detectedTitle: sourceJob.detected_title || sourceJob.title || null,
        jobKind: this.resolveJobKindForJob(sourceJob, {
          encodePlan: this.safeParseJson(sourceJob.encode_plan_json)
        }) || 'cd'
      });
      const replacementJobId = Number(replacementJob?.id || 0);
      if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) {
        throw new Error('CD-Neustart fehlgeschlagen: neuer Job konnte nicht erstellt werden.');
      }

      let replacementRawPath = sourceHasSuccessfulRawRip ? sourceResolvedRawPath : null;
      if (replacementRawPath) {
        replacementRawPath = await this.alignRawFolderJobId(replacementRawPath, replacementJobId);
      }

      await historyService.updateJob(replacementJobId, {
        parent_job_id: Number(jobId),
        title: sourceJob.title || null,
        year: sourceJob.year ?? null,
        imdb_id: sourceJob.imdb_id || null,
        poster_url: sourceJob.poster_url || null,
        omdb_json: sourceJob.omdb_json || null,
        selected_from_omdb: Number(sourceJob.selected_from_omdb || 0),
        status: 'CD_READY_TO_RIP',
        last_state: 'CD_READY_TO_RIP',
        error_message: null,
        end_time: null,
        output_path: null,
        disc_device: sourceJob.disc_device || null,
        raw_path: replacementRawPath,
        rip_successful: sourceHasSuccessfulRawRip ? 1 : 0,
        makemkv_info_json: sourceJob.makemkv_info_json || null,
        handbrake_info_json: null,
        mediainfo_info_json: null,
        encode_plan_json: sourceHasSuccessfulRawRip ? (sourceJob.encode_plan_json || null) : null,
        encode_input_path: null,
        encode_review_confirmed: 0
      });
      // Thumbnail für neuen Job kopieren, damit er nicht auf die Datei des alten Jobs angewiesen ist
      if (thumbnailService.isLocalUrl(sourceJob.poster_url)) {
        const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId);
        if (copiedUrl) {
          await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {});
        }
      }

      await historyService.appendLog(
        replacementJobId,
        'USER_ACTION',
        `CD-Rip Neustart aus Job #${jobId}. Alter Job wurde durch neuen Job ersetzt.`
      );
      this._releaseDriveLockForJob(jobId, { reason: 'cd_restart_replaced' });
      await historyService.retireJobInFavorOf(jobId, replacementJobId, {
        reason: 'cd_restart_rip'
      });

      activeJobId = replacementJobId;
      activeJob = await historyService.getJobById(replacementJobId);
      this.cancelRequestedByJob.delete(replacementJobId);
      if (!activeJob) {
        throw new Error(`CD-Neustart fehlgeschlagen: neuer Job #${replacementJobId} konnte nicht geladen werden.`);
      }
    }

    const cdInfo = this.safeParseJson(activeJob.makemkv_info_json) || {};
    const devicePath = String(activeJob.disc_device || '').trim();
    const resolvedExistingRawPath = this.resolveCurrentRawPathForSettings(
      cdSettings,
      'cd',
      activeJob.raw_path
    );
    const hasSuccessfulRawRip = Number(activeJob?.rip_successful || 0) === 1
      && Boolean(resolvedExistingRawPath);
    const skipRip = Boolean(ripConfig?.skipRip || cdInfo?.skipRip || hasSuccessfulRawRip);
    const skipEncode = Boolean(ripConfig?.skipEncode);
    const resolvedCdRipPlugin = await this.resolveAnalyzePlugin({ mediaProfile: 'cd' }, 'rip').catch(() => null);
    const cdRipPlugin = resolvedCdRipPlugin?.id === 'cd' ? resolvedCdRipPlugin : null;

    if (!devicePath && !skipRip) {
      const error = new Error('Kein CD-Laufwerk bekannt.');
      error.statusCode = 400;
      throw error;
    }

    // For skipRip (orphan CD): look up virtual drive path or derive it
    const effectiveDevicePath = devicePath || (
      this._getCdDriveByJobId(activeJobId)?.devicePath || `__virtual__${activeJobId}`
    );

    const format = String(ripConfig?.format || 'flac').trim().toLowerCase();
    const formatOptions = ripConfig?.formatOptions || {};
    const normalizeTrackPosition = (value) => {
      const parsed = Number(value);
      if (!Number.isFinite(parsed) || parsed <= 0) {
        return null;
      }
      return Math.trunc(parsed);
    };
    const selectedTrackPositions = Array.isArray(ripConfig?.selectedTracks)
      ? ripConfig.selectedTracks
        .map(normalizeTrackPosition)
        .filter((value) => Number.isFinite(value) && value > 0)
      : [];
    const normalizeOptionalYear = (value) => {
      if (value === null || value === undefined || String(value).trim() === '') {
        return null;
      }
      const parsed = Number(value);
      if (!Number.isFinite(parsed) || parsed <= 0) {
        return null;
      }
      return Math.trunc(parsed);
    };

    const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : [];
    const incomingTracks = Array.isArray(ripConfig?.tracks) ? ripConfig.tracks : [];
    const incomingByPosition = new Map();
    for (const incoming of incomingTracks) {
      const position = normalizeTrackPosition(incoming?.position);
      if (!position) {
        continue;
      }
      incomingByPosition.set(position, incoming);
    }
    const selectedMeta = cdInfo.selectedMetadata || {};
    const incomingMeta = ripConfig?.metadata && typeof ripConfig.metadata === 'object'
      ? ripConfig.metadata
      : {};
    const effectiveSelectedMeta = {
      ...selectedMeta,
      title: normalizeCdTrackText(incomingMeta?.title)
        || normalizeCdTrackText(selectedMeta?.title)
        || normalizeCdTrackText(activeJob?.title)
        || normalizeCdTrackText(cdInfo?.detectedTitle)
        || 'Audio CD',
      artist: normalizeCdTrackText(incomingMeta?.artist)
        || normalizeCdTrackText(selectedMeta?.artist)
        || null,
      year: normalizeOptionalYear(incomingMeta?.year)
        ?? normalizeOptionalYear(selectedMeta?.year)
        ?? normalizeOptionalYear(activeJob?.year)
        ?? null
    };
    const mergedTracks = tocTracks.map((track) => {
      const position = normalizeTrackPosition(track?.position);
      if (!position) {
        return null;
      }
      const incoming = incomingByPosition.get(position) || null;
      const fallbackTitle = normalizeCdTrackText(track?.title) || `Track ${position}`;
      const fallbackArtist = normalizeCdTrackText(track?.artist) || normalizeCdTrackText(effectiveSelectedMeta?.artist) || '';
      const title = normalizeCdTrackText(incoming?.title) || fallbackTitle;
      const artist = normalizeCdTrackText(incoming?.artist) || fallbackArtist || null;
      const selected = incoming
        ? Boolean(incoming?.selected)
        : (track?.selected !== false);
      return {
        ...track,
        position,
        title,
        artist,
        selected
      };
    }).filter(Boolean);

    const effectiveSelectedTrackPositions = selectedTrackPositions.length > 0
      ? selectedTrackPositions
      : mergedTracks.filter((track) => track?.selected !== false).map((track) => track.position);
    const selectedPreEncodeScriptIds = normalizeScriptIdList(ripConfig?.selectedPreEncodeScriptIds || []);
    const selectedPostEncodeScriptIds = normalizeScriptIdList(ripConfig?.selectedPostEncodeScriptIds || []);
    const selectedPreEncodeChainIds = normalizeChainIdList(ripConfig?.selectedPreEncodeChainIds || []);
    const selectedPostEncodeChainIds = normalizeChainIdList(ripConfig?.selectedPostEncodeChainIds || []);
    const activeEncodePlan = this.safeParseJson(activeJob.encode_plan_json) || {};
    const plannedOutputConflict = activeEncodePlan?.outputConflict && typeof activeEncodePlan.outputConflict === 'object'
      ? activeEncodePlan.outputConflict
      : {};
    const incomingOutputConflict = ripConfig?.outputConflict && typeof ripConfig.outputConflict === 'object'
      ? ripConfig.outputConflict
      : {};
    const outputConflictStrategy = String(
      incomingOutputConflict.strategy
      || plannedOutputConflict.strategy
      || ''
    ).trim().toLowerCase();
    const keepBothOutput = outputConflictStrategy === 'keep_both'
      || Boolean(incomingOutputConflict.keepBoth || plannedOutputConflict.keepBoth || ripConfig?.keepBoth);
    const explicitDeleteFolders = Array.isArray(ripConfig?.deleteFolders)
      ? ripConfig.deleteFolders.filter((value) => typeof value === 'string' && value.trim())
      : [];
    if (explicitDeleteFolders.length > 0) {
      try {
        const specificDeleteResult = await historyService.deleteSpecificOutputFolders(activeJobId, explicitDeleteFolders);
        await historyService.appendLog(
          activeJobId,
          'USER_ACTION',
          `CD-Start: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).`
        );
      } catch (error) {
        logger.warn('startCdRip:delete-specific-folders-failed', {
          jobId: activeJobId,
          error: errorToMeta(error)
        });
      }
    }

    const [
      selectedPreEncodeScripts,
      selectedPostEncodeScripts,
      selectedPreEncodeChains,
      selectedPostEncodeChains
    ] = await Promise.all([
      scriptService.resolveScriptsByIds(selectedPreEncodeScriptIds, { strict: true }),
      scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, { strict: true }),
      scriptChainService.getChainsByIds(selectedPreEncodeChainIds),
      scriptChainService.getChainsByIds(selectedPostEncodeChainIds)
    ]);

    const ensureResolvedChains = (requestedIds, resolvedChains, fieldName) => {
      const resolved = Array.isArray(resolvedChains) ? resolvedChains : [];
      const resolvedSet = new Set(
        resolved
          .map((chain) => Number(chain?.id))
          .filter((id) => Number.isFinite(id) && id > 0)
          .map((id) => Math.trunc(id))
      );
      const missing = requestedIds.filter((id) => !resolvedSet.has(Number(id)));
      if (missing.length === 0) {
        return;
      }
      const error = new Error(`Skriptkette(n) nicht gefunden: ${missing.join(', ')}`);
      error.statusCode = 400;
      error.details = [{ field: fieldName, message: `Nicht gefunden: ${missing.join(', ')}` }];
      throw error;
    };
    ensureResolvedChains(selectedPreEncodeChainIds, selectedPreEncodeChains, 'selectedPreEncodeChainIds');
    ensureResolvedChains(selectedPostEncodeChainIds, selectedPostEncodeChains, 'selectedPostEncodeChainIds');

    const toScriptDescriptor = (script) => {
      const id = Number(script?.id);
      const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null;
      if (!normalizedId) {
        return null;
      }
      const name = String(script?.name || '').trim() || `Skript #${normalizedId}`;
      return { id: normalizedId, name };
    };
    const toChainDescriptor = (chain) => {
      const id = Number(chain?.id);
      const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null;
      if (!normalizedId) {
        return null;
      }
      const name = String(chain?.name || '').trim() || `Kette #${normalizedId}`;
      return { id: normalizedId, name };
    };

    const settings = cdSettings;
    const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
    const cdOutputTemplate = String(
      settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE
    ).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
    const cdRawBaseDir = String(settings.raw_dir || '').trim() || settingsService.DEFAULT_CD_DIR;
    const cdOutputBaseDir = String(settings.movie_dir || '').trim() || cdRawBaseDir;
    const cdRawOwner = String(settings.raw_dir_owner || '').trim();
    const cdOutputOwner = String(settings.movie_dir_owner || settings.raw_dir_owner || '').trim();

    // For skipRip: use existing WAV dir instead of creating a new raw directory
    let rawWavDir;
    let rawJobDir;
    let cdMetadataBase = null;
    if (skipRip) {
      const existingRawPath = this.resolveCurrentRawPathForSettings(settings, 'cd', activeJob.raw_path);
      if (!existingRawPath) {
        const error = new Error(`CD-Encode nicht möglich: RAW-Verzeichnis nicht erreichbar (${activeJob.raw_path}).`);
        error.statusCode = 400;
        throw error;
      }
      rawWavDir = existingRawPath;
      rawJobDir = existingRawPath;
    } else {
      cdMetadataBase = buildRawMetadataBase({
        title: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null,
        year: effectiveSelectedMeta?.year || null
      }, activeJobId);
      const rawDirName = buildRawDirName(cdMetadataBase, activeJobId, { state: RAW_FOLDER_STATES.INCOMPLETE });
      rawJobDir = path.join(cdRawBaseDir, rawDirName);
      rawWavDir = rawJobDir;
      ensureDir(cdRawBaseDir);
      ensureDir(rawJobDir);
      chownRecursive(rawJobDir, cdRawOwner);
    }

    let outputDir = null;
    if (!skipEncode) {
      const baseOutputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdOutputBaseDir, cdOutputTemplate);
      const baseOutputStillExists = (() => {
        try {
          return fs.existsSync(baseOutputDir);
        } catch (_error) {
          return false;
        }
      })();
      // Conflict strategy:
      // - keepBoth => always number (_X)
      // - replace + remaining sibling folders => continue numbering (_X)
      // - replace + all removed => reuse base folder without numbering
      const needsNumberedOutput = keepBothOutput || baseOutputStillExists;
      outputDir = needsNumberedOutput ? ensureUniqueOutputPath(baseOutputDir) : baseOutputDir;
      ensureDir(outputDir);
      chownRecursive(outputDir, cdOutputOwner);
    }
    const previewTrackPos = effectiveSelectedTrackPositions[0] || mergedTracks[0]?.position || 1;
    const previewWavPath = path.join(rawWavDir, `track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`);
    const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${effectiveDevicePath || '<kein Laufwerk>'} ${previewTrackPos} ${previewWavPath}`;
    const cdLiveTrackRows = buildCdLiveTrackRows(
      effectiveSelectedTrackPositions,
      mergedTracks,
      effectiveSelectedMeta?.artist
    );
    const initialCdLive = buildCdLiveProgressSnapshot({
      trackRows: cdLiveTrackRows,
      phase: skipRip ? 'encode' : 'rip',
      trackIndex: cdLiveTrackRows.length > 0 ? 1 : 0,
      trackTotal: cdLiveTrackRows.length,
      trackPosition: cdLiveTrackRows[0]?.position || null,
      ripCompletedCount: skipRip ? cdLiveTrackRows.length : 0,
      encodeCompletedCount: 0
    });
    const startedAt = nowIso();
    const cdEncodePlan = {
      format,
      formatOptions,
      selectedTracks: effectiveSelectedTrackPositions,
      tracks: mergedTracks,
      directReencodeReady: skipRip || !skipEncode,
      directReencodeReadyAt: skipRip || !skipEncode ? startedAt : null,
      outputTemplate: cdOutputTemplate,
      outputConflict: {
        strategy: keepBothOutput ? 'keep_both' : 'replace',
        keepBoth: keepBothOutput
      },
      preEncodeScriptIds: selectedPreEncodeScripts.map((item) => Number(item.id)),
      postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)),
      preEncodeScripts: selectedPreEncodeScripts.map(toScriptDescriptor).filter(Boolean),
      postEncodeScripts: selectedPostEncodeScripts.map(toScriptDescriptor).filter(Boolean),
      preEncodeChainIds: selectedPreEncodeChainIds,
      postEncodeChainIds: selectedPostEncodeChainIds,
      preEncodeChains: selectedPreEncodeChains.map(toChainDescriptor).filter(Boolean),
      postEncodeChains: selectedPostEncodeChains.map(toChainDescriptor).filter(Boolean)
    };

    const updatedCdInfo = {
      ...cdInfo,
      tracks: mergedTracks,
      selectedMetadata: effectiveSelectedMeta
    };
    const jobUpdate = {
      title: effectiveSelectedMeta?.title || null,
      year: normalizeOptionalYear(effectiveSelectedMeta?.year),
      status: 'CD_RIPPING',
      last_state: 'CD_RIPPING',
      start_time: startedAt,
      end_time: null,
      error_message: null,
      output_path: outputDir,
      handbrake_info_json: null,
      mediainfo_info_json: null,
      makemkv_info_json: JSON.stringify(updatedCdInfo),
      encode_plan_json: JSON.stringify(cdEncodePlan)
    };
    // Only overwrite raw_path for normal rips; skipRip keeps the existing WAV directory
    if (!skipRip) {
      jobUpdate.raw_path = rawJobDir;
      jobUpdate.rip_successful = 0;
    }
    await historyService.updateJob(activeJobId, jobUpdate);

    const existingCdDrive = this.cdDrives.get(effectiveDevicePath);
    this._setCdDriveState(effectiveDevicePath, {
      state: 'CD_RIPPING',
      jobId: activeJobId,
      progress: 0,
      eta: null,
      statusText: skipRip ? 'Tracks werden encodiert …' : (skipEncode ? 'CD wird RAW gerippt …' : 'CD wird gerippt …'),
      context: {
        ...(existingCdDrive?.context || {}),
        jobId: activeJobId,
        mediaProfile: 'cd',
        tracks: mergedTracks,
        selectedMetadata: effectiveSelectedMeta,
        devicePath: devicePath || null,
        virtualDrivePath: skipRip ? effectiveDevicePath : undefined,
        skipRip: skipRip || undefined,
        cdparanoiaCmd,
        rawWavDir,
        outputPath: outputDir,
        outputTemplate: cdOutputTemplate,
        cdRipConfig: cdEncodePlan,
        cdLive: initialCdLive,
        cdparanoiaCommandPreview
      }
    });

    if (!skipRip && !String(effectiveDevicePath || '').startsWith('__virtual__')) {
      this._acquireDriveLockForJob(effectiveDevicePath, activeJobId, {
        stage: 'CD_RIPPING',
        source: 'CDPARANOIA_RIP',
        mediaProfile: 'cd',
        reason: 'rip_running'
      });
    } else {
      this._releaseDriveLockForJob(activeJobId, { reason: 'encode_from_raw' });
    }

    logger.info('cd:rip:start', {
      jobId: activeJobId,
      devicePath: effectiveDevicePath,
      skipRip,
      skipEncode,
      pluginId: cdRipPlugin?.id || null,
      format,
      trackCount: effectiveSelectedTrackPositions.length
    });
    await historyService.appendLog(
      activeJobId,
      'SYSTEM',
      skipRip
        ? `CD-Encode aus RAW gestartet: Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}`
        : (
          skipEncode
            ? `CD-RAW-Rip gestartet: Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}`
            : `CD-Rip gestartet: Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}`
        )
    );
    if (
      selectedPreEncodeScripts.length > 0
      || selectedPreEncodeChains.length > 0
      || selectedPostEncodeScripts.length > 0
      || selectedPostEncodeChains.length > 0
    ) {
      await historyService.appendLog(
        activeJobId,
        'SYSTEM',
        `CD Skript-Auswahl: Pre-Skripte=${selectedPreEncodeScripts.length}, Pre-Ketten=${selectedPreEncodeChains.length}, `
        + `Post-Skripte=${selectedPostEncodeScripts.length}, Post-Ketten=${selectedPostEncodeChains.length}.`
      );
    }

    // Run asynchronously so the HTTP response returns immediately
    this._runCdRip({
      jobId: activeJobId,
      devicePath: effectiveDevicePath,
      cdparanoiaCmd,
      rawWavDir,
      rawBaseDir: skipRip ? null : cdRawBaseDir,
      cdMetadataBase,
      outputDir,
      format,
      formatOptions,
      outputTemplate: cdOutputTemplate,
      rawOwner: cdRawOwner,
      outputOwner: cdOutputOwner,
      selectedTrackPositions: effectiveSelectedTrackPositions,
      tocTracks: mergedTracks,
      selectedMeta: effectiveSelectedMeta,
      encodePlan: cdEncodePlan,
      ripPlugin: cdRipPlugin,
      skipRip,
      skipEncode
    }).catch((error) => {
      logger.error('cd:rip:unhandled', { jobId: activeJobId, error: errorToMeta(error) });
    });

    return {
      jobId: activeJobId,
      sourceJobId: shouldReplaceSourceJob ? Number(jobId) : null,
      replacedSourceJob: shouldReplaceSourceJob,
      started: true
    };
  }

  async _runCdRip({
    jobId,
    devicePath,
    cdparanoiaCmd,
    rawWavDir,
    rawBaseDir,
    cdMetadataBase,
    outputDir,
    format,
    formatOptions,
    outputTemplate,
    rawOwner,
    outputOwner,
    selectedTrackPositions,
    tocTracks,
    selectedMeta,
    encodePlan = null,
    ripPlugin = null,
    skipRip = false,
    skipEncode = false
  }) {
    const processKey = Number(jobId);
    let currentProcessHandle = null;
    let lifecycleResolve = null;
    let lifecycleSettled = false;
    const lifecyclePromise = new Promise((resolve) => {
      lifecycleResolve = resolve;
    });
    const settleLifecycle = () => {
      if (lifecycleSettled) {
        return;
      }
      lifecycleSettled = true;
      lifecycleResolve({ settled: true });
    };
    const sharedHandle = {
      child: null,
      promise: lifecyclePromise,
      cancel: () => {
        try {
          currentProcessHandle?.cancel?.();
        } catch (_error) {
          // ignore cancel race errors
        }
      }
    };
    let currentTrackPosition = null;
    let buildLiveContext = () => null;
    this.activeProcesses.set(processKey, sharedHandle);
    this.syncPrimaryActiveProcess();

    try {
      const normalizedEncodePlan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
      const preScriptIds = normalizeScriptIdList(normalizedEncodePlan?.preEncodeScriptIds || []);
      const preChainIds = normalizeChainIdList(normalizedEncodePlan?.preEncodeChainIds || []);
      const postScriptIds = normalizeScriptIdList(normalizedEncodePlan?.postEncodeScriptIds || []);
      const postChainIds = normalizeChainIdList(normalizedEncodePlan?.postEncodeChainIds || []);
      let preEncodeScriptsSummary = {
        configured: 0,
        attempted: 0,
        succeeded: 0,
        failed: 0,
        skipped: 0,
        results: []
      };
      let postEncodeScriptsSummary = {
        configured: 0,
        attempted: 0,
        succeeded: 0,
        failed: 0,
        skipped: 0,
        results: []
      };
      const selectedTrackOrder = normalizeCdTrackPositionList(selectedTrackPositions);
      const liveTrackRows = buildCdLiveTrackRows(selectedTrackOrder, tocTracks, selectedMeta?.artist);
      const effectiveTrackTotal = liveTrackRows.length;
      let ripCompletedCount = 0;
      let encodeCompletedCount = 0;
      let currentPhase = 'rip';
      let currentTrackIndex = effectiveTrackTotal > 0 ? 1 : 0;
      currentTrackPosition = liveTrackRows[0]?.position || null;
      buildLiveContext = (failedTrackPosition = null) => buildCdLiveProgressSnapshot({
        trackRows: liveTrackRows,
        phase: currentPhase,
        trackIndex: currentTrackIndex,
        trackTotal: effectiveTrackTotal,
        trackPosition: currentTrackPosition,
        ripCompletedCount,
        encodeCompletedCount,
        failedTrackPosition
      });
      if (preScriptIds.length > 0 || preChainIds.length > 0) {
        await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Rip Skripte/Ketten werden ausgeführt...');
        preEncodeScriptsSummary = await this.runPreEncodeScripts(jobId, normalizedEncodePlan, {
          mode: 'cd_rip',
          jobId,
          jobTitle: selectedMeta?.title || `Job #${jobId}`,
          inputPath: devicePath || null,
          outputPath: outputDir || null,
          rawPath: rawWavDir || null,
          mediaProfile: 'cd',
          pipelineStage: 'CD_RIPPING'
        });
        await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Rip Skripte/Ketten abgeschlossen.');
      }
      let encodeStateApplied = false;
      let lastProgressPercent = 0;
      const bindProcessHandle = (handle) => {
        currentProcessHandle = handle && typeof handle === 'object' ? handle : null;
        sharedHandle.child = currentProcessHandle?.child || null;
        this.syncPrimaryActiveProcess();
        if (this.cancelRequestedByJob.has(processKey)) {
          try {
            currentProcessHandle?.cancel?.();
          } catch (_error) {
            // ignore cancel race errors
          }
        }
      };
      const handleRipProgress = async ({ phase, percent, trackIndex, trackTotal, trackPosition, trackEvent }) => {
        const normalizedPhase = phase === 'encode' ? 'encode' : 'rip';
        const stage = normalizedPhase === 'rip' ? 'CD_RIPPING' : 'CD_ENCODING';
        const normalizedTrackTotal = normalizePositiveInteger(trackTotal) || effectiveTrackTotal;
        const normalizedTrackIndex = normalizePositiveInteger(trackIndex)
          || currentTrackIndex
          || (normalizedTrackTotal > 0 ? 1 : 0);
        const normalizedTrackPosition = normalizePositiveInteger(trackPosition) || currentTrackPosition || null;
        const normalizedTrackEvent = String(trackEvent || '').trim().toLowerCase();
        let clampedPercent = Math.max(0, Math.min(100, Number(percent) || 0));
        if (skipEncode && normalizedPhase === 'rip') {
          clampedPercent = Math.max(0, Math.min(100, clampedPercent));
        } else if (normalizedPhase === 'rip') {
          clampedPercent = Math.min(clampedPercent, 50);
        } else {
          clampedPercent = Math.max(50, clampedPercent);
        }
        if (clampedPercent < lastProgressPercent) {
          clampedPercent = lastProgressPercent;
        }
        clampedPercent = Number(clampedPercent.toFixed(2));
        lastProgressPercent = clampedPercent;

        if (normalizedPhase === 'rip') {
          currentPhase = 'rip';
          currentTrackIndex = normalizedTrackIndex;
          currentTrackPosition = normalizedTrackPosition;
          if (normalizedTrackEvent === 'complete') {
            ripCompletedCount = Math.max(ripCompletedCount, normalizedTrackIndex);
            if (ripCompletedCount >= normalizedTrackTotal) {
              currentTrackPosition = null;
            }
          } else {
            ripCompletedCount = Math.max(ripCompletedCount, Math.max(0, normalizedTrackIndex - 1));
          }
        } else {
          currentPhase = 'encode';
          ripCompletedCount = Math.max(ripCompletedCount, normalizedTrackTotal);
          currentTrackIndex = normalizedTrackIndex;
          currentTrackPosition = normalizedTrackPosition;
          if (normalizedTrackEvent === 'complete') {
            encodeCompletedCount = Math.max(encodeCompletedCount, normalizedTrackIndex);
            if (encodeCompletedCount >= normalizedTrackTotal) {
              currentTrackPosition = null;
            }
          } else {
            encodeCompletedCount = Math.max(encodeCompletedCount, Math.max(0, normalizedTrackIndex - 1));
          }
        }

        if (!skipEncode && normalizedPhase === 'encode' && !encodeStateApplied) {
          encodeStateApplied = true;
          await historyService.updateJob(jobId, {
            status: 'CD_ENCODING',
            last_state: 'CD_ENCODING'
          });
        }

        const detail = Number.isFinite(Number(trackIndex)) && Number.isFinite(Number(trackTotal)) && Number(trackTotal) > 0
          ? ` (${Math.trunc(Number(trackIndex))}/${Math.trunc(Number(trackTotal))})`
          : '';
        const statusText = normalizedPhase === 'rip'
          ? `${skipEncode ? 'CD wird RAW gerippt' : 'CD wird gerippt'} …${detail}`
          : `Tracks werden encodiert …${detail}`;

        await this.updateProgress(stage, clampedPercent, null, statusText, processKey, {
          contextPatch: {
            cdLive: buildLiveContext(null)
          }
        });
        // Keep cdDrives entry in sync (no broadcast — PIPELINE_PROGRESS covers real-time updates)
        const currentCdDriveEntry = this.cdDrives.get(devicePath);
        if (currentCdDriveEntry) {
          this.cdDrives.set(devicePath, {
            ...currentCdDriveEntry,
            state: stage,
            progress: clampedPercent,
            statusText,
            context: { ...(currentCdDriveEntry.context || {}), cdLive: buildLiveContext(null) }
          });
        }
      };

      let cdRipResult = null;
      if (ripPlugin?.id === 'cd') {
        const pluginJob = await historyService.getJobById(jobId);
        const pluginCtx = await this.buildPluginContext(ripPlugin.id, {
          jobId,
          emitProgress: (progressPercent, statusText) => {
            const fallbackPercent = Math.max(lastProgressPercent, Math.min(100, Number(progressPercent) || 0));
            const fallbackStage = currentPhase === 'encode' ? 'CD_ENCODING' : 'CD_RIPPING';
            void this.updateProgress(fallbackStage, fallbackPercent, null, statusText || null, processKey, {
              contextPatch: { cdLive: buildLiveContext(null) }
            });
          },
          ripConfig: {
            format,
            formatOptions,
            selectedTracks: selectedTrackOrder,
            tracks: tocTracks,
            metadata: selectedMeta,
            skipRip,
            skipEncode
          },
          rawWavDir,
          outputDir,
          outputTemplate,
          isCancelled: () => this.cancelRequestedByJob.has(processKey),
          onProcessHandle: bindProcessHandle,
          onProgress: (event) => {
            handleRipProgress(event).catch((progressError) => {
              logger.debug('cd:rip:plugin-progress-update-failed', {
                jobId,
                error: errorToMeta(progressError)
              });
            });
          },
          onLog: async (_level, msg) => {
            await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {});
          },
          context: { jobId: processKey }
        });
        cdRipResult = await ripPlugin.rip(
          pluginJob || { id: jobId, disc_device: devicePath || null, makemkv_info_json: null },
          pluginCtx
        );
        logger.info('plugin:cd:rip:used', {
          jobId,
          pluginId: ripPlugin.id,
          skipRip,
          skipEncode,
          trackCount: selectedTrackOrder.length
        });
      } else {
        cdRipResult = await cdRipService.ripAndEncode({
          jobId,
          devicePath,
          cdparanoiaCmd,
          rawWavDir,
          outputDir,
          format,
          formatOptions,
          outputTemplate,
          selectedTracks: selectedTrackOrder,
          tracks: tocTracks,
          meta: selectedMeta,
          skipRip,
          skipEncode,
          onProcessHandle: bindProcessHandle,
          isCancelled: () => this.cancelRequestedByJob.has(processKey),
          onProgress: handleRipProgress,
          onLog: async (_level, msg) => {
            await historyService.appendLog(jobId, 'SYSTEM', msg).catch(() => {});
          },
          context: { jobId: processKey }
        });
      }
      const { encodeResults: cdEncodeResults = [] } = cdRipResult || {};
      settleLifecycle();

      if (postScriptIds.length > 0 || postChainIds.length > 0) {
        await historyService.appendLog(jobId, 'SYSTEM', 'Post-Rip Skripte/Ketten werden ausgeführt...');
        try {
          postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, normalizedEncodePlan, {
            mode: 'cd_rip',
            jobId,
            jobTitle: selectedMeta?.title || `Job #${jobId}`,
            inputPath: devicePath || null,
            outputPath: outputDir || null,
            rawPath: rawWavDir || null,
            mediaProfile: 'cd',
            pipelineStage: skipEncode ? 'CD_RIPPING' : 'CD_ENCODING'
          });
        } catch (error) {
          logger.warn('cd:rip:post-script:failed', { jobId, error: errorToMeta(error) });
          await historyService.appendLog(
            jobId,
            'SYSTEM',
            `Post-Rip Skripte/Ketten konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}`
          );
        }
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `Post-Rip Skripte/Ketten abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, `
          + `${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.`
        );
      }

      // RAW-Verzeichnis von Incomplete_ → Zielzustand umbenennen
      let activeRawDir = rawWavDir;
      if (!skipRip && rawBaseDir && cdMetadataBase) {
        try {
          const targetRawState = skipEncode ? RAW_FOLDER_STATES.RIP_COMPLETE : RAW_FOLDER_STATES.COMPLETE;
          const completedRawDirName = buildRawDirName(cdMetadataBase, jobId, { state: targetRawState });
          const completedRawDir = path.join(rawBaseDir, completedRawDirName);
          if (activeRawDir !== completedRawDir && fs.existsSync(activeRawDir) && !fs.existsSync(completedRawDir)) {
            fs.renameSync(activeRawDir, completedRawDir);
            activeRawDir = completedRawDir;
          }
        } catch (_renameError) {
          // ignore – raw dir bleibt unter bestehendem Namen zugänglich
        }
      }

      const directReencodeReadyAt = normalizedEncodePlan?.directReencodeReadyAt || nowIso();
      const persistedEncodePlan = {
        ...normalizedEncodePlan,
        directReencodeReady: true,
        directReencodeReadyAt
      };

      if (skipEncode) {
        await historyService.updateJob(jobId, {
          status: 'CD_READY_TO_RIP',
          last_state: 'CD_READY_TO_RIP',
          end_time: null,
          rip_successful: 1,
          raw_path: activeRawDir,
          output_path: null,
          encode_input_path: null,
          encode_review_confirmed: 0,
          encode_plan_json: JSON.stringify(persistedEncodePlan),
          handbrake_info_json: JSON.stringify({
            mode: 'cd_rip_raw',
            tracks: cdEncodeResults,
            preEncodeScripts: preEncodeScriptsSummary,
            postEncodeScripts: postEncodeScriptsSummary
          })
        });

        const cdPromotedUrl = thumbnailService.promoteJobThumbnail(jobId);
        if (cdPromotedUrl) {
          await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {});
        }

        chownRecursive(activeRawDir, rawOwner || outputOwner);
        await historyService.appendLog(
          jobId,
          'SYSTEM',
          `CD-RAW-Rip abgeschlossen. Laufwerk freigegeben. Encode-Auswahl kann jetzt gestartet werden (RAW: ${activeRawDir}).`
        );

        this._releaseDriveLockForJob(jobId, { reason: 'cd_raw_rip_successful' });

        if (String(devicePath || '').startsWith('__virtual__')) {
          this._removeCdDrive(devicePath);
        } else {
          this._releaseCdDrive(devicePath);
        }

        const readyVirtualDrivePath = `__virtual__${jobId}`;
        currentPhase = 'encode';
        ripCompletedCount = effectiveTrackTotal;
        encodeCompletedCount = 0;
        currentTrackIndex = effectiveTrackTotal > 0 ? 1 : 0;
        currentTrackPosition = liveTrackRows[0]?.position || null;
        const readyCdLive = buildLiveContext(null);

        this._setCdDriveState(readyVirtualDrivePath, {
          state: 'CD_READY_TO_RIP',
          jobId,
          progress: 0,
          eta: null,
          statusText: 'RAW-Rip abgeschlossen - Auswahl und Encode bereit',
          context: {
            jobId,
            mediaProfile: 'cd',
            devicePath: null,
            virtualDrivePath: readyVirtualDrivePath,
            skipRip: true,
            cdparanoiaCmd,
            rawWavDir: activeRawDir,
            outputPath: null,
            outputTemplate,
            tracks: tocTracks,
            selectedMetadata: selectedMeta,
            cdRipConfig: persistedEncodePlan,
            cdLive: readyCdLive
          }
        });
        this.jobProgress.delete(processKey);

        void this.notifyPushover('metadata_ready', {
          title: 'Ripster - CD RAW bereit',
          message: `Job #${jobId}: RAW-Rip abgeschlossen, Auswahl/Encode bereit`
        });
        if (!String(devicePath || '').startsWith('__virtual__')) {
          void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath));
        }
      } else {
        await historyService.updateJob(jobId, {
          status: 'FINISHED',
          last_state: 'FINISHED',
          end_time: nowIso(),
          rip_successful: 1,
          raw_path: activeRawDir,
          output_path: outputDir,
          handbrake_info_json: JSON.stringify({
            mode: 'cd_rip',
            tracks: cdEncodeResults,
            preEncodeScripts: preEncodeScriptsSummary,
            postEncodeScripts: postEncodeScriptsSummary
          }),
          encode_plan_json: JSON.stringify(persistedEncodePlan)
        });

        const cdPromotedUrl = thumbnailService.promoteJobThumbnail(jobId);
        if (cdPromotedUrl) {
          await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {});
        }

        chownRecursive(activeRawDir, rawOwner || outputOwner);
        if (outputDir) {
          chownRecursive(outputDir, outputOwner);
          await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip abgeschlossen. Ausgabe: ${outputDir}`);
          historyService.addJobOutputFolder(jobId, outputDir).catch((e) => {
            logger.warn('cd:record-output-folder-failed', { jobId, error: e?.message });
          });
        } else {
          await historyService.appendLog(jobId, 'SYSTEM', 'CD-Rip abgeschlossen.');
        }

        this._releaseDriveLockForJob(jobId, { reason: 'cd_rip_successful' });
        const finishedStatusText = postEncodeScriptsSummary.failed > 0
          ? `CD-Rip abgeschlossen (${postEncodeScriptsSummary.failed} Skript(e) fehlgeschlagen)`
          : 'CD-Rip abgeschlossen';
        currentPhase = 'encode';
        ripCompletedCount = effectiveTrackTotal;
        encodeCompletedCount = effectiveTrackTotal;
        currentTrackIndex = effectiveTrackTotal;
        currentTrackPosition = null;
        const finishedCdLive = buildLiveContext(null);

        this._setCdDriveState(devicePath, {
          state: 'FINISHED',
          jobId,
          progress: 100,
          eta: null,
          statusText: finishedStatusText,
          context: {
            jobId,
            mediaProfile: 'cd',
            tracks: tocTracks,
            outputDir,
            outputPath: outputDir,
            cdRipConfig: persistedEncodePlan,
            cdLive: finishedCdLive,
            selectedMetadata: selectedMeta
          }
        });
        // Virtual drives (orphan CD jobs without a real device) are removed after encode; real drives reset to DISC_DETECTED
        if (String(devicePath || '').startsWith('__virtual__')) {
          this._removeCdDrive(devicePath);
        } else {
          this._releaseCdDrive(devicePath);
        }
        this.jobProgress.delete(processKey);

        void this.notifyPushover('job_finished', {
          title: 'Ripster - CD Rip erfolgreich',
          message: `Job #${jobId}: ${selectedMeta?.title || 'Audio CD'}`
        });
        if (!String(devicePath || '').startsWith('__virtual__')) {
          void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath));
        }
      }
    } catch (error) {
      settleLifecycle();
      const failedCdLive = buildLiveContext(currentTrackPosition || null);
      const currentDriveEntry = this.cdDrives.get(devicePath);
      const currentDriveState = currentDriveEntry?.state || 'CD_RIPPING';
      const failStage = currentDriveState === 'CD_ENCODING' ? 'CD_ENCODING' : 'CD_RIPPING';
      await this.updateProgress(failStage, currentDriveEntry?.progress ?? 0, null, currentDriveEntry?.statusText ?? null, processKey, {
        contextPatch: { cdLive: failedCdLive }
      });
      logger.error('cd:rip:failed', { jobId, error: errorToMeta(error) });
      await this.failJob(jobId, failStage, error);
    } finally {
      this.activeProcesses.delete(processKey);
      this.syncPrimaryActiveProcess();
    }
  }

  // ═══════════════════════════════════════════════════════════════════════════
  // CONVERTER
  // ═══════════════════════════════════════════════════════════════════════════

  /**
   * Zentraler Intake für dateibasierte Jobs.
   * Bestehende Endpunkte bleiben erhalten und delegieren intern auf diese Methode.
   *
   * @param {object} payload
   * @param {'audiobook_upload'|'audiobook'|'converter_entry'|'converter'} payload.kind
   * @param {object} [payload.file]
   * @param {string} [payload.relPath]
   * @param {object} [payload.options]
   * @returns {Promise<object>}
   */
  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<object>} Job-Objekt
   */
  async createConverterJobFromEntry(relPath, options = {}) {
    const converterScanService = require('./converterScanService');
    const rawDir = await converterScanService.getRawDir();

    if (!rawDir) {
      const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
      error.statusCode = 400;
      throw error;
    }

    const fullPath = path.join(rawDir, relPath);
    if (!fs.existsSync(fullPath)) {
      const error = new Error(`Eingabedatei nicht gefunden: ${fullPath}`);
      error.statusCode = 404;
      throw error;
    }

    const stat = fs.statSync(fullPath);
    const isDirectory = stat.isDirectory();
    const baseName = path.basename(relPath, path.extname(relPath));
    const detectedTitle = baseName || 'Converter Job';

    let converterMediaType = options.converterMediaType
      || converterScanService.detectMediaType(path.basename(relPath));

    // Für Ordner ohne erkannte Dateiendung: Medientyp anhand der enthaltenen Dateien bestimmen
    if (isDirectory && !converterMediaType) {
      try {
        const dirFiles = fs.readdirSync(fullPath, { withFileTypes: true });
        let audioCount = 0;
        let videoCount = 0;
        for (const f of dirFiles) {
          if (!f.isFile()) continue;
          const mt = converterScanService.detectMediaType(f.name);
          if (mt === 'audio') audioCount++;
          else if (mt === 'video' || mt === 'iso') videoCount++;
        }
        // Mehrheit gewinnt; bei Gleichstand gilt audio (häufigster Fall: Musikalbum)
        if (audioCount > 0 && audioCount >= videoCount) converterMediaType = 'audio';
        else if (videoCount > 0) converterMediaType = 'video';
      } catch (_err) {
        // Ordner nicht lesbar — bleibt null
      }
    }

    const isVideoLikeConverterJob = converterMediaType === 'video' || converterMediaType === 'iso';
    const initialStatus = 'READY_TO_START';
    const normalizedConverterMediaType = ['audio', 'video', 'iso'].includes(
      String(converterMediaType || '').trim().toLowerCase()
    )
      ? String(converterMediaType || '').trim().toLowerCase()
      : 'video';
    const job = await historyService.createJob({
      discDevice: null,
      status: initialStatus,
      detectedTitle,
      mediaType: 'converter',
      jobKind: resolveConverterJobKind(normalizedConverterMediaType)
    });

    await historyService.updateJob(job.id, {
      media_type: 'converter',
      job_kind: resolveConverterJobKind(normalizedConverterMediaType),
      raw_path: fullPath,
      encode_plan_json: JSON.stringify({
        mediaProfile: 'converter',
        jobKind: resolveConverterJobKind(normalizedConverterMediaType),
        converterMediaType: normalizedConverterMediaType,
        inputPath: fullPath,
        isFolder: isDirectory,
        audioFiles: isDirectory && normalizedConverterMediaType === 'audio'
          ? require('./converterScanService').detectFormat
            ? null // wird beim Start gefüllt
            : null
          : null
      }),
      last_state: initialStatus
    });

    // Scan-Eintrag als Job markieren
    await converterScanService.markEntryAsJob(relPath, job.id);

    await historyService.appendLog(
      job.id,
      'SYSTEM',
      `Converter-Job erstellt aus: ${relPath} | Typ: ${normalizedConverterMediaType || '-'}`
    );
    if (isVideoLikeConverterJob) {
      await historyService.appendLog(
        job.id,
        'SYSTEM',
        'Metadaten sind optional. Review kann direkt gestartet werden; OMDb kann später zugeordnet werden.'
      );
    }

    logger.info('converter:job:created-from-entry', {
      jobId: job.id, relPath, converterMediaType: normalizedConverterMediaType, fullPath
    });

    return historyService.getJobById(job.id);
  }

  /**
   * Lädt Converter-Dateien hoch und legt Unterordner in converter_raw_dir an.
   * Erstellt KEINE Jobs – das geschieht separat via createConverterJobsFromSelection.
   *
   * @param {Array<{path, originalname, size}>} files - Multer-Dateiobjekte
   * @returns {Promise<{folders: Array<{folderRelPath, fileCount}>}>}
   */
  async uploadConverterFiles(files, options = {}) {
    const converterScanService = require('./converterScanService');
    const rawDir = await converterScanService.getRawDir();

    if (!rawDir) {
      const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
      error.statusCode = 400;
      throw error;
    }

    ensureDir(rawDir);

    const normalizedFiles = Array.isArray(files) ? files : [files];
    if (normalizedFiles.length === 0) {
      const error = new Error('Keine Dateien zum Upload.');
      error.statusCode = 400;
      throw error;
    }

    const settings = await settingsService.getSettingsMap();
    const allowedExtensions = new Set(parseConverterScanExtensions(settings?.converter_scan_extensions));
    const invalidFiles = [];
    for (const file of normalizedFiles) {
      const tempPath = String(file?.path || '').trim();
      const originalName = String(file?.originalname || file?.originalName || '').trim()
        || path.basename(tempPath || 'upload');
      const extension = getFileExtensionWithoutDot(originalName);
      if (!extension || !allowedExtensions.has(extension)) {
        invalidFiles.push(originalName);
      }
    }
    if (invalidFiles.length > 0) {
      cleanupTempUploads(normalizedFiles);
      const allowedList = [...allowedExtensions].join(', ');
      const preview = invalidFiles.slice(0, 6).join(', ');
      const suffix = invalidFiles.length > 6 ? ` (+${invalidFiles.length - 6} weitere)` : '';
      const error = new Error(
        `Upload abgelehnt: Nicht erlaubte Dateiendung in ${invalidFiles.length} Datei(en): ${preview}${suffix}. Erlaubt: ${allowedList}`
      );
      error.statusCode = 400;
      throw error;
    }

    const folderName = options?.folderName ? String(options.folderName).trim() : null;

    if (folderName) {
      // ── Ordner-Upload (wie Klangkiste target_path): alle Dateien in EINEN Ordner ──
      const safeFolderName = sanitizeFileName(folderName) || `upload_${Date.now()}`;
      const targetDir = ensureUniqueOutputPath(path.join(rawDir, safeFolderName));
      const uniqueFolderName = path.basename(targetDir);
      ensureDir(targetDir);

      for (const file of normalizedFiles) {
        const tempPath = String(file?.path || '').trim();
        if (!tempPath || !fs.existsSync(tempPath)) continue;

        const rawName = String(file?.originalname || '').trim();
        const safeFileName = sanitizeFileNameWithExtension(rawName) || rawName || path.basename(tempPath);
        const targetFile = path.join(targetDir, safeFileName);

        // Traversal-Schutz
        if (!targetFile.startsWith(targetDir + path.sep)) {
          logger.warn('converter:upload:traversal-blocked', { rawName });
          continue;
        }

        moveFileWithFallback(tempPath, targetFile);
      }

      logger.info('converter:upload:folder-placed', {
        folderName, targetDir, fileCount: normalizedFiles.length
      });

      return { folders: [{ folderRelPath: uniqueFolderName, fileCount: normalizedFiles.length }] };
    }

    // ── Einzeldatei-Upload ─────────────────────────────────────────────────
    const createdFolders = [];
    for (const file of normalizedFiles) {
      const tempPath = String(file?.path || '').trim();
      const originalName = String(file?.originalname || file?.originalName || '').trim()
        || path.basename(tempPath || 'upload');

      if (!tempPath || !fs.existsSync(tempPath)) {
        logger.warn('converter:upload:temp-missing', { originalName, tempPath });
        continue;
      }

      const baseNameClean = sanitizeFileName(
        path.basename(originalName, path.extname(originalName))
      ) || `upload_${Date.now()}`;
      const targetDir = ensureUniqueOutputPath(path.join(rawDir, baseNameClean));
      const subfolder = path.basename(targetDir);
      ensureDir(targetDir);

      const safeFileName = sanitizeFileNameWithExtension(originalName) || originalName;
      const targetFile = path.join(targetDir, safeFileName);
      moveFileWithFallback(tempPath, targetFile);

      logger.info('converter:upload:file-placed', { originalName, targetFile });

      createdFolders.push({ folderRelPath: subfolder, fileCount: 1 });
    }

    return { folders: createdFolders };
  }

  /**
   * Erstellt Converter-Jobs aus ausgewählten Dateipfaden (relativ zu converter_raw_dir).
   * Video-Dateien bekommen je einen eigenen Job, Audio-Dateien je nach audioMode.
   * Ordner werden vom Frontend bereits zu Einzel-Dateien expandiert.
   *
   * @param {string[]} relPaths   - Dateipfade relativ zum rawDir
   * @param {'individual'|'shared'} audioMode - Modus für Audio-Dateien
   * @returns {Promise<object[]>} Erstellte Jobs
   */
  async createConverterJobsFromSelection(relPaths, audioMode = 'individual') {
    const AUDIO_EXTS = new Set(['.flac', '.mp3', '.wav', '.m4a', '.ogg', '.opus']);
    const converterScanService = require('./converterScanService');
    const rawDir = await converterScanService.getRawDir();

    if (!rawDir) {
      const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
      error.statusCode = 400;
      throw error;
    }

    const audioRelPaths = [];
    const nonAudioRelPaths = []; // Videos, ISOs, Ordner, sonstige

    for (const relPath of relPaths) {
      const ext = path.extname(String(relPath || '')).toLowerCase();
      if (AUDIO_EXTS.has(ext)) {
        audioRelPaths.push(relPath);
      } else {
        nonAudioRelPaths.push(relPath);
      }
    }

    const createdJobs = [];

    // Nicht-Audio (Video, Ordner, sonstige): immer ein Job pro Eintrag
    for (const relPath of nonAudioRelPaths) {
      const job = await this.createFileJob({
        kind: 'converter_entry',
        relPath,
        options: {}
      });
      createdJobs.push(job);
    }

    // Audio-Dateien
    if (audioMode === 'shared' && audioRelPaths.length > 0) {
      // Ein gemeinsamer Job für alle Audio-Dateien
      const fullPaths = audioRelPaths.map((p) => path.join(rawDir, p));
      const rawPath = path.dirname(fullPaths[0]);
      const detectedTitle = path.basename(rawPath) || 'Converter Audio Job';

      const job = await historyService.createJob({
        discDevice: null,
        status: 'READY_TO_START',
        detectedTitle,
        mediaType: 'converter',
        jobKind: 'converter_audio'
      });

      await historyService.updateJob(job.id, {
        media_type: 'converter',
        job_kind: 'converter_audio',
        raw_path: rawPath,
        encode_plan_json: JSON.stringify({
          mediaProfile: 'converter',
          jobKind: 'converter_audio',
          converterMediaType: 'audio',
          inputPath: rawPath,
          inputPaths: fullPaths,
          isFolder: false,
          isSharedAudio: true
        }),
        last_state: 'READY_TO_START'
      });

      await converterScanService.assignEntriesToJob(audioRelPaths, job.id);

      await historyService.appendLog(
        job.id, 'SYSTEM',
        `Converter-Job (gemeinsam) erstellt: ${audioRelPaths.length} Audio-Datei(en)`
      );

      logger.info('converter:job:created-shared-audio', {
        jobId: job.id, fileCount: audioRelPaths.length
      });

      createdJobs.push(await historyService.getJobById(job.id));
    } else {
      // Einzelne Jobs für jede Audio-Datei
      for (const relPath of audioRelPaths) {
        const job = await this.createFileJob({
          kind: 'converter_entry',
          relPath,
          options: { converterMediaType: 'audio' }
        });
        createdJobs.push(job);
      }
    }

    return createdJobs;
  }

  /**
   * Fügt Dateien einem existierenden (noch nicht gestarteten) Converter-Job hinzu.
   * Aktuell werden Mehrfach-Dateien nur für Audio-Jobs unterstützt.
   *
   * @param {number} jobId
   * @param {string[]} relPaths
   * @returns {Promise<{job: object, addedRelPaths: string[]}>}
   */
  async assignConverterFilesToJob(jobId, relPaths = []) {
    const normalizedJobId = Number(jobId);
    if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
      const error = new Error('Ungültige Job-ID.');
      error.statusCode = 400;
      throw error;
    }

    const converterScanService = require('./converterScanService');
    const rawDir = await converterScanService.getRawDir();
    if (!rawDir) {
      const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
      error.statusCode = 400;
      throw error;
    }

    const requestedRelPaths = Array.isArray(relPaths)
      ? relPaths
        .map((relPath) => converterScanService.normalizeRelPath(relPath))
        .filter((relPath) => relPath !== null && relPath !== '')
      : [];
    if (requestedRelPaths.length === 0) {
      const error = new Error('Keine Dateien für Zuweisung übergeben.');
      error.statusCode = 400;
      throw error;
    }

    const job = await historyService.getJobById(normalizedJobId);
    if (!job) {
      const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }
    if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) {
      const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
      error.statusCode = 409;
      throw error;
    }
    if (String(job.status || '').trim().toUpperCase() !== 'READY_TO_START') {
      const error = new Error('Dateien können nur nicht gestarteten Converter-Jobs zugewiesen werden.');
      error.statusCode = 409;
      throw error;
    }

    const existingPlan = this.safeParseJson(job.encode_plan_json) || {};
    const converterMediaType = String(existingPlan.converterMediaType || '').trim().toLowerCase();
    if (converterMediaType !== 'audio') {
      const error = new Error('Weitere Dateien können derzeit nur Audio-Converter-Jobs zugewiesen werden.');
      error.statusCode = 409;
      throw error;
    }
    if (Boolean(existingPlan.isFolder)) {
      const error = new Error('Ordnerbasierte Audio-Jobs können nicht erweitert werden. Bitte neuen gemeinsamen Audio-Job nutzen.');
      error.statusCode = 409;
      throw error;
    }

    const existingInputPaths = [];
    if (Array.isArray(existingPlan.inputPaths) && existingPlan.inputPaths.length > 0) {
      for (const item of existingPlan.inputPaths) {
        const normalized = String(item || '').trim();
        if (normalized) existingInputPaths.push(normalized);
      }
    } else {
      const singleInput = String(existingPlan.inputPath || job.raw_path || '').trim();
      if (singleInput && fs.existsSync(singleInput)) {
        try {
          const stat = fs.statSync(singleInput);
          if (stat.isFile()) {
            existingInputPaths.push(singleInput);
          }
        } catch (_err) {
          // Ignored: validation below catches empty/invalid setups.
        }
      }
    }

    if (existingInputPaths.length === 0) {
      const error = new Error(`Job ${normalizedJobId} enthält keine gültige Eingabedatei.`);
      error.statusCode = 409;
      throw error;
    }

    const nextInputPaths = [...existingInputPaths];
    const knownPaths = new Set(
      existingInputPaths
        .map((filePath) => normalizeComparablePath(filePath))
        .filter(Boolean)
    );
    const addedRelPaths = [];

    for (const relPath of requestedRelPaths) {
      const absolutePath = path.join(rawDir, relPath);
      if (!fs.existsSync(absolutePath)) {
        const error = new Error(`Datei nicht gefunden: ${relPath}`);
        error.statusCode = 404;
        throw error;
      }
      let stat;
      try {
        stat = fs.statSync(absolutePath);
      } catch (_err) {
        const error = new Error(`Datei nicht lesbar: ${relPath}`);
        error.statusCode = 409;
        throw error;
      }
      if (!stat.isFile()) {
        const error = new Error(`Nur Dateien können zugewiesen werden: ${relPath}`);
        error.statusCode = 409;
        throw error;
      }
      const mediaType = converterScanService.detectMediaType(path.basename(relPath));
      if (mediaType !== 'audio') {
        const error = new Error(`Nur Audio-Dateien können diesem Job zugewiesen werden: ${relPath}`);
        error.statusCode = 409;
        throw error;
      }

      const entry = await converterScanService.getEntryByRelPath(relPath);
      const assignedJobId = Number(entry?.job_id);
      if (Number.isFinite(assignedJobId) && assignedJobId > 0 && assignedJobId !== normalizedJobId) {
        const error = new Error(`Datei ist bereits Job #${assignedJobId} zugewiesen: ${relPath}`);
        error.statusCode = 409;
        throw error;
      }

      const comparablePath = normalizeComparablePath(absolutePath);
      if (comparablePath && knownPaths.has(comparablePath)) {
        continue;
      }
      nextInputPaths.push(absolutePath);
      if (comparablePath) knownPaths.add(comparablePath);
      addedRelPaths.push(relPath);
    }

    if (addedRelPaths.length === 0) {
      return {
        job: await historyService.getJobById(normalizedJobId),
        addedRelPaths: []
      };
    }

    const isSharedAudio = nextInputPaths.length > 1;
    const primaryInputPath = isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0];
    const nextPlan = {
      ...existingPlan,
      mediaProfile: 'converter',
      jobKind: 'converter_audio',
      converterMediaType: 'audio',
      isFolder: false,
      isSharedAudio,
      inputPath: primaryInputPath,
      inputPaths: isSharedAudio ? nextInputPaths : null,
      audioFiles: null,
      reviewConfirmed: false
    };

    await historyService.updateJob(normalizedJobId, {
      status: 'READY_TO_START',
      last_state: 'READY_TO_START',
      media_type: 'converter',
      job_kind: 'converter_audio',
      raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
      encode_plan_json: JSON.stringify(nextPlan),
      encode_review_confirmed: 0,
      error_message: null,
      end_time: null
    });

    await converterScanService.assignEntriesToJob(addedRelPaths, normalizedJobId);

    await historyService.appendLog(
      normalizedJobId,
      'USER_ACTION',
      `Dateien dem Converter-Job zugewiesen: ${addedRelPaths.join(', ')}`
    );

    return {
      job: await historyService.getJobById(normalizedJobId),
      addedRelPaths
    };
  }

  /**
   * Entfernt eine Datei aus einem Converter-Job.
   *
   * @param {number} jobId
   * @param {string} relPath
   * @returns {Promise<{job: object, removedRelPath: string}>}
   */
  async removeConverterFileFromJob(jobId, relPath) {
    const normalizedJobId = Number(jobId);
    if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
      const error = new Error('Ungültige Job-ID.');
      error.statusCode = 400;
      throw error;
    }

    const converterScanService = require('./converterScanService');
    const normalizedRelPath = converterScanService.normalizeRelPath(relPath);
    if (normalizedRelPath === null || normalizedRelPath === '') {
      const error = new Error('Ungültiger relPath.');
      error.statusCode = 400;
      throw error;
    }

    const rawDir = await converterScanService.getRawDir();
    if (!rawDir) {
      const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
      error.statusCode = 400;
      throw error;
    }

    const job = await historyService.getJobById(normalizedJobId);
    if (!job) {
      const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }
    if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) {
      const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
      error.statusCode = 409;
      throw error;
    }
    if (String(job.status || '').trim().toUpperCase() !== 'READY_TO_START') {
      const error = new Error('Dateien können nur aus nicht gestarteten Converter-Jobs entfernt werden.');
      error.statusCode = 409;
      throw error;
    }

    const existingPlan = this.safeParseJson(job.encode_plan_json) || {};
    const converterMediaType = String(existingPlan.converterMediaType || '').trim().toLowerCase();
    if (converterMediaType !== 'audio' || Boolean(existingPlan.isFolder)) {
      const error = new Error('Dateien können derzeit nur aus gemeinsamem Audio-Converter-Job entfernt werden.');
      error.statusCode = 409;
      throw error;
    }

    const allInputPaths = [];
    if (Array.isArray(existingPlan.inputPaths) && existingPlan.inputPaths.length > 0) {
      for (const item of existingPlan.inputPaths) {
        const normalized = String(item || '').trim();
        if (normalized) allInputPaths.push(normalized);
      }
    } else {
      const singleInput = String(existingPlan.inputPath || job.raw_path || '').trim();
      if (singleInput && fs.existsSync(singleInput)) {
        try {
          const stat = fs.statSync(singleInput);
          if (stat.isFile()) {
            allInputPaths.push(singleInput);
          }
        } catch (_err) {
          // Keep empty fallback and validate below.
        }
      }
    }

    if (allInputPaths.length === 0) {
      const error = new Error(`Job ${normalizedJobId} enthält keine gültigen Eingabedateien.`);
      error.statusCode = 409;
      throw error;
    }

    const targetPath = path.join(rawDir, normalizedRelPath);
    const targetComparable = normalizeComparablePath(targetPath);
    const hasTarget = allInputPaths.some(
      (filePath) => normalizeComparablePath(filePath) === targetComparable
    );
    if (!hasTarget) {
      const error = new Error(`Datei ist diesem Job nicht zugewiesen: ${normalizedRelPath}`);
      error.statusCode = 409;
      throw error;
    }

    if (allInputPaths.length <= 1) {
      const error = new Error('Die letzte Datei kann nicht entfernt werden. Bitte Job löschen.');
      error.statusCode = 409;
      throw error;
    }

    const nextInputPaths = allInputPaths.filter(
      (filePath) => normalizeComparablePath(filePath) !== targetComparable
    );
    const isSharedAudio = nextInputPaths.length > 1;
    const primaryInputPath = isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0];
    const nextPlan = {
      ...existingPlan,
      mediaProfile: 'converter',
      jobKind: 'converter_audio',
      converterMediaType: 'audio',
      isFolder: false,
      isSharedAudio,
      inputPath: primaryInputPath,
      inputPaths: isSharedAudio ? nextInputPaths : null,
      audioFiles: null,
      reviewConfirmed: false
    };

    await historyService.updateJob(normalizedJobId, {
      status: 'READY_TO_START',
      last_state: 'READY_TO_START',
      media_type: 'converter',
      job_kind: 'converter_audio',
      raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
      encode_plan_json: JSON.stringify(nextPlan),
      encode_review_confirmed: 0,
      error_message: null,
      end_time: null
    });

    await converterScanService.clearEntryJobAssignment(normalizedRelPath, normalizedJobId);

    await historyService.appendLog(
      normalizedJobId,
      'USER_ACTION',
      `Datei aus Converter-Job entfernt: ${normalizedRelPath}`
    );

    return {
      job: await historyService.getJobById(normalizedJobId),
      removedRelPath: normalizedRelPath
    };
  }

  /**
   * Entfernt eine Datei aus einem Converter-Job via absolutem Input-Pfad.
   * Nützlich für Track-Tabellen, die mit inputPaths (absolut) arbeiten.
   *
   * @param {number} jobId
   * @param {string} inputPath
   * @returns {Promise<{job: object, removedRelPath: string|null}>}
   */
  async removeConverterInputFromJob(jobId, inputPath) {
    const normalizedJobId = Number(jobId);
    const normalizedInputPath = String(inputPath || '').trim();
    if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
      const error = new Error('Ungültige Job-ID.');
      error.statusCode = 400;
      throw error;
    }
    if (!normalizedInputPath) {
      const error = new Error('inputPath fehlt.');
      error.statusCode = 400;
      throw error;
    }

    const converterScanService = require('./converterScanService');
    const rawDir = await converterScanService.getRawDir();
    if (rawDir) {
      const absoluteRawDir = normalizeComparablePath(rawDir);
      const absoluteInputPath = normalizeComparablePath(normalizedInputPath);
      if (absoluteRawDir && absoluteInputPath && absoluteInputPath.startsWith(`${absoluteRawDir}${path.sep}`)) {
        const relCandidate = path.relative(rawDir, normalizedInputPath);
        const normalizedRelPath = converterScanService.normalizeRelPath(relCandidate);
        if (normalizedRelPath && normalizedRelPath !== '.') {
          return this.removeConverterFileFromJob(normalizedJobId, normalizedRelPath);
        }
      }
    }

    // Fallback: direkte Plan-Manipulation (wenn kein valider relPath ableitbar ist)
    const job = await historyService.getJobById(normalizedJobId);
    if (!job) {
      const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }
    const existingPlan = this.safeParseJson(job.encode_plan_json) || {};
    const allInputPaths = Array.isArray(existingPlan.inputPaths)
      ? existingPlan.inputPaths.map((value) => String(value || '').trim()).filter(Boolean)
      : [String(existingPlan.inputPath || job.raw_path || '').trim()].filter(Boolean);
    if (allInputPaths.length <= 1) {
      const error = new Error('Die letzte Datei kann nicht entfernt werden. Bitte Job löschen.');
      error.statusCode = 409;
      throw error;
    }
    const targetComparable = normalizeComparablePath(normalizedInputPath);
    const nextInputPaths = allInputPaths.filter((value) => normalizeComparablePath(value) !== targetComparable);
    if (nextInputPaths.length === allInputPaths.length) {
      const error = new Error('Datei ist diesem Job nicht zugewiesen.');
      error.statusCode = 409;
      throw error;
    }
    const isSharedAudio = nextInputPaths.length > 1;
    const nextPlan = {
      ...existingPlan,
      mediaProfile: 'converter',
      jobKind: 'converter_audio',
      converterMediaType: 'audio',
      isFolder: false,
      isSharedAudio,
      inputPath: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
      inputPaths: isSharedAudio ? nextInputPaths : null,
      audioFiles: null,
      reviewConfirmed: false
    };

    await historyService.updateJob(normalizedJobId, {
      status: 'READY_TO_START',
      last_state: 'READY_TO_START',
      media_type: 'converter',
      job_kind: 'converter_audio',
      raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
      encode_plan_json: JSON.stringify(nextPlan),
      encode_review_confirmed: 0,
      error_message: null,
      end_time: null
    });

    await historyService.appendLog(
      normalizedJobId,
      'USER_ACTION',
      `Datei aus Converter-Job entfernt (inputPath): ${normalizedInputPath}`
    );

    return {
      job: await historyService.getJobById(normalizedJobId),
      removedRelPath: null
    };
  }

  /**
   * Speichert Converter-Draft-Konfiguration persistent für READY_TO_START Jobs.
   * Damit bleiben UI-Eingaben (Metadaten, Tracktitel, Presets etc.) bei Reload erhalten.
   *
   * @param {number} jobId
   * @param {object} config
   * @returns {Promise<{job: object}>}
   */
  async updateConverterJobConfig(jobId, config = {}) {
    const normalizedJobId = Number(jobId);
    if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
      const error = new Error('Ungültige Job-ID.');
      error.statusCode = 400;
      throw error;
    }

    const job = await historyService.getJobById(normalizedJobId);
    if (!job) {
      const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }
    if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) {
      const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
      error.statusCode = 409;
      throw error;
    }
    const statusUpper = String(job.status || '').trim().toUpperCase();
    const allowedDraftStates = new Set(['READY_TO_START', 'READY_TO_ENCODE', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION']);
    if (!allowedDraftStates.has(statusUpper)) {
      const error = new Error('Draft kann nur für nicht gestartete oder geprüfte Converter-Jobs gespeichert werden.');
      error.statusCode = 409;
      throw error;
    }

    const existingPlan = this.safeParseJson(job.encode_plan_json) || {};
    const normalizeText = (value, maxLen = 300) => {
      const normalized = String(value || '').trim();
      if (!normalized) return null;
      return normalized.slice(0, maxLen);
    };
    const normalizeNullableNumber = (value) => {
      const n = Number(value);
      return Number.isFinite(n) ? Math.trunc(n) : null;
    };

    const requestedMediaType = String(
      config.converterMediaType || existingPlan.converterMediaType || 'video'
    ).trim().toLowerCase();
    const converterMediaType = ['video', 'audio', 'iso'].includes(requestedMediaType)
      ? requestedMediaType
      : 'video';

    const defaultFormat = converterMediaType === 'audio' ? 'flac' : 'mkv';
    const allowedAudioFormats = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']);
    const allowedVideoFormats = new Set(['mkv', 'mp4', 'm4v']);
    let outputFormat = normalizeText(config.outputFormat || existingPlan.outputFormat || defaultFormat, 20)
      ?.toLowerCase() || defaultFormat;
    if (converterMediaType === 'audio' && !allowedAudioFormats.has(outputFormat)) {
      outputFormat = 'flac';
    }
    if ((converterMediaType === 'video' || converterMediaType === 'iso') && !allowedVideoFormats.has(outputFormat)) {
      outputFormat = 'mkv';
    }

    let userPreset = existingPlan.userPreset || null;
    if (Object.prototype.hasOwnProperty.call(config, 'userPreset')) {
      if (config.userPreset && typeof config.userPreset === 'object') {
        const presetId = Number(config.userPreset.id);
        userPreset = {
          ...(Number.isFinite(presetId) && presetId > 0 ? { id: Math.trunc(presetId) } : {}),
          ...(normalizeText(config.userPreset.handbrakePreset, 200)
            ? { handbrakePreset: normalizeText(config.userPreset.handbrakePreset, 200) }
            : {}),
          ...(normalizeText(config.userPreset.extraArgs, 1000)
            ? { extraArgs: normalizeText(config.userPreset.extraArgs, 1000) }
            : {})
        };
        if (Object.keys(userPreset).length === 0) {
          userPreset = null;
        }
      } else {
        userPreset = null;
      }
    }

    const nextAudioFormatOptions = (
      Object.prototype.hasOwnProperty.call(config, 'audioFormatOptions')
      && config.audioFormatOptions
      && typeof config.audioFormatOptions === 'object'
    )
      ? { ...config.audioFormatOptions }
      : (existingPlan.audioFormatOptions || {});

    const nextMetadata = (
      Object.prototype.hasOwnProperty.call(config, 'metadata')
      && config.metadata
      && typeof config.metadata === 'object'
    )
      ? {
          albumTitle: normalizeText(config.metadata.albumTitle, 300),
          albumArtist: normalizeText(config.metadata.albumArtist, 300),
          albumYear: normalizeNullableNumber(config.metadata.albumYear),
          mbId: normalizeText(config.metadata.mbId, 120),
          coverUrl: normalizeText(config.metadata.coverUrl, 500) || null
        }
      : (existingPlan.metadata || null);

    const normalizeTrack = (track) => {
      const position = Number(track?.position);
      if (!Number.isFinite(position) || position <= 0) return null;
      return {
        position: Math.trunc(position),
        title: normalizeText(track?.title, 300) || null,
        artist: normalizeText(track?.artist, 300) || null
      };
    };
    const nextTracks = Array.isArray(config.tracks)
      ? config.tracks.map(normalizeTrack).filter(Boolean)
      : (Array.isArray(existingPlan.tracks) ? existingPlan.tracks : null);

    const sanitizeMbEntry = (entry) => {
      if (!entry || typeof entry !== 'object') return null;
      return {
        mbId: normalizeText(entry.mbId, 120),
        title: normalizeText(entry.title, 300),
        artist: normalizeText(entry.artist, 300),
        year: normalizeNullableNumber(entry.year),
        country: normalizeText(entry.country, 16)
      };
    };
    const nextMusicBrainz = (
      Object.prototype.hasOwnProperty.call(config, 'musicBrainz')
      && config.musicBrainz
      && typeof config.musicBrainz === 'object'
    )
      ? {
          query: normalizeText(config.musicBrainz.query, 400),
          selected: sanitizeMbEntry(config.musicBrainz.selected),
          results: Array.isArray(config.musicBrainz.results)
            ? config.musicBrainz.results.map(sanitizeMbEntry).filter(Boolean).slice(0, 50)
            : []
        }
      : (existingPlan.musicBrainz || null);

    const normalizeEncodeItem = (item) => {
      if (!item || typeof item !== 'object') return null;
      const type = String(item.type || '').trim().toLowerCase();
      if (type !== 'script' && type !== 'chain') return null;
      const id = Number(item.id);
      if (!Number.isFinite(id) || id <= 0) return null;
      return { type, id: Math.trunc(id) };
    };
    const nextPreEncodeItems = Array.isArray(config.preEncodeItems)
      ? config.preEncodeItems.map(normalizeEncodeItem).filter(Boolean)
      : (Array.isArray(existingPlan.preEncodeItems) ? existingPlan.preEncodeItems : []);
    const nextPostEncodeItems = Array.isArray(config.postEncodeItems)
      ? config.postEncodeItems.map(normalizeEncodeItem).filter(Boolean)
      : (Array.isArray(existingPlan.postEncodeItems) ? existingPlan.postEncodeItems : []);

    const nextPlan = {
      ...existingPlan,
      mediaProfile: 'converter',
      jobKind: resolveConverterJobKind(converterMediaType),
      converterMediaType,
      outputFormat,
      userPreset,
      audioFormatOptions: nextAudioFormatOptions,
      metadata: nextMetadata,
      tracks: nextTracks,
      musicBrainz: nextMusicBrainz,
      preEncodeItems: nextPreEncodeItems,
      postEncodeItems: nextPostEncodeItems,
      reviewConfirmed: false
    };

    const newCoverUrl = nextMetadata?.coverUrl || null;
    const jobPatch = {
      media_type: 'converter',
      job_kind: resolveConverterJobKind(converterMediaType),
      encode_plan_json: JSON.stringify(nextPlan),
      encode_review_confirmed: 0,
      error_message: null
    };
    if (newCoverUrl && !thumbnailService.isLocalUrl(newCoverUrl)) {
      jobPatch.poster_url = newCoverUrl;
    }

    await historyService.updateJob(normalizedJobId, jobPatch);

    if (newCoverUrl && !thumbnailService.isLocalUrl(newCoverUrl)) {
      historyService.queuePosterCache(normalizedJobId, newCoverUrl, {
        source: 'Coverart',
        logFailures: true
      });
    }

    return {
      job: await historyService.getJobById(normalizedJobId)
    };
  }

  /**
   * Startet einen Converter-Job mit der finalen Konfiguration (Preset, Format etc.).
   *
   * @param {number} jobId
   * @param {object} config
   * @param {string} config.converterMediaType   - 'video'|'audio'|'iso'
   * @param {string} config.outputFormat         - Ausgabeformat (mkv, mp4, flac, mp3, ...)
   * @param {object} [config.userPreset]         - HandBrake User-Preset (Video)
   * @param {object} [config.trackSelection]     - Audio/Subtitle-Selektion (Video)
   * @param {number} [config.handBrakeTitleId]   - HandBrake Titel-ID (Video)
   * @param {object} [config.audioFormatOptions] - Format-Optionen (Audio)
   * @returns {Promise<object>}
   */
  async startConverterJob(jobId, config = {}) {
    const normalizedJobId = Number(jobId);
    if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
      const error = new Error('Ungültige Job-ID für Converter-Start.');
      error.statusCode = 400;
      throw error;
    }

    const job = await historyService.getJobById(normalizedJobId);
    if (!job) {
      const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
      error.statusCode = 404;
      throw error;
    }

    const existingPlan = this.safeParseJson(job.encode_plan_json) || {};
    const converterMediaType = config.converterMediaType
      || existingPlan.converterMediaType
      || 'video';

    const metadata = config.metadata || existingPlan.metadata || null;
    const tracks = config.tracks || existingPlan.tracks || null;

    const converterScanService = require('./converterScanService');
    const settings = await settingsService.getSettingsMap();
    const rawDir = String(
      settings?.converter_raw_dir || require('../config').defaultConverterRawDir || ''
    ).trim();
    const movieDir = String(
      settings?.converter_movie_dir || require('../config').defaultConverterMovieDir || ''
    ).trim();
    const audioDir = String(
      settings?.converter_audio_dir || require('../config').defaultConverterAudioDir || ''
    ).trim();

    const inputPath = existingPlan.inputPath || job.raw_path;
    if (!inputPath || !fs.existsSync(inputPath)) {
      const error = new Error(`Eingabedatei nicht gefunden: ${inputPath}`);
      error.statusCode = 404;
      throw error;
    }

    // Ausgabepfad bestimmen
    let outputPath = null;
    let outputDir = null;
    let audioTrackTemplate = null;
    const allowedAudioFormats = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']);
    const allowedVideoFormats = new Set(['mkv', 'mp4', 'm4v']);
    const defaultOutputFormat = converterMediaType === 'audio' ? 'flac' : 'mkv';
    let outputFormat = String(config.outputFormat || existingPlan.outputFormat || defaultOutputFormat).trim().toLowerCase();
    if (converterMediaType === 'audio' && !allowedAudioFormats.has(outputFormat)) {
      outputFormat = 'flac';
    }
    if ((converterMediaType === 'video' || converterMediaType === 'iso') && !allowedVideoFormats.has(outputFormat)) {
      outputFormat = 'mkv';
    }
    const baseName = sanitizeFileName(
      path.basename(inputPath, path.extname(inputPath))
    ) || 'output';
    const title = job.title || job.detected_title || baseName;
    const safeTitle = sanitizeFileName(title) || baseName;

    // Metadaten-basierte Namen (Album-Titel und Interpret)
    const albumTitle = metadata?.albumTitle ? String(metadata.albumTitle).trim() : null;
    const albumArtist = metadata?.albumArtist ? String(metadata.albumArtist).trim() : null;
    const albumYear = Number.isFinite(Number(metadata?.albumYear))
      ? Math.trunc(Number(metadata.albumYear))
      : (Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : null);
    const safeAlbumTitle = albumTitle ? (sanitizeFileName(albumTitle) || safeTitle) : safeTitle;
    const safeAlbumArtist = albumArtist ? (sanitizeFileName(albumArtist) || null) : null;

    const parseTemplateSegments = (template) => String(template || '')
      .replace(/\\/g, '/')
      .replace(/\/+/g, '/')
      .replace(/^\/+|\/+$/g, '')
      .split('/')
      .map((segment) => String(segment || '').trim())
      .filter(Boolean);

    const normalizeAudioTemplate = (template) => {
      let value = String(template || '').trim();
      if (!value) {
        return '{artist} - {title}';
      }

      // Legacy-Recovery: alte fehlerhafte Werte konnten Placeholder-Klammern verlieren.
      // Auch gemischte Templates (teilweise mit {}, teilweise ohne) werden repariert.
      value = value.replace(
        /(^|[^A-Za-z0-9_$\{])(trackNr|trackNumber|artist|album|title|year)(?=$|[^A-Za-z0-9_\}])/g,
        (_full, prefix, key) => `${prefix}{${key}}`
      );

      // Falls ein kombiniertes Album+Track-Template ohne "/" gespeichert wurde,
      // trennen wir vor der Tracknummer automatisch.
      if (
        !value.includes('/')
        && /\{(?:trackNr|trackNumber)\}/i.test(value)
        && /\{(?:album|year)\}/i.test(value)
      ) {
        value = value.replace(/\)\s*(\{(?:trackNr|trackNumber)\})/i, ')/$1');
      }

      return value;
    };

    const renderTemplateSegment = (segment, values, fallback = 'unknown') => {
      const rendered = renderTemplate(segment, values);
      return sanitizeFileName(rendered) || fallback;
    };

    const albumTemplateValues = {
      artist: safeAlbumArtist || safeAlbumTitle,
      album: safeAlbumTitle,
      title: safeAlbumTitle,
      year: albumYear ?? new Date().getFullYear(),
      trackNr: '01',
      trackNumber: '1'
    };

    if (converterMediaType === 'video' || converterMediaType === 'iso') {
      const videoTemplateRaw = String(
        settings?.converter_output_template_video || '{title}'
      ).trim() || '{title}';
      const videoSegmentsRaw = parseTemplateSegments(videoTemplateRaw);
      const videoSegments = (videoSegmentsRaw.length > 0 ? videoSegmentsRaw : ['{title}'])
        .map((segment) => renderTemplateSegment(segment, {
          title: safeTitle,
          year: Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : new Date().getFullYear()
        }, safeTitle));
      const videoBaseName = videoSegments[videoSegments.length - 1] || safeTitle;
      const videoFolders = videoSegments.slice(0, -1);
      outputPath = path.join(movieDir, ...(videoFolders.length > 0 ? videoFolders : []), `${videoBaseName}.${outputFormat}`);
    } else if (converterMediaType === 'audio') {
      const isFolder = Boolean(existingPlan.isFolder || config.isFolder);
      const isSharedAudio = Boolean(existingPlan.isSharedAudio);
      const audioTemplateRaw = normalizeAudioTemplate(String(
        settings?.converter_output_template_audio || '{artist} - {title}'
      ).trim() || '{artist} - {title}');
      const audioSegmentsRaw = parseTemplateSegments(audioTemplateRaw);
      if (isFolder || isSharedAudio) {
        const folderSegmentRaw = audioSegmentsRaw.length > 1
          ? audioSegmentsRaw.slice(0, -1)
          : audioSegmentsRaw;
        const folderSegments = (folderSegmentRaw.length > 0 ? folderSegmentRaw : ['{artist} - {album}'])
          .map((segment) => renderTemplateSegment(segment, albumTemplateValues, safeAlbumTitle));
        outputDir = path.join(audioDir, ...folderSegments);
        audioTrackTemplate = audioSegmentsRaw.length > 1
          ? audioSegmentsRaw[audioSegmentsRaw.length - 1]
          : (existingPlan.audioTrackTemplate || '{trackNr} - {title}');
      } else {
        const firstTrack = Array.isArray(tracks) && tracks.length > 0 ? tracks[0] : null;
        const singleValues = {
          ...albumTemplateValues,
          title: sanitizeFileName(String(firstTrack?.title || safeAlbumTitle).trim()) || safeAlbumTitle,
          artist: sanitizeFileName(String(firstTrack?.artist || safeAlbumArtist || safeAlbumTitle).trim()) || (safeAlbumArtist || safeAlbumTitle),
          trackNr: '01',
          trackNumber: '1'
        };
        const renderedSegments = (audioSegmentsRaw.length > 0 ? audioSegmentsRaw : ['{artist} - {title}'])
          .map((segment) => renderTemplateSegment(segment, singleValues, safeAlbumTitle));
        const singleBaseName = renderedSegments[renderedSegments.length - 1] || safeAlbumTitle;
        const singleFolders = renderedSegments.slice(0, -1);
        outputPath = path.join(audioDir, ...(singleFolders.length > 0 ? singleFolders : []), `${singleBaseName}.${outputFormat}`);
      }
    }

    // Audiodateien im Ordner laden
    let audioFiles = existingPlan.audioFiles;
    if (converterMediaType === 'audio' && existingPlan.isFolder && !audioFiles) {
      const { readdirSync } = fs;
      const AUDIO_EXTS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
      try {
        audioFiles = readdirSync(inputPath)
          .filter((f) => AUDIO_EXTS.has(path.extname(f).slice(1).toLowerCase()))
          .sort();
      } catch (_err) {
        audioFiles = [];
      }
    }

    const normalizeStartEncodeItem = (item) => {
      if (!item || typeof item !== 'object') return null;
      const type = String(item.type || '').trim().toLowerCase();
      if (type !== 'script' && type !== 'chain') return null;
      const id = Number(item.id);
      if (!Number.isFinite(id) || id <= 0) return null;
      return { type, id: Math.trunc(id) };
    };
    const preEncodeItems = Array.isArray(config.preEncodeItems)
      ? config.preEncodeItems.map(normalizeStartEncodeItem).filter(Boolean)
      : (Array.isArray(existingPlan.preEncodeItems) ? existingPlan.preEncodeItems : []);
    const postEncodeItems = Array.isArray(config.postEncodeItems)
      ? config.postEncodeItems.map(normalizeStartEncodeItem).filter(Boolean)
      : (Array.isArray(existingPlan.postEncodeItems) ? existingPlan.postEncodeItems : []);
    const preEncodeScriptIds = preEncodeItems.filter((i) => i.type === 'script').map((i) => i.id);
    const postEncodeScriptIds = postEncodeItems.filter((i) => i.type === 'script').map((i) => i.id);
    const preEncodeChainIds = preEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id);
    const postEncodeChainIds = postEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id);

    const nextEncodePlan = {
      ...existingPlan,
      mediaProfile: 'converter',
      jobKind: resolveConverterJobKind(converterMediaType),
      converterMediaType,
      inputPath,
      inputPaths: existingPlan.inputPaths || null,
      outputPath: outputPath || null,
      outputDir: outputDir || null,
      outputFormat,
      userPreset: config.userPreset || existingPlan.userPreset || null,
      trackSelection: config.trackSelection || existingPlan.trackSelection || null,
      handBrakeTitleId: config.handBrakeTitleId || existingPlan.handBrakeTitleId || null,
      audioFormatOptions: config.audioFormatOptions || existingPlan.audioFormatOptions || {},
      audioTrackTemplate: audioTrackTemplate || existingPlan.audioTrackTemplate || null,
      audioFiles: audioFiles || existingPlan.audioFiles || null,
      metadata: metadata || null,
      tracks: tracks || null,
      preEncodeItems,
      postEncodeItems,
      preEncodeScriptIds,
      postEncodeScriptIds,
      preEncodeChainIds,
      postEncodeChainIds,
      reviewConfirmed: converterMediaType === 'audio'
    };

    const isVideoLikeConverterJob = converterMediaType === 'video' || converterMediaType === 'iso';
    const metadataTitle = String(metadata?.title || '').trim() || null;
    const metadataYear = Number.isFinite(Number(metadata?.year)) ? Math.trunc(Number(metadata.year)) : null;
    const metadataImdbId = String(metadata?.imdbId || '').trim() || null;
    const metadataPoster = String(metadata?.poster || '').trim() || null;
    const selectedFromOmdb = metadataImdbId ? 1 : 0;

    const jobPatch = {
      status: 'READY_TO_START',
      last_state: 'READY_TO_START',
      media_type: 'converter',
      job_kind: resolveConverterJobKind(converterMediaType),
      output_path: outputPath || outputDir || null,
      encode_plan_json: JSON.stringify(nextEncodePlan),
      encode_review_confirmed: converterMediaType === 'audio' ? 1 : 0,
      error_message: null,
      end_time: null
    };
    if (isVideoLikeConverterJob) {
      if (metadataTitle) {
        jobPatch.title = metadataTitle;
      }
      if (metadataYear !== null) {
        jobPatch.year = metadataYear;
      }
      if (metadataImdbId) {
        jobPatch.imdb_id = metadataImdbId;
      }
      if (metadataPoster) {
        jobPatch.poster_url = metadataPoster;
      }
      jobPatch.selected_from_omdb = selectedFromOmdb;
    }
    await historyService.updateJob(normalizedJobId, jobPatch);

    await historyService.appendLog(
      normalizedJobId,
      'USER_ACTION',
      `Converter konfiguriert: Typ ${converterMediaType} | Format ${outputFormat}`
    );
    if (isVideoLikeConverterJob) {
      await historyService.appendLog(
        normalizedJobId,
        'SYSTEM',
        'Starte Mediainfo-Review mit Titel-/Spurauswahl (analog Rip-Workflow).'
      );
    }

    const startResult = await this.startPreparedJob(normalizedJobId);
    return {
      jobId: normalizedJobId,
      ...(startResult && typeof startResult === 'object' ? startResult : {})
    };
  }

  /**
   * Gibt alle Audiobook-Jobs zurück (für die Audiobooks-Seite).
   */
  async getAudiobookJobs() {
    const db = await require('../db/database').getDb();
    const rows = await db.all(`
      SELECT id, title, detected_title, year, poster_url,
             status, last_state, media_type, job_kind,
             encode_plan_json, handbrake_info_json, makemkv_info_json,
             output_path, raw_path, error_message,
             start_time, end_time, created_at, updated_at
      FROM jobs
      WHERE media_type = 'audiobook'
         OR job_kind = 'audiobook'
         OR (
           (media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other')
           AND (
             encode_plan_json LIKE '%"mediaProfile":"audiobook"%'
             OR encode_plan_json LIKE '%"mode":"audiobook"%'
           )
         )
      ORDER BY created_at DESC
      LIMIT 200
    `);
    return rows.map((row) => {
      const encodePlan = this.safeParseJson(row.encode_plan_json);
      const handbrakeInfo = this.safeParseJson(row.handbrake_info_json);
      const makemkvInfo = this.safeParseJson(row.makemkv_info_json);
      const rawMediaType = String(row?.media_type || '').trim().toLowerCase();
      const planMode = String(encodePlan?.mode || '').trim().toLowerCase();
      const planProfile = String(encodePlan?.mediaProfile || '').trim().toLowerCase();
      const effectiveMediaType = rawMediaType === 'audiobook'
        ? 'audiobook'
        : (
          planProfile === 'audiobook' || planMode === 'audiobook'
            ? 'audiobook'
            : (rawMediaType || null)
        );

      return {
        ...row,
        media_type: effectiveMediaType,
        encodePlan,
        handbrakeInfo,
        makemkvInfo
      };
    });
  }

  /**
   * Gibt alle Converter-Jobs zurück (für die Converter-Seite).
   */
  async getConverterJobs() {
    const db = await require('../db/database').getDb();
    const rows = await db.all(`
      SELECT id, title, detected_title, year, imdb_id, poster_url,
             status, last_state, media_type, job_kind, encode_review_confirmed,
             encode_plan_json, output_path, raw_path, encode_input_path, error_message,
             start_time, end_time, created_at, updated_at
      FROM jobs
      WHERE media_type = 'converter'
         OR job_kind LIKE 'converter_%'
         OR (
           (media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other')
           AND (
             encode_plan_json LIKE '%"mediaProfile":"converter"%'
             OR encode_plan_json LIKE '%"converterMediaType"%'
             OR raw_path LIKE '%/converter/%'
             OR encode_input_path LIKE '%/converter/%'
           )
         )
      ORDER BY created_at DESC
      LIMIT 200
    `);
    return rows.map((r) => {
      const encodePlan = this.safeParseJson(r.encode_plan_json);
      const rawMediaType = String(r?.media_type || '').trim().toLowerCase();
      const converterHintFromPlan = (
        String(encodePlan?.mediaProfile || '').trim().toLowerCase() === 'converter'
        || ['video', 'audio', 'iso'].includes(String(encodePlan?.converterMediaType || '').trim().toLowerCase())
      );
      const converterHintFromPath = (
        hasConverterPathSegment(r?.raw_path)
        || hasConverterPathSegment(r?.encode_input_path)
        || hasConverterPathSegment(encodePlan?.inputPath)
      );
      const effectiveMediaType = rawMediaType === 'converter'
        ? 'converter'
        : (
          (converterHintFromPlan || converterHintFromPath)
            ? 'converter'
            : (rawMediaType || null)
        );
      return {
        ...r,
        media_type: effectiveMediaType,
        encodePlan
      };
    });
  }

}

module.exports = new PipelineService();
