From bebe9eac8c8ce393cef213b833136acf7cc2a1fb Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Fri, 17 Apr 2026 08:04:52 +0000 Subject: [PATCH] 0.13.1-10 Misc fixes --- .claude/settings.json | 52 - backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/db/database.js | 8 +- backend/src/routes/historyRoutes.js | 50 +- backend/src/services/historyService.js | 103 +- backend/src/services/pipelineService.js | 634 +++++++--- db/schema.sql | 4 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api/client.js | 2 + .../components/MetadataSelectionDialog.jsx | 33 +- .../src/components/PipelineStatusCard.jsx | 207 +++- frontend/src/pages/RipperPage.jsx | 23 +- install-dev.sh | 1037 ----------------- install.sh | 131 ++- install_old.sh | 832 ------------- package-lock.json | 4 +- package.json | 2 +- 19 files changed, 979 insertions(+), 2155 deletions(-) delete mode 100644 .claude/settings.json delete mode 100644 install-dev.sh delete mode 100644 install_old.sh diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 4a6d001..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(ls /home/michael/ripster/*.sh)", - "Bash(ls /home/michael/ripster/backend/*.sh)", - "Bash(systemctl list-units --type=service)", - "Bash(pip install -q -r requirements-docs.txt)", - "Bash(mkdocs build --strict)", - "Read(//mnt/external/media/**)", - "WebFetch(domain:www.makemkv.com)", - "Bash(node --check backend/src/services/pipelineService.js)", - "Bash(wc -l /home/michael/ripster/debug/backend/data/logs/backend/*.log)", - "Bash(find /home/michael/ripster -name *.db)", - "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, category, label, type, default_value, options_json FROM settings_schema WHERE category=''Laufwerk'' ORDER BY order_index\")", - "Bash(node --check /home/michael/ripster/backend/src/services/diskDetectionService.js)", - "Bash(node --check /home/michael/ripster/backend/src/services/settingsService.js)", - "Bash(node --check /home/michael/ripster/backend/src/services/pipelineService.js)", - "Bash(node --check /home/michael/ripster/backend/src/db/database.js)", - "Bash(node --check /home/michael/ripster/backend/src/routes/settingsRoutes.js)", - "Bash(sqlite3 /home/michael/ripster/backend/ripster.db \"SELECT key, value FROM settings_values WHERE key LIKE ''%drive%'' OR key LIKE ''%disc%'' ORDER BY key;\")", - "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, value FROM settings_values WHERE key LIKE ''%drive%'' OR key LIKE ''%disc%'' ORDER BY key;\")", - "Bash(lsblk -J -o NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL)", - "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(e\\) for e in d.get\\(''''blockdevices'''',[]\\)]\")", - "Bash(lsblk -o NAME,TYPE,MODEL)", - "Bash(node --check /home/michael/ripster/backend/src/routes/pipelineRoutes.js)", - "Bash(grep -n \"inferMediaProfile\" /home/michael/ripster/backend/src/services/*.js)", - "Bash(grep -rn \"PROCESS_LOG\\\\|LOG_UPDATE\\\\|processLog\" /home/michael/ripster/backend/src --include=*.js)", - "Bash(grep -n \"buildHandBrakeConfig\" /home/michael/ripster/backend/src/services/*.js)", - "Bash(grep -n \"class.*Plugin\\\\|get id\\(\\)\\\\|get name\\(\\)\" /home/michael/ripster/backend/src/plugins/*.js)", - "Bash(/home/michael/ripster/db/schema.sql:*)", - "Read(//home/michael/**)", - "Bash(find /home/michael/ripster/AddOns/klangkiste -name *.jsx -o -name *.js -o -name *.vue)", - "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, label, order_index FROM settings_schema WHERE key LIKE ''converter%'' ORDER BY order_index\")", - "Bash(find /home/michael/ripster/backend -name *.db)", - "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT count\\(*\\) FROM settings_schema\")", - "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key FROM settings_schema WHERE key LIKE ''%converter%''\")", - "Bash(grep -n \"renderMediaTree\\\\|renderMediaContent\\\\|activeSection === ''media''\" /home/michael/ripster/AddOns/klangkiste/gui/frontend/src/App.jsx)", - "Bash(grep -n \"activeModal === ''new-folder''\" /home/michael/ripster/AddOns/klangkiste/gui/frontend/src/App.jsx)", - "Bash(sqlite3 /home/michael/ripster_data/ripster.db \"SELECT id, status, last_state, encode_input_path FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")", - "Bash(sqlite3 /opt/ripster_data/ripster.db \"SELECT id, status, last_state FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")", - "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(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 369ef41..e46ad31 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.13.1-9", + "version": "0.13.1-10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.13.1-9", + "version": "0.13.1-10", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index b10cf22..9101d68 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.13.1-9", + "version": "0.13.1-10", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index d3e7e41..1a9a9e6 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -844,13 +844,13 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [ { key: 'output_template_dvd_series_episode', required: 1, - description: 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.', + description: 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', validation_json: '{"minLength":1}' }, { key: 'output_template_dvd_series_multi_episode', required: 1, - description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', + description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', validation_json: '{"minLength":1}' } ]; @@ -1076,13 +1076,13 @@ async function migrateSettingsSchemaMetadata(db) { await db.run( `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) - VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)` + VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)` ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`); await db.run( `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) - VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537)` + VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537)` ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})')`); diff --git a/backend/src/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js index e6e54ab..5640eea 100644 --- a/backend/src/routes/historyRoutes.js +++ b/backend/src/routes/historyRoutes.js @@ -174,10 +174,11 @@ router.post( const id = Number(req.params.id); const target = String(req.body?.target || 'none'); const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase()); - const resetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase()); - const keepDetectedDevice = req.body?.keepDetectedDevice !== undefined + const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase()); + const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase()); + const requestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined ? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase()) - : !resetDriveState; + : !requestedResetDriveState; const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths) ? req.body.selectedMoviePaths .map((item) => String(item || '').trim()) @@ -189,20 +190,41 @@ router.post( id, target, includeRelated, - resetDriveState, - keepDetectedDevice, + requestedResetDriveState, + preserveRawForImportJobs, + requestedKeepDetectedDevice, selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0 }); - const preview = resetDriveState - ? await historyService.getJobDeletePreview(id, { includeRelated }) - : null; + const preview = await historyService.getJobDeletePreview(id, { includeRelated }); + const containsOrphanRawImportJob = Boolean( + preview?.flags?.containsOrphanRawImportJob + || (Array.isArray(preview?.relatedJobs) && preview.relatedJobs.some((row) => Boolean(row?.orphanRawImport))) + ); + const resetDriveState = containsOrphanRawImportJob + ? false + : requestedResetDriveState; + const keepDetectedDevice = containsOrphanRawImportJob + ? true + : requestedKeepDetectedDevice; + + if (containsOrphanRawImportJob && requestedResetDriveState) { + logger.info('post:delete-job:orphan-drive-reset-skipped', { + reqId: req.reqId, + id + }); + } + const relatedDevicePaths = Array.isArray(preview?.relatedJobs) ? preview.relatedJobs .map((row) => String(row?.discDevice || '').trim()) .filter(Boolean) : []; - const result = await historyService.deleteJob(id, target, { includeRelated, selectedMoviePaths }); + const result = await historyService.deleteJob(id, target, { + includeRelated, + selectedMoviePaths, + preserveRawForImportJobs + }); await pipelineService.onJobsDeleted(result?.deletedJobIds || [id], { resetDriveState, devicePaths: relatedDevicePaths @@ -212,7 +234,15 @@ router.post( const uiReset = await pipelineService.resetFrontendState('history_delete', { keepDetectedDevice }); - res.json({ ...result, uiReset }); + res.json({ + ...result, + uiReset, + safeguards: { + containsOrphanRawImportJob, + resetDriveStateApplied: resetDriveState, + keepDetectedDeviceApplied: keepDetectedDevice + } + }); }) ); diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 052ec17..2646473 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -133,6 +133,16 @@ function parseInfoFromValue(value, fallback = null) { return parseJsonSafe(value, fallback); } +function isOrphanRawImportMakemkvInfo(makemkvInfo = null) { + const info = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}; + const source = String( + info?.source + || info?.importRecovery?.source + || '' + ).trim().toLowerCase(); + return source === 'orphan_raw_import'; +} + function hasBlurayStructure(rawPath) { const basePath = String(rawPath || '').trim(); if (!basePath) { @@ -5180,6 +5190,7 @@ class HistoryService { ? Math.trunc(manualEpisodeCountRaw) : null; const manualEpisodes = Array.isArray(payload.episodes) ? payload.episodes : null; + const hasExplicitManualEpisodes = Boolean(manualEpisodes && manualEpisodes.length > 0); const manualMetadataKind = String(payload.metadataKind || '').trim().toLowerCase() || null; const manualProviderId = String(payload.providerId || '').trim() || null; @@ -5240,7 +5251,7 @@ class HistoryService { let episodeCount = manualEpisodeCount !== null ? manualEpisodeCount : (Number(existingSelectedMetadata?.episodeCount || 0) || 0); - let episodes = manualEpisodes && manualEpisodes.length > 0 + let episodes = hasExplicitManualEpisodes ? manualEpisodes : (Array.isArray(existingSelectedMetadata?.episodes) ? existingSelectedMetadata.episodes : []); const discNumber = manualDiscNumber !== null @@ -5280,6 +5291,13 @@ class HistoryService { ? `tmdb:${effectiveTmdbId}:season:${seasonNumber}` : `tmdb:${effectiveTmdbId}`; } + let tmdbLanguage = null; + try { + const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd'); + tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null; + } catch (_settingsError) { + tmdbLanguage = null; + } let tmdbDetails = existingSelectedMetadata?.tmdbDetails && typeof existingSelectedMetadata.tmdbDetails === 'object' ? { ...existingSelectedMetadata.tmdbDetails } @@ -5287,6 +5305,7 @@ class HistoryService { if (effectiveTmdbId !== null) { try { const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, { + language: tmdbLanguage, appendToResponse: ['credits', 'external_ids'] }); if (seriesDetails) { @@ -5325,12 +5344,16 @@ class HistoryService { if (effectiveTmdbId !== null && seasonNumber !== null) { try { - const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber); - const seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, seasonNumber); + const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber, { + language: tmdbLanguage + }); + const seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, seasonNumber, { + language: tmdbLanguage + }); const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails); if (seasonSummary) { const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : []; - if (fetchedEpisodes.length > 0 && (!Array.isArray(episodes) || episodes.length === 0)) { + if (fetchedEpisodes.length > 0 && !hasExplicitManualEpisodes) { episodes = fetchedEpisodes; } const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0; @@ -6177,15 +6200,20 @@ class HistoryService { lineageArtifactsByJobId, outputFoldersByJobId ); - const relatedJobs = jobs.map((job) => ({ - id: Number(job.id), - parentJobId: normalizeJobIdValue(job.parent_job_id), - title: buildJobDisplayTitle(job), - status: String(job.status || '').trim() || null, - discDevice: String(job.disc_device || '').trim() || null, - isPrimary: Number(job.id) === normalizedJobId, - createdAt: String(job.created_at || '').trim() || null - })); + const relatedJobs = jobs.map((job) => { + const mkInfo = parseJsonSafe(job?.makemkv_info_json, {}); + return { + id: Number(job.id), + parentJobId: normalizeJobIdValue(job.parent_job_id), + title: buildJobDisplayTitle(job), + status: String(job.status || '').trim() || null, + discDevice: String(job.disc_device || '').trim() || null, + isPrimary: Number(job.id) === normalizedJobId, + createdAt: String(job.created_at || '').trim() || null, + orphanRawImport: isOrphanRawImportMakemkvInfo(mkInfo) + }; + }); + const orphanRawImportJobs = relatedJobs.filter((row) => Boolean(row?.orphanRawImport)).length; const existingRawCandidates = preview.pathCandidates.raw.filter((row) => row.exists).length; const existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length; @@ -6195,8 +6223,12 @@ class HistoryService { relatedJobs, pathCandidates: preview.pathCandidates, protectedRoots: preview.protectedRoots, + flags: { + containsOrphanRawImportJob: orphanRawImportJobs > 0 + }, counts: { relatedJobs: relatedJobs.length, + orphanRawImportJobs, rawCandidates: preview.pathCandidates.raw.length, movieCandidates: preview.pathCandidates.movie.length, existingRawCandidates, @@ -6887,6 +6919,28 @@ class HistoryService { throw error; } + const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs); + const isOrphanRawImportJobRow = (row) => { + if (!row || typeof row !== 'object') { + return false; + } + const mkInfo = parseJsonSafe(row?.makemkv_info_json, {}); + return isOrphanRawImportMakemkvInfo(mkInfo); + }; + const resolveEffectiveFileTarget = (requestedTarget, row) => { + if (!preserveRawForImportJobs || !isOrphanRawImportJobRow(row)) { + return requestedTarget; + } + const normalizedRequested = String(requestedTarget || 'none').trim().toLowerCase(); + if (normalizedRequested === 'raw') { + return 'none'; + } + if (normalizedRequested === 'both') { + return 'movie'; + } + return normalizedRequested; + }; + const includeRelated = Boolean(options?.includeRelated); if (!includeRelated) { const resolved = await this.getJobByIdOrReplacement(jobId); @@ -6903,10 +6957,11 @@ class HistoryService { throw error; } + const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing); let fileSummary = null; - if (fileTarget !== 'none') { + if (effectiveFileTarget !== 'none') { const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false }); - fileSummary = this._deletePathsFromPreview(preview, fileTarget, { + fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, { selectedMoviePaths: options?.selectedMoviePaths }); } @@ -6968,7 +7023,8 @@ class HistoryService { logger.warn('job:deleted', { jobId: normalizedJobId, - fileTarget, + fileTarget: effectiveFileTarget, + requestedFileTarget: fileTarget, includeRelated: false, pipelineStateReset: isActivePipelineJob, filesDeleted: fileSummary @@ -6982,7 +7038,8 @@ class HistoryService { return { deleted: true, jobId: normalizedJobId, - fileTarget, + fileTarget: effectiveFileTarget, + requestedFileTarget: fileTarget, includeRelated: false, deletedJobIds: [Number(normalizedJobId)], fileSummary @@ -6990,6 +7047,7 @@ class HistoryService { } const resolved = await this.getJobByIdOrReplacement(jobId); + const existing = resolved?.job || null; const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId); const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true }); const deleteJobIds = Array.isArray(preview?.relatedJobs) @@ -7014,9 +7072,10 @@ class HistoryService { throw error; } + const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing); let fileSummary = null; - if (fileTarget !== 'none') { - fileSummary = this._deletePathsFromPreview(preview, fileTarget, { + if (effectiveFileTarget !== 'none') { + fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, { selectedMoviePaths: options?.selectedMoviePaths }); } @@ -7068,7 +7127,8 @@ class HistoryService { logger.warn('job:deleted', { jobId: normalizedJobId, - fileTarget, + fileTarget: effectiveFileTarget, + requestedFileTarget: fileTarget, includeRelated: true, deletedJobIds: deleteJobIds, deletedJobCount: deleteJobIds.length, @@ -7084,7 +7144,8 @@ class HistoryService { return { deleted: true, jobId: normalizedJobId, - fileTarget, + fileTarget: effectiveFileTarget, + requestedFileTarget: fileTarget, includeRelated: true, deletedJobIds: deleteJobIds, deletedJobs: preview.relatedJobs, diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 25f8cba..0fc138a 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -94,11 +94,29 @@ const CONVERTER_SUPPORTED_SCAN_EXTENSIONS = Object.freeze([ 'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus' ]); const CONVERTER_SUPPORTED_SCAN_EXTENSION_SET = new Set(CONVERTER_SUPPORTED_SCAN_EXTENSIONS); +const TMDB_SERIES_SEARCH_CACHE_TTL_MS = 2 * 60 * 1000; +const tmdbSeriesSearchCache = new Map(); function nowIso() { return new Date().toISOString(); } +function cloneTmdbSeriesSearchResults(rows = []) { + if (!Array.isArray(rows)) { + return []; + } + return rows.map((row) => ({ + ...(row && typeof row === 'object' ? row : {}), + episodes: Array.isArray(row?.episodes) + ? row.episodes.map((episode) => ( + episode && typeof episode === 'object' + ? { ...episode } + : episode + )) + : [] + })); +} + function normalizeCdTrackText(value) { return String(value || '') .normalize('NFC') @@ -948,15 +966,16 @@ function formatTemplateTwoDigit(rawValue) { return String(rawValue || '').trim() || String(numeric); } -function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitle = null, fallbackEpisodeNumber = 1) { +function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitle = null, fallbackEpisodeNumber = 1, options = {}) { const assignmentSource = assignment && typeof assignment === 'object' ? assignment : {}; const selectedTitleSource = selectedTitle && typeof selectedTitle === 'object' ? selectedTitle : {}; + const allowSelectedTitleEpisodeNumber = options?.allowSelectedTitleEpisodeNumber !== false; const episodeStart = normalizeTemplateNumberValue( assignmentSource?.episodeNumberStart ?? assignmentSource?.episodeNoStart ?? assignmentSource?.episodeNumber ?? assignmentSource?.number - ?? selectedTitleSource?.episodeNumber + ?? (allowSelectedTitleEpisodeNumber ? selectedTitleSource?.episodeNumber : null) ?? null, fallbackEpisodeNumber ); @@ -987,6 +1006,167 @@ function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitl }; } +function hasExplicitSeriesEpisodeAssignmentRange(assignment = null) { + const source = assignment && typeof assignment === 'object' ? assignment : null; + if (!source) { + return false; + } + const hasStart = normalizeEpisodeNumberValue( + source?.episodeNumberStart + ?? source?.episodeNoStart + ?? source?.episodeNumber + ?? source?.number + ?? null + ) !== null; + const hasEnd = normalizeEpisodeNumberValue( + source?.episodeNumberEnd + ?? source?.episodeNoEnd + ?? null + ) !== null; + const hasSpan = normalizePositiveInteger( + source?.episodeSpan + ?? source?.episodeCount + ?? null + ) !== null; + const hasRangeToken = String(source?.episodeRange || '').trim().length > 0; + return Boolean(hasStart || hasEnd || hasSpan || hasRangeToken); +} + +function resolveSeriesSelectedTitleIdOrder(encodePlan = null, titles = []) { + const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; + const explicitSelected = normalizeReviewTitleIdList( + Array.isArray(plan?.selectedTitleIds) + ? plan.selectedTitleIds + : [plan?.encodeInputTitleId] + ); + if (explicitSelected.length > 0) { + return explicitSelected; + } + return normalizeReviewTitleIdList( + (Array.isArray(titles) ? titles : []) + .filter((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) + .map((title) => title?.id) + ); +} + +function resolveSeriesEpisodeRangeForPlanTitle(encodePlan = null, titleId = null, fallbackEpisodeNumber = 1) { + const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; + const titles = Array.isArray(plan?.titles) ? plan.titles : []; + const titlesById = new Map(); + for (const title of titles) { + const normalizedId = normalizeReviewTitleId(title?.id); + if (!normalizedId) { + continue; + } + titlesById.set(Number(normalizedId), title); + } + + const normalizedTitleId = normalizeReviewTitleId(titleId) + || normalizeReviewTitleId(plan?.encodeInputTitleId) + || null; + let orderedTitleIds = resolveSeriesSelectedTitleIdOrder(plan, titles); + if (normalizedTitleId && !orderedTitleIds.some((id) => Number(id) === Number(normalizedTitleId))) { + orderedTitleIds = [...orderedTitleIds, Number(normalizedTitleId)]; + } + if (orderedTitleIds.length === 0 && normalizedTitleId) { + orderedTitleIds = [Number(normalizedTitleId)]; + } + + const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' + ? plan.episodeAssignments + : {}; + const fallbackStart = normalizePositiveInteger(fallbackEpisodeNumber) || 1; + let cursor = fallbackStart; + let targetRange = null; + let targetAssignment = null; + let targetTitle = null; + + for (const rawId of orderedTitleIds) { + const currentId = normalizeReviewTitleId(rawId); + if (!currentId) { + continue; + } + const assignment = assignmentMap[String(currentId)] || assignmentMap[currentId] || null; + const selectedTitle = titlesById.get(Number(currentId)) || null; + const hasExplicitRange = hasExplicitSeriesEpisodeAssignmentRange(assignment); + const range = resolveSeriesEpisodeRangeFromAssignment( + assignment, + selectedTitle, + cursor, + { allowSelectedTitleEpisodeNumber: hasExplicitRange } + ); + const normalizedEnd = normalizeEpisodeNumberValue(range?.end); + if (normalizedEnd !== null) { + cursor = normalizedEnd + 1; + } + if (normalizedTitleId && Number(currentId) === Number(normalizedTitleId)) { + targetRange = range; + targetAssignment = assignment; + targetTitle = selectedTitle; + break; + } + } + + if (!targetRange) { + const fallbackTitle = normalizedTitleId ? (titlesById.get(Number(normalizedTitleId)) || null) : null; + const fallbackAssignment = normalizedTitleId + ? (assignmentMap[String(normalizedTitleId)] || assignmentMap[normalizedTitleId] || null) + : null; + const hasExplicitRange = hasExplicitSeriesEpisodeAssignmentRange(fallbackAssignment); + targetRange = resolveSeriesEpisodeRangeFromAssignment( + fallbackAssignment, + fallbackTitle, + cursor, + { allowSelectedTitleEpisodeNumber: hasExplicitRange } + ); + targetAssignment = fallbackAssignment; + targetTitle = fallbackTitle; + } + + return { + range: targetRange, + assignment: targetAssignment, + title: targetTitle + }; +} + +function templateUsesLeadingZeroForToken(template, token) { + const source = String(template || '').trim(); + const normalizedToken = String(token || '').trim(); + if (!source || !normalizedToken) { + return false; + } + const pattern = new RegExp(`\\{\\s*0\\s*:\\s*${normalizedToken}\\s*\\}`, 'i'); + return pattern.test(source); +} + +function formatSeriesEpisodeNumberByTemplate(rawValue, template, token = 'episodeNr') { + const numeric = normalizeEpisodeNumberValue(rawValue); + if (numeric === null) { + return String(rawValue || '').trim() || '0'; + } + if (Number.isInteger(numeric)) { + const normalized = String(Math.trunc(numeric)); + if (templateUsesLeadingZeroForToken(template, token)) { + return normalized.padStart(2, '0'); + } + return normalized; + } + return String(numeric); +} + +function buildSeriesFallbackEpisodeTitle(episodeRangeInfo = null, template = '') { + const start = normalizeEpisodeNumberValue(episodeRangeInfo?.start) ?? 1; + const end = normalizeEpisodeNumberValue(episodeRangeInfo?.end) ?? start; + if (end > start) { + const startToken = formatSeriesEpisodeNumberByTemplate(start, template, 'episodeNr'); + const endToken = formatSeriesEpisodeNumberByTemplate(end, template, 'episodeNr'); + return `Folge ${startToken}-${endToken}`; + } + const token = formatSeriesEpisodeNumberByTemplate(start, template, 'episodeNr'); + return `Folge ${token}`; +} + function buildSeriesEpisodeRangeToken(start, end) { const startToken = formatTemplateTwoDigit(start); const endToken = formatTemplateTwoDigit(end); @@ -1178,19 +1358,39 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) { const assignment = primaryTitleId ? (episodeAssignments[String(primaryTitleId)] || episodeAssignments[primaryTitleId] || null) : null; + const selectedTitlePosition = primaryTitleId + ? selectedTitleIds.findIndex((id) => Number(id) === Number(primaryTitleId)) + : -1; + const fallbackEpisodeNumber = normalizePositiveInteger( + encodePlan?.seriesBatchChildIndex + ?? null + ) || (selectedTitlePosition >= 0 ? selectedTitlePosition + 1 : 1); + const episodeResolution = resolveSeriesEpisodeRangeForPlanTitle( + encodePlan, + primaryTitleId, + fallbackEpisodeNumber + ); + const resolvedAssignment = episodeResolution?.assignment || assignment || null; + const resolvedTitle = episodeResolution?.title || selectedTitle || null; + const episodeRangeInfo = ( + episodeResolution?.range + && typeof episodeResolution.range === 'object' + ) + ? episodeResolution.range + : resolveSeriesEpisodeRangeFromAssignment( + resolvedAssignment, + resolvedTitle, + fallbackEpisodeNumber, + { allowSelectedTitleEpisodeNumber: hasExplicitSeriesEpisodeAssignmentRange(resolvedAssignment) } + ); const seasonNumber = normalizePositiveInteger( - assignment?.seasonNumber - ?? selectedTitle?.seasonNumber + resolvedAssignment?.seasonNumber + ?? resolvedTitle?.seasonNumber ?? selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null ) || 1; - const episodeRangeInfo = resolveSeriesEpisodeRangeFromAssignment( - assignment, - selectedTitle, - primaryTitleId || 1 - ); const episodeTemplateValue = episodeRangeInfo.start; const episodeRangeToken = buildSeriesEpisodeRangeToken( episodeRangeInfo.start, @@ -1199,7 +1399,7 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) { const episodePartsToken = buildSeriesEpisodePartsToken(episodeRangeInfo); const discNumber = normalizePositiveInteger( selectedMetadata?.discNumber - ?? assignment?.discNumber + ?? resolvedAssignment?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null ); @@ -1210,16 +1410,27 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) { || job?.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'series') ) || (fallbackJobId ? `job-${fallbackJobId}` : 'series'); + const singleTemplateRaw = String( + settings?.output_template_dvd_series_episode + || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE + ).trim(); + const multiTemplateRaw = String( + settings?.output_template_dvd_series_multi_episode + || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE + ).trim(); + const singleTemplate = singleTemplateRaw || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE; + const multiTemplate = multiTemplateRaw || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE; + const template = episodeRangeInfo.isMulti ? multiTemplate : singleTemplate; + const fallbackEpisodeTitle = buildSeriesFallbackEpisodeTitle(episodeRangeInfo, template); const rawEpisodeTitle = normalizeCdTrackText( - assignment?.episodeTitle - || selectedTitle?.episodeTitle - || selectedTitle?.fileName - || `Episode ${episodeRangeToken}` - ) || `Episode ${episodeRangeToken}`; + resolvedAssignment?.episodeTitle + || resolvedTitle?.episodeTitle + || fallbackEpisodeTitle + ) || fallbackEpisodeTitle; const episodeTitle = normalizeSeriesEpisodeTitleForOutput( rawEpisodeTitle, episodeRangeInfo - ); + ) || fallbackEpisodeTitle; const language = String( settings?.dvd_series_language || selectedMetadata?.language @@ -1227,21 +1438,10 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) { ).trim() || 'unknown'; const year = Number(selectedMetadata?.year || job?.year || new Date().getFullYear()) || new Date().getFullYear(); - const singleTemplate = String( - settings?.output_template_dvd_series_episode - || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE - ).trim() || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE; - const multiTemplate = String( - settings?.output_template_dvd_series_multi_episode - || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE - ).trim() || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE; - const template = episodeRangeInfo.isMulti ? multiTemplate : singleTemplate; const rendered = renderTemplate(template, { seriesTitle, seasonNr: seasonNumber, - seasonNo: String(seasonNumber), episodeNr: episodeTemplateValue, - episodeNo: String(episodeTemplateValue), episodeNoStart: episodeRangeInfo.start, episodeNoEnd: episodeRangeInfo.end, episodeNumberStart: episodeRangeInfo.start, @@ -5283,15 +5483,16 @@ function resolveSeriesBatchChildDisplayTitle(parentJob, parentPlan, titleId, chi const episodeRange = resolveSeriesEpisodeRangeFromAssignment( assignment, selectedTitle, - childIndex + 1 + childIndex + 1, + { allowSelectedTitleEpisodeNumber: hasExplicitSeriesEpisodeAssignmentRange(assignment) } ); + const fallbackEpisodeLabel = `Folge ${buildSeriesEpisodeRangeToken(episodeRange.start, episodeRange.end)}`; const rawEpisodeLabel = String( assignment?.episodeTitle || selectedTitle?.episodeTitle - || selectedTitle?.fileName - || '' + || fallbackEpisodeLabel ).trim(); - const episodeLabel = normalizeSeriesEpisodeTitleForOutput(rawEpisodeLabel, episodeRange); + const episodeLabel = normalizeSeriesEpisodeTitleForOutput(rawEpisodeLabel, episodeRange) || fallbackEpisodeLabel; const seasonToken = seasonNumber !== null ? String(seasonNumber).padStart(2, '0') : null; const episodeToken = buildSeriesEpisodeRangeToken( @@ -5326,6 +5527,25 @@ function buildSeriesBatchChildPlan(parentPlan, titleId, parentJobId, childIndex, ? plan.episodeAssignments : {}; const selectedAssignment = assignmentMap[String(titleId)] || assignmentMap[titleId] || null; + const inferredRangeForChild = resolveSeriesEpisodeRangeForPlanTitle(plan, titleId, childIndex + 1)?.range || null; + const selectedAssignmentHasRange = hasExplicitSeriesEpisodeAssignmentRange(selectedAssignment); + const resolvedChildAssignment = ( + selectedAssignmentHasRange + || !inferredRangeForChild + ) + ? selectedAssignment + : { + ...(selectedAssignment && typeof selectedAssignment === 'object' ? selectedAssignment : {}), + titleId: Number(titleId), + episodeNumber: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumber) ?? inferredRangeForChild.start, + episodeNumberStart: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumberStart) ?? inferredRangeForChild.start, + episodeNumberEnd: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumberEnd) ?? inferredRangeForChild.end, + episodeSpan: normalizePositiveInteger(selectedAssignment?.episodeSpan) + || Math.max(1, Math.trunc(Number(inferredRangeForChild.end) - Number(inferredRangeForChild.start) + 1)), + episodeRange: String(selectedAssignment?.episodeRange || '').trim() + || buildSeriesEpisodeRangeToken(inferredRangeForChild.start, inferredRangeForChild.end), + seasonNumber: normalizePositiveInteger(selectedAssignment?.seasonNumber) ?? normalizePositiveInteger(selectedTitle?.seasonNumber) ?? null + }; return { ...plan, @@ -5343,12 +5563,12 @@ function buildSeriesBatchChildPlan(parentPlan, titleId, parentJobId, childIndex, handBrakeTitleIds: handBrakeTitleId ? [handBrakeTitleId] : [], titleSelectionRequired: false, titles: remappedTitles, - episodeAssignments: selectedAssignment + episodeAssignments: resolvedChildAssignment ? { [String(titleId)]: { - ...selectedAssignment, + ...resolvedChildAssignment, titleId: Number(titleId), - filePath: selectedTitle?.filePath || selectedAssignment?.filePath || null + filePath: selectedTitle?.filePath || resolvedChildAssignment?.filePath || null } } : {}, @@ -8759,6 +8979,10 @@ class PipelineService extends EventEmitter { if (this.isSeriesContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) { continue; } + const status = String(job?.status || job?.last_state || '').trim().toUpperCase(); + if (status !== 'ENCODING' && status !== 'CD_ENCODING') { + continue; + } const poolType = this.resolveQueuePoolTypeForJob(job); if (poolType === 'audio') { audioRunning += 1; @@ -10759,49 +10983,52 @@ class PipelineService extends EventEmitter { const filmRunning = runningUsage.filmRunning; const cdRunning = runningUsage.audioRunning; const totalRunning = runningUsage.totalRunning; - // Counts all actively processing jobs (excludes waiting states like READY_TO_ENCODE). - const anyActiveJobs = totalRunning; // Find next startable entry let entryIndex = -1; - for (let i = 0; i < this.queueEntries.length; i++) { - const candidate = this.queueEntries[i]; - const isNonJob = candidate.type && candidate.type !== 'job'; - - if (isNonJob) { - // Non-job entries (script, chain, wait) only start when no jobs are actively running. - // anyActiveJobs covers all active states (ANALYZING, RIPPING, ENCODING, MEDIAINFO_CHECK etc.) - // activeProcesses provides a second safety net for race conditions during state transitions. - if (anyActiveJobs === 0 && this.activeProcesses.size === 0) { - entryIndex = i; - } - break; // FIFO: stop scanning regardless (non-job blocks everything behind it) - } - - // Job entry: check hierarchical limits - if (totalRunning >= maxTotal) { - // Total limit reached – nothing can start - break; - } - - const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate); - if (candidatePoolType === 'audio') { - if (cdRunning < maxCd) { - entryIndex = i; + const independentEntryIndex = this.queueEntries.findIndex((candidate) => { + const type = String(candidate?.type || '').trim().toLowerCase(); + return type === 'script' || type === 'chain'; + }); + if (independentEntryIndex >= 0) { + entryIndex = independentEntryIndex; + } else { + for (let i = 0; i < this.queueEntries.length; i++) { + const candidate = this.queueEntries[i]; + const isWaitEntry = String(candidate?.type || '').trim().toLowerCase() === 'wait'; + if (isWaitEntry) { + // Wait keeps queue order and blocks until no tracked job process is active. + if (this.activeProcesses.size === 0) { + entryIndex = i; + } break; } - // CD limit reached - if (!cdBypass) break; // Strict FIFO: stop scanning - continue; // Bypass mode: skip this blocked CD entry - } else { - // Film/video job entry - if (filmRunning < maxFilm) { - entryIndex = i; + + // Job entry: check hierarchical limits (caps apply only to encode jobs). + if (totalRunning >= maxTotal) { + // Total encode cap reached – nothing can start. break; } - // Film limit reached - if (!cdBypass) break; // Strict FIFO: stop scanning - continue; // Bypass mode: skip this blocked film entry + + const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate); + if (candidatePoolType === 'audio') { + if (cdRunning < maxCd) { + entryIndex = i; + break; + } + // CD/audio encode cap reached + if (!cdBypass) break; // Strict FIFO: stop scanning + continue; // Bypass mode: skip this blocked CD entry + } else { + // Film/video encode job entry + if (filmRunning < maxFilm) { + entryIndex = i; + break; + } + // Film encode cap reached + if (!cdBypass) break; // Strict FIFO: stop scanning + continue; // Bypass mode: skip this blocked film entry + } } } @@ -12493,7 +12720,36 @@ class PipelineService extends EventEmitter { seasonNumber: seasonFilter || null }); - let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery); + let tmdbLanguage = null; + try { + const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd'); + tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null; + } catch (_settingsError) { + tmdbLanguage = null; + } + + const cacheKey = [ + normalizedQuery.toLowerCase(), + seasonFilter.toLowerCase(), + String(tmdbLanguage || '').trim().toLowerCase() + ].join('::'); + const cacheEntry = tmdbSeriesSearchCache.get(cacheKey); + const nowTs = Date.now(); + if (cacheEntry && cacheEntry.expiresAt > nowTs && Array.isArray(cacheEntry.results)) { + logger.info('tmdb:series-search:cache-hit', { + query: normalizedQuery, + seasonNumber: seasonFilter || null, + count: cacheEntry.results.length + }); + return cloneTmdbSeriesSearchResults(cacheEntry.results); + } + if (cacheEntry && cacheEntry.expiresAt <= nowTs) { + tmdbSeriesSearchCache.delete(cacheKey); + } + + let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, { + language: tmdbLanguage + }); if (!Array.isArray(normalizedResults)) { normalizedResults = []; } @@ -12518,6 +12774,26 @@ class PipelineService extends EventEmitter { throw error; } + const cacheNowTs = Date.now(); + if (tmdbSeriesSearchCache.size >= 200) { + for (const [key, entry] of tmdbSeriesSearchCache.entries()) { + if (!entry || entry.expiresAt <= cacheNowTs) { + tmdbSeriesSearchCache.delete(key); + } + } + if (tmdbSeriesSearchCache.size >= 200) { + const oldestKey = tmdbSeriesSearchCache.keys().next().value; + if (oldestKey) { + tmdbSeriesSearchCache.delete(oldestKey); + } + } + } + + tmdbSeriesSearchCache.set(cacheKey, { + results: cloneTmdbSeriesSearchResults(normalizedResults), + expiresAt: cacheNowTs + TMDB_SERIES_SEARCH_CACHE_TTL_MS + }); + return normalizedResults; } @@ -14174,12 +14450,22 @@ class PipelineService extends EventEmitter { || mkInfo?.analyzeContext?.selectedMetadata?.episodeCount || 0 ) || 0; - let effectiveEpisodes = Array.isArray(episodes) + const hasExplicitEpisodePayload = Array.isArray(episodes) && episodes.length > 0; + let effectiveEpisodes = hasExplicitEpisodePayload ? episodes : (Array.isArray(mkInfo?.analyzeContext?.selectedMetadata?.episodes) ? mkInfo.analyzeContext.selectedMetadata.episodes : []); let tmdbDetails = null; + let tmdbLanguage = null; + if (effectiveMetadataProvider === 'tmdb') { + try { + const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd'); + tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null; + } catch (_settingsError) { + tmdbLanguage = null; + } + } const isDvdTmdbMetadataSelection = mediaProfile === 'dvd' && effectiveWorkflowKind === 'series'; if (mediaProfile === 'dvd' && effectiveWorkflowKind === 'film') { @@ -14218,6 +14504,7 @@ class PipelineService extends EventEmitter { if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId) { try { const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, { + language: tmdbLanguage, appendToResponse: ['credits', 'external_ids'] }); tmdbDetails = tmdbService.buildSeriesDetailsSummary(seriesDetails); @@ -14248,15 +14535,17 @@ class PipelineService extends EventEmitter { if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveSeasonNumber) { try { - seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber); - seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber); + seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber, { + language: tmdbLanguage + }); + seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber, { + language: tmdbLanguage + }); const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails); if (seasonSummary) { const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : []; - if (fetchedEpisodes.length > 0) { - if (!Array.isArray(effectiveEpisodes) || effectiveEpisodes.length === 0) { - effectiveEpisodes = fetchedEpisodes; - } + if (fetchedEpisodes.length > 0 && !hasExplicitEpisodePayload) { + effectiveEpisodes = fetchedEpisodes; } const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0; if (fetchedEpisodeCount > 0) { @@ -17981,6 +18270,98 @@ class PipelineService extends EventEmitter { }; } + buildPostEncodeScriptsSummaryPlaceholder(encodePlan) { + const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []); + const chainIds = normalizeChainIdList(encodePlan?.postEncodeChainIds || []); + return { + configured: scriptIds.length + chainIds.length, + attempted: 0, + succeeded: 0, + failed: 0, + skipped: 0, + aborted: false, + abortReason: null, + failedScriptId: null, + failedScriptName: null, + pending: scriptIds.length + chainIds.length > 0, + results: [] + }; + } + + async persistPostEncodeScriptsSummary(jobId, summary) { + try { + const job = await historyService.getJobById(jobId); + if (!job) { + return; + } + const handbrakeInfo = this.safeParseJson(job.handbrake_info_json); + const nextInfo = handbrakeInfo && typeof handbrakeInfo === 'object' + ? handbrakeInfo + : {}; + await historyService.updateJob(jobId, { + handbrake_info_json: JSON.stringify({ + ...nextInfo, + postEncodeScripts: { + ...(summary && typeof summary === 'object' ? summary : {}), + pending: false + } + }) + }); + } catch (error) { + logger.warn('encode:post-script:persist-summary-failed', { jobId, error: errorToMeta(error) }); + } + } + + runPostEncodeScriptsDetached(jobId, encodePlan, context = {}, options = {}) { + const placeholderSummary = this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan); + if (placeholderSummary.configured <= 0) { + return { started: false, summary: placeholderSummary }; + } + + const phaseLabel = String(options?.phaseLabel || 'Post-Encode').trim() || 'Post-Encode'; + void historyService.appendLog( + jobId, + 'SYSTEM', + `${phaseLabel} Skripte/Ketten werden unabhängig im Hintergrund ausgeführt.` + ); + + void (async () => { + let finalSummary = { + ...placeholderSummary, + pending: false + }; + try { + finalSummary = { + ...(await this.runPostEncodeScripts(jobId, encodePlan, context, null)), + pending: false + }; + } catch (error) { + logger.warn('encode:post-script:detached-failed', { jobId, error: errorToMeta(error) }); + finalSummary = { + ...placeholderSummary, + attempted: placeholderSummary.configured, + failed: placeholderSummary.configured, + aborted: true, + abortReason: error?.message || 'unknown', + pending: false + }; + } + + await historyService.appendLog( + jobId, + 'SYSTEM', + `${phaseLabel} Skripte/Ketten abgeschlossen: ${finalSummary.succeeded} erfolgreich, ` + + `${finalSummary.failed} fehlgeschlagen, ${finalSummary.skipped} übersprungen.` + ); + await this.persistPostEncodeScriptsSummary(jobId, finalSummary); + })(); + + return { + started: true, + summary: placeholderSummary + }; + } + async startEncodingFromPrepared(jobId, options = {}) { this.ensureNotBusy('startEncodingFromPrepared', jobId); logger.info('encode:start-from-prepared', { jobId }); @@ -18568,40 +18949,15 @@ class PipelineService extends EventEmitter { historyService.addJobOutputFolder(jobId, finalizedOutputPath).catch((e) => { logger.warn('encode:record-output-folder-failed', { jobId, error: e?.message }); }); - let postEncodeScriptsSummary = { - configured: 0, - attempted: 0, - succeeded: 0, - failed: 0, - skipped: 0, - results: [] + const postEncodeExecutionContext = { + mode, + jobTitle: job.title || job.detected_title || null, + inputPath, + outputPath: finalizedOutputPath, + rawPath: activeRawPath, + pipelineStage: 'ENCODING' }; - try { - postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, encodePlan, { - mode, - jobTitle: job.title || job.detected_title || null, - inputPath, - outputPath: finalizedOutputPath, - rawPath: activeRawPath - }, encodeScriptProgressTracker); - } catch (error) { - logger.warn('encode:post-script:summary-failed', { - jobId, - error: errorToMeta(error) - }); - await historyService.appendLog( - jobId, - 'SYSTEM', - `Post-Encode Skripte konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}` - ); - } - if (postEncodeScriptsSummary.configured > 0) { - await historyService.appendLog( - jobId, - 'SYSTEM', - `Post-Encode Skripte abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.${postEncodeScriptsSummary.aborted ? ' Kette wurde abgebrochen.' : ''}` - ); - } + const postEncodeScriptsSummary = this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan); let finalizedRawPath = activeRawPath || null; if (activeRawPath) { const currentRawPath = String(activeRawPath || '').trim(); @@ -18662,6 +19018,9 @@ class PipelineService extends EventEmitter { output_path: finalizedOutputPath, error_message: null }); + this.runPostEncodeScriptsDetached(jobId, encodePlan, postEncodeExecutionContext, { + phaseLabel: 'Post-Encode' + }); return { outputPath: finalizedOutputPath, handbrakeInfo: handbrakeInfoWithPostScripts, @@ -18723,6 +19082,9 @@ class PipelineService extends EventEmitter { logger.info('encoding:finished:background', { jobId, mode, activeJobId: this.snapshot.activeJobId }); void this.pumpQueue(); } + this.runPostEncodeScriptsDetached(jobId, encodePlan, postEncodeExecutionContext, { + phaseLabel: 'Post-Encode' + }); if (mode === 'reencode') { void this.notifyPushover('reencode_finished', { @@ -23532,8 +23894,6 @@ class PipelineService extends EventEmitter { const normalizedEncodePlan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; const preScriptIds = normalizeScriptIdList(normalizedEncodePlan?.preEncodeScriptIds || []); const preChainIds = normalizeChainIdList(normalizedEncodePlan?.preEncodeChainIds || []); - const postScriptIds = normalizeScriptIdList(normalizedEncodePlan?.postEncodeScriptIds || []); - const postChainIds = normalizeChainIdList(normalizedEncodePlan?.postEncodeChainIds || []); let preEncodeScriptsSummary = { configured: 0, attempted: 0, @@ -23755,35 +24115,17 @@ class PipelineService extends EventEmitter { } const { encodeResults: cdEncodeResults = [] } = cdRipResult || {}; settleLifecycle(); - - if (postScriptIds.length > 0 || postChainIds.length > 0) { - await historyService.appendLog(jobId, 'SYSTEM', 'Post-Rip Skripte/Ketten werden ausgeführt...'); - try { - postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, normalizedEncodePlan, { - mode: 'cd_rip', - jobId, - jobTitle: selectedMeta?.title || `Job #${jobId}`, - inputPath: devicePath || null, - outputPath: outputDir || null, - rawPath: rawWavDir || null, - mediaProfile: 'cd', - pipelineStage: skipEncode ? 'CD_RIPPING' : 'CD_ENCODING' - }); - } catch (error) { - logger.warn('cd:rip:post-script:failed', { jobId, error: errorToMeta(error) }); - await historyService.appendLog( - jobId, - 'SYSTEM', - `Post-Rip Skripte/Ketten konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}` - ); - } - await historyService.appendLog( - jobId, - 'SYSTEM', - `Post-Rip Skripte/Ketten abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ` - + `${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.` - ); - } + postEncodeScriptsSummary = this.buildPostEncodeScriptsSummaryPlaceholder(normalizedEncodePlan); + let postCdScriptContext = { + mode: 'cd_rip', + jobId, + jobTitle: selectedMeta?.title || `Job #${jobId}`, + inputPath: devicePath || null, + outputPath: outputDir || null, + rawPath: rawWavDir || null, + mediaProfile: 'cd', + pipelineStage: skipEncode ? 'CD_RIPPING' : 'CD_ENCODING' + }; // RAW-Verzeichnis von Incomplete_ → Zielzustand umbenennen let activeRawDir = rawWavDir; @@ -23800,6 +24142,11 @@ class PipelineService extends EventEmitter { // ignore – raw dir bleibt unter bestehendem Namen zugänglich } } + postCdScriptContext = { + ...postCdScriptContext, + outputPath: outputDir || null, + rawPath: activeRawDir || rawWavDir || null + }; const directReencodeReadyAt = normalizedEncodePlan?.directReencodeReadyAt || nowIso(); const persistedEncodePlan = { @@ -23963,6 +24310,9 @@ class PipelineService extends EventEmitter { void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath)); } } + this.runPostEncodeScriptsDetached(jobId, normalizedEncodePlan, postCdScriptContext, { + phaseLabel: 'Post-Rip' + }); } catch (error) { settleLifecycle(); const failedCdLive = buildLiveContext(currentTrackPosition || null); diff --git a/db/schema.sql b/db/schema.sql index 4ba6fc2..666767f 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -464,11 +464,11 @@ VALUES ('output_template_dvd', 'Pfade', 'Output Template (DVD)', 'string', 1, 'T INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd', '${title} (${year})/${title} (${year})'); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536); +VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'); INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537); +VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'); -- Tools – CD diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d590361..966d619 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.13.1-9", + "version": "0.13.1-10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.13.1-9", + "version": "0.13.1-10", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 169047c..5afe0a8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.13.1-9", + "version": "0.13.1-10", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 9eec20f..30dbe7c 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -823,6 +823,7 @@ export const api = { async deleteJobEntry(jobId, target = 'none', options = {}) { const includeRelated = Boolean(options?.includeRelated); const resetDriveState = Boolean(options?.resetDriveState); + const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs); const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths) ? options.selectedMoviePaths .map((item) => String(item || '').trim()) @@ -838,6 +839,7 @@ export const api = { target, includeRelated, resetDriveState, + preserveRawForImportJobs, ...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}), ...(selectedMoviePaths ? { selectedMoviePaths } : {}) }) diff --git a/frontend/src/components/MetadataSelectionDialog.jsx b/frontend/src/components/MetadataSelectionDialog.jsx index 9460c44..11b5195 100644 --- a/frontend/src/components/MetadataSelectionDialog.jsx +++ b/frontend/src/components/MetadataSelectionDialog.jsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Dialog } from 'primereact/dialog'; import { Button } from 'primereact/button'; import { DataTable } from 'primereact/datatable'; @@ -45,11 +45,13 @@ export default function MetadataSelectionDialog({ const [manualImdb, setManualImdb] = useState(''); const [manualDiscNumber, setManualDiscNumber] = useState(''); const [extraResults, setExtraResults] = useState([]); + const [hasSearchOverride, setHasSearchOverride] = useState(false); const [searchBusy, setSearchBusy] = useState(false); const [searchError, setSearchError] = useState(''); const [submitError, setSubmitError] = useState(''); const [seasonFilter, setSeasonFilter] = useState(''); const [discValidationError, setDiscValidationError] = useState(''); + const activeSearchRequestRef = useRef(0); const contextWorkflowKind = normalizeWorkflowKind( context?.selectedMetadata?.workflowKind || context?.workflowKind @@ -102,12 +104,14 @@ export default function MetadataSelectionDialog({ setManualImdb(defaultImdb); setManualDiscNumber(''); setExtraResults([]); + setHasSearchOverride(false); setSearchBusy(false); setSearchError(''); setSubmitError(''); setSeasonFilter(''); setDiscValidationError(''); setSelectedMetadataProvider(contextMetadataProvider); + activeSearchRequestRef.current += 1; }, [visible, context]); useEffect(() => { @@ -129,7 +133,8 @@ export default function MetadataSelectionDialog({ const base = Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : (context?.omdbCandidates || []); - const all = [...base, ...extraResults]; + const sourceRows = hasSearchOverride ? extraResults : base; + const all = Array.isArray(sourceRows) ? sourceRows : []; const map = new Map(); all.forEach((item) => { @@ -149,7 +154,7 @@ export default function MetadataSelectionDialog({ }); return Array.from(map.values()); - }, [context, extraResults]); + }, [context, extraResults, hasSearchOverride]); const filteredRows = useMemo(() => { if (metadataProvider !== 'tmdb') { @@ -192,18 +197,28 @@ export default function MetadataSelectionDialog({ if (!trimmedQuery || typeof onSearch !== 'function') { return; } + const requestId = activeSearchRequestRef.current + 1; + activeSearchRequestRef.current = requestId; + setHasSearchOverride(true); setSearchBusy(true); setSearchError(''); try { const results = await onSearch(trimmedQuery, { metadataProvider }); + if (activeSearchRequestRef.current !== requestId) { + return; + } setExtraResults(Array.isArray(results) ? results : []); } catch (error) { - setExtraResults([]); + if (activeSearchRequestRef.current !== requestId) { + return; + } setSearchError(error?.message || 'Suche fehlgeschlagen.'); } finally { - setSearchBusy(false); + if (activeSearchRequestRef.current === requestId) { + setSearchBusy(false); + } } }; @@ -262,10 +277,6 @@ export default function MetadataSelectionDialog({ }; const handleHideRequest = () => { - if (requiresDiscNumber && !hasValidDiscNumber) { - setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.'); - return; - } onHide?.(); }; @@ -291,6 +302,7 @@ export default function MetadataSelectionDialog({ setSelectedMetadataProvider(nextProvider); setSelected(null); setExtraResults([]); + setHasSearchOverride(false); setSearchError(''); setSubmitError(''); setDiscValidationError(''); @@ -331,7 +343,7 @@ export default function MetadataSelectionDialog({ /> {requiresDiscNumber - ? 'Pflichtfeld fuer Serien-DVDs. Ohne Disk-Nummer koennen die Metadaten nicht verlassen werden.' + ? 'Pflichtfeld fuer Serien-DVDs. Ohne Disk-Nummer koennen die Metadaten nicht uebernommen werden.' : 'Optionale Disc-Nummer fuer internes Mapping und Output-Templates. Nur Werte groesser 0 werden gespeichert.'} @@ -394,7 +406,6 @@ export default function MetadataSelectionDialog({ severity="secondary" text onClick={handleHideRequest} - disabled={requiresDiscNumber && !hasValidDiscNumber} />