From 974b7bfad3b9b9db51e36ce562c06f01a6d56ff7 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Fri, 10 Apr 2026 18:44:41 +0000 Subject: [PATCH] 0.13.0 DVD Series --- backend/.env.example | 1 + backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/config.js | 1 + backend/src/db/database.js | 161 +- backend/src/plugins/DVDSeriesPlugin.js | 117 + backend/src/plugins/VideoDiscPlugin.js | 21 +- backend/src/routes/historyRoutes.js | 17 +- backend/src/routes/pipelineRoutes.js | 73 +- backend/src/routes/settingsRoutes.js | 46 +- backend/src/services/diskDetectionService.js | 117 +- backend/src/services/dvdSeriesScanService.js | 498 +++ backend/src/services/historyService.js | 1739 +++++++- backend/src/services/pipelineService.js | 3759 +++++++++++++++-- backend/src/services/settingsService.js | 35 +- backend/src/services/tmdbService.js | 428 ++ backend/src/utils/files.js | 35 +- db/schema.sql | 97 + frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api/client.js | 25 + .../src/components/DynamicSettingsForm.jsx | 117 +- frontend/src/components/JobDetailDialog.jsx | 717 +++- .../src/components/MediaInfoReviewPanel.jsx | 595 ++- .../components/MetadataSelectionDialog.jsx | 127 +- .../src/components/PipelineStatusCard.jsx | 894 +++- frontend/src/pages/DatabasePage.jsx | 2 +- frontend/src/pages/HistoryPage.jsx | 246 +- frontend/src/pages/RipperPage.jsx | 320 +- frontend/src/pages/SettingsPage.jsx | 2 + frontend/src/styles/app.css | 142 + frontend/src/utils/jobTaxonomy.js | 51 +- install-dev.sh | 2 + install.sh | 2 + package-lock.json | 4 +- package.json | 2 +- 36 files changed, 9666 insertions(+), 739 deletions(-) create mode 100644 backend/src/plugins/DVDSeriesPlugin.js create mode 100644 backend/src/services/dvdSeriesScanService.js create mode 100644 backend/src/services/tmdbService.js diff --git a/backend/.env.example b/backend/.env.example index 70e6726..4e89a56 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,5 +8,6 @@ LOG_LEVEL=debug # Leer lassen = relativ zum data/-Verzeichnis der DB (data/output/raw etc.) DEFAULT_RAW_DIR= DEFAULT_MOVIE_DIR= +DEFAULT_SERIES_DIR= DEFAULT_CD_DIR= DEFAULT_DOWNLOAD_DIR= diff --git a/backend/package-lock.json b/backend/package-lock.json index 8b5d637..db7b526 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.12.0-19", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.12.0-19", + "version": "0.13.0", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 49d5c21..753a787 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.12.0-19", + "version": "0.13.0", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/config.js b/backend/src/config.js index 250618f..8cf14e0 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -23,6 +23,7 @@ module.exports = { logLevel: process.env.LOG_LEVEL || 'info', defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'), defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'), + defaultSeriesDir: resolveOutputPath(process.env.DEFAULT_SERIES_DIR, 'output', 'series'), defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd'), defaultAudiobookRawDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_RAW_DIR, 'output', 'audiobook-raw'), defaultAudiobookDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_DIR, 'output', 'audiobooks'), diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 45346ed..ab9751c 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -838,6 +838,18 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [ required: 0, description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.', validation_json: '{}' + }, + { + 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}.', + 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}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', + validation_json: '{"minLength":1}' } ]; @@ -917,6 +929,16 @@ async function migrateSettingsSchemaMetadata(db) { WHERE key = 'raw_dir_cd_owner' AND label != 'Eigentümer CD RAW-Ordner'` ); + // depends_on-Spalte zu settings_schema hinzufügen, bevor neue abhängige + // Settings per INSERT OR IGNORE angelegt werden. + { + const cols = await db.all(`PRAGMA table_info(settings_schema)`); + if (!cols.some((c) => c.name === 'depends_on')) { + await db.run(`ALTER TABLE settings_schema ADD COLUMN depends_on TEXT DEFAULT NULL`); + logger.info('migrate:settings-schema-add-depends_on'); + } + } + // Add movie_dir_cd if not already present await db.run( `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) @@ -986,6 +1008,87 @@ async function migrateSettingsSchemaMetadata(db) { ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL)`); + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('raw_dir_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('raw_dir_dvd_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (DVD Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD).', NULL, '[]', '{}', 1022)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('series_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('series_dir_dvd_owner', 'Pfade', 'Eigentümer Serien-Ordner (DVD)', 'string', 0, 'Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_owner', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('dvd_series_plugin_enabled', 'Features', 'DVD Serien-Plugin aktiviert', 'boolean', 1, 'Aktiviert die additive Serienerkennung für DVDs. Wenn deaktiviert, bleibt der bestehende DVD-Film-Flow unverändert.', 'false', '[]', '{}', 145)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_plugin_enabled', 'false')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) + VALUES ('dvd_series_prescan_enabled', 'Features', 'DVD HandBrake-Prescan aktiviert', 'boolean', 1, 'Führt nach der DVD-Erkennung einen zusätzlichen HandBrake-Prescan für die Serienheuristik aus.', 'true', '[]', '{}', 146, 'dvd_series_plugin_enabled')` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_prescan_enabled', 'true')`); + + 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)` + ); + 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}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}', '[]', '{"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{seasonNr}E{episodeRange}')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('tmdb_api_read_access_token', 'Metadaten', 'TMDb Read Access Token', 'string', 0, 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', NULL, '[]', '{}', 401)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('tmdb_api_read_access_token', NULL)`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('dvd_series_language', 'Metadaten', 'DVD Serien-Sprache', 'string', 1, 'Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('dvd_series_fallback_languages', 'Metadaten', 'DVD Serien-Fallback-Sprachen', 'string', 1, 'Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('dvd_series_order_preference', 'Metadaten', 'DVD Serien-Ordnung', 'select', 1, 'Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden.', 'episode_group', '[{"label":"Episode Group","value":"episode_group"},{"label":"Aired / Season","value":"aired"}]', '{}', 405)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('dvd_series_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true')`); + + await db.run(`DELETE FROM settings_values WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`); + await db.run(`DELETE FROM settings_schema WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`); + // Converter-Pfade und Eigentümer await db.run( `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) @@ -1077,15 +1180,6 @@ async function migrateSettingsSchemaMetadata(db) { ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6')`); - // depends_on-Spalte zu settings_schema hinzufügen (falls noch nicht vorhanden) - { - const cols = await db.all(`PRAGMA table_info(settings_schema)`); - if (!cols.some((c) => c.name === 'depends_on')) { - await db.run(`ALTER TABLE settings_schema ADD COLUMN depends_on TEXT DEFAULT NULL`); - logger.info('migrate:settings-schema-add-depends_on'); - } - } - await db.run(` CREATE TABLE IF NOT EXISTS user_prefs ( key TEXT PRIMARY KEY, @@ -1093,6 +1187,55 @@ async function migrateSettingsSchemaMetadata(db) { updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) `); + + await db.run(` + CREATE TABLE IF NOT EXISTS dvd_series_disc_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL DEFAULT 'tmdb', + provider_series_id TEXT, + provider_series_name TEXT, + season_number INTEGER, + season_type TEXT NOT NULL DEFAULT 'dvd', + disc_label TEXT, + disc_serial TEXT, + disc_number INTEGER, + language TEXT, + disc_signature TEXT NOT NULL, + fingerprint_json TEXT, + episode_map_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + await db.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dvd_series_disc_profiles_signature ON dvd_series_disc_profiles(disc_signature)`); + await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_disc_profiles_series ON dvd_series_disc_profiles(provider, provider_series_id, season_number)`); + await db.run(`UPDATE dvd_series_disc_profiles SET provider = 'tmdb' WHERE provider = 'tvdb'`); + + await db.run(` + CREATE TABLE IF NOT EXISTS dvd_series_title_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_job_id INTEGER NOT NULL, + disc_profile_id INTEGER, + title_index INTEGER NOT NULL, + title_kind TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 0, + selected INTEGER NOT NULL DEFAULT 1, + provider TEXT NOT NULL DEFAULT 'tmdb', + provider_series_id TEXT, + season_number INTEGER, + episode_start INTEGER, + episode_end INTEGER, + episode_ids_json TEXT, + mapping_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE CASCADE, + FOREIGN KEY (disc_profile_id) REFERENCES dvd_series_disc_profiles(id) ON DELETE SET NULL + ) + `); + await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_title_mappings_parent_job ON dvd_series_title_mappings(parent_job_id, title_index)`); + await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_title_mappings_series ON dvd_series_title_mappings(provider, provider_series_id, season_number)`); + await db.run(`UPDATE dvd_series_title_mappings SET provider = 'tmdb' WHERE provider = 'tvdb'`); } async function backfillJobOutputFolders(db) { diff --git a/backend/src/plugins/DVDSeriesPlugin.js b/backend/src/plugins/DVDSeriesPlugin.js new file mode 100644 index 0000000..c204344 --- /dev/null +++ b/backend/src/plugins/DVDSeriesPlugin.js @@ -0,0 +1,117 @@ +'use strict'; + +const { SourcePlugin } = require('./PluginBase'); +const dvdSeriesScanService = require('../services/dvdSeriesScanService'); +const tmdbService = require('../services/tmdbService'); + +/** + * Companion-Plugin für Serien-DVDs. + * + * Dieses Plugin ersetzt den bestehenden DVDPlugin-Flow nicht. Es wird in einem + * späteren Schritt explizit aus dem DVD-Flow aufgerufen, sobald ein Prescan eine + * serientypische Titelstruktur vermuten lässt. + */ +class DVDSeriesPlugin extends SourcePlugin { + get id() { + return 'dvd_series'; + } + + get name() { + return 'DVD Series'; + } + + detect() { + return false; + } + + async analyze(_devicePath, job, ctx) { + ctx.markExecution('analyze', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'DVDSeriesPlugin.js' + }); + + const scanText = String(ctx.extra?.scanText || '').trim(); + if (!scanText) { + return { + parsedScan: null, + seriesAnalysis: null, + providerConfigured: await tmdbService.isConfigured() + }; + } + + const result = dvdSeriesScanService.analyzeHandBrakeScan(scanText, ctx.extra?.seriesOptions || {}); + const seriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([ + { + value: result?.parsed?.discTitle || '', + source: 'handbrake_disc_title' + }, + { + value: ctx.extra?.detectedTitle || '', + source: 'detected_title' + } + ]); + return { + parsedScan: result.parsed, + seriesAnalysis: result.analysis, + seriesLookupHint, + providerConfigured: await tmdbService.isConfigured() + }; + } + + async review(_job, ctx) { + ctx.markExecution('review', { + pluginId: this.id, + pluginName: this.name, + pluginFile: 'DVDSeriesPlugin.js' + }); + return null; + } + + async rip() { + throw new Error('DVDSeriesPlugin.rip() ist ein Companion-Plugin und wird nicht direkt ausgeführt.'); + } + + async encode() { + throw new Error('DVDSeriesPlugin.encode() ist ein Companion-Plugin und wird nicht direkt ausgeführt.'); + } + + async searchSeries(query, options = {}) { + return tmdbService.searchSeries(query, options); + } + + async searchSeriesWithSeason(query, seasonNumber, options = {}) { + return tmdbService.searchSeriesWithSeason(query, seasonNumber, options); + } + + getSettingsSchema() { + return [ + { + key: 'dvd_series_plugin_enabled', + category: 'Features', + label: 'DVD Serien-Plugin aktiviert', + type: 'boolean', + required: 1, + description: 'Aktiviert die additive Serienerkennung für DVDs.', + default_value: 'false', + options_json: '[]', + validation_json: '{}', + order_index: 145 + }, + { + key: 'tmdb_api_read_access_token', + category: 'Metadaten', + label: 'TMDb Read Access Token', + type: 'string', + required: 0, + description: 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', + default_value: null, + options_json: '[]', + validation_json: '{}', + order_index: 401 + } + ]; + } +} + +module.exports = { DVDSeriesPlugin }; diff --git a/backend/src/plugins/VideoDiscPlugin.js b/backend/src/plugins/VideoDiscPlugin.js index e1e7d7d..c415a9b 100644 --- a/backend/src/plugins/VideoDiscPlugin.js +++ b/backend/src/plugins/VideoDiscPlugin.js @@ -161,7 +161,9 @@ class VideoDiscPlugin extends SourcePlugin { cmd: scanConfig.cmd, args: scanConfig.args, collectLines: scanLines, - collectStderrLines: false, + // HandBrake scan details (duration/chapters/audio/subtitles) are often + // emitted on stderr. Collect both streams for reliable disc heuristics. + collectStderrLines: true, silent: reviewSilent }); } catch (cmdError) { @@ -179,6 +181,7 @@ class VideoDiscPlugin extends SourcePlugin { cmd: scanConfig.cmd, args: scanConfig.args, onStdoutLine: (line) => scanLines.push(line), + onStderrLine: (line) => scanLines.push(line), context: { jobId: job?.id, source: `${this.id}Plugin.review` } }); } @@ -219,6 +222,7 @@ class VideoDiscPlugin extends SourcePlugin { deviceInfo, selectedTitleId = null, backupOutputBase = null, + disableMinLengthFilter = false, isCancelled, onProcessHandle } = ctx.extra || {}; @@ -237,7 +241,8 @@ class VideoDiscPlugin extends SourcePlugin { selectedTitleId, mediaProfile: this.mediaProfile, settingsMap: settings, - backupOutputBase + backupOutputBase, + disableMinLengthFilter }); const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv') @@ -249,7 +254,8 @@ class VideoDiscPlugin extends SourcePlugin { args: ripConfig.args, ripMode, rawJobDir, - selectedTitleId + selectedTitleId, + disableMinLengthFilter }); ctx.emitProgress(0, ripMode === 'backup' @@ -443,7 +449,7 @@ function _assertNotCancelled(isCancelled) { } } -async function _runCommandCaptured({ cmd, args, onStdoutLine, context }) { +async function _runCommandCaptured({ cmd, args, onStdoutLine, onStderrLine, context }) { const lines = []; const handle = spawnTrackedProcess({ cmd, @@ -454,7 +460,12 @@ async function _runCommandCaptured({ cmd, args, onStdoutLine, context }) { onStdoutLine(line); } }, - onStderrLine: () => {}, + onStderrLine: (line) => { + lines.push(line); + if (typeof onStderrLine === 'function') { + onStderrLine(line); + } + }, context: context || {} }); diff --git a/backend/src/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js index 34ae80e..ffa9378 100644 --- a/backend/src/routes/historyRoutes.js +++ b/backend/src/routes/historyRoutes.js @@ -18,13 +18,15 @@ router.get( .map((value) => String(value || '').trim()) .filter(Boolean); const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase()); + const includeChildren = ['1', 'true', 'yes'].includes(String(req.query.includeChildren || '').toLowerCase()); logger.info('get:jobs', { reqId: req.reqId, status: req.query.status, statuses: statuses.length > 0 ? statuses : null, search: req.query.search, limit, - lite + lite, + includeChildren }); const jobs = await historyService.getJobs({ @@ -32,7 +34,8 @@ router.get( statuses, search: req.query.search, limit, - includeFsChecks: !lite + includeFsChecks: !lite, + includeChildren }); res.json({ jobs }); @@ -108,6 +111,16 @@ router.post( }) ); +router.post( + '/:id/error/ack', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + logger.info('post:job:error:ack', { reqId: req.reqId, id }); + const job = await historyService.acknowledgeJobError(id); + res.json({ job }); + }) +); + router.post( '/:id/delete-files', asyncHandler(async (req, res) => { diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 16339d5..d2a7c2c 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -155,6 +155,17 @@ router.get( }) ); +router.get( + '/tmdb/series/search', + asyncHandler(async (req, res) => { + const query = req.query.q || ''; + const seasonNumber = req.query.season || null; + logger.info('get:tmdb:series-search', { reqId: req.reqId, query, seasonNumber }); + const results = await pipelineService.searchTmdbSeries(String(query), seasonNumber); + res.json({ results }); + }) +); + router.get( '/cd/musicbrainz/search', asyncHandler(async (req, res) => { @@ -341,7 +352,26 @@ router.get( router.post( '/select-metadata', asyncHandler(async (req, res) => { - const { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist, selectedHandBrakeTitleId } = req.body; + const { + jobId, + title, + year, + imdbId, + poster, + fromOmdb, + selectedPlaylist, + selectedHandBrakeTitleId, + selectedHandBrakeTitleIds, + metadataProvider, + providerId, + tmdbId, + metadataKind, + seasonNumber, + seasonName, + episodeCount, + episodes, + discNumber + } = req.body; if (!jobId) { const error = new Error('jobId fehlt.'); @@ -358,7 +388,13 @@ router.post( poster, fromOmdb, selectedPlaylist, - selectedHandBrakeTitleId + selectedHandBrakeTitleId, + selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null, + metadataProvider, + providerId, + tmdbId, + seasonNumber, + discNumber }); const job = await pipelineService.selectMetadata({ @@ -369,7 +405,17 @@ router.post( poster, fromOmdb, selectedPlaylist, - selectedHandBrakeTitleId + selectedHandBrakeTitleId, + selectedHandBrakeTitleIds, + metadataProvider, + providerId, + tmdbId, + metadataKind, + seasonNumber, + seasonName, + episodeCount, + episodes, + discNumber }); res.json({ job }); @@ -391,7 +437,9 @@ router.post( asyncHandler(async (req, res) => { const jobId = Number(req.params.jobId); const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null; + const selectedEncodeTitleIds = req.body?.selectedEncodeTitleIds ?? null; const selectedTrackSelection = req.body?.selectedTrackSelection ?? null; + const episodeAssignments = req.body?.episodeAssignments ?? null; const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds; const selectedPreEncodeScriptIds = req.body?.selectedPreEncodeScriptIds; const selectedPostEncodeChainIds = req.body?.selectedPostEncodeChainIds; @@ -403,7 +451,9 @@ router.post( reqId: req.reqId, jobId, selectedEncodeTitleId, + selectedEncodeTitleIdsCount: Array.isArray(selectedEncodeTitleIds) ? selectedEncodeTitleIds.length : 0, selectedTrackSelectionProvided: Boolean(selectedTrackSelection), + episodeAssignmentsProvided: Boolean(episodeAssignments && typeof episodeAssignments === 'object'), skipPipelineStateUpdate, selectedUserPresetId, selectedHandBrakePreset, @@ -422,7 +472,9 @@ router.post( }); const job = await pipelineService.confirmEncodeReview(jobId, { selectedEncodeTitleId, + selectedEncodeTitleIds, selectedTrackSelection, + episodeAssignments, selectedPostEncodeScriptIds, selectedPreEncodeScriptIds, selectedPostEncodeChainIds, @@ -525,8 +577,19 @@ router.post( const jobId = Number(req.params.jobId); const keepBoth = Boolean(req.body?.keepBoth); const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; - logger.info('post:restart-encode', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length }); - const result = await pipelineService.restartEncodeWithLastSettings(jobId, { keepBoth, deleteFolders }); + const restartMode = String(req.body?.restartMode || 'all').trim().toLowerCase() || 'all'; + logger.info('post:restart-encode', { + reqId: req.reqId, + jobId, + keepBoth, + restartMode, + deleteFolderCount: deleteFolders.length + }); + const result = await pipelineService.restartEncodeWithLastSettings(jobId, { + keepBoth, + deleteFolders, + restartMode + }); res.json({ result }); }) ); diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index 5503496..72f560c 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -22,7 +22,7 @@ function isSensitiveSettingKey(key) { if (!normalized) { return false; } - return /(token|password|secret|api_key|registration_key|pushover_user)/i.test(normalized); + return /(token|password|secret|api_key|registration_key|pushover_user|subscriber_pin)/i.test(normalized); } router.get( @@ -471,6 +471,50 @@ router.get( }) ); +router.post( + '/drives/force-unlock', + asyncHandler(async (req, res) => { + const { devicePath, all } = req.body || {}; + const settings = await settingsService.getSettingsMap(); + const expertMode = ['1', 'true', 'yes', 'on'].includes(String(settings?.ui_expert_mode || '').toLowerCase()); + if (!expertMode) { + const error = new Error('Expertenmodus erforderlich.'); + error.statusCode = 403; + throw error; + } + + const devices = await diskDetectionService.getBlockDeviceInfo(); + const drives = devices + .filter((d) => d.type === 'rom') + .map((d) => { + const name = String(d.name || ''); + return d.path || (name ? `/dev/${name}` : null); + }) + .filter(Boolean); + + const activeLockPaths = (diskDetectionService.getActiveLocks?.() || []) + .map((entry) => String(entry?.path || '').trim()) + .filter(Boolean); + + const targetPaths = all + ? Array.from(new Set([...drives, ...activeLockPaths])) + : [String(devicePath || '').trim()].filter(Boolean); + if (targetPaths.length === 0) { + const error = new Error('Kein Laufwerk angegeben.'); + error.statusCode = 400; + throw error; + } + + const results = []; + for (const target of targetPaths) { + const result = await pipelineService.forceUnlockDrive(target, { reason: 'settings_force_unlock' }); + results.push(result); + } + + res.json({ results }); + }) +); + // User preferences (UI state, persisted per-installation) router.get( '/prefs/:key', diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js index be5b6bf..993f576 100644 --- a/backend/src/services/diskDetectionService.js +++ b/backend/src/services/diskDetectionService.js @@ -374,13 +374,67 @@ class DiskDetectionService extends EventEmitter { return String(devicePath || '').trim(); } - lockDevice(devicePath, owner = null) { + _resolveDeviceRealPath(devicePath) { + const normalized = this.normalizeDevicePath(devicePath); + if (!normalized || !normalized.startsWith('/')) { + return null; + } + try { + if (fs.realpathSync && typeof fs.realpathSync.native === 'function') { + return fs.realpathSync.native(normalized); + } + return fs.realpathSync(normalized); + } catch (_error) { + return null; + } + } + + _deviceBaseName(devicePath) { + const normalized = this.normalizeDevicePath(devicePath); + if (!normalized) { + return ''; + } + const parts = normalized.split('/').filter(Boolean); + return String(parts[parts.length - 1] || '').trim(); + } + + _isSameDevicePath(leftPath, rightPath) { + const left = this.normalizeDevicePath(leftPath); + const right = this.normalizeDevicePath(rightPath); + if (!left || !right) { + return false; + } + if (left === right) { + return true; + } + const leftReal = this._resolveDeviceRealPath(left); + const rightReal = this._resolveDeviceRealPath(right); + if (leftReal && rightReal && leftReal === rightReal) { + return true; + } + const leftBase = this._deviceBaseName(left); + const rightBase = this._deviceBaseName(right); + if (leftBase && rightBase && leftBase === rightBase && /^sr\d+$/i.test(leftBase)) { + return true; + } + return false; + } + + _resolveLockKey(devicePath) { const normalized = this.normalizeDevicePath(devicePath); if (!normalized) { return null; } + return this._resolveDeviceRealPath(normalized) || normalized; + } - const entry = this.deviceLocks.get(normalized) || { + lockDevice(devicePath, owner = null) { + const lockKey = this._resolveLockKey(devicePath); + if (!lockKey) { + return null; + } + + const entry = this.deviceLocks.get(lockKey) || { count: 0, owners: [] }; @@ -389,16 +443,16 @@ class DiskDetectionService extends EventEmitter { if (owner) { entry.owners.push(owner); } - this.deviceLocks.set(normalized, entry); + this.deviceLocks.set(lockKey, entry); logger.info('lock:add', { - devicePath: normalized, + devicePath: lockKey, count: entry.count, owner }); return { - devicePath: normalized, + devicePath: lockKey, owner }; } @@ -409,24 +463,33 @@ class DiskDetectionService extends EventEmitter { return; } - const entry = this.deviceLocks.get(normalized); + const directKey = this._resolveLockKey(normalized); + const candidateKeys = Array.from(this.deviceLocks.keys()); + const lockKey = (directKey && this.deviceLocks.has(directKey)) + ? directKey + : (candidateKeys.find((key) => this._isSameDevicePath(key, normalized)) || null); + if (!lockKey) { + return; + } + + const entry = this.deviceLocks.get(lockKey); if (!entry) { return; } entry.count = Math.max(0, entry.count - 1); if (entry.count === 0) { - this.deviceLocks.delete(normalized); + this.deviceLocks.delete(lockKey); logger.info('lock:remove', { - devicePath: normalized, + devicePath: lockKey, owner }); return; } - this.deviceLocks.set(normalized, entry); + this.deviceLocks.set(lockKey, entry); logger.info('lock:decrement', { - devicePath: normalized, + devicePath: lockKey, count: entry.count, owner }); @@ -438,20 +501,26 @@ class DiskDetectionService extends EventEmitter { return 0; } - const entry = this.deviceLocks.get(normalized); - if (!entry) { + const candidateKeys = Array.from(this.deviceLocks.keys()) + .filter((key) => this._isSameDevicePath(key, normalized)); + if (candidateKeys.length === 0) { return 0; } - const previousCount = Math.max(0, Number(entry.count) || 0); - this.deviceLocks.delete(normalized); - logger.warn('lock:force-remove', { - devicePath: normalized, - previousCount, - reason: String(options?.reason || '').trim() || null, - deletedJobIds: Array.isArray(options?.deletedJobIds) ? options.deletedJobIds : [] - }); - return previousCount; + let removed = 0; + for (const lockKey of candidateKeys) { + const entry = this.deviceLocks.get(lockKey); + const previousCount = Math.max(0, Number(entry?.count) || 0); + removed += previousCount; + this.deviceLocks.delete(lockKey); + logger.warn('lock:force-remove', { + devicePath: lockKey, + previousCount, + reason: String(options?.reason || '').trim() || null, + deletedJobIds: Array.isArray(options?.deletedJobIds) ? options.deletedJobIds : [] + }); + } + return removed; } isDeviceLocked(devicePath) { @@ -459,7 +528,11 @@ class DiskDetectionService extends EventEmitter { if (!normalized) { return false; } - return this.deviceLocks.has(normalized); + const directKey = this._resolveLockKey(normalized); + if (directKey && this.deviceLocks.has(directKey)) { + return true; + } + return Array.from(this.deviceLocks.keys()).some((key) => this._isSameDevicePath(key, normalized)); } getActiveLocks() { diff --git a/backend/src/services/dvdSeriesScanService.js b/backend/src/services/dvdSeriesScanService.js new file mode 100644 index 0000000..c268aa8 --- /dev/null +++ b/backend/src/services/dvdSeriesScanService.js @@ -0,0 +1,498 @@ +'use strict'; + +const TITLE_KIND = Object.freeze({ + EPISODE_CANDIDATE: 'episode_candidate', + PLAY_ALL: 'play_all', + EXTRA: 'extra', + DUPLICATE: 'duplicate', + SHORT: 'short', + UNKNOWN: 'unknown' +}); + +const DEFAULTS = Object.freeze({ + minEpisodeMinutes: 18, + maxEpisodeMinutes: 75, + minChapterCount: 3, + shortTitleMinutes: 5 +}); + +function nowIso() { + return new Date().toISOString(); +} + +function parseDurationToSeconds(rawValue) { + const text = String(rawValue || '').trim(); + const match = text.match(/^(\d{1,2}):(\d{2}):(\d{2})$/); + if (!match) { + return 0; + } + return (Number(match[1]) * 3600) + (Number(match[2]) * 60) + Number(match[3]); +} + +function roundToStep(value, step) { + const num = Number(value); + if (!Number.isFinite(num) || step <= 0) { + return 0; + } + return Math.round(num / step) * step; +} + +function median(values = []) { + const nums = (Array.isArray(values) ? values : []) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .sort((left, right) => left - right); + if (nums.length === 0) { + return 0; + } + const middle = Math.floor(nums.length / 2); + if (nums.length % 2 === 1) { + return nums[middle]; + } + return (nums[middle - 1] + nums[middle]) / 2; +} + +function normalizeLanguageCode(rawCode, fallbackLabel = null) { + const raw = String(rawCode || '').trim().toLowerCase(); + if (raw && raw.length === 3) { + return raw; + } + const label = String(fallbackLabel || '').trim().toLowerCase(); + if (label.startsWith('de')) return 'deu'; + if (label.startsWith('en')) return 'eng'; + if (label.startsWith('fr')) return 'fra'; + if (label.startsWith('es')) return 'spa'; + if (label.startsWith('nl')) return 'nld'; + return raw || 'und'; +} + +function normalizeSeriesLookupTitle(rawValue) { + return String(rawValue || '') + .replace(/[_./]+/g, ' ') + .replace(/\b(?:disc|dvd|cd)\s*\d+\b/gi, ' ') + .replace(/\b(?:s(?:taffel|eason)?|season)\s*\d+\b/gi, ' ') + .replace(/\b\d{1,2}x\b/gi, ' ') + .replace(/\b(?:complete|collection|boxset)\b/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function deriveSeriesLookupHint(inputs = []) { + const normalizedInputs = (Array.isArray(inputs) ? inputs : [inputs]) + .map((entry) => { + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + return { + value: String(entry.value || '').trim(), + source: String(entry.source || '').trim() || 'unknown' + }; + } + return { + value: String(entry || '').trim(), + source: 'unknown' + }; + }) + .filter((entry) => entry.value); + + for (const entry of normalizedInputs) { + const compactValue = entry.value.replace(/[_./]+/g, ' ').replace(/\s+/g, ' ').trim(); + if (!compactValue) { + continue; + } + + const seasonMatch = compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\d{1,2})(?=\s|$)/i) + || compactValue.match(/(?:^|\s)(\d{1,2})x(?=\s|$)/i); + if (!seasonMatch) { + continue; + } + + const seasonNumber = Number(seasonMatch[1] || 0); + if (!Number.isFinite(seasonNumber) || seasonNumber <= 0) { + continue; + } + + const discMatch = compactValue.match(/(?:^|\s)(?:disc|dvd|cd)\s*(\d{1,2})(?=\s|$)/i); + const discNumber = discMatch + ? Number(discMatch[1] || 0) || null + : null; + + const baseTitle = normalizeSeriesLookupTitle(compactValue); + if (!baseTitle) { + continue; + } + + return { + query: baseTitle, + seriesTitle: baseTitle, + seasonNumber, + discNumber, + source: entry.source, + sourceLabel: entry.value, + confidence: discNumber ? 'high' : 'medium' + }; + } + + return null; +} + +function normalizeTitleRecord(title = {}) { + const durationSeconds = Number(title.durationSeconds || 0); + const chapterCount = Number(title.chapterCount || 0); + const roundedDurationBucket = roundToStep(durationSeconds, 30); + const audioLanguages = (Array.isArray(title.audioTracks) ? title.audioTracks : []) + .map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel)) + .filter(Boolean) + .sort(); + const subtitleLanguages = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : []) + .map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel)) + .filter(Boolean) + .sort(); + + return { + index: Number(title.index || 0), + durationSeconds, + durationLabel: String(title.durationLabel || '').trim() || null, + chapterCount, + chapterDurationsMs: Array.isArray(title.chapterDurationsMs) ? title.chapterDurationsMs : [], + aspectRatio: String(title.aspectRatio || '').trim() || null, + audioTracks: Array.isArray(title.audioTracks) ? title.audioTracks : [], + subtitleTracks: Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [], + flags: Array.isArray(title.flags) ? title.flags : [], + roundedDurationBucket, + signature: JSON.stringify({ + duration: roundedDurationBucket, + chapterCount, + audioLanguages, + subtitleLanguages + }) + }; +} + +function buildBestEpisodeCluster(titles = [], options = {}) { + const minEpisodeSeconds = Math.max(60, Math.round(Number(options.minEpisodeMinutes || DEFAULTS.minEpisodeMinutes) * 60)); + const maxEpisodeSeconds = Math.max(minEpisodeSeconds, Math.round(Number(options.maxEpisodeMinutes || DEFAULTS.maxEpisodeMinutes) * 60)); + const minChapterCount = Math.max(1, Number(options.minChapterCount || DEFAULTS.minChapterCount)); + const candidates = titles.filter((title) => + title.durationSeconds >= minEpisodeSeconds + && title.durationSeconds <= maxEpisodeSeconds + && title.chapterCount >= minChapterCount + ); + + if (candidates.length === 0) { + return { + titles: [], + medianDurationSeconds: 0, + medianChapterCount: 0 + }; + } + + let bestCluster = []; + for (const anchor of candidates) { + const durationTolerance = Math.max(90, Math.round(anchor.durationSeconds * 0.12)); + const cluster = candidates.filter((candidate) => { + const durationGap = Math.abs(candidate.durationSeconds - anchor.durationSeconds); + const chapterGap = Math.abs(candidate.chapterCount - anchor.chapterCount); + return durationGap <= durationTolerance && chapterGap <= 2; + }); + + if (cluster.length > bestCluster.length) { + bestCluster = cluster; + continue; + } + if (cluster.length === bestCluster.length) { + const clusterMedian = median(cluster.map((item) => item.durationSeconds)); + const bestMedian = median(bestCluster.map((item) => item.durationSeconds)); + if (clusterMedian > bestMedian) { + bestCluster = cluster; + } + } + } + + return { + titles: bestCluster, + medianDurationSeconds: median(bestCluster.map((item) => item.durationSeconds)), + medianChapterCount: median(bestCluster.map((item) => item.chapterCount)) + }; +} + +function classifyTitles(titles = [], cluster = {}, options = {}) { + const shortTitleSeconds = Math.max(60, Math.round(Number(options.shortTitleMinutes || DEFAULTS.shortTitleMinutes) * 60)); + const clusterTitleIds = new Set((cluster.titles || []).map((item) => Number(item.index))); + const duplicateFirstBySignature = new Map(); + + for (const title of titles) { + if (!duplicateFirstBySignature.has(title.signature)) { + duplicateFirstBySignature.set(title.signature, title.index); + } + } + + const playAllCandidate = (() => { + if (!Array.isArray(cluster.titles) || cluster.titles.length < 2 || !cluster.medianDurationSeconds) { + return null; + } + const minPlayAllSeconds = cluster.medianDurationSeconds * Math.max(2, cluster.titles.length - 1); + return titles + .filter((title) => + !clusterTitleIds.has(Number(title.index)) + && title.durationSeconds >= minPlayAllSeconds * 0.75 + && title.chapterCount >= Math.max(6, Math.round(cluster.medianChapterCount * cluster.titles.length * 0.6)) + ) + .sort((left, right) => right.durationSeconds - left.durationSeconds)[0] || null; + })(); + + return titles.map((title) => { + const isFirstWithSignature = duplicateFirstBySignature.get(title.signature) === title.index; + const isShort = title.durationSeconds > 0 && title.durationSeconds <= shortTitleSeconds; + const isEpisodeCandidate = clusterTitleIds.has(Number(title.index)); + const isPlayAll = playAllCandidate && Number(playAllCandidate.index) === Number(title.index); + const kind = isPlayAll + ? TITLE_KIND.PLAY_ALL + : isEpisodeCandidate + ? TITLE_KIND.EPISODE_CANDIDATE + : !isFirstWithSignature + ? TITLE_KIND.DUPLICATE + : isShort + ? TITLE_KIND.SHORT + : (title.durationSeconds > 0 ? TITLE_KIND.EXTRA : TITLE_KIND.UNKNOWN); + + let confidence = 0.2; + if (kind === TITLE_KIND.EPISODE_CANDIDATE) confidence = 0.8; + if (kind === TITLE_KIND.PLAY_ALL) confidence = 0.95; + if (kind === TITLE_KIND.DUPLICATE) confidence = 0.85; + if (kind === TITLE_KIND.SHORT) confidence = 0.9; + if (kind === TITLE_KIND.EXTRA) confidence = 0.55; + + return { + ...title, + kind, + confidence, + duplicateOfTitleIndex: kind === TITLE_KIND.DUPLICATE + ? duplicateFirstBySignature.get(title.signature) + : null + }; + }); +} + +function buildDiscSignature(parsedScan = {}) { + return JSON.stringify({ + discTitle: String(parsedScan.discTitle || '').trim() || null, + discSerial: String(parsedScan.discSerial || '').trim() || null, + titleCount: Number(parsedScan.titleCount || 0), + durations: (Array.isArray(parsedScan.titles) ? parsedScan.titles : []) + .map((title) => ({ + index: Number(title.index || 0), + durationBucket: roundToStep(title.durationSeconds || 0, 30), + chapterCount: Number(title.chapterCount || 0) + })) + }); +} + +function summarizeSeriesLikelihood(classifiedTitles = [], cluster = {}) { + const episodeCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length; + const playAllCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length; + const duplicateCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length; + const extrasCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length; + + const reasons = []; + let confidence = 'low'; + let seriesLike = false; + + if (episodeCount >= 3) { + seriesLike = true; + confidence = playAllCount > 0 ? 'high' : 'medium'; + reasons.push(`${episodeCount} ähnlich lange Episodenkandidaten erkannt.`); + } else if (episodeCount === 2) { + seriesLike = true; + confidence = 'medium'; + reasons.push('Mindestens zwei ähnlich lange Episodenkandidaten erkannt.'); + } + + if (playAllCount > 0) { + reasons.push('Ein langer Play-All-Titel wurde erkannt.'); + } + if (duplicateCount > 0) { + reasons.push(`${duplicateCount} alternative/duplizierte Titel gefunden.`); + } + if (cluster.medianDurationSeconds > 0) { + reasons.push(`Typische Episodenlänge ca. ${Math.round(cluster.medianDurationSeconds / 60)} Minuten.`); + } + if (extrasCount > 0) { + reasons.push(`${extrasCount} Titel als Extras oder Sonderfälle klassifiziert.`); + } + + return { + seriesLike, + confidence, + reasons + }; +} + +function parseHandBrakeScanText(rawOutput) { + const lines = String(rawOutput || '').split(/\r?\n/); + const parsed = { + source: 'handbrake_scan_text', + generatedAt: nowIso(), + discTitle: null, + discSerial: null, + titleCount: 0, + rawLineCount: lines.length, + titles: [] + }; + + let currentTitle = null; + const ensureCurrentTitle = (index) => { + const normalizedIndex = Number(index); + if (!Number.isFinite(normalizedIndex) || normalizedIndex <= 0) { + return null; + } + if (currentTitle && Number(currentTitle.index) === normalizedIndex) { + return currentTitle; + } + const existing = parsed.titles.find((title) => Number(title.index) === normalizedIndex); + if (existing) { + currentTitle = existing; + return currentTitle; + } + currentTitle = { + index: normalizedIndex, + durationLabel: null, + durationSeconds: 0, + chapterCount: 0, + chapterDurationsMs: [], + audioTracks: [], + subtitleTracks: [], + aspectRatio: null, + flags: [] + }; + parsed.titles.push(currentTitle); + return currentTitle; + }; + + for (const line of lines) { + let match = line.match(/libdvdnav:\s+DVD Title:\s+(.+)\s*$/i); + if (match) { + parsed.discTitle = String(match[1] || '').trim() || null; + continue; + } + + match = line.match(/libdvdnav:\s+DVD Serial Number:\s+(.+)\s*$/i); + if (match) { + parsed.discSerial = String(match[1] || '').trim() || null; + continue; + } + + match = line.match(/scan:\s+DVD has\s+(\d+)\s+title/i); + if (match) { + parsed.titleCount = Number(match[1] || 0); + continue; + } + + match = line.match(/scan:\s+scanning title\s+(\d+)/i); + if (match) { + ensureCurrentTitle(match[1]); + continue; + } + + match = line.match(/scan:\s+duration is\s+(\d{1,2}:\d{2}:\d{2})/i); + if (match && currentTitle) { + currentTitle.durationLabel = match[1]; + currentTitle.durationSeconds = parseDurationToSeconds(match[1]); + continue; + } + + match = line.match(/scan:\s+title\s+(\d+)\s+has\s+(\d+)\s+chapters/i); + if (match) { + const title = ensureCurrentTitle(match[1]); + if (title) { + title.chapterCount = Number(match[2] || 0); + } + continue; + } + + match = line.match(/scan:\s+chap\s+\d+,\s+(\d+)\s+ms/i); + if (match && currentTitle) { + currentTitle.chapterDurationsMs.push(Number(match[1] || 0)); + continue; + } + + match = line.match(/scan:\s+id=0x[0-9a-f]+,\s+lang=(.+?)\s+\([^)]+\),\s+3cc=([a-z]{3})/i); + if (match && currentTitle) { + const track = { + languageLabel: String(match[1] || '').trim() || null, + languageCode: normalizeLanguageCode(match[2], match[1]) + }; + if (/\[VOBSUB\]/i.test(line)) { + currentTitle.subtitleTracks.push(track); + } else { + currentTitle.audioTracks.push(track); + } + continue; + } + + match = line.match(/scan:\s+aspect\s+=\s+(.+)$/i); + if (match && currentTitle) { + currentTitle.aspectRatio = String(match[1] || '').trim() || null; + continue; + } + + match = line.match(/scan:\s+ignoring title \(too short\)/i); + if (match && currentTitle) { + currentTitle.flags.push('ignored_too_short'); + } + } + + parsed.titles.sort((left, right) => Number(left.index || 0) - Number(right.index || 0)); + if (!parsed.titleCount) { + parsed.titleCount = parsed.titles.length; + } + return parsed; +} + +function analyzeParsedScan(parsedScan = {}, options = {}) { + const titles = (Array.isArray(parsedScan.titles) ? parsedScan.titles : []).map(normalizeTitleRecord); + const cluster = buildBestEpisodeCluster(titles, options); + const classifiedTitles = classifyTitles(titles, cluster, options); + const summary = summarizeSeriesLikelihood(classifiedTitles, cluster); + + return { + source: parsedScan.source || 'handbrake_scan_text', + generatedAt: nowIso(), + discTitle: parsedScan.discTitle || null, + discSerial: parsedScan.discSerial || null, + titleCount: Number(parsedScan.titleCount || titles.length || 0), + discSignature: buildDiscSignature({ + discTitle: parsedScan.discTitle, + discSerial: parsedScan.discSerial, + titleCount: parsedScan.titleCount, + titles + }), + summary: { + ...summary, + titleCount: classifiedTitles.length, + episodeCandidateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length, + playAllCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length, + duplicateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length, + extraCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length, + typicalEpisodeMinutes: cluster.medianDurationSeconds + ? Number((cluster.medianDurationSeconds / 60).toFixed(2)) + : 0 + }, + titles: classifiedTitles + }; +} + +function analyzeHandBrakeScan(rawOutput, options = {}) { + const parsed = parseHandBrakeScanText(rawOutput); + const analysis = analyzeParsedScan(parsed, options); + return { + parsed, + analysis + }; +} + +module.exports = { + TITLE_KIND, + parseHandBrakeScanText, + analyzeParsedScan, + analyzeHandBrakeScan, + deriveSeriesLookupHint +}; diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 9245708..efda175 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -2,8 +2,10 @@ const { getDb } = require('../db/database'); const logger = require('./logger').child('HISTORY'); const fs = require('fs'); const path = require('path'); +const { defaultConverterRawDir } = require('../config'); const settingsService = require('./settingsService'); const omdbService = require('./omdbService'); +const tmdbService = require('./tmdbService'); const cdRipService = require('./cdRipService'); const { getJobLogDir } = require('./logPathService'); const thumbnailService = require('./thumbnailService'); @@ -211,19 +213,81 @@ function hasAudiobookStructure(rawPath) { } } -function detectOrphanMediaType(rawPath) { +function hasNestedMediaStructure(rawPath, detector, maxDepth = 2) { + const startPath = String(rawPath || '').trim(); + if (!startPath || typeof detector !== 'function') { + return false; + } + + const normalizedMaxDepth = Number.isFinite(Number(maxDepth)) + ? Math.max(0, Math.trunc(Number(maxDepth))) + : 0; + const queue = [{ currentPath: startPath, depth: 0 }]; + const visited = new Set(); + + while (queue.length > 0) { + const next = queue.shift(); + const currentPath = String(next?.currentPath || '').trim(); + const depth = Number(next?.depth || 0); + if (!currentPath) { + continue; + } + const normalizedPath = normalizeComparablePath(currentPath); + if (!normalizedPath || visited.has(normalizedPath)) { + continue; + } + visited.add(normalizedPath); + + if (detector(currentPath)) { + return true; + } + if (depth >= normalizedMaxDepth) { + continue; + } + + try { + const entries = fs.readdirSync(currentPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry?.isDirectory?.() || isHiddenDirectoryName(entry.name)) { + continue; + } + queue.push({ + currentPath: path.join(currentPath, entry.name), + depth: depth + 1 + }); + } + } catch (_error) { + // ignore fs errors while traversing nested structures + } + } + + return false; +} + +function detectOrphanMediaType(rawPath, options = {}) { if (hasBlurayStructure(rawPath)) { return 'bluray'; } if (hasDvdStructure(rawPath)) { return 'dvd'; } + // Some RAW folders contain one or two nested disc directories (e.g. ".../Rip_Complete_XYZ/.../VIDEO_TS"). + // Inspect nested folders as fallback so these imports are not classified as "other". + if (hasNestedMediaStructure(rawPath, hasBlurayStructure, 2)) { + return 'bluray'; + } + if (hasNestedMediaStructure(rawPath, hasDvdStructure, 2)) { + return 'dvd'; + } if (hasCdStructure(rawPath)) { return 'cd'; } if (hasAudiobookStructure(rawPath)) { return 'audiobook'; } + if (options?.seriesRawPathHint) { + return 'dvd'; + } return 'other'; } @@ -507,8 +571,14 @@ function toProcessLogStreamKey(jobId) { function resolveEffectiveRawPath(storedPath, rawDir, extraDirs = []) { const stored = String(storedPath || '').trim(); if (!stored) return stored; - const folderName = path.basename(stored); - if (!folderName) return stored; + const baseFolderName = path.basename(stored); + if (!baseFolderName) return stored; + const folderNames = [baseFolderName]; + const stripped = stripRawFolderStatePrefix(baseFolderName); + if (stripped) { + folderNames.push(applyRawFolderPrefix(stripped, RAW_RIP_COMPLETE_PREFIX)); + folderNames.push(applyRawFolderPrefix(stripped, RAW_INCOMPLETE_PREFIX)); + } const candidates = []; const seen = new Set(); @@ -526,11 +596,18 @@ function resolveEffectiveRawPath(storedPath, rawDir, extraDirs = []) { }; pushCandidate(stored); - if (rawDir) { - pushCandidate(path.join(String(rawDir).trim(), folderName)); - } - for (const extraDir of Array.isArray(extraDirs) ? extraDirs : []) { - pushCandidate(path.join(String(extraDir || '').trim(), folderName)); + const storedParent = path.dirname(stored); + for (const name of folderNames) { + if (!name) continue; + if (storedParent && storedParent !== '.') { + pushCandidate(path.join(storedParent, name)); + } + if (rawDir) { + pushCandidate(path.join(String(rawDir).trim(), name)); + } + for (const extraDir of Array.isArray(extraDirs) ? extraDirs : []) { + pushCandidate(path.join(String(extraDir || '').trim(), name)); + } } for (const candidate of candidates) { @@ -543,7 +620,7 @@ function resolveEffectiveRawPath(storedPath, rawDir, extraDirs = []) { } } - return rawDir ? path.join(String(rawDir).trim(), folderName) : stored; + return rawDir ? path.join(String(rawDir).trim(), baseFolderName) : stored; } function resolveEffectiveOutputPath(storedPath, movieDir) { @@ -578,6 +655,93 @@ function getConfiguredMediaPathList(settings = {}, baseKey) { return unique; } +function getSeriesRawPathCandidates(settings = {}) { + const source = settings && typeof settings === 'object' ? settings : {}; + const unique = []; + const seen = new Set(); + const pushPath = (candidate) => { + const rawPath = String(candidate || '').trim(); + if (!rawPath) { + return; + } + const normalized = normalizeComparablePath(rawPath); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + unique.push(normalized); + }; + + for (const candidate of getConfiguredMediaPathList(source, 'series_raw_dir')) { + pushPath(candidate); + } + pushPath(source.raw_dir_dvd_series); + + const dvdEffective = settingsService.resolveEffectiveToolSettings(source, 'dvd') || {}; + pushPath(dvdEffective.series_raw_dir); + + return unique; +} + +function isLikelySeriesRawPath(rawPath, settings = {}) { + const normalizedRawPath = normalizeComparablePath(rawPath); + if (!normalizedRawPath) { + return false; + } + + const seriesRoots = getSeriesRawPathCandidates(settings); + if (seriesRoots.some((candidate) => isPathInside(candidate, normalizedRawPath))) { + return true; + } + + // Fallback for common default layouts when series raw dir is not explicitly configured. + return /(^|\/)raw\/dvd\/series(\/|$)/i.test(normalizedRawPath); +} + +function getOrphanRawScanPathList(settings = {}) { + const source = settings && typeof settings === 'object' ? settings : {}; + const unique = []; + const seen = new Set(); + const pushPath = (candidate) => { + const rawPath = String(candidate || '').trim(); + if (!rawPath) { + return; + } + const normalized = normalizeComparablePath(rawPath); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + unique.push(normalized); + }; + + // Explicitly configured RAW roots (legacy + profiled keys) + for (const candidate of getConfiguredMediaPathList(source, 'raw_dir')) { + pushPath(candidate); + } + for (const candidate of getConfiguredMediaPathList(source, 'series_raw_dir')) { + pushPath(candidate); + } + pushPath(source.raw_dir_dvd_series); + pushPath(source.converter_raw_dir); + + // Effective settings roots per profile (covers fallback behavior in settings service) + for (const profile of ['bluray', 'dvd', 'cd', 'audiobook']) { + const effective = settingsService.resolveEffectiveToolSettings(source, profile) || {}; + pushPath(effective.raw_dir); + if (profile === 'dvd') { + pushPath(effective.series_raw_dir); + } + } + + // Hard defaults (even when no explicit setting exists yet) + pushPath(settingsService.DEFAULT_RAW_DIR); + pushPath(settingsService.DEFAULT_AUDIOBOOK_RAW_DIR); + pushPath(defaultConverterRawDir); + + return unique; +} + function isDirectoryLikeOutput(mediaType, encodePlan = null, handbrakeInfo = null) { if (mediaType === 'cd') { return true; @@ -600,6 +764,30 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = const handbrakeInfo = parsed?.handbrakeInfo || parseJsonSafe(job?.handbrake_info_json, null); const mediaType = inferMediaType(job, mkInfo, miInfo, plan, handbrakeInfo); const directoryLikeOutput = isDirectoryLikeOutput(mediaType, plan, handbrakeInfo); + const selectedMetadata = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {}; + const metadataKind = String(selectedMetadata?.metadataKind || '').trim().toLowerCase(); + const normalizedJobKind = String(job?.job_kind || '').trim().toLowerCase(); + const hasSeriesJobKind = normalizedJobKind === 'dvd_series_child' || normalizedJobKind === 'dvd_series_container'; + const hasSeriesParent = normalizeJobIdValue(job?.parent_job_id) !== null; + const hasSeriesRawPathHint = isLikelySeriesRawPath(job?.raw_path, settings || {}); + const hasSeriesMetadataHint = hasSeriesMetadataSignals( + selectedMetadata, + mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' ? mkInfo.analyzeContext : {} + ); + const seriesSignals = ( + Number(selectedMetadata?.tmdbId || 0) > 0 + || Number(selectedMetadata?.seasonNumber || 0) > 0 + || (Array.isArray(selectedMetadata?.episodes) && selectedMetadata.episodes.length > 0) + || Number(selectedMetadata?.episodeCount || 0) > 0 + || ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind) + ); + const isSeriesDvd = mediaType === 'dvd' && ( + seriesSignals + || hasSeriesMetadataHint + || hasSeriesJobKind + || hasSeriesParent + || hasSeriesRawPathHint + ); // Converter jobs use their own storage dirs — do not remap output path if (mediaType === 'converter') { @@ -624,15 +812,33 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed = } const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType); - const rawDir = String(effectiveSettings?.raw_dir || '').trim(); - const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim(); + const seriesRawDir = String(effectiveSettings?.series_raw_dir || '').trim(); + const seriesMovieDir = String(effectiveSettings?.series_dir || '').trim(); + const defaultRawDir = String(effectiveSettings?.raw_dir || '').trim(); + let rawDir = isSeriesDvd ? (seriesRawDir || defaultRawDir) : defaultRawDir; + if (isSeriesDvd) { + const normalizedStoredRawPath = normalizeComparablePath(job?.raw_path); + if (normalizedStoredRawPath) { + const seriesRawCandidates = getSeriesRawPathCandidates(settings || {}); + const matchedSeriesRawDir = seriesRawCandidates.find((candidate) => isPathInside(candidate, normalizedStoredRawPath)); + if (matchedSeriesRawDir) { + rawDir = matchedSeriesRawDir; + } + } + } + const configuredMovieDir = isSeriesDvd + ? (seriesMovieDir || String(effectiveSettings?.movie_dir || '').trim()) + : String(effectiveSettings?.movie_dir || '').trim(); const movieDir = configuredMovieDir || rawDir; - const rawLookupDirs = getConfiguredMediaPathList(settings || {}, 'raw_dir') + const rawLookupDirsBase = isSeriesDvd + ? getSeriesRawPathCandidates(settings || {}) + : getConfiguredMediaPathList(settings || {}, 'raw_dir'); + const rawLookupDirs = rawLookupDirsBase .filter((candidate) => normalizeComparablePath(candidate) !== normalizeComparablePath(rawDir)); const effectiveRawPath = job?.raw_path ? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs) : (job?.raw_path || null); - const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path) + const effectiveOutputPath = (!directoryLikeOutput && configuredMovieDir && job?.output_path && !isSeriesDvd) ? resolveEffectiveOutputPath(job.output_path, configuredMovieDir) : (job?.output_path || null); @@ -917,6 +1123,203 @@ function parseRawFolderMetadata(folderName) { }; } +function normalizePositiveNumberOrNull(value) { + const raw = String(value ?? '').trim(); + if (!raw) { + return null; + } + const parsed = Number(raw.replace(',', '.')); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +} + +function normalizePositiveIntegerOrNull(value) { + const parsed = normalizePositiveNumberOrNull(value); + if (parsed === null) { + return null; + } + return Math.trunc(parsed); +} + +function extractSeasonNumberFromText(value) { + const text = String(value || '').trim(); + if (!text) { + return null; + } + + const directPattern = text.match(/(?:staffel|season|serie|series|s)\s*[-_.:]?\s*(\d{1,3}(?:[.,]\d+)?)/i); + if (directPattern?.[1]) { + return normalizePositiveNumberOrNull(directPattern[1]); + } + + const tokenPattern = text.match(/(?:^|[\s._-])s(\d{1,3}(?:[.,]\d+)?)(?:$|[\s._-])/i); + if (tokenPattern?.[1]) { + return normalizePositiveNumberOrNull(tokenPattern[1]); + } + + return null; +} + +function extractDiscNumberFromText(value) { + const text = String(value || '').trim(); + if (!text) { + return null; + } + + const directPattern = text.match(/(?:disc|disk|dvd|cd)\s*[-_.:]?\s*(\d{1,2})/i); + if (directPattern?.[1]) { + return normalizePositiveIntegerOrNull(directPattern[1]); + } + + const tokenPattern = text.match(/(?:^|[\s._-])d(?:isc)?[-_.:]?\s*(\d{1,2})(?:$|[\s._-])/i); + if (tokenPattern?.[1]) { + return normalizePositiveIntegerOrNull(tokenPattern[1]); + } + + return null; +} + +function extractSelectedMetadataFromMakemkvInfo(makemkvInfo = {}) { + const source = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}; + const analyzeContext = source.analyzeContext && typeof source.analyzeContext === 'object' + ? source.analyzeContext + : {}; + const analyzeSelected = analyzeContext.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' + ? analyzeContext.selectedMetadata + : {}; + const topLevelSelected = source.selectedMetadata && typeof source.selectedMetadata === 'object' + ? source.selectedMetadata + : {}; + const merged = { + ...analyzeSelected, + ...topLevelSelected + }; + + if (!merged.metadataProvider && analyzeContext.metadataProvider) { + merged.metadataProvider = analyzeContext.metadataProvider; + } + if (!merged.metadataKind && analyzeContext.metadataKind) { + merged.metadataKind = analyzeContext.metadataKind; + } + + return merged; +} + +function hasSeriesMetadataSignals(selectedMetadata = {}, analyzeContext = {}) { + const metadata = selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}; + const analyze = analyzeContext && typeof analyzeContext === 'object' ? analyzeContext : {}; + const metadataKind = String(metadata.metadataKind || analyze.metadataKind || '').trim().toLowerCase(); + const seasonNumber = normalizePositiveNumberOrNull( + metadata.seasonNumber ?? analyze?.seriesLookupHint?.seasonNumber ?? null + ); + const seasonName = String(metadata.seasonName || '').trim(); + const episodeCount = Number(metadata.episodeCount || 0) || 0; + const hasEpisodeList = Array.isArray(metadata.episodes) && metadata.episodes.length > 0; + const tmdbId = normalizePositiveIntegerOrNull( + metadata.tmdbId ?? analyze.tmdbId ?? metadata.providerId ?? analyze.providerId ?? null + ); + const provider = String( + metadata.metadataProvider + || analyze.metadataProvider + || '' + ).trim().toLowerCase(); + const providerIsTmdb = provider === 'tmdb' || provider === 'themoviedb'; + const seriesKind = ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind); + + if (seriesKind || seasonNumber !== null || Boolean(seasonName) || episodeCount > 0 || hasEpisodeList || tmdbId !== null) { + return true; + } + return providerIsTmdb && Boolean(String(analyze?.seriesLookupHint?.query || '').trim()); +} + +function buildSeriesImportHints({ + folderName = '', + rawPath = '', + metadata = {}, + sourceSelectedMetadata = {}, + sourceAnalyzeContext = {} +} = {}) { + const safeMetadata = metadata && typeof metadata === 'object' ? metadata : {}; + const selected = sourceSelectedMetadata && typeof sourceSelectedMetadata === 'object' + ? sourceSelectedMetadata + : {}; + const analyze = sourceAnalyzeContext && typeof sourceAnalyzeContext === 'object' + ? sourceAnalyzeContext + : {}; + const titleSource = String( + selected.title + || selected.seriesTitle + || safeMetadata.title + || '' + ).trim(); + const combinedText = [folderName, path.basename(String(rawPath || '').trim())] + .filter(Boolean) + .join(' '); + const seasonNumber = normalizePositiveNumberOrNull( + selected.seasonNumber + ?? analyze?.seriesLookupHint?.seasonNumber + ?? extractSeasonNumberFromText(combinedText) + ); + const discNumber = normalizePositiveIntegerOrNull( + selected.discNumber + ?? analyze?.seriesLookupHint?.discNumber + ?? extractDiscNumberFromText(combinedText) + ); + + const seriesLookupHint = { + query: titleSource || null, + seasonNumber, + discNumber + }; + const metadataHasSeriesSignals = hasSeriesMetadataSignals(selected, analyze); + if (!metadataHasSeriesSignals && !seriesLookupHint.query && seriesLookupHint.seasonNumber === null && seriesLookupHint.discNumber === null) { + return null; + } + + return { + selectedMetadata: { + ...selected, + metadataProvider: String(selected.metadataProvider || analyze.metadataProvider || 'tmdb').trim().toLowerCase() || 'tmdb', + metadataKind: String(selected.metadataKind || analyze.metadataKind || 'series').trim().toLowerCase() || 'series', + title: titleSource || null, + seasonNumber, + discNumber + }, + analyzeContextPatch: { + metadataProvider: String( + analyze.metadataProvider + || selected.metadataProvider + || 'tmdb' + ).trim().toLowerCase() || 'tmdb', + metadataKind: String( + analyze.metadataKind + || selected.metadataKind + || 'series' + ).trim().toLowerCase() || 'series', + seriesLookupHint: { + ...(analyze.seriesLookupHint && typeof analyze.seriesLookupHint === 'object' + ? analyze.seriesLookupHint + : {}), + ...seriesLookupHint + }, + seriesAnalysis: { + ...(analyze.seriesAnalysis && typeof analyze.seriesAnalysis === 'object' + ? analyze.seriesAnalysis + : {}), + summary: { + ...(analyze?.seriesAnalysis?.summary && typeof analyze.seriesAnalysis.summary === 'object' + ? analyze.seriesAnalysis.summary + : {}), + seriesLike: true, + confidence: analyze?.seriesAnalysis?.summary?.confidence || 'medium' + } + } + } + }; +} + function buildRawPathForJobId(rawPath, jobId) { const normalizedJobId = Number(jobId); if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { @@ -1993,7 +2396,7 @@ class HistoryService { await db.run(`UPDATE jobs SET ${fields.join(', ')} WHERE id = ?`, values); logger.debug('job:updated', { jobId, patchKeys: Object.keys(patch) }); - return this.getJobById(jobId); + return this.getJobById(jobId, { skipRepair: true }); } async updateJobStatus(jobId, status, extra = {}) { @@ -2482,6 +2885,32 @@ class HistoryService { const recovery = options.recovery && typeof options.recovery === 'object' ? options.recovery : null; + const analyzeContextPatch = options.analyzeContextPatch && typeof options.analyzeContextPatch === 'object' + ? options.analyzeContextPatch + : {}; + const providedSelectedMetadata = options.selectedMetadata && typeof options.selectedMetadata === 'object' + ? options.selectedMetadata + : {}; + const compactMetadataObject = (input) => Object.fromEntries( + Object.entries(input || {}).filter(([, value]) => { + if (value === null || value === undefined) { + return false; + } + if (typeof value === 'string') { + return value.trim().length > 0; + } + return true; + }) + ); + const existingSelectedMetadata = compactMetadataObject(extractSelectedMetadataFromMakemkvInfo(existingInfo)); + const normalizedProvidedSelectedMetadata = compactMetadataObject(providedSelectedMetadata); + const mergedSelectedMetadata = { + ...existingSelectedMetadata, + ...normalizedProvidedSelectedMetadata + }; + const existingAnalyzeContext = existingInfo.analyzeContext && typeof existingInfo.analyzeContext === 'object' + ? existingInfo.analyzeContext + : {}; const previousImportContext = existingInfo.importContext && typeof existingInfo.importContext === 'object' ? existingInfo.importContext : {}; @@ -2510,36 +2939,47 @@ class HistoryService { rawPath, mediaProfile, analyzeContext: { - ...(existingInfo.analyzeContext && typeof existingInfo.analyzeContext === 'object' - ? existingInfo.analyzeContext - : {}), + ...existingAnalyzeContext, + ...analyzeContextPatch, mediaProfile }, importContext: nextImportContext }; + if (Object.keys(mergedSelectedMetadata).length > 0) { + nextInfo.selectedMetadata = mergedSelectedMetadata; + nextInfo.analyzeContext = { + ...nextInfo.analyzeContext, + selectedMetadata: { + ...(existingAnalyzeContext.selectedMetadata && typeof existingAnalyzeContext.selectedMetadata === 'object' + ? existingAnalyzeContext.selectedMetadata + : {}), + ...mergedSelectedMetadata + } + }; + } + if (mediaProfile === 'cd' && recovery) { if (Array.isArray(recovery.tracks) && recovery.tracks.length > 0) { nextInfo.tracks = recovery.tracks; } if (recovery.selectedMetadata && typeof recovery.selectedMetadata === 'object') { - const recoverySelectedMetadata = Object.fromEntries( - Object.entries(recovery.selectedMetadata).filter(([, value]) => { - if (value === null || value === undefined) { - return false; - } - if (typeof value === 'string') { - return value.trim().length > 0; - } - return true; - }) - ); + const recoverySelectedMetadata = compactMetadataObject(recovery.selectedMetadata); nextInfo.selectedMetadata = { - ...(existingInfo.selectedMetadata && typeof existingInfo.selectedMetadata === 'object' - ? existingInfo.selectedMetadata + ...(nextInfo.selectedMetadata && typeof nextInfo.selectedMetadata === 'object' + ? nextInfo.selectedMetadata : {}), ...recoverySelectedMetadata }; + nextInfo.analyzeContext = { + ...nextInfo.analyzeContext, + selectedMetadata: { + ...(nextInfo.analyzeContext?.selectedMetadata && typeof nextInfo.analyzeContext.selectedMetadata === 'object' + ? nextInfo.analyzeContext.selectedMetadata + : {}), + ...nextInfo.selectedMetadata + } + }; } nextInfo.cdparanoiaCmd = String( recovery.cdparanoiaCmd @@ -2689,12 +3129,13 @@ class HistoryService { } const makemkvInfo = parseJsonSafe(row.makemkv_info_json, {}); - const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' - ? makemkvInfo.selectedMetadata + const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(makemkvInfo); + const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object' + ? makemkvInfo.analyzeContext : {}; const rowMediaProfile = normalizeMediaTypeValue( - makemkvInfo?.mediaProfile - || makemkvInfo?.analyzeContext?.mediaProfile + makemkvInfo?.analyzeContext?.mediaProfile + || makemkvInfo?.mediaProfile || inferMediaTypeFromJobKind(row?.job_kind) || row?.media_type ); @@ -2717,6 +3158,8 @@ class HistoryService { const hasExternalMetadata = Boolean( row?.omdb_json || row?.imdb_id + || selectedMetadata?.tmdbId + || analyzeContext?.tmdbId || selectedMetadata?.mbId || selectedMetadata?.musicBrainzId || selectedMetadata?.musicbrainzId @@ -2753,6 +3196,7 @@ class HistoryService { job: row, makemkvInfo, selectedMetadata, + analyzeContext, mediaProfile: rowMediaProfile, posterCandidate }; @@ -2990,9 +3434,111 @@ class HistoryService { return updatedJob; } - async getJobById(jobId) { + async repairImportedOrphanJobClassification(job, settings = null) { + if (!job || typeof job !== 'object') { + return job; + } + + const makemkvInfo = parseJsonSafe(job.makemkv_info_json, null); + if (String(makemkvInfo?.source || '').trim().toLowerCase() !== 'orphan_raw_import') { + return job; + } + + const settingsMap = settings && typeof settings === 'object' + ? settings + : {}; + const resolvedRawPath = String( + job?.raw_path + || makemkvInfo?.rawPath + || '' + ).trim() || null; + const folderMeta = parseRawFolderMetadata(path.basename(String(resolvedRawPath || '').trim())); + const seriesRawPathHint = isLikelySeriesRawPath(resolvedRawPath, settingsMap); + const detectedMediaType = detectOrphanMediaType(resolvedRawPath, { seriesRawPathHint }); + const mkMediaType = normalizeMediaTypeValue( + makemkvInfo?.analyzeContext?.mediaProfile + || makemkvInfo?.mediaProfile + || inferMediaTypeFromJobKind(job?.job_kind) + || job?.media_type + ); + const effectiveMediaType = detectedMediaType === 'other' + ? (mkMediaType || (seriesRawPathHint ? 'dvd' : 'other')) + : detectedMediaType; + const patch = {}; + + if (['bluray', 'dvd', 'cd', 'audiobook'].includes(effectiveMediaType)) { + if (normalizeMediaTypeValue(job?.media_type) !== effectiveMediaType) { + patch.media_type = effectiveMediaType; + } + if (normalizeJobKindValue(job?.job_kind) !== effectiveMediaType) { + patch.job_kind = effectiveMediaType; + } + } + + let nextMakemkvInfo = makemkvInfo && typeof makemkvInfo === 'object' + ? makemkvInfo + : {}; + const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(nextMakemkvInfo); + const analyzeContext = nextMakemkvInfo?.analyzeContext && typeof nextMakemkvInfo.analyzeContext === 'object' + ? nextMakemkvInfo.analyzeContext + : {}; + const hasSeriesSignals = hasSeriesMetadataSignals(selectedMetadata, analyzeContext); + + if (effectiveMediaType === 'dvd' && seriesRawPathHint && !hasSeriesSignals) { + const seriesImportHints = buildSeriesImportHints({ + folderName: path.basename(String(resolvedRawPath || '').trim()), + rawPath: resolvedRawPath, + metadata: folderMeta, + sourceSelectedMetadata: selectedMetadata, + sourceAnalyzeContext: analyzeContext + }); + if (seriesImportHints) { + nextMakemkvInfo = this.buildOrphanRawImportMakemkvInfo({ + importedAt: nextMakemkvInfo?.importedAt || job?.end_time || new Date().toISOString(), + rawPath: resolvedRawPath, + requestedRawPath: nextMakemkvInfo?.importContext?.requestedRawPath || resolvedRawPath, + sourceFolderJobId: nextMakemkvInfo?.importContext?.sourceFolderJobId || folderMeta.folderJobId || null, + mediaProfile: effectiveMediaType, + existingInfo: nextMakemkvInfo, + selectedMetadata: seriesImportHints.selectedMetadata, + analyzeContextPatch: seriesImportHints.analyzeContextPatch + }); + } + } else if ( + effectiveMediaType !== 'other' + && normalizeMediaTypeValue(nextMakemkvInfo?.analyzeContext?.mediaProfile || nextMakemkvInfo?.mediaProfile) !== effectiveMediaType + ) { + nextMakemkvInfo = this.buildOrphanRawImportMakemkvInfo({ + importedAt: nextMakemkvInfo?.importedAt || job?.end_time || new Date().toISOString(), + rawPath: resolvedRawPath, + requestedRawPath: nextMakemkvInfo?.importContext?.requestedRawPath || resolvedRawPath, + sourceFolderJobId: nextMakemkvInfo?.importContext?.sourceFolderJobId || folderMeta.folderJobId || null, + mediaProfile: effectiveMediaType, + existingInfo: nextMakemkvInfo + }); + } + + const nextMakemkvInfoJson = JSON.stringify(nextMakemkvInfo || {}); + if (nextMakemkvInfoJson !== String(job?.makemkv_info_json || '')) { + patch.makemkv_info_json = nextMakemkvInfoJson; + } + + if (Object.keys(patch).length === 0) { + return job; + } + return this.updateJob(job.id, patch); + } + + async getJobById(jobId, options = {}) { const db = await getDb(); - return db.get('SELECT * FROM jobs WHERE id = ?', [jobId]); + const row = await db.get('SELECT * FROM jobs WHERE id = ?', [jobId]); + if (!row || options?.skipRepair) { + return row; + } + const settings = options?.settings && typeof options.settings === 'object' + ? options.settings + : await settingsService.getSettingsMap(); + return this.repairImportedOrphanJobClassification(row, settings); } async getJobs(filters = {}) { @@ -3000,6 +3546,7 @@ class HistoryService { const where = []; const values = []; const includeFsChecks = filters?.includeFsChecks !== false; + const includeChildren = filters?.includeChildren === true; const rawStatuses = Array.isArray(filters?.statuses) ? filters.statuses : (typeof filters?.statuses === 'string' @@ -3027,6 +3574,10 @@ class HistoryService { values.push(`%${filters.search}%`, `%${filters.search}%`, `%${filters.search}%`, `%${filters.search}%`); } + if (!includeChildren) { + where.push(`parent_job_id IS NULL`); + } + const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; const [jobs, settings] = await Promise.all([ @@ -3043,10 +3594,262 @@ class HistoryService { settingsService.getSettingsMap() ]); - return jobs.map((job) => ({ - ...enrichJobRow(job, settings, { includeFsChecks }), - log_count: includeFsChecks ? (hasProcessLogFile(job.id) ? 1 : 0) : 0 - })); + const repairedJobs = await Promise.all( + jobs.map((job) => this.repairImportedOrphanJobClassification(job, settings)) + ); + const derivedParentIds = new Set( + repairedJobs + .map((job) => normalizeJobIdValue(job?.parent_job_id)) + .filter(Boolean) + ); + const adjustedJobs = repairedJobs.map((job) => { + const normalizedId = normalizeJobIdValue(job?.id); + const hasParent = normalizeJobIdValue(job?.parent_job_id) !== null; + const jobKind = String(job?.job_kind || '').trim().toLowerCase(); + if (hasParent && !jobKind) { + return { ...job, job_kind: 'dvd_series_child' }; + } + if (!hasParent && normalizedId && derivedParentIds.has(normalizedId) && !jobKind) { + return { ...job, job_kind: 'dvd_series_container' }; + } + return job; + }); + + const containerJobs = adjustedJobs.filter((job) => String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'); + const containerIds = containerJobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean); + const seriesCandidates = adjustedJobs.filter((job) => { + const mkInfo = parseJsonSafe(job?.makemkv_info_json, {}); + const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' + ? mkInfo.analyzeContext + : {}; + const selected = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {}; + const tmdbId = Number(selected?.tmdbId || 0) || null; + const seasonNumber = Number(selected?.seasonNumber || 0) || null; + return (tmdbId && seasonNumber) || hasSeriesMetadataSignals(selected, analyzeContext); + }); + const seriesCandidateIds = seriesCandidates.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean); + let childRows = []; + let outputFolders = []; + if (containerIds.length > 0) { + const placeholders = containerIds.map(() => '?').join(', '); + childRows = await db.all( + `SELECT id, parent_job_id, output_path, raw_path, handbrake_info_json, status, encode_plan_json, makemkv_info_json, rip_successful FROM jobs WHERE parent_job_id IN (${placeholders})`, + containerIds + ); + const childIds = childRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean); + if (childIds.length > 0) { + const childPlaceholders = childIds.map(() => '?').join(', '); + outputFolders = await db.all( + `SELECT job_id, output_path FROM job_output_folders WHERE job_id IN (${childPlaceholders})`, + childIds + ); + } + } + + const childOutputMap = new Map(); + for (const child of childRows) { + const childId = normalizeJobIdValue(child?.id); + if (!childId) { + continue; + } + if (!childOutputMap.has(childId)) { + childOutputMap.set(childId, new Set()); + } + const childSet = childOutputMap.get(childId); + const directOutput = String(child?.output_path || '').trim(); + if (directOutput) { + childSet.add(directOutput); + } + } + for (const folder of outputFolders) { + const childId = normalizeJobIdValue(folder?.job_id); + if (!childId) { + continue; + } + if (!childOutputMap.has(childId)) { + childOutputMap.set(childId, new Set()); + } + const outputPath = String(folder?.output_path || '').trim(); + if (outputPath) { + childOutputMap.get(childId).add(outputPath); + } + } + + const containerOutputSummary = new Map(); + const containerRawSummary = new Map(); + const containerEncodeSummary = new Map(); + const containerChildSummary = new Map(); + for (const container of containerJobs) { + const containerId = normalizeJobIdValue(container?.id); + if (!containerId) { + continue; + } + const mkInfo = parseJsonSafe(container?.makemkv_info_json, {}); + const selectedMetadata = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {}; + const episodeCount = Number(selectedMetadata?.episodeCount || 0); + const episodesLength = Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes.length : 0; + const expectedTotal = episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0); + const childIds = childRows + .filter((row) => normalizeJobIdValue(row?.parent_job_id) === containerId) + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean); + let rawExistsAny = false; + let encodeSuccessAny = false; + let rawExistsCount = 0; + let backupSuccessCount = 0; + let encodeSuccessCount = 0; + let expectedFromPlans = 0; + const seenOutputs = new Set(); + let existingCount = 0; + const totalChildren = childIds.length; + for (const childId of childIds) { + const childRow = childRows.find((row) => normalizeJobIdValue(row?.id) === childId) || null; + if (childRow) { + const rawPath = String(childRow?.raw_path || '').trim(); + if (rawPath) { + const resolvedChildPaths = resolveEffectiveStoragePathsForJob(settings, childRow); + const rawStatus = includeFsChecks + ? inspectDirectory(resolvedChildPaths.effectiveRawPath) + : buildUnknownDirectoryStatus(resolvedChildPaths.effectiveRawPath); + if (rawStatus?.exists) { + rawExistsAny = true; + rawExistsCount += 1; + } + } + const mkInfo = parseJsonSafe(childRow?.makemkv_info_json, null); + const ripSuccessful = Number(childRow?.rip_successful || 0) === 1 + || String(mkInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; + if (ripSuccessful) { + backupSuccessCount += 1; + } + const childPlan = parseJsonSafe(childRow?.encode_plan_json, null); + if (childPlan && typeof childPlan === 'object') { + const titles = Array.isArray(childPlan?.titles) ? childPlan.titles : []; + const selectedCount = titles.filter((title) => title?.selectedForEncode || title?.encodeInput).length; + if (selectedCount > 0) { + expectedFromPlans += selectedCount; + } + } + const handbrakeInfo = parseJsonSafe(childRow?.handbrake_info_json, null); + const hbStatus = String(handbrakeInfo?.status || '').trim().toUpperCase(); + if (hbStatus === 'SUCCESS') { + encodeSuccessAny = true; + encodeSuccessCount += 1; + } + } + const outputs = Array.from(childOutputMap.get(childId) || []); + for (const outputPath of outputs) { + if (!outputPath || seenOutputs.has(outputPath)) { + continue; + } + seenOutputs.add(outputPath); + if (!includeFsChecks || fs.existsSync(outputPath)) { + existingCount += 1; + } + } + } + if (!encodeSuccessAny && existingCount > 0) { + encodeSuccessAny = true; + if (encodeSuccessCount === 0 && totalChildren > 0) { + encodeSuccessCount = Math.min(1, totalChildren); + } + } + const expectedFinal = expectedFromPlans > 0 + ? expectedFromPlans + : (expectedTotal > 0 ? expectedTotal : existingCount); + containerOutputSummary.set(containerId, { + existing: existingCount, + expected: expectedFinal + }); + containerRawSummary.set(containerId, { exists: rawExistsAny }); + containerEncodeSummary.set(containerId, { success: encodeSuccessAny }); + containerChildSummary.set(containerId, { + raw: { existing: rawExistsCount, expected: totalChildren }, + backup: { existing: backupSuccessCount, expected: totalChildren }, + encode: { existing: encodeSuccessCount, expected: totalChildren } + }); + } + + const standaloneSeriesOutputSummary = new Map(); + if (seriesCandidateIds.length > 0) { + const placeholders = seriesCandidateIds.map(() => '?').join(', '); + const seriesOutputRows = await db.all( + `SELECT job_id, output_path FROM job_output_folders WHERE job_id IN (${placeholders})`, + seriesCandidateIds + ); + const outputsByJobId = new Map(); + for (const row of seriesOutputRows) { + const jobId = normalizeJobIdValue(row?.job_id); + if (!jobId) continue; + if (!outputsByJobId.has(jobId)) outputsByJobId.set(jobId, new Set()); + const pathValue = String(row?.output_path || '').trim(); + if (pathValue) outputsByJobId.get(jobId).add(pathValue); + } + for (const job of seriesCandidates) { + const jobId = normalizeJobIdValue(job?.id); + if (!jobId || containerIds.includes(jobId)) { + continue; + } + const mkInfo = parseJsonSafe(job?.makemkv_info_json, {}); + const selected = mkInfo?.analyzeContext?.selectedMetadata || mkInfo?.selectedMetadata || {}; + const episodeCount = Number(selected?.episodeCount || 0); + const episodesLength = Array.isArray(selected?.episodes) ? selected.episodes.length : 0; + const expectedFromMetadata = episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0); + const plan = parseJsonSafe(job?.encode_plan_json, null); + const titles = Array.isArray(plan?.titles) ? plan.titles : []; + const expectedFromPlan = titles.filter((title) => title?.selectedForEncode || title?.encodeInput).length; + const expectedFinal = expectedFromPlan > 0 ? expectedFromPlan : expectedFromMetadata; + const outputSet = outputsByJobId.get(jobId) || new Set(); + const directOutput = String(job?.output_path || '').trim(); + if (directOutput) outputSet.add(directOutput); + let existingCount = 0; + for (const outputPath of outputSet) { + if (!outputPath) continue; + if (!includeFsChecks || fs.existsSync(outputPath)) { + existingCount += 1; + } + } + standaloneSeriesOutputSummary.set(jobId, { + existing: existingCount, + expected: expectedFinal > 0 ? expectedFinal : existingCount + }); + } + } + + return adjustedJobs.map((job) => { + const enriched = enrichJobRow(job, settings, { includeFsChecks }); + const containerId = normalizeJobIdValue(job?.id); + const summary = containerId + ? (containerOutputSummary.get(containerId) || standaloneSeriesOutputSummary.get(containerId) || null) + : (standaloneSeriesOutputSummary.get(containerId) || null); + const rawSummary = containerId ? containerRawSummary.get(containerId) : null; + const encodeSummary = containerId ? containerEncodeSummary.get(containerId) : null; + const childSummary = containerId ? containerChildSummary.get(containerId) : null; + const rawStatus = rawSummary + ? { + ...enriched.rawStatus, + exists: rawSummary.exists, + isDirectory: rawSummary.exists + } + : enriched.rawStatus; + const outputStatus = summary + ? { + ...enriched.outputStatus, + exists: summary.existing > 0, + isFile: summary.existing > 0 + } + : enriched.outputStatus; + const encodeSuccess = encodeSummary ? Boolean(encodeSummary.success) : enriched.encodeSuccess; + return { + ...enriched, + rawStatus, + outputStatus, + encodeSuccess, + ...(childSummary ? { seriesChildSummary: childSummary } : {}), + ...(summary ? { seriesOutputSummary: summary } : {}), + log_count: includeFsChecks ? (hasProcessLogFile(job.id) ? 1 : 0) : 0 + }; + }); } async getJobsByIds(jobIds = []) { @@ -3069,8 +3872,21 @@ class HistoryService { settingsService.getSettingsMap() ]); const byId = new Map(rows.map((row) => [Number(row.id), row])); + const repairedRows = await Promise.all( + ids + .map((id) => byId.get(id)) + .filter(Boolean) + .map((job) => this.repairImportedOrphanJobClassification(job, settings)) + ); + + const repairedById = new Map( + repairedRows + .filter(Boolean) + .map((row) => [Number(row.id), row]) + ); + return ids - .map((id) => byId.get(id)) + .map((id) => repairedById.get(id)) .filter(Boolean) .map((job) => ({ ...enrichJobRow(job, settings), @@ -3078,6 +3894,158 @@ class HistoryService { })); } + async findSeriesContainerJob(tmdbId, seasonNumber) { + const normalizedTmdbId = Number(tmdbId || 0) || null; + const normalizedSeason = Number(seasonNumber || 0) || null; + if (!normalizedTmdbId || !normalizedSeason) { + return null; + } + const db = await getDb(); + const row = await db.get( + ` + SELECT * + FROM jobs + WHERE job_kind = 'dvd_series_container' + AND ( + json_extract(makemkv_info_json, '$.analyzeContext.selectedMetadata.tmdbId') = ? + OR json_extract(makemkv_info_json, '$.selectedMetadata.tmdbId') = ? + ) + AND ( + json_extract(makemkv_info_json, '$.analyzeContext.selectedMetadata.seasonNumber') = ? + OR json_extract(makemkv_info_json, '$.selectedMetadata.seasonNumber') = ? + ) + ORDER BY id DESC + LIMIT 1 + `, + [normalizedTmdbId, normalizedTmdbId, normalizedSeason, normalizedSeason] + ); + return row || null; + } + + async findLikelySeriesContainerJob({ title = null, year = null } = {}) { + const normalizedTitle = normalizeComparableLabel(title || ''); + const normalizedYear = Number.isFinite(Number(year)) && Number(year) > 0 + ? Math.trunc(Number(year)) + : null; + if (!normalizedTitle) { + return null; + } + + const db = await getDb(); + const rows = await db.all( + ` + SELECT * + FROM jobs + WHERE job_kind = 'dvd_series_container' + ORDER BY id DESC + ` + ); + const candidates = []; + for (const row of (Array.isArray(rows) ? rows : [])) { + const mkInfo = parseJsonSafe(row?.makemkv_info_json, {}) || {}; + const selected = extractSelectedMetadataFromMakemkvInfo(mkInfo); + const containerTitle = String( + selected?.title + || selected?.seriesTitle + || row?.title + || row?.detected_title + || '' + ).trim(); + const containerNormalizedTitle = normalizeComparableLabel(containerTitle); + if (!containerNormalizedTitle) { + continue; + } + + let score = 0; + if (containerNormalizedTitle === normalizedTitle) { + score += 10; + } else if (containerNormalizedTitle.includes(normalizedTitle) || normalizedTitle.includes(containerNormalizedTitle)) { + score += 6; + } else { + continue; + } + + const containerYearRaw = Number(selected?.year || row?.year || 0); + const containerYear = Number.isFinite(containerYearRaw) && containerYearRaw > 0 + ? Math.trunc(containerYearRaw) + : null; + if (normalizedYear && containerYear === normalizedYear) { + score += 4; + } else if (normalizedYear && containerYear && Math.abs(containerYear - normalizedYear) <= 1) { + score += 2; + } + + const tmdbId = normalizePositiveIntegerOrNull(selected?.tmdbId || selected?.providerId || null); + const seasonNumber = normalizePositiveIntegerOrNull(selected?.seasonNumber || null); + if (tmdbId && seasonNumber) { + score += 3; + } + + candidates.push({ + row, + score + }); + } + + if (candidates.length === 0) { + return null; + } + + candidates.sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + return Number(right.row?.id || 0) - Number(left.row?.id || 0); + }); + + const best = candidates[0]; + const second = candidates[1] || null; + if (second && second.score === best.score) { + return null; + } + return best.row || null; + } + + async listSeriesSiblingJobs(tmdbId, seasonNumber) { + const normalizedTmdbId = Number(tmdbId || 0) || null; + const normalizedSeason = Number(seasonNumber || 0) || null; + if (!normalizedTmdbId || !normalizedSeason) { + return []; + } + const db = await getDb(); + const rows = await db.all( + ` + SELECT * + FROM jobs + WHERE job_kind != 'dvd_series_container' + AND ( + json_extract(makemkv_info_json, '$.analyzeContext.selectedMetadata.tmdbId') = ? + OR json_extract(makemkv_info_json, '$.selectedMetadata.tmdbId') = ? + ) + AND ( + json_extract(makemkv_info_json, '$.analyzeContext.selectedMetadata.seasonNumber') = ? + OR json_extract(makemkv_info_json, '$.selectedMetadata.seasonNumber') = ? + ) + ORDER BY id ASC + `, + [normalizedTmdbId, normalizedTmdbId, normalizedSeason, normalizedSeason] + ); + return Array.isArray(rows) ? rows : []; + } + + async listChildJobs(parentJobId) { + const normalizedParentJobId = normalizeJobIdValue(parentJobId); + if (!normalizedParentJobId) { + return []; + } + const db = await getDb(); + const rows = await db.all( + `SELECT * FROM jobs WHERE parent_job_id = ? ORDER BY id ASC`, + [normalizedParentJobId] + ); + return Array.isArray(rows) ? rows : []; + } + async findLatestReplacementJobId(sourceJobId) { const normalizedSourceJobId = normalizeJobIdValue(sourceJobId); if (!normalizedSourceJobId) { @@ -3155,6 +4123,7 @@ class HistoryService { SELECT * FROM jobs WHERE status IN ('ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING') + AND COALESCE(job_kind, '') != 'dvd_series_container' ORDER BY updated_at ASC, id ASC ` ), @@ -3181,6 +4150,7 @@ class HistoryService { 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP' ) + AND COALESCE(job_kind, '') != 'dvd_series_container' ORDER BY updated_at ASC, id ASC ` ), @@ -3237,8 +4207,12 @@ class HistoryService { if (!loadedJob) { return null; } - const job = await this.repairImportedCdJobArtifacts(loadedJob, settings); - + const classifiedJob = await this.repairImportedOrphanJobClassification(loadedJob, settings); + const job = await this.repairImportedCdJobArtifacts(classifiedJob, settings); + const childRows = await this.listChildJobs(jobId); + const childJobs = await Promise.all( + childRows.map((row) => this.repairImportedOrphanJobClassification(row, settings)) + ); const parsedTail = Number(options.logTailLines); const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0 ? Math.trunc(parsedTail) @@ -3249,6 +4223,66 @@ class HistoryService { const shouldLoadLogs = includeLiveLog || includeLogs; const hasProcessLog = (!shouldLoadLogs && includeFsChecks) ? hasProcessLogFile(jobId) : false; const baseLogCount = hasProcessLog ? 1 : 0; + const enrichedChildren = await Promise.all( + childJobs.map(async (child) => { + const base = { + ...enrichJobRow(child, settings, { includeFsChecks }), + log_count: includeFsChecks ? (hasProcessLogFile(child.id) ? 1 : 0) : 0 + }; + if (!shouldLoadLogs) { + return { + ...base, + logs: [], + log: '', + logMeta: { + loaded: false, + total: base.log_count, + returned: 0, + truncated: false + } + }; + } + const childLog = await this.readProcessLogLines(child.id, { + includeAll: includeAllLogs, + tailLines: logTailLines + }); + return { + ...base, + log: childLog.lines.join('\n'), + logMeta: { + loaded: true, + total: includeAllLogs ? childLog.total : childLog.returned, + returned: childLog.returned, + truncated: childLog.truncated + } + }; + }) + ); + + const isSeriesContainer = String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'; + let seriesChildSummary = null; + if (isSeriesContainer && enrichedChildren.length > 0) { + const total = enrichedChildren.length; + let rawCount = 0; + let backupCount = 0; + let encodeCount = 0; + for (const child of enrichedChildren) { + if (child?.rawStatus?.exists) { + rawCount += 1; + } + if (child?.backupSuccess) { + backupCount += 1; + } + if (child?.encodeSuccess) { + encodeCount += 1; + } + } + seriesChildSummary = { + raw: { existing: rawCount, expected: total }, + backup: { existing: backupCount, expected: total }, + encode: { existing: encodeCount, expected: total } + }; + } const outputFolders = await this.getJobOutputFoldersForLineage(jobId); @@ -3257,6 +4291,8 @@ class HistoryService { ...enrichJobRow(job, settings, { includeFsChecks }), outputFolders, log_count: baseLogCount, + children: enrichedChildren, + ...(seriesChildSummary ? { seriesChildSummary } : {}), logs: [], log: '', logMeta: { @@ -3277,6 +4313,8 @@ class HistoryService { ...enrichJobRow(job, settings, { includeFsChecks }), outputFolders, log_count: processLog.exists ? processLog.total : 0, + children: enrichedChildren, + ...(seriesChildSummary ? { seriesChildSummary } : {}), logs: [], log: processLog.lines.join('\n'), logMeta: { @@ -3397,23 +4435,41 @@ class HistoryService { async getOrphanRawFolders() { const settings = await settingsService.getSettingsMap(); - const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir'); + const rawDirs = getOrphanRawScanPathList(settings); if (rawDirs.length === 0) { - const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,cd,audiobook,other}).'); + const error = new Error('Kein RAW-Pfad verfügbar (weder Settings noch Default-Pfade).'); error.statusCode = 400; throw error; } const db = await getDb(); - const linkedRows = await db.all( - ` - SELECT id, raw_path, status, makemkv_info_json, mediainfo_info_json, encode_plan_json, encode_input_path - FROM jobs - WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' - ` + const [linkedRows, existingJobIdRows] = await Promise.all([ + db.all( + ` + SELECT id, raw_path, status, makemkv_info_json, mediainfo_info_json, encode_plan_json, encode_input_path + FROM jobs + WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' + ` + ), + db.all(`SELECT id FROM jobs`) + ]); + const existingJobIdSet = new Set( + (Array.isArray(existingJobIdRows) ? existingJobIdRows : []) + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean) ); const linkedPathMap = new Map(); + const addLinkedPath = (candidatePath, rowRef) => { + const normalizedPath = normalizeComparablePath(candidatePath); + if (!normalizedPath) { + return; + } + if (!linkedPathMap.has(normalizedPath)) { + linkedPathMap.set(normalizedPath, []); + } + linkedPathMap.get(normalizedPath).push(rowRef); + }; for (const row of linkedRows) { const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, row); const linkedCandidates = [ @@ -3422,13 +4478,30 @@ class HistoryService { ].filter(Boolean); for (const linkedPath of linkedCandidates) { - if (!linkedPathMap.has(linkedPath)) { - linkedPathMap.set(linkedPath, []); - } - linkedPathMap.get(linkedPath).push({ + const rowRef = { id: row.id, status: row.status - }); + }; + addLinkedPath(linkedPath, rowRef); + + // /database scannt nur Top-Level-Ordner je RAW-Root. + // Viele Jobs (insb. Disc-Backups) speichern raw_path jedoch auf einer tieferen Ebene. + // Deshalb markieren wir zusätzlich den zugehörigen Top-Level-Ordner als "linked". + for (const rawRoot of rawDirs) { + const normalizedRawRoot = normalizeComparablePath(rawRoot); + if (!normalizedRawRoot || !isPathInside(normalizedRawRoot, linkedPath)) { + continue; + } + const relative = String(path.relative(normalizedRawRoot, linkedPath) || '').trim(); + if (!relative || relative === '.' || relative.startsWith('..')) { + continue; + } + const topSegment = relative.split(path.sep).find((segment) => String(segment || '').trim() && segment !== '.'); + if (!topSegment) { + continue; + } + addLinkedPath(path.join(normalizedRawRoot, topSegment), rowRef); + } } } @@ -3460,7 +4533,12 @@ class HistoryService { const stat = fs.statSync(rawPath); const metadata = parseRawFolderMetadata(entry.name); - const detectedMediaType = detectOrphanMediaType(rawPath); + if (metadata.folderJobId && existingJobIdSet.has(Number(metadata.folderJobId))) { + continue; + } + const detectedMediaType = detectOrphanMediaType(rawPath, { + seriesRawPathHint: isLikelySeriesRawPath(rawPath, settings) + }); orphanRows.push({ rawPath, folderName: entry.name, @@ -3490,7 +4568,7 @@ class HistoryService { async importOrphanRawFolder(rawPath) { const settings = await settingsService.getSettingsMap(); - const rawDirs = getConfiguredMediaPathList(settings, 'raw_dir'); + const rawDirs = getOrphanRawScanPathList(settings); const requestedRawPath = String(rawPath || '').trim(); if (!requestedRawPath) { @@ -3500,7 +4578,7 @@ class HistoryService { } if (rawDirs.length === 0) { - const error = new Error('Kein RAW-Pfad konfiguriert (raw_dir oder raw_dir_{bluray,dvd,cd,audiobook,other}).'); + const error = new Error('Kein RAW-Pfad verfügbar (weder Settings noch Default-Pfade).'); error.statusCode = 400; throw error; } @@ -3610,17 +4688,29 @@ class HistoryService { } } - const detectedMediaType = detectOrphanMediaType(finalRawPath); + const seriesRawPathHint = isLikelySeriesRawPath(finalRawPath, settings); + const detectedMediaType = detectOrphanMediaType(finalRawPath, { + seriesRawPathHint + }); const initialSourceJobContext = await this.resolveOrphanImportSourceJob({ candidateJobIds: [metadata.folderJobId], mediaProfile: detectedMediaType }); + const fallbackMediaTypeFromSource = normalizeMediaTypeValue( + initialSourceJobContext?.mediaProfile + || initialSourceJobContext?.makemkvInfo?.analyzeContext?.mediaProfile + || initialSourceJobContext?.makemkvInfo?.mediaProfile + || null + ); + const effectiveDetectedMediaType = detectedMediaType === 'other' + ? (fallbackMediaTypeFromSource || (seriesRawPathHint ? 'dvd' : 'other')) + : detectedMediaType; const initialSourceJob = initialSourceJobContext?.job || null; const initialSourceSelectedMetadata = initialSourceJobContext?.selectedMetadata && typeof initialSourceJobContext.selectedMetadata === 'object' ? initialSourceJobContext.selectedMetadata : {}; const orphanPosterUrl = omdbById?.poster || null; - const cdRecovery = detectedMediaType === 'cd' + const cdRecovery = effectiveDetectedMediaType === 'cd' ? recoverCdJobArtifactsForImport({ currentRawPath: finalRawPath, rawPathCandidates: [ @@ -3644,12 +4734,120 @@ class HistoryService { metadata.folderJobId, ...(cdRecovery?.logSources || []).map((source) => source?.jobId) ], - mediaProfile: detectedMediaType + mediaProfile: effectiveDetectedMediaType }) || initialSourceJobContext; const sourceJob = sourceJobContext?.job || null; const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object' ? sourceJobContext.selectedMetadata : {}; + const sourceAnalyzeContext = sourceJobContext?.analyzeContext && typeof sourceJobContext.analyzeContext === 'object' + ? sourceJobContext.analyzeContext + : {}; + const sourceHasSeriesSignals = hasSeriesMetadataSignals(sourceSelectedMetadata, sourceAnalyzeContext); + const seriesImportHints = effectiveDetectedMediaType === 'dvd' && (seriesRawPathHint || sourceHasSeriesSignals) + ? buildSeriesImportHints({ + folderName: path.basename(finalRawPath), + rawPath: finalRawPath, + metadata, + sourceSelectedMetadata, + sourceAnalyzeContext + }) + : null; + let effectiveSeriesImportHints = seriesImportHints; + let inferredSeriesContainer = null; + if (effectiveDetectedMediaType === 'dvd') { + const hintedTmdbId = normalizePositiveIntegerOrNull( + effectiveSeriesImportHints?.selectedMetadata?.tmdbId + ?? effectiveSeriesImportHints?.selectedMetadata?.providerId + ?? null + ); + const hintedSeasonNumber = normalizePositiveIntegerOrNull( + effectiveSeriesImportHints?.selectedMetadata?.seasonNumber + ?? effectiveSeriesImportHints?.analyzeContextPatch?.seriesLookupHint?.seasonNumber + ?? null + ); + + if (hintedTmdbId && hintedSeasonNumber) { + inferredSeriesContainer = await this.findSeriesContainerJob(hintedTmdbId, hintedSeasonNumber); + } + + if (!inferredSeriesContainer && seriesRawPathHint) { + inferredSeriesContainer = await this.findLikelySeriesContainerJob({ + title: sourceSelectedMetadata?.title || sourceSelectedMetadata?.seriesTitle || effectiveTitle || metadata.title || null, + year: sourceSelectedMetadata?.year || sourceJob?.year || metadata.year || null + }); + } + + if (inferredSeriesContainer) { + const containerInfo = parseJsonSafe(inferredSeriesContainer?.makemkv_info_json, {}) || {}; + const containerSelectedMetadata = extractSelectedMetadataFromMakemkvInfo(containerInfo); + const containerAnalyzeContext = containerInfo?.analyzeContext && typeof containerInfo.analyzeContext === 'object' + ? containerInfo.analyzeContext + : {}; + const containerTmdbId = normalizePositiveIntegerOrNull( + containerSelectedMetadata?.tmdbId + || containerSelectedMetadata?.providerId + || null + ); + const containerSeasonNumber = normalizePositiveIntegerOrNull(containerSelectedMetadata?.seasonNumber || null); + const discNumberHint = normalizePositiveIntegerOrNull( + effectiveSeriesImportHints?.selectedMetadata?.discNumber + ?? extractDiscNumberFromText(`${path.basename(finalRawPath)} ${path.basename(absRawPath)}`) + ?? null + ); + + if (containerTmdbId && containerSeasonNumber) { + const mergedTitle = String( + effectiveSeriesImportHints?.selectedMetadata?.title + || containerSelectedMetadata?.title + || effectiveTitle + || inferredSeriesContainer?.title + || '' + ).trim() || null; + effectiveSeriesImportHints = { + selectedMetadata: { + ...containerSelectedMetadata, + ...(effectiveSeriesImportHints?.selectedMetadata && typeof effectiveSeriesImportHints.selectedMetadata === 'object' + ? effectiveSeriesImportHints.selectedMetadata + : {}), + metadataProvider: 'tmdb', + metadataKind: String(containerSelectedMetadata?.metadataKind || 'series').trim().toLowerCase() || 'series', + tmdbId: containerTmdbId, + seasonNumber: containerSeasonNumber, + title: mergedTitle, + ...(discNumberHint ? { discNumber: discNumberHint } : {}) + }, + analyzeContextPatch: { + ...(effectiveSeriesImportHints?.analyzeContextPatch && typeof effectiveSeriesImportHints.analyzeContextPatch === 'object' + ? effectiveSeriesImportHints.analyzeContextPatch + : {}), + metadataProvider: 'tmdb', + metadataKind: String(containerAnalyzeContext?.metadataKind || containerSelectedMetadata?.metadataKind || 'series').trim().toLowerCase() || 'series', + seriesLookupHint: { + ...(containerAnalyzeContext?.seriesLookupHint && typeof containerAnalyzeContext.seriesLookupHint === 'object' + ? containerAnalyzeContext.seriesLookupHint + : {}), + query: mergedTitle, + seasonNumber: containerSeasonNumber, + ...(discNumberHint ? { discNumber: discNumberHint } : {}) + }, + seriesAnalysis: { + ...(containerAnalyzeContext?.seriesAnalysis && typeof containerAnalyzeContext.seriesAnalysis === 'object' + ? containerAnalyzeContext.seriesAnalysis + : {}), + summary: { + ...(containerAnalyzeContext?.seriesAnalysis?.summary && typeof containerAnalyzeContext.seriesAnalysis.summary === 'object' + ? containerAnalyzeContext.seriesAnalysis.summary + : {}), + seriesLike: true, + confidence: containerAnalyzeContext?.seriesAnalysis?.summary?.confidence || 'high' + } + } + } + }; + } + } + } const sourcePosterCandidate = this.getPreferredPosterCandidateForImport(sourceJobContext, null); const recoveredCdEncodePlan = cdRecovery ? this.buildRecoveredCdEncodePlan(cdRecovery) : null; const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({ @@ -3657,10 +4855,13 @@ class HistoryService { rawPath: finalRawPath, requestedRawPath: absRawPath, sourceFolderJobId: metadata.folderJobId || null, - mediaProfile: detectedMediaType, + mediaProfile: effectiveDetectedMediaType, existingInfo: sourceJobContext?.makemkvInfo || null, - recovery: cdRecovery + recovery: cdRecovery, + selectedMetadata: effectiveSeriesImportHints?.selectedMetadata || null, + analyzeContextPatch: effectiveSeriesImportHints?.analyzeContextPatch || null }); + const inferredSeriesContainerId = normalizeJobIdValue(inferredSeriesContainer?.id); const recoveredPosterUrl = orphanPosterUrl || (thumbnailService.isLocalUrl(sourcePosterCandidate) ? null : sourcePosterCandidate) || null; @@ -3678,10 +4879,14 @@ class HistoryService { || '' ).trim() || null; await this.updateJob(created.id, { - ...(detectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}), - ...(detectedMediaType === 'cd' ? { job_kind: 'cd' } : {}), - ...(detectedMediaType === 'dvd' ? { job_kind: 'dvd' } : {}), - ...(detectedMediaType === 'bluray' ? { job_kind: 'bluray' } : {}), + ...(effectiveDetectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}), + ...(effectiveDetectedMediaType === 'cd' ? { job_kind: 'cd', media_type: 'cd' } : {}), + ...(effectiveDetectedMediaType === 'dvd' + ? (inferredSeriesContainerId + ? { job_kind: 'dvd_series_child', media_type: 'dvd', parent_job_id: inferredSeriesContainerId } + : { job_kind: 'dvd', media_type: 'dvd' }) + : {}), + ...(effectiveDetectedMediaType === 'bluray' ? { job_kind: 'bluray', media_type: 'bluray' } : {}), status: 'FINISHED', last_state: 'FINISHED', title: omdbById?.title @@ -3740,8 +4945,8 @@ class HistoryService { created.id, 'SYSTEM', renameSteps.length > 0 - ? `Historieneintrag aus RAW erstellt (Medientyp: ${detectedMediaType}). Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}` - : `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath} (Medientyp: ${detectedMediaType})` + ? `Historieneintrag aus RAW erstellt (Medientyp: ${effectiveDetectedMediaType}). Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}` + : `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath} (Medientyp: ${effectiveDetectedMediaType})` ); if (metadata.imdbId) { await this.appendLog( @@ -3759,11 +4964,20 @@ class HistoryService { `Metadaten aus vorhandenem Job #${sourceJob.id} wiederhergestellt.` ); } + if (inferredSeriesContainerId) { + await this.appendLog( + created.id, + 'SYSTEM', + `Automatisch dem Serien-Container #${inferredSeriesContainerId} zugeordnet.` + ); + } logger.info('job:import-orphan-raw', { jobId: created.id, rawPath: absRawPath, - detectedMediaType + detectedMediaType: effectiveDetectedMediaType, + detectedMediaTypeRaw: detectedMediaType, + seriesRawPathHint }); const imported = await this.getJobById(created.id); @@ -3778,6 +4992,310 @@ class HistoryService { throw error; } + const parseTmdbId = (value) => { + const direct = normalizePositiveIntegerOrNull(value); + if (direct !== null) { + return direct; + } + const text = String(value || '').trim(); + if (!text) { + return null; + } + const providerMatch = text.match(/tmdb:(\d+)/i); + if (providerMatch?.[1]) { + return normalizePositiveIntegerOrNull(providerMatch[1]); + } + return null; + }; + const requestedProviderRaw = String(payload.metadataProvider || '').trim().toLowerCase(); + const requestedTmdbId = parseTmdbId(payload?.tmdbId ?? payload?.providerId ?? null); + const metadataProvider = requestedProviderRaw === 'themoviedb' + ? 'tmdb' + : (requestedProviderRaw || (requestedTmdbId !== null ? 'tmdb' : 'omdb')); + const makemkvInfo = parseJsonSafe(job.makemkv_info_json, {}); + const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object' + ? makemkvInfo.analyzeContext + : {}; + const existingSelectedMetadata = extractSelectedMetadataFromMakemkvInfo(makemkvInfo); + + if (metadataProvider === 'tmdb') { + const manualTitle = String(payload.title || '').trim(); + const manualYearRaw = Number(payload.year); + const manualYear = Number.isFinite(manualYearRaw) ? Math.trunc(manualYearRaw) : null; + const manualImdbId = String(payload.imdbId || '').trim().toLowerCase() || null; + const manualPoster = String(payload.poster || '').trim() || null; + const manualSeasonName = String(payload.seasonName || '').trim() || null; + const manualSeasonNumber = normalizePositiveNumberOrNull(payload.seasonNumber); + const manualDiscNumber = normalizePositiveIntegerOrNull(payload.discNumber); + const manualEpisodeCountRaw = Number(payload.episodeCount); + const manualEpisodeCount = Number.isFinite(manualEpisodeCountRaw) && manualEpisodeCountRaw > 0 + ? Math.trunc(manualEpisodeCountRaw) + : null; + const manualEpisodes = Array.isArray(payload.episodes) ? payload.episodes : null; + const manualMetadataKind = String(payload.metadataKind || '').trim().toLowerCase() || null; + const manualProviderId = String(payload.providerId || '').trim() || null; + + let effectiveTmdbId = requestedTmdbId; + if (effectiveTmdbId === null) { + effectiveTmdbId = parseTmdbId( + existingSelectedMetadata?.tmdbId + ?? existingSelectedMetadata?.providerId + ?? analyzeContext?.tmdbId + ?? analyzeContext?.providerId + ?? null + ); + } + + const hasTmdbManualData = Boolean( + manualTitle + || manualYear !== null + || manualImdbId + || manualPoster + || manualSeasonName + || manualSeasonNumber !== null + || manualDiscNumber !== null + || manualEpisodeCount !== null + || (manualEpisodes && manualEpisodes.length > 0) + ); + if (effectiveTmdbId === null && !hasTmdbManualData) { + const error = new Error('Keine TMDb-/Metadaten zum Aktualisieren angegeben.'); + error.statusCode = 400; + throw error; + } + + const existingYearRaw = Number(existingSelectedMetadata?.year); + const existingYear = Number.isFinite(existingYearRaw) ? Math.trunc(existingYearRaw) : null; + let title = manualTitle + || String(existingSelectedMetadata?.title || '').trim() + || job.title + || job.detected_title + || null; + let year = manualYear !== null + ? manualYear + : (existingYear !== null ? existingYear : (job.year ?? null)); + let imdbId = manualImdbId + || String(existingSelectedMetadata?.imdbId || job.imdb_id || '').trim().toLowerCase() + || null; + let posterUrl = manualPoster + || String(existingSelectedMetadata?.poster || job.poster_url || '').trim() + || null; + let seasonNumber = manualSeasonNumber !== null + ? manualSeasonNumber + : normalizePositiveNumberOrNull( + existingSelectedMetadata?.seasonNumber + ?? analyzeContext?.seriesLookupHint?.seasonNumber + ?? null + ); + let seasonName = manualSeasonName + || String(existingSelectedMetadata?.seasonName || '').trim() + || null; + let episodeCount = manualEpisodeCount !== null + ? manualEpisodeCount + : (Number(existingSelectedMetadata?.episodeCount || 0) || 0); + let episodes = manualEpisodes && manualEpisodes.length > 0 + ? manualEpisodes + : (Array.isArray(existingSelectedMetadata?.episodes) ? existingSelectedMetadata.episodes : []); + const discNumber = manualDiscNumber !== null + ? manualDiscNumber + : normalizePositiveIntegerOrNull( + existingSelectedMetadata?.discNumber + ?? analyzeContext?.seriesLookupHint?.discNumber + ?? null + ); + let metadataKind = manualMetadataKind + || String( + existingSelectedMetadata?.metadataKind + || analyzeContext?.metadataKind + || '' + ).trim().toLowerCase() + || null; + if (!metadataKind) { + metadataKind = seasonNumber !== null ? 'season' : 'series'; + } + let providerId = manualProviderId + || String(existingSelectedMetadata?.providerId || '').trim() + || null; + if (!providerId && effectiveTmdbId !== null) { + providerId = seasonNumber !== null + ? `tmdb:${effectiveTmdbId}:season:${seasonNumber}` + : `tmdb:${effectiveTmdbId}`; + } + + let tmdbDetails = existingSelectedMetadata?.tmdbDetails && typeof existingSelectedMetadata.tmdbDetails === 'object' + ? { ...existingSelectedMetadata.tmdbDetails } + : null; + if (effectiveTmdbId !== null) { + try { + const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, { + appendToResponse: ['credits', 'external_ids'] + }); + if (seriesDetails) { + const detailsSummary = tmdbService.buildSeriesDetailsSummary(seriesDetails); + const createdBy = tmdbService.normalizeNameList(seriesDetails?.created_by, { maxItems: 3 }); + const genres = tmdbService.normalizeNameList(seriesDetails?.genres, { maxItems: 3 }); + tmdbDetails = { + ...(detailsSummary && typeof detailsSummary === 'object' ? detailsSummary : {}), + createdBy, + genres + }; + if (!title) { + title = String(seriesDetails?.name || seriesDetails?.original_name || '').trim() || null; + } + if ((!year || Number(year) <= 0) && tmdbDetails?.firstAirDate) { + const tmdbYear = Number(String(tmdbDetails.firstAirDate).slice(0, 4)); + if (Number.isFinite(tmdbYear) && tmdbYear > 0) { + year = Math.trunc(tmdbYear); + } + } + if (!imdbId && tmdbDetails?.imdbId) { + imdbId = String(tmdbDetails.imdbId).trim().toLowerCase() || null; + } + if (!posterUrl) { + posterUrl = tmdbService.buildImageUrl(seriesDetails?.poster_path, 'w342') || null; + } + } + } catch (tmdbDetailsErr) { + logger.warn('assignOmdbMetadata:tmdb-details-fetch-failed', { + jobId, + tmdbId: effectiveTmdbId, + message: tmdbDetailsErr?.message || String(tmdbDetailsErr) + }); + } + } + + if (effectiveTmdbId !== null && seasonNumber !== null) { + try { + const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber); + const seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, seasonNumber); + 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)) { + episodes = fetchedEpisodes; + } + const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0; + if (fetchedEpisodeCount > 0) { + episodeCount = fetchedEpisodeCount; + } + if (!seasonName && seasonSummary.name) { + seasonName = String(seasonSummary.name).trim() || null; + } + } + const seasonVoteAverageRaw = Number(seasonDetails?.vote_average || 0); + const seasonVoteAverage = Number.isFinite(seasonVoteAverageRaw) && seasonVoteAverageRaw > 0 + ? Number(seasonVoteAverageRaw.toFixed(1)) + : null; + const seasonRuntimeValues = Array.isArray(seasonDetails?.episodes) + ? seasonDetails.episodes + .map((episode) => Number(episode?.runtime || 0)) + .filter((value) => Number.isFinite(value) && value > 0) + : []; + const seasonRuntime = tmdbService.formatRuntimeLabel(seasonRuntimeValues); + const seasonCast = tmdbService.normalizeNameList(seasonCredits?.cast, { maxItems: 6 }); + tmdbDetails = { + ...(tmdbDetails && typeof tmdbDetails === 'object' ? tmdbDetails : {}), + seasonNumber, + seasonVoteAverage, + seasonRuntime, + runtime: seasonRuntime || (tmdbDetails?.runtime || null), + seasonCast + }; + } catch (tmdbSeasonErr) { + logger.warn('assignOmdbMetadata:tmdb-season-fetch-failed', { + jobId, + tmdbId: effectiveTmdbId, + seasonNumber, + message: tmdbSeasonErr?.message || String(tmdbSeasonErr) + }); + } + } + + const nextSelectedMetadata = { + ...(existingSelectedMetadata && typeof existingSelectedMetadata === 'object' ? existingSelectedMetadata : {}), + title, + year, + imdbId, + poster: posterUrl, + metadataProvider: 'tmdb', + providerId, + tmdbId: effectiveTmdbId, + metadataKind, + seasonNumber, + seasonName, + episodeCount, + episodes: Array.isArray(episodes) ? episodes : [], + ...(discNumber !== null ? { discNumber } : {}), + ...(tmdbDetails && typeof tmdbDetails === 'object' ? { tmdbDetails } : {}) + }; + const existingAnalyzeSelected = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' + ? analyzeContext.selectedMetadata + : {}; + const existingSeriesLookupHint = analyzeContext?.seriesLookupHint && typeof analyzeContext.seriesLookupHint === 'object' + ? analyzeContext.seriesLookupHint + : {}; + const nextAnalyzeContext = { + ...analyzeContext, + metadataProvider: 'tmdb', + metadataKind, + selectedMetadata: { + ...existingAnalyzeSelected, + ...nextSelectedMetadata + }, + seriesLookupHint: { + ...existingSeriesLookupHint, + query: String(existingSeriesLookupHint.query || title || '').trim() || null, + seasonNumber: seasonNumber !== null + ? seasonNumber + : normalizePositiveNumberOrNull(existingSeriesLookupHint.seasonNumber ?? null), + discNumber: discNumber !== null + ? discNumber + : normalizePositiveIntegerOrNull(existingSeriesLookupHint.discNumber ?? null) + } + }; + const topLevelSelected = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}; + const nextMakemkvInfo = { + ...(makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}), + selectedMetadata: { + ...topLevelSelected, + ...nextSelectedMetadata + }, + analyzeContext: nextAnalyzeContext + }; + + await this.updateJob(jobId, { + title, + year, + imdb_id: imdbId, + poster_url: posterUrl, + omdb_json: null, + selected_from_omdb: 0, + makemkv_info_json: JSON.stringify(nextMakemkvInfo) + }); + + if (posterUrl && !thumbnailService.isLocalUrl(posterUrl)) { + this.queuePosterCache(jobId, posterUrl, { + source: 'TMDb Poster', + logFailures: true + }); + } + + await this.appendLog( + jobId, + 'USER_ACTION', + effectiveTmdbId !== null + ? `TMDb-Zuordnung aktualisiert: ${effectiveTmdbId}${seasonNumber !== null ? ` (Staffel ${seasonNumber})` : ''} (${title || '-'})` + : `TMDb-Metadaten manuell aktualisiert: title="${title || '-'}", year="${year || '-'}", imdb="${imdbId || '-'}"` + ); + + const [updated, settings] = await Promise.all([ + this.getJobById(jobId), + settingsService.getSettingsMap() + ]); + return enrichJobRow(updated, settings); + } + const imdbIdInput = String(payload.imdbId || '').trim().toLowerCase(); let omdb = null; if (imdbIdInput) { @@ -3806,6 +5324,36 @@ class HistoryService { const imdbId = omdb?.imdbId || imdbIdInput || job.imdb_id || null; const posterUrl = omdb?.poster || manualPoster || job.poster_url || null; const selectedFromOmdb = omdb ? 1 : Number(payload.fromOmdb ? 1 : 0); + const nextSelectedMetadata = { + ...(existingSelectedMetadata && typeof existingSelectedMetadata === 'object' ? existingSelectedMetadata : {}), + title, + year, + imdbId, + poster: posterUrl, + metadataProvider: 'omdb' + }; + const existingAnalyzeSelected = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' + ? analyzeContext.selectedMetadata + : {}; + const nextAnalyzeContext = { + ...analyzeContext, + metadataProvider: 'omdb', + selectedMetadata: { + ...existingAnalyzeSelected, + ...nextSelectedMetadata + } + }; + const topLevelSelected = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}; + const nextMakemkvInfo = { + ...(makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {}), + selectedMetadata: { + ...topLevelSelected, + ...nextSelectedMetadata + }, + analyzeContext: nextAnalyzeContext + }; await this.updateJob(jobId, { title, @@ -3813,7 +5361,8 @@ class HistoryService { imdb_id: imdbId, poster_url: posterUrl, omdb_json: omdb?.raw ? JSON.stringify(omdb.raw) : (job.omdb_json || null), - selected_from_omdb: selectedFromOmdb + selected_from_omdb: selectedFromOmdb, + makemkv_info_json: JSON.stringify(nextMakemkvInfo) }); // Bild herunterladen, in persistenten Ordner verschieben und poster_url aktualisieren @@ -3921,6 +5470,52 @@ class HistoryService { return enrichJobRow(updated, settings); } + async acknowledgeJobError(jobId) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const job = await this.getJobById(normalizedJobId); + if (!job) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + const hasErrorMessage = Boolean(String(job?.error_message || '').trim()); + const statusUpper = String(job?.status || '').trim().toUpperCase(); + const setCancelled = statusUpper === 'ERROR'; + + if (!hasErrorMessage && !setCancelled) { + const [updatedUnchanged, unchangedSettings] = await Promise.all([ + this.getJobById(normalizedJobId), + settingsService.getSettingsMap() + ]); + return enrichJobRow(updatedUnchanged, unchangedSettings); + } + + await this.updateJob(normalizedJobId, { + error_message: null, + ...(setCancelled ? { status: 'CANCELLED' } : {}), + ...(!job?.end_time ? { end_time: new Date().toISOString() } : {}) + }); + + await this.appendLog( + normalizedJobId, + 'USER_ACTION', + 'Fehlermeldung quittiert.' + ); + + const [updated, settings] = await Promise.all([ + this.getJobById(normalizedJobId), + settingsService.getSettingsMap() + ]); + return enrichJobRow(updated, settings); + } + async _resolveRelatedJobsForDeletion(jobId, options = {}) { const includeRelated = options?.includeRelated !== false; const includeLogLinks = options?.includeLogLinks !== false; diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 8cbb5ae..dc88671 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -6,6 +6,8 @@ const { getDb } = require('../db/database'); const settingsService = require('./settingsService'); const historyService = require('./historyService'); const omdbService = require('./omdbService'); +const tmdbService = require('./tmdbService'); +const dvdSeriesScanService = require('./dvdSeriesScanService'); const musicBrainzService = require('./musicBrainzService'); const audnexService = require('./audnexService'); const cdRipService = require('./cdRipService'); @@ -49,6 +51,7 @@ const REVIEW_REFRESH_SETTING_KEYS = new Set([ ]); const QUEUE_ACTIONS = { START_PREPARED: 'START_PREPARED', + START_SERIES_EPISODE: 'START_SERIES_EPISODE', START_CD: 'START_CD', RETRY: 'RETRY', REENCODE: 'REENCODE', @@ -57,6 +60,7 @@ const QUEUE_ACTIONS = { }; const QUEUE_ACTION_LABELS = { [QUEUE_ACTIONS.START_PREPARED]: 'Start', + [QUEUE_ACTIONS.START_SERIES_EPISODE]: 'Serien-Episode starten', [QUEUE_ACTIONS.RETRY]: 'Retry Rippen', [QUEUE_ACTIONS.REENCODE]: 'RAW neu encodieren', [QUEUE_ACTIONS.RESTART_ENCODE]: 'Encode neu starten', @@ -113,6 +117,123 @@ function normalizePositiveInteger(value) { return Math.trunc(parsed); } +function resolveSelectedMetadataForJob(job = null, analyzeContext = null, activeContext = null) { + const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' + ? analyzeContext.selectedMetadata + : {}; + const activeSelectedMetadata = activeContext?.selectedMetadata && typeof activeContext.selectedMetadata === 'object' + ? activeContext.selectedMetadata + : {}; + const merged = { + ...analyzeSelectedMetadata, + ...activeSelectedMetadata + }; + + return { + ...merged, + title: String(merged.title || job?.title || job?.detected_title || '').trim() || null, + year: Number(merged.year ?? job?.year ?? 0) || null, + imdbId: String(merged.imdbId || job?.imdb_id || '').trim() || null, + poster: String(merged.poster || job?.poster_url || '').trim() || null, + metadataProvider: String( + merged.metadataProvider + || analyzeContext?.metadataProvider + || activeContext?.metadataProvider + || 'omdb' + ).trim().toLowerCase() || 'omdb' + }; +} + +function isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata = {}, analyzeContext = null) { + if (normalizeMediaProfile(mediaProfile) !== 'dvd') { + return false; + } + + const metadata = selectedMetadata && typeof selectedMetadata === 'object' + ? selectedMetadata + : {}; + const provider = String( + metadata.metadataProvider + || analyzeContext?.metadataProvider + || '' + ).trim().toLowerCase(); + + const metadataKind = String(metadata.metadataKind || '').trim().toLowerCase(); + const seasonRaw = String(metadata.seasonNumber ?? '').trim(); + const seasonNumber = Number(seasonRaw.replace(',', '.')); + const hasSeasonNumber = seasonRaw + ? (Number.isFinite(seasonNumber) ? seasonNumber > 0 : true) + : false; + const hasSeasonName = Boolean(String(metadata.seasonName || '').trim()); + const tmdbId = Number(metadata.tmdbId || 0) || null; + const episodeCount = Number(metadata.episodeCount || 0) || 0; + const hasEpisodeList = Array.isArray(metadata.episodes) && metadata.episodes.length > 0; + const hasSeriesLookupHint = Boolean( + String(analyzeContext?.seriesLookupHint?.query || '').trim() + || Number(analyzeContext?.seriesLookupHint?.seasonNumber || 0) > 0 + ); + const seriesLikeByAnalysis = Boolean(analyzeContext?.seriesAnalysis?.summary?.seriesLike); + const providerIsTmdb = provider === 'tmdb' || provider === 'themoviedb'; + const metadataSignalsSeries = ( + ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind) + || hasSeasonNumber + || hasSeasonName + || tmdbId !== null + || episodeCount > 0 + || hasEpisodeList + ); + + if (metadataSignalsSeries) { + return true; + } + + // Fallback: wenn die DVD-Serienanalyse bereits positiv war, behandeln wir den + // Job weiterhin als Serie (z.B. bei manueller Eingabe ohne explizite Staffel). + if (seriesLikeByAnalysis || hasSeriesLookupHint) { + return true; + } + + // Zusätzlicher Sicherheitsanker: TMDb + vorhandener Serien-Hint => Serie. + return providerIsTmdb && (seriesLikeByAnalysis || hasSeriesLookupHint); +} + +function resolveSeriesAwareRawStorage(settings = {}, mediaProfile = null, selectedMetadata = {}, analyzeContext = null) { + const fallbackRawDir = String( + settings?.raw_dir + || settingsService.DEFAULT_RAW_DIR + || '' + ).trim(); + const fallbackRawOwner = String(settings?.raw_dir_owner || '').trim(); + const normalizedMediaProfile = normalizeMediaProfile(mediaProfile); + const seriesRawCandidate = String( + settings?.series_raw_dir + || settings?.raw_dir_dvd_series + || '' + ).trim(); + const seriesRawOwnerCandidate = String( + settings?.series_raw_dir_owner + || settings?.raw_dir_dvd_series_owner + || '' + ).trim(); + const seriesDetected = isSeriesDvdMetadataSelection(normalizedMediaProfile, selectedMetadata, analyzeContext); + + if (!seriesDetected) { + return { + rawBaseDir: fallbackRawDir, + rawOwner: fallbackRawOwner, + usingSeriesRawPath: false + }; + } + + const effectiveSeriesRawDir = seriesRawCandidate || fallbackRawDir; + const effectiveSeriesRawOwner = seriesRawOwnerCandidate || fallbackRawOwner; + return { + rawBaseDir: effectiveSeriesRawDir, + rawOwner: effectiveSeriesRawOwner, + usingSeriesRawPath: Boolean(effectiveSeriesRawDir && fallbackRawDir && effectiveSeriesRawDir !== fallbackRawDir) + }; +} + function parseConverterScanExtensions(rawValue) { const raw = String(rawValue || '').trim(); if (!raw) { @@ -752,6 +873,179 @@ function resolveOutputTemplateValues(job, fallbackJobId = null) { } const DEFAULT_OUTPUT_TEMPLATE = '${title} (${year})/${title} (${year})'; +const DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE = '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'; + +function parseJsonObjectSafe(raw) { + if (!raw) { + return {}; + } + if (typeof raw === 'object' && !Array.isArray(raw)) { + return raw; + } + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + } catch (_error) { + // ignore parse errors for path rendering fallback + } + return {}; +} + +function normalizeTemplateNumberValue(rawValue, fallbackValue = 1) { + const fallback = Number.isFinite(Number(fallbackValue)) && Number(fallbackValue) > 0 + ? Number(fallbackValue) + : 1; + const normalizedEpisode = normalizeEpisodeNumberValue(rawValue); + if (normalizedEpisode !== null) { + return normalizedEpisode; + } + return fallback; +} + +function formatTemplateTwoDigit(rawValue) { + const numeric = Number(rawValue); + if (!Number.isFinite(numeric)) { + return String(rawValue || '').trim() || '00'; + } + if (Number.isInteger(numeric)) { + return String(Math.trunc(numeric)).padStart(2, '0'); + } + return String(rawValue || '').trim() || String(numeric); +} + +function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) { + const encodePlan = parseJsonObjectSafe(job?.encode_plan_json); + const makemkvInfo = parseJsonObjectSafe(job?.makemkv_info_json); + const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object' + ? makemkvInfo.analyzeContext + : {}; + const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, null); + const mediaProfile = normalizeMediaProfile( + job?.media_type + || encodePlan?.mediaProfile + || analyzeContext?.mediaProfile + || null + ); + const selectedTitleIds = normalizeReviewTitleIdList( + Array.isArray(encodePlan?.selectedTitleIds) + ? encodePlan.selectedTitleIds + : [encodePlan?.encodeInputTitleId] + ); + const primaryTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId) + || selectedTitleIds[0] + || null; + const seriesByPlan = Boolean( + encodePlan?.seriesBatchParent + || encodePlan?.seriesBatchChild + || encodePlan?.seriesBatchParentJobId + ); + const isSeries = mediaProfile === 'dvd' + && ( + seriesByPlan + || isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext) + ); + if (!isSeries) { + return null; + } + + const titles = Array.isArray(encodePlan?.titles) ? encodePlan.titles : []; + const selectedTitle = primaryTitleId + ? (titles.find((title) => Number(title?.id) === Number(primaryTitleId)) || null) + : (titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) || null); + const episodeAssignments = encodePlan?.episodeAssignments && typeof encodePlan.episodeAssignments === 'object' + ? encodePlan.episodeAssignments + : {}; + const assignment = primaryTitleId + ? (episodeAssignments[String(primaryTitleId)] || episodeAssignments[primaryTitleId] || null) + : null; + + const seasonNumber = normalizePositiveInteger( + assignment?.seasonNumber + ?? selectedTitle?.seasonNumber + ?? selectedMetadata?.seasonNumber + ?? analyzeContext?.seriesLookupHint?.seasonNumber + ?? null + ) || 1; + const episodeTemplateValue = normalizeTemplateNumberValue( + assignment?.episodeNumber + ?? selectedTitle?.episodeNumber + ?? null, + primaryTitleId || 1 + ); + const discNumber = normalizePositiveInteger( + selectedMetadata?.discNumber + ?? assignment?.discNumber + ?? analyzeContext?.seriesLookupHint?.discNumber + ?? null + ); + const seriesTitle = normalizeCdTrackText( + selectedMetadata?.title + || selectedMetadata?.seriesTitle + || job?.title + || job?.detected_title + || (fallbackJobId ? `job-${fallbackJobId}` : 'series') + ) || (fallbackJobId ? `job-${fallbackJobId}` : 'series'); + const episodeTitle = normalizeCdTrackText( + assignment?.episodeTitle + || selectedTitle?.episodeTitle + || selectedTitle?.fileName + || `Episode ${String(episodeTemplateValue)}` + ) || `Episode ${String(episodeTemplateValue)}`; + const language = String( + settings?.dvd_series_language + || selectedMetadata?.language + || 'unknown' + ).trim() || 'unknown'; + const year = Number(selectedMetadata?.year || job?.year || new Date().getFullYear()) || new Date().getFullYear(); + + const template = String( + settings?.output_template_dvd_series_episode + || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE + ).trim() || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE; + const rendered = renderTemplate(template, { + seriesTitle, + seasonNr: seasonNumber, + seasonNo: String(seasonNumber), + episodeNr: episodeTemplateValue, + episodeNo: String(episodeTemplateValue), + episodeTitle, + discNr: discNumber || '', + year, + language + }); + const segments = rendered + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+|\/+$/g, '') + .split('/') + .map((seg) => sanitizeFileName(seg)) + .filter(Boolean); + const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled'; + const folderParts = segments.slice(0, -1); + const folderPath = folderParts.length > 0 ? path.join(...folderParts) : ''; + const rootDir = String( + settings?.series_dir + || settings?.movie_dir + || settingsService.DEFAULT_MOVIE_DIR + || '' + ).trim(); + const rootOwner = String( + settings?.series_dir_owner + || settings?.movie_dir_owner + || '' + ).trim(); + const ext = String(settings?.output_extension || 'mkv').trim() || 'mkv'; + + return { + rootDir, + rootOwner, + folderPath, + baseName, + ext + }; +} function resolveOutputPathParts(settings, values) { const template = String(settings.output_template || DEFAULT_OUTPUT_TEMPLATE).trim() @@ -777,6 +1071,13 @@ function resolveOutputPathParts(settings, values) { } function buildFinalOutputPathFromJob(settings, job, fallbackJobId = null) { + const seriesParts = resolveSeriesOutputPathParts(settings, job, fallbackJobId); + if (seriesParts?.rootDir) { + if (seriesParts.folderPath) { + return path.join(seriesParts.rootDir, seriesParts.folderPath, `${seriesParts.baseName}.${seriesParts.ext}`); + } + return path.join(seriesParts.rootDir, `${seriesParts.baseName}.${seriesParts.ext}`); + } const movieDir = settings.movie_dir; const values = resolveOutputTemplateValues(job, fallbackJobId); const { folderPath, baseName } = resolveOutputPathParts(settings, values); @@ -788,10 +1089,12 @@ function buildFinalOutputPathFromJob(settings, job, fallbackJobId = null) { } function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) { - const movieDir = settings.movie_dir; + const seriesParts = resolveSeriesOutputPathParts(settings, job, fallbackJobId); + const movieDir = seriesParts?.rootDir || settings.movie_dir; const values = resolveOutputTemplateValues(job, fallbackJobId); - const { baseName } = resolveOutputPathParts(settings, values); - const ext = String(settings.output_extension || 'mkv').trim() || 'mkv'; + const { baseName: defaultBaseName } = resolveOutputPathParts(settings, values); + const baseName = seriesParts?.baseName || defaultBaseName; + const ext = String(seriesParts?.ext || settings.output_extension || 'mkv').trim() || 'mkv'; const numericJobId = Number(fallbackJobId || job?.id || 0); const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0 ? `Incomplete_job-${numericJobId}` @@ -2752,6 +3055,176 @@ function parseHandBrakeTitleList(scanJson) { }); } +function normalizeSeriesScanTitleKind(rawKind) { + return String(rawKind || '').trim().toLowerCase(); +} + +function enrichSeriesAnalyzeContextFromScan(analyzeContext = null, scanLines = null, options = {}) { + const baseAnalyzeContext = analyzeContext && typeof analyzeContext === 'object' + ? analyzeContext + : {}; + const existingSeriesTitles = Array.isArray(baseAnalyzeContext?.seriesAnalysis?.titles) + ? baseAnalyzeContext.seriesAnalysis.titles + : []; + if (existingSeriesTitles.length > 0) { + return { + analyzeContext: baseAnalyzeContext, + derived: false, + reason: 'already_available' + }; + } + + const scanText = Array.isArray(scanLines) + ? scanLines.join('\n') + : String(scanLines || '').trim(); + if (!String(scanText || '').trim()) { + return { + analyzeContext: baseAnalyzeContext, + derived: false, + reason: 'scan_missing' + }; + } + + try { + const analysisResult = dvdSeriesScanService.analyzeHandBrakeScan(scanText, options?.seriesOptions || {}); + const analysis = analysisResult?.analysis || null; + const analysisTitles = Array.isArray(analysis?.titles) ? analysis.titles : []; + if (analysisTitles.length === 0) { + return { + analyzeContext: baseAnalyzeContext, + derived: false, + reason: 'no_titles' + }; + } + + return { + analyzeContext: { + ...baseAnalyzeContext, + seriesAnalysis: analysis + }, + derived: true, + reason: 'scan_classification' + }; + } catch (_error) { + return { + analyzeContext: baseAnalyzeContext, + derived: false, + reason: 'scan_parse_failed' + }; + } +} + +function filterSeriesDvdHandBrakeCandidates(candidates = [], analyzeContext = null) { + const sourceCandidates = Array.isArray(candidates) ? candidates : []; + const seriesTitles = Array.isArray(analyzeContext?.seriesAnalysis?.titles) + ? analyzeContext.seriesAnalysis.titles + : []; + + if (sourceCandidates.length === 0 || seriesTitles.length === 0) { + return { + candidates: sourceCandidates, + applied: false, + reason: null, + sourceCount: sourceCandidates.length, + resultCount: sourceCandidates.length + }; + } + + const episodeCandidateIds = new Set( + seriesTitles + .filter((title) => normalizeSeriesScanTitleKind(title?.kind) === 'episode_candidate') + .map((title) => Number(title?.index)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + const blockedIds = new Set( + seriesTitles + .filter((title) => ['play_all', 'duplicate', 'short'].includes(normalizeSeriesScanTitleKind(title?.kind))) + .map((title) => Number(title?.index)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + + let filtered = sourceCandidates; + let reason = null; + + if (episodeCandidateIds.size > 0) { + const byEpisodeIds = sourceCandidates.filter((item) => episodeCandidateIds.has(Number(item?.handBrakeTitleId))); + if (byEpisodeIds.length > 0) { + filtered = byEpisodeIds; + reason = 'episode_candidates_only'; + } + } + + if (filtered === sourceCandidates && blockedIds.size > 0) { + const withoutBlocked = sourceCandidates.filter((item) => !blockedIds.has(Number(item?.handBrakeTitleId))); + if (withoutBlocked.length > 0 && withoutBlocked.length < sourceCandidates.length) { + filtered = withoutBlocked; + reason = 'excluded_playall_duplicate_short'; + } + } + + if (filtered === sourceCandidates && sourceCandidates.length >= 3) { + const durations = sourceCandidates + .map((item) => Number(item?.durationSeconds || 0)) + .filter((value) => Number.isFinite(value) && value > 0) + .sort((a, b) => a - b); + if (durations.length >= 3) { + const median = durations[Math.floor(durations.length / 2)]; + const sortedDesc = sourceCandidates + .map((item) => ({ + id: Number(item?.handBrakeTitleId), + durationSeconds: Number(item?.durationSeconds || 0) + })) + .filter((item) => Number.isFinite(item.id) && item.id > 0 && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0) + .sort((a, b) => b.durationSeconds - a.durationSeconds); + if (sortedDesc.length >= 2) { + const longest = sortedDesc[0]; + const second = sortedDesc[1]; + const looksLikePlayAll = longest.durationSeconds >= median * 1.8 + && longest.durationSeconds >= second.durationSeconds * 1.4; + if (looksLikePlayAll) { + const withoutLongest = sourceCandidates.filter((item) => Number(item?.handBrakeTitleId) !== longest.id); + if (withoutLongest.length > 0) { + filtered = withoutLongest; + reason = 'heuristic_playall_longest'; + } + } + } + } + } + + if (filtered === sourceCandidates && sourceCandidates.length === 2) { + const sortedDesc = sourceCandidates + .map((item) => ({ + id: Number(item?.handBrakeTitleId), + durationSeconds: Number(item?.durationSeconds || 0) + })) + .filter((item) => Number.isFinite(item.id) && item.id > 0 && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0) + .sort((a, b) => b.durationSeconds - a.durationSeconds); + if (sortedDesc.length === 2) { + const longest = sortedDesc[0]; + const second = sortedDesc[1]; + const looksLikePlayAll = longest.durationSeconds >= second.durationSeconds * 1.6; + if (looksLikePlayAll) { + const withoutLongest = sourceCandidates.filter((item) => Number(item?.handBrakeTitleId) !== longest.id); + if (withoutLongest.length > 0) { + filtered = withoutLongest; + reason = 'heuristic_playall_two_titles'; + } + } + } + } + + return { + candidates: filtered, + applied: filtered !== sourceCandidates, + reason, + sourceCount: sourceCandidates.length, + resultCount: filtered.length + }; +} + function parseHandBrakeSelectedTitleInfo(scanJson, options = {}) { const titleList = pickScanTitleList(scanJson); if (!Array.isArray(titleList) || titleList.length === 0) { @@ -2763,6 +3236,7 @@ function parseHandBrakeSelectedTitleInfo(scanJson, options = {}) { const preferredHandBrakeTitleId = Number.isFinite(rawPreferredHandBrakeTitleId) && rawPreferredHandBrakeTitleId > 0 ? Math.trunc(rawPreferredHandBrakeTitleId) : null; + const strictHandBrakeTitleId = Boolean(options?.strictHandBrakeTitleId && preferredHandBrakeTitleId); const makeMkvSubtitleTracks = Array.isArray(options?.makeMkvSubtitleTracks) ? options.makeMkvSubtitleTracks : []; @@ -2891,6 +3365,9 @@ function parseHandBrakeSelectedTitleInfo(scanJson, options = {}) { if (preferredHandBrakeTitleId) { selected = parsedTitles.find((title) => title.handBrakeTitleId === preferredHandBrakeTitleId) || null; } + if (!selected && strictHandBrakeTitleId) { + return null; + } if (!selected && preferredPlaylistId) { const playlistMatches = parsedTitles .filter((title) => normalizePlaylistId(title?.playlistId) === preferredPlaylistId) @@ -2927,6 +3404,85 @@ function parseHandBrakeSelectedTitleInfo(scanJson, options = {}) { }; } +function buildSelectedHandBrakeReviewTitles(scanJson, selectedHandBrakeTitleIds = [], options = {}) { + const normalizedTitleIds = normalizeReviewTitleIdList(selectedHandBrakeTitleIds); + if (normalizedTitleIds.length === 0) { + return []; + } + + const encodeInputPath = String(options?.encodeInputPath || '').trim() || null; + const selectedSet = new Set(normalizedTitleIds.map((id) => Number(id))); + const resolvedTitleIds = []; + const titles = []; + + for (const handBrakeTitleId of normalizedTitleIds) { + const titleInfo = parseHandBrakeSelectedTitleInfo(scanJson, { + handBrakeTitleId, + strictHandBrakeTitleId: true + }); + if (!titleInfo) { + continue; + } + const normalizedTitleId = normalizeReviewTitleId(titleInfo?.handBrakeTitleId); + if (!normalizedTitleId) { + continue; + } + resolvedTitleIds.push(normalizedTitleId); + const selectedForEncode = selectedSet.has(normalizedTitleId); + titles.push({ + id: normalizedTitleId, + filePath: encodeInputPath || `disc-track-scan://title-${normalizedTitleId}`, + fileName: titleInfo?.fileName || `Title #${normalizedTitleId}`, + makemkvTitleId: null, + sizeBytes: Number(titleInfo?.sizeBytes || 0) || 0, + durationSeconds: Number(titleInfo?.durationSeconds || 0) || 0, + durationMinutes: Number(((Number(titleInfo?.durationSeconds || 0) || 0) / 60).toFixed(2)), + selectedByMinLength: true, + eligibleForEncode: true, + selectedForEncode, + encodeInput: selectedForEncode, + playlistId: titleInfo?.playlistId || null, + playlistFile: titleInfo?.playlistId ? `${titleInfo.playlistId}.mpls` : null, + playlistRecommended: false, + playlistEvaluationLabel: null, + playlistSegmentCommand: null, + playlistSegmentFiles: [], + audioTracks: (Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []).map((track) => ({ + ...track, + id: normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || track?.id || null, + sourceTrackId: normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || track?.sourceTrackId || track?.id || null, + selectedByRule: true, + selectedForEncode, + encodePreviewActions: [], + encodePreviewSummary: 'Übernehmen', + encodeActions: [], + encodeActionSummary: selectedForEncode ? 'Übernehmen' : 'Nicht übernommen' + })), + subtitleTracks: (Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : []).map((track) => ({ + ...track, + id: normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || track?.id || null, + sourceTrackId: normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || track?.sourceTrackId || track?.id || null, + selectedByRule: true, + selectedForEncode, + subtitlePreviewSummary: 'Übernehmen', + subtitlePreviewFlags: [], + subtitlePreviewBurnIn: false, + subtitlePreviewForced: false, + subtitlePreviewForcedOnly: false, + subtitlePreviewDefaultTrack: false, + burnIn: false, + forced: false, + forcedOnly: false, + defaultTrack: false, + flags: [], + subtitleActionSummary: selectedForEncode ? 'Übernehmen' : 'Nicht übernommen' + })) + }); + } + + return titles; +} + function pickTitleIdForTrackReview(playlistAnalysis, selectedTitleId = null) { const explicit = normalizeNonNegativeInteger(selectedTitleId); if (explicit !== null) { @@ -2988,9 +3544,12 @@ function buildDiscScanReview({ sourceArg = null, mode = 'pre_rip', preRip = true, - encodeInputPath = null + encodeInputPath = null, + disableMinLengthFilter = false }) { - const minLengthMinutes = Number(settings?.makemkv_min_length_minutes || 0); + const minLengthMinutes = disableMinLengthFilter + ? 0 + : Number(settings?.makemkv_min_length_minutes || 0); const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60)); const selectedPlaylist = normalizePlaylistId(selectedPlaylistId); const selectedMakemkvId = Number(selectedMakemkvTitleId); @@ -3843,71 +4402,440 @@ function normalizeReviewTitleId(rawValue) { return Math.trunc(value); } -function applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId) { - const normalizedTitleId = normalizeReviewTitleId(selectedEncodeTitleId); - if (!normalizedTitleId) { +function normalizeReviewTitleIdList(rawValues) { + const values = Array.isArray(rawValues) ? rawValues : []; + const seen = new Set(); + const normalized = []; + for (const value of values) { + const id = normalizeReviewTitleId(value); + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + normalized.push(id); + } + return normalized; +} + +function normalizeEpisodeNumberValue(value) { + const numeric = Number(String(value ?? '').trim()); + if (!Number.isFinite(numeric) || numeric <= 0) { + return null; + } + const rounded = Math.round(numeric * 100) / 100; + return rounded; +} + +function normalizeEpisodeAssignmentsPayload(rawAssignments, selectedTitleIds = []) { + const selectedSet = new Set( + normalizeReviewTitleIdList(selectedTitleIds).map((id) => Number(id)) + ); + const assignmentEntries = Array.isArray(rawAssignments) + ? rawAssignments + .map((entry) => { + const titleId = normalizeReviewTitleId(entry?.titleId ?? entry?.id ?? null); + if (!titleId) { + return null; + } + return [titleId, entry]; + }) + .filter(Boolean) + : (rawAssignments && typeof rawAssignments === 'object' + ? Object.entries(rawAssignments) + : []); + if (assignmentEntries.length === 0) { + return {}; + } + + const output = {}; + for (const [rawTitleId, rawAssignment] of assignmentEntries) { + const titleId = normalizeReviewTitleId(rawTitleId); + if (!titleId) { + continue; + } + if (selectedSet.size > 0 && !selectedSet.has(Number(titleId))) { + continue; + } + const assignment = rawAssignment && typeof rawAssignment === 'object' + ? rawAssignment + : {}; + const episodeIdRaw = Number(assignment?.episodeId ?? assignment?.id ?? 0); + const episodeId = Number.isFinite(episodeIdRaw) && episodeIdRaw > 0 + ? Math.trunc(episodeIdRaw) + : null; + const episodeNumber = normalizeEpisodeNumberValue(assignment?.episodeNumber ?? assignment?.number ?? null); + const seasonNumber = normalizePositiveInteger(assignment?.seasonNumber ?? assignment?.season ?? null); + const episodeTitle = String( + assignment?.episodeTitle + ?? assignment?.title + ?? assignment?.name + ?? '' + ).trim() || null; + if (!episodeId && episodeNumber === null && seasonNumber === null && !episodeTitle) { + continue; + } + output[String(titleId)] = { + titleId, + episodeId, + episodeNumber, + seasonNumber, + episodeTitle + }; + } + return output; +} + +function isSeriesBatchParentPlan(rawPlan) { + const plan = rawPlan && typeof rawPlan === 'object' ? rawPlan : null; + if (!plan) { + return false; + } + return Boolean(plan.seriesBatchParent) && !Boolean(plan.seriesBatchChild); +} + +function isSeriesBatchChildPlan(rawPlan) { + const plan = rawPlan && typeof rawPlan === 'object' ? rawPlan : null; + if (!plan) { + return false; + } + return Boolean(plan.seriesBatchChild); +} + +function resolveSeriesBatchParentJobIdFromPlan(rawPlan, fallbackParentJobId = null) { + const plan = rawPlan && typeof rawPlan === 'object' ? rawPlan : null; + if (!plan) { + return normalizePositiveInteger(fallbackParentJobId); + } + const fromPlan = normalizePositiveInteger( + plan.seriesBatchParentJobId + || plan.parentJobId + || null + ); + if (fromPlan) { + return fromPlan; + } + return normalizePositiveInteger(fallbackParentJobId); +} + +function isTerminalStatus(status) { + const normalized = String(status || '').trim().toUpperCase(); + return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED'; +} + +function normalizeSeriesEpisodeStatus(status, fallback = 'QUEUED') { + const normalized = String(status || '').trim().toUpperCase(); + if (['QUEUED', 'RUNNING', 'FINISHED', 'ERROR', 'CANCELLED'].includes(normalized)) { + return normalized; + } + return String(fallback || 'QUEUED').trim().toUpperCase() || 'QUEUED'; +} + +function buildSeriesBatchEpisodesFromPlan(parentJob, parentPlan, titleIds = []) { + const plan = parentPlan && typeof parentPlan === 'object' ? parentPlan : {}; + const normalizedTitleIds = normalizeReviewTitleIdList(titleIds); + const existingEpisodes = Array.isArray(plan?.seriesBatchEpisodes) ? plan.seriesBatchEpisodes : []; + const completedTitleIdSet = new Set( + normalizeReviewTitleIdList([ + ...(Array.isArray(plan?.seriesBatchCompletedTitleIds) ? plan.seriesBatchCompletedTitleIds : []), + ...existingEpisodes + .filter((entry) => normalizeSeriesEpisodeStatus(entry?.status, 'QUEUED') === 'FINISHED') + .map((entry) => entry?.titleId) + ]).map((value) => Number(value)) + ); + const existingByTitleId = new Map(); + for (const entry of existingEpisodes) { + const titleId = normalizeReviewTitleId(entry?.titleId); + if (!titleId) { + continue; + } + existingByTitleId.set(Number(titleId), entry); + } + + const nextEpisodes = []; + for (let index = 0; index < normalizedTitleIds.length; index += 1) { + const titleId = normalizedTitleIds[index]; + const existing = existingByTitleId.get(Number(titleId)) || null; + const label = resolveSeriesBatchChildDisplayTitle(parentJob, plan, titleId, index); + const parsedProgress = Number(existing?.progress); + const rawStatus = normalizeSeriesEpisodeStatus(existing?.status, 'QUEUED'); + const status = ( + completedTitleIdSet.has(Number(titleId)) + && rawStatus !== 'ERROR' + && rawStatus !== 'CANCELLED' + ) + ? 'FINISHED' + : rawStatus; + const progress = status === 'FINISHED' + ? 100 + : ( + Number.isFinite(parsedProgress) + ? Math.max(0, Math.min(100, parsedProgress)) + : 0 + ); + nextEpisodes.push({ + episodeIndex: index + 1, + titleId: Number(titleId), + label, + status, + progress, + eta: existing?.eta ?? null, + startedAt: existing?.startedAt || null, + finishedAt: existing?.finishedAt || null, + outputPath: existing?.outputPath || null, + error: existing?.error || null, + trackSelection: existing?.trackSelection && typeof existing.trackSelection === 'object' + ? existing.trackSelection + : null, + handbrakeInfo: existing?.handbrakeInfo && typeof existing.handbrakeInfo === 'object' + ? existing.handbrakeInfo + : null + }); + } + + return nextEpisodes; +} + +function buildSeriesBatchProgressFromEpisodes(parentJobId, episodes = []) { + const rows = Array.isArray(episodes) ? episodes : []; + const totalCount = rows.length; + let finishedCount = 0; + let cancelledCount = 0; + let errorCount = 0; + let runningCount = 0; + let aggregateProgress = 0; + const children = []; + + for (const entry of rows) { + const status = normalizeSeriesEpisodeStatus(entry?.status, 'QUEUED'); + const progressRaw = Number(entry?.progress); + const progress = status === 'FINISHED' + ? 100 + : ( + Number.isFinite(progressRaw) + ? Math.max(0, Math.min(100, progressRaw)) + : 0 + ); + if (status === 'FINISHED') { + finishedCount += 1; + } else if (status === 'ERROR') { + errorCount += 1; + } else if (status === 'CANCELLED') { + cancelledCount += 1; + } else if (status === 'RUNNING') { + runningCount += 1; + } + aggregateProgress += (status === 'FINISHED' ? 100 : progress); + children.push({ + jobId: Number(parentJobId), + title: String(entry?.label || `Episode #${entry?.episodeIndex || children.length + 1}`).trim(), + status, + progress: Number(progress.toFixed(2)), + eta: entry?.eta ?? null, + episodeIndex: normalizePositiveInteger(entry?.episodeIndex) || null, + titleId: normalizeReviewTitleId(entry?.titleId) || null, + outputPath: entry?.outputPath || null, + error: entry?.error || null + }); + } + + const overallProgress = totalCount > 0 + ? Number((aggregateProgress / totalCount).toFixed(2)) + : 0; + const summaryText = `Serien-Encoding: ${finishedCount}/${totalCount} abgeschlossen${errorCount > 0 ? ` | Fehler: ${errorCount}` : ''}${cancelledCount > 0 ? ` | Abgebrochen: ${cancelledCount}` : ''}`; + + return { + parentJobId: Number(parentJobId), + totalCount, + finishedCount, + cancelledCount, + errorCount, + runningCount, + overallProgress, + summaryText, + children + }; +} + +function resolveSeriesBatchChildDisplayTitle(parentJob, parentPlan, titleId, childIndex = 0) { + const plan = parentPlan && typeof parentPlan === 'object' ? parentPlan : {}; + const titles = Array.isArray(plan?.titles) ? plan.titles : []; + const selectedTitle = titles.find((title) => Number(title?.id) === Number(titleId)) || null; + const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' + ? plan.episodeAssignments + : {}; + const assignment = assignmentMap[String(titleId)] || assignmentMap[titleId] || null; + + const baseTitle = String( + parentJob?.title + || parentJob?.detected_title + || 'Serie' + ).trim() || 'Serie'; + const seasonNumber = normalizePositiveInteger( + assignment?.seasonNumber + ?? selectedTitle?.seasonNumber + ?? null + ); + const episodeNumber = normalizeEpisodeNumberValue( + assignment?.episodeNumber + ?? selectedTitle?.episodeNumber + ?? null + ); + const episodeLabel = String( + assignment?.episodeTitle + || selectedTitle?.episodeTitle + || selectedTitle?.fileName + || '' + ).trim(); + + const seasonToken = seasonNumber !== null ? String(seasonNumber).padStart(2, '0') : null; + const episodeToken = episodeNumber !== null + ? ( + Number.isInteger(episodeNumber) + ? String(Math.trunc(episodeNumber)).padStart(2, '0') + : String(episodeNumber) + ) + : null; + const code = seasonToken && episodeToken ? `S${seasonToken}E${episodeToken}` : `Episode ${childIndex + 1}`; + + return `${baseTitle} - ${code}${episodeLabel ? ` - ${episodeLabel}` : ''}`; +} + +function buildSeriesBatchChildPlan(parentPlan, titleId, parentJobId, childIndex, childCount) { + const plan = parentPlan && typeof parentPlan === 'object' ? parentPlan : {}; + const titles = Array.isArray(plan?.titles) ? plan.titles : []; + const selectedTitle = titles.find((title) => Number(title?.id) === Number(titleId)) || null; + const selectedTitleIds = [Number(titleId)]; + const handBrakeTitleId = normalizeReviewTitleId( + selectedTitle?.handBrakeTitleId + ?? selectedTitle?.titleIndex + ?? titleId + ); + const remappedTitles = titles.map((title) => { + const currentTitleId = normalizeReviewTitleId(title?.id); + const selectedForEncode = currentTitleId !== null && Number(currentTitleId) === Number(titleId); + return { + ...title, + selectedForEncode, + encodeInput: selectedForEncode + }; + }); + const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object' + ? plan.episodeAssignments + : {}; + const selectedAssignment = assignmentMap[String(titleId)] || assignmentMap[titleId] || null; + + return { + ...plan, + reviewConfirmed: true, + reviewConfirmedAt: String(plan?.reviewConfirmedAt || '').trim() || nowIso(), + selectedTitleIds, + encodeInputTitleId: Number(titleId), + encodeInputPath: String( + selectedTitle?.filePath + || selectedTitle?.path + || plan?.encodeInputPath + || '' + ).trim() || null, + handBrakeTitleId: handBrakeTitleId || null, + handBrakeTitleIds: handBrakeTitleId ? [handBrakeTitleId] : [], + titleSelectionRequired: false, + titles: remappedTitles, + episodeAssignments: selectedAssignment + ? { + [String(titleId)]: { + ...selectedAssignment, + titleId: Number(titleId), + filePath: selectedTitle?.filePath || selectedAssignment?.filePath || null + } + } + : {}, + seriesBatchParent: false, + seriesBatchChild: true, + seriesBatchParentJobId: Number(parentJobId), + seriesBatchChildIndex: Number(childIndex) + 1, + seriesBatchChildCount: Number(childCount) || 1, + seriesBatchTitleId: Number(titleId) + }; +} + +function applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId, selectedEncodeTitleIds = null) { + const normalizedPrimaryTitleId = normalizeReviewTitleId(selectedEncodeTitleId); + const normalizedTitleIds = normalizeReviewTitleIdList([ + ...(Array.isArray(selectedEncodeTitleIds) ? selectedEncodeTitleIds : []), + ...(normalizedPrimaryTitleId ? [normalizedPrimaryTitleId] : []) + ]); + if (normalizedTitleIds.length === 0) { return { plan: encodePlan, - selectedTitle: null + selectedTitle: null, + selectedTitleIds: [] }; } const titles = Array.isArray(encodePlan?.titles) ? encodePlan.titles : []; - const selectedTitle = titles.find((item) => Number(item?.id) === normalizedTitleId) || null; - if (!selectedTitle) { - const error = new Error(`Gewählter Titel #${normalizedTitleId} ist nicht vorhanden.`); - error.statusCode = 400; - throw error; - } - - const eligible = selectedTitle?.eligibleForEncode !== undefined - ? Boolean(selectedTitle.eligibleForEncode) - : Boolean(selectedTitle?.selectedByMinLength); - if (!eligible) { - const error = new Error(`Titel #${normalizedTitleId} ist laut MIN_LENGTH_MINUTES nicht encodierbar.`); - error.statusCode = 400; - throw error; + const selectedTitleSet = new Set(normalizedTitleIds.map((id) => Number(id))); + const selectedTitles = []; + for (const selectedId of normalizedTitleIds) { + const selectedTitle = titles.find((item) => Number(item?.id) === selectedId) || null; + if (!selectedTitle) { + const error = new Error(`Gewählter Titel #${selectedId} ist nicht vorhanden.`); + error.statusCode = 400; + throw error; + } + const eligible = selectedTitle?.eligibleForEncode !== undefined + ? Boolean(selectedTitle.eligibleForEncode) + : Boolean(selectedTitle?.selectedByMinLength); + if (!eligible) { + const error = new Error(`Titel #${selectedId} ist laut MIN_LENGTH_MINUTES nicht encodierbar.`); + error.statusCode = 400; + throw error; + } + selectedTitles.push(selectedTitle); } + const effectivePrimaryTitleId = normalizedPrimaryTitleId || normalizeReviewTitleId(selectedTitles[0]?.id); + const selectedTitle = titles.find((item) => Number(item?.id) === effectivePrimaryTitleId) || selectedTitles[0] || null; const remappedTitles = titles.map((title) => { - const isEncodeInput = Number(title?.id) === normalizedTitleId; + const titleId = Number(title?.id); + const isEncodeInput = titleId === effectivePrimaryTitleId; + const selectedForEncode = selectedTitleSet.has(titleId); const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => { const selectedByRule = Boolean(track?.selectedByRule); - const selectedForEncode = isEncodeInput && selectedByRule; + const trackSelectedForEncode = selectedForEncode && selectedByRule; const previewActions = Array.isArray(track?.encodePreviewActions) ? track.encodePreviewActions : []; const previewSummary = track?.encodePreviewSummary || 'Nicht übernommen'; return { ...track, - selectedForEncode, - encodeActions: selectedForEncode ? previewActions : [], - encodeActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen' + selectedForEncode: trackSelectedForEncode, + encodeActions: trackSelectedForEncode ? previewActions : [], + encodeActionSummary: trackSelectedForEncode ? previewSummary : 'Nicht übernommen' }; }); const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => { const selectedByRule = Boolean(track?.selectedByRule); - const selectedForEncode = isEncodeInput && selectedByRule; + const trackSelectedForEncode = selectedForEncode && selectedByRule; const previewFlags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []; const previewSummary = track?.subtitlePreviewSummary || 'Nicht übernommen'; return { ...track, - selectedForEncode, - burnIn: selectedForEncode ? Boolean(track?.subtitlePreviewBurnIn) : false, - forced: selectedForEncode ? Boolean(track?.subtitlePreviewForced) : false, - forcedOnly: selectedForEncode ? Boolean(track?.subtitlePreviewForcedOnly) : false, - defaultTrack: selectedForEncode ? Boolean(track?.subtitlePreviewDefaultTrack) : false, - flags: selectedForEncode ? previewFlags : [], - subtitleActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen' + selectedForEncode: trackSelectedForEncode, + burnIn: trackSelectedForEncode ? Boolean(track?.subtitlePreviewBurnIn) : false, + forced: trackSelectedForEncode ? Boolean(track?.subtitlePreviewForced) : false, + forcedOnly: trackSelectedForEncode ? Boolean(track?.subtitlePreviewForcedOnly) : false, + defaultTrack: trackSelectedForEncode ? Boolean(track?.subtitlePreviewDefaultTrack) : false, + flags: trackSelectedForEncode ? previewFlags : [], + subtitleActionSummary: trackSelectedForEncode ? previewSummary : 'Nicht übernommen' }; }); return { ...title, encodeInput: isEncodeInput, - selectedForEncode: isEncodeInput, + selectedForEncode, audioTracks, subtitleTracks }; @@ -3917,11 +4845,13 @@ function applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId) { plan: { ...encodePlan, titles: remappedTitles, - encodeInputTitleId: normalizedTitleId, + selectedTitleIds: normalizedTitleIds, + encodeInputTitleId: effectivePrimaryTitleId, encodeInputPath: selectedTitle?.filePath || null, titleSelectionRequired: false }, - selectedTitle + selectedTitle, + selectedTitleIds: normalizedTitleIds }; } @@ -4393,6 +5323,14 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { subtitleSelectionValidationErrors: [] }; } + const selectedTitleIds = normalizeReviewTitleIdList( + Array.isArray(plan?.selectedTitleIds) + ? plan.selectedTitleIds + : [encodeInputTitleId] + ); + const selectedTitleIdSet = new Set( + (selectedTitleIds.length > 0 ? selectedTitleIds : [encodeInputTitleId]).map((id) => Number(id)) + ); const selectionPayload = selectedTrackSelection && typeof selectedTrackSelection === 'object' ? selectedTrackSelection @@ -4458,12 +5396,22 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { const explicitSubtitleVariants = normalizeSubtitleVariantSelection(rawSelection.subtitleVariantSelection); const explicitSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(rawSelection.subtitleLanguageOrder); let effectiveSubtitleVariantSelection = explicitSubtitleVariants; + let forceExplicitEmptySubtitleSelection = false; const hasRawSubtitleTrackIds = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleTrackIds'); const hasRawSubtitleVariantSelection = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleVariantSelection'); if (effectiveSubtitleVariantSelection.map.size === 0 && !hasRawSubtitleVariantSelection && hasRawSubtitleTrackIds) { - effectiveSubtitleVariantSelection = requestedSubtitleTrackIds.length > 0 - ? buildSubtitleVariantSelectionFromTrackIds(encodeTitle.subtitleTracks, requestedSubtitleTrackIds) - : buildEmptySubtitleVariantSelectionForAllLanguages(encodeTitle.subtitleTracks); + if (requestedSubtitleTrackIds.length > 0) { + effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds( + encodeTitle.subtitleTracks, + requestedSubtitleTrackIds + ); + } else { + // User provided subtitleTrackIds explicitly, but none match this title. + // Treat this as an explicit empty selection for this title and do not + // fall back to auto-selected subtitle flags (which could include all tracks). + effectiveSubtitleVariantSelection = buildEmptySubtitleVariantSelectionForAllLanguages(encodeTitle.subtitleTracks); + forceExplicitEmptySubtitleSelection = true; + } } else if (effectiveSubtitleVariantSelection.map.size === 0 && !hasRawSubtitleVariantSelection && requestedSubtitleTrackIds.length > 0) { effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds(encodeTitle.subtitleTracks, requestedSubtitleTrackIds); } @@ -4476,18 +5424,22 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { : effectiveSubtitleVariantSelection.order, fallbackSelectedSubtitleTrackIds: requestedSubtitleTrackIds, fallbackSelectedSubtitleTrackIdsOrdered: requestedSubtitleTrackIdsOrdered, - hasExplicitVariantSelection: hasRawSubtitleVariantSelection + hasExplicitVariantSelection: hasRawSubtitleVariantSelection || forceExplicitEmptySubtitleSelection }); const audioSelectionSet = new Set(requestedAudioTrackIds.map((id) => String(id))); const subtitleSelectionSet = new Set(resolvedSubtitleSelection.selectedPhysicalTrackIds.map((id) => String(id))); const remappedTitles = plan.titles.map((title) => { - const isEncodeInput = Number(title?.id) === encodeInputTitleId; + const titleId = Number(title?.id); + const isEncodeInput = titleId === encodeInputTitleId; + const keepSelectedForEncode = selectedTitleIdSet.has(titleId); const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => { const trackId = Number(track?.id); - const selectedForEncode = isEncodeInput && audioSelectionSet.has(String(Math.trunc(trackId))); + const selectedForEncode = isEncodeInput + ? audioSelectionSet.has(String(Math.trunc(trackId))) + : (keepSelectedForEncode ? Boolean(track?.selectedForEncode) : false); const previewActions = Array.isArray(track?.encodePreviewActions) ? track.encodePreviewActions : []; const previewSummary = track?.encodePreviewSummary || 'Nicht übernommen'; return { @@ -4500,7 +5452,9 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => { const trackId = Number(track?.id); - const selectedForEncode = isEncodeInput && subtitleSelectionSet.has(String(Math.trunc(trackId))); + const selectedForEncode = isEncodeInput + ? subtitleSelectionSet.has(String(Math.trunc(trackId))) + : (keepSelectedForEncode ? Boolean(track?.selectedForEncode) : false); const previewFlags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []; const previewSummary = track?.subtitlePreviewSummary || 'Nicht übernommen'; return { @@ -4518,27 +5472,38 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { return { ...title, encodeInput: isEncodeInput, - selectedForEncode: isEncodeInput, + selectedForEncode: keepSelectedForEncode, audioTracks, subtitleTracks }; }); + const currentManualSelection = { + titleId: encodeInputTitleId, + audioTrackIds: requestedAudioTrackIds, + subtitleTrackIds: resolvedSubtitleSelection.selectedPhysicalTrackIds, + subtitleTrackIdsOrdered: resolvedSubtitleSelection.subtitleTrackIdsOrdered, + subtitleForcedTrackIndexes: resolvedSubtitleSelection.subtitleForcedSelectionIndexes, + subtitleVariantSelection: resolvedSubtitleSelection.subtitleVariantSelection, + subtitleLanguageOrder: resolvedSubtitleSelection.subtitleLanguageOrder, + subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors, + updatedAt: nowIso() + }; + const existingManualTrackSelectionByTitle = plan?.manualTrackSelectionByTitle + && typeof plan.manualTrackSelectionByTitle === 'object' + ? plan.manualTrackSelectionByTitle + : {}; + const manualTrackSelectionByTitle = { + ...existingManualTrackSelectionByTitle, + [encodeInputTitleId]: currentManualSelection + }; + return { plan: { ...plan, titles: remappedTitles, - manualTrackSelection: { - titleId: encodeInputTitleId, - audioTrackIds: requestedAudioTrackIds, - subtitleTrackIds: resolvedSubtitleSelection.selectedPhysicalTrackIds, - subtitleTrackIdsOrdered: resolvedSubtitleSelection.subtitleTrackIdsOrdered, - subtitleForcedTrackIndexes: resolvedSubtitleSelection.subtitleForcedSelectionIndexes, - subtitleVariantSelection: resolvedSubtitleSelection.subtitleVariantSelection, - subtitleLanguageOrder: resolvedSubtitleSelection.subtitleLanguageOrder, - subtitleSelectionValidationErrors: resolvedSubtitleSelection.validationErrors, - updatedAt: nowIso() - } + manualTrackSelection: currentManualSelection, + manualTrackSelectionByTitle }, selectionApplied: true, audioTrackIds: requestedAudioTrackIds, @@ -4576,9 +5541,26 @@ function extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath = null) { // flags on the track objects when the field is absent (e.g. auto-selected plans without user review). // Always resolve through track?.id – never sourceTrackId, which may hold MakeMKV stream IDs // (e.g. 189) that have no relation to HandBrake's 1-indexed track positions. - const manualSelection = plan.manualTrackSelection && typeof plan.manualTrackSelection === 'object' - ? plan.manualTrackSelection + const manualSelectionByTitle = plan?.manualTrackSelectionByTitle + && typeof plan.manualTrackSelectionByTitle === 'object' + ? plan.manualTrackSelectionByTitle + : {}; + const manualSelectionForTitle = encodeInputTitleId + ? ( + manualSelectionByTitle[encodeInputTitleId] + || manualSelectionByTitle[String(encodeInputTitleId)] + || null + ) : null; + const manualSelection = ( + manualSelectionForTitle && typeof manualSelectionForTitle === 'object' + ? manualSelectionForTitle + : ( + plan.manualTrackSelection && typeof plan.manualTrackSelection === 'object' + ? plan.manualTrackSelection + : null + ) + ); const manualTitleId = normalizeReviewTitleId(manualSelection?.titleId); const manualMatchesTitle = !manualTitleId || !encodeInputTitleId || manualTitleId === encodeInputTitleId; @@ -5140,6 +6122,8 @@ class PipelineService extends EventEmitter { this.activeProcesses = new Map(); this.cancelRequestedByJob = new Set(); this.jobProgress = new Map(); + this.seriesBatchParentByChild = new Map(); + this.seriesBatchParentProgressSyncAt = new Map(); this.lastPersistAt = 0; this.lastProgressKey = null; this.queueEntries = []; @@ -5546,9 +6530,11 @@ class PipelineService extends EventEmitter { new Set( [ effectiveSettings?.raw_dir, + effectiveSettings?.series_raw_dir, sourceMap?.raw_dir, sourceMap?.raw_dir_bluray, sourceMap?.raw_dir_dvd, + sourceMap?.raw_dir_dvd_series, sourceMap?.raw_dir_cd, sourceMap?.raw_dir_audiobook, preferredDefaultRawDir, @@ -5691,6 +6677,7 @@ class PipelineService extends EventEmitter { const rawExtraDirs = [ settings?.raw_dir_bluray, settings?.raw_dir_dvd, + settings?.raw_dir_dvd_series, settings?.raw_dir_cd, settings?.raw_dir_audiobook, settingsService.DEFAULT_CD_DIR, @@ -6193,6 +7180,45 @@ class PipelineService extends EventEmitter { return String(value || '').trim(); } + _resolveDriveRealPath(value) { + const normalized = this.normalizeDrivePath(value); + if (!normalized || !normalized.startsWith('/')) { + return null; + } + try { + if (fs.realpathSync && typeof fs.realpathSync.native === 'function') { + return fs.realpathSync.native(normalized); + } + return fs.realpathSync(normalized); + } catch (_error) { + return null; + } + } + + _isSameDrivePath(pathA, pathB) { + const a = this.normalizeDrivePath(pathA); + const b = this.normalizeDrivePath(pathB); + if (!a || !b) { + return false; + } + if (a === b) { + return true; + } + + const aReal = this._resolveDriveRealPath(a); + const bReal = this._resolveDriveRealPath(b); + if (aReal && bReal && aReal === bReal) { + return true; + } + + const aBase = path.basename(a); + const bBase = path.basename(b); + if (aBase && bBase && aBase === bBase && /^sr\d+$/i.test(aBase)) { + return true; + } + return false; + } + _buildDriveLockOwner(jobId, extra = {}) { const normalizedJobId = Number(jobId); return { @@ -6219,7 +7245,7 @@ class PipelineService extends EventEmitter { return null; } for (const [jobId, lock] of this.driveLocksByJob.entries()) { - if (this.normalizeDrivePath(lock?.devicePath) === normalizedPath) { + if (this._isSameDrivePath(lock?.devicePath, normalizedPath)) { return { ...lock, jobId: Number(jobId) @@ -6238,7 +7264,7 @@ class PipelineService extends EventEmitter { const targetJobId = Math.trunc(normalizedJobId); const existing = this._getDriveLockByJobId(targetJobId); - if (existing && this.normalizeDrivePath(existing.devicePath) === normalizedPath) { + if (existing && this._isSameDrivePath(existing.devicePath, normalizedPath)) { return true; } if (existing) { @@ -6300,6 +7326,113 @@ class PipelineService extends EventEmitter { return error; } + _isProcessHandleRunning(processHandle) { + if (!processHandle || typeof processHandle !== 'object') { + return false; + } + const child = processHandle.child; + // Conservative fallback for orchestrating/shared handles without direct child. + if (!child || typeof child !== 'object') { + return true; + } + const pid = Number(child.pid); + if (!Number.isFinite(pid) || pid <= 0) { + return false; + } + if (child.exitCode !== null && child.exitCode !== undefined) { + return false; + } + if (child.signalCode !== null && child.signalCode !== undefined) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (String(error?.code || '').toUpperCase() === 'EPERM') { + return true; + } + return false; + } + } + + _isActiveProcessForJob(jobId, options = {}) { + const normalizedJobId = this.normalizeQueueJobId(jobId); + if (!normalizedJobId) { + return false; + } + const processHandle = this.activeProcesses.get(normalizedJobId) || null; + if (!processHandle) { + return false; + } + const running = this._isProcessHandleRunning(processHandle); + if (!running && options?.cleanupStale) { + this.activeProcesses.delete(normalizedJobId); + this.syncPrimaryActiveProcess(); + logger.warn('process-handle:stale-removed', { jobId: normalizedJobId }); + } + return running; + } + + _hasForeignInteractiveSession(activeJobId = null) { + const normalizedActiveJobId = this.normalizeQueueJobId(activeJobId); + if (!normalizedActiveJobId) { + return false; + } + const snapshotActiveJobId = this.normalizeQueueJobId(this.snapshot.activeJobId); + if (!snapshotActiveJobId || snapshotActiveJobId === normalizedActiveJobId) { + return false; + } + const PIPELINE_INTERACTIVE_STATES = new Set([ + 'ANALYZING', + 'METADATA_SELECTION', + 'WAITING_FOR_USER_DECISION', + 'MEDIAINFO_CHECK' + ]); + const snapshotState = String(this.snapshot.state || '').trim().toUpperCase(); + return PIPELINE_INTERACTIVE_STATES.has(snapshotState); + } + + async _findProtectedDriveLockForAnalyze(devicePath) { + const normalizedPath = this.normalizeDrivePath(devicePath); + if (!normalizedPath) { + return null; + } + + const existingLock = this._getDriveLockByPath(normalizedPath); + const ownerJobId = this.normalizeQueueJobId(existingLock?.owner?.jobId || existingLock?.jobId); + // Only an actually running process may protect the drive from force-unlock. + // Stale ERROR/CANCELLED/RIPPING remnants must not block analyze forever. + if (ownerJobId && this._isActiveProcessForJob(ownerJobId, { cleanupStale: true })) { + return existingLock; + } + return null; + } + + async _ensureDriveUnlockedForAnalyze(devicePath) { + const normalizedPath = this.normalizeDrivePath(devicePath); + if (!normalizedPath) { + return; + } + const hasLock = Boolean(diskDetectionService.isDeviceLocked(normalizedPath) || this._getDriveLockByPath(normalizedPath)); + if (!hasLock) { + return; + } + + const protectedLock = await this._findProtectedDriveLockForAnalyze(normalizedPath); + if (protectedLock) { + throw this._buildDriveLockedError(normalizedPath, protectedLock); + } + + await this.forceUnlockDrive(normalizedPath, { + reason: 'analyze_stale_lock_clear' + }); + const stillLocked = Boolean(diskDetectionService.isDeviceLocked(normalizedPath) || this._getDriveLockByPath(normalizedPath)); + if (stillLocked) { + throw this._buildDriveLockedError(normalizedPath, this._getDriveLockByPath(normalizedPath)); + } + } + _shouldKeepDriveLockForJobRow(job = null) { const status = String(job?.status || '').trim().toUpperCase(); const lastState = String(job?.last_state || '').trim().toUpperCase(); @@ -6356,11 +7489,7 @@ class PipelineService extends EventEmitter { if (!normalizedPath) { return []; } - - const existingLock = this._getDriveLockByPath(normalizedPath); - if (diskDetectionService.isDeviceLocked(normalizedPath) || existingLock) { - throw this._buildDriveLockedError(normalizedPath, existingLock); - } + await this._ensureDriveUnlockedForAnalyze(normalizedPath); const db = await getDb(); const rows = await db.all( @@ -6398,13 +7527,23 @@ class PipelineService extends EventEmitter { if (!replaceableStates.has(rowState)) { continue; } - if (this.activeProcesses.has(normalizedJobId)) { + if (this._isActiveProcessForJob(normalizedJobId, { cleanupStale: true })) { const error = new Error(`Analyse nicht möglich: Job #${normalizedJobId} für ${normalizedPath} ist noch aktiv.`); error.statusCode = 409; throw error; } if (this._shouldKeepDriveLockForJobRow(row)) { - throw this._buildDriveLockedError(normalizedPath, this._getDriveLockByJobId(normalizedJobId)); + const lockForJob = this._getDriveLockByJobId(normalizedJobId); + const hasPhysicalLock = Boolean(diskDetectionService.isDeviceLocked(normalizedPath)); + const hasProtectedProcess = this._isActiveProcessForJob(normalizedJobId, { cleanupStale: true }); + if (hasProtectedProcess || lockForJob || hasPhysicalLock) { + throw this._buildDriveLockedError(normalizedPath, lockForJob); + } + logger.warn('analyze:stale-lock-protection-cleared', { + devicePath: normalizedPath, + jobId: normalizedJobId, + status: rowState + }); } await historyService.deleteJob(normalizedJobId, 'none', { includeRelated: false }); await this.onJobsDeleted([normalizedJobId], { suppressQueueRefresh: true }); @@ -6619,6 +7758,7 @@ class PipelineService extends EventEmitter { }, 120000); } const forceUnlockedPaths = this._forceUnlockDriveLocksForDeletedJobs(Array.from(affectedDevicePaths), normalizedIds); + const staleLockClearedPaths = await this._forceUnlockStaleDriveLocks(Array.from(affectedDevicePaths)); let driveResetChanged = false; if (resetDriveState) { @@ -6652,7 +7792,7 @@ class PipelineService extends EventEmitter { void this.pumpQueue(); } - if (resetDriveState || forceUnlockedPaths.length > 0) { + if (resetDriveState || forceUnlockedPaths.length > 0 || staleLockClearedPaths.length > 0) { void this._rescanDrivesAfterDelete(Array.from(affectedDevicePaths)); } return { @@ -6661,6 +7801,85 @@ class PipelineService extends EventEmitter { }; } + async _forceUnlockStaleDriveLocks(devicePaths = []) { + const normalizedPaths = Array.from(new Set( + (Array.isArray(devicePaths) ? devicePaths : []) + .map((value) => this.normalizeDrivePath(value)) + .filter((value) => Boolean(value) && !value.startsWith('__virtual__')) + )); + if (normalizedPaths.length === 0 || typeof diskDetectionService.forceUnlockDevice !== 'function') { + return []; + } + + const activeLocks = diskDetectionService.getActiveLocks(); + const locksToCheck = normalizedPaths + .map((devicePath) => { + const lock = activeLocks.find((entry) => this.normalizeDrivePath(entry?.path) === devicePath) || null; + return lock ? { devicePath, lock } : null; + }) + .filter(Boolean); + if (locksToCheck.length === 0) { + return []; + } + + const ownerJobIds = new Set(); + for (const entry of locksToCheck) { + const owners = Array.isArray(entry.lock?.owners) ? entry.lock.owners : []; + for (const owner of owners) { + const jobId = Number(owner?.jobId); + if (Number.isFinite(jobId) && jobId > 0) { + ownerJobIds.add(Math.trunc(jobId)); + } + } + } + if (ownerJobIds.size === 0) { + return []; + } + + let existingJobIds = new Set(); + try { + const db = await getDb(); + const placeholders = Array.from(ownerJobIds).map(() => '?').join(', '); + const rows = await db.all(`SELECT id FROM jobs WHERE id IN (${placeholders})`, Array.from(ownerJobIds)); + existingJobIds = new Set( + (Array.isArray(rows) ? rows : []) + .map((row) => Number(row?.id)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)) + ); + } catch (error) { + logger.warn('drive-lock:stale-check:failed', { error: errorToMeta(error) }); + return []; + } + + const cleared = []; + for (const entry of locksToCheck) { + const owners = Array.isArray(entry.lock?.owners) ? entry.lock.owners : []; + const hasActiveOwner = owners.some((owner) => { + const jobId = Number(owner?.jobId); + if (!Number.isFinite(jobId) || jobId <= 0) { + return false; + } + return existingJobIds.has(Math.trunc(jobId)); + }); + if (hasActiveOwner) { + continue; + } + const clearedCount = Number(diskDetectionService.forceUnlockDevice(entry.devicePath, { + reason: 'stale_lock', + deletedJobIds: [] + }) || 0); + if (clearedCount > 0) { + cleared.push(entry.devicePath); + } + } + + if (cleared.length > 0) { + logger.warn('drive-lock:stale-unlocked', { devicePaths: cleared }); + } + return cleared; + } + normalizeParallelJobsLimit(rawValue) { const value = Number(rawValue); if (!Number.isFinite(value) || value < 1) { @@ -6719,6 +7938,54 @@ class PipelineService extends EventEmitter { return this.normalizeParallelJobsLimit(settings?.pipeline_max_parallel_cd_encodes ?? 2); } + async forceUnlockDrive(devicePath, options = {}) { + const normalizedPath = this.normalizeDrivePath(devicePath); + if (!normalizedPath || normalizedPath.startsWith('__virtual__')) { + return { unlocked: 0, path: normalizedPath || null }; + } + + const activeLocks = Array.isArray(diskDetectionService.getActiveLocks?.()) + ? diskDetectionService.getActiveLocks() + : []; + const matchingLockPaths = Array.from(new Set([ + normalizedPath, + ...activeLocks + .map((entry) => this.normalizeDrivePath(entry?.path)) + .filter((lockPath) => this._isSameDrivePath(lockPath, normalizedPath)) + ])); + + // Remove any in-memory job lock mappings for this drive (including alias paths) + const clearedJobIds = []; + for (const [jobId, lock] of this.driveLocksByJob.entries()) { + if (matchingLockPaths.some((candidatePath) => this._isSameDrivePath(lock?.devicePath, candidatePath))) { + this.driveLocksByJob.delete(jobId); + clearedJobIds.push(Number(jobId)); + } + } + + let unlocked = 0; + for (const lockPath of matchingLockPaths) { + unlocked += Number(diskDetectionService.forceUnlockDevice(lockPath, { + reason: String(options?.reason || 'manual_force_unlock').trim() || 'manual_force_unlock', + deletedJobIds: Array.isArray(options?.relatedJobIds) ? options.relatedJobIds : [] + }) || 0); + } + + if (clearedJobIds.length > 0 || unlocked > 0) { + logger.warn('drive-lock:force-unlock:manual', { + devicePath: normalizedPath, + matchingLockPaths, + unlocked, + clearedJobIds + }); + } + + this._broadcastPipelineStateChanged(); + void this._rescanDrivesAfterDelete(matchingLockPaths); + + return { unlocked, path: normalizedPath, matchingLockPaths, clearedJobIds }; + } + async getMaxTotalEncodes() { const settings = await settingsService.getSettingsMap(); const value = Number(settings?.pipeline_max_total_encodes); @@ -6805,6 +8072,9 @@ class PipelineService extends EventEmitter { let filmRunning = 0; let audioRunning = 0; for (const job of rows) { + if (this.isSeriesBatchChildJob(job)) { + continue; + } const poolType = this.resolveQueuePoolTypeForJob(job); if (poolType === 'audio') { audioRunning += 1; @@ -6819,6 +8089,692 @@ class PipelineService extends EventEmitter { }; } + isSeriesBatchParentQueueAnchor(jobLike = null) { + const plan = this.extractQueueJobPlan(jobLike); + return isSeriesBatchParentPlan(plan); + } + + isSeriesBatchChildJob(jobLike = null) { + const plan = this.extractQueueJobPlan(jobLike); + return isSeriesBatchChildPlan(plan); + } + + resolveSeriesBatchParentJobId(jobLike = null) { + const plan = this.extractQueueJobPlan(jobLike); + const parentJobId = resolveSeriesBatchParentJobIdFromPlan( + plan, + jobLike?.parent_job_id + ); + return this.normalizeQueueJobId(parentJobId); + } + + async listSeriesBatchChildJobs(parentJobId) { + const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); + if (!normalizedParentJobId) { + return []; + } + const db = await getDb(); + const rows = await db.all( + ` + SELECT * + FROM jobs + WHERE parent_job_id = ? + ORDER BY id ASC + `, + [normalizedParentJobId] + ); + return (Array.isArray(rows) ? rows : []).filter((row) => this.isSeriesBatchChildJob(row)); + } + + async updateSeriesBatchParentProgress(parentJobId, options = {}) { + const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); + if (!normalizedParentJobId) { + return null; + } + + const parentJob = options?.parentJob && Number(options.parentJob?.id) === Number(normalizedParentJobId) + ? options.parentJob + : await historyService.getJobById(normalizedParentJobId); + if (!parentJob) { + return null; + } + if (!this.isSeriesBatchParentQueueAnchor(parentJob)) { + return null; + } + + const parentPlan = this.safeParseJson(parentJob.encode_plan_json); + if (!isSeriesBatchParentPlan(parentPlan) || !Array.isArray(parentPlan?.seriesBatchEpisodes)) { + this.seriesBatchParentProgressSyncAt.delete(Number(normalizedParentJobId)); + return null; + } + + const selectedTitleIds = normalizeReviewTitleIdList( + Array.isArray(parentPlan?.selectedTitleIds) + ? parentPlan.selectedTitleIds + : parentPlan.seriesBatchEpisodes.map((item) => item?.titleId) + ); + const episodes = buildSeriesBatchEpisodesFromPlan(parentJob, parentPlan, selectedTitleIds); + if (episodes.length === 0) { + this.seriesBatchParentProgressSyncAt.delete(Number(normalizedParentJobId)); + return null; + } + + const summary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, episodes); + const totalCount = summary.totalCount; + const finishedCount = summary.finishedCount; + const cancelledCount = summary.cancelledCount; + const errorCount = summary.errorCount; + const runningCount = summary.runningCount; + const totalProgress = summary.overallProgress; + const allTerminal = runningCount === 0 && (finishedCount + cancelledCount + errorCount) === totalCount; + const summaryText = summary.summaryText; + const contextPatch = { + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount, + finishedCount, + cancelledCount, + errorCount, + runningCount, + children: summary.children + } + }; + + for (const [childJobId, mappedParentJobId] of this.seriesBatchParentByChild.entries()) { + if (Number(mappedParentJobId) === Number(normalizedParentJobId)) { + this.seriesBatchParentByChild.delete(Number(childJobId)); + } + } + + if (allTerminal) { + this.seriesBatchParentProgressSyncAt.delete(Number(normalizedParentJobId)); + const finalState = errorCount > 0 + ? 'ERROR' + : (cancelledCount > 0 ? 'CANCELLED' : 'FINISHED'); + const finalErrorMessage = finalState === 'FINISHED' + ? null + : ( + finalState === 'ERROR' + ? `Serien-Batch beendet mit ${errorCount} Fehler(n).` + : 'Serien-Batch abgebrochen.' + ); + await historyService.updateJob(normalizedParentJobId, { + status: finalState, + last_state: finalState, + end_time: nowIso(), + error_message: finalErrorMessage + }); + if (Number(this.snapshot.activeJobId) === Number(normalizedParentJobId)) { + await this.setState(finalState, { + activeJobId: normalizedParentJobId, + progress: totalProgress, + eta: null, + statusText: summaryText, + context: { + ...(this.snapshot.context || {}), + jobId: normalizedParentJobId, + ...contextPatch + } + }); + } else { + await this.updateProgress(finalState, totalProgress, null, summaryText, normalizedParentJobId, { + contextPatch + }); + } + return { + parentJobId: normalizedParentJobId, + final: true, + state: finalState, + progress: totalProgress, + summaryText, + childCount: totalCount + }; + } + + await historyService.updateJob(normalizedParentJobId, { + status: 'ENCODING', + last_state: 'ENCODING', + end_time: null, + error_message: null + }); + await this.updateProgress('ENCODING', totalProgress, null, summaryText, normalizedParentJobId, { + contextPatch + }); + return { + parentJobId: normalizedParentJobId, + final: false, + state: 'ENCODING', + progress: totalProgress, + summaryText, + childCount: totalCount + }; + } + + async startSeriesBatchFromPrepared(parentJobId, parentJob, options = {}) { + const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); + if (!normalizedParentJobId) { + return { handled: false }; + } + const sourceJob = parentJob && Number(parentJob?.id) === Number(normalizedParentJobId) + ? parentJob + : await historyService.getJobById(normalizedParentJobId); + if (!sourceJob) { + return { handled: false }; + } + + const parentPlan = this.safeParseJson(sourceJob.encode_plan_json); + if (isSeriesBatchChildPlan(parentPlan)) { + return { handled: false }; + } + const planMode = String(parentPlan?.mode || '').trim().toLowerCase(); + const isPreRipPlan = planMode === 'pre_rip' || Boolean(parentPlan?.preRip); + if (isPreRipPlan) { + return { handled: false }; + } + const mediaProfile = this.resolveMediaProfileForJob(sourceJob, { + encodePlan: parentPlan + }); + const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json); + const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' + ? mkInfo.analyzeContext + : {}; + const selectedMetadata = resolveSelectedMetadataForJob(sourceJob, analyzeContext, this.snapshot.context || {}); + const isSeriesDvd = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + const selectedTitleIds = normalizeReviewTitleIdList( + Array.isArray(parentPlan?.selectedTitleIds) + ? parentPlan.selectedTitleIds + : [parentPlan?.encodeInputTitleId] + ); + if (!isSeriesDvd || selectedTitleIds.length <= 1) { + return { handled: false }; + } + + const allTitles = Array.isArray(parentPlan?.titles) ? parentPlan.titles : []; + const validSelectedTitleIds = selectedTitleIds.filter((titleId) => + allTitles.some((title) => Number(title?.id) === Number(titleId)) + ); + if (validSelectedTitleIds.length <= 1) { + return { handled: false }; + } + const nextEpisodes = buildSeriesBatchEpisodesFromPlan(sourceJob, parentPlan, validSelectedTitleIds); + if (nextEpisodes.length <= 1) { + return { handled: false }; + } + const nextParentPlan = { + ...parentPlan, + seriesBatchParent: true, + seriesBatchChild: false, + seriesBatchParentJobId: null, + seriesBatchChildJobIds: [], + seriesBatchCompletedTitleIds: [], + seriesBatchDispatchedAt: nowIso(), + seriesBatchTotalChildren: nextEpisodes.length, + seriesBatchEpisodes: nextEpisodes, + reviewConfirmed: true + }; + const progressSummary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, nextEpisodes); + + await historyService.updateJob(normalizedParentJobId, { + encode_plan_json: JSON.stringify(nextParentPlan), + status: 'READY_TO_ENCODE', + last_state: 'READY_TO_ENCODE', + end_time: null, + error_message: null, + encode_review_confirmed: 1 + }); + await historyService.appendLog( + normalizedParentJobId, + 'SYSTEM', + `Serien-Batch gestartet: ${nextEpisodes.length} Episode(n) als interne Queue-Einträge angelegt (Single-Job-Modus).` + ); + + let queuedChildren = 0; + let startedChildren = 0; + for (let index = 0; index < nextEpisodes.length; index += 1) { + const episode = nextEpisodes[index]; + const queueResult = await this.enqueueOrStartAction( + QUEUE_ACTIONS.START_SERIES_EPISODE, + normalizedParentJobId, + () => this.startSeriesBatchEpisode(normalizedParentJobId, { + immediate: true, + titleId: episode.titleId, + episodeIndex: episode.episodeIndex + }), + { + poolType: 'film', + allowDuplicateJobEntries: true, + // Always queue series-episodes first so all remaining episodes are + // visible in Queue immediately. PumpQueue starts the first one. + forceQueue: true, + uniqueEntryKey: `series_episode_${episode.episodeIndex}_${episode.titleId}`, + entryData: { + seriesEpisodeIndex: episode.episodeIndex, + seriesTitleId: episode.titleId, + seriesEpisodeLabel: episode.label + } + } + ); + if (queueResult?.queued) { + queuedChildren += 1; + } else if (queueResult?.started) { + startedChildren += 1; + } + } + + await this.updateProgress( + startedChildren > 0 ? 'ENCODING' : 'READY_TO_ENCODE', + progressSummary.overallProgress, + null, + progressSummary.summaryText, + normalizedParentJobId, + { + contextPatch: { + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: progressSummary.totalCount, + finishedCount: progressSummary.finishedCount, + cancelledCount: progressSummary.cancelledCount, + errorCount: progressSummary.errorCount, + runningCount: progressSummary.runningCount, + children: progressSummary.children + } + } + } + ); + return { + handled: true, + result: { + started: startedChildren > 0, + stage: startedChildren > 0 ? 'ENCODING' : 'READY_TO_ENCODE', + seriesBatch: true, + childCount: nextEpisodes.length, + queuedChildren, + startedChildren + } + }; + } + + async startSeriesBatchEpisode(parentJobId, options = {}) { + const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); + if (!normalizedParentJobId) { + const error = new Error('Ungültige Serien-Job-ID.'); + error.statusCode = 400; + throw error; + } + + const sourceParentJob = await historyService.getJobById(normalizedParentJobId); + if (!sourceParentJob) { + const error = new Error(`Job ${normalizedParentJobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const sourcePlan = this.safeParseJson(sourceParentJob.encode_plan_json); + if (!isSeriesBatchParentPlan(sourcePlan) || !Array.isArray(sourcePlan?.seriesBatchEpisodes)) { + const error = new Error(`Job ${normalizedParentJobId} ist kein Serien-Batch-Parent.`); + error.statusCode = 409; + throw error; + } + + const requestedEpisodeIndex = normalizePositiveInteger(options?.episodeIndex); + const requestedTitleId = normalizeReviewTitleId(options?.titleId); + const episodes = buildSeriesBatchEpisodesFromPlan( + sourceParentJob, + sourcePlan, + sourcePlan.selectedTitleIds || sourcePlan.seriesBatchEpisodes.map((item) => item?.titleId) + ); + const targetEpisodeIdx = episodes.findIndex((item) => ( + (requestedEpisodeIndex && Number(item?.episodeIndex) === Number(requestedEpisodeIndex)) + || (requestedTitleId && Number(item?.titleId) === Number(requestedTitleId)) + )); + const fallbackEpisodeIdx = episodes.findIndex((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'QUEUED'); + const episodeIndex = targetEpisodeIdx >= 0 ? targetEpisodeIdx : fallbackEpisodeIdx; + if (episodeIndex < 0) { + return { + started: false, + seriesBatch: true, + stage: 'FINISHED', + reason: 'no_queued_episode' + }; + } + const episode = episodes[episodeIndex]; + const episodeStatus = normalizeSeriesEpisodeStatus(episode?.status, 'QUEUED'); + if (episodeStatus === 'FINISHED') { + return { + started: false, + seriesBatch: true, + stage: 'ENCODING', + reason: 'already_finished' + }; + } + + if (this.activeProcesses.has(Number(normalizedParentJobId))) { + const error = new Error(`Serien-Episode kann nicht gestartet werden: Job #${normalizedParentJobId} läuft bereits.`); + error.statusCode = 409; + throw error; + } + + const now = nowIso(); + const runningEpisodes = episodes.map((item, idx) => ( + idx === episodeIndex + ? { + ...item, + status: 'RUNNING', + progress: 0, + eta: null, + startedAt: now, + finishedAt: null, + error: null + } + : item + )); + const runningPlan = { + ...sourcePlan, + seriesBatchEpisodes: runningEpisodes, + seriesBatchParent: true, + seriesBatchChild: false, + seriesBatchChildJobIds: [] + }; + await historyService.updateJob(normalizedParentJobId, { + encode_plan_json: JSON.stringify(runningPlan), + status: 'ENCODING', + last_state: 'ENCODING', + end_time: null, + error_message: null, + encode_review_confirmed: 1 + }); + await historyService.appendLog( + normalizedParentJobId, + 'SYSTEM', + `Serien-Episode gestartet (${episode.episodeIndex}/${runningEpisodes.length}): ${episode.label}` + ); + + const runningSummary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, runningEpisodes); + await this.updateProgress('ENCODING', runningSummary.overallProgress, null, runningSummary.summaryText, normalizedParentJobId, { + contextPatch: { + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: runningSummary.totalCount, + finishedCount: runningSummary.finishedCount, + cancelledCount: runningSummary.cancelledCount, + errorCount: runningSummary.errorCount, + runningCount: runningSummary.runningCount, + children: runningSummary.children + } + } + }); + + const episodePlanRaw = buildSeriesBatchChildPlan( + runningPlan, + episode.titleId, + normalizedParentJobId, + episodeIndex, + runningEpisodes.length + ); + const manualSelectionByTitle = runningPlan?.manualTrackSelectionByTitle + && typeof runningPlan.manualTrackSelectionByTitle === 'object' + ? runningPlan.manualTrackSelectionByTitle + : {}; + const episodeManualSelection = manualSelectionByTitle[String(episode.titleId)] + || manualSelectionByTitle[Number(episode.titleId)] + || null; + const episodeManualSelectionByTitle = episodeManualSelection && typeof episodeManualSelection === 'object' + ? { + [String(episode.titleId)]: { + ...episodeManualSelection, + titleId: Number(episode.titleId) + } + } + : {}; + const episodePlan = { + ...episodePlanRaw, + seriesBatchParent: false, + seriesBatchChild: false, + seriesBatchParentJobId: null, + seriesBatchChildIndex: null, + seriesBatchChildCount: null, + seriesBatchTitleId: null, + seriesBatchVirtualEpisode: true, + manualTrackSelection: episodeManualSelection || null, + manualTrackSelectionByTitle: episodeManualSelectionByTitle + }; + + try { + const episodeResult = await this.startEncodingFromPrepared(normalizedParentJobId, { + encodePlanOverride: episodePlan, + seriesBatchEpisodeContext: { + parentJobId: normalizedParentJobId, + episodeIndex: episode.episodeIndex, + episodeLabel: episode.label, + episodeCount: runningEpisodes.length + } + }); + + const postJob = await historyService.getJobById(normalizedParentJobId); + const postPlanRaw = this.safeParseJson(postJob?.encode_plan_json); + const postPlan = postPlanRaw && typeof postPlanRaw === 'object' ? postPlanRaw : runningPlan; + const postEpisodes = buildSeriesBatchEpisodesFromPlan( + postJob || sourceParentJob, + postPlan, + postPlan.selectedTitleIds || runningEpisodes.map((item) => item.titleId) + ).map((item, idx) => ( + idx === episodeIndex + ? { + ...item, + status: 'FINISHED', + progress: 100, + eta: null, + finishedAt: nowIso(), + error: null, + outputPath: episodeResult?.outputPath || item?.outputPath || null, + trackSelection: episodeResult?.trackSelection || item?.trackSelection || null, + handbrakeInfo: episodeResult?.handbrakeInfo || item?.handbrakeInfo || null + } + : item + )); + const nextPlan = { + ...postPlan, + seriesBatchParent: true, + seriesBatchChild: false, + seriesBatchChildJobIds: [], + seriesBatchCompletedTitleIds: normalizeReviewTitleIdList([ + ...(Array.isArray(postPlan?.seriesBatchCompletedTitleIds) ? postPlan.seriesBatchCompletedTitleIds : []), + episode.titleId, + ...postEpisodes + .filter((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'FINISHED') + .map((item) => item?.titleId) + ]), + seriesBatchEpisodes: postEpisodes + }; + const summary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, postEpisodes); + const allFinished = postEpisodes.length > 0 && postEpisodes.every((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'FINISHED'); + const anyErrors = postEpisodes.some((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'ERROR'); + const anyCancelled = postEpisodes.some((item) => normalizeSeriesEpisodeStatus(item?.status, 'QUEUED') === 'CANCELLED'); + + const previousHandbrakeInfo = this.safeParseJson(postJob?.handbrake_info_json) || {}; + const nextHandbrakeInfo = { + ...(previousHandbrakeInfo && typeof previousHandbrakeInfo === 'object' ? previousHandbrakeInfo : {}), + mode: 'series_batch', + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: summary.totalCount, + finishedCount: summary.finishedCount, + cancelledCount: summary.cancelledCount, + errorCount: summary.errorCount, + runningCount: summary.runningCount, + episodes: postEpisodes + } + }; + + const nextState = allFinished + ? (anyErrors ? 'ERROR' : (anyCancelled ? 'CANCELLED' : 'FINISHED')) + : 'READY_TO_ENCODE'; + const nextLastState = allFinished ? nextState : 'ENCODING'; + await historyService.updateJob(normalizedParentJobId, { + encode_plan_json: JSON.stringify(nextPlan), + handbrake_info_json: JSON.stringify(nextHandbrakeInfo), + status: nextState, + last_state: nextLastState, + end_time: allFinished ? nowIso() : null, + error_message: allFinished && nextState !== 'FINISHED' + ? (nextState === 'ERROR' ? 'Serien-Batch beendet mit Fehlern.' : 'Serien-Batch abgebrochen.') + : null, + output_path: episodeResult?.outputPath || postJob?.output_path || null + }); + + await historyService.appendLog( + normalizedParentJobId, + 'SYSTEM', + `Serien-Episode abgeschlossen (${episode.episodeIndex}/${postEpisodes.length}): ${episode.label}${episodeResult?.outputPath ? ` -> ${episodeResult.outputPath}` : ''}` + ); + + await this.updateProgress(nextState, summary.overallProgress, null, summary.summaryText, normalizedParentJobId, { + contextPatch: { + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: summary.totalCount, + finishedCount: summary.finishedCount, + cancelledCount: summary.cancelledCount, + errorCount: summary.errorCount, + runningCount: summary.runningCount, + children: summary.children + } + } + }); + + if (allFinished && nextState === 'FINISHED') { + void this.notifyPushover('job_finished', { + title: 'Ripster - Job abgeschlossen', + message: `${sourceParentJob.title || sourceParentJob.detected_title || `Job #${normalizedParentJobId}`} (${summary.finishedCount} Episode(n))` + }); + const parentMediaProfile = this.resolveMediaProfileForJob(sourceParentJob, { encodePlan: nextPlan }); + const parentSettings = await settingsService.getEffectiveSettingsMap(parentMediaProfile); + void this.ejectDriveIfEnabled(parentSettings); + + if (Number(this.snapshot.activeJobId) === Number(normalizedParentJobId)) { + setTimeout(async () => { + if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === normalizedParentJobId) { + await this.setState('IDLE', { + finishingJobId: normalizedParentJobId, + activeJobId: null, + progress: 0, + eta: null, + statusText: 'Bereit', + context: {} + }); + } + }, 3000); + } + } + + void this.emitQueueChanged(); + void this.pumpQueue(); + return { + started: true, + seriesBatch: true, + stage: nextState, + finished: allFinished, + outputPath: episodeResult?.outputPath || null + }; + } catch (error) { + const cancelled = this.cancelRequestedByJob.has(Number(normalizedParentJobId)) + || String(error?.runInfo?.status || '').trim().toUpperCase() === 'CANCELLED'; + const postJob = await historyService.getJobById(normalizedParentJobId); + const postPlanRaw = this.safeParseJson(postJob?.encode_plan_json); + const postPlan = postPlanRaw && typeof postPlanRaw === 'object' ? postPlanRaw : runningPlan; + const postEpisodes = buildSeriesBatchEpisodesFromPlan( + postJob || sourceParentJob, + postPlan, + postPlan.selectedTitleIds || runningEpisodes.map((item) => item.titleId) + ).map((item, idx) => { + const itemStatus = normalizeSeriesEpisodeStatus(item?.status, 'QUEUED'); + if (idx === episodeIndex) { + return { + ...item, + status: cancelled ? 'CANCELLED' : 'ERROR', + progress: itemStatus === 'FINISHED' ? 100 : 0, + eta: null, + finishedAt: nowIso(), + error: error?.message || (cancelled ? 'Vom Benutzer abgebrochen.' : 'Encode fehlgeschlagen.') + }; + } + if (itemStatus === 'QUEUED' || itemStatus === 'RUNNING') { + return { + ...item, + status: cancelled ? 'CANCELLED' : 'ERROR', + progress: 0, + eta: null, + finishedAt: nowIso(), + error: cancelled ? 'Batch abgebrochen.' : 'Batch wegen Fehler gestoppt.' + }; + } + return item; + }); + const nextPlan = { + ...postPlan, + seriesBatchParent: true, + seriesBatchChild: false, + seriesBatchChildJobIds: [], + seriesBatchCompletedTitleIds: normalizeReviewTitleIdList( + Array.isArray(postPlan?.seriesBatchCompletedTitleIds) ? postPlan.seriesBatchCompletedTitleIds : [] + ), + seriesBatchEpisodes: postEpisodes + }; + const summary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, postEpisodes); + const nextState = cancelled ? 'CANCELLED' : 'ERROR'; + + const previousHandbrakeInfo = this.safeParseJson(postJob?.handbrake_info_json) || {}; + const nextHandbrakeInfo = { + ...(previousHandbrakeInfo && typeof previousHandbrakeInfo === 'object' ? previousHandbrakeInfo : {}), + mode: 'series_batch', + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: summary.totalCount, + finishedCount: summary.finishedCount, + cancelledCount: summary.cancelledCount, + errorCount: summary.errorCount, + runningCount: summary.runningCount, + episodes: postEpisodes + } + }; + + const queueBefore = this.queueEntries.length; + this.queueEntries = this.queueEntries.filter((entry) => !( + Number(entry?.jobId) === Number(normalizedParentJobId) + && String(entry?.action || '').trim().toUpperCase() === QUEUE_ACTIONS.START_SERIES_EPISODE + )); + const removedSeriesEntries = Math.max(0, queueBefore - this.queueEntries.length); + await historyService.updateJob(normalizedParentJobId, { + encode_plan_json: JSON.stringify(nextPlan), + handbrake_info_json: JSON.stringify(nextHandbrakeInfo), + status: nextState, + last_state: nextState, + end_time: nowIso(), + error_message: error?.message || (cancelled ? 'Vom Benutzer abgebrochen.' : 'Serien-Batch fehlgeschlagen.') + }); + await historyService.appendLog( + normalizedParentJobId, + 'SYSTEM', + `Serien-Batch ${cancelled ? 'abgebrochen' : 'fehlgeschlagen'} nach Episode ${episode.episodeIndex}/${postEpisodes.length} (${episode.label}). Queue-Einträge entfernt: ${removedSeriesEntries}.` + ); + await this.updateProgress(nextState, summary.overallProgress, null, summary.summaryText, normalizedParentJobId, { + contextPatch: { + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: summary.totalCount, + finishedCount: summary.finishedCount, + cancelledCount: summary.cancelledCount, + errorCount: summary.errorCount, + runningCount: summary.runningCount, + children: summary.children + } + } + }); + await this.emitQueueChanged(); + throw error; + } + } + findQueueEntryIndexByJobId(jobId) { return this.queueEntries.findIndex((entry) => Number(entry?.jobId) === Number(jobId)); } @@ -7029,6 +8985,9 @@ class PipelineService extends EventEmitter { historyService.getRunningJobs(), historyService.getQueueIdleJobs() ]); + const visibleRunningJobs = (Array.isArray(runningJobs) ? runningJobs : []).filter((job) => + !this.isSeriesBatchChildJob(job) + ); const runningPoolUsage = this.buildRunningPoolUsage(runningJobs); const runningEncodeCount = runningPoolUsage.filmRunning; const runningCdCount = runningPoolUsage.audioRunning; @@ -7038,11 +8997,14 @@ class PipelineService extends EventEmitter { .filter((id) => Number.isFinite(id) && id > 0); const queuedJobIdSet = new Set(queuedJobIds); const runningJobIdSet = new Set( - runningJobs + visibleRunningJobs .map((job) => Number(job?.id)) .filter((id) => Number.isFinite(id) && id > 0) ); const idleJobs = (Array.isArray(idleJobsRaw) ? idleJobsRaw : []).filter((job) => { + if (this.isSeriesBatchChildJob(job)) { + return false; + } const jobId = Number(job?.id); if (!Number.isFinite(jobId) || jobId <= 0) { return false; @@ -7056,10 +9018,28 @@ class PipelineService extends EventEmitter { const scriptMetaByJobId = await this.buildQueueJobScriptMeta( Array.from( new Map( - [...runningJobs, ...queuedRows, ...idleJobs].map((row) => [Number(row?.id), row]) + [...visibleRunningJobs, ...queuedRows, ...idleJobs].map((row) => [Number(row?.id), row]) ).values() ) ); + const resolveRunningSeriesEpisodeSubtitle = (job) => { + if (!this.isSeriesBatchParentQueueAnchor(job)) { + return null; + } + const plan = this.safeParseJson(job?.encode_plan_json); + const episodes = Array.isArray(plan?.seriesBatchEpisodes) ? plan.seriesBatchEpisodes : []; + if (episodes.length === 0) { + return null; + } + const runningEpisode = episodes.find((episode) => ( + normalizeSeriesEpisodeStatus(episode?.status, 'QUEUED') === 'RUNNING' + )) || null; + const candidate = runningEpisode || episodes.find((episode) => ( + normalizeSeriesEpisodeStatus(episode?.status, 'QUEUED') === 'QUEUED' + )) || null; + const label = String(candidate?.label || '').trim(); + return label || null; + }; const queue = { maxParallelJobs, @@ -7069,9 +9049,10 @@ class PipelineService extends EventEmitter { runningCount: runningEncodeCount, runningCdCount, idleCount: idleJobs.length, - runningJobs: runningJobs.map((job) => ({ + runningJobs: visibleRunningJobs.map((job) => ({ jobId: Number(job.id), title: job.title || job.detected_title || `Job #${job.id}`, + subtitle: resolveRunningSeriesEpisodeSubtitle(job), status: job.status, lastState: job.last_state || null, poolType: this.resolveQueuePoolTypeForJob(job), @@ -7111,13 +9092,23 @@ class PipelineService extends EventEmitter { // type === 'job' const row = queuedById.get(Number(entry.jobId)); const scriptMeta = scriptMetaByJobId.get(Number(entry.jobId)) || null; + const seriesEpisodeLabel = String(entry?.seriesEpisodeLabel || '').trim(); + const displayTitleBase = row?.title || row?.detected_title || `Job #${entry.jobId}`; + const displayTitle = displayTitleBase; + const subtitle = ( + String(entry?.action || '').trim().toUpperCase() === QUEUE_ACTIONS.START_SERIES_EPISODE + && seriesEpisodeLabel + ) + ? seriesEpisodeLabel + : null; return { ...base, jobId: Number(entry.jobId), action: entry.action, actionLabel: QUEUE_ACTION_LABELS[entry.action] || entry.action, poolType: this.resolveQueuePoolTypeForEntry(entry, queuedById), - title: row?.title || row?.detected_title || `Job #${entry.jobId}`, + title: displayTitle, + subtitle, status: row?.status || null, lastState: row?.last_state || null, hasScripts: Boolean(scriptMeta?.hasScripts), @@ -7264,15 +9255,34 @@ class PipelineService extends EventEmitter { poolType = this.resolveQueuePoolTypeForJob(poolJob); } - const existingQueueIndex = this.findQueueEntryIndexByJobId(normalizedJobId); - if (existingQueueIndex >= 0) { - return { - queued: true, - started: false, - queuePosition: existingQueueIndex + 1, - action, - poolType - }; + const allowDuplicateJobEntries = Boolean(options?.allowDuplicateJobEntries); + const uniqueEntryKey = String(options?.uniqueEntryKey || '').trim() || null; + if (!allowDuplicateJobEntries) { + const existingQueueIndex = this.findQueueEntryIndexByJobId(normalizedJobId); + if (existingQueueIndex >= 0) { + return { + queued: true, + started: false, + queuePosition: existingQueueIndex + 1, + action, + poolType + }; + } + } else if (uniqueEntryKey) { + const existingQueueIndex = this.queueEntries.findIndex((entry) => ( + Number(entry?.jobId) === normalizedJobId + && String(entry?.action || '').trim().toUpperCase() === String(action || '').trim().toUpperCase() + && String(entry?.uniqueEntryKey || '').trim() === uniqueEntryKey + )); + if (existingQueueIndex >= 0) { + return { + queued: true, + started: false, + queuePosition: existingQueueIndex + 1, + action, + poolType + }; + } } const [maxFilm, maxCd, maxTotal, cdBypass, runningJobs] = await Promise.all([ @@ -7295,9 +9305,12 @@ class PipelineService extends EventEmitter { }); const laneRunning = poolType === 'audio' ? audioRunning : filmRunning; const laneCap = poolType === 'audio' ? maxCd : maxFilm; - const shouldQueue = poolType === 'audio' && cdBypass + const forceQueue = Boolean(options?.forceQueue); + const shouldQueue = forceQueue + ? true + : (poolType === 'audio' && cdBypass ? (hasBlockingAudioQueueEntry || laneRunning >= laneCap || totalRunning >= maxTotal) - : (this.queueEntries.length > 0 || laneRunning >= laneCap || totalRunning >= maxTotal); + : (this.queueEntries.length > 0 || laneRunning >= laneCap || totalRunning >= maxTotal)); if (!shouldQueue) { const result = await startNow(); await this.emitQueueChanged(); @@ -7315,6 +9328,7 @@ class PipelineService extends EventEmitter { jobId: normalizedJobId, action, poolType, + uniqueEntryKey, ...(options?.entryData && typeof options.entryData === 'object' ? options.entryData : {}), enqueuedAt: nowIso() }); @@ -7465,6 +9479,13 @@ class PipelineService extends EventEmitter { case QUEUE_ACTIONS.START_PREPARED: await this.startPreparedJob(jobId, { immediate: true }); break; + case QUEUE_ACTIONS.START_SERIES_EPISODE: + await this.startSeriesBatchEpisode(jobId, { + immediate: true, + titleId: entry?.seriesTitleId ?? null, + episodeIndex: entry?.seriesEpisodeIndex ?? null + }); + break; case QUEUE_ACTIONS.RETRY: await this.retry(jobId, { immediate: true }); break; @@ -7918,6 +9939,28 @@ class PipelineService extends EventEmitter { nextProgress.context = mergedContext; } this.jobProgress.set(effectiveJobId, nextProgress); + + const normalizedEffectiveJobId = Number(effectiveJobId); + const seriesBatchParentJobId = this.seriesBatchParentByChild.get(normalizedEffectiveJobId) || null; + if ( + seriesBatchParentJobId + && Number(seriesBatchParentJobId) !== normalizedEffectiveJobId + ) { + const normalizedStage = String(stage || '').trim().toUpperCase(); + const nowMs = Date.now(); + const lastSyncAt = this.seriesBatchParentProgressSyncAt.get(Number(seriesBatchParentJobId)) || 0; + const forceSync = normalizedStage === 'FINISHED' || normalizedStage === 'ERROR' || normalizedStage === 'CANCELLED'; + if (forceSync || (nowMs - lastSyncAt) >= 1200) { + this.seriesBatchParentProgressSyncAt.set(Number(seriesBatchParentJobId), nowMs); + void this.updateSeriesBatchParentProgress(seriesBatchParentJobId).catch((error) => { + logger.warn('series-batch:parent-progress:sync-from-child-progress-failed', { + parentJobId: seriesBatchParentJobId, + childJobId: normalizedEffectiveJobId, + error: errorToMeta(error) + }); + }); + } + } } // Only update the global snapshot fields when this update belongs to the @@ -8459,10 +10502,7 @@ class PipelineService extends EventEmitter { logger.info('analyze:start', { devicePath }); const requestedDevicePath = this.normalizeDrivePath(devicePath); if (requestedDevicePath) { - const existingLock = this._getDriveLockByPath(requestedDevicePath); - if (diskDetectionService.isDeviceLocked(requestedDevicePath) || existingLock) { - throw this._buildDriveLockedError(requestedDevicePath, existingLock); - } + await this._ensureDriveUnlockedForAnalyze(requestedDevicePath); } let device; @@ -8563,9 +10603,28 @@ class PipelineService extends EventEmitter { jobKind: resolveJobKindForMediaProfile(mediaProfile) }); + // Surface the newly created analyze job immediately in the Ripper UI, + // even if metadata provider lookups (TMDb/OMDb) take longer. + await this.setState('ANALYZING', { + activeJobId: job.id, + progress: 0, + eta: null, + statusText: 'Disk wird analysiert ...', + context: { + jobId: job.id, + device: deviceWithProfile, + detectedTitle, + mediaProfile + } + }); + try { let effectiveDetectedTitle = detectedTitle; let omdbCandidates = null; + let metadataCandidates = null; + let metadataProvider = 'omdb'; + let seriesAnalysis = null; + let seriesLookupHint = null; let pluginExecution = null; if (analyzePlugin) { const pluginContext = await this.buildPluginContext(analyzePlugin.id, { @@ -8597,13 +10656,97 @@ class PipelineService extends EventEmitter { }); } } - if (!Array.isArray(omdbCandidates)) { + if (mediaProfile === 'dvd' && analyzePlugin?.id === 'dvd') { + try { + const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd'); + const dvdSeriesPluginEnabled = ['1', 'true', 'yes', 'on'].includes( + String(dvdSettings?.dvd_series_plugin_enabled || '').trim().toLowerCase() + ); + const dvdSeriesPrescanEnabled = ['1', 'true', 'yes', 'on'].includes( + String(dvdSettings?.dvd_series_prescan_enabled || '').trim().toLowerCase() + ); + + if (dvdSeriesPluginEnabled && dvdSeriesPrescanEnabled) { + const reviewResult = await this.runPluginReviewScan(analyzePlugin, job, { + jobId: job.id, + deviceInfo: deviceWithProfile, + reviewMode: 'pre_rip', + source: 'HANDBRAKE_SCAN', + silent: true + }); + pluginExecution = this.mergePluginExecutionState(pluginExecution, reviewResult?.pluginExecution || null); + + const scanText = Array.isArray(reviewResult?.scanLines) + ? reviewResult.scanLines.join('\n') + : ''; + if (scanText) { + const { DVDSeriesPlugin } = require('../plugins/DVDSeriesPlugin'); + const dvdSeriesPlugin = new DVDSeriesPlugin(); + const dvdSeriesContext = await this.buildPluginContext(dvdSeriesPlugin.id, { + discInfo: deviceWithProfile, + jobId: job.id, + scanText, + detectedTitle: effectiveDetectedTitle + }); + const seriesPluginResult = await dvdSeriesPlugin.analyze(device.path, job, dvdSeriesContext); + pluginExecution = this.mergePluginExecutionState( + pluginExecution, + this.sanitizePluginExecutionState(dvdSeriesContext.getPluginExecution()) + ); + + seriesAnalysis = seriesPluginResult?.seriesAnalysis || null; + seriesLookupHint = seriesPluginResult?.seriesLookupHint || null; + const seriesLike = Boolean(seriesAnalysis?.summary?.seriesLike); + + if (seriesLike) { + metadataProvider = 'tmdb'; + metadataCandidates = []; + + if (seriesPluginResult?.providerConfigured && seriesLookupHint?.query) { + metadataCandidates = await tmdbService.searchSeriesWithSeasons( + seriesLookupHint.query, + { language: dvdSettings?.dvd_series_language || null } + ).catch((error) => { + logger.warn('dvd-series:tmdb-search-failed', { + jobId: job.id, + query: seriesLookupHint?.query || null, + seasonNumber: seriesLookupHint?.seasonNumber || null, + error: errorToMeta(error) + }); + return []; + }); + } + } + + logger.info('dvd-series:analyze:done', { + jobId: job.id, + seriesLike: Boolean(seriesAnalysis?.summary?.seriesLike), + confidence: seriesAnalysis?.summary?.confidence || null, + metadataProvider, + metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0, + seriesLookupHint + }); + } + } + } catch (error) { + logger.warn('dvd-series:analyze:fallback-legacy', { + jobId: job.id, + error: errorToMeta(error) + }); + } + } + if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) { omdbCandidates = await omdbService.search(effectiveDetectedTitle).catch(() => []); } + if (metadataProvider !== 'tmdb') { + metadataCandidates = Array.isArray(omdbCandidates) ? omdbCandidates : []; + } logger.info('metadata:prepare:result', { jobId: job.id, detectedTitle: effectiveDetectedTitle, - omdbCandidateCount: omdbCandidates.length + metadataProvider, + metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0, + omdbCandidateCount: Array.isArray(omdbCandidates) ? omdbCandidates.length : 0 }); const prepareInfo = this.withPluginExecutionMeta( @@ -8614,7 +10757,11 @@ class PipelineService extends EventEmitter { playlistAnalysis: null, playlistDecisionRequired: false, selectedPlaylist: null, - selectedTitleId: null + selectedTitleId: null, + metadataProvider, + metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], + seriesAnalysis, + seriesLookupHint } }, mediaProfile), pluginExecution @@ -8629,20 +10776,12 @@ class PipelineService extends EventEmitter { await historyService.appendLog( job.id, 'SYSTEM', - `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".` + metadataProvider === 'tmdb' + ? `Serien-DVD erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.` + : `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".` ); - const runningJobs = await historyService.getRunningJobs(); - const foreignRunningJobs = runningJobs.filter((item) => Number(item?.id) !== Number(job.id)); - // Only block metadata selection if a foreign job requires active user interaction - // in the pipeline UI. Autonomous jobs (ENCODING, RIPPING, READY_TO_ENCODE) run - // independently via activeProcesses and must not prevent new disc metadata selection. - const PIPELINE_INTERACTIVE_STATES = new Set([ - 'ANALYZING', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'MEDIAINFO_CHECK' - ]); - const keepCurrentPipelineSession = foreignRunningJobs.some( - (item) => PIPELINE_INTERACTIVE_STATES.has(String(item?.status || '').toUpperCase()) - ); + const keepCurrentPipelineSession = this._hasForeignInteractiveSession(job.id); if (!keepCurrentPipelineSession) { await this.setState('METADATA_SELECTION', { activeJobId: job.id, @@ -8656,8 +10795,12 @@ class PipelineService extends EventEmitter { detectedTitleSource: effectiveDetectedTitle !== detectedTitle ? 'plugin' : (device.discLabel ? 'discLabel' : 'fallback'), + metadataProvider, + metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], omdbCandidates, mediaProfile, + seriesAnalysis, + seriesLookupHint, playlistAnalysis: null, playlistDecisionRequired: false, playlistCandidates: [], @@ -8667,21 +10810,26 @@ class PipelineService extends EventEmitter { } }); } else { + const foreignActiveJobId = this.normalizeQueueJobId(this.snapshot.activeJobId); await historyService.appendLog( job.id, 'SYSTEM', - `Metadaten-Auswahl im Hintergrund vorbereitet. Aktive Session bleibt bei interaktivem Job #${foreignRunningJobs.map((item) => item.id).join(',')}.` + `Metadaten-Auswahl im Hintergrund vorbereitet. Aktive Session bleibt bei interaktivem Job #${foreignActiveJobId || 'unbekannt'}.` ); } void this.notifyPushover('metadata_ready', { title: 'Ripster - Metadaten bereit', - message: `Job #${job.id}: ${effectiveDetectedTitle} (${omdbCandidates.length} Treffer)` + message: `Job #${job.id}: ${effectiveDetectedTitle} (${Array.isArray(metadataCandidates) ? metadataCandidates.length : 0} ${metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'} Treffer)` }); return { jobId: job.id, detectedTitle: effectiveDetectedTitle, + metadataProvider, + metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], + seriesAnalysis, + seriesLookupHint, omdbCandidates }; } catch (error) { @@ -8698,6 +10846,55 @@ class PipelineService extends EventEmitter { return results; } + async searchTmdbSeries(query, seasonNumber = null) { + const normalizedQuery = String(query || '').trim(); + if (!normalizedQuery) { + const error = new Error('TMDb-Suche benötigt einen Titel.'); + error.statusCode = 400; + throw error; + } + + const tmdbConfigured = await tmdbService.isConfigured(); + if (!tmdbConfigured) { + const error = new Error('TMDb Read Access Token fehlt oder ist ungültig.'); + error.statusCode = 412; + throw error; + } + + const seasonFilter = String(seasonNumber || '').trim(); + logger.info('tmdb:series-search', { + query: normalizedQuery, + seasonNumber: seasonFilter || null + }); + + let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery); + if (!Array.isArray(normalizedResults)) { + normalizedResults = []; + } + + if (seasonFilter) { + const seasonFilterLower = seasonFilter.toLowerCase(); + normalizedResults = normalizedResults.filter((row) => { + const rowSeason = String(row?.seasonNumber ?? '').trim().toLowerCase(); + const rowSeasonName = String(row?.seasonName || '').trim().toLowerCase(); + return rowSeason.includes(seasonFilterLower) || rowSeasonName.includes(seasonFilterLower); + }); + } + + logger.info('tmdb:series-search:done', { query: normalizedQuery, count: normalizedResults.length }); + + if (normalizedResults.length === 0) { + const seasonInfo = seasonFilter + ? ` (Staffel-Filter ${seasonFilter})` + : ''; + const error = new Error(`Keine TMDb-Treffer für "${normalizedQuery}"${seasonInfo}.`); + error.statusCode = 404; + throw error; + } + + return normalizedResults; + } + async runDiscTrackReviewForJob(jobId, deviceInfo = null, options = {}) { this.ensureNotBusy('runDiscTrackReviewForJob', jobId); logger.info('disc-track-review:start', { jobId, deviceInfo, options }); @@ -8731,12 +10928,8 @@ class PipelineService extends EventEmitter { ?? this.snapshot.context?.selectedTitleId ?? null; const selectedMakemkvTitleId = normalizeNonNegativeInteger(selectedMakemkvTitleIdRaw); - const selectedMetadata = { - title: job.title || job.detected_title || null, - year: job.year || null, - imdbId: job.imdb_id || null, - poster: job.poster_url || null - }; + const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {}); + const isSeriesDvdReview = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); await this.setState('MEDIAINFO_CHECK', { activeJobId: jobId, @@ -8788,6 +10981,13 @@ class PipelineService extends EventEmitter { error.runInfo = runInfo; throw error; } + if (isSeriesDvdReview) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-DVD erkannt: Mindestlängen-Filter für Vorab-Spurprüfung ist deaktiviert.' + ); + } const review = buildDiscScanReview({ scanJson: parsed, @@ -8796,7 +10996,8 @@ class PipelineService extends EventEmitter { selectedPlaylistId, selectedMakemkvTitleId, mediaProfile, - sourceArg + sourceArg, + disableMinLengthFilter: isSeriesDvdReview }); if (!Array.isArray(review.titles) || review.titles.length === 0) { @@ -8959,12 +11160,7 @@ class PipelineService extends EventEmitter { ? (options?.selectedTitleId ?? null) : (options?.selectedTitleId ?? analyzeContext.selectedTitleId ?? this.snapshot.context?.selectedTitleId ?? null); const selectedMakemkvTitleId = normalizeNonNegativeInteger(selectedTitleSource); - const selectedMetadata = { - title: job.title || job.detected_title || null, - year: job.year || null, - imdbId: job.imdb_id || null, - poster: job.poster_url || null - }; + const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {}); if (this.isPrimaryJob(jobId)) { await this.setState('MEDIAINFO_CHECK', { @@ -9859,9 +12055,43 @@ class PipelineService extends EventEmitter { return this.runMediainfoReviewForJob(jobId, rawPath, options); } - async selectMetadata({ jobId, title, year, imdbId, poster, fromOmdb = null, selectedPlaylist = null, selectedHandBrakeTitleId = null }) { + async selectMetadata({ + jobId, + title, + year, + imdbId, + poster, + fromOmdb = null, + selectedPlaylist = null, + selectedHandBrakeTitleId = null, + selectedHandBrakeTitleIds = null, + metadataProvider = null, + providerId = null, + tmdbId = null, + metadataKind = null, + seasonNumber = null, + seasonName = null, + episodeCount = null, + episodes = null, + discNumber = null + }) { this.ensureNotBusy('selectMetadata', jobId); - logger.info('metadata:selected', { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist, selectedHandBrakeTitleId }); + logger.info('metadata:selected', { + jobId, + title, + year, + imdbId, + poster, + fromOmdb, + selectedPlaylist, + selectedHandBrakeTitleId, + selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null, + metadataProvider, + providerId, + tmdbId, + seasonNumber, + discNumber + }); const job = await historyService.getJobById(jobId); if (!job) { @@ -9883,6 +12113,13 @@ class PipelineService extends EventEmitter { const normalizedSelectedPlaylist = normalizePlaylistId(selectedPlaylist); const normalizedSelectedHandBrakeTitleId = normalizeReviewTitleId(selectedHandBrakeTitleId); + const normalizedSelectedHandBrakeTitleIds = normalizeReviewTitleIdList(selectedHandBrakeTitleIds); + const effectiveSelectedHandBrakeTitleIds = normalizedSelectedHandBrakeTitleIds.length > 0 + ? normalizedSelectedHandBrakeTitleIds + : (normalizedSelectedHandBrakeTitleId ? [normalizedSelectedHandBrakeTitleId] : []); + const effectiveSelectedHandBrakeTitleId = normalizedSelectedHandBrakeTitleId + || effectiveSelectedHandBrakeTitleIds[0] + || null; const waitingForDecision = ( String(job.status || '').trim().toUpperCase() === 'WAITING_FOR_USER_DECISION' || String(job.last_state || '').trim().toUpperCase() === 'WAITING_FOR_USER_DECISION' @@ -9895,7 +12132,7 @@ class PipelineService extends EventEmitter { || (fromOmdb !== null && fromOmdb !== undefined) ); - if ((normalizedSelectedPlaylist || normalizedSelectedHandBrakeTitleId) && waitingForDecision && !hasExplicitMetadataPayload) { + if ((normalizedSelectedPlaylist || effectiveSelectedHandBrakeTitleId) && waitingForDecision && !hasExplicitMetadataPayload) { await historyService.updateJob(jobId, { status: 'MEDIAINFO_CHECK', last_state: 'MEDIAINFO_CHECK', @@ -9907,8 +12144,8 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'USER_ACTION', - normalizedSelectedHandBrakeTitleId - ? `Converter Titel-Auswahl gesetzt: -t ${normalizedSelectedHandBrakeTitleId}.` + effectiveSelectedHandBrakeTitleId + ? `Converter Titel-Auswahl gesetzt: -t ${effectiveSelectedHandBrakeTitleId}${effectiveSelectedHandBrakeTitleIds.length > 1 ? ` (Mehrfachauswahl: ${effectiveSelectedHandBrakeTitleIds.map((id) => `-t ${id}`).join(', ')})` : ''}.` : `Converter Playlist-Auswahl gesetzt: ${toPlaylistFile(normalizedSelectedPlaylist) || normalizedSelectedPlaylist}.` ); @@ -9917,7 +12154,8 @@ class PipelineService extends EventEmitter { mode: 'converter', mediaProfile: 'converter', selectedPlaylist: normalizedSelectedPlaylist || null, - selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId || null, + selectedHandBrakeTitleId: effectiveSelectedHandBrakeTitleId || null, + selectedHandBrakeTitleIds: effectiveSelectedHandBrakeTitleIds, previousEncodePlan: converterPlan }); } catch (error) { @@ -9952,9 +12190,13 @@ class PipelineService extends EventEmitter { const posterValue = poster === undefined ? (job.poster_url || null) : (poster || null); + const effectiveDiscNumber = normalizePositiveInteger(discNumber); - let omdbJsonValue = job.omdb_json || null; - if (fromOmdb && effectiveImdbId) { + const converterMetadataProvider = String(metadataProvider || 'omdb').trim().toLowerCase() || 'omdb'; + let omdbJsonValue = converterMetadataProvider === 'omdb' + ? (job.omdb_json || null) + : null; + if (fromOmdb && effectiveImdbId && converterMetadataProvider === 'omdb') { try { const omdbFull = await omdbService.fetchByImdbId(effectiveImdbId); if (omdbFull?.raw) { @@ -9975,7 +12217,16 @@ class PipelineService extends EventEmitter { title: effectiveTitle, year: effectiveYear, imdbId: effectiveImdbId, - poster: posterValue + poster: posterValue, + metadataProvider: converterMetadataProvider, + providerId: String(providerId || '').trim() || null, + tmdbId: Number(tmdbId || 0) || null, + metadataKind: String(metadataKind || '').trim().toLowerCase() || null, + seasonNumber: Number(seasonNumber || 0) || null, + seasonName: String(seasonName || '').trim() || null, + episodeCount: Number(episodeCount || 0) || 0, + episodes: Array.isArray(episodes) ? episodes : [], + ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}) }, reviewConfirmed: false }; @@ -10015,6 +12266,13 @@ class PipelineService extends EventEmitter { const normalizedSelectedPlaylist = normalizePlaylistId(selectedPlaylist); const normalizedSelectedHandBrakeTitleId = normalizeReviewTitleId(selectedHandBrakeTitleId); + const normalizedSelectedHandBrakeTitleIds = normalizeReviewTitleIdList(selectedHandBrakeTitleIds); + const effectiveSelectedHandBrakeTitleIds = normalizedSelectedHandBrakeTitleIds.length > 0 + ? normalizedSelectedHandBrakeTitleIds + : (normalizedSelectedHandBrakeTitleId ? [normalizedSelectedHandBrakeTitleId] : []); + const effectiveSelectedHandBrakeTitleId = normalizedSelectedHandBrakeTitleId + || effectiveSelectedHandBrakeTitleIds[0] + || null; const waitingForPlaylistSelection = ( job.status === 'WAITING_FOR_USER_DECISION' || job.last_state === 'WAITING_FOR_USER_DECISION' @@ -10027,14 +12285,15 @@ class PipelineService extends EventEmitter { || (fromOmdb !== null && fromOmdb !== undefined) ); - if (normalizedSelectedHandBrakeTitleId && waitingForPlaylistSelection && job.raw_path && !hasExplicitMetadataPayload && !normalizedSelectedPlaylist) { + if (effectiveSelectedHandBrakeTitleId && waitingForPlaylistSelection && job.raw_path && !hasExplicitMetadataPayload && !normalizedSelectedPlaylist) { const currentMkInfo = mkInfo; const currentAnalyzeContext = currentMkInfo?.analyzeContext || {}; const updatedMkInfo = this.withAnalyzeContextMediaProfile({ ...currentMkInfo, analyzeContext: { ...currentAnalyzeContext, - selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId + selectedHandBrakeTitleId: effectiveSelectedHandBrakeTitleId, + selectedHandBrakeTitleIds: effectiveSelectedHandBrakeTitleIds } }, mediaProfile); @@ -10051,19 +12310,21 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'USER_ACTION', - `DVD Titel-Auswahl gesetzt: -t ${normalizedSelectedHandBrakeTitleId}.` + `DVD Titel-Auswahl gesetzt: -t ${effectiveSelectedHandBrakeTitleId}${effectiveSelectedHandBrakeTitleIds.length > 1 ? ` (Mehrfachauswahl: ${effectiveSelectedHandBrakeTitleIds.map((id) => `-t ${id}`).join(', ')})` : ''}.` ); try { await this.runMediainfoReviewForJob(jobId, job.raw_path, { mode: 'rip', mediaProfile, - selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId + selectedHandBrakeTitleId: effectiveSelectedHandBrakeTitleId, + selectedHandBrakeTitleIds: effectiveSelectedHandBrakeTitleIds }); } catch (error) { logger.error('metadata:handbrake-title-selection:review-failed', { jobId, - selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId, + selectedHandBrakeTitleId: effectiveSelectedHandBrakeTitleId, + selectedHandBrakeTitleIds: effectiveSelectedHandBrakeTitleIds, error: errorToMeta(error) }); await this.failJob(jobId, 'MEDIAINFO_CHECK', error); @@ -10134,7 +12395,7 @@ class PipelineService extends EventEmitter { const parsedYear = Number(year); effectiveYear = Number.isNaN(parsedYear) ? null : parsedYear; } - const effectiveImdbId = imdbId === undefined + let effectiveImdbId = imdbId === undefined ? (job.imdb_id || null) : (imdbId || null); const selectedFromOmdb = fromOmdb === null || fromOmdb === undefined @@ -10143,10 +12404,54 @@ class PipelineService extends EventEmitter { const posterValue = poster === undefined ? (job.poster_url || null) : (poster || null); + const effectiveMetadataProvider = String( + metadataProvider + || mkInfo?.analyzeContext?.metadataProvider + || 'omdb' + ).trim().toLowerCase() || 'omdb'; + const effectiveProviderId = String( + providerId + || mkInfo?.analyzeContext?.selectedMetadata?.providerId + || '' + ).trim() || null; + const effectiveTmdbId = Number( + tmdbId + || mkInfo?.analyzeContext?.selectedMetadata?.tmdbId + || 0 + ) || null; + const effectiveMetadataKind = String( + metadataKind + || mkInfo?.analyzeContext?.selectedMetadata?.metadataKind + || '' + ).trim().toLowerCase() || null; + const effectiveDiscNumber = normalizePositiveInteger(discNumber); + const effectiveSeasonNumber = Number( + seasonNumber + || mkInfo?.analyzeContext?.selectedMetadata?.seasonNumber + || 0 + ) || null; + let effectiveSeasonName = String( + seasonName + || mkInfo?.analyzeContext?.selectedMetadata?.seasonName + || '' + ).trim() || null; + let effectiveEpisodeCount = Number( + episodeCount + || mkInfo?.analyzeContext?.selectedMetadata?.episodeCount + || 0 + ) || 0; + let effectiveEpisodes = Array.isArray(episodes) + ? episodes + : (Array.isArray(mkInfo?.analyzeContext?.selectedMetadata?.episodes) + ? mkInfo.analyzeContext.selectedMetadata.episodes + : []); + let tmdbDetails = null; // Fetch full OMDb details when selecting from OMDb with a valid IMDb ID. - let omdbJsonValue = job.omdb_json || null; - if (fromOmdb && effectiveImdbId) { + let omdbJsonValue = effectiveMetadataProvider === 'omdb' + ? (job.omdb_json || null) + : null; + if (fromOmdb && effectiveImdbId && effectiveMetadataProvider === 'omdb') { try { const omdbFull = await omdbService.fetchByImdbId(effectiveImdbId); if (omdbFull?.raw) { @@ -10157,22 +12462,216 @@ class PipelineService extends EventEmitter { } } + let seasonDetails = null; + let seasonCredits = null; + if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId) { + try { + const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, { + appendToResponse: ['credits', 'external_ids'] + }); + tmdbDetails = tmdbService.buildSeriesDetailsSummary(seriesDetails); + if (!effectiveImdbId && tmdbDetails?.imdbId) { + effectiveImdbId = tmdbDetails.imdbId; + } + if ((!effectiveYear || Number(effectiveYear) <= 0) && tmdbDetails?.firstAirDate) { + const tmdbYear = Number(String(tmdbDetails.firstAirDate).slice(0, 4)); + if (Number.isFinite(tmdbYear) && tmdbYear > 0) { + effectiveYear = Math.trunc(tmdbYear); + } + } + const createdBy = tmdbService.normalizeNameList(seriesDetails?.created_by, { maxItems: 3 }); + const genres = tmdbService.normalizeNameList(seriesDetails?.genres, { maxItems: 3 }); + tmdbDetails = { + ...(tmdbDetails && typeof tmdbDetails === 'object' ? tmdbDetails : {}), + createdBy, + genres + }; + } catch (tmdbDetailsErr) { + logger.warn('metadata:tmdb-details-fetch-failed', { + jobId, + tmdbId: effectiveTmdbId, + error: errorToMeta(tmdbDetailsErr) + }); + } + } + + if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveSeasonNumber) { + try { + seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber); + seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber); + const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails); + if (seasonSummary) { + const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : []; + if (fetchedEpisodes.length > 0) { + if (!Array.isArray(effectiveEpisodes) || effectiveEpisodes.length === 0) { + effectiveEpisodes = fetchedEpisodes; + } + } + const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0; + if (fetchedEpisodeCount > 0) { + effectiveEpisodeCount = fetchedEpisodeCount; + } + if (!effectiveSeasonName && seasonSummary.name) { + effectiveSeasonName = String(seasonSummary.name).trim() || null; + } + } + const seasonVoteAverageRaw = Number(seasonDetails?.vote_average || 0); + const seasonVoteAverage = Number.isFinite(seasonVoteAverageRaw) && seasonVoteAverageRaw > 0 + ? Number(seasonVoteAverageRaw.toFixed(1)) + : null; + const seasonRuntimeValues = Array.isArray(seasonDetails?.episodes) + ? seasonDetails.episodes.map((episode) => Number(episode?.runtime || 0)).filter((val) => Number.isFinite(val) && val > 0) + : []; + const seasonRuntimeLabel = tmdbService.formatRuntimeLabel(seasonRuntimeValues); + const seasonCast = tmdbService.normalizeNameList(seasonCredits?.cast, { maxItems: 6 }); + tmdbDetails = { + ...(tmdbDetails && typeof tmdbDetails === 'object' ? tmdbDetails : {}), + seasonNumber: effectiveSeasonNumber, + seasonVoteAverage, + seasonRuntime: seasonRuntimeLabel, + seasonCast + }; + } catch (tmdbSeasonErr) { + logger.warn('metadata:tmdb-season-fetch-failed', { + jobId, + tmdbId: effectiveTmdbId, + seasonNumber: effectiveSeasonNumber, + error: errorToMeta(tmdbSeasonErr) + }); + } + } + const selectedMetadata = { title: effectiveTitle, year: effectiveYear, imdbId: effectiveImdbId, - poster: posterValue + poster: posterValue, + metadataProvider: effectiveMetadataProvider, + providerId: effectiveProviderId, + tmdbId: effectiveTmdbId, + metadataKind: effectiveMetadataKind, + ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}), + seasonNumber: effectiveSeasonNumber, + seasonName: effectiveSeasonName, + episodeCount: effectiveEpisodeCount, + episodes: effectiveEpisodes, + ...(tmdbDetails && typeof tmdbDetails === 'object' ? { tmdbDetails } : {}) }; + + let parentContainerJobId = normalizePositiveInteger(job.parent_job_id || null); + const isDvdSeriesSelection = ( + mediaProfile === 'dvd' + && effectiveMetadataProvider === 'tmdb' + && Number(effectiveTmdbId) > 0 + && Number(effectiveSeasonNumber) > 0 + ); + if (isDvdSeriesSelection) { + if (!parentContainerJobId) { + const existingContainer = await historyService.findSeriesContainerJob(effectiveTmdbId, effectiveSeasonNumber); + if (existingContainer) { + parentContainerJobId = Number(existingContainer.id); + } else { + const likelyContainer = await historyService.findLikelySeriesContainerJob({ + title: effectiveTitle, + year: effectiveYear + }); + if (likelyContainer) { + const likelyMkInfo = this.safeParseJson(likelyContainer?.makemkv_info_json) || {}; + const likelySelected = likelyMkInfo?.analyzeContext?.selectedMetadata + || likelyMkInfo?.selectedMetadata + || {}; + const likelyTmdbId = Number(likelySelected?.tmdbId || 0) || null; + const likelySeason = Number(likelySelected?.seasonNumber || 0) || null; + const seasonCompatible = !likelySeason || likelySeason === Number(effectiveSeasonNumber); + const tmdbCompatible = !likelyTmdbId || likelyTmdbId === Number(effectiveTmdbId); + if (seasonCompatible && tmdbCompatible) { + parentContainerJobId = Number(likelyContainer.id); + logger.info('dvd-series:container:assigned-likely', { + jobId, + parentContainerJobId, + tmdbId: effectiveTmdbId, + seasonNumber: effectiveSeasonNumber + }); + } + } + } + if (!parentContainerJobId) { + const containerJob = await historyService.createJob({ + discDevice: job.disc_device || null, + status: job.status || 'METADATA_SELECTION', + detectedTitle: effectiveTitle || job.detected_title || null, + mediaType: mediaProfile, + jobKind: 'dvd_series_container' + }); + parentContainerJobId = Number(containerJob?.id || 0) || null; + } + } + if (parentContainerJobId) { + const containerMkInfo = this.withAnalyzeContextMediaProfile({ + analyzeContext: { + metadataProvider: effectiveMetadataProvider, + selectedMetadata + } + }, mediaProfile); + await historyService.updateJob(parentContainerJobId, { + title: effectiveTitle, + year: effectiveYear, + imdb_id: effectiveImdbId, + poster_url: posterValue, + selected_from_omdb: selectedFromOmdb, + omdb_json: omdbJsonValue, + status: job.status || 'METADATA_SELECTION', + last_state: job.status || 'METADATA_SELECTION', + media_type: mediaProfile, + job_kind: 'dvd_series_container', + makemkv_info_json: JSON.stringify(containerMkInfo) + }); + } + + const siblingRows = await historyService.listSeriesSiblingJobs(effectiveTmdbId, effectiveSeasonNumber); + for (const sibling of siblingRows) { + const siblingId = Number(sibling?.id || 0); + if (!siblingId || siblingId === Number(jobId)) { + continue; + } + const siblingParentId = normalizePositiveInteger(sibling.parent_job_id || null); + const needsParentUpdate = siblingParentId !== parentContainerJobId; + const needsKindUpdate = String(sibling?.job_kind || '').trim().toLowerCase() !== 'dvd_series_child'; + if (!needsParentUpdate && !needsKindUpdate) { + continue; + } + await historyService.updateJob(siblingId, { + parent_job_id: parentContainerJobId, + job_kind: 'dvd_series_child', + ...(sibling?.media_type ? {} : { media_type: mediaProfile }) + }); + } + } const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' ? 'backup' : 'mkv'; const isBackupMode = ripMode === 'backup'; + const rawTitleBase = isDvdSeriesSelection && effectiveDiscNumber + ? `${selectedMetadata.title || job.detected_title || 'Serie'} - Disc ${effectiveDiscNumber}` + : (selectedMetadata.title || job.detected_title || null); const metadataBase = buildRawMetadataBase({ - title: selectedMetadata.title || job.detected_title || null, + title: rawTitleBase, year: selectedMetadata.year || null }, jobId); - const existingRawPath = findExistingRawDirectory(settings.raw_dir, metadataBase); + const rawStorage = resolveSeriesAwareRawStorage( + settings, + mediaProfile, + selectedMetadata, + mkInfo?.analyzeContext || null + ); + const rawBaseDir = String( + rawStorage?.rawBaseDir + || settings?.raw_dir + || settingsService.DEFAULT_RAW_DIR + || '' + ).trim(); + const existingRawPath = findExistingRawDirectory(rawBaseDir, metadataBase); let updatedRawPath = existingRawPath || null; if (existingRawPath) { const existingRawState = resolveRawFolderStateFromPath(existingRawPath); @@ -10180,7 +12679,7 @@ class PipelineService extends EventEmitter { ? RAW_FOLDER_STATES.INCOMPLETE : RAW_FOLDER_STATES.RIP_COMPLETE; const renamedDirName = buildRawDirName(metadataBase, jobId, { state: renameState }); - const renamedRawPath = path.join(settings.raw_dir, renamedDirName); + const renamedRawPath = path.join(rawBaseDir, renamedDirName); if (existingRawPath !== renamedRawPath && !fs.existsSync(renamedRawPath)) { try { fs.renameSync(existingRawPath, renamedRawPath); @@ -10216,7 +12715,9 @@ class PipelineService extends EventEmitter { playlistAnalysis: playlistDecision.playlistAnalysis || mkInfo?.analyzeContext?.playlistAnalysis || null, playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired), selectedPlaylist: playlistDecision.selectedPlaylist || null, - selectedTitleId: playlistDecision.selectedTitleId ?? null + selectedTitleId: playlistDecision.selectedTitleId ?? null, + metadataProvider: effectiveMetadataProvider, + selectedMetadata } }, mediaProfile); @@ -10230,7 +12731,8 @@ class PipelineService extends EventEmitter { status: nextStatus, last_state: nextStatus, raw_path: updatedRawPath, - makemkv_info_json: JSON.stringify(updatedMakemkvInfo) + makemkv_info_json: JSON.stringify(updatedMakemkvInfo), + ...(parentContainerJobId ? { parent_job_id: parentContainerJobId, job_kind: 'dvd_series_child', media_type: mediaProfile } : {}) }); // Bild in Cache laden (async, blockiert nicht) @@ -10241,17 +12743,7 @@ class PipelineService extends EventEmitter { }); } - const runningJobs = await historyService.getRunningJobs(); - const foreignRunningJobs = runningJobs.filter((item) => Number(item?.id) !== Number(jobId)); - // Only block the state transition if a foreign job requires active user interaction. - // Autonomous jobs (ENCODING, RIPPING, READY_TO_ENCODE) must not prevent this job - // from advancing through its own pipeline steps. - const PIPELINE_INTERACTIVE_STATES = new Set([ - 'ANALYZING', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'MEDIAINFO_CHECK' - ]); - const keepCurrentPipelineSession = foreignRunningJobs.some( - (item) => PIPELINE_INTERACTIVE_STATES.has(String(item?.status || '').toUpperCase()) - ); + const keepCurrentPipelineSession = this._hasForeignInteractiveSession(jobId); if (existingRawPath) { await historyService.appendLog( @@ -10266,6 +12758,13 @@ class PipelineService extends EventEmitter { `Kein bestehendes RAW-Verzeichnis zu den Metadaten gefunden (${metadataBase}).` ); } + if (rawStorage.usingSeriesRawPath) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-DVD RAW-Pfad aktiv: ${rawBaseDir}` + ); + } if (!keepCurrentPipelineSession) { await this.setState(nextStatus, { @@ -10295,10 +12794,11 @@ class PipelineService extends EventEmitter { } }); } else { + const foreignActiveJobId = this.normalizeQueueJobId(this.snapshot.activeJobId); await historyService.appendLog( jobId, 'SYSTEM', - `Metadaten übernommen. Aktive Session bleibt bei laufendem Job #${foreignRunningJobs.map((item) => item.id).join(',')}.` + `Metadaten übernommen. Aktive Session bleibt bei interaktivem Job #${foreignActiveJobId || 'unbekannt'}.` ); } @@ -10488,6 +12988,11 @@ class PipelineService extends EventEmitter { throw error; } + const seriesBatchStart = await this.startSeriesBatchFromPrepared(jobId, job, options); + if (seriesBatchStart?.handled) { + return seriesBatchStart.result; + } + if (isPreRipReadyState) { const rawReuse = await this.resolveUsableRawInputForReadyJob(jobId, job, encodePlanForReadyState); if (rawReuse.hasUsableRawInput) { @@ -10541,6 +13046,14 @@ class PipelineService extends EventEmitter { error_message: null, end_time: null }); + if (job?.parent_job_id) { + await historyService.updateJob(job.parent_job_id, { + status: 'RIPPING', + last_state: 'RIPPING', + error_message: null, + end_time: null + }); + } this.startRipEncode(jobId).catch((error) => { logger.error('startPreparedJob:rip-background-failed', { jobId, error: errorToMeta(error) }); @@ -10678,6 +13191,14 @@ class PipelineService extends EventEmitter { encode_review_confirmed: 0, output_path: null }); + if (job?.parent_job_id) { + await historyService.updateJob(job.parent_job_id, { + status: 'RIPPING', + last_state: 'RIPPING', + error_message: null, + end_time: null + }); + } this.startRipEncode(jobId).catch((error) => { logger.error('startPreparedJob:background-failed', { jobId, error: errorToMeta(error) }); @@ -10813,7 +13334,11 @@ class PipelineService extends EventEmitter { logger.info('confirmEncodeReview:requested', { jobId, selectedEncodeTitleId: options?.selectedEncodeTitleId ?? null, + selectedEncodeTitleIdsCount: Array.isArray(options?.selectedEncodeTitleIds) + ? options.selectedEncodeTitleIds.length + : 0, selectedTrackSelectionProvided: Boolean(options?.selectedTrackSelection), + episodeAssignmentsProvided: Boolean(options?.episodeAssignments && typeof options.episodeAssignments === 'object'), skipPipelineStateUpdate, selectedHandBrakePreset: options?.selectedHandBrakePreset ?? null, selectedPostEncodeScriptIdsCount: Array.isArray(options?.selectedPostEncodeScriptIds) @@ -10872,20 +13397,115 @@ class PipelineService extends EventEmitter { } const selectedEncodeTitleId = options?.selectedEncodeTitleId ?? null; - const planWithSelectionResult = applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId); - let planForConfirm = planWithSelectionResult.plan; - const trackSelectionResult = applyManualTrackSelectionToPlan( - planForConfirm, - options?.selectedTrackSelection || null + const selectedEncodeTitleIds = normalizeReviewTitleIdList(options?.selectedEncodeTitleIds); + const effectiveSelectedEncodeTitleIds = selectedEncodeTitleIds.length > 0 + ? selectedEncodeTitleIds + : normalizeReviewTitleIdList(selectedEncodeTitleId ? [selectedEncodeTitleId] : []); + const planWithSelectionResult = applyEncodeTitleSelectionToPlan( + encodePlan, + selectedEncodeTitleId, + effectiveSelectedEncodeTitleIds ); - if (Array.isArray(trackSelectionResult.subtitleSelectionValidationErrors) && trackSelectionResult.subtitleSelectionValidationErrors.length > 0) { - const error = new Error( - `Subtitle-Auswahl ungültig: ${trackSelectionResult.subtitleSelectionValidationErrors.join(' | ')}` - ); - error.statusCode = 400; - throw error; + let planForConfirm = planWithSelectionResult.plan; + const selectedTrackSelectionPayload = options?.selectedTrackSelection && typeof options.selectedTrackSelection === 'object' + ? options.selectedTrackSelection + : null; + const trackSelectionTargets = normalizeReviewTitleIdList( + Array.isArray(planForConfirm?.selectedTitleIds) && planForConfirm.selectedTitleIds.length > 0 + ? planForConfirm.selectedTitleIds + : [planForConfirm?.encodeInputTitleId] + ); + const trackSelectionResults = []; + if (selectedTrackSelectionPayload && trackSelectionTargets.length > 0) { + for (const trackSelectionTitleId of trackSelectionTargets) { + const perTitleSelection = selectedTrackSelectionPayload?.[trackSelectionTitleId] + || selectedTrackSelectionPayload?.[String(trackSelectionTitleId)] + || null; + if (!perTitleSelection || typeof perTitleSelection !== 'object') { + continue; + } + const selectionResult = applyManualTrackSelectionToPlan( + { + ...planForConfirm, + encodeInputTitleId: trackSelectionTitleId + }, + { + [trackSelectionTitleId]: perTitleSelection + } + ); + if ( + Array.isArray(selectionResult.subtitleSelectionValidationErrors) + && selectionResult.subtitleSelectionValidationErrors.length > 0 + ) { + const error = new Error( + `Subtitle-Auswahl ungültig (Titel #${trackSelectionTitleId}): ${selectionResult.subtitleSelectionValidationErrors.join(' | ')}` + ); + error.statusCode = 400; + throw error; + } + planForConfirm = { + ...selectionResult.plan, + encodeInputTitleId: planWithSelectionResult?.plan?.encodeInputTitleId || planForConfirm?.encodeInputTitleId || null, + encodeInputPath: planWithSelectionResult?.plan?.encodeInputPath || planForConfirm?.encodeInputPath || null + }; + trackSelectionResults.push({ + titleId: trackSelectionTitleId, + audioTrackIds: selectionResult.audioTrackIds, + subtitleTrackIds: selectionResult.subtitleTrackIds, + subtitleForcedTrackIndexes: selectionResult.subtitleForcedTrackIndexes, + subtitleSelectionValidationErrors: selectionResult.subtitleSelectionValidationErrors + }); + } } - planForConfirm = trackSelectionResult.plan; + const primaryTrackSelectionResult = trackSelectionResults.find((entry) => + Number(entry?.titleId) === Number(planForConfirm?.encodeInputTitleId) + ) || trackSelectionResults[0] || { + audioTrackIds: [], + subtitleTrackIds: [], + subtitleForcedTrackIndexes: [], + subtitleSelectionValidationErrors: [] + }; + const normalizedEpisodeAssignments = normalizeEpisodeAssignmentsPayload( + options?.episodeAssignments, + planForConfirm?.selectedTitleIds || [] + ); + const titleById = new Map( + (Array.isArray(planForConfirm?.titles) ? planForConfirm.titles : []) + .map((title) => [Number(title?.id), title]) + ); + const remappedTitlesWithEpisodes = (Array.isArray(planForConfirm?.titles) ? planForConfirm.titles : []).map((title) => { + const titleId = normalizeReviewTitleId(title?.id); + const assignment = titleId ? normalizedEpisodeAssignments[String(titleId)] : null; + if (!assignment) { + return title; + } + const episodeTitle = String(assignment?.episodeTitle || '').trim() || null; + return { + ...title, + episodeId: assignment?.episodeId || null, + episodeNumber: assignment?.episodeNumber ?? null, + seasonNumber: assignment?.seasonNumber ?? title?.seasonNumber ?? null, + episodeTitle, + fileName: episodeTitle || title?.fileName || `Title #${titleId}` + }; + }); + const normalizedEpisodeAssignmentsWithPath = Object.fromEntries( + Object.entries(normalizedEpisodeAssignments).map(([titleIdKey, assignment]) => { + const resolvedTitle = titleById.get(Number(titleIdKey)) || null; + return [ + titleIdKey, + { + ...assignment, + filePath: resolvedTitle?.filePath || null + } + ]; + }) + ); + planForConfirm = { + ...planForConfirm, + titles: remappedTitlesWithEpisodes, + episodeAssignments: normalizedEpisodeAssignmentsWithPath + }; const hasExplicitPostScriptSelection = options?.selectedPostEncodeScriptIds !== undefined; const selectedPostEncodeScriptIds = hasExplicitPostScriptSelection ? normalizeScriptIdList(options?.selectedPostEncodeScriptIds || []) @@ -11025,10 +13645,12 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'USER_ACTION', - `Mediainfo-Prüfung bestätigt.${isPreRipMode ? ' Backup/Rip darf gestartet werden.' : ' Encode darf gestartet werden.'}${confirmedPlan.encodeInputTitleId ? ` Gewählter Titel #${confirmedPlan.encodeInputTitleId}.` : ''}` - + ` Audio-Spuren: ${trackSelectionResult.audioTrackIds.length > 0 ? trackSelectionResult.audioTrackIds.join(',') : 'none'}.` - + ` Subtitle-Spuren: ${trackSelectionResult.subtitleTrackIds.length > 0 ? trackSelectionResult.subtitleTrackIds.join(',') : 'none'}.` - + ` Subtitle-Forced-Indizes: ${Array.isArray(trackSelectionResult.subtitleForcedTrackIndexes) && trackSelectionResult.subtitleForcedTrackIndexes.length > 0 ? trackSelectionResult.subtitleForcedTrackIndexes.join(',') : 'none'}.` + `Mediainfo-Prüfung bestätigt.${isPreRipMode ? ' Backup/Rip darf gestartet werden.' : ' Encode darf gestartet werden.'}${confirmedPlan.encodeInputTitleId ? ` Primär-Titel #${confirmedPlan.encodeInputTitleId}.` : ''}` + + ` Gewählte Titel: ${Array.isArray(confirmedPlan?.selectedTitleIds) && confirmedPlan.selectedTitleIds.length > 0 ? confirmedPlan.selectedTitleIds.map((id) => `#${id}`).join(', ') : 'none'}.` + + ` Audio-Spuren (Primär): ${primaryTrackSelectionResult.audioTrackIds.length > 0 ? primaryTrackSelectionResult.audioTrackIds.join(',') : 'none'}.` + + ` Subtitle-Spuren (Primär): ${primaryTrackSelectionResult.subtitleTrackIds.length > 0 ? primaryTrackSelectionResult.subtitleTrackIds.join(',') : 'none'}.` + + ` Subtitle-Forced-Indizes (Primär): ${Array.isArray(primaryTrackSelectionResult.subtitleForcedTrackIndexes) && primaryTrackSelectionResult.subtitleForcedTrackIndexes.length > 0 ? primaryTrackSelectionResult.subtitleForcedTrackIndexes.join(',') : 'none'}.` + + ` Episoden-Zuordnung: ${Object.keys(confirmedPlan?.episodeAssignments || {}).length > 0 ? Object.keys(confirmedPlan.episodeAssignments).length : 0}.` + ` Pre-Encode-Scripte: ${selectedPreEncodeScripts.length > 0 ? selectedPreEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.` + ` Pre-Encode-Ketten: ${selectedPreEncodeChainIds.length > 0 ? selectedPreEncodeChainIds.join(',') : 'none'}.` + ` Post-Encode-Scripte: ${selectedPostEncodeScripts.length > 0 ? selectedPostEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.` @@ -11091,16 +13713,6 @@ class PipelineService extends EventEmitter { const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json); - // Importierte Jobs (RAW-Ordner-Import) müssen zuerst vollständig analysiert und - // encodiert werden, bevor ein Re-Encode möglich ist. Nur "Neu einlesen" ist erlaubt. - if (mkInfo?.source === 'orphan_raw_import' && !sourceJob.handbrake_info_json) { - const error = new Error( - 'Re-Encode nicht möglich: Job wurde via RAW-Import erstellt und wurde noch nicht vollständig encodiert. Bitte zuerst "Neu einlesen" ausführen.' - ); - error.statusCode = 409; - throw error; - } - const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json); const reencodeMediaProfile = this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: mkInfo, @@ -11719,6 +14331,7 @@ class PipelineService extends EventEmitter { const reviewPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'review').catch(() => null); let reviewPluginExecution = null; const analyzeContext = mkInfo?.analyzeContext || {}; + let effectiveAnalyzeContext = analyzeContext; const selectedPlaylistId = normalizePlaylistId( analyzeContext.selectedPlaylist || this.snapshot.context?.selectedPlaylist @@ -11740,6 +14353,8 @@ class PipelineService extends EventEmitter { } const isConverterReview = String(options?.mode || '').trim().toLowerCase() === 'converter' || String(mediaProfile || '').trim().toLowerCase() === 'converter'; + const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {}); + const isSeriesDvdReview = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); const reviewSettings = isConverterReview ? { ...settings, @@ -11747,7 +14362,12 @@ class PipelineService extends EventEmitter { handbrake_preset: '', handbrake_extra_args: '' } - : settings; + : (isSeriesDvdReview + ? { + ...settings, + makemkv_min_length_minutes: 0 + } + : settings); await historyService.appendLog( jobId, 'SYSTEM', @@ -11770,12 +14390,13 @@ class PipelineService extends EventEmitter { }; } - const selectedMetadata = { - title: job.title || job.detected_title || null, - year: job.year || null, - imdbId: job.imdb_id || null, - poster: job.poster_url || null - }; + if (isSeriesDvdReview) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-DVD erkannt: Mindestlängen-Filter für Titelprüfung ist deaktiviert.' + ); + } if (this.isPrimaryJob(jobId)) { await this.setState('MEDIAINFO_CHECK', { @@ -11877,17 +14498,40 @@ class PipelineService extends EventEmitter { dvdHandBrakeScanJson = parseMediainfoJsonOutput(dvdScanLines.join('\n')); if (dvdHandBrakeScanJson) { - const minLengthMinutes = Number(settings?.makemkv_min_length_minutes || 0); + const minLengthMinutes = Number(reviewSettings?.makemkv_min_length_minutes || 0); const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60)); const allDvdTitles = parseHandBrakeTitleList(dvdHandBrakeScanJson); - const filteredDvdCandidates = minLengthSeconds > 0 + const filteredDvdCandidatesRaw = minLengthSeconds > 0 ? allDvdTitles.filter((t) => t.durationSeconds >= minLengthSeconds) : allDvdTitles; + const seriesScanContextResult = isSeriesDvdReview + ? enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, dvdScanLines) + : { analyzeContext: effectiveAnalyzeContext, derived: false, reason: 'not_series' }; + effectiveAnalyzeContext = seriesScanContextResult.analyzeContext; + if (seriesScanContextResult.derived) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-Titelklassifizierung aus HandBrake-Scan aktualisiert (PlayAll/Kurz/Duplikate markiert).' + ); + } + const filteredSeriesResult = filterSeriesDvdHandBrakeCandidates(filteredDvdCandidatesRaw, effectiveAnalyzeContext); + const filteredDvdCandidates = filteredSeriesResult.candidates; + const minLengthLabel = minLengthSeconds > 0 + ? `≥ ${minLengthMinutes} min` + : 'ohne Mindestlänge'; await historyService.appendLog( jobId, 'SYSTEM', - `HandBrake DVD-Scan: ${allDvdTitles.length} Titel gesamt, ${filteredDvdCandidates.length} ≥ ${minLengthMinutes} min.` + `HandBrake DVD-Scan: ${allDvdTitles.length} Titel gesamt, ${filteredDvdCandidates.length} ${minLengthLabel}.` ); + if (filteredSeriesResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Filter auf DVD-Titel angewendet: ${filteredSeriesResult.sourceCount} -> ${filteredSeriesResult.resultCount} (${filteredSeriesResult.reason || 'heuristik'}).` + ); + } // For synthetic mediaInfo: prefer the single MIN_LENGTH candidate; otherwise fall back to // the best available title (longest / main feature) as a placeholder for the review. const preferredTitleId = filteredDvdCandidates.length === 1 @@ -12126,16 +14770,58 @@ class PipelineService extends EventEmitter { const dvdScanInputPath = dvdHandBrakeScanInputPath || (rawMedia.source === 'dvd' ? rawPath : mediaFiles[0].path); const preSelectedHandBrakeTitleId = normalizeReviewTitleId(options?.selectedHandBrakeTitleId); - if (preSelectedHandBrakeTitleId) { - enrichedReview = { - ...enrichedReview, - handBrakeTitleId: preSelectedHandBrakeTitleId, - encodeInputPath: dvdScanInputPath - }; + const preSelectedHandBrakeTitleIds = normalizeReviewTitleIdList( + options?.selectedHandBrakeTitleIds + || analyzeContext?.selectedHandBrakeTitleIds + ); + const effectivePreSelectedHandBrakeTitleIds = preSelectedHandBrakeTitleIds.length > 0 + ? preSelectedHandBrakeTitleIds + : (preSelectedHandBrakeTitleId ? [preSelectedHandBrakeTitleId] : []); + if (effectivePreSelectedHandBrakeTitleIds.length > 0) { + const selectedReviewTitles = buildSelectedHandBrakeReviewTitles( + dvdHandBrakeScanJson, + effectivePreSelectedHandBrakeTitleIds, + { encodeInputPath: dvdScanInputPath } + ); + const resolvedSelectedTitleIds = selectedReviewTitles + .map((title) => normalizeReviewTitleId(title?.id)) + .filter(Boolean); + const resolvedPrimaryTitleId = normalizeReviewTitleId(preSelectedHandBrakeTitleId) + || resolvedSelectedTitleIds[0] + || null; + if (selectedReviewTitles.length > 0 && resolvedPrimaryTitleId) { + const selectedSet = new Set(resolvedSelectedTitleIds.map((id) => Number(id))); + const normalizedTitles = selectedReviewTitles.map((title) => { + const titleId = normalizeReviewTitleId(title?.id); + const selectedForEncode = selectedSet.has(Number(titleId)); + return { + ...title, + selectedForEncode, + encodeInput: titleId === resolvedPrimaryTitleId + }; + }); + enrichedReview = { + ...enrichedReview, + titles: normalizedTitles, + selectedTitleIds: resolvedSelectedTitleIds, + encodeInputTitleId: resolvedPrimaryTitleId, + handBrakeTitleId: resolvedPrimaryTitleId, + handBrakeTitleIds: resolvedSelectedTitleIds, + encodeInputPath: dvdScanInputPath, + titleSelectionRequired: false + }; + } else { + enrichedReview = { + ...enrichedReview, + handBrakeTitleId: resolvedPrimaryTitleId, + handBrakeTitleIds: effectivePreSelectedHandBrakeTitleIds, + encodeInputPath: dvdScanInputPath + }; + } await historyService.appendLog( jobId, 'SYSTEM', - `HandBrake Titel-Auswahl (manuell) übernommen: -t ${preSelectedHandBrakeTitleId}` + `HandBrake Titel-Auswahl (manuell) übernommen: -t ${resolvedPrimaryTitleId || '-'}${effectivePreSelectedHandBrakeTitleIds.length > 1 ? ` (Mehrfachauswahl: ${effectivePreSelectedHandBrakeTitleIds.map((id) => `-t ${id}`).join(', ')})` : ''}` ); } else if (dvdSelectedTitleInfo) { // Exactly one title passed the MIN_LENGTH filter (or fallback to best available) — @@ -12143,6 +14829,7 @@ class PipelineService extends EventEmitter { enrichedReview = { ...enrichedReview, handBrakeTitleId: dvdSelectedTitleInfo.handBrakeTitleId, + handBrakeTitleIds: [dvdSelectedTitleInfo.handBrakeTitleId], encodeInputPath: dvdScanInputPath }; await historyService.appendLog( @@ -12248,18 +14935,42 @@ class PipelineService extends EventEmitter { dvdTitleScanJson = parseMediainfoJsonOutput(dvdTitleScanLines.join('\n')); if (dvdTitleScanJson) { const allTitles = parseHandBrakeTitleList(dvdTitleScanJson); - const minLengthMinutes = Number(settings?.makemkv_min_length_minutes || 0); + const minLengthMinutes = Number(reviewSettings?.makemkv_min_length_minutes || 0); const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60)); - const titleCandidates = allTitles.filter((t) => t.durationSeconds >= minLengthSeconds); + const titleCandidatesRaw = allTitles.filter((t) => t.durationSeconds >= minLengthSeconds); + const seriesScanContextResult = isSeriesDvdReview + ? enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, dvdTitleScanLines) + : { analyzeContext: effectiveAnalyzeContext, derived: false, reason: 'not_series' }; + effectiveAnalyzeContext = seriesScanContextResult.analyzeContext; + if (seriesScanContextResult.derived) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-Titelklassifizierung aus HandBrake-Titelscan aktualisiert (PlayAll/Kurz/Duplikate markiert).' + ); + } + const filteredSeriesResult = filterSeriesDvdHandBrakeCandidates(titleCandidatesRaw, effectiveAnalyzeContext); + const titleCandidates = filteredSeriesResult.candidates; + const minLengthLabel = minLengthSeconds > 0 + ? `≥ ${minLengthMinutes} min` + : 'ohne Mindestlänge'; await historyService.appendLog( jobId, 'SYSTEM', - `HandBrake DVD Titel-Scan: ${allTitles.length} gesamt, ${titleCandidates.length} ≥ ${minLengthMinutes} min.` + `HandBrake DVD Titel-Scan: ${allTitles.length} gesamt, ${titleCandidates.length} ${minLengthLabel}.` ); + if (filteredSeriesResult.applied) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Filter auf DVD-Titel angewendet: ${filteredSeriesResult.sourceCount} -> ${filteredSeriesResult.resultCount} (${filteredSeriesResult.reason || 'heuristik'}).` + ); + } if (titleCandidates.length === 1) { enrichedReview = { ...enrichedReview, handBrakeTitleId: titleCandidates[0].handBrakeTitleId, + handBrakeTitleIds: [titleCandidates[0].handBrakeTitleId], encodeInputPath: dvdScanInputPath }; await historyService.appendLog( @@ -12340,7 +15051,9 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'SYSTEM', - `HandBrake DVD Titel-Scan: Kein Titel ≥ ${minLengthMinutes} min. Ohne Titel-ID fortgefahren.` + minLengthSeconds > 0 + ? `HandBrake DVD Titel-Scan: Kein Titel ≥ ${minLengthMinutes} min. Ohne Titel-ID fortgefahren.` + : 'HandBrake DVD Titel-Scan: Kein verwertbarer Titel gefunden. Ohne Titel-ID fortgefahren.' ); enrichedReview = { ...enrichedReview, encodeInputPath: dvdScanInputPath }; } @@ -12865,7 +15578,7 @@ class PipelineService extends EventEmitter { }; } - async startEncodingFromPrepared(jobId) { + async startEncodingFromPrepared(jobId, options = {}) { this.ensureNotBusy('startEncodingFromPrepared', jobId); logger.info('encode:start-from-prepared', { jobId }); @@ -12876,7 +15589,21 @@ class PipelineService extends EventEmitter { throw error; } - const encodePlan = this.safeParseJson(job.encode_plan_json); + const encodePlanOverride = options?.encodePlanOverride && typeof options.encodePlanOverride === 'object' + ? options.encodePlanOverride + : null; + const encodePlan = encodePlanOverride || this.safeParseJson(job.encode_plan_json); + const seriesBatchEpisodeContext = options?.seriesBatchEpisodeContext && typeof options.seriesBatchEpisodeContext === 'object' + ? options.seriesBatchEpisodeContext + : null; + const isSeriesBatchEpisodeRun = Boolean(seriesBatchEpisodeContext); + const seriesBatchParentJobId = this.resolveSeriesBatchParentJobId({ + ...job, + encodePlan + }); + if (seriesBatchParentJobId && !isSeriesBatchEpisodeRun) { + this.seriesBatchParentByChild.set(Number(jobId), Number(seriesBatchParentJobId)); + } const mediaProfile = this.resolveMediaProfileForJob(job, { encodePlan, rawPath: job.raw_path @@ -12897,7 +15624,9 @@ class PipelineService extends EventEmitter { const movieDir = settings.movie_dir; ensureDir(movieDir); const mode = encodePlan?.mode || this.snapshot.context?.mode || 'rip'; - let inputPath = job.encode_input_path || encodePlan?.encodeInputPath || this.snapshot.context?.inputPath || null; + let inputPath = isSeriesBatchEpisodeRun + ? (encodePlan?.encodeInputPath || job.encode_input_path || this.snapshot.context?.inputPath || null) + : (job.encode_input_path || encodePlan?.encodeInputPath || this.snapshot.context?.inputPath || null); let playlistDecision = null; const resolveInputFromRaw = (rawPathCandidate) => { if (!rawPathCandidate) { @@ -12943,15 +15672,34 @@ class PipelineService extends EventEmitter { throw error; } - const incompleteOutputPath = buildIncompleteOutputPathFromJob(settings, job, jobId); - const preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, job, jobId); + const outputPathJobView = encodePlanOverride + ? { + ...job, + encode_plan_json: JSON.stringify(encodePlanOverride) + } + : job; + const incompleteOutputPath = buildIncompleteOutputPathFromJob(settings, outputPathJobView, jobId); + const preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, outputPathJobView, jobId); ensureDir(path.dirname(incompleteOutputPath)); + const liveJobContext = this.jobProgress.get(Number(jobId))?.context; + const existingSeriesBatchContext = isSeriesBatchEpisodeRun + ? ( + (liveJobContext?.seriesBatch && typeof liveJobContext.seriesBatch === 'object' + ? liveJobContext.seriesBatch + : null) + || (this.snapshot.context?.seriesBatch && typeof this.snapshot.context.seriesBatch === 'object' + ? this.snapshot.context.seriesBatch + : null) + ) + : null; await this.setState('ENCODING', { activeJobId: jobId, progress: 0, eta: null, - statusText: mode === 'reencode' ? 'Re-Encoding mit HandBrake' : 'Encoding mit HandBrake', + statusText: isSeriesBatchEpisodeRun + ? `Serien-Episode läuft: ${seriesBatchEpisodeContext?.episodeLabel || `Episode ${seriesBatchEpisodeContext?.episodeIndex || '?'}`}` + : (mode === 'reencode' ? 'Re-Encoding mit HandBrake' : 'Encoding mit HandBrake'), context: { jobId, mode, @@ -12965,7 +15713,8 @@ class PipelineService extends EventEmitter { year: job.year || null, imdbId: job.imdb_id || null, poster: job.poster_url || null - } + }, + ...(existingSeriesBatchContext ? { seriesBatch: existingSeriesBatchContext } : {}) } }); @@ -12976,19 +15725,28 @@ class PipelineService extends EventEmitter { encode_input_path: inputPath, ...(activeRawPath ? { raw_path: activeRawPath } : {}) }); + if (seriesBatchParentJobId) { + await this.updateSeriesBatchParentProgress(seriesBatchParentJobId).catch((error) => { + logger.warn('series-batch:parent-progress:update-on-child-start-failed', { + parentJobId: seriesBatchParentJobId, + childJobId: jobId, + error: errorToMeta(error) + }); + }); + } await historyService.appendLog( jobId, 'SYSTEM', - `Temporärer Encode-Output: ${incompleteOutputPath} (wird nach erfolgreichem Encode in den finalen Zielordner verschoben).` + `${isSeriesBatchEpisodeRun ? `[Episode ${seriesBatchEpisodeContext?.episodeIndex || '?'}] ` : ''}Temporärer Encode-Output: ${incompleteOutputPath} (wird nach erfolgreichem Encode in den finalen Zielordner verschoben).` ); - if (mode === 'reencode') { + if (!isSeriesBatchEpisodeRun && mode === 'reencode') { void this.notifyPushover('reencode_started', { title: 'Ripster - Re-Encode gestartet', message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` }); - } else { + } else if (!isSeriesBatchEpisodeRun) { void this.notifyPushover('encoding_started', { title: 'Ripster - Encoding gestartet', message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}` @@ -13068,6 +15826,17 @@ class PipelineService extends EventEmitter { `HandBrake Titel-Mapping aus Vorbereitung übernommen: -t ${handBrakeTitleId}` ); } + if (!handBrakeTitleId && isSeriesBatchEpisodeRun) { + const seriesEpisodeFallbackTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId); + if (seriesEpisodeFallbackTitleId) { + handBrakeTitleId = seriesEpisodeFallbackTitleId; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-Episode nutzt Titel-ID aus Encode-Plan: -t ${handBrakeTitleId}` + ); + } + } const selectedPlaylistId = normalizePlaylistId( encodePlan?.selectedPlaylistId || (Array.isArray(encodePlan?.titles) @@ -13217,18 +15986,137 @@ class PipelineService extends EventEmitter { `HandBrake Titel-Selektion aktiv: -t ${handBrakeTitleId}` ); } - const handBrakeProgressParser = encodeScriptProgressTracker.hasScriptSteps - ? (line) => { - const parsed = parseHandBrakeProgress(line); - if (!parsed || parsed.percent === null || parsed.percent === undefined) { - return parsed; - } - return { - ...parsed, - percent: encodeScriptProgressTracker.mapHandBrakePercent(parsed.percent) - }; + const buildSeriesBatchLiveContextPatch = (episodeProgressPercent, episodeEta = null) => { + if (!isSeriesBatchEpisodeRun) { + return null; } - : parseHandBrakeProgress; + const liveContext = this.jobProgress.get(Number(jobId))?.context; + const snapshotContext = Number(this.snapshot?.activeJobId) === Number(jobId) + ? this.snapshot?.context + : null; + const currentSeriesBatch = ( + liveContext?.seriesBatch && typeof liveContext.seriesBatch === 'object' + ? liveContext.seriesBatch + : null + ) || ( + snapshotContext?.seriesBatch && typeof snapshotContext.seriesBatch === 'object' + ? snapshotContext.seriesBatch + : null + ); + if (!currentSeriesBatch) { + return null; + } + + const rawChildren = Array.isArray(currentSeriesBatch.children) ? currentSeriesBatch.children : []; + if (rawChildren.length === 0) { + return null; + } + + const normalizedEpisodeIndex = normalizePositiveInteger(seriesBatchEpisodeContext?.episodeIndex); + const normalizedEpisodeTitleId = normalizeReviewTitleId( + seriesBatchEpisodeContext?.titleId + ?? encodePlan?.encodeInputTitleId + ?? null + ); + + let targetChildIndex = rawChildren.findIndex((child) => ( + (normalizedEpisodeIndex && normalizePositiveInteger(child?.episodeIndex) === normalizedEpisodeIndex) + || (normalizedEpisodeTitleId && normalizeReviewTitleId(child?.titleId) === normalizedEpisodeTitleId) + )); + if (targetChildIndex < 0 && normalizedEpisodeIndex && normalizedEpisodeIndex <= rawChildren.length) { + targetChildIndex = normalizedEpisodeIndex - 1; + } + if (targetChildIndex < 0) { + targetChildIndex = rawChildren.findIndex((child) => normalizeSeriesEpisodeStatus(child?.status, 'QUEUED') === 'RUNNING'); + } + if (targetChildIndex < 0) { + targetChildIndex = 0; + } + + const clampedEpisodeProgress = Number.isFinite(Number(episodeProgressPercent)) + ? Math.max(0, Math.min(100, Number(episodeProgressPercent))) + : 0; + const normalizedEpisodeEta = episodeEta !== undefined ? episodeEta : null; + + let finishedCount = 0; + let cancelledCount = 0; + let errorCount = 0; + let runningCount = 0; + const nextChildren = rawChildren.map((child, childIndex) => { + const currentStatus = normalizeSeriesEpisodeStatus(child?.status, 'QUEUED'); + const isTargetChild = childIndex === targetChildIndex; + const nextStatus = ( + isTargetChild + && currentStatus !== 'FINISHED' + && currentStatus !== 'ERROR' + && currentStatus !== 'CANCELLED' + ) + ? 'RUNNING' + : currentStatus; + let nextProgress = Number(child?.progress); + if (nextStatus === 'FINISHED') { + nextProgress = 100; + } else if (isTargetChild && nextStatus === 'RUNNING') { + nextProgress = clampedEpisodeProgress; + } else if (!Number.isFinite(nextProgress)) { + nextProgress = 0; + } + const normalizedProgress = Math.max(0, Math.min(100, Number(nextProgress) || 0)); + + if (nextStatus === 'FINISHED') { + finishedCount += 1; + } else if (nextStatus === 'CANCELLED') { + cancelledCount += 1; + } else if (nextStatus === 'ERROR') { + errorCount += 1; + } else if (nextStatus === 'RUNNING') { + runningCount += 1; + } + + return { + ...child, + status: nextStatus, + progress: Number(normalizedProgress.toFixed(2)), + eta: isTargetChild ? normalizedEpisodeEta : (child?.eta ?? null) + }; + }); + + const totalCountRaw = Number(currentSeriesBatch.totalCount || 0); + const totalCount = Number.isFinite(totalCountRaw) && totalCountRaw > 0 + ? Math.trunc(totalCountRaw) + : nextChildren.length; + + return { + seriesBatch: { + ...currentSeriesBatch, + parentJobId: Number(currentSeriesBatch.parentJobId || jobId), + totalCount, + finishedCount, + cancelledCount, + errorCount, + runningCount, + children: nextChildren + } + }; + }; + const handBrakeProgressParser = (line) => { + const parsed = parseHandBrakeProgress(line); + if (!parsed || parsed.percent === null || parsed.percent === undefined) { + return parsed; + } + const mappedPercent = encodeScriptProgressTracker.hasScriptSteps + ? encodeScriptProgressTracker.mapHandBrakePercent(parsed.percent) + : parsed.percent; + const nextParsed = { + ...parsed, + percent: mappedPercent + }; + const seriesBatchContextPatch = buildSeriesBatchLiveContextPatch(mappedPercent, parsed.eta); + if (seriesBatchContextPatch) { + nextParsed.contextPatch = seriesBatchContextPatch; + } + return nextParsed; + }; let handbrakeInfo = null; const encodeCtx = await this.buildPluginContext(encodePlugin.id, { jobId, @@ -13255,7 +16143,13 @@ class PipelineService extends EventEmitter { preferredFinalOutputPath ); const finalizedOutputPath = outputFinalization.outputPath; - chownRecursive(path.dirname(finalizedOutputPath), settings.movie_dir_owner); + const seriesOutputParts = resolveSeriesOutputPathParts(settings, outputPathJobView, jobId); + const outputOwner = String( + seriesOutputParts?.rootOwner + || settings.movie_dir_owner + || '' + ).trim(); + chownRecursive(path.dirname(finalizedOutputPath), outputOwner); if (outputFinalization.outputPathWithTimestamp) { await historyService.appendLog( jobId, @@ -13354,6 +16248,25 @@ class PipelineService extends EventEmitter { postEncodeScripts: postEncodeScriptsSummary }, encodePluginExecution); + if (isSeriesBatchEpisodeRun) { + await historyService.updateJob(jobId, { + handbrake_info_json: JSON.stringify(handbrakeInfoWithPostScripts), + status: 'ENCODING', + last_state: 'ENCODING', + end_time: null, + raw_path: finalizedRawPath, + rip_successful: 1, + output_path: finalizedOutputPath, + error_message: null + }); + return { + outputPath: finalizedOutputPath, + handbrakeInfo: handbrakeInfoWithPostScripts, + trackSelection, + rawPath: finalizedRawPath + }; + } + await historyService.updateJob(jobId, { handbrake_info_json: JSON.stringify(handbrakeInfoWithPostScripts), status: 'FINISHED', @@ -13411,6 +16324,16 @@ class PipelineService extends EventEmitter { void this.ejectDriveIfEnabled(settings); } + if (seriesBatchParentJobId) { + await this.updateSeriesBatchParentProgress(seriesBatchParentJobId).catch((error) => { + logger.warn('series-batch:parent-progress:update-on-child-finish-failed', { + parentJobId: seriesBatchParentJobId, + childJobId: jobId, + error: errorToMeta(error) + }); + }); + } + setTimeout(async () => { if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) { await this.setState('IDLE', { @@ -13430,6 +16353,9 @@ class PipelineService extends EventEmitter { }); } logger.error('encode:start-from-prepared:failed', { jobId, mode, error: errorToMeta(error) }); + if (isSeriesBatchEpisodeRun) { + throw error; + } await this.failJob(jobId, 'ENCODING', error); error.jobAlreadyFailed = true; throw error; @@ -13468,6 +16394,7 @@ class PipelineService extends EventEmitter { .map(Number).filter((id) => Number.isFinite(id) && id > 0) : []; const mkInfo = this.safeParseJson(job.makemkv_info_json); + const analyzeContext = mkInfo?.analyzeContext || {}; const mediaProfile = this.resolveMediaProfileForJob(job, { encodePlan: preRipPlanBeforeRip, makemkvInfo: mkInfo, @@ -13483,6 +16410,17 @@ class PipelineService extends EventEmitter { const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup' ? 'backup' : 'mkv'; + const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, this.snapshot.context || {}); + const isSeriesDvdRip = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + const rawStorage = resolveSeriesAwareRawStorage(settings, mediaProfile, selectedMetadata, analyzeContext); + const effectiveRawBaseDir = String( + rawStorage?.rawBaseDir + || rawBaseDir + || settingsService.DEFAULT_RAW_DIR + || '' + ).trim(); + const effectiveRawOwner = String(rawStorage?.rawOwner || settings.raw_dir_owner || '').trim(); + const disableMinLengthFilter = ripMode === 'mkv' && isSeriesDvdRip; const effectiveSelectedTitleId = ripMode === 'mkv' ? (selectedTitleId ?? null) : null; const effectiveSelectedPlaylist = ripMode === 'mkv' ? (selectedPlaylist || null) : null; const effectiveSelectedPlaylistFile = ripMode === 'mkv' ? selectedPlaylistFile : null; @@ -13499,18 +16437,18 @@ class PipelineService extends EventEmitter { selectedTitleDurationSeconds: Number(selectedPlaylistTitleInfo?.durationSeconds || 0), selectedTitleDurationLabel: selectedPlaylistTitleInfo?.durationLabel || null }); - logger.debug('ripEncode:paths', { jobId, rawBaseDir }); + logger.debug('ripEncode:paths', { jobId, rawBaseDir: effectiveRawBaseDir }); - ensureDir(rawBaseDir); + ensureDir(effectiveRawBaseDir); const metadataBase = buildRawMetadataBase({ title: job.title || job.detected_title || null, year: job.year || null }, jobId); const rawDirName = buildRawDirName(metadataBase, jobId, { state: RAW_FOLDER_STATES.INCOMPLETE }); - const rawJobDir = path.join(rawBaseDir, rawDirName); + const rawJobDir = path.join(effectiveRawBaseDir, rawDirName); ensureDir(rawJobDir); - chownRecursive(rawJobDir, settings.raw_dir_owner); + chownRecursive(rawJobDir, effectiveRawOwner); logger.info('rip:raw-dir-created', { jobId, rawJobDir }); const deviceCandidate = this.detectedDisc || this.snapshot.context?.device || { @@ -13541,12 +16479,7 @@ class PipelineService extends EventEmitter { selectedPlaylist: effectiveSelectedPlaylist, selectedTitleId: effectiveSelectedTitleId, preRipSelectionLocked: hasPreRipConfirmedSelection, - selectedMetadata: { - title: job.title || job.detected_title || null, - year: job.year || null, - imdbId: job.imdb_id || null, - poster: job.poster_url || null - } + selectedMetadata } }); @@ -13572,6 +16505,14 @@ class PipelineService extends EventEmitter { error_message: null, end_time: null }); + if (job?.parent_job_id) { + await historyService.updateJob(job.parent_job_id, { + status: 'RIPPING', + last_state: 'RIPPING', + error_message: null, + end_time: null + }); + } job = await historyService.getJobById(jobId); let makemkvInfo = null; @@ -13605,6 +16546,20 @@ class PipelineService extends EventEmitter { `${decisionContext.detailText} Rip läuft ohne Vorauswahl. Finale Titelwahl erfolgt in der Mediainfo-Prüfung per Checkbox.` ); } + if (disableMinLengthFilter) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Serien-DVD erkannt: MakeMKV-Rip läuft ohne Mindestlängen-Filter (--minlength deaktiviert).' + ); + } + if (rawStorage.usingSeriesRawPath) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-DVD RAW-Pfad aktiv: ${effectiveRawBaseDir}` + ); + } const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'rip').catch(() => null); if (devicePath) { const existingLock = this._getDriveLockByPath(devicePath); @@ -13631,6 +16586,7 @@ class PipelineService extends EventEmitter { rawJobDir, deviceInfo: device, selectedTitleId: effectiveSelectedTitleId, + disableMinLengthFilter, backupOutputBase, runCommand: this.runCommand.bind(this) }); @@ -13689,7 +16645,7 @@ class PipelineService extends EventEmitter { try { fs.renameSync(rawJobDir, ripCompleteRawJobDir); activeRawJobDir = ripCompleteRawJobDir; - chownRecursive(activeRawJobDir, settings.raw_dir_owner); + chownRecursive(activeRawJobDir, effectiveRawOwner); await historyService.updateRawPathByOldPath(rawJobDir, ripCompleteRawJobDir); await historyService.appendLog( jobId, @@ -13818,19 +16774,11 @@ class PipelineService extends EventEmitter { ['converter', 'cd', 'audiobook'].includes(mediaProfile) ? mediaProfile : null ); const isCdRetry = mediaProfile === 'cd'; - const isAudiobookRetry = mediaProfile === 'audiobook'; - // CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden (z.B. importierte Jobs). - // Importierte Audiobooks (orphan_raw_import ohne bisherigen Encode) dürfen ebenfalls - // neu gestartet werden, da erst der vollständige Flow durchlaufen werden muss. - const isImportedAudiobookWithoutEncode = isAudiobookRetry - && sourceStatus === 'FINISHED' - && sourceMakemkvInfo?.source === 'orphan_raw_import' - && !sourceJob.handbrake_info_json; + // CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden. const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus) || ['ERROR', 'CANCELLED'].includes(sourceLastState) - || (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus)) - || isImportedAudiobookWithoutEncode; + || (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus)); if (!retryable) { const error = new Error( `Retry nicht möglich: Job ${jobId} ist nicht im Status ERROR/CANCELLED (aktuell ${sourceStatus || sourceLastState || '-'}).` @@ -14240,7 +17188,13 @@ class PipelineService extends EventEmitter { const resolvedRestartEncodeJob = await this.resolveExistingJobForAction(jobId, 'restart_encode'); jobId = resolvedRestartEncodeJob.resolvedJobId; this.ensureNotBusy('restartEncodeWithLastSettings', jobId); - logger.info('restartEncodeWithLastSettings:requested', { jobId }); + const requestedRestartMode = String(options?.restartMode || 'all').trim().toLowerCase() === 'from_abort' + ? 'from_abort' + : 'all'; + logger.info('restartEncodeWithLastSettings:requested', { + jobId, + restartMode: requestedRestartMode + }); this.cancelRequestedByJob.delete(Number(jobId)); const triggerReason = String(options?.triggerReason || 'manual').trim().toLowerCase(); @@ -14253,15 +17207,6 @@ class PipelineService extends EventEmitter { throw error; } - const mkInfoForRestart = this.safeParseJson(job.makemkv_info_json); - if (mkInfoForRestart?.source === 'orphan_raw_import' && !job.handbrake_info_json) { - const error = new Error( - 'Encode-Neustart nicht möglich: Job wurde via RAW-Import erstellt und wurde noch nicht vollständig encodiert. Bitte zuerst "Neu einlesen" ausführen.' - ); - error.statusCode = 409; - throw error; - } - const encodePlan = this.safeParseJson(job.encode_plan_json); if (!encodePlan || !Array.isArray(encodePlan.titles) || encodePlan.titles.length === 0) { const error = new Error('Encode-Neustart nicht möglich: encode_plan fehlt.'); @@ -14283,7 +17228,12 @@ class PipelineService extends EventEmitter { const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase(); const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip); const reviewConfirmed = Boolean(Number(job.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed); - if (!reviewConfirmed) { + const encodePreviouslySuccessful = String(this.safeParseJson(job.handbrake_info_json)?.status || '').trim().toUpperCase() === 'SUCCESS'; + const allowReviewBypass = !reviewConfirmed + && encodePreviouslySuccessful + && Array.isArray(encodePlan?.titles) + && encodePlan.titles.length > 0; + if (!reviewConfirmed && !allowReviewBypass) { const error = new Error('Encode-Neustart nicht möglich: Spurauswahl wurde noch nicht bestätigt.'); error.statusCode = 409; throw error; @@ -14303,7 +17253,6 @@ class PipelineService extends EventEmitter { ? Boolean(settings.handbrake_restart_delete_incomplete_output) : true; const handBrakeInfo = this.safeParseJson(job.handbrake_info_json); - const encodePreviouslySuccessful = String(handBrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; const previousOutputPath = String(job.output_path || '').trim() || null; const keepBoth = Boolean(options?.keepBoth); @@ -14341,8 +17290,130 @@ class PipelineService extends EventEmitter { } } + let effectiveEncodePlan = encodePlan; + let effectiveRestartMode = requestedRestartMode; + let seriesBatchRestartSummary = null; + if (isSeriesBatchParentPlan(encodePlan)) { + const parentSelectedTitleIds = normalizeReviewTitleIdList( + Array.isArray(encodePlan?.selectedTitleIds) + ? encodePlan.selectedTitleIds + : [encodePlan?.encodeInputTitleId] + ); + const orderedEpisodes = buildSeriesBatchEpisodesFromPlan(job, encodePlan, parentSelectedTitleIds) + .map((entry, index) => ({ + episodeIndex: normalizePositiveInteger(entry?.episodeIndex) || (index + 1), + titleId: normalizeReviewTitleId(entry?.titleId) || parentSelectedTitleIds[index] || null, + status: normalizeSeriesEpisodeStatus(entry?.status, 'QUEUED'), + outputPath: String(entry?.outputPath || '').trim() || null + })) + .filter((entry) => Boolean(entry.titleId)) + .sort((left, right) => left.episodeIndex - right.episodeIndex); + + let selectedTitleIdsForRestart = parentSelectedTitleIds; + let restartFromEntry = null; + if (requestedRestartMode === 'from_abort' && orderedEpisodes.length > 0) { + restartFromEntry = orderedEpisodes.find((entry) => entry.status !== 'FINISHED') || null; + if (restartFromEntry) { + const startIndex = orderedEpisodes.findIndex((entry) => entry === restartFromEntry); + selectedTitleIdsForRestart = orderedEpisodes + .slice(startIndex >= 0 ? startIndex : 0) + .map((entry) => entry.titleId) + .filter(Boolean); + if (selectedTitleIdsForRestart.length === 0) { + selectedTitleIdsForRestart = parentSelectedTitleIds; + } + } else { + effectiveRestartMode = 'all'; + selectedTitleIdsForRestart = parentSelectedTitleIds; + } + } else { + effectiveRestartMode = 'all'; + } + + if (selectedTitleIdsForRestart.length > 0) { + const selectedTitleSet = new Set(selectedTitleIdsForRestart.map((id) => String(id))); + const selectionResult = applyEncodeTitleSelectionToPlan( + encodePlan, + selectedTitleIdsForRestart[0], + selectedTitleIdsForRestart + ); + let nextSeriesPlan = selectionResult.plan; + const episodeAssignmentsSource = nextSeriesPlan?.episodeAssignments && typeof nextSeriesPlan.episodeAssignments === 'object' + ? nextSeriesPlan.episodeAssignments + : {}; + const filteredEpisodeAssignments = Object.fromEntries( + Object.entries(episodeAssignmentsSource).filter(([titleId]) => selectedTitleSet.has(String(titleId))) + ); + const manualByTitleSource = nextSeriesPlan?.manualTrackSelectionByTitle + && typeof nextSeriesPlan.manualTrackSelectionByTitle === 'object' + ? nextSeriesPlan.manualTrackSelectionByTitle + : {}; + const filteredManualByTitle = Object.fromEntries( + Object.entries(manualByTitleSource).filter(([titleId]) => selectedTitleSet.has(String(titleId))) + ); + const activeRestartTitleId = normalizeReviewTitleId(nextSeriesPlan?.encodeInputTitleId) + || selectedTitleIdsForRestart[0] + || null; + const activeManualSelection = activeRestartTitleId + ? (filteredManualByTitle[String(activeRestartTitleId)] || null) + : null; + nextSeriesPlan = { + ...nextSeriesPlan, + episodeAssignments: filteredEpisodeAssignments, + manualTrackSelectionByTitle: filteredManualByTitle, + manualTrackSelection: activeManualSelection || nextSeriesPlan?.manualTrackSelection || null, + seriesBatchParent: false, + seriesBatchChild: false, + seriesBatchParentJobId: null, + seriesBatchChildJobIds: [], + seriesBatchDispatchedAt: null, + seriesBatchTotalChildren: selectedTitleIdsForRestart.length + }; + effectiveEncodePlan = nextSeriesPlan; + } + + if ( + effectiveRestartMode === 'from_abort' + && restartFromEntry + && restartDeleteIncompleteOutput + && !keepBoth + ) { + const restartChildOutputPath = String(restartFromEntry?.outputPath || '').trim(); + const restartChildWasFinished = restartFromEntry.status === 'FINISHED'; + if (restartChildOutputPath && !restartChildWasFinished && fs.existsSync(restartChildOutputPath)) { + try { + fs.rmSync(restartChildOutputPath, { recursive: true, force: true }); + await historyService.appendLog( + jobId, + 'USER_ACTION', + `Serien-Neustart ab Abbruch: Unvollständigen Episoden-Output entfernt (${restartChildOutputPath}).` + ); + } catch (deleteChildOutputError) { + logger.warn('restartEncodeWithLastSettings:series-child-output-delete-failed', { + jobId, + episodeIndex: restartFromEntry?.episodeIndex || null, + titleId: restartFromEntry?.titleId || null, + outputPath: restartChildOutputPath, + error: errorToMeta(deleteChildOutputError) + }); + } + } + } + + seriesBatchRestartSummary = { + mode: effectiveRestartMode, + selectedTitleIds: normalizeReviewTitleIdList( + Array.isArray(effectiveEncodePlan?.selectedTitleIds) + ? effectiveEncodePlan.selectedTitleIds + : [effectiveEncodePlan?.encodeInputTitleId] + ), + restartFromEpisodeIndex: restartFromEntry ? Number(restartFromEntry?.episodeIndex || 0) : null, + restartFromTitleId: restartFromEntry ? Number(restartFromEntry?.titleId || 0) : null + }; + } + const restartPlan = { - ...encodePlan, + ...effectiveEncodePlan, reviewConfirmed: false, reviewConfirmedAt: null, prefilledFromPreviousRun: true, @@ -14450,10 +17521,17 @@ class PipelineService extends EventEmitter { await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {}); } } + const restartModeDetails = seriesBatchRestartSummary + ? ( + seriesBatchRestartSummary.mode === 'from_abort' + ? ` Serienmodus: Neu starten ab Abbruch (ab Episode #${seriesBatchRestartSummary.restartFromEpisodeIndex || '-'}${seriesBatchRestartSummary.restartFromTitleId ? ` | Titel ${seriesBatchRestartSummary.restartFromTitleId}` : ''}).` + : ` Serienmodus: Alle neu starten (${seriesBatchRestartSummary.selectedTitleIds.length} Episode(n)).` + ) + : ''; const loadedSelectionText = ( previousOutputPath - ? `Letzte bestätigte Auswahl wurde geladen und kann angepasst werden. Vorheriger Output-Pfad: ${previousOutputPath}. autoDeleteIncomplete=${restartDeleteIncompleteOutput ? 'on' : 'off'}` - : 'Letzte bestätigte Auswahl wurde geladen und kann angepasst werden.' + ? `Letzte bestätigte Auswahl wurde geladen und kann angepasst werden. Vorheriger Output-Pfad: ${previousOutputPath}. autoDeleteIncomplete=${restartDeleteIncompleteOutput ? 'on' : 'off'}.${restartModeDetails}` + : `Letzte bestätigte Auswahl wurde geladen und kann angepasst werden.${restartModeDetails}` ); let restartLogMessage; if (triggerReason === 'cancelled_encode') { @@ -14482,7 +17560,9 @@ class PipelineService extends EventEmitter { statusText: hasEncodableTitle ? (isPreRipMode ? 'Vorherige Spurauswahl geladen - anpassen und Backup/Rip + Encode starten' - : 'Vorherige Encode-Auswahl geladen - anpassen und Encoding starten') + : ( + `Vorherige Encode-Auswahl geladen - anpassen und Encoding starten${seriesBatchRestartSummary?.mode === 'from_abort' ? ' (ab Abbruch)' : ''}` + )) : (isPreRipMode ? 'Vorherige Spurauswahl geladen - kein passender Titel gewählt' : 'Vorherige Encode-Auswahl geladen - kein Titel erfüllt MIN_LENGTH_MINUTES'), @@ -14508,7 +17588,8 @@ class PipelineService extends EventEmitter { reviewConfirmed: false, sourceJobId: Number(jobId), jobId: replacementJobId, - replacedSourceJob: true + replacedSourceJob: true, + restartMode: seriesBatchRestartSummary?.mode || effectiveRestartMode }; } @@ -14855,6 +17936,130 @@ class PipelineService extends EventEmitter { }; } + async cancelSeriesBatchParentJob(parentJobId, parentJob = null) { + const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); + if (!normalizedParentJobId) { + const error = new Error('Ungültige Serien-Batch Job-ID.'); + error.statusCode = 400; + throw error; + } + + const sourceParentJob = parentJob && Number(parentJob?.id) === Number(normalizedParentJobId) + ? parentJob + : await historyService.getJobById(normalizedParentJobId); + if (!sourceParentJob || !this.isSeriesBatchParentQueueAnchor(sourceParentJob)) { + return { + cancelled: false, + seriesBatch: false, + jobId: normalizedParentJobId + }; + } + + const reason = 'Serien-Batch vom Benutzer abgebrochen.'; + const queueEntriesBefore = this.queueEntries.length; + this.queueEntries = this.queueEntries.filter((entry) => !( + Number(entry?.jobId) === Number(normalizedParentJobId) + && String(entry?.action || '').trim().toUpperCase() === QUEUE_ACTIONS.START_SERIES_EPISODE + )); + const removedQueueEntries = Math.max(0, queueEntriesBefore - this.queueEntries.length); + + const sourcePlan = this.safeParseJson(sourceParentJob.encode_plan_json); + const selectedTitleIds = normalizeReviewTitleIdList( + Array.isArray(sourcePlan?.selectedTitleIds) + ? sourcePlan.selectedTitleIds + : (Array.isArray(sourcePlan?.seriesBatchEpisodes) + ? sourcePlan.seriesBatchEpisodes.map((entry) => entry?.titleId) + : []) + ); + const sourceEpisodes = buildSeriesBatchEpisodesFromPlan( + sourceParentJob, + sourcePlan, + selectedTitleIds + ); + const cancelledEpisodes = sourceEpisodes.map((entry) => { + const status = normalizeSeriesEpisodeStatus(entry?.status, 'QUEUED'); + if (status === 'FINISHED' || status === 'ERROR' || status === 'CANCELLED') { + return entry; + } + return { + ...entry, + status: 'CANCELLED', + eta: null, + finishedAt: nowIso(), + error: reason + }; + }); + const nextPlan = { + ...(sourcePlan && typeof sourcePlan === 'object' ? sourcePlan : {}), + seriesBatchParent: true, + seriesBatchChild: false, + seriesBatchChildJobIds: [], + seriesBatchEpisodes: cancelledEpisodes + }; + const summary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, cancelledEpisodes); + const previousHandbrakeInfo = this.safeParseJson(sourceParentJob.handbrake_info_json) || {}; + const nextHandbrakeInfo = { + ...(previousHandbrakeInfo && typeof previousHandbrakeInfo === 'object' ? previousHandbrakeInfo : {}), + mode: 'series_batch', + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: summary.totalCount, + finishedCount: summary.finishedCount, + cancelledCount: summary.cancelledCount, + errorCount: summary.errorCount, + runningCount: summary.runningCount, + episodes: cancelledEpisodes + } + }; + + const processHandle = this.activeProcesses.get(normalizedParentJobId) || null; + if (processHandle) { + this.cancelRequestedByJob.add(Number(normalizedParentJobId)); + try { + processHandle.cancel(); + } catch (_error) { + // ignore cancel race errors + } + } + + this.seriesBatchParentProgressSyncAt.delete(Number(normalizedParentJobId)); + await historyService.updateJob(normalizedParentJobId, { + encode_plan_json: JSON.stringify(nextPlan), + handbrake_info_json: JSON.stringify(nextHandbrakeInfo), + status: 'CANCELLED', + last_state: 'CANCELLED', + end_time: nowIso(), + error_message: reason + }); + await historyService.appendLog( + normalizedParentJobId, + 'USER_ACTION', + `${reason} Episoden: ${summary.totalCount}, Queue-Einträge entfernt: ${removedQueueEntries}, aktiver Episode-Run: ${processHandle ? 'ja' : 'nein'}.` + ); + await this.updateProgress('CANCELLED', summary.overallProgress, null, summary.summaryText, normalizedParentJobId, { + contextPatch: { + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: summary.totalCount, + finishedCount: summary.finishedCount, + cancelledCount: summary.cancelledCount, + errorCount: summary.errorCount, + runningCount: summary.runningCount, + children: summary.children + } + } + }); + await this.emitQueueChanged(); + return { + cancelled: true, + queuedOnly: false, + seriesBatch: true, + jobId: normalizedParentJobId, + childCount: summary.totalCount, + removedQueueEntries + }; + } + async cancel(jobId = null) { const normalizedJobId = this.normalizeQueueJobId(jobId) || this.normalizeQueueJobId(this.snapshot.activeJobId) @@ -14867,6 +18072,11 @@ class PipelineService extends EventEmitter { throw error; } + const targetJob = await historyService.getJobById(normalizedJobId).catch(() => null); + if (targetJob && this.isSeriesBatchParentQueueAnchor(targetJob)) { + return this.cancelSeriesBatchParentJob(normalizedJobId, targetJob); + } + const processHandle = this.activeProcesses.get(normalizedJobId) || null; if (!processHandle) { const queuedIndex = this.findQueueEntryIndexByJobId(normalizedJobId); @@ -15209,7 +18419,18 @@ class PipelineService extends EventEmitter { runInfo.lastProgress = progress.percent; runInfo.eta = progress.eta || runInfo.eta; const statusText = composeStatusText(stage, progress.percent, runInfo.lastDetail); - void this.updateProgress(stage, progress.percent, progress.eta, statusText, normalizedJobId); + const contextPatch = progress.contextPatch && typeof progress.contextPatch === 'object' + && !Array.isArray(progress.contextPatch) + ? progress.contextPatch + : null; + void this.updateProgress( + stage, + progress.percent, + progress.eta, + statusText, + normalizedJobId, + contextPatch ? { contextPatch } : undefined + ); } else if (detail) { const jobEntry = this.jobProgress.get(Number(normalizedJobId)); const currentProgress = jobEntry?.progress ?? Number(this.snapshot.progress || 0); @@ -15575,6 +18796,20 @@ class PipelineService extends EventEmitter { this.cancelRequestedByJob.delete(Number(jobId)); + const seriesBatchParentJobId = this.resolveSeriesBatchParentJobId({ + ...job, + encodePlan + }); + if (seriesBatchParentJobId) { + await this.updateSeriesBatchParentProgress(seriesBatchParentJobId).catch((seriesError) => { + logger.warn('series-batch:parent-progress:update-on-child-fail-failed', { + parentJobId: seriesBatchParentJobId, + childJobId: jobId, + error: errorToMeta(seriesError) + }); + }); + } + void this.notifyPushover(isCancelled ? 'job_cancelled' : 'job_error', { title: isCancelled ? 'Ripster - Job abgebrochen' : 'Ripster - Job Fehler', message: `${title} (${stage}): ${message}` diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 6e065e1..abc0413 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -16,6 +16,7 @@ const { setLogRootDir } = require('./logPathService'); const { defaultRawDir: DEFAULT_RAW_DIR, defaultMovieDir: DEFAULT_MOVIE_DIR, + defaultSeriesDir: DEFAULT_SERIES_DIR, defaultCdDir: DEFAULT_CD_DIR, defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR, defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR, @@ -38,6 +39,7 @@ const HANDBRAKE_PRESET_RELEVANT_SETTING_KEYS = new Set([ const SENSITIVE_SETTING_KEYS = new Set([ 'makemkv_registration_key', 'omdb_api_key', + 'tmdb_api_read_access_token', 'pushover_token', 'pushover_user' ]); @@ -62,18 +64,30 @@ const PROFILED_SETTINGS = { cd: 'raw_dir_cd_owner', audiobook: 'raw_dir_audiobook_owner' }, + series_raw_dir: { + dvd: 'raw_dir_dvd_series' + }, + series_raw_dir_owner: { + dvd: 'raw_dir_dvd_series_owner' + }, movie_dir: { bluray: 'movie_dir_bluray', dvd: 'movie_dir_dvd', cd: 'movie_dir_cd', audiobook: 'movie_dir_audiobook' }, + series_dir: { + dvd: 'series_dir_dvd' + }, movie_dir_owner: { bluray: 'movie_dir_bluray_owner', dvd: 'movie_dir_dvd_owner', cd: 'movie_dir_cd_owner', audiobook: 'movie_dir_audiobook_owner' }, + series_dir_owner: { + dvd: 'series_dir_dvd_owner' + }, mediainfo_extra_args: { bluray: 'mediainfo_extra_args_bluray', dvd: 'mediainfo_extra_args_dvd' @@ -111,8 +125,12 @@ const PROFILED_SETTINGS = { const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([ 'raw_dir', 'raw_dir_owner', + 'series_raw_dir', + 'series_raw_dir_owner', 'movie_dir', - 'movie_dir_owner' + 'movie_dir_owner', + 'series_dir', + 'series_dir_owner' ]); function applyRuntimeLogDirSetting(rawValue) { @@ -813,6 +831,8 @@ class SettingsService { } else { resolvedValue = DEFAULT_RAW_DIR; } + } else if (legacyKey === 'series_dir') { + resolvedValue = DEFAULT_SERIES_DIR; } else if (legacyKey === 'movie_dir') { if (normalizedRequestedProfile === 'cd') { resolvedValue = DEFAULT_CD_DIR; @@ -858,7 +878,7 @@ class SettingsService { const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook'); return { bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir }, - dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir }, + dvd: { raw: dvd.raw_dir, seriesRaw: dvd.series_raw_dir || dvd.raw_dir, movies: dvd.movie_dir, series: dvd.series_dir }, cd: { raw: cd.raw_dir, movies: cd.movie_dir }, audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir }, downloads: { path: bluray.download_dir }, @@ -870,6 +890,7 @@ class SettingsService { defaults: { raw: DEFAULT_RAW_DIR, movies: DEFAULT_MOVIE_DIR, + series: DEFAULT_SERIES_DIR, cd: DEFAULT_CD_DIR, audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR, audiobookMovies: DEFAULT_AUDIOBOOK_DIR, @@ -1116,6 +1137,7 @@ class SettingsService { : 'mkv'; const sourceArg = this.resolveSourceArg(map, deviceInfo); const rawSelectedTitleId = normalizeNonNegativeInteger(options?.selectedTitleId); + const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter); const parsedExtra = splitArgs(map.makemkv_rip_extra_args); let extra = []; let baseArgs = []; @@ -1141,6 +1163,9 @@ class SettingsService { const minLength = Number(map.makemkv_min_length_minutes || 60); const hasExplicitTitle = rawSelectedTitleId !== null; const targetTitle = hasExplicitTitle ? String(Math.trunc(rawSelectedTitleId)) : 'all'; + const minLengthSeconds = Number.isFinite(minLength) && minLength > 0 + ? Math.round(minLength * 60) + : 0; if (hasExplicitTitle) { baseArgs = [ '-r', '--progress=-same', @@ -1150,9 +1175,12 @@ class SettingsService { rawJobDir ]; } else { + const minLengthArgs = (!disableMinLengthFilter && minLengthSeconds > 0) + ? [`--minlength=${minLengthSeconds}`] + : []; baseArgs = [ '-r', '--progress=-same', - '--minlength=' + Math.round(minLength * 60), + ...minLengthArgs, 'mkv', sourceArg, targetTitle, @@ -1166,6 +1194,7 @@ class SettingsService { ripMode, rawJobDir, deviceInfo, + disableMinLengthFilter: ripMode === 'mkv' ? disableMinLengthFilter : false, selectedTitleId: ripMode === 'mkv' && Number.isFinite(rawSelectedTitleId) && rawSelectedTitleId >= 0 ? Math.trunc(rawSelectedTitleId) : null diff --git a/backend/src/services/tmdbService.js b/backend/src/services/tmdbService.js new file mode 100644 index 0000000..bfb9b90 --- /dev/null +++ b/backend/src/services/tmdbService.js @@ -0,0 +1,428 @@ +'use strict'; + +const settingsService = require('./settingsService'); +const logger = require('./logger').child('TMDB'); + +const TMDB_BASE_URL = 'https://api.themoviedb.org/3'; +const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p'; +const TMDB_TIMEOUT_MS = 10000; + +class TmdbService { + normalizeNameList(values = [], options = {}) { + const maxItems = Math.max(1, Number(options.maxItems || 10)); + const source = Array.isArray(values) ? values : []; + const output = []; + const seen = new Set(); + for (const item of source) { + const name = String(item?.name || item || '').trim(); + if (!name) { + continue; + } + const key = name.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(name); + if (output.length >= maxItems) { + break; + } + } + return output; + } + + formatRuntimeLabel(value) { + if (Array.isArray(value)) { + const values = value + .map((entry) => Number(entry)) + .filter((entry) => Number.isFinite(entry) && entry > 0) + .map((entry) => Math.trunc(entry)); + if (values.length === 0) { + return null; + } + const min = Math.min(...values); + const max = Math.max(...values); + return min === max ? `${min} min` : `${min}-${max} min`; + } + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric > 0) { + return `${Math.trunc(numeric)} min`; + } + const text = String(value || '').trim(); + return text || null; + } + + async getConfig() { + const settings = await settingsService.getSettingsMap(); + return { + readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null, + language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE' + }; + } + + async isConfigured() { + const config = await this.getConfig(); + return Boolean(config.readAccessToken); + } + + async resolveLanguage(explicitLanguage = null) { + const config = await this.getConfig(); + return String(explicitLanguage || config.language || 'de-DE').trim() || 'de-DE'; + } + + async request(pathName, options = {}) { + const config = await this.getConfig(); + if (!config.readAccessToken) { + return null; + } + + const timeoutMs = Math.max(1000, Number(options.timeoutMs || TMDB_TIMEOUT_MS)); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const normalizedPath = String(pathName || '').replace(/^\/+/, ''); + const url = new URL(normalizedPath, `${TMDB_BASE_URL}/`); + if (options.query && typeof options.query === 'object') { + for (const [key, value] of Object.entries(options.query)) { + if (value === undefined || value === null || value === '') { + continue; + } + url.searchParams.set(key, String(value)); + } + } + + const response = await fetch(url, { + method: options.method || 'GET', + headers: { + 'accept': 'application/json', + 'Authorization': `Bearer ${config.readAccessToken}`, + 'User-Agent': 'Ripster/1.0' + }, + signal: controller.signal + }); + + if (!response.ok) { + const error = new Error(`TMDb request failed (${response.status})`); + error.statusCode = response.status; + error.url = url.toString(); + throw error; + } + + return response.json(); + } finally { + clearTimeout(timer); + } + } + + buildImageUrl(imagePath, size = 'w342') { + const normalizedPath = String(imagePath || '').trim(); + if (!normalizedPath) { + return null; + } + return `${TMDB_IMAGE_BASE_URL}/${String(size || 'w342').trim()}/${normalizedPath.replace(/^\/+/, '')}`; + } + + async searchSeries(query, options = {}) { + const normalizedQuery = String(query || '').trim(); + if (!normalizedQuery || !(await this.isConfigured())) { + return []; + } + const language = await this.resolveLanguage(options.language); + + const data = await this.request('/search/tv', { + query: { + query: normalizedQuery, + first_air_date_year: options.year || undefined, + language, + page: options.page || 1, + include_adult: false + } + }).catch((error) => { + logger.warn('search:failed', { + query: normalizedQuery, + error: error?.message || String(error) + }); + return null; + }); + + const rows = Array.isArray(data?.results) ? data.results : []; + return rows + .map((row) => ({ + id: Number(row?.id || 0) || null, + title: String(row?.name || row?.original_name || '').trim() || null, + originalTitle: String(row?.original_name || '').trim() || null, + year: Number(String(row?.first_air_date || '').slice(0, 4)) || null, + overview: String(row?.overview || '').trim() || null, + posterPath: String(row?.poster_path || '').trim() || null, + poster: this.buildImageUrl(row?.poster_path, 'w342'), + backdropPath: String(row?.backdrop_path || '').trim() || null, + backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'), + originalLanguage: String(row?.original_language || '').trim() || null, + popularity: Number(row?.popularity || 0) || 0 + })) + .filter((row) => row.id && row.title); + } + + async getSeriesDetails(seriesId, options = {}) { + const id = Number(seriesId); + if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) { + return null; + } + const language = await this.resolveLanguage(options.language); + const appendToResponse = Array.isArray(options.appendToResponse) + ? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean) + : []; + return this.request(`/tv/${Math.trunc(id)}`, { + query: { + language, + append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined + } + }).catch((error) => { + logger.warn('series:details:failed', { + seriesId: Math.trunc(id), + error: error?.message || String(error) + }); + return null; + }); + } + + async getEpisodeGroups(seriesId) { + const id = Number(seriesId); + if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) { + return []; + } + const response = await this.request(`/tv/${Math.trunc(id)}/episode_groups`).catch((error) => { + logger.warn('series:episode-groups:failed', { + seriesId: Math.trunc(id), + error: error?.message || String(error) + }); + return null; + }); + return Array.isArray(response?.results) ? response.results : []; + } + + async getEpisodeGroupDetails(groupId, options = {}) { + const normalizedId = String(groupId || '').trim(); + if (!normalizedId || !(await this.isConfigured())) { + return null; + } + const language = await this.resolveLanguage(options.language); + return this.request(`/tv/episode_group/${normalizedId}`, { + query: { + language + } + }).catch((error) => { + logger.warn('episode-group:details:failed', { + groupId: normalizedId, + error: error?.message || String(error) + }); + return null; + }); + } + + async getSeasonDetails(seriesId, seasonNumber, options = {}) { + const id = Number(seriesId); + const season = Number(seasonNumber); + if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) { + return null; + } + const language = await this.resolveLanguage(options.language); + return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, { + query: { + language + } + }).catch(() => null); + } + + async getSeasonCredits(seriesId, seasonNumber, options = {}) { + const id = Number(seriesId); + const season = Number(seasonNumber); + if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) { + return null; + } + const language = await this.resolveLanguage(options.language); + return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}/credits`, { + query: { + language + } + }).catch((error) => { + logger.warn('season:credits:failed', { + seriesId: Math.trunc(id), + seasonNumber: Math.trunc(season), + error: error?.message || String(error) + }); + return null; + }); + } + + buildSeasonSummary(seasonDetails = null) { + const details = seasonDetails && typeof seasonDetails === 'object' ? seasonDetails : {}; + const episodes = Array.isArray(details.episodes) ? details.episodes : []; + return { + seasonNumber: Number(details.season_number || details.seasonNumber || 0) || null, + name: String(details.name || '').trim() || null, + overview: String(details.overview || '').trim() || null, + posterPath: String(details.poster_path || '').trim() || null, + poster: this.buildImageUrl(details.poster_path, 'w342'), + episodeCount: episodes.length, + episodes: episodes.map((episode) => ({ + id: Number(episode?.id || 0) || null, + number: Number(episode?.episode_number || episode?.episodeNumber || 0) || null, + seasonNumber: Number(episode?.season_number || details.season_number || 0) || null, + name: String(episode?.name || '').trim() || null, + overview: String(episode?.overview || '').trim() || null, + runtime: Number(episode?.runtime || 0) || null, + airDate: String(episode?.air_date || '').trim() || null, + stillPath: String(episode?.still_path || '').trim() || null, + still: this.buildImageUrl(episode?.still_path, 'w300') + })).filter((episode) => episode.id && episode.number) + }; + } + + buildSeriesDetailsSummary(seriesDetails = null) { + const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {}; + const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : []; + const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : []; + const creators = Array.isArray(details?.created_by) ? details.created_by : []; + const genres = Array.isArray(details?.genres) ? details.genres : []; + const runtimeLabel = this.formatRuntimeLabel(details?.episode_run_time); + const directorNames = this.normalizeNameList( + crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'), + { maxItems: 5 } + ); + const creatorNames = this.normalizeNameList(creators, { maxItems: 5 }); + const actorNames = this.normalizeNameList(cast, { maxItems: 10 }); + const genreNames = this.normalizeNameList(genres, { maxItems: 10 }); + const voteAverageRaw = Number(details?.vote_average || 0); + const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0 + ? Number(voteAverageRaw.toFixed(1)) + : null; + const voteCount = Number(details?.vote_count || 0); + const imdbId = String(details?.external_ids?.imdb_id || '').trim() || null; + + return { + director: directorNames.length > 0 + ? directorNames.join(', ') + : (creatorNames.length > 0 ? creatorNames.join(', ') : null), + actors: actorNames.length > 0 ? actorNames.join(', ') : null, + runtime: runtimeLabel, + runtimeLabel, + genre: genreNames.length > 0 ? genreNames.join(', ') : null, + imdbRating: voteAverage !== null ? voteAverage.toFixed(1) : null, + voteAverage, + voteCount: Number.isFinite(voteCount) && voteCount > 0 ? Math.trunc(voteCount) : null, + rottenTomatoes: null, + imdbId, + tmdbId: Number(details?.id || 0) || null, + firstAirDate: String(details?.first_air_date || '').trim() || null + }; + } + + buildSeriesMetadataCandidate(series = {}, options = {}) { + const season = series?.season && typeof series.season === 'object' + ? series.season + : null; + const seasonNumber = Number(options.seasonNumber || season?.seasonNumber || 0) || null; + const providerId = seasonNumber + ? `tmdb:${series.id}:season:${seasonNumber}` + : `tmdb:${series.id}`; + + return { + provider: 'tmdb', + providerId, + metadataKind: seasonNumber ? 'season' : 'series', + tmdbId: Number(series?.id || 0) || null, + title: String(series?.title || '').trim() || null, + originalTitle: String(series?.originalTitle || '').trim() || null, + year: Number(series?.year || 0) || null, + overview: String(series?.overview || '').trim() || null, + poster: series?.poster || this.buildImageUrl(series?.posterPath, 'w342'), + backdrop: series?.backdrop || this.buildImageUrl(series?.backdropPath, 'w780'), + seasonNumber, + seasonName: String(season?.name || '').trim() || null, + seasonOverview: String(season?.overview || '').trim() || null, + seasonPoster: season?.poster || this.buildImageUrl(season?.posterPath, 'w342'), + episodeCount: Number(season?.episodeCount || 0) || 0, + episodes: Array.isArray(season?.episodes) ? season.episodes : [] + }; + } + + buildSeasonSummariesFromSeriesDetails(seriesDetails = null) { + const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {}; + const seasons = Array.isArray(details.seasons) ? details.seasons : []; + return seasons + .map((season) => ({ + seasonNumber: Number(season?.season_number || season?.seasonNumber || 0) || null, + name: String(season?.name || '').trim() || null, + overview: String(season?.overview || '').trim() || null, + posterPath: String(season?.poster_path || '').trim() || null, + poster: this.buildImageUrl(season?.poster_path, 'w342'), + episodeCount: Number(season?.episode_count || season?.episodeCount || 0) || 0, + episodes: [] + })) + .filter((season) => season.seasonNumber !== null && season.episodeCount > 0); + } + + async searchSeriesWithSeasons(query, options = {}) { + const candidates = await this.searchSeries(query, options); + if (candidates.length === 0) { + return []; + } + + const limit = Math.max(1, Math.min(10, Number(options.limit || 5))); + const selectedCandidates = candidates.slice(0, limit); + + const expanded = await Promise.all(selectedCandidates.map(async (candidate) => { + const details = await this.getSeriesDetails(candidate.id, options); + const seasons = this.buildSeasonSummariesFromSeriesDetails(details); + if (seasons.length === 0) { + return [this.buildSeriesMetadataCandidate(candidate)]; + } + return seasons.map((season) => this.buildSeriesMetadataCandidate({ + ...candidate, + season + }, { + seasonNumber: season.seasonNumber + })); + })); + + return expanded.flat().filter((item) => item?.tmdbId && item?.title); + } + + async searchSeriesWithSeason(query, seasonNumber, options = {}) { + const normalizedSeason = Number(seasonNumber); + if (!Number.isFinite(normalizedSeason) || normalizedSeason < 0) { + return []; + } + + const candidates = await this.searchSeries(query, options); + if (candidates.length === 0) { + return []; + } + + const limit = Math.max(1, Math.min(10, Number(options.limit || 5))); + const selectedCandidates = candidates.slice(0, limit); + const withSeasons = await Promise.all(selectedCandidates.map(async (candidate) => { + const seasonDetails = await this.getSeasonDetails(candidate.id, normalizedSeason, options); + if (!seasonDetails) { + return { + ...candidate, + season: null + }; + } + return { + ...candidate, + season: this.buildSeasonSummary(seasonDetails) + }; + })); + + return withSeasons + .filter((candidate) => candidate.season && candidate.season.episodeCount > 0) + .map((candidate) => this.buildSeriesMetadataCandidate(candidate, { + seasonNumber: normalizedSeason + })); + } +} + +module.exports = new TmdbService(); diff --git a/backend/src/utils/files.js b/backend/src/utils/files.js index e16145a..8b9402f 100644 --- a/backend/src/utils/files.js +++ b/backend/src/utils/files.js @@ -45,13 +45,44 @@ function sanitizeFileNameWithExtension(input) { function renderTemplate(template, values) { const rawSource = String(template || '${title} (${year})'); + const parseToken = (rawToken) => { + const token = String(rawToken || '').trim(); + if (!token) { + return { format: '', key: '' }; + } + const separatorIndex = token.indexOf(':'); + if (separatorIndex <= 0) { + return { format: '', key: token }; + } + return { + format: token.slice(0, separatorIndex).trim(), + key: token.slice(separatorIndex + 1).trim() + }; + }; + const applyFormat = (value, format) => { + const normalizedFormat = String(format || '').trim().toLowerCase(); + if (!normalizedFormat) { + return String(value); + } + // {0:key} => number with leading zero (width 2), e.g. 2 -> 02 + if (normalizedFormat === '0') { + const raw = String(value); + const match = raw.match(/^(-?)(\d+)$/); + if (match) { + const sign = match[1] || ''; + const digits = String(match[2] || ''); + return `${sign}${digits.padStart(2, '0')}`; + } + } + return String(value); + }; const resolveToken = (rawKey) => { - const key = String(rawKey || '').trim(); + const { format, key } = parseToken(rawKey); const val = values[key]; if (val === undefined || val === null || val === '') { return 'unknown'; } - return String(val); + return applyFormat(val, format); }; const source = ( diff --git a/db/schema.sql b/db/schema.sql index 80351bb..9e04b9a 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -208,6 +208,51 @@ CREATE TABLE converter_scan_entries ( CREATE INDEX idx_converter_scan_entries_rel_path ON converter_scan_entries(rel_path); CREATE INDEX idx_converter_scan_entries_job_id ON converter_scan_entries(job_id); +CREATE TABLE dvd_series_disc_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL DEFAULT 'tmdb', + provider_series_id TEXT, + provider_series_name TEXT, + season_number INTEGER, + season_type TEXT NOT NULL DEFAULT 'dvd', + disc_label TEXT, + disc_serial TEXT, + disc_number INTEGER, + language TEXT, + disc_signature TEXT NOT NULL, + fingerprint_json TEXT, + episode_map_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX idx_dvd_series_disc_profiles_signature ON dvd_series_disc_profiles(disc_signature); +CREATE INDEX idx_dvd_series_disc_profiles_series ON dvd_series_disc_profiles(provider, provider_series_id, season_number); + +CREATE TABLE dvd_series_title_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_job_id INTEGER NOT NULL, + disc_profile_id INTEGER, + title_index INTEGER NOT NULL, + title_kind TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 0, + selected INTEGER NOT NULL DEFAULT 1, + provider TEXT NOT NULL DEFAULT 'tmdb', + provider_series_id TEXT, + season_number INTEGER, + episode_start INTEGER, + episode_end INTEGER, + episode_ids_json TEXT, + mapping_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE CASCADE, + FOREIGN KEY (disc_profile_id) REFERENCES dvd_series_disc_profiles(id) ON DELETE SET NULL +); + +CREATE INDEX idx_dvd_series_title_mappings_parent_job ON dvd_series_title_mappings(parent_job_id, title_index); +CREATE INDEX idx_dvd_series_title_mappings_series ON dvd_series_title_mappings(provider, provider_series_id, season_number); + -- ============================================================================= -- Default Settings Seed -- ============================================================================= @@ -221,6 +266,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('raw_dir_dvd_owner', 'Pfade', 'Eigentümer Raw-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1025); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_owner', NULL); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_dvd_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (DVD Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD).', NULL, '[]', '{}', 1022); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_owner', NULL); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('movie_dir_bluray_owner', 'Pfade', 'Eigentümer Film-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1115); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray_owner', NULL); @@ -229,6 +278,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('movie_dir_dvd_owner', 'Pfade', 'Eigentümer Film-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1125); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd_owner', NULL); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('series_dir_dvd_owner', 'Pfade', 'Eigentümer Serien-Ordner (DVD)', 'string', 0, 'Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_owner', NULL); + -- Laufwerk INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('drive_mode', 'Laufwerk', 'Laufwerksmodus', 'select', 1, 'Auto-Discovery oder explizites Device.', 'auto', '[{"label":"Auto Discovery","value":"auto"},{"label":"Explizites Device","value":"explicit"}]', '{}', 10); @@ -259,6 +312,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('raw_dir_dvd', 'Pfade', 'Raw-Ordner (DVD)', 'path', 0, 'RAW-Zielpfad für DVD. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 102); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd', NULL); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('raw_dir_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series', NULL); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('movie_dir_bluray', 'Pfade', 'Film-Ordner (Blu-ray)', 'path', 0, 'Encode-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 111); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray', NULL); @@ -267,6 +324,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('movie_dir_dvd', 'Pfade', 'Film-Ordner (DVD)', 'path', 0, 'Encode-Zielpfad für DVD. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 112); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd', NULL); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('series_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd', NULL); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('log_dir', 'Pfade', 'Log Ordner', 'path', 1, 'Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend.', 'data/logs', '[]', '{"minLength":1}', 120); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('log_dir', 'data/logs'); @@ -284,6 +345,14 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('hardware_monitoring_interval_ms', 'Monitoring', 'Hardware Monitoring Intervall (ms)', 'number', 1, 'Polling-Intervall für CPU/RAM/GPU/Storage-Metriken.', '5000', '[]', '{"min":1000,"max":60000}', 140); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('hardware_monitoring_interval_ms', '5000'); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('dvd_series_plugin_enabled', 'Features', 'DVD Serien-Plugin aktiviert', 'boolean', 1, 'Aktiviert die additive Serienerkennung für DVDs. Wenn deaktiviert, bleibt der bestehende DVD-Film-Flow unverändert.', 'false', '[]', '{}', 145); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_plugin_enabled', 'false'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) +VALUES ('dvd_series_prescan_enabled', 'Features', 'DVD HandBrake-Prescan aktiviert', 'boolean', 1, 'Führt nach der DVD-Erkennung einen zusätzlichen HandBrake-Prescan für die Serienheuristik aus.', 'true', '[]', '{}', 146, 'dvd_series_plugin_enabled'); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_prescan_enabled', 'true'); + -- Tools INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('makemkv_command', 'Tools', 'MakeMKV Kommando', 'string', 1, 'Pfad oder Befehl für makemkvcon.', 'makemkvcon', '[]', '{"minLength":1}', 200); @@ -402,6 +471,14 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('output_template_dvd', 'Pfade', 'Output Template (DVD)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 535); 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); +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}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}', '[]', '{"minLength":1}', 537); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'); + -- Tools – CD INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('cdparanoia_command', 'Tools', 'cdparanoia Kommando', 'string', 1, 'Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist.', 'cdparanoia', '[]', '{"minLength":1}', 230); @@ -479,6 +556,26 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_api_key', NULL); +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('tmdb_api_read_access_token', 'Metadaten', 'TMDb Read Access Token', 'string', 0, 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', NULL, '[]', '{}', 401); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('tmdb_api_read_access_token', NULL); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('dvd_series_language', 'Metadaten', 'DVD Serien-Sprache', 'string', 1, 'Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('dvd_series_fallback_languages', 'Metadaten', 'DVD Serien-Fallback-Sprachen', 'string', 1, 'Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('dvd_series_order_preference', 'Metadaten', 'DVD Serien-Ordnung', 'select', 1, 'Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden.', 'episode_group', '[{"label":"Episode Group","value":"episode_group"},{"label":"Aired / Season","value":"aired"}]', '{}', 405); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('dvd_series_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true'); + INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie'); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0339f25..644f25c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.12.0-19", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.12.0-19", + "version": "0.13.0", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 16180cc..4684190 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.12.0-19", + "version": "0.13.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 39b33d1..1d10f4f 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -363,6 +363,12 @@ export const api = { getDetectedDrives() { return request('/settings/drives'); }, + forceUnlockDrive(payload = {}) { + return request('/settings/drives/force-unlock', { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + }, async getHandBrakePresets(options = {}) { const result = await requestCachedGet('/settings/handbrake-presets', { ttlMs: 10 * 60 * 1000, @@ -547,6 +553,14 @@ export const api = { searchOmdb(q) { return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`); }, + searchTmdbSeries(q, seasonNumber = null) { + const params = new URLSearchParams(); + params.set('q', String(q || '')); + if (seasonNumber !== undefined && seasonNumber !== null && String(seasonNumber).trim() !== '') { + params.set('season', String(seasonNumber).trim()); + } + return request(`/pipeline/tmdb/series/search?${params.toString()}`); + }, searchMusicBrainz(q) { return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`); }, @@ -675,6 +689,7 @@ export const api = { const body = {}; if (options.keepBoth) body.keepBoth = true; if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders; + if (options.restartMode) body.restartMode = options.restartMode; const result = await request(`/pipeline/restart-encode/${jobId}`, { method: 'POST', body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined @@ -743,6 +758,9 @@ export const api = { if (params.lite) { query.set('lite', '1'); } + if (params.includeChildren) { + query.set('includeChildren', '1'); + } const suffix = query.toString() ? `?${query.toString()}` : ''; return request(`/history${suffix}`); }, @@ -773,6 +791,13 @@ export const api = { afterMutationInvalidate(['/history']); return result; }, + async acknowledgeJobError(jobId) { + const result = await request(`/history/${jobId}/error/ack`, { + method: 'POST' + }); + afterMutationInvalidate(['/history']); + return result; + }, async deleteJobFiles(jobId, target = 'both') { const result = await request(`/history/${jobId}/delete-files`, { method: 'POST', diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index 8a3d8a7..d00f0e2 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -293,9 +293,10 @@ function ExternalStoragePathsEditor({ value, onChange, settingKey }) { ); } -function DetectedDrivesInfo() { +function DetectedDrivesInfo({ expertModeEnabled = false, onNotify = null }) { const [drives, setDrives] = useState(null); const [loading, setLoading] = useState(false); + const [unlocking, setUnlocking] = useState(false); const load = () => { setLoading(true); @@ -307,21 +308,70 @@ function DetectedDrivesInfo() { useEffect(() => { load(); }, []); + const notify = (severity, summary, detail) => { + if (typeof onNotify === 'function') { + onNotify({ severity, summary, detail }); + } + }; + + const handleUnlockOne = async (devicePath) => { + setUnlocking(true); + try { + await api.forceUnlockDrive({ devicePath }); + notify('success', 'Laufwerk freigegeben', `${devicePath} wurde entsperrt.`); + load(); + } catch (error) { + notify('error', 'Freigabe fehlgeschlagen', error?.message || 'Unbekannter Fehler'); + } finally { + setUnlocking(false); + } + }; + + const handleUnlockAll = async () => { + setUnlocking(true); + try { + await api.forceUnlockDrive({ all: true }); + notify('success', 'Laufwerke freigegeben', 'Alle erkannten Laufwerke wurden entsperrt.'); + load(); + } catch (error) { + notify('error', 'Freigabe fehlgeschlagen', error?.message || 'Unbekannter Fehler'); + } finally { + setUnlocking(false); + } + }; + return (
Erkannte optische Laufwerke -
{drives === null || loading ? Wird geladen… @@ -334,6 +384,7 @@ function DetectedDrivesInfo() { Gerät Modell MakeMKV Index + {expertModeEnabled ? Freigabe : null} @@ -342,6 +393,19 @@ function DetectedDrivesInfo() { {d.path} {d.model || '—'} + {expertModeEnabled ? ( + +