From ff8a8f67bfa13a11376e3c548002974853d70948 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Tue, 28 Apr 2026 09:18:56 +0000 Subject: [PATCH] 0.15.0-3 Misc Fixes --- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/db/database.js | 20 ++ backend/src/plugins/VideoDiscPlugin.js | 7 +- backend/src/services/historyService.js | 339 ++++++++++++++---- backend/src/services/pipelineService.js | 141 +++++--- backend/src/services/settingsService.js | 5 +- backend/src/utils/encodePlan.js | 63 +++- db/schema.sql | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/components/JobDetailDialog.jsx | 171 ++++++++- .../src/components/PipelineStatusCard.jsx | 142 ++++++++ frontend/src/pages/HistoryPage.jsx | 8 +- frontend/src/pages/RipperPage.jsx | 4 +- frontend/src/styles/app.css | 68 ++++ install.sh | 3 +- package-lock.json | 4 +- package.json | 2 +- 19 files changed, 834 insertions(+), 157 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 8e19dee..60cd97d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.15.0-2", + "version": "0.15.0-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.15.0-2", + "version": "0.15.0-3", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index be607cb..7a84b9a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.15.0-2", + "version": "0.15.0-3", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 4c7328e..ddee180 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -941,6 +941,26 @@ async function migrateSettingsSchemaMetadata(db) { }); } + const metadataReadyNotifyLabel = 'Bei manueller Auswahl senden'; + const metadataReadyNotifyDescription = 'Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist).'; + const metadataReadyNotifyResult = await db.run( + `UPDATE settings_schema + SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE key = 'pushover_notify_metadata_ready' AND (label != ? OR description != ?)`, + [ + metadataReadyNotifyLabel, + metadataReadyNotifyDescription, + metadataReadyNotifyLabel, + metadataReadyNotifyDescription + ] + ); + if (metadataReadyNotifyResult?.changes > 0) { + logger.info('migrate:settings-schema-metadata-ready-notify-updated', { + key: 'pushover_notify_metadata_ready', + label: metadataReadyNotifyLabel + }); + } + const dvdSeriesMultiEpisodeTemplateDefault = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'; const legacyDvdSeriesMultiEpisodeTemplateDefaults = [ '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}' diff --git a/backend/src/plugins/VideoDiscPlugin.js b/backend/src/plugins/VideoDiscPlugin.js index 94dccee..9bd3b74 100644 --- a/backend/src/plugins/VideoDiscPlugin.js +++ b/backend/src/plugins/VideoDiscPlugin.js @@ -230,6 +230,7 @@ class VideoDiscPlugin extends SourcePlugin { selectedTitleId = null, backupOutputBase = null, disableMinLengthFilter = false, + sourceArgOverride = null, isCancelled, onProcessHandle } = ctx.extra || {}; @@ -249,7 +250,8 @@ class VideoDiscPlugin extends SourcePlugin { mediaProfile: this.mediaProfile, settingsMap: settings, backupOutputBase, - disableMinLengthFilter + disableMinLengthFilter, + sourceArgOverride }); const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv') @@ -262,7 +264,8 @@ class VideoDiscPlugin extends SourcePlugin { ripMode, rawJobDir, selectedTitleId, - disableMinLengthFilter + disableMinLengthFilter, + sourceArgOverride: sourceArgOverride || null }); ctx.emitProgress(0, ripMode === 'backup' diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index edcb8d9..6db7e83 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -2558,6 +2558,96 @@ function deleteFilesRecursively(rootPath, keepRoot = true) { return result; } +function collectFilesRecursively(rootPath) { + const normalizedRoot = normalizeComparablePath(rootPath); + if (!normalizedRoot) { + return []; + } + const result = []; + const visit = (currentPath) => { + let stat = null; + try { + stat = fs.lstatSync(currentPath); + } catch (_error) { + return; + } + if (stat.isDirectory()) { + let entries = []; + try { + entries = fs.readdirSync(currentPath, { withFileTypes: true }); + } catch (_error) { + return; + } + for (const entry of entries) { + visit(path.join(currentPath, entry.name)); + } + return; + } + result.push(normalizeComparablePath(currentPath)); + }; + visit(normalizedRoot); + return Array.from(new Set(result.filter(Boolean))).sort((left, right) => left.localeCompare(right, 'de')); +} + +function removeEmptyParentDirectories(startPath, options = {}) { + const start = normalizeComparablePath(startPath); + if (!start) { + return []; + } + const protectedRoots = new Set( + (Array.isArray(options?.protectedRoots) ? options.protectedRoots : []) + .map((entry) => normalizeComparablePath(entry)) + .filter(Boolean) + ); + const allowedRoots = Array.from(protectedRoots); + const removed = []; + const removedSet = new Set(); + + let current = start; + while (current && !isFilesystemRootPath(current)) { + if (protectedRoots.has(current)) { + break; + } + if (allowedRoots.length > 0 && !allowedRoots.some((rootPath) => isPathInside(rootPath, current))) { + break; + } + let stat = null; + try { + stat = fs.lstatSync(current); + } catch (_error) { + break; + } + if (!stat.isDirectory()) { + break; + } + let entries = []; + try { + entries = fs.readdirSync(current); + } catch (_error) { + break; + } + if (entries.length > 0) { + break; + } + try { + fs.rmdirSync(current); + if (!removedSet.has(current)) { + removedSet.add(current); + removed.push(current); + } + } catch (_error) { + break; + } + const parentDir = normalizeComparablePath(path.dirname(current)); + if (!parentDir || parentDir === current) { + break; + } + current = parentDir; + } + + return removed; +} + function normalizeJobIdValue(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { @@ -6378,7 +6468,6 @@ class HistoryService { } const artifactMoviePathSet = new Set(artifactMoviePaths); - const includeMovieParentCandidate = resolvedPaths?.mediaType !== 'converter'; for (const outputPath of explicitMoviePaths) { addCandidate( movieCandidates, @@ -6387,16 +6476,6 @@ class HistoryService { artifactMoviePathSet.has(outputPath) ? 'lineage_output_path' : 'output_path', movieAllowedPaths ); - const parentDir = toNormalizedPath(path.dirname(outputPath)); - if (includeMovieParentCandidate && parentDir && !movieRoots.includes(parentDir)) { - addCandidate( - movieCandidates, - 'movie', - parentDir, - artifactMoviePathSet.has(outputPath) ? 'lineage_output_parent' : 'output_parent', - movieAllowedPaths - ); - } } if (normalizedJobId && resolvedPaths?.mediaType !== 'cd') { @@ -6505,28 +6584,117 @@ class HistoryService { return normalizedSources.includes('output_path') || normalizedSources.includes('lineage_output_path'); }; - const buildList = (target) => Array.from(candidateMap.values()) - .filter((row) => row.target === target) - .map((row) => { + const buildList = (target) => { + const rows = Array.from(candidateMap.values()) + .filter((row) => row.target === target); + if (target !== 'movie') { + return rows + .map((row) => { + const inspection = inspectDeletionPath(row.path); + const sources = Array.from(row.sources).sort((left, right) => left.localeCompare(right)); + return { + target, + path: row.path, + exists: Boolean(inspection.exists), + isDirectory: Boolean(inspection.isDirectory), + isFile: Boolean(inspection.isFile), + jobIds: Array.from(row.jobIds).sort((left, right) => left - right), + sources + }; + }) + .filter(Boolean) + .sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de')); + } + + const moviePathMap = new Map(); + const upsertMoviePath = ({ path: candidatePath, exists, isDirectory, isFile, jobIds, sources }) => { + const normalizedPath = normalizeComparablePath(candidatePath); + if (!normalizedPath) { + return; + } + if (!moviePathMap.has(normalizedPath)) { + moviePathMap.set(normalizedPath, { + target: 'movie', + path: normalizedPath, + exists: Boolean(exists), + isDirectory: Boolean(isDirectory), + isFile: Boolean(isFile), + jobIds: new Set(), + sources: new Set() + }); + } + const row = moviePathMap.get(normalizedPath); + row.exists = row.exists || Boolean(exists); + row.isDirectory = row.isDirectory || Boolean(isDirectory); + row.isFile = row.isFile || Boolean(isFile); + for (const jobId of (Array.isArray(jobIds) ? jobIds : [])) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (normalizedJobId) { + row.jobIds.add(normalizedJobId); + } + } + for (const source of (Array.isArray(sources) ? sources : [])) { + const normalizedSource = String(source || '').trim(); + if (normalizedSource) { + row.sources.add(normalizedSource); + } + } + }; + + for (const row of rows) { const inspection = inspectDeletionPath(row.path); const sources = Array.from(row.sources).sort((left, right) => left.localeCompare(right)); - // Do not expose stale output file paths from DB/lineage in delete preview. - // They cannot be deleted anyway and show up as "ghost paths" in UI/QA checks. - if (target === 'movie' && !inspection.exists && hasTrackedOutputSource(sources)) { - return null; + // Do not expose stale output paths from DB/lineage in delete preview. + if (!inspection.exists && hasTrackedOutputSource(sources)) { + continue; } - return { - target, - path: row.path, - exists: Boolean(inspection.exists), - isDirectory: Boolean(inspection.isDirectory), - isFile: Boolean(inspection.isFile), - jobIds: Array.from(row.jobIds).sort((left, right) => left - right), + if (!inspection.exists) { + upsertMoviePath({ + path: row.path, + exists: false, + isDirectory: false, + isFile: true, + jobIds: Array.from(row.jobIds), + sources + }); + continue; + } + if (inspection.isDirectory) { + const files = collectFilesRecursively(inspection.path); + for (const filePath of files) { + upsertMoviePath({ + path: filePath, + exists: true, + isDirectory: false, + isFile: true, + jobIds: Array.from(row.jobIds), + sources + }); + } + continue; + } + upsertMoviePath({ + path: inspection.path, + exists: true, + isDirectory: false, + isFile: true, + jobIds: Array.from(row.jobIds), sources - }; - }) - .filter(Boolean) - .sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de')); + }); + } + + return Array.from(moviePathMap.values()) + .map((row) => ({ + target: row.target, + path: row.path, + exists: Boolean(row.exists), + isDirectory: false, + isFile: true, + jobIds: Array.from(row.jobIds).sort((left, right) => left - right), + sources: Array.from(row.sources).sort((left, right) => left.localeCompare(right)) + })) + .sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de')); + }; return { pathCandidates: { @@ -6650,7 +6818,7 @@ class HistoryService { if (targetKey === 'raw' && selectedRawPathFilter.size > 0) { summary[targetKey].reason = 'Keine ausgewählten RAW-Dateien/Ordner gefunden.'; } else if (targetKey === 'movie' && selectedMoviePathFilter.size > 0) { - summary[targetKey].reason = 'Keine ausgewählten Audio/Video-Ordner gefunden.'; + summary[targetKey].reason = 'Keine ausgewählten Audio/Video-Dateien gefunden.'; } else { summary[targetKey].reason = 'Keine passenden Dateien/Ordner gefunden.'; } @@ -6678,6 +6846,9 @@ class HistoryService { } if (inspection.isDirectory) { + if (targetKey === 'movie') { + continue; + } const keepRoot = protectedRoots.has(inspection.path); const result = deleteFilesRecursively(inspection.path, keepRoot); const filesDeleted = Number(result?.filesDeleted || 0); @@ -6709,6 +6880,23 @@ class HistoryService { keepRoot: false, jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : [] }); + + if (targetKey === 'movie') { + const parentDir = normalizeComparablePath(path.dirname(inspection.path)); + const removedParentDirs = removeEmptyParentDirectories(parentDir, { + protectedRoots: Array.from(protectedRoots) + }); + for (const removedPath of removedParentDirs) { + summary[targetKey].dirsRemoved += 1; + summary.deletedPaths.push({ + target: targetKey, + path: removedPath, + type: 'directory', + keepRoot: false, + jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : [] + }); + } + } } summary[targetKey].deleted = summary[targetKey].pathsDeleted > 0 @@ -6810,37 +6998,9 @@ class HistoryService { )); if (summary.movie?.deleted) { - const movieDeleteEntries = (Array.isArray(summary?.deletedPaths) ? summary.deletedPaths : []) - .filter((entry) => String(entry?.target || '').trim().toLowerCase() === 'movie') - .map((entry) => ({ - path: normalizeComparablePath(entry?.path), - type: String(entry?.type || '').trim().toLowerCase() - })) - .filter((entry) => Boolean(entry.path)); - const previewMoviePaths = (Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : []) - .map((candidate) => normalizeComparablePath(candidate?.path)) - .filter(Boolean); - const cleanupPaths = new Set(); - - for (const deletedEntry of movieDeleteEntries) { - cleanupPaths.add(deletedEntry.path); - for (const candidatePath of previewMoviePaths) { - if (deletedEntry.type === 'directory') { - if (isPathInside(deletedEntry.path, candidatePath)) { - cleanupPaths.add(candidatePath); - } - } else if (candidatePath === deletedEntry.path) { - cleanupPaths.add(candidatePath); - } - } - } - - for (const deletedPath of cleanupPaths) { - await this.removeJobOutputFolderFromJobs( - relatedJobIds.length > 0 ? relatedJobIds : [normalizedJobId], - deletedPath - ); - } + await this.removeMissingJobOutputFoldersFromJobs( + relatedJobIds.length > 0 ? relatedJobIds : [normalizedJobId] + ); } await this.appendLog( @@ -7340,6 +7500,57 @@ class HistoryService { } } + async removeMissingJobOutputFoldersFromJobs(jobIds = []) { + const normalizedJobIds = Array.isArray(jobIds) + ? jobIds + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + : []; + if (normalizedJobIds.length === 0) { + return { removed: 0, removedPaths: [] }; + } + const db = await getDb(); + const placeholders = normalizedJobIds.map(() => '?').join(', '); + const rows = await db.all( + `SELECT id, output_path FROM job_output_folders WHERE job_id IN (${placeholders})`, + normalizedJobIds + ); + const removedPaths = []; + for (const row of (Array.isArray(rows) ? rows : [])) { + const normalizedPath = normalizeComparablePath(row?.output_path); + let removeEntry = false; + if (!normalizedPath) { + removeEntry = true; + } else if (!fs.existsSync(normalizedPath)) { + removeEntry = true; + } else { + try { + const stat = fs.lstatSync(normalizedPath); + if (stat.isDirectory()) { + const entries = fs.readdirSync(normalizedPath); + if (entries.length === 0) { + fs.rmdirSync(normalizedPath); + removeEntry = true; + } + } + } catch (_error) { + removeEntry = true; + } + } + if (!removeEntry) { + continue; + } + await db.run('DELETE FROM job_output_folders WHERE id = ?', [Number(row.id)]); + if (normalizedPath) { + removedPaths.push(normalizedPath); + } + } + return { + removed: removedPaths.length, + removedPaths: Array.from(new Set(removedPaths)).sort((left, right) => left.localeCompare(right, 'de')) + }; + } + async deleteSpecificOutputFolders(jobId, folderPaths = []) { const paths = Array.isArray(folderPaths) ? folderPaths.filter((p) => typeof p === 'string' && p.trim()) : []; if (paths.length === 0) return { deleted: [], failed: [] }; @@ -7621,6 +7832,9 @@ class HistoryService { selectedRawPaths: selectedRawPathsForDelete, selectedMoviePaths: selectedMoviePathsForDelete }); + if (fileSummary?.movie?.deleted) { + await this.removeMissingJobOutputFoldersFromJobs([normalizedJobId]); + } } const db = await getDb(); @@ -7798,6 +8012,9 @@ class HistoryService { selectedRawPaths: selectedRawPathsForDelete, selectedMoviePaths: selectedMoviePathsForDelete }); + if (fileSummary?.movie?.deleted) { + await this.removeMissingJobOutputFoldersFromJobs(deleteJobIds); + } } await db.exec('BEGIN'); diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 5abd16e..4c41ea1 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -23,7 +23,7 @@ const logger = require('./logger').child('PIPELINE'); const { spawnTrackedProcess } = require('./processRunner'); const { parseMakeMkvProgress, parseHandBrakeProgress, parseMkvmergeProgress } = require('../utils/progressParsers'); const { ensureDir, sanitizeFileName, sanitizeFileNameWithExtension, renderTemplate, findMediaFiles } = require('../utils/files'); -const { buildMediainfoReview } = require('../utils/encodePlan'); +const { buildMediainfoReview, refreshAudioTrackActionsForPlanTitles } = require('../utils/encodePlan'); const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis'); const { errorToMeta } = require('../utils/errorMeta'); const userPresetService = require('./userPresetService'); @@ -242,11 +242,32 @@ function resolveDiscNumberFromJobLike(job = null) { ); } +function normalizeContextJobId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + function resolveSelectedMetadataForJob(job = null, analyzeContext = null, activeContext = null) { + const jobId = normalizeContextJobId(job?.id ?? job?.jobId ?? null); + const activeContextJobId = normalizeContextJobId( + activeContext?.jobId + ?? activeContext?.activeJobId + ?? null + ); + const allowActiveContextMetadata = ( + jobId === null + ? true + : (activeContextJobId !== null && activeContextJobId === jobId) + ); const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' ? analyzeContext.selectedMetadata : {}; - const activeSelectedMetadata = activeContext?.selectedMetadata && typeof activeContext.selectedMetadata === 'object' + const activeSelectedMetadata = allowActiveContextMetadata + && activeContext?.selectedMetadata + && typeof activeContext.selectedMetadata === 'object' ? activeContext.selectedMetadata : {}; const merged = { @@ -256,7 +277,7 @@ function resolveSelectedMetadataForJob(job = null, analyzeContext = null, active const workflowKind = normalizeDvdMetadataWorkflowKind( merged.workflowKind || analyzeContext?.workflowKind - || activeContext?.workflowKind + || (allowActiveContextMetadata ? activeContext?.workflowKind : null) || null ); @@ -270,7 +291,7 @@ function resolveSelectedMetadataForJob(job = null, analyzeContext = null, active metadataProvider: String( merged.metadataProvider || analyzeContext?.metadataProvider - || activeContext?.metadataProvider + || (allowActiveContextMetadata ? activeContext?.metadataProvider : null) || 'omdb' ).trim().toLowerCase() || 'omdb' }; @@ -11382,7 +11403,17 @@ class PipelineService extends EventEmitter { .filter((row) => Number(row?.id) > 0 && !this.isContainerHistoryJob(row)) .filter((row) => { if (isSeriesContainer) { - return !isSeriesBatchEpisodeSubJobRow(row); + const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' + ? row.encodePlan + : this.safeParseJson(row?.encode_plan_json); + const rowJobKind = String(row?.job_kind || row?.jobKind || '').trim().toLowerCase(); + const isSeriesEpisodeSubJob = ( + isSeriesBatchChildPlan(encodePlan) + || Boolean(encodePlan?.seriesBatchVirtualEpisode) + || rowJobKind === 'dvd_series_episode' + || rowJobKind === 'dvd_series_virtual_episode' + ); + return !isSeriesEpisodeSubJob; } if (isMultipartContainer) { return this.isMultipartMovieDiscChildHistoryJob(row) || this.isMultipartMovieMergeHistoryJob(row); @@ -16035,7 +16066,7 @@ class PipelineService extends EventEmitter { try { let effectiveDetectedTitle = detectedTitle; - let omdbCandidates = null; + let omdbCandidates = []; let metadataCandidates = null; let metadataProvider = 'omdb'; let seriesAnalysis = null; @@ -16054,9 +16085,6 @@ class PipelineService extends EventEmitter { if (pluginDetectedTitle) { effectiveDetectedTitle = pluginDetectedTitle; } - if (Array.isArray(pluginResult?.omdbCandidates)) { - omdbCandidates = pluginResult.omdbCandidates; - } logger.info('plugin:analyze:used', { jobId: normalizedJobId, pluginId: analyzePlugin.id, @@ -16076,7 +16104,6 @@ class PipelineService extends EventEmitter { if (isSeriesDiscMediaProfile(mediaProfile) && analyzePlugin && ['dvd', 'bluray'].includes(analyzePlugin.id)) { try { - const seriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile); const rawMedia = collectRawMediaCandidates(resolvedRawPath); const seriesScanInputPath = mediaProfile === 'dvd' ? (resolveDvdScanInputPathFromRaw(resolvedRawPath, rawMedia) || resolvedRawPath) @@ -16183,21 +16210,6 @@ class PipelineService extends EventEmitter { seriesTitle: String(seriesLookupHint?.seriesTitle || fallbackQuery).trim() || fallbackQuery }; } - - if (seriesPluginResult?.providerConfigured && seriesLookupHint?.query) { - metadataCandidates = await tmdbService.searchSeriesWithSeasons( - seriesLookupHint.query, - { language: seriesSettings?.dvd_series_language || null } - ).catch((error) => { - logger.warn('dvd-series:tmdb-search-failed', { - jobId: normalizedJobId, - query: seriesLookupHint?.query || null, - seasonNumber: seriesLookupHint?.seasonNumber || null, - error: errorToMeta(error) - }); - return []; - }); - } } logger.info('dvd-series:analyze:done', { @@ -16220,7 +16232,7 @@ class PipelineService extends EventEmitter { } if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) { - omdbCandidates = await omdbService.search(effectiveDetectedTitle).catch(() => []); + omdbCandidates = []; } if (metadataProvider !== 'tmdb') { metadataCandidates = Array.isArray(omdbCandidates) ? omdbCandidates : []; @@ -16262,8 +16274,8 @@ class PipelineService extends EventEmitter { normalizedJobId, 'SYSTEM', metadataProvider === 'tmdb' - ? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.` - : `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".` + ? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? `, Staffel ${seriesLookupHint.seasonNumber}` : ''}).` + : `Disk erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${effectiveDetectedTitle}").` ); await this.setState('METADATA_SELECTION', { @@ -16294,7 +16306,7 @@ class PipelineService extends EventEmitter { void this.notifyPushover('metadata_ready', { title: 'Ripster - Metadaten bereit', - message: `Job #${normalizedJobId}: ${effectiveDetectedTitle} (${Array.isArray(metadataCandidates) ? metadataCandidates.length : 0} ${metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'} Treffer)` + message: `Job #${normalizedJobId}: Metadaten müssen manuell zugeordnet werden` }); return { @@ -16448,7 +16460,7 @@ class PipelineService extends EventEmitter { try { let effectiveDetectedTitle = detectedTitle; - let omdbCandidates = null; + let omdbCandidates = []; let metadataCandidates = null; let metadataProvider = 'omdb'; let seriesAnalysis = null; @@ -16466,9 +16478,6 @@ class PipelineService extends EventEmitter { if (pluginDetectedTitle) { effectiveDetectedTitle = pluginDetectedTitle; } - if (Array.isArray(pluginResult?.omdbCandidates)) { - omdbCandidates = pluginResult.omdbCandidates; - } logger.info('plugin:analyze:used', { jobId: job.id, pluginId: analyzePlugin.id, @@ -16486,7 +16495,6 @@ class PipelineService extends EventEmitter { } if (isSeriesDiscMediaProfile(mediaProfile) && analyzePlugin && ['dvd', 'bluray'].includes(analyzePlugin.id)) { try { - const seriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile); const reviewResult = await this.runPluginReviewScan(analyzePlugin, job, { jobId: job.id, deviceInfo: deviceWithProfile, @@ -16586,21 +16594,6 @@ class PipelineService extends EventEmitter { seriesTitle: String(seriesLookupHint?.seriesTitle || fallbackQuery).trim() || fallbackQuery }; } - - if (seriesPluginResult?.providerConfigured && seriesLookupHint?.query) { - metadataCandidates = await tmdbService.searchSeriesWithSeasons( - seriesLookupHint.query, - { language: seriesSettings?.dvd_series_language || null } - ).catch((error) => { - logger.warn('dvd-series:tmdb-search-failed', { - jobId: job.id, - query: seriesLookupHint?.query || null, - seasonNumber: seriesLookupHint?.seasonNumber || null, - error: errorToMeta(error) - }); - return []; - }); - } } logger.info('dvd-series:analyze:done', { @@ -16621,7 +16614,7 @@ class PipelineService extends EventEmitter { } } if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) { - omdbCandidates = await omdbService.search(effectiveDetectedTitle).catch(() => []); + omdbCandidates = []; } if (metadataProvider !== 'tmdb') { metadataCandidates = Array.isArray(omdbCandidates) ? omdbCandidates : []; @@ -16662,8 +16655,8 @@ class PipelineService extends EventEmitter { job.id, 'SYSTEM', metadataProvider === 'tmdb' - ? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.` - : `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".` + ? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? `, Staffel ${seriesLookupHint.seasonNumber}` : ''}).` + : `Disk erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${effectiveDetectedTitle}").` ); const keepCurrentPipelineSession = this._hasForeignInteractiveSession(job.id); @@ -16705,7 +16698,7 @@ class PipelineService extends EventEmitter { void this.notifyPushover('metadata_ready', { title: 'Ripster - Metadaten bereit', - message: `Job #${job.id}: ${effectiveDetectedTitle} (${Array.isArray(metadataCandidates) ? metadataCandidates.length : 0} ${metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'} Treffer)` + message: `Job #${job.id}: Metadaten müssen manuell zugeordnet werden` }); return { @@ -21500,6 +21493,25 @@ class PipelineService extends EventEmitter { throw error; } const confirmSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile); + const actionDisplaySettings = { + ...(confirmSettings && typeof confirmSettings === 'object' ? confirmSettings : {}), + handbrake_preset: String( + resolvedUserPreset?.handbrakePreset + || confirmSettings?.handbrake_preset + || '' + ).trim() || null, + handbrake_extra_args: String( + resolvedUserPreset?.extraArgs + || confirmSettings?.handbrake_extra_args + || '' + ).trim() + }; + confirmedPlan.titles = refreshAudioTrackActionsForPlanTitles( + confirmedPlan.titles, + actionDisplaySettings, + null + ); + const resolvedConfirmRawPath = this.resolveCurrentRawPathForSettings( confirmSettings, readyMediaProfile, @@ -25168,6 +25180,7 @@ class PipelineService extends EventEmitter { selectedTitleId: effectiveSelectedTitleId, disableMinLengthFilter, backupOutputBase, + sourceArgOverride: null, runCommand: this.runCommand.bind(this) }); makemkvInfo = await ripPlugin.rip(job, ripCtx); @@ -25325,7 +25338,6 @@ class PipelineService extends EventEmitter { // Retry starts processing immediately (Rip/Merge) and bypasses the encode queue. return this.retry(jobId, { ...options, immediate: true }); } - const resolvedRetryJob = await this.resolveExistingJobForAction(jobId, 'retry'); jobId = resolvedRetryJob.resolvedJobId; this.ensureNotBusy('retry', jobId); @@ -25357,7 +25369,9 @@ class PipelineService extends EventEmitter { const isCdRetry = mediaProfile === 'cd'; const isAudiobookRetry = mediaProfile === 'audiobook'; const isMultipartMergeRetry = sourceJobKind === 'multipart_movie_merge'; + const isVideoDiscRetry = mediaProfile === 'dvd' || mediaProfile === 'bluray'; const sourceParentJobId = this.normalizeQueueJobId(sourceJob?.parent_job_id); + let mergeContainerJobId = null; if (isMultipartMergeRetry) { mergeContainerJobId = this.normalizeQueueJobId(sourceEncodePlan?.containerJobId); @@ -25391,12 +25405,21 @@ class PipelineService extends EventEmitter { } // CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden. - const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus) + const retryableByStatus = ['ERROR', 'CANCELLED'].includes(sourceStatus) || ['ERROR', 'CANCELLED'].includes(sourceLastState) || (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus)); + const incompleteRipRetryableStates = ['WAITING_FOR_USER_DECISION', 'READY_TO_ENCODE', 'MEDIAINFO_CHECK']; + const retryableByIncompleteVideoRipState = isVideoDiscRetry + && !isMultipartMergeRetry + && ( + incompleteRipRetryableStates.includes(sourceStatus) + || incompleteRipRetryableStates.includes(sourceLastState) + ); + const retryable = retryableByStatus || retryableByIncompleteVideoRipState; if (!retryable) { + const currentStateLabel = sourceStatus || sourceLastState || '-'; const error = new Error( - `Retry nicht möglich: Job ${jobId} ist nicht im Status ERROR/CANCELLED (aktuell ${sourceStatus || sourceLastState || '-'}).` + `Retry nicht möglich: Job ${jobId} ist nicht in einem retry-fähigen Status (aktuell ${currentStateLabel}).` ); error.statusCode = 409; throw error; @@ -25690,7 +25713,11 @@ class PipelineService extends EventEmitter { }); } else { this.startRipEncode(retryJobId).catch((error) => { - logger.error('retry:background-failed', { jobId: retryJobId, sourceJobId: jobId, error: errorToMeta(error) }); + logger.error('retry:background-failed', { + jobId: retryJobId, + sourceJobId: jobId, + error: errorToMeta(error) + }); }); } diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 538f819..61dcf81 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -1176,7 +1176,8 @@ class SettingsService { const ripMode = String(map.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' ? 'backup' : 'mkv'; - const sourceArg = this.resolveSourceArg(map, deviceInfo); + const sourceArgOverride = String(options?.sourceArgOverride || '').trim(); + const sourceArg = sourceArgOverride || this.resolveSourceArg(map, deviceInfo); const rawSelectedTitleId = normalizeNonNegativeInteger(options?.selectedTitleId); const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter); const parsedExtra = splitArgs(map.makemkv_rip_extra_args); @@ -1210,6 +1211,7 @@ class SettingsService { if (hasExplicitTitle) { baseArgs = [ '-r', '--progress=-same', + '--decrypt', 'mkv', sourceArg, targetTitle, @@ -1221,6 +1223,7 @@ class SettingsService { : []; baseArgs = [ '-r', '--progress=-same', + '--decrypt', ...minLengthArgs, 'mkv', sourceArg, diff --git a/backend/src/utils/encodePlan.js b/backend/src/utils/encodePlan.js index 9ea8943..b95b0c9 100644 --- a/backend/src/utils/encodePlan.js +++ b/backend/src/utils/encodePlan.js @@ -1243,6 +1243,66 @@ function computeAudioTrackActions(track, selectedIndex, selector) { }; } +function refreshAudioTrackActionsForPlanTitles(titles, settings = {}, presetProfile = null) { + const sourceTitles = Array.isArray(titles) ? titles : []; + if (sourceTitles.length === 0) { + return sourceTitles; + } + + const selectors = buildTrackSelectors(settings || {}, presetProfile || null); + const audioSelector = selectors?.audio && typeof selectors.audio === 'object' + ? selectors.audio + : {}; + + return sourceTitles.map((title) => { + const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : []; + if (audioTracks.length === 0) { + return title; + } + + const selectedTracks = audioTracks.filter((track) => Boolean(track?.selectedForEncode)); + const selectedIndexById = new Map( + selectedTracks + .map((track, index) => { + const trackId = Number(track?.id); + if (!Number.isFinite(trackId)) { + return null; + } + return [Math.trunc(trackId), index]; + }) + .filter(Boolean) + ); + + const nextAudioTracks = audioTracks.map((track) => { + if (!track?.selectedForEncode) { + return { + ...track, + encodeActions: [], + encodeActionSummary: 'Nicht übernommen' + }; + } + const numericTrackId = Number(track?.id); + const normalizedTrackId = Number.isFinite(numericTrackId) ? Math.trunc(numericTrackId) : null; + const selectedIndex = (normalizedTrackId !== null && selectedIndexById.has(normalizedTrackId)) + ? selectedIndexById.get(normalizedTrackId) + : 0; + const actions = computeAudioTrackActions(track, selectedIndex, audioSelector); + return { + ...track, + encodePreviewActions: actions.actions, + encodePreviewSummary: actions.summary, + encodeActions: actions.actions, + encodeActionSummary: actions.summary + }; + }); + + return { + ...title, + audioTracks: nextAudioTracks + }; + }); +} + function computeSubtitleFlags(trackId, selectedTrackIds, selector) { const selected = selectedTrackIds.includes(trackId); if (!selected) { @@ -1526,5 +1586,6 @@ function buildMediainfoReview({ module.exports = { parseDurationSeconds, - buildMediainfoReview + buildMediainfoReview, + refreshAudioTrackActionsForPlanTitles }; diff --git a/db/schema.sql b/db/schema.sql index 10db9ca..cbdbd48 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -647,7 +647,7 @@ VALUES ('pushover_timeout_ms', 'Benachrichtigungen', 'PushOver Timeout (ms)', 'n INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_timeout_ms', '7000'); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('pushover_notify_metadata_ready', 'Benachrichtigungen', 'Bei Metadaten-Auswahl senden', 'boolean', 1, 'Sendet wenn Metadaten zur Auswahl bereitstehen.', 'true', '[]', '{}', 570); +VALUES ('pushover_notify_metadata_ready', 'Benachrichtigungen', 'Bei manueller Auswahl senden', 'boolean', 1, 'Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist).', 'true', '[]', '{}', 570); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_metadata_ready', 'true'); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3e81d08..92aab59 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.15.0-2", + "version": "0.15.0-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.15.0-2", + "version": "0.15.0-3", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index a525a10..c4a09d0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.15.0-2", + "version": "0.15.0-3", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index efd7c2b..ef3c0a3 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -184,6 +184,78 @@ function trackSizeFormat(bytes) { return `${size.toFixed(2)} ${units[i]}`; } +function resolveManualTrackSelectionForTitle(encodePlan, titleId) { + const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; + const normalizedTitleId = normalizePositiveInteger(titleId); + if (!normalizedTitleId) { + return null; + } + const byTitle = plan?.manualTrackSelectionByTitle && typeof plan.manualTrackSelectionByTitle === 'object' + ? plan.manualTrackSelectionByTitle + : {}; + const directSelection = byTitle[normalizedTitleId] || byTitle[String(normalizedTitleId)] || null; + if (directSelection && typeof directSelection === 'object') { + return directSelection; + } + const fallbackSelection = plan?.manualTrackSelection && typeof plan.manualTrackSelection === 'object' + ? plan.manualTrackSelection + : null; + if (!fallbackSelection) { + return null; + } + const fallbackTitleId = normalizePositiveInteger(fallbackSelection?.titleId); + return fallbackTitleId === normalizedTitleId ? fallbackSelection : null; +} + +function buildEffectiveTitleTrackSelection(encodePlan, title) { + const manualSelection = resolveManualTrackSelectionForTitle(encodePlan, title?.id); + const hasManualAudio = Array.isArray(manualSelection?.audioTrackIds); + const hasManualSubtitle = Array.isArray(manualSelection?.subtitleTrackIdsOrdered) || Array.isArray(manualSelection?.subtitleTrackIds); + const selectedAudioIds = hasManualAudio + ? normalizeIdList(manualSelection.audioTrackIds) + : normalizeIdList((Array.isArray(title?.audioTracks) ? title.audioTracks : []) + .filter((track) => Boolean(track?.selectedForEncode)) + .map((track) => track?.id)); + const selectedSubtitleIds = hasManualSubtitle + ? normalizeIdList( + Array.isArray(manualSelection?.subtitleTrackIdsOrdered) && manualSelection.subtitleTrackIdsOrdered.length > 0 + ? manualSelection.subtitleTrackIdsOrdered + : manualSelection?.subtitleTrackIds + ) + : normalizeIdList((Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []) + .filter((track) => Boolean(track?.selectedForEncode)) + .map((track) => track?.id)); + + return { + selectedAudioSet: new Set(selectedAudioIds.map((id) => String(id))), + selectedSubtitleSet: new Set(selectedSubtitleIds.map((id) => String(id))), + hasManualAudio, + hasManualSubtitle + }; +} + +function getTrackActionLabel({ + selected = false, + summary = '', + manualSelection = false, + fallback = 'Übernehmen' +}) { + if (!selected) { + return 'Nicht übernommen'; + } + const rawSummary = String(summary || '').trim(); + if (!rawSummary) { + return manualSelection ? `${fallback} (manuell)` : fallback; + } + if (/^nicht übernommen/i.test(rawSummary)) { + return manualSelection ? `${fallback} (manuell)` : fallback; + } + if (/^preset-default\b/i.test(rawSummary) || /Preset-Default \(HandBrake\)/i.test(rawSummary)) { + return manualSelection ? `${fallback} (manuell)` : 'Übernehmen'; + } + return rawSummary; +} + function buildExecutedHandBrakeCommand(handbrakeInfo) { const cmd = String(handbrakeInfo?.cmd || '').trim(); const args = Array.isArray(handbrakeInfo?.args) ? handbrakeInfo.args : []; @@ -1378,6 +1450,7 @@ export default function JobDetailDialog({ const selectedEncodeTitles = encodePlanTitles.filter( (title) => title.selectedForEncode || title.encodeInput || String(title.id) === String(reviewSelectedEncodeTitleId) ); + const trackDisplayTitles = selectedEncodeTitles.length > 0 ? selectedEncodeTitles : encodePlanTitles; const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo); const posterUrl = String( job?.poster_url @@ -1390,7 +1463,41 @@ export default function JobDetailDialog({ const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null; const canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.exists && typeof onDownloadArchive === 'function'); const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function'); - const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : []; + const outputFolders = (() => { + const baseFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : []; + if (!isDiskContainer) { + return baseFolders; + } + const merged = []; + const seen = new Set(); + const addFolder = (folder, fallbackId = null) => { + const outputPath = String(folder?.output_path || '').trim(); + if (!outputPath || seen.has(outputPath)) { + return; + } + seen.add(outputPath); + merged.push({ + id: folder?.id ?? fallbackId ?? outputPath, + output_path: outputPath + }); + }; + for (const folder of baseFolders) { + addFolder(folder); + } + for (const child of childJobs) { + const childOutputFolders = Array.isArray(child?.outputFolders) ? child.outputFolders : []; + for (const childFolder of childOutputFolders) { + addFolder(childFolder, `child-folder-${child?.id || 'x'}-${String(childFolder?.output_path || '')}`); + } + if (child?.output_path) { + addFolder( + { output_path: child.output_path }, + `child-output-${child?.id || 'x'}-${String(child.output_path || '')}` + ); + } + } + return merged; + })(); const mergeOutputPathCandidates = new Set( [ ...(isMultipartMergeJob ? [job?.output_path] : []), @@ -2083,7 +2190,7 @@ export default function JobDetailDialog({ ) : null} {/* Spurauswahl – read-only */} - {selectedEncodeTitles.length > 0 ? ( + {trackDisplayTitles.length > 0 ? ( isDvdSeries ? (
@@ -2091,9 +2198,10 @@ export default function JobDetailDialog({
Spurauswahl
- {selectedEncodeTitles.map((title) => { + {trackDisplayTitles.map((title) => { const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : []; const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : []; + const trackSelection = buildEffectiveTitleTrackSelection(job?.encodePlan, title); return (
@@ -2109,9 +2217,16 @@ export default function JobDetailDialog({ const chLayout = trackChLayout(track.channels); let displayText = `#${track.id} | ${lang} | ${codec}`; if (chLayout) displayText += ` | ${chLayout}`; - const actionInfo = track.selectedForEncode - ? (String(track.encodePreviewSummary || track.encodeActionSummary || '').trim() || 'Copy') - : 'Nicht übernommen'; + const normalizedTrackId = normalizePositiveInteger(track?.id); + const selected = normalizedTrackId + ? trackSelection.selectedAudioSet.has(String(normalizedTrackId)) + : Boolean(track?.selectedForEncode); + const actionInfo = getTrackActionLabel({ + selected, + summary: track.encodeActionSummary || track.encodePreviewSummary, + manualSelection: trackSelection.hasManualAudio, + fallback: 'Übernehmen' + }); return (
{displayText} @@ -2128,10 +2243,16 @@ export default function JobDetailDialog({ const lang = trackLang(track.language || track.languageLabel); const codec = trackCodec('subtitle', track.format); const displayText = `#${track.id} | ${lang} | ${codec}`; - const rawAction = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim(); - const actionInfo = track.selectedForEncode - ? (/^nicht übernommen$/i.test(rawAction) ? 'Übernehmen' : (rawAction || 'Übernehmen')) - : 'Nicht übernommen'; + const normalizedTrackId = normalizePositiveInteger(track?.id); + const selected = normalizedTrackId + ? trackSelection.selectedSubtitleSet.has(String(normalizedTrackId)) + : Boolean(track?.selectedForEncode); + const actionInfo = getTrackActionLabel({ + selected, + summary: track.subtitleActionSummary || track.subtitlePreviewSummary, + manualSelection: trackSelection.hasManualSubtitle, + fallback: 'Übernehmen' + }); return (
{displayText} @@ -2150,9 +2271,10 @@ export default function JobDetailDialog({ ) : (
Spurauswahl
- {selectedEncodeTitles.map((title) => { + {trackDisplayTitles.map((title) => { const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : []; const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : []; + const trackSelection = buildEffectiveTitleTrackSelection(job?.encodePlan, title); return (
@@ -2168,9 +2290,16 @@ export default function JobDetailDialog({ const chLayout = trackChLayout(track.channels); let displayText = `#${track.id} | ${lang} | ${codec}`; if (chLayout) displayText += ` | ${chLayout}`; - const actionInfo = track.selectedForEncode - ? (String(track.encodePreviewSummary || track.encodeActionSummary || '').trim() || 'Copy') - : 'Nicht übernommen'; + const normalizedTrackId = normalizePositiveInteger(track?.id); + const selected = normalizedTrackId + ? trackSelection.selectedAudioSet.has(String(normalizedTrackId)) + : Boolean(track?.selectedForEncode); + const actionInfo = getTrackActionLabel({ + selected, + summary: track.encodeActionSummary || track.encodePreviewSummary, + manualSelection: trackSelection.hasManualAudio, + fallback: 'Übernehmen' + }); return (
{displayText} @@ -2187,10 +2316,16 @@ export default function JobDetailDialog({ const lang = trackLang(track.language || track.languageLabel); const codec = trackCodec('subtitle', track.format); const displayText = `#${track.id} | ${lang} | ${codec}`; - const rawAction = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim(); - const actionInfo = track.selectedForEncode - ? (/^nicht übernommen$/i.test(rawAction) ? 'Übernehmen' : (rawAction || 'Übernehmen')) - : 'Nicht übernommen'; + const normalizedTrackId = normalizePositiveInteger(track?.id); + const selected = normalizedTrackId + ? trackSelection.selectedSubtitleSet.has(String(normalizedTrackId)) + : Boolean(track?.selectedForEncode); + const actionInfo = getTrackActionLabel({ + selected, + summary: track.subtitleActionSummary || track.subtitlePreviewSummary, + manualSelection: trackSelection.hasManualSubtitle, + fallback: 'Übernehmen' + }); return (
{displayText} diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx index c448a52..85bb626 100644 --- a/frontend/src/components/PipelineStatusCard.jsx +++ b/frontend/src/components/PipelineStatusCard.jsx @@ -179,6 +179,85 @@ function resolvePipelineMediaProfile(pipeline, mediaInfoReview) { return null; } +function normalizeMakeMkvRipIssueMessage(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); +} + +function parseMakeMkvRipIssueLine(rawLine) { + const line = String(rawLine || '').trim(); + if (!line) { + return null; + } + + const sourceMatch = line.match(/^\[[^\]]+\]\s+\[([A-Z0-9_]+)\]\s+/i); + const source = String(sourceMatch?.[1] || '').trim().toUpperCase(); + if (source && source !== 'MAKEMKV_RIP') { + return null; + } + + const msgCodeMatch = line.match(/\bMSG:(\d+),/i); + const parsedCode = msgCodeMatch ? Number(msgCodeMatch[1]) : null; + const code = Number.isFinite(parsedCode) ? Math.trunc(parsedCode) : null; + const quotedMessageMatch = line.match(/\bMSG:\d+,[^,]*,[^,]*,"((?:[^"\\]|\\.)*)"/i); + const fallback = line + .replace(/^\[[^\]]+\]\s+\[[^\]]+\]\s*/i, '') + .trim(); + const rawMessage = quotedMessageMatch?.[1] + ? quotedMessageMatch[1].replace(/\\"/g, '"') + : (fallback || line); + const message = normalizeMakeMkvRipIssueMessage(rawMessage); + if (!message) { + return null; + } + + const isErrorLine = /\berror\b|\bfail(?:ed|ure)?\b|\bhash check\b|\buncorrectable\b/i.test(message); + if (!isErrorLine) { + return null; + } + + return { code, message }; +} + +function extractMakeMkvRipIssues(makemkvInfo) { + if (!makemkvInfo || typeof makemkvInfo !== 'object') { + return []; + } + + const sourceLines = []; + if (Array.isArray(makemkvInfo?.highlights)) { + sourceLines.push(...makemkvInfo.highlights); + } + + const stdoutTail = String(makemkvInfo?.stdoutTail || '').trim(); + if (stdoutTail) { + sourceLines.push(...stdoutTail.split('\n')); + } + + const stderrTail = String(makemkvInfo?.stderrTail || '').trim(); + if (stderrTail) { + sourceLines.push(...stderrTail.split('\n')); + } + + const seen = new Set(); + const issues = []; + for (const line of sourceLines) { + const parsed = parseMakeMkvRipIssueLine(line); + if (!parsed) { + continue; + } + const key = `${parsed.code ?? '-'}|${parsed.message.toLowerCase()}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + issues.push(parsed); + if (issues.length >= 12) { + break; + } + } + return issues; +} + function isBurnedSubtitleTrack(track) { const flags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags @@ -2880,6 +2959,33 @@ export default function PipelineStatusCard({ + `${runningSeriesEpisode?.title ? ` - ${runningSeriesEpisode.title}` : ''}` ) : (pipeline?.statusText || 'Bereit'); + const ripIssueEntries = useMemo( + () => extractMakeMkvRipIssues(jobRow?.makemkvInfo), + [jobRow?.makemkvInfo] + ); + const ripIssueVisibleEntries = ripIssueEntries.slice(0, 8); + const hiddenRipIssueCount = Math.max(0, ripIssueEntries.length - ripIssueVisibleEntries.length); + const ripIssueSummaryLabel = ripIssueEntries.length === 1 + ? '1 Rip-Fehler erkannt' + : `${ripIssueEntries.length} Rip-Fehler erkannt`; + const showRipIssueNotice = Boolean( + (jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray') + && ripCompleted + && ripIssueEntries.length > 0 + && !running + && ( + state === 'WAITING_FOR_USER_DECISION' + || state === 'READY_TO_ENCODE' + || state === 'MEDIAINFO_CHECK' + || Boolean(mediaInfoReview) + ) + ); + const canTriggerRipRetry = Boolean( + showRipIssueNotice + && retryJobId + && typeof onRetry === 'function' + && !queueLocked + ); const isSeriesBatchParentReview = Boolean( mediaInfoReview?.seriesBatchParent || pipeline?.context?.mediaInfoReview?.seriesBatchParent @@ -3672,6 +3778,42 @@ export default function PipelineStatusCard({
) : null} + {showRipIssueNotice ? ( +
+
+