From 5284dd2a433dc1c372ab2f6efc4b269cac2e7a3f Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Mon, 30 Mar 2026 14:37:23 +0000 Subject: [PATCH] 0.12.0-16 misc fixes --- .claude/settings.json | 7 +- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/plugins/ConverterPlugin.js | 136 +- backend/src/routes/converterRoutes.js | 89 +- backend/src/routes/pipelineRoutes.js | 11 +- backend/src/services/cdRipService.js | 37 +- backend/src/services/converterScanService.js | 151 +- backend/src/services/historyService.js | 144 +- backend/src/services/omdbService.js | 110 +- backend/src/services/pipelineService.js | 2540 +++++++++++++++-- backend/src/services/settingsService.js | 46 +- backend/src/utils/encodePlan.js | 534 +++- backend/src/utils/files.js | 19 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api/client.js | 28 + frontend/src/components/CdMetadataDialog.jsx | 4 +- frontend/src/components/CdRipConfigPanel.jsx | 7 +- .../src/components/ConverterFileExplorer.jsx | 369 ++- frontend/src/components/ConverterJobCard.jsx | 1510 ++++++++-- frontend/src/components/JobDetailDialog.jsx | 489 +++- .../src/components/MediaInfoReviewPanel.jsx | 546 +++- .../src/components/PipelineStatusCard.jsx | 271 +- frontend/src/pages/ConverterPage.jsx | 526 +++- frontend/src/pages/DashboardPage.jsx | 57 +- frontend/src/pages/HistoryPage.jsx | 44 +- frontend/src/styles/app.css | 158 +- package-lock.json | 4 +- package.json | 2 +- 30 files changed, 6955 insertions(+), 896 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 1844cfd..4a6d001 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -41,7 +41,12 @@ "Read(//opt/**)", "Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state, encode_input_path, substr\\(encode_plan_json,1,500\\) as plan FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")", "Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state FROM jobs ORDER BY id DESC LIMIT 10;\")", - "Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state, encode_input_path FROM jobs WHERE title LIKE ''%Kill%'' OR id IN \\(69, 212, 213\\) ORDER BY id DESC;\")" + "Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state, encode_input_path FROM jobs WHERE title LIKE ''%Kill%'' OR id IN \\(69, 212, 213\\) ORDER BY id DESC;\")", + "Bash(find /home/michael/ripster -name *.log -newer /home/michael/ripster/debug/markus.log)", + "Bash(pm2 logs:*)", + "Bash(journalctl -u ripster --no-pager -n 50)", + "Bash(pm2 list:*)", + "Bash(journalctl -u ripster-smoketest-http --no-pager -n 40)" ] } } diff --git a/backend/package-lock.json b/backend/package-lock.json index 6b1ee67..61b9b2a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.12.0-15", + "version": "0.12.0-16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.12.0-15", + "version": "0.12.0-16", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 64bd849..ef74898 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.12.0-15", + "version": "0.12.0-16", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/plugins/ConverterPlugin.js b/backend/src/plugins/ConverterPlugin.js index 7222575..ba35f90 100644 --- a/backend/src/plugins/ConverterPlugin.js +++ b/backend/src/plugins/ConverterPlugin.js @@ -5,7 +5,7 @@ const path = require('path'); const { SourcePlugin } = require('./PluginBase'); const { spawnTrackedProcess } = require('../services/processRunner'); const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers'); -const { ensureDir, sanitizeFileName } = require('../utils/files'); +const { ensureDir, sanitizeFileName, renderTemplate } = require('../utils/files'); const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov', 'm4v', 'wmv', 'ts']); const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']); @@ -74,6 +74,20 @@ function buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, format, opts = { args.push('-codec:a', 'copy'); } + // Metadata tags + const trackTitle = opts.trackTitle ? String(opts.trackTitle).trim() : null; + const trackArtist = (opts.trackArtist || opts.albumArtist) + ? String(opts.trackArtist || opts.albumArtist).trim() : null; + const albumTitle = opts.albumTitle ? String(opts.albumTitle).trim() : null; + const albumYear = opts.albumYear ? String(opts.albumYear).trim() : null; + const trackNumber = opts.trackNumber != null ? String(opts.trackNumber) : null; + + if (trackTitle) args.push('-metadata', `title=${trackTitle}`); + if (trackArtist) args.push('-metadata', `artist=${trackArtist}`); + if (albumTitle) args.push('-metadata', `album=${albumTitle}`); + if (albumYear) args.push('-metadata', `date=${albumYear}`); + if (trackNumber) args.push('-metadata', `track=${trackNumber}`); + args.push(outputFile); return { cmd: ffmpegCmd, args }; } @@ -345,12 +359,110 @@ class ConverterPlugin extends SourcePlugin { const outputFormat = String(encodePlan.outputFormat || encodePlan.audioFormat || 'flac').trim().toLowerCase(); const formatOptions = encodePlan.audioFormatOptions || {}; const isFolder = Boolean(encodePlan.isFolder); + const isSharedAudio = Boolean(encodePlan.isSharedAudio) + || (Array.isArray(encodePlan.inputPaths) && encodePlan.inputPaths.length > 0 && !isFolder); + + // Metadata from user config + const planMetadata = encodePlan.metadata || {}; + const planTracks = Array.isArray(encodePlan.tracks) ? encodePlan.tracks : []; if (!AUDIO_OUTPUT_FORMATS.has(outputFormat)) { throw new Error(`Unbekanntes Audio-Ausgabeformat: ${outputFormat}`); } - if (isFolder) { + /** Build per-track metadata opts merged with album-level metadata */ + function trackMetaOpts(position, defaultBaseName) { + const trackMeta = planTracks.find((t) => t.position === position) || {}; + return { + ...formatOptions, + trackTitle: trackMeta.title || defaultBaseName || null, + trackArtist: trackMeta.artist || planMetadata.albumArtist || null, + albumTitle: planMetadata.albumTitle || null, + albumArtist: planMetadata.albumArtist || null, + albumYear: planMetadata.albumYear ? String(planMetadata.albumYear) : null, + trackNumber: position + }; + } + + /** Build output filename with optional track number + title */ + function buildOutputFileName(position, trackMeta, defaultBaseName) { + const numStr = String(position).padStart(2, '0'); + const title = trackMeta?.title ? sanitizeFileName(trackMeta.title) : null; + const templateRaw = String(encodePlan.audioTrackTemplate || '').trim(); + if (templateRaw) { + const rendered = renderTemplate(templateRaw, { + trackNr: numStr, + trackNumber: String(position), + title: title || sanitizeFileName(defaultBaseName) || `Track ${numStr}`, + artist: sanitizeFileName(trackMeta?.artist || planMetadata.albumArtist || '') || 'unknown', + album: sanitizeFileName(planMetadata.albumTitle || '') || 'unknown', + year: planMetadata.albumYear || 'unknown' + }); + const sanitized = sanitizeFileName(rendered); + if (sanitized) { + return `${sanitized}.${outputFormat}`; + } + } + if (title) return `${numStr} - ${title}.${outputFormat}`; + return `${sanitizeFileName(defaultBaseName) || numStr}.${outputFormat}`; + } + + if (isSharedAudio) { + // Freigegebene Audio-Dateien (mehrere Einzeldateien in einem Job) + const inputPaths = encodePlan.inputPaths || []; + if (inputPaths.length === 0) { + throw new Error('ConverterPlugin._encodeAudio(): inputPaths leer für shared-audio-Job'); + } + if (!outputDir) { + throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für shared-audio-Job'); + } + ensureDir(outputDir); + + ctx.logger.info('converter:encode:audio:shared:start', { + jobId: job?.id, fileCount: inputPaths.length, outputDir, format: outputFormat + }); + + for (let i = 0; i < inputPaths.length; i++) { + if (typeof isCancelled === 'function' && isCancelled()) { + const err = new Error('Job wurde vom Benutzer abgebrochen.'); + err.statusCode = 409; + throw err; + } + + const inputFile = inputPaths[i]; + const fileName = path.basename(inputFile); + const baseName = path.basename(fileName, path.extname(fileName)); + const position = i + 1; + const trackMeta = planTracks.find((t) => t.position === position) || {}; + const outputFileName = buildOutputFileName(position, trackMeta, baseName); + const outputFile = path.join(outputDir, outputFileName); + + ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile }); + ctx.emitProgress( + Math.round((i / inputPaths.length) * 100), + `Audio: ${position}/${inputPaths.length} — ${fileName}` + ); + + const encodeConfig = buildAudioFfmpegArgs( + ffmpegCmd, inputFile, outputFile, outputFormat, + trackMetaOpts(position, baseName) + ); + + await _spawnAndWait({ + cmd: encodeConfig.cmd, args: encodeConfig.args, + jobId: job?.id, + isCancelled, + onProcessHandle, + context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' } + }); + } + + ctx.logger.info('converter:encode:audio:shared:done', { + jobId: job?.id, fileCount: inputPaths.length, outputDir + }); + + ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: inputPaths.length }; + } else if (isFolder) { // Ordner-Modus: alle Audio-Dateien im Ordner konvertieren const audioFiles = encodePlan.audioFiles || listAudioFilesInDir(inputPath); @@ -373,15 +485,21 @@ class ConverterPlugin extends SourcePlugin { const fileName = audioFiles[i]; const inputFile = path.join(inputPath, fileName); const baseName = path.basename(fileName, path.extname(fileName)); - const outputFile = path.join(outputDir, `${sanitizeFileName(baseName)}.${outputFormat}`); + const position = i + 1; + const trackMeta = planTracks.find((t) => t.position === position) || {}; + const outputFileName = buildOutputFileName(position, trackMeta, baseName); + const outputFile = path.join(outputDir, outputFileName); - ctx.logger.info(`converter:encode:audio:track:${i + 1}`, { inputFile, outputFile }); + ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile }); ctx.emitProgress( Math.round((i / audioFiles.length) * 100), - `Audio: ${i + 1}/${audioFiles.length} — ${fileName}` + `Audio: ${position}/${audioFiles.length} — ${fileName}` ); - const encodeConfig = buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, outputFormat, formatOptions); + const encodeConfig = buildAudioFfmpegArgs( + ffmpegCmd, inputFile, outputFile, outputFormat, + trackMetaOpts(position, baseName) + ); await _spawnAndWait({ cmd: encodeConfig.cmd, args: encodeConfig.args, @@ -409,7 +527,11 @@ class ConverterPlugin extends SourcePlugin { }); ctx.emitProgress(0, `Audio: Encoding läuft …`); - const encodeConfig = buildAudioFfmpegArgs(ffmpegCmd, inputPath, outputPath, outputFormat, formatOptions); + const baseName = path.basename(inputPath, path.extname(inputPath)); + const encodeConfig = buildAudioFfmpegArgs( + ffmpegCmd, inputPath, outputPath, outputFormat, + trackMetaOpts(1, baseName) + ); await _spawnAndWait({ cmd: encodeConfig.cmd, args: encodeConfig.args, diff --git a/backend/src/routes/converterRoutes.js b/backend/src/routes/converterRoutes.js index 29f197f..f8f219b 100644 --- a/backend/src/routes/converterRoutes.js +++ b/backend/src/routes/converterRoutes.js @@ -146,6 +146,90 @@ router.post( }) ); +/** + * POST /api/converter/jobs/:jobId/assign-files + * Body: { relPaths: string[] } + * Fügt ausgewählte Dateien einem bestehenden (nicht gestarteten) Converter-Job hinzu. + */ +router.post( + '/jobs/:jobId/assign-files', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : []; + if (!Number.isFinite(jobId) || jobId <= 0) { + return res.status(400).json({ detail: 'Ungültige jobId.' }); + } + if (relPaths.length === 0) { + return res.status(400).json({ detail: 'Keine Dateien übergeben.' }); + } + logger.info('post:jobs:assign-files', { jobId, count: relPaths.length }); + const result = await pipelineService.assignConverterFilesToJob(jobId, relPaths); + res.json(result); + }) +); + +/** + * POST /api/converter/jobs/:jobId/remove-file + * Body: { relPath: string } + * Entfernt eine Datei aus einem bestehenden Converter-Job. + */ +router.post( + '/jobs/:jobId/remove-file', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const relPath = String(req.body?.relPath || '').trim(); + if (!Number.isFinite(jobId) || jobId <= 0) { + return res.status(400).json({ detail: 'Ungültige jobId.' }); + } + if (!relPath) { + return res.status(400).json({ detail: 'relPath fehlt.' }); + } + logger.info('post:jobs:remove-file', { jobId, relPath }); + const result = await pipelineService.removeConverterFileFromJob(jobId, relPath); + res.json(result); + }) +); + +/** + * POST /api/converter/jobs/:jobId/remove-input + * Body: { inputPath: string } + * Entfernt eine Datei aus einem Converter-Job anhand des absoluten Input-Pfads. + */ +router.post( + '/jobs/:jobId/remove-input', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const inputPath = String(req.body?.inputPath || '').trim(); + if (!Number.isFinite(jobId) || jobId <= 0) { + return res.status(400).json({ detail: 'Ungültige jobId.' }); + } + if (!inputPath) { + return res.status(400).json({ detail: 'inputPath fehlt.' }); + } + logger.info('post:jobs:remove-input', { jobId, inputPath }); + const result = await pipelineService.removeConverterInputFromJob(jobId, inputPath); + res.json(result); + }) +); + +/** + * POST /api/converter/jobs/:jobId/config + * Body: partial config draft (outputFormat, presets, metadata, tracks, MusicBrainz-UI-Stand) + * Speichert den Draft für READY_TO_START Jobs persistent im encode_plan_json. + */ +router.post( + '/jobs/:jobId/config', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + if (!Number.isFinite(jobId) || jobId <= 0) { + return res.status(400).json({ detail: 'Ungültige jobId.' }); + } + logger.debug('post:jobs:config', { jobId }); + const result = await pipelineService.updateConverterJobConfig(jobId, req.body || {}); + res.json(result); + }) +); + // ── Datei-Operationen (reines FS, keine DB) ─────────────────────────────── /** @@ -299,7 +383,7 @@ router.post( asyncHandler(async (req, res) => { const jobId = Number(req.params.jobId); logger.info('post:jobs:cancel', { jobId }); - const result = await pipelineService.cancelJob(jobId); + const result = await pipelineService.cancel(jobId); res.json({ result }); }) ); @@ -313,7 +397,8 @@ router.delete( asyncHandler(async (req, res) => { const jobId = Number(req.params.jobId); logger.info('delete:jobs', { jobId }); - await historyService.deleteJob(jobId); + await historyService.deleteJob(jobId, 'none', { includeRelated: false }); + await converterScanService.clearAssignmentsForJob(jobId); res.json({ ok: true }); }) ); diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 7adda3c..181a706 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -385,8 +385,15 @@ router.post( const jobId = Number(req.params.jobId); const keepBoth = Boolean(req.body?.keepBoth); const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; - logger.info('post:restart-review', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length }); - const result = await pipelineService.restartReviewFromRaw(jobId, { keepBoth, deleteFolders }); + const reuseCurrentJob = Boolean(req.body?.reuseCurrentJob); + logger.info('post:restart-review', { + reqId: req.reqId, + jobId, + keepBoth, + reuseCurrentJob, + deleteFolderCount: deleteFolders.length + }); + const result = await pipelineService.restartReviewFromRaw(jobId, { keepBoth, deleteFolders, reuseCurrentJob }); res.json({ result }); }) ); diff --git a/backend/src/services/cdRipService.js b/backend/src/services/cdRipService.js index 36c5103..90e73bf 100644 --- a/backend/src/services/cdRipService.js +++ b/backend/src/services/cdRipService.js @@ -381,6 +381,35 @@ async function runProcessTracked({ } } +/** + * Returns sorted plain WAV filenames (not .cdda.wav) in rawWavDir. + * Used as fallback when track01.cdda.wav files are absent (e.g. orphan imports with artist-title WAV files). + */ +function getSortedPlainWavFiles(rawWavDir) { + try { + return fs.readdirSync(rawWavDir) + .filter((f) => /\.wav$/i.test(f) && !/\.cdda\.wav$/i.test(f)) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + } catch (_) { + return []; + } +} + +/** + * Resolves the WAV file path for a given track position. + * Primary: track01.cdda.wav (cdparanoia format) + * Fallback: Nth sorted plain WAV file (for orphan imports with artist-title filenames) + */ +function resolveTrackWavFile(rawWavDir, position, sortedPlainWavFiles) { + const cddaPath = path.join(rawWavDir, `track${String(position).padStart(2, '0')}.cdda.wav`); + if (fs.existsSync(cddaPath)) return cddaPath; + if (Array.isArray(sortedPlainWavFiles) && sortedPlainWavFiles.length >= position && position >= 1) { + const fallback = path.join(rawWavDir, sortedPlainWavFiles[position - 1]); + if (fs.existsSync(fallback)) return fallback; + } + return cddaPath; +} + /** * Rip and encode a CD. * @@ -458,10 +487,12 @@ async function ripAndEncode(options) { const encodePercentSpan = Math.max(0, 100 - encodePercentStart); // ── Phase 1: Rip each selected track to WAV ────────────────────────────── + const _sortedPlainWavFiles = skipRip ? getSortedPlainWavFiles(rawWavDir) : null; + if (skipRip) { // Encode-only: WAV-Dateien müssen bereits vorhanden sein for (const track of tracksToRip) { - const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`); + const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles); if (!fs.existsSync(wavFile)) { throw new Error(`Encode-only: WAV-Datei fehlt für Track ${track.position} (${wavFile})`); } @@ -550,7 +581,7 @@ async function ripAndEncode(options) { for (let i = 0; i < tracksToRip.length; i++) { assertNotCancelled(isCancelled); const track = tracksToRip[i]; - const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`); + const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles); const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate); onProgress && onProgress({ phase: 'encode', @@ -582,7 +613,7 @@ async function ripAndEncode(options) { for (let i = 0; i < tracksToRip.length; i++) { assertNotCancelled(isCancelled); const track = tracksToRip[i]; - const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`); + const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles); if (!fs.existsSync(wavFile)) { throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`); diff --git a/backend/src/services/converterScanService.js b/backend/src/services/converterScanService.js index ec0a6b7..0aff036 100644 --- a/backend/src/services/converterScanService.js +++ b/backend/src/services/converterScanService.js @@ -251,14 +251,120 @@ async function getEntryByRelPath(relPath) { return row || null; } -async function markEntryAsJob(relPath, jobId) { +async function setEntryJobAssignment(relPath, jobId) { + const normalizedRelPath = normalizeRelPath(relPath); + if (normalizedRelPath === null || normalizedRelPath === '') { + throw makeError('Ungültiger relPath für Job-Zuweisung.', 400); + } + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + throw makeError('Ungültige jobId für Job-Zuweisung.', 400); + } + + const db = await getDb(); + const existing = await db.get( + `SELECT entry_type, file_size, detected_media_type, detected_format + FROM converter_scan_entries + WHERE rel_path = ?`, + [normalizedRelPath] + ); + + let entryType = String(existing?.entry_type || 'file').trim() || 'file'; + let fileSize = existing?.file_size ?? null; + let detectedMediaType = existing?.detected_media_type ?? null; + let detectedFormat = existing?.detected_format ?? null; + + const rawDir = await getRawDir(); + const absPath = rawDir ? path.join(rawDir, normalizedRelPath) : null; + if (absPath && fs.existsSync(absPath)) { + try { + const stat = fs.statSync(absPath); + entryType = stat.isDirectory() ? 'directory' : 'file'; + fileSize = stat.isFile() ? stat.size : null; + if (stat.isFile()) { + const fileName = path.basename(normalizedRelPath); + detectedMediaType = detectMediaType(fileName); + detectedFormat = detectFormat(fileName); + } + } catch (_err) { + // Keep existing metadata values if stat/read fails. + } + } + + await db.run( + ` + INSERT INTO converter_scan_entries ( + rel_path, + entry_type, + file_size, + detected_media_type, + detected_format, + job_id, + last_seen_at + ) + VALUES (?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(rel_path) DO UPDATE SET + entry_type = excluded.entry_type, + file_size = excluded.file_size, + detected_media_type = excluded.detected_media_type, + detected_format = excluded.detected_format, + job_id = excluded.job_id, + last_seen_at = excluded.last_seen_at + `, + [ + normalizedRelPath, + entryType, + fileSize, + detectedMediaType, + detectedFormat, + Math.trunc(normalizedJobId) + ] + ); +} + +async function clearEntryJobAssignment(relPath, expectedJobId = null) { + const normalizedRelPath = normalizeRelPath(relPath); + if (normalizedRelPath === null || normalizedRelPath === '') { + throw makeError('Ungültiger relPath für Job-Entfernung.', 400); + } + const db = await getDb(); + const normalizedExpected = Number(expectedJobId); + if (Number.isFinite(normalizedExpected) && normalizedExpected > 0) { + await db.run( + `UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ? AND job_id = ?`, + [normalizedRelPath, Math.trunc(normalizedExpected)] + ); + return; + } + await db.run( + `UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ?`, + [normalizedRelPath] + ); +} + +async function clearAssignmentsForJob(jobId) { + const normalizedJobId = Number(jobId); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + throw makeError('Ungültige jobId für Job-Entfernung.', 400); + } const db = await getDb(); await db.run( - `UPDATE converter_scan_entries SET job_id = ? WHERE rel_path = ?`, - [jobId, relPath] + `UPDATE converter_scan_entries SET job_id = NULL WHERE job_id = ?`, + [Math.trunc(normalizedJobId)] ); } +async function assignEntriesToJob(relPaths, jobId) { + const normalizedPaths = Array.isArray(relPaths) ? relPaths : []; + for (const relPath of normalizedPaths) { + await setEntryJobAssignment(relPath, jobId); + } +} + +async function markEntryAsJob(relPath, jobId) { + await setEntryJobAssignment(relPath, jobId); +} + async function getRawDir() { const settings = await settingsService.getSettingsMap(); return String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim(); @@ -480,7 +586,7 @@ async function createFolder(parentRelPath, name) { const TREE_MAX_DEPTH = 8; -function buildRawTree(rawDir, relPath, depth) { +function buildRawTree(rawDir, relPath, depth, assignments = new Map()) { if (depth >= TREE_MAX_DEPTH) return []; const absDir = relPath ? path.join(rawDir, relPath) : rawDir; let dirents; @@ -503,19 +609,23 @@ function buildRawTree(rawDir, relPath, depth) { if (dirent.name.startsWith('.')) continue; const childRel = relPath ? `${relPath}/${dirent.name}` : dirent.name; if (dirent.isDirectory()) { - const children = buildRawTree(rawDir, childRel, depth + 1); + const children = buildRawTree(rawDir, childRel, depth + 1, assignments); const size = children.reduce((s, c) => s + (c.size || 0), 0); nodes.push({ name: dirent.name, type: 'folder', path: childRel, size, children }); } else if (dirent.isFile()) { let size = 0; try { size = fs.statSync(path.join(rawDir, childRel)).size; } catch (_) {} + const assignment = assignments.get(childRel) || null; nodes.push({ name: dirent.name, type: 'file', path: childRel, size, detectedMediaType: detectMediaType(dirent.name), - detectedFormat: detectFormat(dirent.name) + detectedFormat: detectFormat(dirent.name), + jobId: assignment?.jobId || null, + jobTitle: assignment?.jobTitle || null, + jobStatus: assignment?.jobStatus || null }); } } @@ -527,7 +637,30 @@ async function getTree() { if (!rawDir || !fs.existsSync(rawDir)) { return { rawDir: rawDir || null, tree: null }; } - const children = buildRawTree(rawDir, '', 0); + const db = await getDb(); + const rows = await db.all(` + SELECT + e.rel_path, + e.job_id, + j.title AS job_title, + j.detected_title AS job_detected_title, + j.status AS job_status + FROM converter_scan_entries e + LEFT JOIN jobs j ON j.id = e.job_id + WHERE e.job_id IS NOT NULL + `); + const assignments = new Map(); + for (const row of rows) { + const rel = String(row?.rel_path || '').trim(); + const jobId = Number(row?.job_id); + if (!rel || !Number.isFinite(jobId) || jobId <= 0) continue; + assignments.set(rel, { + jobId: Math.trunc(jobId), + jobTitle: String(row?.job_title || row?.job_detected_title || '').trim() || null, + jobStatus: String(row?.job_status || '').trim() || null + }); + } + const children = buildRawTree(rawDir, '', 0, assignments); const size = children.reduce((s, c) => s + (c.size || 0), 0); return { rawDir, @@ -541,6 +674,10 @@ module.exports = { getEntryById, getEntryByRelPath, markEntryAsJob, + setEntryJobAssignment, + clearEntryJobAssignment, + clearAssignmentsForJob, + assignEntriesToJob, getRawDir, normalizeRelPath, getTree, diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index fa90587..9851119 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -2991,6 +2991,75 @@ class HistoryService { })); } + async findLatestReplacementJobId(sourceJobId) { + const normalizedSourceJobId = normalizeJobIdValue(sourceJobId); + if (!normalizedSourceJobId) { + return null; + } + + const db = await getDb(); + const rows = await db.all( + ` + SELECT a.job_id + FROM job_lineage_artifacts a + JOIN jobs j ON j.id = a.job_id + WHERE a.source_job_id = ? + ORDER BY a.id DESC + `, + [normalizedSourceJobId] + ); + + for (const row of (Array.isArray(rows) ? rows : [])) { + const candidateJobId = normalizeJobIdValue(row?.job_id); + if (!candidateJobId || candidateJobId === normalizedSourceJobId) { + continue; + } + return candidateJobId; + } + + return null; + } + + async getJobByIdOrReplacement(jobId) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + return { + requestedJobId: null, + resolvedJobId: null, + replaced: false, + job: null + }; + } + + const directJob = await this.getJobById(normalizedJobId); + if (directJob) { + return { + requestedJobId: normalizedJobId, + resolvedJobId: normalizedJobId, + replaced: false, + job: directJob + }; + } + + const replacementJobId = await this.findLatestReplacementJobId(normalizedJobId); + if (!replacementJobId) { + return { + requestedJobId: normalizedJobId, + resolvedJobId: normalizedJobId, + replaced: false, + job: null + }; + } + + const replacementJob = await this.getJobById(replacementJobId); + return { + requestedJobId: normalizedJobId, + resolvedJobId: replacementJobId, + replaced: Boolean(replacementJob), + job: replacementJob || null + }; + } + async getRunningJobs() { const db = await getDb(); const [rows, settings] = await Promise.all([ @@ -4357,21 +4426,72 @@ class HistoryService { const rawRoot = normalizeComparablePath(effectiveRawDir); const stat = fs.lstatSync(rawPath); const isFile = stat.isFile(); + const plan = resolvedPaths.encodePlan || {}; - // Für Converter-Jobs: Datei liegt in einem Unterordner → Ordner löschen - let deletePath = rawPath; - if (isFile && resolvedPaths.mediaType === 'converter') { - const parentDir = normalizeComparablePath(path.dirname(rawPath)); - if (parentDir && parentDir !== rawRoot && isPathInside(rawRoot, parentDir)) { - deletePath = parentDir; + // Für Converter-Jobs: nur die zum Job gehörenden Dateien löschen. + // Der übergeordnete Ordner wird nur entfernt, wenn er danach leer ist. + // Ausnahme: Ordner-Jobs (isFolder=true) haben einen dedizierten Ordner → komplett löschen. + const isConverterFolder = resolvedPaths.mediaType === 'converter' && Boolean(plan.isFolder); + const isSharedAudio = resolvedPaths.mediaType === 'converter' && Boolean(plan.isSharedAudio); + const isConverterFileJob = resolvedPaths.mediaType === 'converter' && !isConverterFolder; + + if (isConverterFileJob) { + // Bestimme die zu löschenden Dateien dieses Jobs + let filesToDelete = null; + if (isSharedAudio && Array.isArray(plan.inputPaths) && plan.inputPaths.length > 0) { + // Shared-Audio-Job: nur die explizit gelisteten Einzeldateien entfernen + filesToDelete = plan.inputPaths.map(normalizeComparablePath).filter(Boolean); + } else if (isFile) { + // Einzeldatei-Job: nur diese eine Datei entfernen + filesToDelete = [rawPath]; } - } - const keepRoot = deletePath === rawRoot; - const result = deleteFilesRecursively(deletePath, keepRoot); - summary.raw.deleted = true; - summary.raw.filesDeleted = result.filesDeleted; - summary.raw.dirsRemoved = result.dirsRemoved; + if (filesToDelete) { + let filesDeleted = 0; + const parentDirs = new Set(); + for (const filePath of filesToDelete) { + if (!isPathInside(rawRoot, filePath)) continue; + if (!fs.existsSync(filePath)) continue; + const fStat = fs.lstatSync(filePath); + if (!fStat.isFile()) continue; + fs.unlinkSync(filePath); + filesDeleted++; + const parentDir = normalizeComparablePath(path.dirname(filePath)); + if (parentDir && parentDir !== rawRoot && isPathInside(rawRoot, parentDir)) { + parentDirs.add(parentDir); + } + } + // Übergeordneten Ordner löschen, wenn er nach dem Löschen leer ist + let dirsRemoved = 0; + for (const parentDir of parentDirs) { + try { + if (!fs.existsSync(parentDir)) continue; + const remaining = fs.readdirSync(parentDir); + if (remaining.length === 0) { + fs.rmdirSync(parentDir); + dirsRemoved++; + } + } catch (_err) { /* ignore */ } + } + summary.raw.deleted = true; + summary.raw.filesDeleted = filesDeleted; + summary.raw.dirsRemoved = dirsRemoved; + } else { + // Fallback: rawPath ist ein Verzeichnis ohne explizite Dateiliste + const keepRoot = rawPath === rawRoot; + const result = deleteFilesRecursively(rawPath, keepRoot); + summary.raw.deleted = true; + summary.raw.filesDeleted = result.filesDeleted; + summary.raw.dirsRemoved = result.dirsRemoved; + } + } else { + // Regulärer Job oder dedizierter Converter-Ordner-Job (isFolder=true): gesamten Pfad löschen + const keepRoot = rawPath === rawRoot; + const result = deleteFilesRecursively(rawPath, keepRoot); + summary.raw.deleted = true; + summary.raw.filesDeleted = result.filesDeleted; + summary.raw.dirsRemoved = result.dirsRemoved; + } } } diff --git a/backend/src/services/omdbService.js b/backend/src/services/omdbService.js index 57d8d6e..025fb58 100644 --- a/backend/src/services/omdbService.js +++ b/backend/src/services/omdbService.js @@ -1,7 +1,93 @@ const settingsService = require('./settingsService'); const logger = require('./logger').child('OMDB'); +const OMDB_BASE_URL = 'https://www.omdbapi.com/'; +const OMDB_TIMEOUT_MS = 10000; +const OMDB_MAX_ATTEMPTS = 2; +const OMDB_RETRY_BASE_DELAY_MS = 300; + +function normalizeOmdbTimeoutMs(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return OMDB_TIMEOUT_MS; + } + return Math.max(1000, Math.trunc(parsed)); +} + +function isRetryableOmdbError(error, aborted = false) { + if (aborted) { + return true; + } + const code = String(error?.code || '').trim().toUpperCase(); + if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNABORTED') { + return true; + } + const causeCode = String(error?.cause?.code || '').trim().toUpperCase(); + if (causeCode === 'UND_ERR_CONNECT_TIMEOUT' || causeCode === 'UND_ERR_HEADERS_TIMEOUT' || causeCode === 'UND_ERR_SOCKET') { + return true; + } + const message = String(error?.message || '').toLowerCase(); + return message.includes('timed out') || message.includes('timeout'); +} + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + class OmdbService { + async requestJson(url, meta = {}, options = {}) { + const timeoutMs = normalizeOmdbTimeoutMs(options?.timeoutMs); + const maxAttempts = Math.max(1, Math.trunc(Number(options?.maxAttempts || OMDB_MAX_ATTEMPTS))); + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Ripster/1.0' + }, + signal: controller.signal + }); + if (!response.ok) { + logger.warn('request:http-failed', { + ...meta, + status: response.status, + attempt, + maxAttempts + }); + return null; + } + return await response.json(); + } catch (error) { + const aborted = error?.name === 'AbortError'; + const retryable = isRetryableOmdbError(error, aborted); + const willRetry = retryable && attempt < maxAttempts; + logger[willRetry ? 'info' : 'warn']('request:failed', { + ...meta, + timeoutMs, + aborted, + retryable, + attempt, + maxAttempts, + willRetry, + message: error?.message || String(error) + }); + if (willRetry) { + await sleep(OMDB_RETRY_BASE_DELAY_MS * attempt); + continue; + } + return null; + } finally { + clearTimeout(timer); + } + } + return null; + } + async search(query) { if (!query || query.trim().length === 0) { return []; @@ -13,20 +99,18 @@ class OmdbService { if (!apiKey) { return []; } + const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms); const type = settings.omdb_default_type || 'movie'; - const url = new URL('https://www.omdbapi.com/'); + const url = new URL(OMDB_BASE_URL); url.searchParams.set('apikey', apiKey); url.searchParams.set('s', query.trim()); url.searchParams.set('type', type); - const response = await fetch(url); - if (!response.ok) { - logger.error('search:http-failed', { query, status: response.status }); - throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`); + const data = await this.requestJson(url, { query, action: 'search' }, { timeoutMs }); + if (!data || typeof data !== 'object') { + return []; } - - const data = await response.json(); if (data.Response === 'False' || !Array.isArray(data.Search)) { logger.warn('search:no-results', { query, response: data.Response, error: data.Error }); return []; @@ -54,19 +138,17 @@ class OmdbService { if (!apiKey) { return null; } + const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms); - const url = new URL('https://www.omdbapi.com/'); + const url = new URL(OMDB_BASE_URL); url.searchParams.set('apikey', apiKey); url.searchParams.set('i', normalizedId); url.searchParams.set('plot', 'full'); - const response = await fetch(url); - if (!response.ok) { - logger.error('fetchByImdbId:http-failed', { imdbId: normalizedId, status: response.status }); - throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`); + const data = await this.requestJson(url, { imdbId: normalizedId, action: 'fetchByImdbId' }, { timeoutMs }); + if (!data || typeof data !== 'object') { + return null; } - - const data = await response.json(); if (data.Response === 'False') { logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error }); return null; diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index d2761f9..b1a8e75 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -75,6 +75,16 @@ const RAW_FOLDER_STATES = Object.freeze({ 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; function nowIso() { return new Date().toISOString(); @@ -1448,8 +1458,173 @@ function normalizePositiveTrackId(rawValue) { return Math.trunc(parsed); } -function isLikelyForcedSubtitleTrack(track) { - const text = [ +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, @@ -1459,10 +1634,56 @@ function isLikelyForcedSubtitleTrack(track) { .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; } - if (/\bnot forced\b/.test(text)) { + 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 ( @@ -1472,6 +1693,122 @@ function isLikelyForcedSubtitleTrack(track) { ); } +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) { @@ -1479,37 +1816,234 @@ function annotateSubtitleForcedAvailability(handBrakeSubtitleTracks, makeMkvSubt } const mkTracks = Array.isArray(makeMkvSubtitleTracks) ? makeMkvSubtitleTracks : []; - const forcedSourceIdsByLanguage = new Map(); - - for (const track of mkTracks) { - if (!isLikelyForcedSubtitleTrack(track)) { - continue; - } + const normalizedMkTracks = mkTracks.map((track, index) => { const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'); const sourceTrackId = normalizePositiveTrackId(track?.sourceTrackId ?? track?.id); - if (!sourceTrackId) { - continue; + 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 (!forcedSourceIdsByLanguage.has(language)) { - forcedSourceIdsByLanguage.set(language, []); + if (!mkByLanguage.has(track.language)) { + mkByLanguage.set(track.language, []); } - const list = forcedSourceIdsByLanguage.get(language); - if (!list.includes(sourceTrackId)) { - list.push(sourceTrackId); + 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; + } } } - return hbTracks.map((track) => { - const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und'); - const forcedSourceTrackIds = normalizeTrackIdList(forcedSourceIdsByLanguage.get(language) || []); - const forcedTrack = isLikelyForcedSubtitleTrack(track); - return { - ...track, - forcedTrack, - forcedAvailable: forcedTrack || forcedSourceTrackIds.length > 0, - forcedSourceTrackIds - }; - }); + 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) { @@ -1705,7 +2239,10 @@ function buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo) { Language: track?.language || 'und', Language_String3: track?.language || 'und', Title: track?.title || null, - Format: track?.format || 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 }); } @@ -2187,7 +2724,12 @@ function parseHandBrakeSelectedTitleInfo(scanJson, options = {}) { languageLabel, title: track?.Name || track?.Description || null, format: track?.SourceName || track?.Format || track?.Codec || null, - channels: 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); @@ -2372,6 +2914,11 @@ function buildDiscScanReview({ 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: [], @@ -2452,15 +2999,17 @@ function buildDiscScanReview({ }), 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: false, - forced: false, - forcedOnly: false, - defaultTrack: false, - flags: [], - subtitleActionSummary: selectedForEncode ? 'Übernehmen' : 'Nicht übernommen' + 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' }; }) }; @@ -2701,6 +3250,30 @@ function isPathInsideDirectory(parentPath, candidatePath) { 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); @@ -3231,6 +3804,29 @@ function normalizeTrackIdList(rawList) { 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 @@ -3245,6 +3841,373 @@ function isBurnedSubtitleTrack(track) { ); } +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(); @@ -3272,7 +4235,9 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { plan: encodePlan, selectionApplied: false, audioTrackIds: [], - subtitleTrackIds: [] + subtitleTrackIds: [], + subtitleForcedTrackIndexes: [], + subtitleSelectionValidationErrors: [] }; } @@ -3282,7 +4247,9 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { plan, selectionApplied: false, audioTrackIds: [], - subtitleTrackIds: [] + subtitleTrackIds: [], + subtitleForcedTrackIndexes: [], + subtitleSelectionValidationErrors: [] }; } @@ -3294,7 +4261,9 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { plan, selectionApplied: false, audioTrackIds: [], - subtitleTrackIds: [] + subtitleTrackIds: [], + subtitleForcedTrackIndexes: [], + subtitleSelectionValidationErrors: [] }; } @@ -3306,7 +4275,9 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { plan, selectionApplied: false, audioTrackIds: [], - subtitleTrackIds: [] + subtitleTrackIds: [], + subtitleForcedTrackIndexes: [], + subtitleSelectionValidationErrors: [] }; } @@ -3316,7 +4287,9 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { plan, selectionApplied: false, audioTrackIds: [], - subtitleTrackIds: [] + subtitleTrackIds: [], + subtitleForcedTrackIndexes: [], + subtitleSelectionValidationErrors: [] }; } @@ -3328,7 +4301,7 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { ); const validSubtitleTrackIds = new Set( (Array.isArray(encodeTitle.subtitleTracks) ? encodeTitle.subtitleTracks : []) - .filter((track) => !isBurnedSubtitleTrack(track)) + .filter((track) => !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate)) .map((track) => Number(track?.id)) .filter((id) => Number.isFinite(id)) .map((id) => Math.trunc(id)) @@ -3338,9 +4311,35 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { .filter((id) => validAudioTrackIds.has(id)); const requestedSubtitleTrackIds = normalizeTrackIdList(rawSelection.subtitleTrackIds) .filter((id) => validSubtitleTrackIds.has(id)); + const requestedSubtitleTrackIdsOrdered = normalizeTrackIdSequence(rawSelection.subtitleTrackIds, { dedupe: false }) + .filter((id) => validSubtitleTrackIds.has(id)); + + const explicitSubtitleVariants = normalizeSubtitleVariantSelection(rawSelection.subtitleVariantSelection); + const explicitSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(rawSelection.subtitleLanguageOrder); + let effectiveSubtitleVariantSelection = explicitSubtitleVariants; + const hasRawSubtitleTrackIds = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleTrackIds'); + const hasRawSubtitleVariantSelection = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleVariantSelection'); + if (effectiveSubtitleVariantSelection.map.size === 0 && !hasRawSubtitleVariantSelection && hasRawSubtitleTrackIds) { + effectiveSubtitleVariantSelection = requestedSubtitleTrackIds.length > 0 + ? buildSubtitleVariantSelectionFromTrackIds(encodeTitle.subtitleTracks, requestedSubtitleTrackIds) + : buildEmptySubtitleVariantSelectionForAllLanguages(encodeTitle.subtitleTracks); + } else if (effectiveSubtitleVariantSelection.map.size === 0 && !hasRawSubtitleVariantSelection && requestedSubtitleTrackIds.length > 0) { + effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds(encodeTitle.subtitleTracks, requestedSubtitleTrackIds); + } + + const resolvedSubtitleSelection = resolveDeterministicSubtitleSelection({ + subtitleTracks: encodeTitle.subtitleTracks, + subtitleVariantSelection: Object.fromEntries(effectiveSubtitleVariantSelection.map.entries()), + subtitleLanguageOrder: explicitSubtitleLanguageOrder.length > 0 + ? explicitSubtitleLanguageOrder + : effectiveSubtitleVariantSelection.order, + fallbackSelectedSubtitleTrackIds: requestedSubtitleTrackIds, + fallbackSelectedSubtitleTrackIdsOrdered: requestedSubtitleTrackIdsOrdered, + hasExplicitVariantSelection: hasRawSubtitleVariantSelection + }); const audioSelectionSet = new Set(requestedAudioTrackIds.map((id) => String(id))); - const subtitleSelectionSet = new Set(requestedSubtitleTrackIds.map((id) => String(id))); + const subtitleSelectionSet = new Set(resolvedSubtitleSelection.selectedPhysicalTrackIds.map((id) => String(id))); const remappedTitles = plan.titles.map((title) => { const isEncodeInput = Number(title?.id) === encodeInputTitleId; @@ -3391,13 +4390,20 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { manualTrackSelection: { titleId: encodeInputTitleId, audioTrackIds: requestedAudioTrackIds, - subtitleTrackIds: requestedSubtitleTrackIds, + subtitleTrackIds: resolvedSubtitleSelection.selectedPhysicalTrackIds, + subtitleTrackIdsOrdered: resolvedSubtitleSelection.subtitleTrackIdsOrdered, + subtitleForcedTrackIndexes: resolvedSubtitleSelection.subtitleForcedSelectionIndexes, + subtitleVariantSelection: resolvedSubtitleSelection.subtitleVariantSelection, + subtitleLanguageOrder: resolvedSubtitleSelection.subtitleLanguageOrder, + subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors, updatedAt: nowIso() } }, selectionApplied: true, audioTrackIds: requestedAudioTrackIds, - subtitleTrackIds: requestedSubtitleTrackIds + subtitleTrackIds: resolvedSubtitleSelection.subtitleTrackIdsOrdered, + subtitleForcedTrackIndexes: resolvedSubtitleSelection.subtitleForcedSelectionIndexes, + subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors }; } @@ -3439,12 +4445,44 @@ function extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath = null) { ? normalizeTrackIdList(manualSelection.audioTrackIds) : normalizeTrackIdList(allAudioTracks.filter((t) => Boolean(t?.selectedForEncode)).map((t) => t?.id)); - const subtitleTrackIds = (manualMatchesTitle && Array.isArray(manualSelection?.subtitleTrackIds)) + 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(subtitleTrackIds.map(String)); + 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)); @@ -3456,15 +4494,19 @@ function extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath = null) { const subtitleDefaultTrackId = normalizeTrackIdList( selectedSubtitleTracks.filter((track) => Boolean(track?.defaultTrack)).map((track) => track?.id) )[0] || null; - const subtitleForcedTrackId = normalizeTrackIdList( - selectedSubtitleTracks.filter((track) => Boolean(track?.forced)).map((track) => track?.id) - )[0] || null; - const subtitleForcedOnly = selectedSubtitleTracks.some((track) => Boolean(track?.forcedOnly)); + 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, @@ -3631,7 +4673,11 @@ function extractManualSelectionPayloadFromPlan(encodePlan) { } return { audioTrackIds: normalizeTrackIdList(selection.audioTrackIds), - subtitleTrackIds: normalizeTrackIdList(selection.subtitleTrackIds) + 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 : [] }; } @@ -3780,7 +4826,7 @@ function resolvePrefillEncodeTitleId(reviewPlan, previousPlan) { function mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds, { excludeBurned = false } = {}) { const tracks = Array.isArray(targetTracks) ? targetTracks : []; const allowedTracks = excludeBurned - ? tracks.filter((track) => !isBurnedSubtitleTrack(track)) + ? tracks.filter((track) => !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate)) : tracks; const requested = normalizeTrackIdList(sourceTrackIds); if (requested.length === 0 || allowedTracks.length === 0) { @@ -3869,7 +4915,7 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n ); const fallbackSubtitleTrackIds = normalizeTrackIdList( (Array.isArray(nextSelectedTitle?.subtitleTracks) ? nextSelectedTitle.subtitleTracks : []) - .filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track)) + .filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track) && !Boolean(track?.duplicate)) .map((track) => track?.id) ); const effectiveAudioTrackIds = previousAudioSourceIds.length > 0 && mappedAudioTrackIds.length === 0 @@ -4388,6 +5434,66 @@ class PipelineService extends EventEmitter { return this.resolveCurrentRawPath(rawBaseDir, stored, rawExtraDirs); } + 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(); @@ -4409,6 +5515,7 @@ class PipelineService extends EventEmitter { 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') `); if (!Array.isArray(rows) || rows.length === 0) { return; @@ -5369,6 +6476,25 @@ class PipelineService extends EventEmitter { 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()); } @@ -6330,8 +7456,39 @@ class PipelineService extends EventEmitter { 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 @@ -6390,10 +7547,11 @@ class PipelineService extends EventEmitter { statusText: this.snapshot.statusText }); - const DRIVE_ACTIVE_STATES = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING']); - if (DRIVE_ACTIVE_STATES.has(state)) { + const shouldSuspendCurrentPolling = this.shouldSuspendDrivePollingForState(state, this.snapshot.context); + const shouldSuspendPreviousPolling = this.shouldSuspendDrivePollingForState(previous, previousContext); + if (shouldSuspendCurrentPolling) { diskDetectionService.suspendPolling(); - } else if (DRIVE_ACTIVE_STATES.has(previous)) { + } else if (shouldSuspendPreviousPolling) { diskDetectionService.resumePolling(); } @@ -8068,6 +9226,72 @@ class PipelineService extends EventEmitter { 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, @@ -8076,9 +9300,37 @@ class PipelineService extends EventEmitter { languageLabel: sourceMeta?.languageLabel || track?.languageLabel || track?.language || 'und', title: sourceMeta?.title ?? track?.title ?? null, format: sourceMeta?.format || track?.format || null, - forcedTrack: Boolean(sourceMeta?.forcedTrack), + 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 || []) + 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' }; }); @@ -8641,14 +9893,14 @@ class PipelineService extends EventEmitter { } 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 = await historyService.getJobById(jobId); - if (!preloadedJob) { - const error = new Error(`Job ${jobId} nicht gefunden.`); - error.statusCode = 404; - throw error; - } + 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; @@ -8708,12 +9960,7 @@ class PipelineService extends EventEmitter { return this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob }); } - const job = options?.preloadedJob || await historyService.getJobById(jobId); - if (!job) { - const error = new Error(`Job ${jobId} nicht gefunden.`); - error.statusCode = 404; - throw error; - } + 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); @@ -8973,6 +10220,13 @@ class PipelineService extends EventEmitter { planForConfirm, options?.selectedTrackSelection || null ); + if (Array.isArray(trackSelectionResult.subtitleSelectionValidationErrors) && trackSelectionResult.subtitleSelectionValidationErrors.length > 0) { + const error = new Error( + `Subtitle-Auswahl ungültig: ${trackSelectionResult.subtitleSelectionValidationErrors.join(' | ')}` + ); + error.statusCode = 400; + throw error; + } planForConfirm = trackSelectionResult.plan; const hasExplicitPostScriptSelection = options?.selectedPostEncodeScriptIds !== undefined; const selectedPostEncodeScriptIds = hasExplicitPostScriptSelection @@ -9097,6 +10351,7 @@ class PipelineService extends EventEmitter { `Mediainfo-Prüfung bestätigt.${isPreRipMode ? ' Backup/Rip darf gestartet werden.' : ' Encode darf gestartet werden.'}${confirmedPlan.encodeInputTitleId ? ` Gewählter Titel #${confirmedPlan.encodeInputTitleId}.` : ''}` + ` Audio-Spuren: ${trackSelectionResult.audioTrackIds.length > 0 ? trackSelectionResult.audioTrackIds.join(',') : 'none'}.` + ` Subtitle-Spuren: ${trackSelectionResult.subtitleTrackIds.length > 0 ? trackSelectionResult.subtitleTrackIds.join(',') : 'none'}.` + + ` Subtitle-Forced-Indizes: ${Array.isArray(trackSelectionResult.subtitleForcedTrackIndexes) && trackSelectionResult.subtitleForcedTrackIndexes.length > 0 ? trackSelectionResult.subtitleForcedTrackIndexes.join(',') : 'none'}.` + ` Pre-Encode-Scripte: ${selectedPreEncodeScripts.length > 0 ? selectedPreEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.` + ` Pre-Encode-Ketten: ${selectedPreEncodeChainIds.length > 0 ? selectedPreEncodeChainIds.join(',') : 'none'}.` + ` Post-Encode-Scripte: ${selectedPostEncodeScripts.length > 0 ? selectedPostEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.` @@ -9260,7 +10515,7 @@ class PipelineService extends EventEmitter { // WAV-Dateien im RAW-Verzeichnis suchen const wavFiles = fs.existsSync(resolvedCdRawPath) - ? fs.readdirSync(resolvedCdRawPath).filter((f) => f.endsWith('.cdda.wav')) + ? 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.'); @@ -9525,16 +10780,13 @@ class PipelineService extends EventEmitter { } 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 = await historyService.getJobById(jobId); - if (!sourceJob) { - const error = new Error(`Job ${jobId} nicht gefunden.`); - error.statusCode = 404; - throw error; - } + 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)) { @@ -9566,7 +10818,7 @@ class PipelineService extends EventEmitter { } const wavFiles = fs.existsSync(resolvedRawPath) - ? fs.readdirSync(resolvedRawPath).filter((f) => f.endsWith('.cdda.wav')) + ? 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.'); @@ -9597,8 +10849,15 @@ class PipelineService extends EventEmitter { const tocTracks = Array.isArray(mkInfo.tracks) && mkInfo.tracks.length > 0 ? mkInfo.tracks - : (Array.isArray(encodePlan.tracks) ? encodePlan.tracks : []); - const resetTracks = tocTracks.map((track, index) => { + : (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 { @@ -11039,6 +12298,13 @@ class PipelineService extends EventEmitter { 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 { @@ -11198,7 +12464,7 @@ class PipelineService extends EventEmitter { 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=${trackSelection.subtitleForcedTrackId ?? (trackSelection.subtitleForcedOnly ? 'forced-only' : 'none')}` + `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) { @@ -11772,16 +13038,13 @@ class PipelineService extends EventEmitter { 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 = await historyService.getJobById(jobId); - if (!sourceJob) { - const error = new Error(`Job ${jobId} nicht gefunden.`); - error.statusCode = 404; - throw error; - } + 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.'); @@ -11982,6 +13245,31 @@ class PipelineService extends EventEmitter { 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), title: sourceJob.title || null, @@ -11999,17 +13287,10 @@ class PipelineService extends EventEmitter { encode_plan_json: (isCdRetry || isAudiobookRetry) ? (sourceJob.encode_plan_json || null) : null, - encode_input_path: isAudiobookRetry - ? ( - sourceJob.encode_input_path - || sourceEncodePlan?.encodeInputPath - || sourceMakemkvInfo?.rawFilePath - || null - ) - : null, + encode_input_path: retryEncodeInputPath, encode_review_confirmed: isAudiobookRetry ? 1 : 0, output_path: null, - raw_path: isAudiobookRetry ? (sourceJob.raw_path || null) : 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') }; @@ -12066,15 +13347,12 @@ class PipelineService extends EventEmitter { } 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 = await historyService.getJobById(jobId); - if (!job) { - const error = new Error(`Job ${jobId} nicht gefunden.`); - error.statusCode = 404; - throw error; - } + const job = resolvedReadyJob.job || await historyService.getJobById(jobId); const isReadyToEncode = job.status === 'READY_TO_ENCODE' || job.last_state === 'READY_TO_ENCODE'; if (!isReadyToEncode) { @@ -12153,6 +13431,7 @@ class PipelineService extends EventEmitter { context: { ...(this.snapshot.context || {}), jobId, + rawPath: activeResumeRawPath, inputPath, hasEncodableTitle, reviewConfirmed, @@ -12195,17 +13474,14 @@ class PipelineService extends EventEmitter { return this.restartEncodeWithLastSettings(jobId, { ...options, immediate: true }); } + const resolvedRestartEncodeJob = await this.resolveExistingJobForAction(jobId, 'restart_encode'); + jobId = resolvedRestartEncodeJob.resolvedJobId; this.ensureNotBusy('restartEncodeWithLastSettings', jobId); logger.info('restartEncodeWithLastSettings:requested', { jobId }); this.cancelRequestedByJob.delete(Number(jobId)); const triggerReason = String(options?.triggerReason || 'manual').trim().toLowerCase(); - const job = await historyService.getJobById(jobId); - if (!job) { - const error = new Error(`Job ${jobId} nicht gefunden.`); - error.statusCode = 404; - throw error; - } + const job = resolvedRestartEncodeJob.job || await historyService.getJobById(jobId); const currentStatus = String(job.status || '').trim().toUpperCase(); if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK'].includes(currentStatus)) { @@ -12313,7 +13589,7 @@ class PipelineService extends EventEmitter { readyMediaProfile, job.raw_path ); - const activeRestartRawPath = resolvedRestartRawPath || String(job.raw_path || '').trim() || null; + let activeRestartRawPath = resolvedRestartRawPath || String(job.raw_path || '').trim() || null; let inputPath = isPreRipMode ? null @@ -12354,6 +13630,17 @@ class PipelineService extends EventEmitter { 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), title: job.title || null, @@ -12421,6 +13708,7 @@ class PipelineService extends EventEmitter { context: { ...(this.snapshot.context || {}), jobId: replacementJobId, + rawPath: activeRestartRawPath, inputPath, hasEncodableTitle, reviewConfirmed: false, @@ -12444,16 +13732,13 @@ class PipelineService extends EventEmitter { } 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 = await historyService.getJobById(jobId); - if (!sourceJob) { - const error = new Error(`Job ${jobId} nicht gefunden.`); - error.statusCode = 404; - throw error; - } + const sourceJob = resolvedRestartReviewJob.job || await historyService.getJobById(jobId); if (!sourceJob.raw_path) { const error = new Error('Review-Neustart nicht möglich: raw_path fehlt.'); @@ -12469,15 +13754,62 @@ class PipelineService extends EventEmitter { rawPath: sourceJob.raw_path }); const reviewSettings = await settingsService.getSettingsMap(); - const resolvedReviewRawPath = this.resolveCurrentRawPathForSettings( + let resolvedReviewRawPath = this.resolveCurrentRawPathForSettings( reviewSettings, reviewMediaProfile, sourceJob.raw_path ); if (!resolvedReviewRawPath) { - const error = new Error(`Review-Neustart nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`); - error.statusCode = 400; - throw error; + 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) @@ -12556,6 +13888,7 @@ class PipelineService extends EventEmitter { const nextMakemkvInfoJson = mkInfo && typeof mkInfo === 'object' ? JSON.stringify({ ...mkInfo, + rawPath: resolvedReviewRawPath, analyzeContext: { ...(mkInfo?.analyzeContext || {}), playlistAnalysis: null, @@ -12584,90 +13917,136 @@ class PipelineService extends EventEmitter { raw_path: resolvedReviewRawPath }; - const replacementJob = await historyService.createJob({ - discDevice: sourceJob.disc_device || null, - status: 'MEDIAINFO_CHECK', - detectedTitle: sourceJob.detected_title || sourceJob.title || null - }); - 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 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; - 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), - 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: null, - encode_review_confirmed: 0, - ...jobUpdatePayload - }); - - // 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(() => {}); + if (reuseCurrentJob) { + await historyService.resetProcessLog(targetJobId); + await historyService.updateJob(targetJobId, { + ...jobUpdatePayload, + 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 + }); + 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), + 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( - replacementJobId, + targetJobId, 'USER_ACTION', `Queue-Eintrag entfernt (Review-Neustart): ${removedQueueActionLabel}` ); } if (shouldRealignEncodeInput) { await historyService.appendLog( - replacementJobId, + targetJobId, 'SYSTEM', - `Review-Neustart: Encode-Input auf aktuellen RAW-Pfad abgeglichen: ${existingEncodeInputPath || '-'} -> ${normalizedReviewInputPath}` + `Review-Neustart: Encode-Input auf aktuellen RAW-Pfad abgeglichen: ${existingEncodeInputPath || '-'} -> ${activeReviewInputPath || '-'}` ); } await historyService.appendLog( - replacementJobId, + targetJobId, 'USER_ACTION', - `Review-Neustart aus RAW angefordert.${forcePlaylistReselection ? ' Playlist-Auswahl wird zurückgesetzt.' : ''} MakeMKV Full-Analyse wird vollständig neu ausgeführt.` + `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.` ); - await historyService.retireJobInFavorOf(jobId, replacementJobId, { - reason: 'restart_review' - }); + if (!reuseCurrentJob) { + await historyService.retireJobInFavorOf(jobId, targetJobId, { + reason: 'restart_review' + }); + } await this.setState('MEDIAINFO_CHECK', { - activeJobId: replacementJobId, + activeJobId: targetJobId, progress: 0, eta: null, statusText: 'Titel-/Spurprüfung wird neu gestartet...', context: { ...(this.snapshot.context || {}), - jobId: replacementJobId, + jobId: targetJobId, reviewConfirmed: false, mediaInfoReview: null } }); - this.runReviewForRawJob(replacementJobId, resolvedReviewRawPath, { + this.runReviewForRawJob(targetJobId, activeReviewRawPath, { mode: options?.mode || 'reencode', - sourceJobId: Number(jobId), + sourceJobId: reviewSourceJobId, forcePlaylistReselection, forceFreshAnalyze: true, previousEncodePlan }).catch((error) => { - logger.error('restartReviewFromRaw:background-failed', { jobId: replacementJobId, sourceJobId: jobId, error: errorToMeta(error) }); - this.failJob(replacementJobId, 'MEDIAINFO_CHECK', error).catch((failError) => { + 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: replacementJobId, + jobId: targetJobId, error: errorToMeta(failError) }); }); @@ -12678,8 +14057,8 @@ class PipelineService extends EventEmitter { started: true, stage: 'MEDIAINFO_CHECK', sourceJobId: Number(jobId), - jobId: replacementJobId, - replacedSourceJob: true + jobId: targetJobId, + replacedSourceJob }; } @@ -14452,6 +15831,13 @@ class PipelineService extends EventEmitter { error_message: null }); + // Datei-Zuweisungen im Explorer freigeben + try { + await require('./converterScanService').clearAssignmentsForJob(jobId); + } catch (_clearErr) { + // nicht kritisch + } + await historyService.appendLog( jobId, 'SYSTEM', @@ -14495,6 +15881,12 @@ class PipelineService extends EventEmitter { } 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); } })(); @@ -14949,15 +16341,12 @@ class PipelineService extends EventEmitter { } 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 = await historyService.getJobById(jobId); - if (!sourceJob) { - const error = new Error(`Job ${jobId} nicht gefunden.`); - error.statusCode = 404; - throw error; - } + const sourceJob = resolvedCdRipJob.job || await historyService.getJobById(jobId); let activeJobId = Number(jobId); let activeJob = sourceJob; @@ -14982,6 +16371,11 @@ class PipelineService extends EventEmitter { 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, @@ -14996,7 +16390,7 @@ class PipelineService extends EventEmitter { end_time: null, output_path: null, disc_device: sourceJob.disc_device || null, - raw_path: sourceHasSuccessfulRawRip ? sourceResolvedRawPath : null, + raw_path: replacementRawPath, rip_successful: sourceHasSuccessfulRawRip ? 1 : 0, makemkv_info_json: sourceJob.makemkv_info_json || null, handbrake_info_json: null, @@ -15986,9 +17380,29 @@ class PipelineService extends EventEmitter { const baseName = path.basename(relPath, path.extname(relPath)); const detectedTitle = baseName || 'Converter Job'; - const converterMediaType = options.converterMediaType + 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 job = await historyService.createJob({ discDevice: null, status: 'READY_TO_START', @@ -16059,8 +17473,8 @@ class PipelineService extends EventEmitter { if (folderName) { // ── Ordner-Upload (wie Klangkiste target_path): alle Dateien in EINEN Ordner ── const safeFolderName = sanitizeFileName(folderName) || `upload_${Date.now()}`; - const uniqueFolderName = `${safeFolderName}_${Date.now()}`; - const targetDir = path.join(rawDir, uniqueFolderName); + const targetDir = ensureUniqueOutputPath(path.join(rawDir, safeFolderName)); + const uniqueFolderName = path.basename(targetDir); ensureDir(targetDir); for (const file of normalizedFiles) { @@ -16102,8 +17516,8 @@ class PipelineService extends EventEmitter { const baseNameClean = sanitizeFileName( path.basename(originalName, path.extname(originalName)) ) || `upload_${Date.now()}`; - const subfolder = `${baseNameClean}_${Date.now()}`; - const targetDir = path.join(rawDir, subfolder); + const targetDir = ensureUniqueOutputPath(path.join(rawDir, baseNameClean)); + const subfolder = path.basename(targetDir); ensureDir(targetDir); const safeFileName = sanitizeFileNameWithExtension(originalName) || originalName; @@ -16120,7 +17534,8 @@ class PipelineService extends EventEmitter { /** * Erstellt Converter-Jobs aus ausgewählten Dateipfaden (relativ zu converter_raw_dir). - * Audio-Dateien können als einzelne Jobs oder als ein gemeinsamer Job angelegt werden. + * 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 @@ -16138,7 +17553,7 @@ class PipelineService extends EventEmitter { } const audioRelPaths = []; - const nonAudioRelPaths = []; + const nonAudioRelPaths = []; // Videos, ISOs, Ordner, sonstige for (const relPath of relPaths) { const ext = path.extname(String(relPath || '')).toLowerCase(); @@ -16151,12 +17566,13 @@ class PipelineService extends EventEmitter { const createdJobs = []; - // Nicht-Audio (Video, ISO, Ordner): immer ein Job pro Eintrag + // Nicht-Audio (Video, Ordner, sonstige): immer ein Job pro Eintrag for (const relPath of nonAudioRelPaths) { const job = await this.createConverterJobFromEntry(relPath, {}); 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)); @@ -16183,6 +17599,8 @@ class PipelineService extends EventEmitter { 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)` @@ -16204,6 +17622,619 @@ class PipelineService extends EventEmitter { 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 (String(job.media_type || '').trim().toLowerCase() !== 'converter') { + 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', + 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', + 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 (String(job.media_type || '').trim().toLowerCase() !== 'converter') { + 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', + 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', + 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', + 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', + 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 (String(job.media_type || '').trim().toLowerCase() !== 'converter') { + 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('Draft kann nur für nicht gestartete 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 outputFormat = normalizeText(config.outputFormat || existingPlan.outputFormat || defaultFormat, 20) + ?.toLowerCase() || defaultFormat; + + 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', + converterMediaType, + outputFormat, + userPreset, + audioFormatOptions: nextAudioFormatOptions, + metadata: nextMetadata, + tracks: nextTracks, + musicBrainz: nextMusicBrainz, + preEncodeItems: nextPreEncodeItems, + postEncodeItems: nextPostEncodeItems, + reviewConfirmed: false + }; + + const newCoverUrl = nextMetadata?.coverUrl || null; + const jobPatch = { + 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.). * @@ -16237,6 +18268,9 @@ class PipelineService extends EventEmitter { || 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( @@ -16259,6 +18293,7 @@ class PipelineService extends EventEmitter { // Ausgabepfad bestimmen let outputPath = null; let outputDir = null; + let audioTrackTemplate = null; const outputFormat = String(config.outputFormat || 'mkv').trim().toLowerCase(); const baseName = sanitizeFileName( path.basename(inputPath, path.extname(inputPath)) @@ -16266,23 +18301,107 @@ class PipelineService extends EventEmitter { 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 videoTemplate = String( + const videoTemplateRaw = String( settings?.converter_output_template_video || '{title}' - ).replace('{title}', safeTitle); - outputPath = path.join(movieDir, `${videoTemplate}.${outputFormat}`); + ).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); - if (isFolder) { - const audioTemplate = String( - settings?.converter_output_template_audio || '{artist} - {title}' - ).replace('{title}', safeTitle).replace('{artist}', safeTitle); - outputDir = path.join(audioDir, sanitizeFileName(audioTemplate) || safeTitle); + 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 audioTemplate = String( - settings?.converter_output_template_audio || '{artist} - {title}' - ).replace('{title}', safeTitle).replace('{artist}', safeTitle); - outputPath = path.join(audioDir, `${sanitizeFileName(audioTemplate) || safeTitle}.${outputFormat}`); + 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}`); } } @@ -16300,11 +18419,31 @@ class PipelineService extends EventEmitter { } } + 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', converterMediaType, inputPath, + inputPaths: existingPlan.inputPaths || null, outputPath: outputPath || null, outputDir: outputDir || null, outputFormat, @@ -16312,7 +18451,16 @@ class PipelineService extends EventEmitter { 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: true }; diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 4ebdccc..6e065e1 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -156,6 +156,40 @@ function normalizeTrackIds(rawList) { 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 = String(Math.trunc(value)); + if (dedupe && seen.has(normalized)) { + continue; + } + if (dedupe) { + seen.add(normalized); + } + output.push(normalized); + } + return output; +} + +function normalizePositiveIndexes(rawList, maxValue = null) { + const values = normalizeTrackIds(rawList) + .map((item) => Number(item)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)); + if (!Number.isFinite(maxValue) || maxValue <= 0) { + return values; + } + const limit = Math.trunc(maxValue); + return values.filter((value) => value <= limit); +} + function normalizeNonNegativeInteger(rawValue) { if (rawValue === null || rawValue === undefined) { return null; @@ -1209,10 +1243,14 @@ class SettingsService { } const audioTrackIds = normalizeTrackIds(rawSelection.audioTrackIds); - const subtitleTrackIds = normalizeTrackIds(rawSelection.subtitleTrackIds); + const subtitleTrackIds = normalizeTrackIdSequence(rawSelection.subtitleTrackIds, { dedupe: false }); const subtitleBurnTrackId = normalizeTrackIds([rawSelection.subtitleBurnTrackId])[0] || null; const subtitleDefaultTrackId = normalizeTrackIds([rawSelection.subtitleDefaultTrackId])[0] || null; const subtitleForcedTrackId = normalizeTrackIds([rawSelection.subtitleForcedTrackId])[0] || null; + const subtitleForcedTrackIndexes = normalizePositiveIndexes( + rawSelection.subtitleForcedTrackIndexes, + subtitleTrackIds.length + ); const subtitleForcedOnly = Boolean(rawSelection.subtitleForcedOnly); const filteredExtra = removeSelectionArgs(extra); const overrideArgs = [ @@ -1227,7 +1265,9 @@ class SettingsService { if (subtitleDefaultTrackId !== null) { overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`); } - if (subtitleForcedTrackId !== null) { + if (subtitleForcedTrackIndexes.length > 0) { + overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackIndexes.join(',')}`); + } else if (subtitleForcedTrackId !== null) { overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`); } else if (subtitleForcedOnly) { overrideArgs.push('--subtitle-forced'); @@ -1245,6 +1285,7 @@ class SettingsService { subtitleTrackIds, subtitleBurnTrackId, subtitleDefaultTrackId, + subtitleForcedTrackIndexes, subtitleForcedTrackId, subtitleForcedOnly } @@ -1258,6 +1299,7 @@ class SettingsService { subtitleTrackIds, subtitleBurnTrackId, subtitleDefaultTrackId, + subtitleForcedTrackIndexes, subtitleForcedTrackId, subtitleForcedOnly } diff --git a/backend/src/utils/encodePlan.js b/backend/src/utils/encodePlan.js index 0e1061f..9ea8943 100644 --- a/backend/src/utils/encodePlan.js +++ b/backend/src/utils/encodePlan.js @@ -3,6 +3,16 @@ const { splitArgs } = require('./commandLine'); const DEFAULT_AUDIO_COPY_MASK = ['aac', 'ac3', 'eac3', 'truehd', 'dts', 'dtshd', 'mp3', 'flac']; const DEFAULT_AUDIO_FALLBACK = 'av_aac'; +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 ISO2_TO_3_LANGUAGE = { de: 'deu', en: 'eng', @@ -271,7 +281,7 @@ function parseMediaInfoFile(mediaInfoJson, fileInfo, index) { channels: item?.Channels || item?.Channel_s_ || null })); - const subtitleTracks = tracks + const subtitleTracksRaw = tracks .filter((item) => { const type = String(item?.['@type'] || '').toLowerCase(); return type === 'text' || type === 'subtitle'; @@ -282,8 +292,14 @@ function parseMediaInfoFile(mediaInfoJson, fileInfo, index) { language: normalizeLanguage(item?.Language || item?.Language_String3 || item?.Language_String || 'und'), languageLabel: item?.Language_String3 || item?.Language || item?.Language_String || 'und', title: item?.Title || null, - format: item?.Format || null + format: item?.Format || null, + defaultFlag: parseBooleanFlag(item?.Default ?? item?.Default_String ?? item?.IsDefault ?? item?.isDefault), + forcedFlag: parseBooleanFlagNullable(item?.Forced ?? item?.Forced_String ?? item?.IsForced ?? item?.isForced), + sdhFlag: parseSubtitleSdhFlag(item), + eventCount: parseSubtitleEventCount(item), + streamSizeBytes: parseSubtitleStreamSizeBytes(item) })); + const subtitleTracks = annotateSubtitleTracks(subtitleTracksRaw); const videoTracks = tracks .filter((item) => String(item?.['@type'] || '').toLowerCase() === 'video') @@ -350,6 +366,467 @@ function parseTrackIdList(raw) { .filter((item) => Number.isFinite(item)); } +function parseBooleanFlag(value) { + return parseBooleanFlagNullable(value) === true; +} + +function parseBooleanFlagNullable(value) { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value === 1; + } + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (raw === 'yes' || raw === 'true' || raw === '1') { + return true; + } + if (raw === 'no' || raw === 'false' || raw === '0') { + 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 = parseBooleanFlagNullable(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 = parseBooleanFlagNullable(candidate); + if (parsed === true || parsed === false) { + return parsed; + } + } + + const serviceKind = String(track?.serviceKind || track?.ServiceKind || '').trim().toLowerCase(); + if (!serviceKind) { + return null; + } + if (serviceKind.includes('sdh') || serviceKind.includes('cc') || serviceKind.includes('hearing')) { + return true; + } + return null; +} + +function parseSubtitleEventCount(track) { + const candidates = [ + track?.CountOfEvents, + track?.countOfEvents, + track?.EventCount, + track?.eventCount, + 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?.StreamSize, + track?.streamSize, + track?.StreamSize_Original, + track?.streamSizeOriginal, + 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?.StreamSize_String, + track?.streamSizeString, + track?.StreamSize_Original_String, + track?.streamSizeOriginalString, + track?.Size_String, + track?.sizeString + ]; + for (const candidate of textCandidates) { + const text = String(candidate || '').trim(); + if (!text) { + continue; + } + const match = text.match(/([0-9]+(?:[.,][0-9]+)?)\s*([kmgt]?b)/i); + if (!match) { + continue; + } + const value = Number(String(match[1]).replace(',', '.')); + if (!Number.isFinite(value) || value <= 0) { + continue; + } + const unit = String(match[2] || 'b').toLowerCase(); + const factorByUnit = { + b: 1, + kb: 1024, + mb: 1024 ** 2, + gb: 1024 ** 3, + tb: 1024 ** 4 + }; + const factor = factorByUnit[unit] || 1; + return Math.max(0, Math.trunc(value * factor)); + } + return null; +} + +function normalizeSubtitleConfidence(raw) { + const value = String(raw || '').trim().toLowerCase(); + if (value === 'high' || value === 'medium' || value === 'low') { + return value; + } + return 'low'; +} + +function subtitleConfidenceScore(raw) { + return SUBTITLE_CONFIDENCE_SCORES[normalizeSubtitleConfidence(raw)] || 0; +} + +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 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?.sourceConfidence) - subtitleConfidenceScore(a?.sourceConfidence); + 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 isHeuristicForcedSubtitleCandidate(languageEntries, candidate) { + if (!candidate) { + return false; + } + if (!isLikelyBitmapSubtitleFormat(candidate)) { + return false; + } + const comparable = (Array.isArray(languageEntries) ? languageEntries : []) + .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 annotateSubtitleTracks(subtitleTracks) { + const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : []; + if (tracks.length === 0) { + return []; + } + + const entries = tracks.map((track, index) => ({ + ...track, + language: normalizeLanguage(track?.language || track?.languageLabel || 'und'), + defaultFlag: Boolean(track?.defaultFlag), + forcedFlag: parseSubtitleForcedFlag(track), + forcedTrack: false, + sourceConfidence: null, + confidenceSource: 'heuristic', + duplicate: false, + selected: false, + subtitleType: 'full', + forcedAvailable: false, + forcedSourceTrackIds: [], + sdhLikely: (parseSubtitleSdhFlag(track) === true) || Boolean(track?.sdhLikely) || isLikelySdhSubtitleTrack(track), + originalIndex: index + })); + + const byLanguage = new Map(); + for (const entry of entries) { + if (!byLanguage.has(entry.language)) { + byLanguage.set(entry.language, []); + } + byLanguage.get(entry.language).push(entry); + } + + for (const languageEntries of byLanguage.values()) { + for (const entry of languageEntries) { + const forcedByFlag = entry.forcedFlag === true; + const forcedBlockedByFlag = entry.forcedFlag === false; + const forcedByTitle = !entry.sdhLikely && !forcedBlockedByFlag && isLikelyForcedSubtitleTrack(entry); + + if (forcedByFlag) { + entry.forcedTrack = true; + entry.sourceConfidence = 'high'; + entry.confidenceSource = 'explicit_flag'; + } else if (forcedByTitle) { + entry.forcedTrack = true; + entry.sourceConfidence = 'medium'; + entry.confidenceSource = 'title'; + } + } + + if (!languageEntries.some((entry) => entry.forcedTrack)) { + const candidates = languageEntries + .filter((entry) => !entry.sdhLikely && entry.forcedFlag !== false) + .sort(compareSubtitleTracksByForcedHeuristic); + const forcedCandidate = candidates[0] || null; + if (forcedCandidate && isHeuristicForcedSubtitleCandidate(languageEntries, forcedCandidate)) { + forcedCandidate.forcedTrack = true; + forcedCandidate.sourceConfidence = 'low'; + forcedCandidate.confidenceSource = 'heuristic'; + } + } + + for (const entry of languageEntries) { + if (!entry.forcedTrack) { + continue; + } + entry.sourceConfidence = normalizeSubtitleConfidence(entry.sourceConfidence || 'low'); + } + + const forcedEntries = languageEntries.filter((entry) => entry.forcedTrack); + const fullEntries = languageEntries.filter((entry) => !entry.forcedTrack && !entry.sdhLikely); + const sdhEntries = languageEntries.filter((entry) => !entry.forcedTrack && 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 = forcedEntries + .map((entry) => Number(entry?.sourceTrackId ?? entry?.id)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)); + const forcedAvailable = Boolean(forcedWinner); + + for (const entry of languageEntries) { + const typeWinner = entry.forcedTrack + ? forcedWinner + : (entry.sdhLikely ? sdhWinner : fullWinner); + entry.duplicate = Boolean(typeWinner && typeWinner !== entry); + if (entry.forcedTrack) { + entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate); + } else if (entry.sdhLikely) { + entry.selected = Boolean(!fullWinner && typeWinner && typeWinner === entry && !entry.duplicate); + } else { + entry.selected = Boolean(typeWinner && typeWinner === entry && !entry.duplicate); + } + entry.subtitleType = entry.forcedTrack ? 'forced' : 'full'; + entry.forcedAvailable = forcedAvailable; + entry.forcedSourceTrackIds = forcedSourceTrackIds; + const fullHasForced = !entry.forcedTrack && Boolean( + entry.fullHasForced + ?? entry.subtitleFullHasForced + ?? entry.hasForcedVariant + ); + const explicitForcedOnly = entry.isForcedOnly ?? entry.forcedOnly ?? entry.subtitlePreviewForcedOnly; + entry.isForcedOnly = typeof explicitForcedOnly === 'boolean' + ? explicitForcedOnly + : (entry.forcedTrack && !fullHasForced); + entry.fullHasForced = fullHasForced; + } + } + + return entries + .sort((a, b) => a.originalIndex - b.originalIndex) + .map((entry) => { + const { originalIndex, ...rest } = entry; + return rest; + }); +} + function parseEncoderList(raw) { return String(raw || '') .split(',') @@ -638,7 +1115,10 @@ function buildTrackSelectors(settings, presetProfile) { function selectTrackIds(tracks, selector, trackType) { const available = Array.isArray(tracks) ? tracks : []; - if (available.length === 0) { + const selectable = trackType === 'subtitle' + ? available.filter((track) => !Boolean(track?.duplicate)) + : available; + if (selectable.length === 0) { return []; } @@ -648,13 +1128,13 @@ function selectTrackIds(tracks, selector, trackType) { if (selector.mode === 'all') { if (selector.firstOnly) { - return [available[0].id]; + return [selectable[0].id]; } - return available.map((track) => track.id); + return selectable.map((track) => track.id); } if (selector.mode === 'explicit') { - const explicit = available + const explicit = selectable .filter((track) => selector.explicitIds.includes(track.id)) .map((track) => track.id); if (selector.firstOnly) { @@ -664,7 +1144,7 @@ function selectTrackIds(tracks, selector, trackType) { } if (selector.mode === 'language') { - const matches = available.filter((track) => selector.languages.includes(track.language)); + const matches = selectable.filter((track) => selector.languages.includes(track.language)); if (selector.firstOnly) { return matches.length > 0 ? [matches[0].id] : []; } @@ -672,11 +1152,11 @@ function selectTrackIds(tracks, selector, trackType) { } if (selector.mode === 'first') { - return [available[0].id]; + return [selectable[0].id]; } if (trackType === 'audio') { - return [available[0].id]; + return [selectable[0].id]; } return []; @@ -916,21 +1396,45 @@ function buildMediainfoReview({ const normalizedSubtitle = title.subtitleTracks.map((track) => { const selectedByRule = selectedSubtitleIds.includes(track.id); const subtitleFlags = computeSubtitleFlags(track.id, selectedSubtitleIds, trackSelectors.subtitle); + const inferredForced = Boolean( + track?.forcedTrack + || String(track?.subtitleType || '').trim().toLowerCase() === 'forced' + ); + const inferredForcedOnly = Boolean( + track?.isForcedOnly + ?? track?.forcedOnly + ?? track?.subtitlePreviewForcedOnly + ?? inferredForced + ); + const inferredDefault = Boolean(track?.defaultFlag); + const subtitlePreviewFlags = []; + if (subtitleFlags.burned) { + subtitlePreviewFlags.push('burned'); + } + if (subtitleFlags.forced || inferredForced) { + subtitlePreviewFlags.push('forced'); + } + if (subtitleFlags.forcedOnly || inferredForcedOnly) { + subtitlePreviewFlags.push('forced-only'); + } + if (subtitleFlags.default || inferredDefault) { + subtitlePreviewFlags.push('default'); + } const subtitlePreviewSummary = !selectedByRule ? 'Nicht übernommen' - : (subtitleFlags.flags.length > 0 - ? `Übernehmen (${subtitleFlags.flags.join(', ')})` + : (subtitlePreviewFlags.length > 0 + ? `Übernehmen (${subtitlePreviewFlags.join(', ')})` : 'Übernehmen'); return { ...track, selectedByRule, subtitlePreviewSummary, - subtitlePreviewFlags: subtitleFlags.flags, + subtitlePreviewFlags: selectedByRule ? subtitlePreviewFlags : [], subtitlePreviewBurnIn: subtitleFlags.burned, - subtitlePreviewForced: subtitleFlags.forced, - subtitlePreviewForcedOnly: subtitleFlags.forcedOnly, - subtitlePreviewDefaultTrack: subtitleFlags.default + subtitlePreviewForced: selectedByRule ? (subtitleFlags.forced || inferredForced) : false, + subtitlePreviewForcedOnly: selectedByRule ? (subtitleFlags.forcedOnly || inferredForcedOnly) : false, + subtitlePreviewDefaultTrack: selectedByRule ? (subtitleFlags.default || inferredDefault) : false }; }); diff --git a/backend/src/utils/files.js b/backend/src/utils/files.js index 98fb6ea..e16145a 100644 --- a/backend/src/utils/files.js +++ b/backend/src/utils/files.js @@ -44,13 +44,26 @@ function sanitizeFileNameWithExtension(input) { } function renderTemplate(template, values) { - return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => { - const val = values[key.trim()]; + const rawSource = String(template || '${title} (${year})'); + const resolveToken = (rawKey) => { + const key = String(rawKey || '').trim(); + const val = values[key]; if (val === undefined || val === null || val === '') { return 'unknown'; } return String(val); - }); + }; + + const source = ( + /[{}]/.test(rawSource) + ? rawSource + : rawSource.replace(/\b(trackNr|trackNumber|artist|album|title|year)\b/g, '{$1}') + ); + + // Support both ${key} and legacy {key} placeholders (+ recovered bare tokens). + return source + .replace(/\$\{([^}]+)\}/g, (_m, key) => resolveToken(key)) + .replace(/\{([^{}$]+)\}/g, (_m, key) => resolveToken(key)); } function findLargestMediaFile(dirPath, extensions = ['.mkv', '.mp4']) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d632aad..08289e8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.12.0-15", + "version": "0.12.0-16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.12.0-15", + "version": "0.12.0-16", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 3afde2d..251d84a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.12.0-15", + "version": "0.12.0-16", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 9d37d1e..bc59dec 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -617,6 +617,7 @@ export const api = { const body = {}; if (options.keepBoth) body.keepBoth = true; if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders; + if (options.reuseCurrentJob) body.reuseCurrentJob = true; const result = await request(`/pipeline/restart-review/${jobId}`, { method: 'POST', body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined @@ -920,6 +921,33 @@ export const api = { body: JSON.stringify({ relPaths, audioMode }) }); }, + converterAssignFilesToJob(jobId, relPaths = []) { + afterMutationInvalidate(['/converter/jobs']); + return request(`/converter/jobs/${encodeURIComponent(jobId)}/assign-files`, { + method: 'POST', + body: JSON.stringify({ relPaths }) + }); + }, + converterRemoveFileFromJob(jobId, relPath) { + afterMutationInvalidate(['/converter/jobs']); + return request(`/converter/jobs/${encodeURIComponent(jobId)}/remove-file`, { + method: 'POST', + body: JSON.stringify({ relPath }) + }); + }, + converterRemoveInputFromJob(jobId, inputPath) { + afterMutationInvalidate(['/converter/jobs']); + return request(`/converter/jobs/${encodeURIComponent(jobId)}/remove-input`, { + method: 'POST', + body: JSON.stringify({ inputPath }) + }); + }, + converterUpdateJobConfig(jobId, config = {}) { + return request(`/converter/jobs/${encodeURIComponent(jobId)}/config`, { + method: 'POST', + body: JSON.stringify(config) + }); + }, converterUploadFiles(formData) { return request('/converter/upload', { method: 'POST', body: formData }); }, diff --git a/frontend/src/components/CdMetadataDialog.jsx b/frontend/src/components/CdMetadataDialog.jsx index e944a11..67d93a9 100644 --- a/frontend/src/components/CdMetadataDialog.jsx +++ b/frontend/src/components/CdMetadataDialog.jsx @@ -86,6 +86,8 @@ export default function CdMetadataDialog({ const tocTracks = Array.isArray(context?.tracks) ? context.tracks : []; + const contextJobId = Number(context?.jobId || 0); + useEffect(() => { if (!visible) { return; @@ -103,7 +105,7 @@ export default function CdMetadataDialog({ titles[t.position] = t.title || `Track ${t.position}`; } setTrackTitles(titles); - }, [visible, context]); + }, [visible, contextJobId]); useEffect(() => { if (!selected) { diff --git a/frontend/src/components/CdRipConfigPanel.jsx b/frontend/src/components/CdRipConfigPanel.jsx index b96acde..bc3461a 100644 --- a/frontend/src/components/CdRipConfigPanel.jsx +++ b/frontend/src/components/CdRipConfigPanel.jsx @@ -64,7 +64,12 @@ function normalizePosition(value) { } function normalizeTrackText(value) { - return String(value || '').replace(/\s+/g, ' ').trim(); + return String(value || '') + .normalize('NFC') + .replace(/[♥❤♡❥❣❦❧]/gu, ' ') + .replace(/\p{C}+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); } function normalizeYear(value) { diff --git a/frontend/src/components/ConverterFileExplorer.jsx b/frontend/src/components/ConverterFileExplorer.jsx index 092c8dc..1f77d25 100644 --- a/frontend/src/components/ConverterFileExplorer.jsx +++ b/frontend/src/components/ConverterFileExplorer.jsx @@ -26,7 +26,7 @@ function mediaTypeBadge(type) { return ; } -/** Navigiert den Baum per Pfad-String */ +/** Navigiert den Baum per Pfad-String (Ordner UND Dateien) */ function getNodeByPath(root, targetPath) { if (!root) return null; if ((root.path || '') === (targetPath || '')) return root; @@ -34,6 +34,8 @@ function getNodeByPath(root, targetPath) { if (child.type === 'folder') { const found = getNodeByPath(child, targetPath); if (found) return found; + } else if ((child.path || '') === (targetPath || '')) { + return child; } } return null; @@ -78,6 +80,52 @@ function collectDescendantFilePaths(node) { return result; } +/** Zustände, in denen ein Job aktiv läuft → Dateien sind gesperrt */ +const LOCKED_JOB_STATES = new Set([ + 'ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', + 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING' +]); + +/** Datei ist gesperrt wenn ihr Job gerade aktiv läuft */ +function isNodeLocked(node) { + if (!node || node.type !== 'file') return false; + const jobId = Number(node.jobId); + if (!Number.isFinite(jobId) || jobId <= 0) return false; + return LOCKED_JOB_STATES.has(String(node.jobStatus || '').trim().toUpperCase()); +} + +/** Alle nicht bereits einem Job zugewiesenen und nicht gesperrten Dateipfade sammeln */ +function collectDescendantSelectableFilePaths(node) { + const result = []; + for (const child of (node?.children || [])) { + if (child.type === 'file') { + const assignedJobId = Number(child.jobId); + if (!Number.isFinite(assignedJobId) || assignedJobId <= 0) { + result.push(child.path || ''); + } + } else if (child.type === 'folder') { + result.push(...collectDescendantSelectableFilePaths(child)); + } + } + return result; +} + +/** Alle nicht bereits einem Job zugewiesenen und nicht gesperrten Datei-Knoten sammeln */ +function collectDescendantSelectableFileNodes(node) { + const result = []; + for (const child of (node?.children || [])) { + if (child.type === 'file') { + const assignedJobId = Number(child.jobId); + if (!Number.isFinite(assignedJobId) || assignedJobId <= 0) { + result.push(child); + } + } else if (child.type === 'folder') { + result.push(...collectDescendantSelectableFileNodes(child)); + } + } + return result; +} + /** Nur "Wurzel"-Selektionen: Pfade, die keinen ausgewählten Elternpfad haben */ function getRootSelections(paths) { const set = new Set(paths); @@ -98,7 +146,12 @@ function collectFolderPaths(node, result = []) { // ── Hauptkomponente ─────────────────────────────────────────────────────────── -export default function ConverterFileExplorer({ onSelectionChange, refreshToken, navigateToPath }) { +export default function ConverterFileExplorer({ + onSelectionChange, + refreshToken, + navigateToPath, + onAssignmentChanged +}) { const toastRef = useRef(null); const explorerRef = useRef(null); @@ -111,8 +164,11 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, const [currentPath, setCurrentPath] = useState(''); const [expandedFolders, setExpandedFolders] = useState(() => new Set([''])); - // Auswahl + // Auswahl (Checkbox → Job-Zuweisung) const [selectedPaths, setSelectedPaths] = useState([]); + // Aktive Zeilen-Auswahl (Klick → Rename/Delete/Move) + const [activePaths, setActivePaths] = useState([]); + const [activeAnchor, setActiveAnchor] = useState(null); // Seitenleiste const [sidebarQuery, setSidebarQuery] = useState(''); @@ -123,10 +179,13 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, const [renameName, setRenameName] = useState(''); const [moveTarget, setMoveTarget] = useState(''); const [busy, setBusy] = useState(false); + const [jobUnassignTarget, setJobUnassignTarget] = useState(null); // Stabile Ref für onSelectionChange const onSelectionChangeRef = useRef(onSelectionChange); useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]); + const onAssignmentChangedRef = useRef(onAssignmentChanged); + useEffect(() => { onAssignmentChangedRef.current = onAssignmentChanged; }, [onAssignmentChanged]); // ── Baum laden ───────────────────────────────────────────────────────────── @@ -155,35 +214,42 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, // eslint-disable-next-line react-hooks/exhaustive-deps }, [navigateToPath]); - // Auswahl an Eltern melden: nur Wurzel-Selektionen (keine redundanten Kind-Pfade) + // Auswahl an Eltern melden: Ordner werden zu ihren Datei-Kindern expandiert useEffect(() => { if (!tree) return; const rootPaths = getRootSelections(selectedPaths); - const report = rootPaths.map((p) => { + const report = []; + for (const p of rootPaths) { const node = getNodeByPath(tree, p); - if (!node) return null; - return { - relPath: node.path, - entryType: node.type === 'folder' ? 'directory' : 'file', - detectedMediaType: node.detectedMediaType || null, - detectedFormat: node.detectedFormat || null - }; - }).filter(Boolean); + if (!node) continue; + if (node.type === 'folder') { + const fileNodes = collectDescendantSelectableFileNodes(node); + if (fileNodes.length > 0) { + for (const fn of fileNodes) { + report.push({ + relPath: fn.path, + entryType: 'file', + detectedMediaType: fn.detectedMediaType || null, + detectedFormat: fn.detectedFormat || null + }); + } + } else { + // Leerer Ordner: als Ordner-Eintrag weitergeben + report.push({ relPath: node.path, entryType: 'directory', detectedMediaType: null, detectedFormat: null }); + } + } else { + report.push({ + relPath: node.path, + entryType: 'file', + detectedMediaType: node.detectedMediaType || null, + detectedFormat: node.detectedFormat || null + }); + } + } onSelectionChangeRef.current?.(report); }, [selectedPaths, tree]); - // Outside-Click → Auswahl aufheben - useEffect(() => { - function handleOutside(event) { - if (activeModal) return; - if (!explorerRef.current) return; - if (!explorerRef.current.contains(event.target)) { - setSelectedPaths([]); - } - } - window.addEventListener('mousedown', handleOutside); - return () => window.removeEventListener('mousedown', handleOutside); - }, [activeModal]); + // Auswahl bleibt erhalten — kein Outside-Click-Handler // ── Navigation ───────────────────────────────────────────────────────────── @@ -194,7 +260,8 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, function navigateTo(pathStr) { setCurrentPath(pathStr || ''); - setSelectedPaths([]); + setActivePaths([]); + setActiveAnchor(null); setExpandedFolders((prev) => new Set(prev).add(pathStr || '')); } @@ -236,10 +303,31 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, function handleCheckboxChange(item, checked) { const p = item.path || ''; + if (item.type === 'file') { + // Gesperrte Dateien (Job läuft) → keine Aktion möglich + if (isNodeLocked(item)) return; + + const assignedJobId = Number(item.jobId); + const isAssigned = Number.isFinite(assignedJobId) && assignedJobId > 0; + if (isAssigned && !checked) { + setJobUnassignTarget({ + relPath: p, + jobId: Math.trunc(assignedJobId), + jobTitle: String(item.jobTitle || '').trim() || null + }); + setActiveModal('job-unassign'); + return; + } + if (isAssigned) { + return; + } + } + if (item.type === 'folder') { const folderNode = getNodeByPath(tree, p); - const descendantFiles = collectDescendantFilePaths(folderNode); + const descendantFiles = collectDescendantSelectableFilePaths(folderNode); if (checked) { + if (descendantFiles.length === 0) return; setSelectedPaths((prev) => Array.from(new Set([...prev, p, ...descendantFiles]))); } else { const toRemove = new Set([p, ...descendantFiles]); @@ -271,6 +359,35 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, } } + async function handleRemoveFromJob() { + if (!jobUnassignTarget?.jobId || !jobUnassignTarget?.relPath) return; + setBusy(true); + try { + const result = await api.converterRemoveFileFromJob(jobUnassignTarget.jobId, jobUnassignTarget.relPath); + const removedRelPath = String(result?.removedRelPath || jobUnassignTarget.relPath || '').trim(); + toastRef.current?.show({ + severity: 'success', + summary: 'Aus Job entfernt', + detail: removedRelPath || 'Datei wurde aus dem Job entfernt.', + life: 2800 + }); + setSelectedPaths((prev) => prev.filter((entryPath) => entryPath !== removedRelPath)); + setJobUnassignTarget(null); + setActiveModal(''); + await loadTree(); + onAssignmentChangedRef.current?.(); + } catch (err) { + toastRef.current?.show({ + severity: 'error', + summary: 'Fehler', + detail: err.message || 'Datei konnte nicht aus dem Job entfernt werden.', + life: 4200 + }); + } finally { + setBusy(false); + } + } + function handleOpen(item) { if (item.type !== 'folder') return; navigateTo(item.path || ''); @@ -294,16 +411,16 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, } async function handleRename() { - if (!selectedPaths.length) return; + if (!activePaths.length) return; const name = renameName.trim(); if (!name) return; setBusy(true); try { - await api.converterRenameFile(rootSelected[0], name); + await api.converterRenameFile(activePaths[0], name); toastRef.current?.show({ severity: 'success', summary: 'Umbenannt', detail: `→ ${name}`, life: 2500 }); setRenameName(''); setActiveModal(''); - setSelectedPaths([]); + setActivePaths([]); await loadTree(); } catch (err) { toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 }); @@ -311,14 +428,15 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, } async function handleDeleteSelected() { - if (!rootSelected.length) return; + if (!activePaths.length) return; setBusy(true); try { - for (const p of rootSelected) { + for (const p of activePaths) { await api.converterDeleteFile(p); } - toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', detail: `${rootSelected.length} Eintrag/Einträge`, life: 2500 }); - setSelectedPaths([]); + toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', detail: `${activePaths.length} Eintrag/Einträge`, life: 2500 }); + setActivePaths([]); + setSelectedPaths((prev) => prev.filter((x) => !activePaths.includes(x))); setActiveModal(''); await loadTree(); } catch (err) { @@ -327,14 +445,14 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, } async function handleMoveSelected() { - if (!selectedPaths.length) return; + if (!activePaths.length) return; setBusy(true); try { const isRoot = moveTarget === '' || moveTarget === '__root__'; - await api.converterMoveFile(rootSelected[0], isRoot ? '' : moveTarget); + await api.converterMoveFile(activePaths[0], isRoot ? '' : moveTarget); toastRef.current?.show({ severity: 'success', summary: 'Verschoben', life: 2500 }); setMoveTarget(''); - setSelectedPaths([]); + setActivePaths([]); setActiveModal(''); await loadTree(); } catch (err) { @@ -342,6 +460,49 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, } finally { setBusy(false); } } + function handleSelectActive() { + const newPaths = []; + for (const p of activePaths) { + const node = getNodeByPath(tree, p); + if (!node || isNodeLocked(node)) continue; + if (node.type === 'folder') { + newPaths.push(p, ...collectDescendantSelectableFilePaths(node)); + } else { + newPaths.push(p); + } + } + setSelectedPaths((prev) => Array.from(new Set([...prev, ...newPaths]))); + } + + function handleRowClick(e, item) { + if (isNodeLocked(item)) return; + const p = item.path || ''; + const items = currentItems; + + if (e.shiftKey && activeAnchor) { + const anchorIdx = items.findIndex((i) => (i.path || '') === activeAnchor); + const clickIdx = items.findIndex((i) => (i.path || '') === p); + if (anchorIdx !== -1 && clickIdx !== -1) { + const from = Math.min(anchorIdx, clickIdx); + const to = Math.max(anchorIdx, clickIdx); + const rangePaths = items.slice(from, to + 1).map((i) => i.path || ''); + if (e.ctrlKey || e.metaKey) { + setActivePaths((prev) => Array.from(new Set([...prev, ...rangePaths]))); + } else { + setActivePaths(rangePaths); + } + } + } else if (e.ctrlKey || e.metaKey) { + setActivePaths((prev) => + prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p] + ); + setActiveAnchor(p); + } else { + setActivePaths([p]); + setActiveAnchor(p); + } + } + // ── Ordnerbaum (Seitenleiste) ─────────────────────────────────────────────── function renderFolderTree(node, depth) { @@ -352,6 +513,9 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, const childFolders = (node.children || []).filter((c) => c.type === 'folder'); const hasChildren = childFolders.length > 0; + const nodeSel = isSelected(key); + const nodeIndet = isIndeterminate(node); + return (
{/* Zeilen */} @@ -553,31 +744,36 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, Leer.
) : currentItems.map((item) => { - const sel = isSelected(item.path); + const assignedJobId = Number(item.jobId); + const assigned = item.type === 'file' && Number.isFinite(assignedJobId) && assignedJobId > 0; + const locked = isNodeLocked(item); + const sel = (assigned || isSelected(item.path)) && !locked; const indet = isIndeterminate(item); + const active = activePaths.includes(item.path || ''); + const jobTitle = String(item.jobTitle || '').trim(); return (
{ - if (item.type === 'folder') { - handleOpen(item); - } else { - handleCheckboxChange(item, !sel); - } - }} + className={`explorer-row${sel ? ' selected' : ''}${active ? ' row-active' : ''}${locked ? ' row-locked' : ''}`} + onClick={(e) => handleRowClick(e, item)} + onDoubleClick={() => item.type === 'folder' && handleOpen(item)} role="row" - tabIndex={0} - onKeyDown={(e) => { if (e.key === 'Enter') item.type === 'folder' ? handleOpen(item) : handleCheckboxChange(item, !sel); }} + tabIndex={locked ? -1 : 0} + onKeyDown={(e) => { if (locked) return; if (e.key === 'Enter') item.type === 'folder' ? handleOpen(item) : handleCheckboxChange(item, !sel); }} > - e.stopPropagation()}> - { if (el) el.indeterminate = indet; }} - onChange={(e) => handleCheckboxChange(item, e.target.checked)} - aria-label={`${item.name} auswählen`} - /> + + {locked ? ( + + ) : ( + { if (el) el.indeterminate = indet; }} + onChange={(e) => handleCheckboxChange(item, e.target.checked)} + onClick={(e) => e.stopPropagation()} + aria-label={`${item.name} auswählen`} + /> + )} @@ -593,6 +789,24 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, {formatBytes(item.size)} + + {locked ? ( + + + #{item.jobId} läuft + + ) : assigned ? ( + + #{item.jobId}{jobTitle ? ` | ${jobTitle}` : ''} + + ) : ( + - + )} +
); })} @@ -603,6 +817,7 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, + {getRootSelections(selectedPaths).length} ausgewählt )} @@ -618,6 +833,40 @@ export default function ConverterFileExplorer({ onSelectionChange, refreshToken, {/* ── Dialoge ─────────────────────────────────────────────────────────── */} + {/* Aus Job entfernen */} + { setActiveModal(''); setJobUnassignTarget(null); }} + style={{ width: '420px' }} + footer={( +
+
+ )} + modal + > +

+ Soll die Datei aus dem Job  + + {jobUnassignTarget?.jobTitle || (jobUnassignTarget?.jobId ? `#${jobUnassignTarget.jobId}` : '-')} + +  entfernt werden? +

+
+ {/* Neuer Ordner */} + + {field.description ? {field.description} : null} + onChange(field.key, e.value)} + min={field.min} + max={field.max} + step={field.step || 1} + /> + + ); + } + if (field.type === 'select') { + return ( +
+ + {field.description ? {field.description} : null} + onChange(field.key, e.value)} + /> +
+ ); + } + return null; +} + +function normalizeScriptId(value) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : null; +} + +function normalizeChainId(value) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : null; +} + +function normalizeIdList(values, kind = 'script') { + const list = Array.isArray(values) ? values : []; + const seen = new Set(); + const output = []; + for (const value of list) { + const normalized = kind === 'chain' ? normalizeChainId(value) : normalizeScriptId(value); + if (normalized === null) continue; + const key = String(normalized); + if (seen.has(key)) continue; + seen.add(key); + output.push(normalized); + } + return output; +} + +function buildEncodeItemsFromConfig(config, phase) { + const source = config && typeof config === 'object' ? config : {}; + const prefix = phase === 'post' ? 'post' : 'pre'; + const explicitItems = Array.isArray(source[`${prefix}EncodeItems`]) ? source[`${prefix}EncodeItems`] : []; + const fromExplicit = explicitItems + .map((item) => { + const type = String(item?.type || '').trim().toLowerCase(); + if (type !== 'script' && type !== 'chain') return null; + const id = type === 'chain' + ? normalizeChainId(item?.id ?? item?.chainId) + : normalizeScriptId(item?.id ?? item?.scriptId); + if (!id) return null; + return { type, id }; + }) + .filter(Boolean); + if (fromExplicit.length > 0) return fromExplicit; + const scriptIds = normalizeIdList(source[`${prefix}EncodeScriptIds`], 'script'); + const chainIds = normalizeIdList(source[`${prefix}EncodeChainIds`], 'chain'); + return [ + ...scriptIds.map((id) => ({ type: 'script', id })), + ...chainIds.map((id) => ({ type: 'chain', id })) + ]; +} + + +function normalizeTrackStageStatus(value) { + const raw = String(value || '').trim().toLowerCase(); + if (raw === 'done' || raw === 'complete' || raw === 'completed' || raw === 'ok' || raw === 'success') return 'done'; + if (raw === 'in_progress' || raw === 'running' || raw === 'active' || raw === 'processing') return 'in_progress'; + if (raw === 'error' || raw === 'failed' || raw === 'cancelled' || raw === 'aborted') return 'error'; + return 'pending'; +} + +function trackStatusTagMeta(value) { + const normalized = normalizeTrackStageStatus(value); + if (normalized === 'done') return { label: 'Fertig', severity: 'success' }; + if (normalized === 'in_progress') return { label: 'Läuft', severity: 'info' }; + if (normalized === 'error') return { label: 'Fehler', severity: 'danger' }; + return { label: 'Offen', severity: 'secondary' }; +} + +// ── Helper: derive audio tracks from plan ───────────────────────────────────── + +function deriveAudioTracks(plan) { + if (Array.isArray(plan?.inputPaths) && plan.inputPaths.length > 0) { + return plan.inputPaths.map((p, i) => ({ + position: i + 1, + filePath: p, + defaultTitle: p.split('/').pop().replace(/\.[^.]+$/, '') + })); + } + const p = plan?.inputPath || ''; + if (p && !plan?.isFolder) { + return [{ position: 1, filePath: p, defaultTitle: p.split('/').pop().replace(/\.[^.]+$/, '') }]; + } + return []; +} + +// ── Status helpers ──────────────────────────────────────────────────────────── + function mediaTypeBadge(type) { const map = { video: { label: 'Video', severity: 'info' }, @@ -58,36 +170,145 @@ function isActiveStatus(status) { } function isTerminal(status) { - return ['DONE', 'ERROR', 'CANCELLED'].includes(String(status || '').toUpperCase()); + return ['DONE', 'FINISHED', 'ERROR', 'CANCELLED'].includes(String(status || '').toUpperCase()); } -/** - * Inline-Konfigurationsbereich für READY_TO_START Jobs. - * Zeigt alle nötigen Optionen direkt in der Karte an. - */ -function InlineConfig({ job, plan, onStarted }) { +// ── Inline-Konfiguration ────────────────────────────────────────────────────── + +function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsChanged }) { const converterMediaType = plan?.converterMediaType || 'video'; const isVideo = converterMediaType === 'video' || converterMediaType === 'iso'; const isAudio = converterMediaType === 'audio'; + const planMetadata = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : {}; + const planTracks = Array.isArray(plan?.tracks) ? plan.tracks : []; + const planMusicBrainz = plan?.musicBrainz && typeof plan.musicBrainz === 'object' ? plan.musicBrainz : {}; - const [outputFormat, setOutputFormat] = useState(isAudio ? 'flac' : 'mkv'); + // ── Format state ──────────────────────────────────────────────────────────── + const audioFormatValues = new Set(CD_FORMATS.map((e) => e.value)); + const videoFormatValues = new Set(VIDEO_OUTPUT_FORMATS.map((e) => e.value)); + const initialOutputFormat = String(plan?.outputFormat || (isAudio ? 'flac' : 'mkv')).trim().toLowerCase(); + const [outputFormat, setOutputFormat] = useState( + isAudio + ? (audioFormatValues.has(initialOutputFormat) ? initialOutputFormat : 'flac') + : (videoFormatValues.has(initialOutputFormat) ? initialOutputFormat : 'mkv') + ); + const [audioFormatOptions, setAudioFormatOptions] = useState(() => ({ + ...getDefaultFormatOptions(initialOutputFormat), + ...(plan?.audioFormatOptions && typeof plan.audioFormatOptions === 'object' ? plan.audioFormatOptions : {}) + })); + + // Sync format options defaults when format changes + useEffect(() => { + if (!isAudio) return; + setAudioFormatOptions((prev) => ({ + ...getDefaultFormatOptions(outputFormat), + ...prev + })); + }, [outputFormat, isAudio]); + + // ── Video presets ─────────────────────────────────────────────────────────── const [userPresets, setUserPresets] = useState([]); const [hbPresets, setHbPresets] = useState([]); - const [selectedUserPreset, setSelectedUserPreset] = useState(null); - const [selectedHbPreset, setSelectedHbPreset] = useState(''); - const [audioFormatOptions, setAudioFormatOptions] = useState({ - flacCompression: 5, - mp3Mode: 'cbr', - mp3Bitrate: 192, - mp3Quality: 4, - aacBitrate: 256, - opusBitrate: 160, - oggQuality: 6 + const [selectedUserPreset, setSelectedUserPreset] = useState( + Number.isFinite(Number(plan?.userPreset?.id)) ? Math.trunc(Number(plan.userPreset.id)) : null + ); + const [selectedHbPreset, setSelectedHbPreset] = useState( + String(plan?.userPreset?.handbrakePreset || '').trim() + ); + + // ── Audio tracks ──────────────────────────────────────────────────────────── + const audioTrackInputPathsKey = Array.isArray(plan?.inputPaths) ? plan.inputPaths.join('|') : ''; + const audioTracks = useMemo( + () => deriveAudioTracks(plan), + // eslint-disable-next-line react-hooks/exhaustive-deps + [plan?.inputPath, plan?.isFolder, audioTrackInputPathsKey] + ); + + // ── Audio metadata ────────────────────────────────────────────────────────── + const [albumTitle, setAlbumTitle] = useState( + String(planMetadata?.albumTitle || job.title || job.detected_title || '').trim() + ); + const [albumArtist, setAlbumArtist] = useState(String(planMetadata?.albumArtist || '').trim()); + const [albumYear, setAlbumYear] = useState(() => { + const y = Number(planMetadata?.albumYear); + return Number.isFinite(y) ? Math.trunc(y) : null; }); + const [coverUrl, setCoverUrl] = useState( + String(planMetadata?.coverUrl || job?.poster_url || '').trim() || null + ); + const [trackFields, setTrackFields] = useState(() => { + const map = {}; + for (const { position, defaultTitle } of audioTracks) { + const savedTrack = planTracks.find((t) => Number(t?.position) === Number(position)) || {}; + map[position] = { + title: String(savedTrack?.title || defaultTitle || '').trim(), + artist: String(savedTrack?.artist || '').trim() + }; + } + return map; + }); + + // ── MusicBrainz ───────────────────────────────────────────────────────────── + const [selectedMb, setSelectedMb] = useState( + planMusicBrainz?.selected && typeof planMusicBrainz.selected === 'object' + ? planMusicBrainz.selected + : null + ); + const [mbQuery] = useState(String(planMusicBrainz?.query || '').trim()); + const [mbResults] = useState(Array.isArray(planMusicBrainz?.results) ? planMusicBrainz.results : []); + + // ── Video metadata ────────────────────────────────────────────────────────── + const [videoMetadata, setVideoMetadata] = useState(() => ({ + title: String(planMetadata?.title || job.title || job.detected_title || '').trim(), + year: Number.isFinite(Number(planMetadata?.year)) ? Math.trunc(Number(planMetadata.year)) : null, + imdbId: String(planMetadata?.imdbId || '').trim() || null, + poster: String(planMetadata?.poster || '').trim() || null + })); + + // ── Script / Chain catalog ────────────────────────────────────────────────── + const [scriptCatalog, setScriptCatalog] = useState([]); + const [chainCatalog, setChainCatalog] = useState([]); + const [preItems, setPreItems] = useState(() => buildEncodeItemsFromConfig(plan, 'pre')); + const [postItems, setPostItems] = useState(() => buildEncodeItemsFromConfig(plan, 'post')); + + useEffect(() => { + if (!isAudio) return; + let cancelled = false; + const loadCatalog = async () => { + try { + const [scriptsRes, chainsRes] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]); + if (cancelled) return; + setScriptCatalog( + scriptsRes.status === 'fulfilled' + ? (Array.isArray(scriptsRes.value?.scripts) ? scriptsRes.value.scripts : []).map((s) => ({ id: s.id, name: s.name })) + : [] + ); + setChainCatalog( + chainsRes.status === 'fulfilled' + ? (Array.isArray(chainsRes.value?.chains) ? chainsRes.value.chains : []).map((c) => ({ id: c.id, name: c.name })) + : [] + ); + } catch { + if (!cancelled) { setScriptCatalog([]); setChainCatalog([]); } + } + }; + void loadCatalog(); + return () => { cancelled = true; }; + }, [isAudio]); + + // ── Misc state ────────────────────────────────────────────────────────────── + const [metadataDialogVisible, setMetadataDialogVisible] = useState(false); + const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false); + const [searchDialogBusy, setSearchDialogBusy] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [draftSaving, setDraftSaving] = useState(false); + const [draftError, setDraftError] = useState(null); + const saveTimeoutRef = useRef(null); + const initializedRef = useRef(false); + const latestSavedRef = useRef(null); - // Presets laden + // ── Video presets loader ──────────────────────────────────────────────────── useEffect(() => { if (!isVideo) return; api.getUserPresets?.('video') @@ -98,6 +319,296 @@ function InlineConfig({ job, plan, onStarted }) { .catch(() => setHbPresets([])); }, [isVideo]); + // ── Sync trackFields when audioTracks change ──────────────────────────────── + useEffect(() => { + if (!isAudio || audioTracks.length === 0) return; + setTrackFields((prev) => { + const next = {}; + for (const { position, defaultTitle } of audioTracks) { + const savedTrack = planTracks.find((t) => Number(t?.position) === Number(position)) || {}; + const existing = prev[position] || null; + next[position] = { + title: String(existing?.title || savedTrack?.title || defaultTitle || '').trim(), + artist: String(existing?.artist || savedTrack?.artist || '').trim() + }; + } + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(next); + if (prevKeys.length === nextKeys.length) { + const equal = nextKeys.every((key) => ( + String(prev[key]?.title || '') === String(next[key]?.title || '') + && String(prev[key]?.artist || '') === String(next[key]?.artist || '') + )); + if (equal) return prev; + } + return next; + }); + }, [isAudio, audioTracks, planTracks]); + + // ── Draft payload ─────────────────────────────────────────────────────────── + const draftPayload = useMemo(() => { + let userPreset = null; + if (selectedUserPreset) { + const preset = userPresets.find((p) => Number(p?.id) === Number(selectedUserPreset)); + userPreset = preset + ? { id: preset.id, handbrakePreset: preset.handbrake_preset || null, extraArgs: preset.extra_args || '' } + : { id: selectedUserPreset }; + } else if (selectedHbPreset) { + userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' }; + } + + const tracks = isAudio + ? audioTracks.map(({ position, defaultTitle }) => { + const f = trackFields[position] || {}; + return { + position, + title: String(f.title || defaultTitle || '').trim() || defaultTitle || `Track ${position}`, + artist: String(f.artist || '').trim() || null + }; + }) + : null; + + const metadata = isAudio + ? { + albumTitle: String(albumTitle || '').trim() || job.detected_title || '', + albumArtist: String(albumArtist || '').trim() || null, + albumYear: Number.isFinite(Number(albumYear)) ? Math.trunc(Number(albumYear)) : null, + mbId: selectedMb?.mbId || null, + coverUrl: coverUrl || null + } + : { + title: String(videoMetadata?.title || job.title || job.detected_title || '').trim() || null, + year: Number.isFinite(Number(videoMetadata?.year)) ? Math.trunc(Number(videoMetadata.year)) : null, + imdbId: String(videoMetadata?.imdbId || '').trim() || null, + poster: String(videoMetadata?.poster || '').trim() || null + }; + + return { + converterMediaType, + outputFormat, + userPreset, + audioFormatOptions: isAudio ? audioFormatOptions : null, + metadata, + tracks, + musicBrainz: isAudio + ? { query: mbQuery, selected: selectedMb || null, results: Array.isArray(mbResults) ? mbResults.slice(0, 50) : [] } + : null, + preEncodeItems: isAudio ? preItems : null, + postEncodeItems: isAudio ? postItems : null + }; + }, [ + selectedUserPreset, selectedHbPreset, userPresets, isAudio, audioTracks, trackFields, + albumTitle, albumArtist, albumYear, coverUrl, selectedMb, converterMediaType, outputFormat, + audioFormatOptions, mbQuery, mbResults, job.detected_title, job.title, videoMetadata, + preItems, postItems + ]); + + const draftSerialized = useMemo(() => JSON.stringify(draftPayload), [draftPayload]); + + useEffect(() => { + if (!initializedRef.current) { + initializedRef.current = true; + latestSavedRef.current = draftSerialized; + return; + } + if (busy || draftSerialized === latestSavedRef.current) return; + if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = setTimeout(async () => { + setDraftSaving(true); + setDraftError(null); + try { + await api.converterUpdateJobConfig(job.id, draftPayload); + latestSavedRef.current = draftSerialized; + } catch (err) { + setDraftError(err?.message || 'Draft konnte nicht gespeichert werden.'); + } finally { + setDraftSaving(false); + } + }, 650); + return () => { + if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = null; } + }; + }, [busy, draftPayload, draftSerialized, job.id]); + + // ── Dialog contexts ───────────────────────────────────────────────────────── + const metadataDialogContext = useMemo(() => ({ + jobId: job.id, + detectedTitle: videoMetadata?.title || job.title || job.detected_title || '', + selectedMetadata: { + title: videoMetadata?.title || job.title || job.detected_title || '', + year: videoMetadata?.year || null, + imdbId: videoMetadata?.imdbId || null, + poster: videoMetadata?.poster || null + }, + omdbCandidates: [] + }), [job.id, job.title, job.detected_title, videoMetadata]); + + const cdMetadataDialogContext = useMemo(() => ({ + jobId: job.id, + tracks: audioTracks.map(({ position, defaultTitle }) => ({ + position, + title: String(trackFields[position]?.title || defaultTitle || '').trim() || `Track ${position}`, + artist: String(trackFields[position]?.artist || '').trim() || null + })) + }), [job.id, audioTracks, trackFields]); + + // ── Dialog handlers ───────────────────────────────────────────────────────── + const handleOmdbSearch = async (query) => { + try { + const response = await api.searchOmdb(query); + return Array.isArray(response?.results) ? response.results : []; + } catch { return []; } + }; + + const handleOmdbSubmit = async (payload) => { + setSearchDialogBusy(true); + try { + setVideoMetadata({ + title: String(payload?.title || job.title || job.detected_title || '').trim(), + year: Number.isFinite(Number(payload?.year)) ? Math.trunc(Number(payload.year)) : null, + imdbId: String(payload?.imdbId || '').trim() || null, + poster: String(payload?.poster || '').trim() || null + }); + setMetadataDialogVisible(false); + } finally { + setSearchDialogBusy(false); + } + }; + + const handleMusicBrainzSearchDialog = async (query) => { + try { + const response = await api.searchMusicBrainz(query); + return Array.isArray(response?.results) ? response.results : []; + } catch { return []; } + }; + + const handleMusicBrainzReleaseFetch = async (mbId) => { + try { + const response = await api.getMusicBrainzRelease(mbId); + return response?.release || null; + } catch { return null; } + }; + + const handleCdMetadataSubmit = async (payload) => { + setSearchDialogBusy(true); + try { + const title = String(payload?.title || '').trim(); + const artist = String(payload?.artist || '').trim(); + const year = Number(payload?.year); + const tracks = Array.isArray(payload?.tracks) ? payload.tracks : []; + if (title) setAlbumTitle(title); + setAlbumArtist(artist); + setAlbumYear(Number.isFinite(year) ? Math.trunc(year) : null); + setSelectedMb(payload?.mbId ? { + mbId: payload.mbId, + title: title || null, + artist: artist || null, + year: Number.isFinite(year) ? Math.trunc(year) : null + } : null); + setCoverUrl(String(payload?.coverUrl || '').trim() || null); + if (tracks.length > 0) { + setTrackFields((prev) => { + const next = { ...prev }; + tracks.forEach((track, index) => { + const position = Number.isFinite(Number(track?.position)) && Number(track.position) > 0 + ? Math.trunc(Number(track.position)) + : (index + 1); + next[position] = { + title: String(track?.title || next[position]?.title || `Track ${position}`).trim(), + artist: String(track?.artist || next[position]?.artist || '').trim() + }; + }); + return next; + }); + } + setCdMetadataDialogVisible(false); + } finally { + setSearchDialogBusy(false); + } + }; + + // ── Track field handlers ──────────────────────────────────────────────────── + const handleTrackField = (position, key, value) => { + setTrackFields((prev) => ({ ...prev, [position]: { ...(prev[position] || {}), [key]: value } })); + }; + + const handleDeleteTrack = async (filePath) => { + try { + await api.converterRemoveInputFromJob(job.id, filePath); + onInputsChanged?.(job.id); + } catch (err) { + // silently ignore — UI will refresh via onInputsChanged + } + }; + + // ── Encode item handlers ──────────────────────────────────────────────────── + const moveItem = (phase, index, direction) => { + const updater = phase === 'post' ? setPostItems : setPreItems; + updater((prev) => { + const list = Array.isArray(prev) ? [...prev] : []; + const from = Number(index); + const to = from + (direction === 'up' ? -1 : 1); + if (!Number.isInteger(from) || from < 0 || from >= list.length || to < 0 || to >= list.length) return list; + const [moved] = list.splice(from, 1); + list.splice(to, 0, moved); + return list; + }); + }; + + const addItem = (phase, type) => { + const normalizedType = type === 'chain' ? 'chain' : 'script'; + const updater = phase === 'post' ? setPostItems : setPreItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const selectedIds = new Set( + current + .filter((item) => item?.type === normalizedType) + .map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const catalog = normalizedType === 'chain' ? chainCatalog : scriptCatalog; + const candidate = (Array.isArray(catalog) ? catalog : []) + .map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id)) + .find((id) => id !== null && !selectedIds.has(String(id))); + if (candidate === undefined || candidate === null) return current; + return [...current, { type: normalizedType, id: candidate }]; + }); + }; + + const changeItem = (phase, index, type, nextId) => { + const normalizedType = type === 'chain' ? 'chain' : 'script'; + const normalizedId = normalizedType === 'chain' ? normalizeChainId(nextId) : normalizeScriptId(nextId); + if (normalizedId === null) return; + const updater = phase === 'post' ? setPostItems : setPreItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const rowIndex = Number(index); + if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) return current; + const duplicate = current.some((item, itemIndex) => { + if (itemIndex === rowIndex) return false; + if (item?.type !== normalizedType) return false; + const existingId = normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id); + return existingId !== null && String(existingId) === String(normalizedId); + }); + if (duplicate) return current; + const next = [...current]; + next[rowIndex] = { type: normalizedType, id: normalizedId }; + return next; + }); + }; + + const removeItem = (phase, index) => { + const updater = phase === 'post' ? setPostItems : setPreItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const rowIndex = Number(index); + if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) return current; + return current.filter((_, itemIndex) => itemIndex !== rowIndex); + }); + }; + + // ── Start handler ─────────────────────────────────────────────────────────── const handleStart = async () => { setBusy(true); setError(null); @@ -110,11 +621,41 @@ function InlineConfig({ job, plan, onStarted }) { userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' }; } + const tracks = isAudio + ? audioTracks.map(({ position, defaultTitle }) => { + const f = trackFields[position] || {}; + return { + position, + title: (f.title || '').trim() || defaultTitle, + artist: (f.artist || '').trim() || albumArtist.trim() || null + }; + }) + : undefined; + + const metadata = isAudio + ? { + albumTitle: albumTitle.trim() || job.detected_title || '', + albumArtist: albumArtist.trim() || null, + albumYear: albumYear || null, + mbId: selectedMb?.mbId || null, + coverUrl: coverUrl || null + } + : { + title: String(videoMetadata?.title || job.title || job.detected_title || '').trim() || null, + year: Number.isFinite(Number(videoMetadata?.year)) ? Math.trunc(Number(videoMetadata.year)) : null, + imdbId: String(videoMetadata?.imdbId || '').trim() || null, + poster: String(videoMetadata?.poster || '').trim() || null + }; + await api.startConverterJob(job.id, { converterMediaType, outputFormat, userPreset, - audioFormatOptions: isAudio ? audioFormatOptions : undefined + audioFormatOptions: isAudio ? audioFormatOptions : null, + metadata, + tracks, + preEncodeItems: isAudio ? preItems : null, + postEncodeItems: isAudio ? postItems : null }); onStarted?.(job.id); @@ -124,16 +665,292 @@ function InlineConfig({ job, plan, onStarted }) { } }; - const outputFormats = isVideo ? VIDEO_OUTPUT_FORMATS : AUDIO_OUTPUT_FORMATS; + // ── Format fields (audio) ─────────────────────────────────────────────────── + const audioSchema = CD_FORMAT_SCHEMAS[outputFormat] || { fields: [] }; + const visibleAudioFields = audioSchema.fields.filter((f) => isFieldVisible(f, audioFormatOptions)); + // ── Encode item section renderer ──────────────────────────────────────────── + const renderEncodeSection = (phase, items) => { + const label = phase === 'post' ? 'Post-Rip Ausführungen (optional)' : 'Pre-Rip Ausführungen (optional)'; + const hint = phase === 'post' + ? 'Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.' + : 'Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen.'; + + return ( +
+

{label}

+ {scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( + Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. + ) : null} + {items.length === 0 ? ( + Keine {phase === 'post' ? 'Post' : 'Pre'}-Rip Ausführungen ausgewählt. + ) : null} + {items.map((item, rowIndex) => { + const isScript = item?.type === 'script'; + const usedScriptIds = new Set( + items.filter((e, i) => e?.type === 'script' && i !== rowIndex) + .map((e) => normalizeScriptId(e?.id)).filter((id) => id !== null).map((id) => String(id)) + ); + const usedChainIds = new Set( + items.filter((e, i) => e?.type === 'chain' && i !== rowIndex) + .map((e) => normalizeChainId(e?.id)).filter((id) => id !== null).map((id) => String(id)) + ); + const scriptOptions = scriptCatalog.map((e) => ({ + label: e?.name || `Skript #${e?.id}`, + value: normalizeScriptId(e?.id), + disabled: usedScriptIds.has(String(normalizeScriptId(e?.id))) + })).filter((e) => e.value !== null); + const chainOptions = chainCatalog.map((e) => ({ + label: e?.name || `Kette #${e?.id}`, + value: normalizeChainId(e?.id), + disabled: usedChainIds.has(String(normalizeChainId(e?.id))) + })).filter((e) => e.value !== null); + return ( +
+ +
+
+ {isScript ? ( + changeItem(phase, rowIndex, 'script', e.value)} + className="full-width" disabled={busy} + /> + ) : ( + changeItem(phase, rowIndex, 'chain', e.value)} + className="full-width" disabled={busy} + /> + )} +
+ ); + })} +
+ {scriptCatalog.length > items.filter((e) => e?.type === 'script').length ? ( +
+ {hint} +
+ ); + }; + + // ── Render ────────────────────────────────────────────────────────────────── + if (isAudio) { + return ( +
+ {error && } + +

Converter Konfiguration

+ + {/* Album-Metadaten */} +
+ Album-Metadaten + {coverUrl && ( +
+
+ {albumTitle +
+
+ )} +
+ setAlbumTitle(e.target.value)} + placeholder="Album" + disabled={busy} + /> + setAlbumArtist(e.target.value)} + placeholder="Interpret" + disabled={busy} + /> + setAlbumYear(e.value || null)} + placeholder="Jahr" + useGrouping={false} + min={1900} max={2100} + disabled={busy} + /> +
+
+ + {/* Ausgabeformat */} +
+ + setOutputFormat(e.value)} + disabled={busy} + /> +
+ + {/* Format-spezifische Felder */} + {visibleAudioFields.map((field) => ( + setAudioFormatOptions((prev) => ({ ...prev, [key]: value }))} + /> + ))} + + {/* Track-Auswahl */} + {audioTracks.length > 0 && ( +
+
+ Tracks ({audioTracks.length}) +
+
+ + + + + + + + + + + + {audioTracks.map(({ position, filePath, defaultTitle }) => { + const f = trackFields[position] || {}; + return ( + + + + + + + + ); + })} + +
NrInterpretTitelLänge
+ {String(position).padStart(2, '0')} + handleTrackField(position, 'artist', e.target.value)} + placeholder={normalizeTrackText(albumArtist) || 'Interpret'} + disabled={busy} + /> + + handleTrackField(position, 'title', e.target.value)} + placeholder={defaultTitle} + disabled={busy} + /> + -
+
+
+ )} + + {plan?.isFolder && audioTracks.length === 0 && ( +

+ Track-Liste wird beim Start aus dem Ordner gelesen. +

+ )} + + {/* Pre / Post Ausführungen */} + {renderEncodeSection('pre', preItems)} + {renderEncodeSection('post', postItems)} + + {/* Aktionen */} +
+
+ + {draftError && ( + {draftError} + )} + {!draftError && ( + + {draftSaving ? 'Speichere Entwurf …' : 'Entwurf gespeichert'} + + )} + + setCdMetadataDialogVisible(false)} + onSubmit={handleCdMetadataSubmit} + onSearch={handleMusicBrainzSearchDialog} + onFetchRelease={handleMusicBrainzReleaseFetch} + busy={searchDialogBusy} + /> +
+ ); + } + + // ── Video render (unchanged layout) ───────────────────────────────────────── return (
- {error && ( - + {error && } + + {isVideo && ( +
+
)}
- {/* Medientyp (read-only) */}
@@ -143,20 +960,17 @@ function InlineConfig({ job, plan, onStarted }) { )}
- - {/* Ausgabeformat */}
setOutputFormat(e.value)} style={{ width: '100%' }} />
- {/* Video: Presets */} {isVideo && (
@@ -190,92 +1004,14 @@ function InlineConfig({ job, plan, onStarted }) {
)} - {/* Audio: Format-Optionen */} - {isAudio && ( -
- {outputFormat === 'flac' && ( -
- - setAudioFormatOptions((p) => ({ ...p, flacCompression: e.value ?? 5 }))} - min={0} max={12} step={1} - style={{ width: '100%' }} - /> -
- )} - {outputFormat === 'mp3' && ( - <> -
- - setAudioFormatOptions((p) => ({ ...p, mp3Mode: e.value || 'cbr' }))} - /> -
-
- {audioFormatOptions.mp3Mode === 'cbr' ? ( - <> - - ({ label: `${v} kbps`, value: v }))} - onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Bitrate: e.value }))} - style={{ width: '100%' }} - /> - - ) : ( - <> - - setAudioFormatOptions((p) => ({ ...p, mp3Quality: e.value ?? 4 }))} - min={0} max={9} step={1} - style={{ width: '100%' }} - /> - - )} -
- - )} - {outputFormat === 'aac' && ( -
- - ({ label: `${v} kbps`, value: v }))} - onChange={(e) => setAudioFormatOptions((p) => ({ ...p, aacBitrate: e.value }))} - style={{ width: '100%' }} - /> -
- )} - {outputFormat === 'opus' && ( -
- - ({ label: `${v} kbps`, value: v }))} - onChange={(e) => setAudioFormatOptions((p) => ({ ...p, opusBitrate: e.value }))} - style={{ width: '100%' }} - /> -
- )} - {outputFormat === 'ogg' && ( -
- - setAudioFormatOptions((p) => ({ ...p, oggQuality: e.value ?? 6 }))} - min={-1} max={10} step={1} - style={{ width: '100%' }} - /> -
- )} -
- )} -
+ {draftError ? ( + {draftError} + ) : ( + + {draftSaving ? 'Speichere Entwurf …' : 'Entwurf gespeichert'} + + )}
+ + setMetadataDialogVisible(false)} + onSubmit={handleOmdbSubmit} + onSearch={handleOmdbSearch} + busy={searchDialogBusy} + />
); } -/** - * Status-Karte für einen einzelnen Converter-Job. - * Zeigt Konfiguration inline an wenn der Job READY_TO_START ist. - */ +// ── Job-Karte ───────────────────────────────────────────────────────────────── + export default function ConverterJobCard({ job, jobProgress, + isExpanded, + onExpand, + onCollapse, onStarted, onDeleted, - onCancelled + onCancelled, + onOpenDetails, + onInputsChanged }) { const [cancelBusy, setCancelBusy] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false); + const [deleteConfirmVisible, setDeleteConfirmVisible] = useState(false); const plan = (() => { - try { return JSON.parse(job.encode_plan_json || '{}'); } catch (_e) { return {}; } + try { return JSON.parse(job.encode_plan_json || '{}'); } catch { return {}; } })(); - const title = job.title || job.detected_title || 'Converter Job'; + const jobIdLabel = Number.isFinite(Number(job?.id)) ? `Job #${Math.trunc(Number(job.id))}` : 'Job'; + const dashboardTitleId = Number.isFinite(Number(job?.id)) ? `#${Math.trunc(Number(job.id))}` : '#-'; + const customTitle = String(job?.title || '').trim(); + const title = customTitle ? `${dashboardTitleId} | ${customTitle}` : dashboardTitleId; const status = job.status || 'UNKNOWN'; - const statusMeta = getStatusMeta(status); + const statusLabel = getStatusLabel(status); + const statusSeverity = getStatusSeverity(status); const converterMediaType = plan?.converterMediaType; + const isAudio = converterMediaType === 'audio'; const active = isActiveStatus(status); const terminal = isTerminal(status); const isReady = String(status).toUpperCase() === 'READY_TO_START'; @@ -318,7 +1072,64 @@ export default function ConverterJobCard({ const liveStatusText = jobProgress?.statusText || null; const progressValue = liveProgress !== null ? Math.round(liveProgress) : null; + // ── Audio details (for active/terminal state) ───────────────────────────── + const audioTracks = isAudio ? deriveAudioTracks(plan) : []; + const planTracks = Array.isArray(plan?.tracks) ? plan.tracks : []; + const planMetadata = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : {}; + const coverUrl = String(planMetadata?.coverUrl || plan?.musicBrainz?.selected?.coverUrl || job?.poster_url || '').trim() || null; + + const albumTitle = String(planMetadata?.albumTitle || job.title || job.detected_title || '').trim() || '-'; + const albumArtist = String(planMetadata?.albumArtist || '').trim() || '-'; + const albumYear = planMetadata?.albumYear || '-'; + const mbId = String(planMetadata?.mbId || '').trim() || '-'; + const outputFormatLabel = (CD_FORMATS.find((f) => f.value === plan?.outputFormat)?.label) || (plan?.outputFormat ? String(plan.outputFormat).toUpperCase() : '-'); + const outputPath = String(job.output_path || '').trim() || '-'; + + // Derive pre/post script names for summary + const preItems = buildEncodeItemsFromConfig(plan, 'pre'); + const postItems = buildEncodeItemsFromConfig(plan, 'post'); + const preNames = [ + ...(Array.isArray(plan?.preEncodeScripts) ? plan.preEncodeScripts.map((s) => String(s?.name || '').trim()).filter(Boolean) : []), + ...preItems.filter((i) => i.type === 'script').map((i) => `Skript #${i.id}`) + ]; + const postNames = [ + ...(Array.isArray(plan?.postEncodeScripts) ? plan.postEncodeScripts.map((s) => String(s?.name || '').trim()).filter(Boolean) : []), + ...postItems.filter((i) => i.type === 'script').map((i) => `Skript #${i.id}`) + ]; + const preChainNames = [ + ...(Array.isArray(plan?.preEncodeChains) ? plan.preEncodeChains.map((c) => String(c?.name || '').trim()).filter(Boolean) : []), + ...preItems.filter((i) => i.type === 'chain').map((i) => `Kette #${i.id}`) + ]; + const postChainNames = [ + ...(Array.isArray(plan?.postEncodeChains) ? plan.postEncodeChains.map((c) => String(c?.name || '').trim()).filter(Boolean) : []), + ...postItems.filter((i) => i.type === 'chain').map((i) => `Kette #${i.id}`) + ]; + + const isFinishedOk = terminal && !['ERROR', 'CANCELLED'].includes(String(status).toUpperCase()); + const completedEncodeCount = (() => { + if (isFinishedOk) return audioTracks.length; + if (!active || audioTracks.length === 0) return 0; + // "Audio: N/M — filename" is emitted by the backend at the START of track N. + // N-1 tracks are done, track N is in progress. + const statusMatch = liveStatusText?.match(/^Audio:\s*(\d+)\s*\//); + if (statusMatch) { + const pos = parseInt(statusMatch[1], 10); + return Math.max(0, Math.min(audioTracks.length, pos - 1)); + } + // Fallback: use progress percentage (Math.round avoids floor precision loss) + if (progressValue !== null) { + return Math.min(audioTracks.length, Math.round((progressValue / 100) * audioTracks.length)); + } + return 0; + })(); + + // ── Action handlers ─────────────────────────────────────────────────────── const handleCancel = async () => { + const statusUpper = String(status || '').toUpperCase(); + const confirmText = statusUpper === 'READY_TO_START' + ? `${jobIdLabel} wirklich abbrechen?\nDer Job wird auf "Abgebrochen" gesetzt.` + : `${jobIdLabel} wirklich abbrechen?`; + if (!window.confirm(confirmText)) return; setCancelBusy(true); try { await api.cancelConverterJob(job.id); @@ -330,102 +1141,111 @@ export default function ConverterJobCard({ } }; - const handleDelete = async () => { - if (!window.confirm(`Job "${title}" wirklich löschen?`)) return; + const handleDelete = () => setDeleteConfirmVisible(true); + + const handleDeleteConfirmed = async () => { + setDeleteConfirmVisible(false); setDeleteBusy(true); try { await api.deleteConverterJob(job.id); onDeleted?.(job.id); } catch (err) { console.error('Delete error:', err); + window.alert(`Fehler beim Löschen:\n${err?.message || 'Unbekannter Fehler'}`); } finally { setDeleteBusy(false); } }; - return ( -
- {/* Header */} -
-
- {title} - {converterMediaType && mediaTypeBadge(converterMediaType)} - + // ── Eingeklappt ─────────────────────────────────────────────────────────── + if (!isExpanded) { + const fileCount = Array.isArray(plan?.inputPaths) && plan.inputPaths.length > 0 + ? plan.inputPaths.length + : null; + return ( +