diff --git a/backend/package-lock.json b/backend/package-lock.json index 795b2ee..1246a25 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.16.1-6", + "version": "0.16.1-7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.16.1-6", + "version": "0.16.1-7", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 3cd8400..afefb7c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.16.1-6", + "version": "0.16.1-7", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/config.js b/backend/src/config.js index 8cf14e0..91c59a1 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -18,6 +18,7 @@ module.exports = { port: process.env.PORT ? Number(process.env.PORT) : 3001, dbPath: resolvedDbPath, dataDir, + tempDir: resolveOutputPath(process.env.DEFAULT_TEMP_DIR, 'temp'), corsOrigin: process.env.CORS_ORIGIN || '*', logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir), logLevel: process.env.LOG_LEVEL || 'info', diff --git a/backend/src/db/database.js b/backend/src/db/database.js index e79958d..5623893 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -528,6 +528,7 @@ async function openAndPrepareDatabase() { await migrateLegacyProfiledToolSettings(dbInstance); await migrateOutputTemplates(dbInstance); await removeDeprecatedSettings(dbInstance); + await migrateTmdbMigrationFlags(dbInstance); await migrateSettingsSchemaMetadata(dbInstance); await ensurePipelineStateRow(dbInstance); await backfillJobOutputFolders(dbInstance); @@ -811,7 +812,9 @@ async function removeDeprecatedSettings(db) { 'use_plugin_architecture_audiobook_analyze', 'use_plugin_architecture_audiobook_encode', 'dvd_series_plugin_enabled', - 'dvd_series_prescan_enabled' + 'dvd_series_prescan_enabled', + 'dvd_series_reuse_disc_profiles', + 'musicbrainz_enabled' ]; for (const key of deprecatedKeys) { const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]); @@ -828,6 +831,64 @@ async function removeDeprecatedSettings(db) { ); } +async function migrateTmdbMigrationFlags(db) { + // Keep this migration idempotent. It helps initialize the new migrate_tmdb flag + // for existing rows after OMDb -> TMDb transition. + const nullableReset = await db.run( + ` + UPDATE jobs + SET migrate_tmdb = 0 + WHERE migrate_tmdb IS NULL + ` + ); + + // Audio-only domains are outside TMDb migration scope. + const nonVideoMarked = await db.run( + ` + UPDATE jobs + SET migrate_tmdb = 1 + WHERE COALESCE(migrate_tmdb, 0) = 0 + AND LOWER(TRIM(COALESCE(media_type, ''))) IN ('cd', 'audiobook') + ` + ); + + // Rows with obvious TMDb assignment can be marked as migrated. + const obviousTmdbMarked = await db.run( + ` + UPDATE jobs + SET migrate_tmdb = 1 + WHERE COALESCE(migrate_tmdb, 0) = 0 + AND ( + makemkv_info_json LIKE '%"metadataProvider":"tmdb"%' + OR makemkv_info_json LIKE '%"tmdbId":%' + OR encode_plan_json LIKE '%"metadataProvider":"tmdb"%' + OR encode_plan_json LIKE '%"tmdbId":%' + ) + ` + ); + + // Explicit OMDb footprints must stay pending for manual migration. + const explicitOmdbPending = await db.run( + ` + UPDATE jobs + SET migrate_tmdb = 0 + WHERE ( + COALESCE(selected_from_omdb, 0) = 1 + OR (omdb_json IS NOT NULL AND TRIM(omdb_json) <> '') + OR makemkv_info_json LIKE '%"metadataProvider":"omdb"%' + OR encode_plan_json LIKE '%"metadataProvider":"omdb"%' + ) + ` + ); + + logger.info('migrate:tmdb-flag:done', { + nullableReset: Number(nullableReset?.changes || 0), + nonVideoMarked: Number(nonVideoMarked?.changes || 0), + obviousTmdbMarked: Number(obviousTmdbMarked?.changes || 0), + explicitOmdbPending: Number(explicitOmdbPending?.changes || 0) + }); +} + // Aktualisiert settings_schema-Metadaten (required, description, validation_json) // für bestehende Einträge, ohne user-konfigurierte Werte in settings_values anzutasten. const SETTINGS_SCHEMA_METADATA_UPDATES = [ @@ -849,6 +910,18 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [ description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.', validation_json: '{}' }, + { + key: 'dvd_series_language', + required: 1, + description: 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US.', + validation_json: '{"minLength":2}' + }, + { + key: 'dvd_series_fallback_languages', + required: 1, + description: 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR.', + validation_json: '{"minLength":2}' + }, { key: 'output_template_bluray_series_episode', required: 1, @@ -973,6 +1046,41 @@ async function migrateSettingsSchemaMetadata(db) { }); } + const metadataLanguageLabel = 'Film-Sprache'; + const metadataLanguageDescription = 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US.'; + const metadataLanguageResult = await db.run( + `UPDATE settings_schema + SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE key = 'dvd_series_language' AND (label != ? OR description != ?)`, + [metadataLanguageLabel, metadataLanguageDescription, metadataLanguageLabel, metadataLanguageDescription] + ); + if (metadataLanguageResult?.changes > 0) { + logger.info('migrate:settings-schema-language-updated', { + key: 'dvd_series_language', + label: metadataLanguageLabel + }); + } + + const metadataFallbackLanguageLabel = 'Fallback Sprache'; + const metadataFallbackLanguageDescription = 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR.'; + const metadataFallbackLanguageResult = await db.run( + `UPDATE settings_schema + SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE key = 'dvd_series_fallback_languages' AND (label != ? OR description != ?)`, + [ + metadataFallbackLanguageLabel, + metadataFallbackLanguageDescription, + metadataFallbackLanguageLabel, + metadataFallbackLanguageDescription + ] + ); + if (metadataFallbackLanguageResult?.changes > 0) { + logger.info('migrate:settings-schema-fallback-language-updated', { + key: 'dvd_series_fallback_languages', + label: metadataFallbackLanguageLabel + }); + } + const dvdSeriesMultiEpisodeTemplateDefault = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'; const legacyDvdSeriesMultiEpisodeTemplateDefaults = [ '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}' @@ -1063,9 +1171,18 @@ async function migrateSettingsSchemaMetadata(db) { await db.run( `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) - VALUES ('handbrake_pre_metadata_scan_enabled', 'Tools', 'Vorab-HandBrake-Scan vor Metadaten', 'boolean', 1, 'Wenn deaktiviert, wird der erste HandBrake-Scan vor der Metadatenauswahl übersprungen.', 'true', '[]', '{}', 221)` + VALUES ('series_playall_tolerance_lower_pct', 'Tools', 'Serie: PlayAll Untergrenze (%)', 'number', 1, 'Untere Toleranz in Prozent für die Plausibilitätsprüfung von PlayAll gegen Episodensumme (DVD/Blu-ray).', '10', '[]', '{"min":0,"max":100}', 234)` ); - await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_pre_metadata_scan_enabled', 'true')`); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_playall_tolerance_lower_pct', '10')`); + + 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_playall_tolerance_upper_pct', 'Tools', 'Serie: PlayAll Obergrenze (%)', 'number', 1, 'Obere Toleranz in Prozent für die Plausibilitätsprüfung von PlayAll gegen Episodensumme (DVD/Blu-ray).', '10', '[]', '{"min":0,"max":100}', 235)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_playall_tolerance_upper_pct', '10')`); + + await db.run(`DELETE FROM settings_values WHERE key = 'handbrake_pre_metadata_scan_enabled'`); + await db.run(`DELETE FROM settings_schema WHERE key = 'handbrake_pre_metadata_scan_enabled'`); await db.run( `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) @@ -1226,30 +1343,20 @@ async function migrateSettingsSchemaMetadata(db) { await db.run( `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) - VALUES ('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)` + VALUES ('dvd_series_language', 'Metadaten', 'Film-Sprache', 'string', 1, 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), 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)` + VALUES ('dvd_series_fallback_languages', 'Metadaten', 'Fallback Sprache', 'string', 1, 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), 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')`); + await db.run(`DELETE FROM settings_values WHERE key = 'dvd_series_order_preference'`); + await db.run(`DELETE FROM settings_schema WHERE key = 'dvd_series_order_preference'`); // Converter-Pfade und Eigentümer await db.run( diff --git a/backend/src/middleware/requestLogger.js b/backend/src/middleware/requestLogger.js index 0186ea9..4a22e10 100644 --- a/backend/src/middleware/requestLogger.js +++ b/backend/src/middleware/requestLogger.js @@ -26,6 +26,7 @@ function truncate(value, maxLen = 1500) { module.exports = function requestLogger(req, res, next) { const reqId = randomUUID(); const startedAt = Date.now(); + let completionLogged = false; req.reqId = reqId; @@ -38,15 +39,37 @@ module.exports = function requestLogger(req, res, next) { body: truncate(req.body) }); + const logCompletion = (kind) => { + if (completionLogged) { + return; + } + completionLogged = true; + logger.info('request:end', { + reqId, + method: req.method, + url: req.originalUrl, + statusCode: res.statusCode, + durationMs: Date.now() - startedAt, + completion: kind + }); + }; + + res.on('finish', () => { + logCompletion('finish'); + }); + res.on('close', () => { if (!res.writableEnded) { logger.warn('request:aborted', { reqId, method: req.method, url: req.originalUrl, + statusCode: res.statusCode, durationMs: Date.now() - startedAt }); + return; } + logCompletion('close'); }); next(); diff --git a/backend/src/plugins/VideoDiscPlugin.js b/backend/src/plugins/VideoDiscPlugin.js index 9bd3b74..98e02fc 100644 --- a/backend/src/plugins/VideoDiscPlugin.js +++ b/backend/src/plugins/VideoDiscPlugin.js @@ -2,7 +2,7 @@ const path = require('path'); const { SourcePlugin } = require('./PluginBase'); -const omdbService = require('../services/omdbService'); +const tmdbService = require('../services/tmdbService'); const { spawnTrackedProcess } = require('../services/processRunner'); const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers'); const { ensureDir } = require('../utils/files'); @@ -11,7 +11,7 @@ const { ensureDir } = require('../utils/files'); * Gemeinsame Basisklasse für Blu-ray und DVD Plugins. * * Implementiert die gemeinsame Pipeline-Logik für alle Video-Disc-Medien: - * analyze() → OMDB-Suche + Disc-Metadaten vorbereiten + * analyze() → TMDb-Suche + Disc-Metadaten vorbereiten * review() → HandBrake-Scan (liefert rohe Scandaten für den Orchestrator) * rip() → makemkvcon backup/mkv * encode() → HandBrakeCLI @@ -54,13 +54,13 @@ class VideoDiscPlugin extends SourcePlugin { } /** - * Führt eine OMDB-Suche für den erkannten Disc-Titel durch. - * Gibt { detectedTitle, omdbCandidates } zurück. + * Führt eine TMDb-Suche für den erkannten Disc-Titel durch. + * Gibt { detectedTitle, metadataCandidates } zurück. * * @param {string} devicePath * @param {object} job - Vorbereiteter Job-Record (noch ohne Metadaten) * @param {PluginContext} ctx - * @returns {Promise<{detectedTitle: string, omdbCandidates: Array}>} + * @returns {Promise<{detectedTitle: string, metadataCandidates: Array}>} */ async analyze(devicePath, job, ctx) { ctx.markExecution('analyze', { @@ -76,8 +76,8 @@ class VideoDiscPlugin extends SourcePlugin { ctx.logger.info(`${this.id}:analyze:start`, { devicePath, jobId: job?.id, detectedTitle }); - const omdbCandidates = await omdbService.search(detectedTitle).catch((error) => { - ctx.logger.warn(`${this.id}:analyze:omdb-failed`, { + const metadataCandidates = await tmdbService.searchMoviesWithDetails(detectedTitle).catch((error) => { + ctx.logger.warn(`${this.id}:analyze:tmdb-failed`, { jobId: job?.id, error: error?.message || String(error) }); @@ -87,10 +87,10 @@ class VideoDiscPlugin extends SourcePlugin { ctx.logger.info(`${this.id}:analyze:done`, { jobId: job?.id, detectedTitle, - omdbCandidates: omdbCandidates.length + metadataCandidates: metadataCandidates.length }); - return { detectedTitle, omdbCandidates }; + return { detectedTitle, metadataCandidates }; } /** @@ -277,6 +277,9 @@ class VideoDiscPlugin extends SourcePlugin { // des PipelineService und liefert das volle runInfo (inkl. highlights, stdout-Log, // Prozess-Tracking, Cancellation). Fallback: _spawnAndWait für Standalone/Tests. const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null; + const makeMkvLineFilter = typeof ctx.extra?.makeMkvLineFilter === 'function' + ? ctx.extra.makeMkvLineFilter + : null; let runInfo; if (runCommandFn) { runInfo = await runCommandFn({ @@ -285,7 +288,8 @@ class VideoDiscPlugin extends SourcePlugin { source: 'MAKEMKV_RIP', cmd: ripConfig.cmd, args: ripConfig.args, - parser: parseMakeMkvProgress + parser: parseMakeMkvProgress, + lineFilter: makeMkvLineFilter }); } else { runInfo = await _spawnAndWait({ diff --git a/backend/src/routes/converterRoutes.js b/backend/src/routes/converterRoutes.js index d8913e3..9db5a82 100644 --- a/backend/src/routes/converterRoutes.js +++ b/backend/src/routes/converterRoutes.js @@ -2,7 +2,6 @@ const express = require('express'); const fs = require('fs'); -const os = require('os'); const path = require('path'); const multer = require('multer'); const asyncHandler = require('../middleware/asyncHandler'); @@ -10,6 +9,7 @@ const pipelineService = require('../services/pipelineService'); const converterScanService = require('../services/converterScanService'); const historyService = require('../services/historyService'); const logger = require('../services/logger').child('CONVERTER_ROUTE'); +const { tempDir } = require('../config'); /** Pfadauflösung mit Traversal-Schutz (wie Klangkiste resolveMediaTarget) */ function resolveTarget(rawDir, input) { @@ -20,9 +20,11 @@ function resolveTarget(rawDir, input) { } const router = express.Router(); +const converterUploadDir = path.join(tempDir, 'ripster-converter-uploads'); +fs.mkdirSync(converterUploadDir, { recursive: true }); const converterUpload = multer({ - dest: path.join(os.tmpdir(), 'ripster-converter-uploads') + dest: converterUploadDir }); // ── Scan ────────────────────────────────────────────────────────────────── diff --git a/backend/src/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js index 8ce998d..cf1f5a1 100644 --- a/backend/src/routes/historyRoutes.js +++ b/backend/src/routes/historyRoutes.js @@ -66,6 +66,42 @@ router.get( }) ); +router.get( + '/tmdb-migration/pending', + asyncHandler(async (req, res) => { + const parsedLimit = Number(req.query.limit); + const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 + ? Math.trunc(parsedLimit) + : 500; + logger.info('get:tmdb-migration:pending', { + reqId: req.reqId, + limit + }); + const jobs = await historyService.getTmdbMigrationPendingJobs({ + limit, + includeFsChecks: false + }); + res.json({ jobs }); + }) +); + +router.post( + '/:id/tmdb-migration', + asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const migrated = ['1', 'true', 'yes', 'on'].includes( + String(req.body?.migrated ?? req.body?.migrateTMDB ?? '').trim().toLowerCase() + ); + logger.info('post:tmdb-migration:flag', { + reqId: req.reqId, + id, + migrated + }); + const job = await historyService.setTmdbMigrationFlag(id, migrated); + res.json({ job }); + }) +); + router.post( '/orphan-raw/import', asyncHandler(async (req, res) => { @@ -104,11 +140,21 @@ router.post( ); router.post( - '/:id/omdb/assign', + '/orphan-raw/delete', + asyncHandler(async (req, res) => { + const rawPath = String(req.body?.rawPath || '').trim(); + logger.warn('post:orphan-raw:delete', { reqId: req.reqId, rawPath }); + const result = await historyService.deleteOrphanRawFolder(rawPath); + res.json(result); + }) +); + +router.post( + '/:id/metadata/assign', asyncHandler(async (req, res) => { const id = Number(req.params.id); const payload = req.body || {}; - logger.info('post:job:omdb:assign', { + logger.info('post:job:metadata:assign', { reqId: req.reqId, id, imdbId: payload?.imdbId || null, @@ -116,11 +162,11 @@ router.post( hasYear: Boolean(payload?.year) }); - const job = await historyService.assignOmdbMetadata(id, payload); + const job = await historyService.assignMetadata(id, payload); // Rename raw/output folders to reflect new metadata (best-effort, non-blocking) pipelineService.renameJobFolders(id).catch((err) => { - logger.warn('post:job:omdb:assign:rename-failed', { id, error: err.message }); + logger.warn('post:job:metadata:assign:rename-failed', { id, error: err.message }); }); res.json({ job }); @@ -141,7 +187,15 @@ router.post( trackCount: Array.isArray(payload?.tracks) ? payload.tracks.length : 0 }); - const job = await historyService.assignCdMetadata(id, payload); + const job = await pipelineService.selectCdMetadata({ + jobId: id, + title: payload?.title, + artist: payload?.artist, + year: payload?.year, + mbId: payload?.mbId, + coverUrl: payload?.coverUrl, + tracks: payload?.tracks + }); // Rename raw/output folders to reflect new metadata (best-effort, non-blocking) pipelineService.renameJobFolders(id).catch((err) => { @@ -250,9 +304,10 @@ router.post( }); const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase()); const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase()); - const requestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined + const hasRequestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined; + const requestedKeepDetectedDevice = hasRequestedKeepDetectedDevice ? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase()) - : !requestedResetDriveState; + : null; const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths) ? req.body.selectedRawPaths .map((item) => String(item || '').trim()) @@ -284,12 +339,35 @@ router.post( (row) => Boolean(row?.selected) && Boolean(row?.orphanRawImport) )) ); + const selectedDeleteJobIds = Array.isArray(preview?.selectedJobIds) + ? preview.selectedJobIds + : [id]; + const selectedDeleteJobs = await historyService.getJobsByIds(selectedDeleteJobIds).catch(() => []); + const autoResetDriveStateForPhysicalCd = Array.isArray(selectedDeleteJobs) && selectedDeleteJobs.some((job) => { + const mediaType = String(job?.media_type || job?.mediaType || '').trim().toLowerCase(); + const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); + const status = String(job?.status || '').trim().toUpperCase(); + const lastState = String(job?.last_state || '').trim().toUpperCase(); + const isCdJob = mediaType === 'cd' + || jobKind === 'cd' + || status.startsWith('CD_') + || lastState.startsWith('CD_'); + if (!isCdJob) { + return false; + } + const discDevice = String(job?.disc_device || job?.discDevice || '').trim(); + return Boolean(discDevice) && !discDevice.startsWith('__virtual__'); + }); + + const requestedResetDriveStateEffective = requestedResetDriveState || autoResetDriveStateForPhysicalCd; const resetDriveState = containsOrphanRawImportJob ? false - : requestedResetDriveState; + : requestedResetDriveStateEffective; const keepDetectedDevice = containsOrphanRawImportJob ? true - : requestedKeepDetectedDevice; + : (hasRequestedKeepDetectedDevice + ? requestedKeepDetectedDevice + : !requestedResetDriveStateEffective); if (containsOrphanRawImportJob && requestedResetDriveState) { logger.info('post:delete-job:orphan-drive-reset-skipped', { @@ -298,6 +376,14 @@ router.post( }); } + if (autoResetDriveStateForPhysicalCd && !requestedResetDriveState) { + logger.info('post:delete-job:auto-drive-reset-cd', { + reqId: req.reqId, + id, + selectedJobIds: selectedDeleteJobIds + }); + } + const relatedDevicePaths = Array.isArray(preview?.relatedJobs) ? preview.relatedJobs .filter((row) => Boolean(row?.selected)) diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index d45c5cd..abadc34 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -1,6 +1,5 @@ const express = require('express'); const fs = require('fs'); -const os = require('os'); const path = require('path'); const multer = require('multer'); const asyncHandler = require('../middleware/asyncHandler'); @@ -11,86 +10,16 @@ const hardwareMonitorService = require('../services/hardwareMonitorService'); const settingsService = require('../services/settingsService'); const logger = require('../services/logger').child('PIPELINE_ROUTE'); const activationBytesService = require('../services/activationBytesService'); -const { defaultAudiobookDir } = require('../config'); +const { tempDir } = require('../config'); const { getDb } = require('../db/database'); const router = express.Router(); +const audiobookUploadDir = path.join(tempDir, 'ripster-audiobook-uploads'); +fs.mkdirSync(audiobookUploadDir, { recursive: true }); const audiobookUpload = multer({ - dest: path.join(os.tmpdir(), 'ripster-audiobook-uploads') + dest: audiobookUploadDir }); -const AUDIOBOOK_TREE_MAX_DEPTH = 8; - -function buildAudiobookOutputTree(rootDir, relPath = '', depth = 0) { - if (depth >= AUDIOBOOK_TREE_MAX_DEPTH) { - return []; - } - const absDir = relPath ? path.join(rootDir, relPath) : rootDir; - let dirents = []; - try { - dirents = fs.readdirSync(absDir, { withFileTypes: true }); - } catch (_error) { - return []; - } - - dirents.sort((left, right) => { - const leftOrder = left.isDirectory() ? 0 : 1; - const rightOrder = right.isDirectory() ? 0 : 1; - if (leftOrder !== rightOrder) { - return leftOrder - rightOrder; - } - return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }); - }); - - const nodes = []; - for (const dirent of dirents) { - if (dirent.name.startsWith('.')) { - continue; - } - const childRelPath = relPath ? `${relPath}/${dirent.name}` : dirent.name; - const childAbsPath = path.join(rootDir, childRelPath); - if (dirent.isDirectory()) { - const children = buildAudiobookOutputTree(rootDir, childRelPath, depth + 1); - const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0); - let mtime = null; - try { - mtime = fs.statSync(childAbsPath).mtime.toISOString(); - } catch (_error) { - mtime = null; - } - nodes.push({ - name: dirent.name, - type: 'folder', - path: childRelPath, - size, - mtime, - children - }); - continue; - } - if (dirent.isFile()) { - let size = 0; - let mtime = null; - try { - const stat = fs.statSync(childAbsPath); - size = Number(stat?.size || 0); - mtime = stat?.mtime ? stat.mtime.toISOString() : null; - } catch (_error) { - size = 0; - mtime = null; - } - nodes.push({ - name: dirent.name, - type: 'file', - path: childRelPath, - size, - mtime - }); - } - } - return nodes; -} - router.get( '/state', asyncHandler(async (req, res) => { @@ -146,11 +75,11 @@ router.post( ); router.get( - '/omdb/search', + '/tmdb/movie/search', asyncHandler(async (req, res) => { const query = req.query.q || ''; - logger.info('get:omdb:search', { reqId: req.reqId, query }); - const results = await pipelineService.searchOmdb(String(query)); + logger.info('get:tmdb:movie-search', { reqId: req.reqId, query }); + const results = await pipelineService.searchTmdbMovies(String(query)); res.json({ results }); }) ); @@ -170,8 +99,12 @@ router.get( '/cd/musicbrainz/search', asyncHandler(async (req, res) => { const query = req.query.q || ''; - logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query }); - const results = await pipelineService.searchMusicBrainz(String(query)); + const trackCountRaw = Number(req.query.trackCount); + const trackCount = Number.isFinite(trackCountRaw) && trackCountRaw > 0 + ? Math.trunc(trackCountRaw) + : null; + logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query, trackCount }); + const results = await pipelineService.searchMusicBrainz(String(query), { trackCount }); res.json({ results }); }) ); @@ -318,47 +251,19 @@ router.get( }) ); -router.get( - '/audiobook/output-tree', - asyncHandler(async (_req, res) => { - const settings = await settingsService.getEffectiveSettingsMap('audiobook'); - const configuredOutputDir = String(settings?.movie_dir || '').trim(); - const rawOutputDir = configuredOutputDir || defaultAudiobookDir || null; - const outputDir = rawOutputDir - ? (path.isAbsolute(rawOutputDir) ? rawOutputDir : path.resolve(process.cwd(), rawOutputDir)) - : null; - - if (!outputDir || !fs.existsSync(outputDir)) { - res.json({ outputDir, tree: null }); - return; - } - - const children = buildAudiobookOutputTree(outputDir, '', 0); - const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0); - res.json({ - outputDir, - tree: { - name: path.basename(outputDir) || 'audiobooks', - type: 'folder', - path: '', - size, - mtime: null, - children - } - }); - }) -); - router.post( '/select-metadata', asyncHandler(async (req, res) => { const { jobId, title, + artist, year, imdbId, poster, - fromOmdb, + mbId, + coverUrl, + tracks, selectedPlaylist, selectedHandBrakeTitleId, selectedHandBrakeTitleIds, @@ -387,10 +292,13 @@ router.post( reqId: req.reqId, jobId, title, + artist, year, imdbId, poster, - fromOmdb, + mbId, + coverUrl, + trackCount: Array.isArray(tracks) ? tracks.length : null, selectedPlaylist, selectedHandBrakeTitleId, selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null, @@ -405,13 +313,37 @@ router.post( existingDiscNumber }); + const looksLikeCdMetadataPayload = ( + artist !== undefined + || mbId !== undefined + || coverUrl !== undefined + || Array.isArray(tracks) + ); + if (looksLikeCdMetadataPayload) { + logger.warn('post:select-metadata:compat-cd-route', { + reqId: req.reqId, + jobId, + trackCount: Array.isArray(tracks) ? tracks.length : 0 + }); + const job = await pipelineService.selectCdMetadata({ + jobId: Number(jobId), + title, + artist, + year, + mbId, + coverUrl: coverUrl || poster || null, + tracks + }); + res.json({ job }); + return; + } + const job = await pipelineService.selectMetadata({ jobId: Number(jobId), title, year, imdbId, poster, - fromOmdb, selectedPlaylist, selectedHandBrakeTitleId, selectedHandBrakeTitleIds, diff --git a/backend/src/services/coverArtRecoveryService.js b/backend/src/services/coverArtRecoveryService.js index 1bc2646..2db62cf 100644 --- a/backend/src/services/coverArtRecoveryService.js +++ b/backend/src/services/coverArtRecoveryService.js @@ -130,12 +130,6 @@ function collectCoverCandidates(row) { push(deriveCoverArtArchiveUrl(mbId), 'musicbrainz.coverartarchive'); } - const omdbInfo = parseJsonSafe(row?.omdb_json, {}); - const omdbPoster = String(omdbInfo?.Poster || '').trim(); - if (omdbPoster && omdbPoster.toUpperCase() !== 'N/A') { - push(omdbPoster, 'omdb_json.Poster'); - } - return candidates; } @@ -280,8 +274,7 @@ class CoverArtRecoveryService { status, poster_url, imdb_id, - makemkv_info_json, - omdb_json + makemkv_info_json FROM jobs ORDER BY COALESCE(updated_at, created_at) DESC, id DESC ` diff --git a/backend/src/services/hardwareMonitorService.js b/backend/src/services/hardwareMonitorService.js index 97bfe0b..a058996 100644 --- a/backend/src/services/hardwareMonitorService.js +++ b/backend/src/services/hardwareMonitorService.js @@ -1096,7 +1096,9 @@ class HardwareMonitorService { } const mountPoint = parsed?.mountPoint ? path.resolve(parsed.mountPoint) : parsed.mountPoint; const hasDedicatedMount = Boolean(mountPoint) && !isRootPath(mountPoint); - const shouldShowExternal = exists || hasDedicatedMount; + // External storage entries must only be visible when a dedicated filesystem + // mount is present. Existing directories alone are not enough. + const shouldShowExternal = hasDedicatedMount; if (hideWhenUnavailable && requireDedicatedMount && !shouldShowExternal) { return hiddenResult({ path: resolvedPath, diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 07a91d2..a01a7d4 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -4,7 +4,6 @@ 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'); @@ -30,6 +29,8 @@ const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_'; const PROCESS_LOG_FILE_PATTERN = /^job-(\d+)\.process\.log$/i; const CD_OUTPUT_AUDIO_EXTENSIONS = new Set(['.flac', '.wav', '.mp3', '.opus', '.ogg']); const CD_RAW_TRACK_FILE_PATTERN = /^track(\d{1,3})\.cdda\.wav$/i; +const INCOMPLETE_OUTPUT_PATH_PATTERN = /(^|[\\/])incomplete_job-\d+([\\/]|$)/i; +const INCOMPLETE_MERGE_OUTPUT_PATH_PATTERN = /(^|[\\/])incomplete_merge_[^\\/]+_job_\d+([\\/]|$)/i; function inspectDirectory(dirPath) { if (!dirPath) { @@ -123,6 +124,24 @@ function inspectOutputFile(filePath) { } } +function isIncompleteRawStoragePath(rawPath) { + const normalized = String(rawPath || '').trim(); + if (!normalized) { + return false; + } + const baseName = path.basename(normalized); + return /^incomplete_/i.test(baseName); +} + +function isIncompleteOutputStoragePath(outputPath) { + const normalized = String(outputPath || '').trim(); + if (!normalized) { + return false; + } + return INCOMPLETE_OUTPUT_PATH_PATTERN.test(normalized) + || INCOMPLETE_MERGE_OUTPUT_PATH_PATTERN.test(normalized); +} + function parseBooleanValue(value, fallback = false) { if (value === null || value === undefined) { return fallback; @@ -257,12 +276,89 @@ function buildNfoXml(metadata = {}) { ].join('\n'); } +function resolveConverterMediaTypeForNfo(job = {}, encodePlan = null) { + const plan = encodePlan && typeof encodePlan === 'object' + ? encodePlan + : parseInfoFromValue(job?.encodePlan ?? job?.encode_plan_json, {}); + const mkInfo = parseInfoFromValue(job?.makemkvInfo ?? job?.makemkv_info_json, {}); + const miInfo = parseInfoFromValue(job?.mediainfoInfo ?? job?.mediainfo_info_json, {}); + const candidates = [ + plan?.converterMediaType, + job?.converterMediaType, + mkInfo?.converterMediaType, + miInfo?.converterMediaType + ]; + for (const candidate of candidates) { + const raw = String(candidate || '').trim().toLowerCase(); + if (raw === 'audio' || raw === 'video' || raw === 'iso') { + return raw; + } + } + return null; +} + +function resolveNfoSupportForJob(job = {}, settings = {}, options = {}) { + const mkInfo = parseInfoFromValue(job?.makemkvInfo ?? job?.makemkv_info_json, {}); + const miInfo = parseInfoFromValue(job?.mediainfoInfo ?? job?.mediainfo_info_json, {}); + const plan = parseInfoFromValue(job?.encodePlan ?? job?.encode_plan_json, {}); + const hbInfo = parseInfoFromValue(job?.handbrakeInfo ?? job?.handbrake_info_json, {}); + const selectedMetadata = extractSelectedMetadataFromMakemkvInfo(mkInfo); + const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' + ? mkInfo.analyzeContext + : {}; + const mediaType = normalizeMediaTypeValue(options?.mediaType) + || normalizeMediaTypeValue(job?.mediaType) + || normalizeMediaTypeValue(job?.media_type) + || inferMediaType(job, mkInfo, miInfo, plan, hbInfo); + const normalizedJobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase(); + const hasSeriesJobKind = normalizedJobKind === 'dvd_series_child' || normalizedJobKind === 'dvd_series_container'; + const hasSeriesMetadata = hasSeriesMetadataSignals(selectedMetadata, analyzeContext); + const hasSeriesRawPathHint = isLikelySeriesRawPath(job?.raw_path, settings || {}); + const seriesBatchMode = String(hbInfo?.mode || '').trim().toLowerCase() === 'series_batch' + || Boolean(plan?.seriesBatchChild) + || Boolean(plan?.seriesBatchVirtualEpisode); + const isSeriesMedia = (mediaType === 'dvd' || mediaType === 'bluray') && ( + hasSeriesJobKind + || hasSeriesMetadata + || hasSeriesRawPathHint + || seriesBatchMode + ); + + const outputPath = String(options?.outputPath || '').trim(); + const outputExt = String(path.extname(outputPath || '') || '').trim().toLowerCase(); + const audioOutputExtensions = new Set([ + '.flac', '.wav', '.mp3', '.opus', '.ogg', '.m4a', '.m4b', '.aac', '.alac', '.aif', '.aiff', '.wma' + ]); + const converterMediaType = resolveConverterMediaTypeForNfo(job, plan); + + let supported = false; + if (mediaType === 'bluray' || mediaType === 'dvd') { + supported = !isSeriesMedia; + } else if (mediaType === 'converter') { + if (converterMediaType === 'audio') { + supported = false; + } else if (converterMediaType === 'video' || converterMediaType === 'iso') { + supported = true; + } else { + supported = !audioOutputExtensions.has(outputExt); + } + } + + return { + supported, + mediaType: mediaType || null, + converterMediaType, + isSeriesMedia + }; +} + function buildNfoStatusForJob({ job = {}, outputPath = null, outputStatus = null, includeFsChecks = true, - settings = {} + settings = {}, + mediaType = null } = {}) { const normalizedOutputPath = String(outputPath || '').trim() || null; const nfoPath = resolveNfoPathFromOutputPath(normalizedOutputPath); @@ -271,16 +367,23 @@ function buildNfoStatusForJob({ settings?.generate_nfo_after_encode, false ); + const supportInfo = resolveNfoSupportForJob(job, settings, { + mediaType, + outputPath: normalizedOutputPath + }); const outputExists = Boolean(outputStatus?.exists); const outputIsFile = Boolean(outputStatus?.isFile); - const canGenerateManual = !nfoSettingEnabled + const canGenerateManual = Boolean(supportInfo?.supported) + && !nfoSettingEnabled && Boolean(nfoPath) && outputExists && outputIsFile && !Boolean(nfoStatus?.exists); let reason = null; - if (nfoSettingEnabled) { + if (!supportInfo?.supported) { + reason = 'unsupported_media'; + } else if (nfoSettingEnabled) { reason = 'setting_enabled'; } else if (!nfoPath) { reason = 'output_not_file'; @@ -294,6 +397,10 @@ function buildNfoStatusForJob({ return { settingEnabled: nfoSettingEnabled, + supported: Boolean(supportInfo?.supported), + mediaType: supportInfo?.mediaType || null, + converterMediaType: supportInfo?.converterMediaType || null, + isSeriesMedia: Boolean(supportInfo?.isSeriesMedia), outputPath: normalizedOutputPath, nfoPath, outputExists: includeFsChecks ? outputExists : null, @@ -1166,7 +1273,6 @@ function buildUnknownFileStatus(filePath = null) { function enrichJobRow(job, settings = null, options = {}) { const includeFsChecks = options?.includeFsChecks !== false; - const omdbInfo = parseJsonSafe(job.omdb_json, null); const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); const handbrakeInfo = resolvedPaths.handbrakeInfo; const directoryLikeOutput = Boolean(resolvedPaths.directoryLikeOutput); @@ -1237,7 +1343,8 @@ function enrichJobRow(job, settings = null, options = {}) { outputPath: resolvedPaths.effectiveOutputPath, outputStatus, includeFsChecks, - settings + settings, + mediaType }); return { @@ -1248,7 +1355,6 @@ function enrichJobRow(job, settings = null, options = {}) { makemkvInfo, handbrakeInfo, mediainfoInfo, - omdbInfo, encodePlan, mediaType, ripSuccessful, @@ -3869,8 +3975,7 @@ class HistoryService { const hasArtist = Boolean(String(selectedMetadata?.artist || '').trim()); const hasPoster = Boolean(posterCandidate); const hasExternalMetadata = Boolean( - row?.omdb_json - || row?.imdb_id + row?.imdb_id || selectedMetadata?.tmdbId || analyzeContext?.tmdbId || selectedMetadata?.mbId @@ -4120,10 +4225,6 @@ class HistoryService { patch.imdb_id = recoveredMbId; } } - if (!job?.omdb_json && sourceJob?.omdb_json) { - patch.omdb_json = sourceJob.omdb_json; - patch.selected_from_omdb = Number(sourceJob.selected_from_omdb || 0); - } if (!job?.poster_url && sourcePosterCandidate && !thumbnailService.isLocalUrl(sourcePosterCandidate)) { patch.poster_url = sourcePosterCandidate; } @@ -4775,6 +4876,62 @@ class HistoryService { }); } + async getTmdbMigrationPendingJobs(options = {}) { + const db = await getDb(); + const limitRaw = Number(options?.limit); + const limit = Number.isFinite(limitRaw) && limitRaw > 0 + ? Math.min(Math.trunc(limitRaw), 2000) + : 1000; + const includeFsChecks = options?.includeFsChecks === true; + + const [rows, settings] = await Promise.all([ + db.all( + ` + SELECT * + FROM jobs + WHERE COALESCE(migrate_tmdb, 0) = 0 + AND LOWER(TRIM(COALESCE(media_type, ''))) NOT IN ('cd', 'audiobook') + AND LOWER(TRIM(COALESCE(job_kind, ''))) NOT IN ('cd', 'audiobook') + ORDER BY COALESCE(updated_at, created_at) DESC, id DESC + LIMIT ? + `, + [limit] + ), + settingsService.getSettingsMap() + ]); + + const repairedRows = await Promise.all( + (Array.isArray(rows) ? rows : []).map((row) => this.repairImportedOrphanJobClassification(row, settings)) + ); + return repairedRows.map((row) => enrichJobRow(row, settings, { includeFsChecks })); + } + + async setTmdbMigrationFlag(jobId, migrated = false) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + const error = new Error('Ungültige Job-ID.'); + error.statusCode = 400; + throw error; + } + + const existing = await this.getJobById(normalizedJobId); + if (!existing) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + await this.updateJob(normalizedJobId, { + migrate_tmdb: migrated ? 1 : 0 + }); + + const [updated, settings] = await Promise.all([ + this.getJobById(normalizedJobId), + settingsService.getSettingsMap() + ]); + return enrichJobRow(updated, settings); + } + async getJobsByIds(jobIds = []) { const ids = Array.isArray(jobIds) ? jobIds @@ -5374,9 +5531,26 @@ class HistoryService { outputPath: enriched?.output_path || null, outputStatus: enriched?.outputStatus || null, includeFsChecks: true, - settings + settings, + mediaType: enriched?.mediaType || null }); + if (!nfoStatus.supported) { + if (mode === 'auto') { + return { + generated: false, + skipped: true, + reason: 'unsupported_media', + nfoStatus + }; + } + const error = new Error( + 'NFO-Generierung ist nur für Filme verfügbar (nicht für Serien, Audiobooks oder CDs).' + ); + error.statusCode = 409; + throw error; + } + if (requireSettingEnabled && !nfoStatus.settingEnabled) { return { generated: false, @@ -5435,7 +5609,8 @@ class HistoryService { outputPath: enriched?.output_path || null, outputStatus: enriched?.outputStatus || null, includeFsChecks: true, - settings + settings, + mediaType: enriched?.mediaType || null }); await this.appendLog( @@ -5487,6 +5662,7 @@ class HistoryService { } const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); + const enrichedJob = enrichJobRow(job, settings, { includeFsChecks: true }); const requestedOutputPath = normalizedTarget === 'output' ? (String(options?.outputPath || '').trim() || null) : null; @@ -5535,6 +5711,34 @@ class HistoryService { throw error; } + if (normalizedTarget === 'raw') { + const discMediaType = String(resolvedPaths?.mediaType || '').trim().toLowerCase(); + const isDiscMedia = ['dvd', 'bluray', 'cd'].includes(discMediaType); + const rawExists = Boolean(enrichedJob?.rawStatus?.exists); + const rawNotEmpty = enrichedJob?.rawStatus?.isEmpty !== true; + if (isIncompleteRawStoragePath(sourcePath) || !rawExists || !rawNotEmpty) { + const error = new Error('RAW ist noch nicht vollständig und kann nicht heruntergeladen werden.'); + error.statusCode = 409; + throw error; + } + if (isDiscMedia && !Boolean(enrichedJob?.ripSuccessful)) { + const error = new Error('RAW-Rip ist noch nicht abgeschlossen und kann nicht heruntergeladen werden.'); + error.statusCode = 409; + throw error; + } + } + + if (normalizedTarget === 'output') { + const statusUpper = String(enrichedJob?.status || '').trim().toUpperCase(); + const outputReady = Boolean(enrichedJob?.encodeSuccess || statusUpper === 'FINISHED'); + const outputExists = Boolean(enrichedJob?.outputStatus?.exists); + if (isIncompleteOutputStoragePath(sourcePath) || !outputExists || !outputReady) { + const error = new Error('Output ist noch nicht vollständig und kann nicht heruntergeladen werden.'); + error.statusCode = 409; + throw error; + } + } + let sourceStat; try { sourceStat = await fs.promises.stat(sourcePath); @@ -5705,6 +5909,63 @@ class HistoryService { }; } + async deleteOrphanRawFolder(rawPath) { + const settings = await settingsService.getSettingsMap(); + const rawDirs = getOrphanRawScanPathList(settings); + const requestedRawPath = String(rawPath || '').trim(); + + if (!requestedRawPath) { + const error = new Error('rawPath fehlt.'); + error.statusCode = 400; + throw error; + } + + if (rawDirs.length === 0) { + const error = new Error('Kein RAW-Pfad verfügbar (weder Settings noch Default-Pfade).'); + error.statusCode = 400; + throw error; + } + + const insideConfiguredRawDir = rawDirs.some((candidate) => isPathInside(candidate, requestedRawPath)); + if (!insideConfiguredRawDir) { + const error = new Error(`RAW-Pfad liegt außerhalb der konfigurierten RAW-Verzeichnisse: ${requestedRawPath}`); + error.statusCode = 400; + throw error; + } + + const absRawPath = normalizeComparablePath(requestedRawPath); + const dirInfo = inspectDirectory(absRawPath); + if (!dirInfo.exists || !dirInfo.isDirectory) { + const error = new Error(`RAW-Pfad existiert nicht als Verzeichnis: ${absRawPath}`); + error.statusCode = 404; + throw error; + } + + const orphanScan = await this.getOrphanRawFolders(); + const orphanRows = Array.isArray(orphanScan?.rows) ? orphanScan.rows : []; + const orphanRow = orphanRows.find((row) => normalizeComparablePath(row?.rawPath) === absRawPath); + if (!orphanRow) { + const error = new Error('RAW-Ordner ist nicht als verwaister /database-Eintrag verfügbar (ggf. bereits verknüpft).'); + error.statusCode = 409; + throw error; + } + + try { + const deleteResult = deleteFilesRecursively(absRawPath, false); + return { + deleted: true, + rawPath: absRawPath, + folderName: String(orphanRow?.folderName || path.basename(absRawPath)).trim() || path.basename(absRawPath), + filesDeleted: Number(deleteResult?.filesDeleted || 0), + dirsRemoved: Number(deleteResult?.dirsRemoved || 0) + }; + } catch (deleteError) { + const error = new Error(`RAW-Ordner konnte nicht gelöscht werden: ${deleteError?.message || deleteError}`); + error.statusCode = 500; + throw error; + } + } + async importOrphanRawFolder(rawPath) { const settings = await settingsService.getSettingsMap(); const rawDirs = getOrphanRawScanPathList(settings); @@ -5942,7 +6203,7 @@ class HistoryService { return enrichJobRow(imported, settings); } - async assignOmdbMetadata(jobId, payload = {}) { + async assignMetadata(jobId, payload = {}) { const job = await this.getJobById(jobId); if (!job) { const error = new Error('Job nicht gefunden.'); @@ -5970,7 +6231,7 @@ class HistoryService { const requestedWorkflowKind = normalizeDvdMetadataWorkflowKind(payload?.workflowKind); const metadataProvider = requestedProviderRaw === 'themoviedb' ? 'tmdb' - : (requestedProviderRaw || (requestedTmdbId !== null ? 'tmdb' : 'omdb')); + : (requestedProviderRaw || (requestedTmdbId !== null ? 'tmdb' : 'tmdb')); const makemkvInfo = parseJsonSafe(job.makemkv_info_json, {}); const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object' ? makemkvInfo.analyzeContext @@ -6025,6 +6286,15 @@ class HistoryService { const existingYearRaw = Number(existingSelectedMetadata?.year); const existingYear = Number.isFinite(existingYearRaw) ? Math.trunc(existingYearRaw) : null; + const existingWorkflowKind = normalizeDvdMetadataWorkflowKind( + existingSelectedMetadata?.workflowKind + || analyzeContext?.workflowKind + || null + ); + const resolvedWorkflowKind = requestedWorkflowKind + || existingWorkflowKind + || (manualSeasonNumber !== null ? 'series' : 'film'); + const isFilmWorkflow = resolvedWorkflowKind === 'film'; let title = manualTitle || String(existingSelectedMetadata?.title || '').trim() || job.title @@ -6039,6 +6309,22 @@ class HistoryService { let posterUrl = manualPoster || String(existingSelectedMetadata?.poster || job.poster_url || '').trim() || null; + let shouldQueuePosterCache = false; + if (posterUrl && !thumbnailService.isLocalUrl(posterUrl)) { + try { + const cacheResult = await this.cacheAndPromoteExternalPoster(jobId, posterUrl, { + source: 'TMDb Poster', + logFailures: false + }); + if (cacheResult?.ok && cacheResult?.localUrl) { + posterUrl = cacheResult.localUrl; + } else { + shouldQueuePosterCache = true; + } + } catch (_error) { + shouldQueuePosterCache = true; + } + } let seasonNumber = manualSeasonNumber !== null ? manualSeasonNumber : normalizePositiveNumberOrNull( @@ -6069,7 +6355,7 @@ class HistoryService { job?.encode_plan_json, job?.handbrake_info_json ); - if ((resolvedJobMediaType === 'dvd' || resolvedJobMediaType === 'bluray') && discNumber === null) { + if (!isFilmWorkflow && (resolvedJobMediaType === 'dvd' || resolvedJobMediaType === 'bluray') && discNumber === null) { const seriesDiscLabel = resolvedJobMediaType === 'bluray' ? 'Blu-ray' : 'DVD'; const error = new Error(`Serien-${seriesDiscLabel} erkannt: Disk-Nummer ist Pflicht (Start bei 1).`); error.statusCode = 400; @@ -6083,15 +6369,17 @@ class HistoryService { ).trim().toLowerCase() || null; if (!metadataKind) { - metadataKind = seasonNumber !== null ? 'season' : 'series'; + metadataKind = isFilmWorkflow ? 'movie' : (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}`; + providerId = isFilmWorkflow + ? `tmdb:${effectiveTmdbId}` + : (seasonNumber !== null + ? `tmdb:${effectiveTmdbId}:season:${seasonNumber}` + : `tmdb:${effectiveTmdbId}`); } let tmdbLanguage = null; try { @@ -6104,7 +6392,7 @@ class HistoryService { let tmdbDetails = existingSelectedMetadata?.tmdbDetails && typeof existingSelectedMetadata.tmdbDetails === 'object' ? { ...existingSelectedMetadata.tmdbDetails } : null; - if (effectiveTmdbId !== null) { + if (effectiveTmdbId !== null && !isFilmWorkflow) { try { const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, { language: tmdbLanguage, @@ -6136,7 +6424,7 @@ class HistoryService { } } } catch (tmdbDetailsErr) { - logger.warn('assignOmdbMetadata:tmdb-details-fetch-failed', { + logger.warn('assignMetadata:tmdb-series-details-fetch-failed', { jobId, tmdbId: effectiveTmdbId, message: tmdbDetailsErr?.message || String(tmdbDetailsErr) @@ -6144,7 +6432,45 @@ class HistoryService { } } - if (effectiveTmdbId !== null && seasonNumber !== null) { + if (effectiveTmdbId !== null && isFilmWorkflow) { + try { + const movieDetails = await tmdbService.getMovieDetails(effectiveTmdbId, { + language: tmdbLanguage, + appendToResponse: ['credits', 'external_ids'] + }); + const detailsSummary = tmdbService.buildMovieDetailsSummary(movieDetails); + const genres = tmdbService.normalizeNameList(movieDetails?.genres, { maxItems: 3 }); + const cast = tmdbService.normalizeNameList(movieDetails?.credits?.cast, { maxItems: 6 }); + tmdbDetails = { + ...(detailsSummary && typeof detailsSummary === 'object' ? detailsSummary : {}), + genres, + seasonCast: cast + }; + if (!title) { + title = String(movieDetails?.title || movieDetails?.original_title || '').trim() || null; + } + if ((!year || Number(year) <= 0) && tmdbDetails?.releaseDate) { + const tmdbYear = Number(String(tmdbDetails.releaseDate).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(movieDetails?.poster_path, 'w342') || null; + } + } catch (tmdbMovieErr) { + logger.warn('assignMetadata:tmdb-movie-details-fetch-failed', { + jobId, + tmdbId: effectiveTmdbId, + message: tmdbMovieErr?.message || String(tmdbMovieErr) + }); + } + } + + if (!isFilmWorkflow && effectiveTmdbId !== null && seasonNumber !== null) { try { const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber, { language: tmdbLanguage @@ -6186,7 +6512,7 @@ class HistoryService { seasonCast }; } catch (tmdbSeasonErr) { - logger.warn('assignOmdbMetadata:tmdb-season-fetch-failed', { + logger.warn('assignMetadata:tmdb-season-fetch-failed', { jobId, tmdbId: effectiveTmdbId, seasonNumber, @@ -6201,16 +6527,16 @@ class HistoryService { year, imdbId, poster: posterUrl, - workflowKind: 'series', + workflowKind: isFilmWorkflow ? 'film' : 'series', metadataProvider: 'tmdb', providerId, tmdbId: effectiveTmdbId, metadataKind, - seasonNumber, - seasonName, - episodeCount, - episodes: Array.isArray(episodes) ? episodes : [], - ...(discNumber !== null ? { discNumber } : {}), + seasonNumber: isFilmWorkflow ? null : seasonNumber, + seasonName: isFilmWorkflow ? null : seasonName, + episodeCount: isFilmWorkflow ? 0 : episodeCount, + episodes: isFilmWorkflow ? [] : (Array.isArray(episodes) ? episodes : []), + ...(isFilmWorkflow ? {} : (discNumber !== null ? { discNumber } : {})), ...(tmdbDetails && typeof tmdbDetails === 'object' ? { tmdbDetails } : {}) }; const existingAnalyzeSelected = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' @@ -6221,23 +6547,29 @@ class HistoryService { : {}; const nextAnalyzeContext = { ...analyzeContext, - workflowKind: 'series', + workflowKind: isFilmWorkflow ? 'film' : 'series', 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) - } + seriesLookupHint: isFilmWorkflow + ? { + ...existingSeriesLookupHint, + seasonNumber: null, + discNumber: null + } + : { + ...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 @@ -6258,10 +6590,11 @@ class HistoryService { poster_url: posterUrl, omdb_json: null, selected_from_omdb: 0, + migrate_tmdb: 1, makemkv_info_json: JSON.stringify(nextMakemkvInfo) }); - if (posterUrl && !thumbnailService.isLocalUrl(posterUrl)) { + if (shouldQueuePosterCache && posterUrl && !thumbnailService.isLocalUrl(posterUrl)) { this.queuePosterCache(jobId, posterUrl, { source: 'TMDb Poster', logFailures: true @@ -6272,7 +6605,7 @@ class HistoryService { jobId, 'USER_ACTION', effectiveTmdbId !== null - ? `TMDb-Zuordnung aktualisiert: ${effectiveTmdbId}${seasonNumber !== null ? ` (Staffel ${seasonNumber})` : ''} (${title || '-'})` + ? `TMDb-Zuordnung aktualisiert: ${effectiveTmdbId}${!isFilmWorkflow && seasonNumber !== null ? ` (Staffel ${seasonNumber})` : ''} (${title || '-'})` : `TMDb-Metadaten manuell aktualisiert: title="${title || '-'}", year="${year || '-'}", imdb="${imdbId || '-'}"` ); @@ -6283,147 +6616,9 @@ class HistoryService { return enrichJobRow(updated, settings); } - const imdbIdInput = String(payload.imdbId || '').trim().toLowerCase(); - let omdb = null; - if (imdbIdInput) { - try { - omdb = await omdbService.fetchByImdbId(imdbIdInput); - } catch (omdbErr) { - logger.warn('assignOmdbMetadata:fetch-failed', { jobId, imdbId: imdbIdInput, message: omdbErr.message }); - } - } - - const manualTitle = String(payload.title || '').trim(); - const manualYearRaw = Number(payload.year); - const manualYear = Number.isFinite(manualYearRaw) ? Math.trunc(manualYearRaw) : null; - const manualPoster = String(payload.poster || '').trim() || null; - const hasManual = manualTitle.length > 0 || manualYear !== null || imdbIdInput.length > 0; - if (!omdb && !hasManual) { - const error = new Error('Keine OMDb-/Metadaten zum Aktualisieren angegeben.'); - error.statusCode = 400; - throw error; - } - - const title = omdb?.title || manualTitle || job.title || job.detected_title || null; - const year = Number.isFinite(Number(omdb?.year)) - ? Number(omdb.year) - : (manualYear !== null ? manualYear : (job.year ?? null)); - 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 resolvedJobMediaType = inferMediaType( - job, - makemkvInfo, - job?.mediainfo_info_json, - job?.encode_plan_json, - job?.handbrake_info_json - ); - const shouldUseFilmWorkflow = resolvedJobMediaType === 'dvd' && metadataProvider === 'omdb'; - const existingWorkflowKind = normalizeDvdMetadataWorkflowKind( - existingSelectedMetadata?.workflowKind - || analyzeContext?.workflowKind - || null - ); - const effectiveWorkflowKind = shouldUseFilmWorkflow - ? 'film' - : (requestedWorkflowKind || existingWorkflowKind); - const nextSelectedMetadata = { - ...(existingSelectedMetadata && typeof existingSelectedMetadata === 'object' ? existingSelectedMetadata : {}), - title, - year, - imdbId, - poster: posterUrl, - metadataProvider: 'omdb', - ...(effectiveWorkflowKind ? { workflowKind: effectiveWorkflowKind } : {}), - ...(shouldUseFilmWorkflow - ? { - providerId: null, - tmdbId: null, - metadataKind: 'movie', - seasonNumber: null, - seasonName: null, - episodeCount: 0, - episodes: [], - discNumber: null, - tmdbDetails: null - } - : {}) - }; - const existingAnalyzeSelected = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' - ? analyzeContext.selectedMetadata - : {}; - const existingSeriesLookupHint = analyzeContext?.seriesLookupHint && typeof analyzeContext.seriesLookupHint === 'object' - ? analyzeContext.seriesLookupHint - : {}; - const nextAnalyzeContext = { - ...analyzeContext, - ...(effectiveWorkflowKind ? { workflowKind: effectiveWorkflowKind } : {}), - metadataProvider: 'omdb', - ...(shouldUseFilmWorkflow ? { metadataKind: 'movie' } : {}), - selectedMetadata: { - ...existingAnalyzeSelected, - ...nextSelectedMetadata - }, - ...(shouldUseFilmWorkflow - ? { - seriesLookupHint: { - ...existingSeriesLookupHint, - seasonNumber: null, - 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: omdb?.raw ? JSON.stringify(omdb.raw) : (job.omdb_json || null), - selected_from_omdb: selectedFromOmdb, - makemkv_info_json: JSON.stringify(nextMakemkvInfo), - ...(shouldUseFilmWorkflow - ? { - parent_job_id: null, - job_kind: 'dvd', - media_type: 'dvd' - } - : {}) - }); - - // Bild herunterladen, in persistenten Ordner verschieben und poster_url aktualisieren - if (posterUrl && !thumbnailService.isLocalUrl(posterUrl)) { - this.queuePosterCache(jobId, posterUrl, { - source: 'OMDb Poster', - logFailures: true - }); - } - - await this.appendLog( - jobId, - 'USER_ACTION', - omdb - ? `OMDb-Zuordnung aktualisiert: ${omdb.imdbId} (${omdb.title || '-'})` - : `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 unsupportedError = new Error('Nur TMDb-Metadaten werden unterstützt.'); + unsupportedError.statusCode = 400; + throw unsupportedError; } async assignCdMetadata(jobId, payload = {}) { @@ -6439,7 +6634,7 @@ class HistoryService { const yearRaw = Number(payload.year); const year = Number.isFinite(yearRaw) && yearRaw > 0 ? Math.trunc(yearRaw) : null; const mbId = String(payload.mbId || '').trim() || null; - const coverUrl = String(payload.coverUrl || '').trim() || null; + let coverUrl = String(payload.coverUrl || '').trim() || null; const selectedTracks = Array.isArray(payload.tracks) ? payload.tracks : null; if (!title && !artist && !mbId) { @@ -6466,6 +6661,23 @@ class HistoryService { }); } + let shouldQueueCoverCache = false; + if (coverUrl && !thumbnailService.isLocalUrl(coverUrl)) { + try { + const cacheResult = await this.cacheAndPromoteExternalPoster(jobId, coverUrl, { + source: 'Coverart', + logFailures: false + }); + if (cacheResult?.ok && cacheResult?.localUrl) { + coverUrl = cacheResult.localUrl; + } else { + shouldQueueCoverCache = true; + } + } catch (_error) { + shouldQueueCoverCache = true; + } + } + const prevSelected = cdInfo.selectedMetadata && typeof cdInfo.selectedMetadata === 'object' ? cdInfo.selectedMetadata : {}; const updatedCdInfo = { ...cdInfo, @@ -6488,7 +6700,7 @@ class HistoryService { makemkv_info_json: JSON.stringify(updatedCdInfo) }); - if (coverUrl && !thumbnailService.isLocalUrl(coverUrl)) { + if (shouldQueueCoverCache && coverUrl && !thumbnailService.isLocalUrl(coverUrl)) { this.queuePosterCache(jobId, coverUrl, { source: 'Coverart', logFailures: true @@ -6727,7 +6939,6 @@ class HistoryService { ]); const rawRoots = sanitizeRoots([ - ...getConfiguredMediaPathList(settings || {}, 'raw_dir'), toNormalizedPath(resolvedPaths?.rawDir), ...explicitRawPaths.map((candidatePath) => toNormalizedPath(path.dirname(candidatePath))) ]); @@ -6958,6 +7169,7 @@ class HistoryService { sources }; }) + .filter((row) => Boolean(row?.exists)) .filter(Boolean) .sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de')); } @@ -7189,6 +7401,33 @@ class HistoryService { } return selectedMoviePathFilter.has(normalizeComparablePath(candidatePath)); }); + + // If selected movie paths are passed explicitly, trust this list as source of + // truth as well. This protects against stale preview maps after path renames. + const candidatePathSet = new Set( + candidates + .map((item) => normalizeComparablePath(item?.path)) + .filter(Boolean) + ); + for (const selectedPath of selectedMoviePathFilter) { + if (!selectedPath || candidatePathSet.has(selectedPath)) { + continue; + } + const inspection = inspectDeletionPath(selectedPath); + if (!inspection.exists || (!inspection.isDirectory && !inspection.isFile)) { + continue; + } + candidates.push({ + target: 'movie', + path: inspection.path, + exists: true, + isDirectory: Boolean(inspection.isDirectory), + isFile: Boolean(inspection.isFile), + jobIds: [], + sources: ['selected_movie_paths'] + }); + candidatePathSet.add(inspection.path); + } } if (candidates.length === 0) { if (targetKey === 'raw' && selectedRawPathFilter.size > 0) { @@ -7223,6 +7462,59 @@ class HistoryService { if (inspection.isDirectory) { if (targetKey === 'movie') { + const filesInDirectory = collectFilesRecursively(inspection.path); + let filesDeleted = 0; + for (const filePath of filesInDirectory) { + const fileInspection = inspectDeletionPath(filePath); + if (!fileInspection.exists) { + continue; + } + if (fileInspection.isDirectory) { + continue; + } + try { + fs.unlinkSync(fileInspection.path); + filesDeleted += 1; + summary.deletedPaths.push({ + target: targetKey, + path: fileInspection.path, + type: 'file', + keepRoot: false, + jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : [] + }); + } catch (_error) { + // continue best-effort for remaining files + } + } + summary[targetKey].filesDeleted += filesDeleted; + + const keepRoot = protectedRoots.has(inspection.path); + const dirDeleteResult = deleteFilesRecursively(inspection.path, keepRoot); + const dirsRemoved = Number(dirDeleteResult?.dirsRemoved || 0); + if (dirsRemoved > 0 || filesDeleted > 0) { + summary[targetKey].dirsRemoved += dirsRemoved; + summary[targetKey].pathsDeleted += 1; + summary.deletedPaths.push({ + target: targetKey, + path: inspection.path, + type: 'directory', + keepRoot, + jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : [] + }); + } + const removedParentDirs = removeEmptyParentDirectories(path.dirname(inspection.path), { + protectedRoots: Array.from(protectedRoots) + }); + for (const removedPath of removedParentDirs) { + summary[targetKey].dirsRemoved += 1; + summary.deletedPaths.push({ + target: targetKey, + path: removedPath, + type: 'directory', + keepRoot: false, + jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : [] + }); + } continue; } const keepRoot = protectedRoots.has(inspection.path); @@ -7536,6 +7828,14 @@ class HistoryService { throw error; } else if (!fs.existsSync(effectiveOutputPath)) { const movieRoot = normalizeComparablePath(effectiveMovieDir); + const pruneEmptyMovieParents = (startPath) => { + if (!startPath || !movieRoot) { + return []; + } + return removeEmptyParentDirectories(startPath, { + protectedRoots: [movieRoot] + }); + }; const trackedFolders = await this.getJobOutputFolders(jobId); const trackedExistingPaths = Array.from(new Set( trackedFolders @@ -7552,9 +7852,12 @@ class HistoryService { if (stat.isDirectory()) { const keepRoot = trackedPath === movieRoot; const result = deleteFilesRecursively(trackedPath, keepRoot); + const removedParentDirs = keepRoot + ? [] + : pruneEmptyMovieParents(path.dirname(trackedPath)); summary.movie.deleted = true; summary.movie.filesDeleted = result.filesDeleted; - summary.movie.dirsRemoved = result.dirsRemoved; + summary.movie.dirsRemoved = result.dirsRemoved + Number(removedParentDirs.length || 0); movieDeletedPathForTracking = trackedPath; movieCandidatePathForTracking = trackedPath; summary.movie.reason = null; @@ -7570,16 +7873,18 @@ class HistoryService { if (canDeleteParentDir) { const result = deleteFilesRecursively(parentDir, false); + const removedParentDirs = pruneEmptyMovieParents(path.dirname(parentDir)); summary.movie.deleted = true; summary.movie.filesDeleted = result.filesDeleted; - summary.movie.dirsRemoved = result.dirsRemoved; + summary.movie.dirsRemoved = result.dirsRemoved + Number(removedParentDirs.length || 0); movieDeletedPathForTracking = parentDir; movieCandidatePathForTracking = trackedPath; } else { fs.unlinkSync(trackedPath); + const removedParentDirs = pruneEmptyMovieParents(parentDir); summary.movie.deleted = true; summary.movie.filesDeleted = 1; - summary.movie.dirsRemoved = 0; + summary.movie.dirsRemoved = Number(removedParentDirs.length || 0); movieDeletedPathForTracking = trackedPath; movieCandidatePathForTracking = trackedPath; } @@ -7593,13 +7898,24 @@ class HistoryService { } else { const outputPath = normalizeComparablePath(effectiveOutputPath); const movieRoot = normalizeComparablePath(effectiveMovieDir); + const pruneEmptyMovieParents = (startPath) => { + if (!startPath || !movieRoot) { + return []; + } + return removeEmptyParentDirectories(startPath, { + protectedRoots: [movieRoot] + }); + }; const stat = fs.lstatSync(outputPath); if (stat.isDirectory()) { const keepRoot = outputPath === movieRoot; const result = deleteFilesRecursively(outputPath, keepRoot ? true : false); + const removedParentDirs = keepRoot + ? [] + : pruneEmptyMovieParents(path.dirname(outputPath)); summary.movie.deleted = true; summary.movie.filesDeleted = result.filesDeleted; - summary.movie.dirsRemoved = result.dirsRemoved; + summary.movie.dirsRemoved = result.dirsRemoved + Number(removedParentDirs.length || 0); movieDeletedPathForTracking = outputPath; movieCandidatePathForTracking = outputPath; } else { @@ -7618,16 +7934,18 @@ class HistoryService { if (canDeleteParentDir) { const result = deleteFilesRecursively(parentDir, false); + const removedParentDirs = pruneEmptyMovieParents(path.dirname(parentDir)); summary.movie.deleted = true; summary.movie.filesDeleted = result.filesDeleted; - summary.movie.dirsRemoved = result.dirsRemoved; + summary.movie.dirsRemoved = result.dirsRemoved + Number(removedParentDirs.length || 0); movieDeletedPathForTracking = parentDir; movieCandidatePathForTracking = outputPath; } else { fs.unlinkSync(outputPath); + const removedParentDirs = pruneEmptyMovieParents(parentDir); summary.movie.deleted = true; summary.movie.filesDeleted = 1; - summary.movie.dirsRemoved = 0; + summary.movie.dirsRemoved = Number(removedParentDirs.length || 0); movieDeletedPathForTracking = outputPath; movieCandidatePathForTracking = outputPath; } @@ -7996,6 +8314,11 @@ class HistoryService { if (stat.isDirectory()) { const keepRoot = normalizedFolderPath === movieRoot; deleteFilesRecursively(normalizedFolderPath, keepRoot); + if (!keepRoot) { + removeEmptyParentDirectories(path.dirname(normalizedFolderPath), { + protectedRoots: [movieRoot] + }); + } } else { const parentDir = normalizeComparablePath(path.dirname(normalizedFolderPath)); const canDeleteParent = parentDir && parentDir !== movieRoot @@ -8004,8 +8327,16 @@ class HistoryService { && fs.lstatSync(parentDir).isDirectory(); if (canDeleteParent) { deleteFilesRecursively(parentDir, false); + removeEmptyParentDirectories(path.dirname(parentDir), { + protectedRoots: [movieRoot] + }); } else { fs.unlinkSync(normalizedFolderPath); + if (parentDir) { + removeEmptyParentDirectories(parentDir, { + protectedRoots: [movieRoot] + }); + } } } await this.removeJobOutputFolderFromJobs(relatedJobIds, normalizedFolderPath); diff --git a/backend/src/services/musicBrainzService.js b/backend/src/services/musicBrainzService.js index 62062d1..5c59dab 100644 --- a/backend/src/services/musicBrainzService.js +++ b/backend/src/services/musicBrainzService.js @@ -1,4 +1,3 @@ -const settingsService = require('./settingsService'); const logger = require('./logger').child('MUSICBRAINZ'); const MB_BASE = 'https://musicbrainz.org/ws/2'; @@ -74,6 +73,17 @@ function normalizeRelease(release) { artist: artistFromTrack || artistFromRecording || artistCredit || null }; }); + const rawReleaseTrackCount = Number(release['track-count']); + const mediaTrackCount = media.reduce((sum, medium) => { + const mediumCount = Number(medium?.['track-count'] ?? medium?.trackCount); + if (!Number.isFinite(mediumCount) || mediumCount <= 0) { + return sum; + } + return sum + Math.trunc(mediumCount); + }, 0); + const trackCount = Number.isFinite(rawReleaseTrackCount) && rawReleaseTrackCount > 0 + ? Math.trunc(rawReleaseTrackCount) + : (mediaTrackCount > 0 ? mediaTrackCount : normalizedTracks.length); // Always generate the CAA URL when an id is present; the browser/onError // handles 404s for releases that have no front cover. @@ -92,27 +102,21 @@ function normalizeRelease(release) { ? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null : null, coverArtUrl, - tracks: normalizedTracks + tracks: normalizedTracks, + trackCount }; } class MusicBrainzService { - async isEnabled() { - const settings = await settingsService.getSettingsMap(); - return settings.musicbrainz_enabled !== 'false'; - } - - async searchByTitle(query) { + async searchByTitle(query, options = {}) { const q = String(query || '').trim(); if (!q) { return []; } - - const enabled = await this.isEnabled(); - if (!enabled) { - return []; - } - + const expectedTrackCountRaw = Number(options?.trackCount); + const expectedTrackCount = Number.isFinite(expectedTrackCountRaw) && expectedTrackCountRaw > 0 + ? Math.trunc(expectedTrackCountRaw) + : null; logger.info('search:start', { query: q }); const url = new URL(`${MB_BASE}/release`); @@ -124,11 +128,23 @@ class MusicBrainzService { try { const data = await mbFetch(url.toString()); const releases = Array.isArray(data.releases) ? data.releases : []; - const results = releases.map(normalizeRelease).filter(Boolean); - logger.info('search:done', { query: q, count: results.length }); + const normalizedReleases = releases.map(normalizeRelease).filter(Boolean); + const results = expectedTrackCount + ? normalizedReleases.filter((release) => Number(release?.trackCount || 0) === expectedTrackCount) + : normalizedReleases; + logger.info('search:done', { + query: q, + expectedTrackCount, + sourceCount: normalizedReleases.length, + count: results.length + }); return results; } catch (error) { - logger.warn('search:failed', { query: q, error: String(error?.message || error) }); + logger.warn('search:failed', { + query: q, + expectedTrackCount, + error: String(error?.message || error) + }); return []; } } @@ -143,11 +159,6 @@ class MusicBrainzService { return null; } - const enabled = await this.isEnabled(); - if (!enabled) { - return null; - } - logger.info('getById:start', { mbId: id }); const url = new URL(`${MB_BASE}/release/${id}`); diff --git a/backend/src/services/omdbService.js b/backend/src/services/omdbService.js deleted file mode 100644 index 781c5c0..0000000 --- a/backend/src/services/omdbService.js +++ /dev/null @@ -1,225 +0,0 @@ -const settingsService = require('./settingsService'); -const logger = require('./logger').child('OMDB'); - -const OMDB_BASE_URL = 'https://www.omdbapi.com/'; -const OMDB_TIMEOUT_MS = 10000; -const OMDB_MAX_ATTEMPTS = 2; -const OMDB_RETRY_BASE_DELAY_MS = 300; - -function normalizeOmdbTimeoutMs(value) { - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - return OMDB_TIMEOUT_MS; - } - return Math.max(1000, Math.trunc(parsed)); -} - -function isRetryableOmdbError(error, aborted = false) { - if (aborted) { - return true; - } - const code = String(error?.code || '').trim().toUpperCase(); - if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNABORTED') { - return true; - } - const causeCode = String(error?.cause?.code || '').trim().toUpperCase(); - if (causeCode === 'UND_ERR_CONNECT_TIMEOUT' || causeCode === 'UND_ERR_HEADERS_TIMEOUT' || causeCode === 'UND_ERR_SOCKET') { - return true; - } - const message = String(error?.message || '').toLowerCase(); - return message.includes('timed out') || message.includes('timeout'); -} - -function sleep(ms) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -class OmdbService { - mapSearchResults(data) { - if (!data || typeof data !== 'object') { - return []; - } - if (data.Response === 'False' || !Array.isArray(data.Search)) { - return []; - } - return data.Search.map((item) => ({ - title: item.Title, - year: item.Year, - imdbId: item.imdbID, - type: item.Type, - poster: item.Poster - })); - } - - async requestJson(url, meta = {}, options = {}) { - const timeoutMs = normalizeOmdbTimeoutMs(options?.timeoutMs); - const maxAttempts = Math.max(1, Math.trunc(Number(options?.maxAttempts || OMDB_MAX_ATTEMPTS))); - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetch(url, { - headers: { - 'Accept': 'application/json', - 'User-Agent': 'Ripster/1.0' - }, - signal: controller.signal - }); - if (!response.ok) { - logger.warn('request:http-failed', { - ...meta, - status: response.status, - attempt, - maxAttempts - }); - return null; - } - return await response.json(); - } catch (error) { - const aborted = error?.name === 'AbortError'; - const retryable = isRetryableOmdbError(error, aborted); - const willRetry = retryable && attempt < maxAttempts; - logger[willRetry ? 'info' : 'warn']('request:failed', { - ...meta, - timeoutMs, - aborted, - retryable, - attempt, - maxAttempts, - willRetry, - message: error?.message || String(error) - }); - if (willRetry) { - await sleep(OMDB_RETRY_BASE_DELAY_MS * attempt); - continue; - } - return null; - } finally { - clearTimeout(timer); - } - } - return null; - } - - async search(query) { - const normalizedQuery = String(query || '').trim(); - if (!normalizedQuery) { - return []; - } - logger.info('search:start', { query: normalizedQuery }); - - // Allow direct IMDb-ID lookups in the search field (useful for History remapping). - const imdbLike = normalizedQuery.toLowerCase(); - if (/^tt\d{6,12}$/.test(imdbLike)) { - const byId = await this.fetchByImdbId(imdbLike); - return byId - ? [{ - title: byId.title, - year: byId.year != null ? String(byId.year) : null, - imdbId: byId.imdbId, - type: byId.type, - poster: byId.poster - }] - : []; - } - - const settings = await settingsService.getSettingsMap(); - const apiKey = settings.omdb_api_key; - if (!apiKey) { - return []; - } - const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms); - - const configuredType = String(settings.omdb_default_type || 'movie').trim().toLowerCase() || 'movie'; - const requestSearch = async (type = null) => { - const url = new URL(OMDB_BASE_URL); - url.searchParams.set('apikey', apiKey); - url.searchParams.set('s', normalizedQuery); - if (type) { - url.searchParams.set('type', type); - } - const data = await this.requestJson( - url, - { query: normalizedQuery, action: 'search', type: type || null }, - { timeoutMs } - ); - return { - data, - results: this.mapSearchResults(data) - }; - }; - - let { data, results } = await requestSearch(configuredType); - if (results.length === 0 && configuredType) { - logger.info('search:fallback-no-type', { - query: normalizedQuery, - configuredType, - response: data?.Response || null, - error: data?.Error || null - }); - ({ data, results } = await requestSearch(null)); - } - - if (results.length === 0) { - logger.warn('search:no-results', { - query: normalizedQuery, - response: data?.Response || null, - error: data?.Error || null, - configuredType - }); - return []; - } - - logger.info('search:done', { query: normalizedQuery, count: results.length, configuredType }); - return results; - } - - async fetchByImdbId(imdbId) { - const normalizedId = String(imdbId || '').trim().toLowerCase(); - if (!/^tt\d{6,12}$/.test(normalizedId)) { - return null; - } - - logger.info('fetchByImdbId:start', { imdbId: normalizedId }); - const settings = await settingsService.getSettingsMap(); - const apiKey = settings.omdb_api_key; - if (!apiKey) { - return null; - } - const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms); - - const url = new URL(OMDB_BASE_URL); - url.searchParams.set('apikey', apiKey); - url.searchParams.set('i', normalizedId); - url.searchParams.set('plot', 'full'); - - const data = await this.requestJson(url, { imdbId: normalizedId, action: 'fetchByImdbId' }, { timeoutMs }); - if (!data || typeof data !== 'object') { - return null; - } - if (data.Response === 'False') { - logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error }); - return null; - } - - const yearMatch = String(data.Year || '').match(/\b(19|20)\d{2}\b/); - const year = yearMatch ? Number(yearMatch[0]) : null; - const poster = data.Poster && data.Poster !== 'N/A' ? data.Poster : null; - - const result = { - title: data.Title || null, - year: Number.isFinite(year) ? year : null, - imdbId: String(data.imdbID || normalizedId), - type: data.Type || null, - poster, - raw: data - }; - logger.info('fetchByImdbId:done', { imdbId: result.imdbId, title: result.title }); - return result; - } -} - -module.exports = new OmdbService(); diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index da63331..d561454 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -1,12 +1,11 @@ const fs = require('fs'); -const os = require('os'); const path = require('path'); const { EventEmitter } = require('events'); const { execFile } = require('child_process'); +const { randomUUID } = require('crypto'); 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'); @@ -31,6 +30,7 @@ const userPresetDefaultsService = require('./userPresetDefaultsService'); const thumbnailService = require('./thumbnailService'); const activationBytesService = require('./activationBytesService'); const { toBoolean } = require('../utils/validators'); +const { tempDir } = require('../config'); const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']); const REVIEW_REFRESH_SETTING_PREFIXES = [ @@ -100,12 +100,30 @@ const CONVERTER_SUPPORTED_SCAN_EXTENSIONS = Object.freeze([ const CONVERTER_SUPPORTED_SCAN_EXTENSION_SET = new Set(CONVERTER_SUPPORTED_SCAN_EXTENSIONS); const TMDB_SERIES_SEARCH_CACHE_TTL_MS = 2 * 60 * 1000; const tmdbSeriesSearchCache = new Map(); -const PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY = 'handbrake_pre_metadata_scan_enabled'; +const PROCESS_LOG_RESET_TERMINAL_STATES = new Set(['FINISHED', 'ERROR', 'CANCELLED']); function nowIso() { return new Date().toISOString(); } +function normalizeLifecycleJobState(value) { + return String(value || '').trim().toUpperCase(); +} + +function shouldResetProcessLogForLifecycle(job = null) { + const status = normalizeLifecycleJobState(job?.status); + const lastState = normalizeLifecycleJobState(job?.last_state); + return PROCESS_LOG_RESET_TERMINAL_STATES.has(status) || PROCESS_LOG_RESET_TERMINAL_STATES.has(lastState); +} + +async function resetProcessLogIfLifecycleAllows(jobId, job = null) { + if (!shouldResetProcessLogForLifecycle(job)) { + return false; + } + await historyService.resetProcessLog(jobId); + return true; +} + function cloneTmdbSeriesSearchResults(rows = []) { if (!Array.isArray(rows)) { return []; @@ -159,6 +177,277 @@ function isSeriesDiscMediaProfile(mediaProfile) { return normalized === 'dvd' || normalized === 'bluray'; } +function normalizeSeriesDetectionClassification(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'series' || normalized === 'film' || normalized === 'ambiguous') { + return normalized; + } + return null; +} + +function hasStrongSeriesLookupHint(seriesLookupHint = null) { + const hint = seriesLookupHint && typeof seriesLookupHint === 'object' + ? seriesLookupHint + : {}; + return Number(hint.seasonNumber || 0) > 0 || Number(hint.discNumber || 0) > 0; +} + +function normalizePlayAllTolerancePercent(rawValue, fallback = 10) { + const parsed = Number(rawValue); + if (!Number.isFinite(parsed)) { + return fallback; + } + if (parsed < 0) { + return 0; + } + if (parsed > 100) { + return 100; + } + return parsed; +} + +function buildSeriesHeuristicOptionsFromSettings(settingsMap = {}) { + const source = settingsMap && typeof settingsMap === 'object' ? settingsMap : {}; + return { + playAllToleranceLowerPct: normalizePlayAllTolerancePercent( + source.series_playall_tolerance_lower_pct, + 10 + ), + playAllToleranceUpperPct: normalizePlayAllTolerancePercent( + source.series_playall_tolerance_upper_pct, + 10 + ) + }; +} + +function deriveSeriesDecisionFromSummary(summary = null, seriesLookupHint = null, options = {}) { + const normalizedSummary = summary && typeof summary === 'object' ? summary : {}; + const episodeCandidateCount = Number(normalizedSummary.episodeCandidateCount || 0) || 0; + const playAllCount = Number(normalizedSummary.playAllCount || 0) || 0; + const typicalEpisodeMinutes = Number(normalizedSummary.typicalEpisodeMinutes || 0) || 0; + const episodeSumSeconds = Number(normalizedSummary.episodeSumSeconds || 0) || ( + episodeCandidateCount > 0 && typicalEpisodeMinutes > 0 + ? Math.round(typicalEpisodeMinutes * 60 * episodeCandidateCount) + : 0 + ); + const lowerPct = normalizePlayAllTolerancePercent(options?.playAllToleranceLowerPct, 10); + const upperPct = normalizePlayAllTolerancePercent(options?.playAllToleranceUpperPct, 10); + const lowerBoundSeconds = episodeSumSeconds > 0 + ? Math.round(episodeSumSeconds * (1 - (lowerPct / 100))) + : 0; + const upperBoundSeconds = episodeSumSeconds > 0 + ? Math.round(episodeSumSeconds * (1 + (upperPct / 100))) + : 0; + const longestOutsideClusterSeconds = Number(normalizedSummary.longestOutsideClusterSeconds || 0) || 0; + const hasWeakLookupQuery = Boolean(String(seriesLookupHint?.query || '').trim()); + const hasStrongLookupHint = hasStrongSeriesLookupHint(seriesLookupHint); + const playAllWithinTolerance = Boolean(normalizedSummary.playAllWithinTolerance); + const plausiblePlayAll = playAllCount > 0 && ( + playAllWithinTolerance + || (episodeSumSeconds <= 0) + ); + const longTitleOutsideUpperBound = ( + episodeCandidateCount >= 2 + && episodeSumSeconds > 0 + && longestOutsideClusterSeconds > upperBoundSeconds + ); + const scoreContributions = []; + let seriesScore = 0; + let filmScore = 0; + const addSeriesScore = (points, reason) => { + const numericPoints = Number(points || 0); + if (!Number.isFinite(numericPoints) || numericPoints === 0) { + return; + } + seriesScore += numericPoints; + scoreContributions.push({ + side: 'series', + points: numericPoints, + reason: String(reason || '').trim() || 'series_signal' + }); + }; + const addFilmScore = (points, reason) => { + const numericPoints = Number(points || 0); + if (!Number.isFinite(numericPoints) || numericPoints === 0) { + return; + } + filmScore += numericPoints; + scoreContributions.push({ + side: 'film', + points: numericPoints, + reason: String(reason || '').trim() || 'film_signal' + }); + }; + + let classification = 'film'; + let recommendedWorkflowKind = 'film'; + let seriesLike = false; + let confidence = 'low'; + + if (episodeCandidateCount >= 3) { + addSeriesScore(70, `episode_candidates>=3 (${episodeCandidateCount})`); + classification = 'series'; + recommendedWorkflowKind = 'series'; + seriesLike = true; + confidence = (plausiblePlayAll || hasStrongLookupHint) ? 'high' : 'medium'; + } else if (episodeCandidateCount === 2) { + addSeriesScore(45, 'episode_candidates=2'); + addFilmScore(20, 'episode_candidates=2_is_borderline'); + if (plausiblePlayAll || hasStrongLookupHint) { + classification = 'series'; + recommendedWorkflowKind = 'series'; + seriesLike = true; + confidence = (plausiblePlayAll && hasStrongLookupHint) ? 'high' : 'medium'; + } else { + addFilmScore(15, 'two_episodes_without_playall_or_strong_hint'); + classification = 'ambiguous'; + recommendedWorkflowKind = 'film'; + seriesLike = false; + confidence = 'medium'; + } + } else if (episodeCandidateCount === 1 && hasStrongLookupHint) { + addFilmScore(35, 'single_episode_candidate'); + addSeriesScore(25, 'strong_series_hint_with_single_episode'); + classification = 'ambiguous'; + recommendedWorkflowKind = 'series'; + seriesLike = false; + confidence = 'low'; + } else if (episodeCandidateCount === 1) { + addFilmScore(40, 'single_episode_candidate_without_strong_hint'); + } else { + addFilmScore(50, `episode_candidates=${episodeCandidateCount}`); + } + + if (plausiblePlayAll) { + addSeriesScore(35, 'playall_within_tolerance'); + } + if (longTitleOutsideUpperBound) { + addFilmScore(30, 'long_title_outside_playall_upper_bound'); + } + if (hasStrongLookupHint) { + addSeriesScore(25, 'strong_lookup_hint_season_or_disc'); + } else if (hasWeakLookupQuery) { + addSeriesScore(5, 'weak_lookup_query_only'); + } + + if (classification === 'ambiguous' && longTitleOutsideUpperBound) { + recommendedWorkflowKind = 'film'; + confidence = 'medium'; + } + + return { + classification, + recommendedWorkflowKind, + manualOverrideRequired: classification === 'ambiguous', + seriesLike, + confidence, + strongLookupHint: hasStrongLookupHint, + weakLookupQuery: hasWeakLookupQuery, + playAllWithinTolerance: plausiblePlayAll, + longTitleOutsideUpperBound, + score: { + series: seriesScore, + film: filmScore, + delta: seriesScore - filmScore, + model: 'series_vs_film_v1', + contributions: scoreContributions + }, + tolerance: { + lowerPct, + upperPct, + episodeSumSeconds, + lowerBoundSeconds, + upperBoundSeconds + } + }; +} + +function formatSeriesHeuristicDuration(seconds) { + const numeric = Number(seconds || 0); + if (!Number.isFinite(numeric) || numeric <= 0) { + return '-'; + } + const clock = formatDurationClock(numeric) || '-'; + const minutes = (numeric / 60).toFixed(2); + return `${clock} (${minutes} min)`; +} + +function formatSeriesHeuristicBool(value) { + return value ? 'ja' : 'nein'; +} + +function normalizeSeriesDecisionForLogging(seriesDecision = null, seriesLookupHint = null, options = {}) { + if (seriesDecision && typeof seriesDecision === 'object') { + return seriesDecision; + } + return deriveSeriesDecisionFromSummary(null, seriesLookupHint, options); +} + +async function appendSeriesHeuristicDecisionLog({ + jobId, + mediaProfile, + seriesAnalysis = null, + seriesDecision = null, + seriesLookupHint = null, + seriesHeuristicOptions = {} +} = {}) { + const normalizedJobId = Number(jobId || 0); + if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) { + return; + } + const summary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' + ? seriesAnalysis.summary + : {}; + const decision = normalizeSeriesDecisionForLogging(seriesDecision, seriesLookupHint, seriesHeuristicOptions); + const tolerance = decision?.tolerance && typeof decision.tolerance === 'object' + ? decision.tolerance + : {}; + const score = decision?.score && typeof decision.score === 'object' + ? decision.score + : {}; + const lookupQueryRaw = String(seriesLookupHint?.query || '').trim(); + const lookupQuery = lookupQueryRaw.length > 80 + ? `${lookupQueryRaw.slice(0, 80)}...` + : lookupQueryRaw; + const lowerPct = Number(tolerance?.lowerPct ?? seriesHeuristicOptions?.playAllToleranceLowerPct ?? 10) || 10; + const upperPct = Number(tolerance?.upperPct ?? seriesHeuristicOptions?.playAllToleranceUpperPct ?? 10) || 10; + const episodeCandidateCount = Number(summary?.episodeCandidateCount || 0) || 0; + const typicalEpisodeMinutes = Number(summary?.typicalEpisodeMinutes || 0) || 0; + const episodeSumSeconds = Number(summary?.episodeSumSeconds || tolerance?.episodeSumSeconds || 0) || 0; + const lowerBoundSeconds = Number(summary?.playAllLowerBoundSeconds || tolerance?.lowerBoundSeconds || 0) || 0; + const upperBoundSeconds = Number(summary?.playAllUpperBoundSeconds || tolerance?.upperBoundSeconds || 0) || 0; + const playAllCount = Number(summary?.playAllCount || 0) || 0; + const playAllCandidateSeconds = Number(summary?.playAllCandidateSeconds || 0) || 0; + const longestOutsideClusterSeconds = Number(summary?.longestOutsideClusterSeconds || 0) || 0; + const titleCount = Number(summary?.titleCount || 0) || 0; + const extraCount = Number(summary?.extraCount || 0) || 0; + const duplicateCount = Number(summary?.duplicateCount || 0) || 0; + const contributionsText = Array.isArray(score?.contributions) && score.contributions.length > 0 + ? score.contributions.map((entry) => { + const side = String(entry?.side || '').trim().toLowerCase() === 'series' ? 'SERIE' : 'FILM'; + const points = Number(entry?.points || 0) || 0; + const reason = String(entry?.reason || '').trim() || 'signal'; + return `${side}:${points}(${reason})`; + }).join(', ') + : 'keine'; + + const lines = [ + `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: Auswertung Film/Serie (vor Metadaten).`, + `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: Episodencluster=${episodeCandidateCount}, typische Episodenlänge=${typicalEpisodeMinutes > 0 ? `${typicalEpisodeMinutes.toFixed(2)} min` : '-'}, Episodensumme=${formatSeriesHeuristicDuration(episodeSumSeconds)}.`, + `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: PlayAll-Toleranz -${lowerPct}%/+${upperPct}% => Bereich ${formatSeriesHeuristicDuration(lowerBoundSeconds)} bis ${formatSeriesHeuristicDuration(upperBoundSeconds)}.`, + `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: PlayAll-Kandidaten=${playAllCount}, PlayAll-Länge=${formatSeriesHeuristicDuration(playAllCandidateSeconds)}, innerhalb Toleranz=${formatSeriesHeuristicBool(Boolean(decision?.playAllWithinTolerance))}.`, + `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: Längster Titel außerhalb Cluster=${formatSeriesHeuristicDuration(longestOutsideClusterSeconds)}, oberhalb Obergrenze=${formatSeriesHeuristicBool(Boolean(decision?.longTitleOutsideUpperBound))}.`, + `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: Titel gesamt=${titleCount}, Extras (lange Nicht-Cluster-Titel)=${extraCount}, Duplikate=${duplicateCount}.`, + `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: Lookup-Hints stark(season/disc)=${formatSeriesHeuristicBool(Boolean(decision?.strongLookupHint))}, schwach(query)=${formatSeriesHeuristicBool(Boolean(decision?.weakLookupQuery))}${lookupQuery ? `, Query="${lookupQuery}"` : ''}.`, + `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: Score FILM=${Number(score?.film || 0) || 0}, SERIE=${Number(score?.series || 0) || 0}, DELTA=${Number(score?.delta || 0) || 0}, Beiträge=[${contributionsText}].`, + `Heuristik ${resolveSeriesDiscLabel(mediaProfile)}: Entscheidung=${String(decision?.classification || 'film').toUpperCase()}, confidence=${String(decision?.confidence || 'low').toLowerCase()}, workflow=${String(decision?.recommendedWorkflowKind || 'film').toLowerCase()}, manualOverride=${formatSeriesHeuristicBool(Boolean(decision?.manualOverrideRequired))}.` + ]; + + for (const line of lines) { + await historyService.appendLog(normalizedJobId, 'SYSTEM', line); + } +} + function resolveSeriesDiscLabel(mediaProfile) { const normalized = normalizeMediaProfile(mediaProfile); if (normalized === 'bluray') { @@ -371,8 +660,8 @@ function resolveSelectedMetadataForJob(job = null, analyzeContext = null, active merged.metadataProvider || analyzeContext?.metadataProvider || (allowActiveContextMetadata ? activeContext?.metadataProvider : null) - || 'omdb' - ).trim().toLowerCase() || 'omdb' + || 'tmdb' + ).trim().toLowerCase() || 'tmdb' }; } @@ -402,26 +691,33 @@ function isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata = {}, analy ).trim().toLowerCase(); const metadataKind = String(metadata.metadataKind || '').trim().toLowerCase(); + if (['film', 'movie', 'feature'].includes(metadataKind)) { + return false; + } 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 hasSeriesLookupHint = hasStrongSeriesLookupHint(analyzeContext?.seriesLookupHint); + const seriesDecisionClassification = normalizeSeriesDetectionClassification( + analyzeContext?.seriesDecision?.classification + ); + const seriesLikeByAnalysis = ( + seriesDecisionClassification === 'series' + || ( + !seriesDecisionClassification + && Boolean(analyzeContext?.seriesAnalysis?.summary?.seriesLike) + ) ); - 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 ); @@ -2668,6 +2964,72 @@ function findMakeMkvBackupFailureMarker(lines) { return lines.find((line) => isMakeMkvBackupFailureMarker(line)) || null; } +function parseMakeMkvDriveLine(line) { + const match = String(line || '').match(/^DRV:\d+,\d+,\d+,\d+,\"([^\"]*)\",\"([^\"]*)\",\"([^\"]*)\"/i); + if (!match) { + return null; + } + return { + driveName: String(match[1] || '').trim(), + discName: String(match[2] || '').trim(), + devicePath: String(match[3] || '').trim() + }; +} + +function parseMakeMkvReadTarget(line) { + const match = String(line || '').match(/occurred while reading '([^']+)'/i); + if (!match) { + return null; + } + return String(match[1] || '').trim() || null; +} + +function createMakeMkvForeignDriveLineFilter(targetDevicePath) { + const normalizedTargetDevicePath = String(targetDevicePath || '').trim(); + if (!normalizedTargetDevicePath) { + return null; + } + + const foreignReadTargets = new Set(); + + return (line) => { + const text = String(line || '').trim(); + if (!text) { + return true; + } + + const driveInfo = parseMakeMkvDriveLine(text); + if (driveInfo) { + const drivePath = String(driveInfo.devicePath || '').trim(); + const isForeignDrive = Boolean(drivePath && drivePath !== normalizedTargetDevicePath); + if (isForeignDrive) { + if (driveInfo.driveName) { + foreignReadTargets.add(driveInfo.driveName); + } + if (driveInfo.discName) { + foreignReadTargets.add(driveInfo.discName); + } + return false; + } + return true; + } + + const readTarget = parseMakeMkvReadTarget(text); + if (!readTarget) { + return true; + } + if (!foreignReadTargets.has(readTarget)) { + return true; + } + + const msgCode = parseMakeMkvMessageCode(text); + if (msgCode === 2003 || /scsi error/i.test(text)) { + return false; + } + return true; + }; +} + function createEncodeScriptProgressTracker({ jobId, preSteps = 0, @@ -4665,6 +5027,14 @@ function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options = 1.2, Number(options?.dominantFeatureDurationRatio || 2.2) ); + const playAllToleranceLowerPct = normalizePlayAllTolerancePercent( + options?.playAllToleranceLowerPct, + 10 + ); + const playAllToleranceUpperPct = normalizePlayAllTolerancePercent( + options?.playAllToleranceUpperPct, + 10 + ); const titles = parseHandBrakeTitleList(scanJson); if (!Array.isArray(titles) || titles.length === 0) { @@ -4743,17 +5113,33 @@ function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options = .map((value) => Math.trunc(value)) ); const shortCount = titlesWithDuration.filter((title) => title.durationSeconds <= shortTitleSeconds).length; - const playAllCount = (() => { - if (episodeCandidateCount < 2 || clusterMedianDuration <= 0) { - return 0; + const episodeSumSeconds = episodeCandidateCount > 0 && clusterMedianDuration > 0 + ? Math.round(clusterMedianDuration * episodeCandidateCount) + : 0; + const playAllLowerBoundSeconds = episodeSumSeconds > 0 + ? Math.round(episodeSumSeconds * (1 - (playAllToleranceLowerPct / 100))) + : 0; + const playAllUpperBoundSeconds = episodeSumSeconds > 0 + ? Math.round(episodeSumSeconds * (1 + (playAllToleranceUpperPct / 100))) + : 0; + const outsideClusterTitles = titlesWithDuration.filter((title) => !clusterIds.has(Number(title?.id))); + const playAllCandidate = (() => { + if (episodeCandidateCount < 2 || clusterMedianDuration <= 0 || outsideClusterTitles.length === 0) { + return null; } - const minPlayAllSeconds = clusterMedianDuration * Math.max(2, episodeCandidateCount - 1) * 0.75; - const hasPlayAll = titlesWithDuration.some((title) => - !clusterIds.has(Number(title?.id)) - && title.durationSeconds >= minPlayAllSeconds - ); - return hasPlayAll ? 1 : 0; + return outsideClusterTitles + .filter((title) => + title.durationSeconds >= playAllLowerBoundSeconds + && title.durationSeconds <= playAllUpperBoundSeconds + ) + .sort((left, right) => right.durationSeconds - left.durationSeconds)[0] || null; })(); + const playAllCount = playAllCandidate ? 1 : 0; + const playAllCandidateSeconds = Number(playAllCandidate?.durationSeconds || 0) || 0; + const playAllWithinTolerance = playAllCandidateSeconds > 0; + const longestOutsideClusterSeconds = outsideClusterTitles.length > 0 + ? Math.max(...outsideClusterTitles.map((title) => Number(title?.durationSeconds || 0)).filter((value) => Number.isFinite(value) && value > 0)) + : 0; const extraCount = titlesWithDuration.filter((title) => !clusterIds.has(Number(title?.id)) && title.durationSeconds > shortTitleSeconds @@ -4804,6 +5190,10 @@ function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options = } if (playAllCount > 0) { reasons.push('Ein zusätzlicher langer Play-All-Kandidat wurde erkannt.'); + } else if (episodeCandidateCount >= 2 && episodeSumSeconds > 0 && longestOutsideClusterSeconds > playAllUpperBoundSeconds) { + reasons.push( + `Langer Titel außerhalb Play-All-Toleranz erkannt (>${Math.round(playAllUpperBoundSeconds / 60)} Minuten bei ±${playAllToleranceLowerPct}%/${playAllToleranceUpperPct}%).` + ); } if (shortCount > 0) { reasons.push(`${shortCount} kurze Titel als Extras/Kurzsegmente erkannt.`); @@ -4828,6 +5218,12 @@ function buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, options = lowChapterEpisodeCount, longestTitleChapterCount: Number(longestTitle?.chapterCount || 0) || 0, playAllCount, + playAllCandidateSeconds, + playAllWithinTolerance, + episodeSumSeconds, + playAllLowerBoundSeconds, + playAllUpperBoundSeconds, + longestOutsideClusterSeconds, duplicateCount: 0, extraCount, typicalEpisodeMinutes: clusterMedianDuration > 0 @@ -5032,6 +5428,27 @@ function mergeSeriesAnalysisWithStructuredFallback(seriesAnalysis = null, struct Number(baseSummary?.playAllCount || 0) || 0, Number(structuredSummary?.playAllCount || 0) || 0 ), + playAllCandidateSeconds: Math.max( + Number(baseSummary?.playAllCandidateSeconds || 0) || 0, + Number(structuredSummary?.playAllCandidateSeconds || 0) || 0 + ), + playAllWithinTolerance: Boolean(baseSummary?.playAllWithinTolerance) || Boolean(structuredSummary?.playAllWithinTolerance), + episodeSumSeconds: Math.max( + Number(baseSummary?.episodeSumSeconds || 0) || 0, + Number(structuredSummary?.episodeSumSeconds || 0) || 0 + ), + playAllLowerBoundSeconds: Math.max( + Number(baseSummary?.playAllLowerBoundSeconds || 0) || 0, + Number(structuredSummary?.playAllLowerBoundSeconds || 0) || 0 + ), + playAllUpperBoundSeconds: Math.max( + Number(baseSummary?.playAllUpperBoundSeconds || 0) || 0, + Number(structuredSummary?.playAllUpperBoundSeconds || 0) || 0 + ), + longestOutsideClusterSeconds: Math.max( + Number(baseSummary?.longestOutsideClusterSeconds || 0) || 0, + Number(structuredSummary?.longestOutsideClusterSeconds || 0) || 0 + ), duplicateCount: Math.max( Number(baseSummary?.duplicateCount || 0) || 0, Number(structuredSummary?.duplicateCount || 0) || 0 @@ -6252,6 +6669,28 @@ function buildRawMetadataBase(jobLike = {}, fallbackJobId = null, options = {}) ? Math.trunc(rawYear) : new Date().getFullYear(); + if (mediaProfile === 'cd') { + const cdArtist = normalizeCdTrackText( + opts.artist + ?? selectedMetadata?.artist + ?? jobLike?.artist + ?? '' + ); + const cdAlbum = normalizeCdTrackText( + opts.album + ?? selectedMetadata?.album + ?? selectedMetadata?.title + ?? jobLike?.title + ?? jobLike?.detected_title + ?? fallbackTitle + ); + // CD-RAW-Ordner sollen immer "INTERPRET - ALBUM (YEAR)" enthalten. + // Falls ein Teil fehlt, wird mit dem jeweils vorhandenen Teil/Fallback gefuellt. + const resolvedArtist = cdArtist || cdAlbum || fallbackTitle; + const resolvedAlbum = cdAlbum || cdArtist || fallbackTitle; + return sanitizeFileName(`${resolvedArtist} - ${resolvedAlbum} (${yearValue})`); + } + if (isSeriesDisc && seasonNumber && discNumber) { const seriesTitle = normalizeCdTrackText( opts.seriesTitle @@ -6380,7 +6819,7 @@ function normalizeMultipartMovieSelectedMetadata(baseSelectedMetadata = {}, disc return { ...source, workflowKind: 'film', - metadataProvider: 'omdb', + metadataProvider: 'tmdb', metadataKind: 'movie', providerId: null, tmdbId: null, @@ -8132,7 +8571,7 @@ function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) { let forceExplicitEmptySubtitleSelection = false; const hasRawSubtitleTrackIds = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleTrackIds'); const hasRawSubtitleVariantSelection = Object.prototype.hasOwnProperty.call(rawSelection, 'subtitleVariantSelection'); - if (effectiveSubtitleVariantSelection.map.size === 0 && !hasRawSubtitleVariantSelection && hasRawSubtitleTrackIds) { + if (effectiveSubtitleVariantSelection.map.size === 0 && hasRawSubtitleTrackIds) { if (requestedSubtitleTrackIds.length > 0) { effectiveSubtitleVariantSelection = buildSubtitleVariantSelectionFromTrackIds( encodeTitle.subtitleTracks, @@ -9467,30 +9906,12 @@ class PipelineService extends EventEmitter { }; } - async isPreMetadataHandBrakeScanEnabled() { - try { - const settings = await settingsService.getSettingsMap(); - if (!settings || typeof settings !== 'object') { - return true; - } - if (!Object.prototype.hasOwnProperty.call(settings, PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY)) { - return true; - } - return toBoolean(settings[PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY]); - } catch (error) { - logger.warn('metadata:pre-scan-setting:read-failed', { - settingKey: PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY, - error: errorToMeta(error) - }); - return true; - } - } - async runMakeMkvSeriesSecondChance({ jobId, mediaProfile, deviceInfo = null, rawPath = null, + seriesOptions = null, silent = true } = {}) { const normalizedJobId = this.normalizeQueueJobId(jobId); @@ -9558,7 +9979,7 @@ class PipelineService extends EventEmitter { const playlistAnalysis = analyzePlaylistObfuscation(scanLines, 0, {}); const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromPlaylistAnalysis( playlistAnalysis, - {} + seriesOptions && typeof seriesOptions === 'object' ? seriesOptions : {} ); return { runInfo, @@ -12741,8 +13162,8 @@ class PipelineService extends EventEmitter { year: containerJob.year || null, imdb_id: containerJob.imdb_id || null, poster_url: containerJob.poster_url || null, - selected_from_omdb: Number(containerJob.selected_from_omdb || 0) || 0, - omdb_json: containerJob.omdb_json || null, + selected_from_omdb: 0, + omdb_json: null, media_type: containerJob.media_type || null, job_kind: 'multipart_movie_merge', is_multipart_movie: 1, @@ -12798,8 +13219,8 @@ class PipelineService extends EventEmitter { year: containerJob.year || null, imdb_id: containerJob.imdb_id || null, poster_url: containerJob.poster_url || null, - selected_from_omdb: Number(containerJob.selected_from_omdb || 0) || 0, - omdb_json: containerJob.omdb_json || null, + selected_from_omdb: 0, + omdb_json: null, media_type: containerJob.media_type || null, job_kind: 'multipart_movie_merge', is_multipart_movie: 1, @@ -13356,7 +13777,8 @@ class PipelineService extends EventEmitter { ); const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe'; - const mergeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ripster-merge-')); + ensureDir(tempDir); + const mergeTmpDir = fs.mkdtempSync(path.join(tempDir, 'ripster-merge-')); void (async () => { let chaptersFilePath = null; let runInfo = null; @@ -14037,8 +14459,8 @@ class PipelineService extends EventEmitter { year: sourceJob.year ?? null, imdb_id: sourceJob.imdb_id || null, poster_url: sourceJob.poster_url || null, - omdb_json: sourceJob.omdb_json || null, - selected_from_omdb: Number(sourceJob.selected_from_omdb || 0), + omdb_json: null, + selected_from_omdb: 0, status: 'READY_TO_ENCODE', last_state: 'READY_TO_ENCODE', error_message: null, @@ -16747,7 +17169,7 @@ class PipelineService extends EventEmitter { }; const analyzePlugin = await this.resolveAnalyzePlugin(deviceWithProfile, 'analyze'); - await historyService.resetProcessLog(normalizedJobId); + await resetProcessLogIfLifecycleAllows(normalizedJobId, sourceJob); await historyService.updateJob(normalizedJobId, { status: 'ANALYZING', last_state: 'ANALYZING', @@ -16798,12 +17220,14 @@ class PipelineService extends EventEmitter { try { let effectiveDetectedTitle = detectedTitle; - let omdbCandidates = []; - let metadataCandidates = null; - let metadataProvider = 'omdb'; + let metadataCandidates = []; + let metadataProvider = 'tmdb'; let seriesAnalysis = null; let seriesLookupHint = null; + let seriesDecision = null; let pluginExecution = null; + const effectiveSeriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile).catch(() => ({})); + const seriesHeuristicOptions = buildSeriesHeuristicOptionsFromSettings(effectiveSeriesSettings); if (analyzePlugin) { const pluginContext = await this.buildPluginContext(analyzePlugin.id, { @@ -16834,12 +17258,10 @@ class PipelineService extends EventEmitter { } } - const preMetadataHandBrakeScanEnabled = await this.isPreMetadataHandBrakeScanEnabled(); if ( isSeriesDiscMediaProfile(mediaProfile) && analyzePlugin && ['dvd', 'bluray'].includes(analyzePlugin.id) - && preMetadataHandBrakeScanEnabled ) { try { const rawMedia = collectRawMediaCandidates(resolvedRawPath); @@ -16868,7 +17290,8 @@ class PipelineService extends EventEmitter { }, jobId: normalizedJobId, scanText, - detectedTitle: effectiveDetectedTitle + detectedTitle: effectiveDetectedTitle, + seriesOptions: seriesHeuristicOptions }); const seriesPluginResult = await dvdSeriesPlugin.analyze(seriesScanInputPath, sourceJob, dvdSeriesContext); pluginExecution = this.mergePluginExecutionState( @@ -16880,7 +17303,7 @@ class PipelineService extends EventEmitter { ? reviewResult.scanLines.join('\n') : '' ); - const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson); + const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, seriesHeuristicOptions); seriesAnalysis = mergeSeriesAnalysisWithStructuredFallback( seriesPluginResult?.seriesAnalysis || null, structuredSeriesAnalysis @@ -16894,6 +17317,7 @@ class PipelineService extends EventEmitter { jobId: normalizedJobId, mediaProfile, rawPath: seriesScanInputPath, + seriesOptions: seriesHeuristicOptions, silent: true }); if (secondChance?.structuredSeriesAnalysis) { @@ -16914,22 +17338,26 @@ class PipelineService extends EventEmitter { const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' ? seriesAnalysis.summary : null; - const seriesLike = Boolean(rawSeriesSummary?.seriesLike); - const hasSeriesLookupHint = Boolean( - String(seriesLookupHint?.query || '').trim() - || Number(seriesLookupHint?.seasonNumber || 0) > 0 - ); + seriesDecision = deriveSeriesDecisionFromSummary(rawSeriesSummary, seriesLookupHint, seriesHeuristicOptions); + const seriesLike = Boolean(seriesDecision?.seriesLike); + const hasSeriesLookupHint = Boolean(seriesDecision?.strongLookupHint); seriesAnalysis = rawSeriesSummary ? { source: String(seriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text', generatedAt: seriesAnalysis?.generatedAt || nowIso(), summary: { seriesLike, - confidence: String(rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low', + confidence: String(seriesDecision?.confidence || rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low', reasons: Array.isArray(rawSeriesSummary?.reasons) ? rawSeriesSummary.reasons : [], titleCount: Number(rawSeriesSummary?.titleCount || 0) || 0, episodeCandidateCount: Number(rawSeriesSummary?.episodeCandidateCount || 0) || 0, playAllCount: Number(rawSeriesSummary?.playAllCount || 0) || 0, + playAllCandidateSeconds: Number(rawSeriesSummary?.playAllCandidateSeconds || 0) || 0, + playAllWithinTolerance: Boolean(rawSeriesSummary?.playAllWithinTolerance), + episodeSumSeconds: Number(rawSeriesSummary?.episodeSumSeconds || 0) || 0, + playAllLowerBoundSeconds: Number(rawSeriesSummary?.playAllLowerBoundSeconds || 0) || 0, + playAllUpperBoundSeconds: Number(rawSeriesSummary?.playAllUpperBoundSeconds || 0) || 0, + longestOutsideClusterSeconds: Number(rawSeriesSummary?.longestOutsideClusterSeconds || 0) || 0, duplicateCount: Number(rawSeriesSummary?.duplicateCount || 0) || 0, extraCount: Number(rawSeriesSummary?.extraCount || 0) || 0, typicalEpisodeMinutes: Number(rawSeriesSummary?.typicalEpisodeMinutes || 0) || 0 @@ -16958,6 +17386,7 @@ class PipelineService extends EventEmitter { metadataProvider, metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0, seriesLookupHint, + seriesDecision, source: 'raw_import' }); } @@ -16967,32 +17396,25 @@ class PipelineService extends EventEmitter { error: errorToMeta(error) }); } - } else if ( - isSeriesDiscMediaProfile(mediaProfile) - && analyzePlugin - && ['dvd', 'bluray'].includes(analyzePlugin.id) - && !preMetadataHandBrakeScanEnabled - ) { - await historyService.appendLog( - normalizedJobId, - 'SYSTEM', - `Vorab-HandBrake-Scan vor Metadaten wurde per Setting "${PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY}" übersprungen.` - ); - logger.info('metadata:pre-scan:skipped', { + } + if (isSeriesDiscMediaProfile(mediaProfile)) { + if (!seriesDecision) { + seriesDecision = deriveSeriesDecisionFromSummary( + seriesAnalysis?.summary || null, + seriesLookupHint, + seriesHeuristicOptions + ); + } + await appendSeriesHeuristicDecisionLog({ jobId: normalizedJobId, mediaProfile, - source: 'raw_import', - settingKey: PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY + seriesAnalysis, + seriesDecision, + seriesLookupHint, + seriesHeuristicOptions }); } - if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) { - omdbCandidates = []; - } - if (metadataProvider !== 'tmdb') { - metadataCandidates = Array.isArray(omdbCandidates) ? omdbCandidates : []; - } - const prepareInfo = this.withPluginExecutionMeta( this.withAnalyzeContextMediaProfile({ source: 'orphan_raw_import', @@ -17007,10 +17429,12 @@ class PipelineService extends EventEmitter { playlistDecisionRequired: false, selectedPlaylist: null, selectedTitleId: null, + workflowKind: String(seriesDecision?.recommendedWorkflowKind || (seriesDecision?.seriesLike ? 'series' : 'film')).trim().toLowerCase(), metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], seriesAnalysis, - seriesLookupHint + seriesLookupHint, + seriesDecision } }, mediaProfile), pluginExecution @@ -17028,9 +17452,11 @@ class PipelineService extends EventEmitter { await historyService.appendLog( normalizedJobId, 'SYSTEM', - metadataProvider === 'tmdb' + String(seriesDecision?.classification || '').trim().toLowerCase() === 'series' ? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? `, Staffel ${seriesLookupHint.seasonNumber}` : ''}).` - : `Disk erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${effectiveDetectedTitle}").` + : (String(seriesDecision?.classification || '').trim().toLowerCase() === 'ambiguous' + ? `${resolveSeriesDiscLabel(mediaProfile)} uneindeutig erkannt (Serie/Film). Bitte Workflow auswählen und Metadaten zuordnen (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}").` + : `Film-${resolveSeriesDiscLabel(mediaProfile)} erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}").`) ); await this.setState('METADATA_SELECTION', { @@ -17044,10 +17470,11 @@ class PipelineService extends EventEmitter { detectedTitleSource: effectiveDetectedTitle !== detectedTitle ? 'plugin' : 'raw_import', metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], - omdbCandidates, mediaProfile, + workflowKind: String(seriesDecision?.recommendedWorkflowKind || (seriesDecision?.seriesLike ? 'series' : 'film')).trim().toLowerCase(), seriesAnalysis, seriesLookupHint, + seriesDecision, playlistAnalysis: null, playlistDecisionRequired: false, playlistCandidates: [], @@ -17074,7 +17501,9 @@ class PipelineService extends EventEmitter { metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], seriesAnalysis, - seriesLookupHint + seriesLookupHint, + seriesDecision, + workflowKind: String(seriesDecision?.recommendedWorkflowKind || (seriesDecision?.seriesLike ? 'series' : 'film')).trim().toLowerCase() }; } catch (error) { logger.error('metadata:prepare:raw-import:failed', { jobId: normalizedJobId, error: errorToMeta(error) }); @@ -17199,7 +17628,7 @@ class PipelineService extends EventEmitter { } // Surface the newly created analyze job immediately in the Ripper UI, - // even if metadata provider lookups (TMDb/OMDb) take longer. + // even if metadata provider lookups (TMDb) take longer. await this.setState('ANALYZING', { activeJobId: job.id, progress: 0, @@ -17215,12 +17644,14 @@ class PipelineService extends EventEmitter { try { let effectiveDetectedTitle = detectedTitle; - let omdbCandidates = []; - let metadataCandidates = null; - let metadataProvider = 'omdb'; + let metadataCandidates = []; + let metadataProvider = 'tmdb'; let seriesAnalysis = null; let seriesLookupHint = null; + let seriesDecision = null; let pluginExecution = null; + const effectiveSeriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile).catch(() => ({})); + const seriesHeuristicOptions = buildSeriesHeuristicOptionsFromSettings(effectiveSeriesSettings); if (analyzePlugin) { const pluginContext = await this.buildPluginContext(analyzePlugin.id, { discInfo: deviceWithProfile, @@ -17248,12 +17679,10 @@ class PipelineService extends EventEmitter { }); } } - const preMetadataHandBrakeScanEnabled = await this.isPreMetadataHandBrakeScanEnabled(); if ( isSeriesDiscMediaProfile(mediaProfile) && analyzePlugin && ['dvd', 'bluray'].includes(analyzePlugin.id) - && preMetadataHandBrakeScanEnabled ) { try { const reviewResult = await this.runPluginReviewScan(analyzePlugin, job, { @@ -17275,7 +17704,8 @@ class PipelineService extends EventEmitter { discInfo: deviceWithProfile, jobId: job.id, scanText, - detectedTitle: effectiveDetectedTitle + detectedTitle: effectiveDetectedTitle, + seriesOptions: seriesHeuristicOptions }); const seriesPluginResult = await dvdSeriesPlugin.analyze(device.path, job, dvdSeriesContext); pluginExecution = this.mergePluginExecutionState( @@ -17287,7 +17717,7 @@ class PipelineService extends EventEmitter { ? reviewResult.scanLines.join('\n') : '' ); - const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson); + const structuredSeriesAnalysis = buildStructuredSeriesAnalysisFromHandBrakeScanJson(scanJson, seriesHeuristicOptions); seriesAnalysis = mergeSeriesAnalysisWithStructuredFallback( seriesPluginResult?.seriesAnalysis || null, structuredSeriesAnalysis @@ -17301,6 +17731,7 @@ class PipelineService extends EventEmitter { jobId: job.id, mediaProfile, deviceInfo: deviceWithProfile, + seriesOptions: seriesHeuristicOptions, silent: true }); if (secondChance?.structuredSeriesAnalysis) { @@ -17321,22 +17752,26 @@ class PipelineService extends EventEmitter { const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' ? seriesAnalysis.summary : null; - const seriesLike = Boolean(rawSeriesSummary?.seriesLike); - const hasSeriesLookupHint = Boolean( - String(seriesLookupHint?.query || '').trim() - || Number(seriesLookupHint?.seasonNumber || 0) > 0 - ); + seriesDecision = deriveSeriesDecisionFromSummary(rawSeriesSummary, seriesLookupHint, seriesHeuristicOptions); + const seriesLike = Boolean(seriesDecision?.seriesLike); + const hasSeriesLookupHint = Boolean(seriesDecision?.strongLookupHint); seriesAnalysis = rawSeriesSummary ? { source: String(seriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text', generatedAt: seriesAnalysis?.generatedAt || nowIso(), summary: { seriesLike, - confidence: String(rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low', + confidence: String(seriesDecision?.confidence || rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low', reasons: Array.isArray(rawSeriesSummary?.reasons) ? rawSeriesSummary.reasons : [], titleCount: Number(rawSeriesSummary?.titleCount || 0) || 0, episodeCandidateCount: Number(rawSeriesSummary?.episodeCandidateCount || 0) || 0, playAllCount: Number(rawSeriesSummary?.playAllCount || 0) || 0, + playAllCandidateSeconds: Number(rawSeriesSummary?.playAllCandidateSeconds || 0) || 0, + playAllWithinTolerance: Boolean(rawSeriesSummary?.playAllWithinTolerance), + episodeSumSeconds: Number(rawSeriesSummary?.episodeSumSeconds || 0) || 0, + playAllLowerBoundSeconds: Number(rawSeriesSummary?.playAllLowerBoundSeconds || 0) || 0, + playAllUpperBoundSeconds: Number(rawSeriesSummary?.playAllUpperBoundSeconds || 0) || 0, + longestOutsideClusterSeconds: Number(rawSeriesSummary?.longestOutsideClusterSeconds || 0) || 0, duplicateCount: Number(rawSeriesSummary?.duplicateCount || 0) || 0, extraCount: Number(rawSeriesSummary?.extraCount || 0) || 0, typicalEpisodeMinutes: Number(rawSeriesSummary?.typicalEpisodeMinutes || 0) || 0 @@ -17364,7 +17799,8 @@ class PipelineService extends EventEmitter { seriesSource: seriesAnalysis?.source || null, metadataProvider, metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0, - seriesLookupHint + seriesLookupHint, + seriesDecision }); } } catch (error) { @@ -17373,36 +17809,29 @@ class PipelineService extends EventEmitter { error: errorToMeta(error) }); } - } else if ( - isSeriesDiscMediaProfile(mediaProfile) - && analyzePlugin - && ['dvd', 'bluray'].includes(analyzePlugin.id) - && !preMetadataHandBrakeScanEnabled - ) { - await historyService.appendLog( - job.id, - 'SYSTEM', - `Vorab-HandBrake-Scan vor Metadaten wurde per Setting "${PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY}" übersprungen.` - ); - logger.info('metadata:pre-scan:skipped', { + } + if (isSeriesDiscMediaProfile(mediaProfile)) { + if (!seriesDecision) { + seriesDecision = deriveSeriesDecisionFromSummary( + seriesAnalysis?.summary || null, + seriesLookupHint, + seriesHeuristicOptions + ); + } + await appendSeriesHeuristicDecisionLog({ jobId: job.id, mediaProfile, - source: 'drive_analyze', - settingKey: PRE_METADATA_HANDBRAKE_SCAN_SETTING_KEY + seriesAnalysis, + seriesDecision, + seriesLookupHint, + seriesHeuristicOptions }); } - if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) { - omdbCandidates = []; - } - if (metadataProvider !== 'tmdb') { - metadataCandidates = Array.isArray(omdbCandidates) ? omdbCandidates : []; - } logger.info('metadata:prepare:result', { jobId: job.id, detectedTitle: effectiveDetectedTitle, metadataProvider, - metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0, - omdbCandidateCount: Array.isArray(omdbCandidates) ? omdbCandidates.length : 0 + metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0 }); const prepareInfo = this.withPluginExecutionMeta( @@ -17414,10 +17843,12 @@ class PipelineService extends EventEmitter { playlistDecisionRequired: false, selectedPlaylist: null, selectedTitleId: null, + workflowKind: String(seriesDecision?.recommendedWorkflowKind || (seriesDecision?.seriesLike ? 'series' : 'film')).trim().toLowerCase(), metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], seriesAnalysis, - seriesLookupHint + seriesLookupHint, + seriesDecision } }, mediaProfile), pluginExecution @@ -17432,9 +17863,11 @@ class PipelineService extends EventEmitter { await historyService.appendLog( job.id, 'SYSTEM', - metadataProvider === 'tmdb' + String(seriesDecision?.classification || '').trim().toLowerCase() === 'series' ? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? `, Staffel ${seriesLookupHint.seasonNumber}` : ''}).` - : `Disk erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${effectiveDetectedTitle}").` + : (String(seriesDecision?.classification || '').trim().toLowerCase() === 'ambiguous' + ? `${resolveSeriesDiscLabel(mediaProfile)} uneindeutig erkannt (Serie/Film). Bitte Workflow auswählen und Metadaten zuordnen (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}").` + : `Film-${resolveSeriesDiscLabel(mediaProfile)} erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}").`) ); const keepCurrentPipelineSession = this._hasForeignInteractiveSession(job.id); @@ -17453,10 +17886,11 @@ class PipelineService extends EventEmitter { : (device.discLabel ? 'discLabel' : 'fallback'), metadataProvider, metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], - omdbCandidates, mediaProfile, + workflowKind: String(seriesDecision?.recommendedWorkflowKind || (seriesDecision?.seriesLike ? 'series' : 'film')).trim().toLowerCase(), seriesAnalysis, seriesLookupHint, + seriesDecision, playlistAnalysis: null, playlistDecisionRequired: false, playlistCandidates: [], @@ -17486,7 +17920,8 @@ class PipelineService extends EventEmitter { metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [], seriesAnalysis, seriesLookupHint, - omdbCandidates + seriesDecision, + workflowKind: String(seriesDecision?.recommendedWorkflowKind || (seriesDecision?.seriesLike ? 'series' : 'film')).trim().toLowerCase() }; } catch (error) { logger.error('metadata:prepare:failed', { jobId: job.id, error: errorToMeta(error) }); @@ -17499,21 +17934,38 @@ class PipelineService extends EventEmitter { } } - async searchOmdb(query) { + async searchTmdbMovies(query) { const rawQuery = String(query || '').trim(); const queryVariants = buildMetadataSearchQueryVariants(rawQuery); if (queryVariants.length === 0) { return []; } - logger.info('omdb:search', { + logger.info('tmdb:movie-search', { query: rawQuery, variants: queryVariants }); + 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; + } + + let tmdbLanguage = null; + try { + const effectiveSettings = await settingsService.getEffectiveSettingsMap('dvd'); + tmdbLanguage = String(effectiveSettings?.dvd_series_language || '').trim() || null; + } catch (_settingsError) { + tmdbLanguage = null; + } + for (const variant of queryVariants) { - const results = await omdbService.search(variant); + const results = await tmdbService.searchMoviesWithDetails(variant, { + language: tmdbLanguage + }); if (Array.isArray(results) && results.length > 0) { - logger.info('omdb:search:done', { + logger.info('tmdb:movie-search:done', { query: rawQuery, matchedVariant: variant, count: results.length @@ -17521,7 +17973,7 @@ class PipelineService extends EventEmitter { return results; } } - logger.info('omdb:search:done', { + logger.info('tmdb:movie-search:done', { query: rawQuery, matchedVariant: null, count: 0 @@ -19543,14 +19995,14 @@ class PipelineService extends EventEmitter { playlistDecisionRequired: false, selectedPlaylist: null, selectedTitleId: null, - metadataProvider: 'omdb', + metadataProvider: 'tmdb', workflowKind: 'film', selectedMetadata: orphanMultipartSelectedMetadata, rawDecisionContext: null }; const currentAnalyzeContextPatch = { ...currentAnalyzeContext, - metadataProvider: 'omdb', + metadataProvider: 'tmdb', workflowKind: 'film', selectedMetadata: currentMultipartSelectedMetadata, rawDecisionContext: null @@ -19609,7 +20061,7 @@ class PipelineService extends EventEmitter { }, mediaProfile); const containerMakemkvInfo = this.withAnalyzeContextMediaProfile({ analyzeContext: { - metadataProvider: 'omdb', + metadataProvider: 'tmdb', workflowKind: 'film', selectedMetadata: containerSelectedMetadata }, @@ -19620,16 +20072,14 @@ class PipelineService extends EventEmitter { const commonYear = selectedMetadata?.year || job?.year || null; const commonImdbId = selectedMetadata?.imdbId || job?.imdb_id || null; const commonPoster = selectedMetadata?.poster || job?.poster_url || null; - const selectedFromOmdb = Number(job?.selected_from_omdb || 0) || 0; - const omdbJsonValue = job?.omdb_json || null; await historyService.updateJob(multipartContainerJobId, { title: commonTitle, year: commonYear, imdb_id: commonImdbId, poster_url: commonPoster, - selected_from_omdb: selectedFromOmdb, - omdb_json: omdbJsonValue, + selected_from_omdb: 0, + omdb_json: null, status: 'READY_TO_START', last_state: 'READY_TO_START', media_type: mediaProfile, @@ -19647,8 +20097,8 @@ class PipelineService extends EventEmitter { year: commonYear, imdb_id: commonImdbId, poster_url: commonPoster, - selected_from_omdb: selectedFromOmdb, - omdb_json: omdbJsonValue, + selected_from_omdb: 0, + omdb_json: null, status: 'READY_TO_START', last_state: 'READY_TO_START', media_type: mediaProfile, @@ -19676,8 +20126,8 @@ class PipelineService extends EventEmitter { year: commonYear, imdb_id: commonImdbId, poster_url: commonPoster, - selected_from_omdb: selectedFromOmdb, - omdb_json: omdbJsonValue, + selected_from_omdb: 0, + omdb_json: null, status: nextStatus, last_state: nextStatus, media_type: mediaProfile, @@ -19832,7 +20282,6 @@ class PipelineService extends EventEmitter { year, imdbId, poster, - fromOmdb = null, selectedPlaylist = null, selectedHandBrakeTitleId = null, selectedHandBrakeTitleIds = null, @@ -19857,7 +20306,6 @@ class PipelineService extends EventEmitter { year, imdbId, poster, - fromOmdb, selectedPlaylist, selectedHandBrakeTitleId, selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null, @@ -19908,7 +20356,6 @@ class PipelineService extends EventEmitter { || year !== undefined || imdbId !== undefined || poster !== undefined - || (fromOmdb !== null && fromOmdb !== undefined) ); if ((normalizedSelectedPlaylist || effectiveSelectedHandBrakeTitleId) && waitingForDecision && !hasExplicitMetadataPayload) { @@ -19963,29 +20410,29 @@ class PipelineService extends EventEmitter { const effectiveImdbId = imdbId === undefined ? (job.imdb_id || null) : (imdbId || null); - const selectedFromOmdb = fromOmdb === null || fromOmdb === undefined - ? Number(job.selected_from_omdb || 0) - : (fromOmdb ? 1 : 0); - const posterValue = poster === undefined + const selectedFromMetadata = 0; + let posterValue = poster === undefined ? (job.poster_url || null) : (poster || null); - const effectiveDiscNumber = normalizePositiveInteger(discNumber); - - const converterMetadataProvider = String(metadataProvider || 'omdb').trim().toLowerCase() || 'omdb'; - let omdbJsonValue = converterMetadataProvider === 'omdb' - ? (job.omdb_json || null) - : null; - if (fromOmdb && effectiveImdbId && converterMetadataProvider === 'omdb') { + let shouldQueuePosterCache = false; + if (posterValue && !thumbnailService.isLocalUrl(posterValue)) { try { - const omdbFull = await omdbService.fetchByImdbId(effectiveImdbId); - if (omdbFull?.raw) { - omdbJsonValue = JSON.stringify(omdbFull.raw); + const cacheResult = await historyService.cacheAndPromoteExternalPoster(jobId, posterValue, { + source: 'Poster', + logFailures: false + }); + if (cacheResult?.ok && cacheResult?.localUrl) { + posterValue = cacheResult.localUrl; + } else { + shouldQueuePosterCache = true; } - } catch (omdbErr) { - logger.warn('metadata:converter:omdb-fetch-failed', { jobId, imdbId: effectiveImdbId, error: errorToMeta(omdbErr) }); + } catch (_error) { + shouldQueuePosterCache = true; } } + const effectiveDiscNumber = normalizePositiveInteger(discNumber); + const converterMetadataProvider = String(metadataProvider || 'tmdb').trim().toLowerCase() || 'tmdb'; const nextEncodePlan = { ...converterPlan, mediaProfile: 'converter', @@ -20015,8 +20462,9 @@ class PipelineService extends EventEmitter { year: effectiveYear, imdb_id: effectiveImdbId, poster_url: posterValue, - selected_from_omdb: selectedFromOmdb, - omdb_json: omdbJsonValue, + selected_from_omdb: selectedFromMetadata, + omdb_json: null, + migrate_tmdb: 1, status: 'READY_TO_START', last_state: 'READY_TO_START', raw_path: converterInputPath, @@ -20026,7 +20474,7 @@ class PipelineService extends EventEmitter { error_message: null }); - if (posterValue && !thumbnailService.isLocalUrl(posterValue)) { + if (shouldQueuePosterCache && posterValue && !thumbnailService.isLocalUrl(posterValue)) { historyService.queuePosterCache(jobId, posterValue, { source: 'Poster', logFailures: true @@ -20061,7 +20509,6 @@ class PipelineService extends EventEmitter { || year !== undefined || imdbId !== undefined || poster !== undefined - || (fromOmdb !== null && fromOmdb !== undefined) ); if (effectiveSelectedHandBrakeTitleId && waitingForPlaylistSelection && job.raw_path && !hasExplicitMetadataPayload && !normalizedSelectedPlaylist) { @@ -20177,17 +20624,31 @@ class PipelineService extends EventEmitter { let effectiveImdbId = imdbId === undefined ? (job.imdb_id || null) : (imdbId || null); - const selectedFromOmdb = fromOmdb === null || fromOmdb === undefined - ? Number(job.selected_from_omdb || 0) - : (fromOmdb ? 1 : 0); - const posterValue = poster === undefined + const selectedFromMetadata = 0; + let posterValue = poster === undefined ? (job.poster_url || null) : (poster || null); + let shouldQueuePosterCache = false; + if (posterValue && !thumbnailService.isLocalUrl(posterValue)) { + try { + const cacheResult = await historyService.cacheAndPromoteExternalPoster(jobId, posterValue, { + source: 'Poster', + logFailures: false + }); + if (cacheResult?.ok && cacheResult?.localUrl) { + posterValue = cacheResult.localUrl; + } else { + shouldQueuePosterCache = true; + } + } catch (_error) { + shouldQueuePosterCache = true; + } + } let effectiveMetadataProvider = String( metadataProvider || mkInfo?.analyzeContext?.metadataProvider - || 'omdb' - ).trim().toLowerCase() || 'omdb'; + || 'tmdb' + ).trim().toLowerCase() || 'tmdb'; const requestedWorkflowKind = normalizeDvdMetadataWorkflowKind(workflowKind); const existingWorkflowKind = normalizeDvdMetadataWorkflowKind( mkInfo?.analyzeContext?.selectedMetadata?.workflowKind @@ -20196,17 +20657,15 @@ class PipelineService extends EventEmitter { || null ); const providerWorkflowKind = effectiveMetadataProvider === 'tmdb' - ? 'series' - : (effectiveMetadataProvider === 'omdb' ? 'film' : null); - const effectiveWorkflowKind = requestedWorkflowKind || providerWorkflowKind || existingWorkflowKind; + ? (isSeriesDiscMediaProfile(mediaProfile) ? 'series' : 'film') + : null; + const effectiveWorkflowKind = requestedWorkflowKind || existingWorkflowKind || providerWorkflowKind; const normalizedDuplicateAction = String(duplicateAction || '').trim().toLowerCase(); const requestedExistingJobId = normalizePositiveInteger(existingJobId); const requestedExistingDiscNumber = normalizePositiveInteger(existingDiscNumber); if (isSeriesDiscMediaProfile(mediaProfile)) { if (effectiveWorkflowKind === 'series') { effectiveMetadataProvider = 'tmdb'; - } else if (effectiveWorkflowKind === 'film') { - effectiveMetadataProvider = 'omdb'; } } let effectiveProviderId = String( @@ -20259,8 +20718,9 @@ class PipelineService extends EventEmitter { const isDvdTmdbMetadataSelection = isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'series'; if (isSeriesDiscMediaProfile(mediaProfile) && effectiveWorkflowKind === 'film') { - effectiveProviderId = null; - effectiveTmdbId = null; + if (!effectiveProviderId && effectiveTmdbId) { + effectiveProviderId = `tmdb:${effectiveTmdbId}`; + } effectiveMetadataKind = 'movie'; effectiveSeasonNumber = null; effectiveSeasonName = null; @@ -20273,24 +20733,9 @@ class PipelineService extends EventEmitter { throw error; } - // Fetch full OMDb details when selecting from OMDb with a valid IMDb ID. - let omdbJsonValue = effectiveMetadataProvider === 'omdb' - ? (job.omdb_json || null) - : null; - if (fromOmdb && effectiveImdbId && effectiveMetadataProvider === 'omdb') { - try { - const omdbFull = await omdbService.fetchByImdbId(effectiveImdbId); - if (omdbFull?.raw) { - omdbJsonValue = JSON.stringify(omdbFull.raw); - } - } catch (omdbErr) { - logger.warn('metadata:omdb-fetch-failed', { jobId, imdbId: effectiveImdbId, error: errorToMeta(omdbErr) }); - } - } - let seasonDetails = null; let seasonCredits = null; - if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId) { + if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveWorkflowKind !== 'film') { try { const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, { language: tmdbLanguage, @@ -20322,6 +20767,39 @@ class PipelineService extends EventEmitter { } } + if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveWorkflowKind === 'film') { + try { + const movieDetails = await tmdbService.getMovieDetailsWithCredits(effectiveTmdbId, { + language: tmdbLanguage, + appendToResponse: ['credits', 'external_ids'], + ensureCredits: true + }); + const movieSummary = tmdbService.buildMovieDetailsSummary(movieDetails); + const genres = tmdbService.normalizeNameList(movieDetails?.genres, { maxItems: 3 }); + const seasonCast = tmdbService.normalizeNameList(movieDetails?.credits?.cast, { maxItems: 6 }); + tmdbDetails = { + ...(movieSummary && typeof movieSummary === 'object' ? movieSummary : {}), + genres, + seasonCast + }; + if (!effectiveImdbId && tmdbDetails?.imdbId) { + effectiveImdbId = tmdbDetails.imdbId; + } + if ((!effectiveYear || Number(effectiveYear) <= 0) && tmdbDetails?.releaseDate) { + const tmdbYear = Number(String(tmdbDetails.releaseDate).slice(0, 4)); + if (Number.isFinite(tmdbYear) && tmdbYear > 0) { + effectiveYear = Math.trunc(tmdbYear); + } + } + } catch (tmdbMovieErr) { + logger.warn('metadata:tmdb-movie-fetch-failed', { + jobId, + tmdbId: effectiveTmdbId, + error: errorToMeta(tmdbMovieErr) + }); + } + } + if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveSeasonNumber) { try { seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber, { @@ -20660,7 +21138,7 @@ class PipelineService extends EventEmitter { const patchedExistingSelectedMetadata = { ...existingSelectedMetadata, workflowKind: 'film', - metadataProvider: 'omdb', + metadataProvider: 'tmdb', metadataKind: 'movie', providerId: null, tmdbId: null, @@ -20684,7 +21162,7 @@ class PipelineService extends EventEmitter { }, analyzeContext: { ...existingAnalyzeContext, - metadataProvider: 'omdb', + metadataProvider: 'tmdb', workflowKind: 'film', selectedMetadata: { ...existingAnalyzeSelectedMetadata, @@ -20695,7 +21173,7 @@ class PipelineService extends EventEmitter { multipartExistingSelectedMetadataForRawRename = patchedExistingSelectedMetadata; multipartExistingAnalyzeContextForRawRename = { ...existingAnalyzeContext, - metadataProvider: 'omdb', + metadataProvider: 'tmdb', workflowKind: 'film', selectedMetadata: patchedExistingSelectedMetadata }; @@ -20715,7 +21193,7 @@ class PipelineService extends EventEmitter { const containerSelectedMetadataPatch = { ...selectedMetadata, workflowKind: 'film', - metadataProvider: 'omdb', + metadataProvider: 'tmdb', metadataKind: 'movie', providerId: null, tmdbId: null, @@ -20727,7 +21205,7 @@ class PipelineService extends EventEmitter { }; const containerMkInfo = this.withAnalyzeContextMediaProfile({ analyzeContext: { - metadataProvider: 'omdb', + metadataProvider: 'tmdb', workflowKind: 'film', selectedMetadata: containerSelectedMetadataPatch }, @@ -20738,8 +21216,8 @@ class PipelineService extends EventEmitter { year: effectiveYear, imdb_id: effectiveImdbId, poster_url: posterValue, - selected_from_omdb: selectedFromOmdb, - omdb_json: omdbJsonValue, + selected_from_omdb: selectedFromMetadata, + omdb_json: null, status: job.status || 'METADATA_SELECTION', last_state: job.status || 'METADATA_SELECTION', media_type: mediaProfile, @@ -20938,8 +21416,8 @@ class PipelineService extends EventEmitter { year: effectiveYear, imdb_id: effectiveImdbId, poster_url: posterValue, - selected_from_omdb: selectedFromOmdb, - omdb_json: omdbJsonValue, + selected_from_omdb: selectedFromMetadata, + omdb_json: null, status: job.status || 'METADATA_SELECTION', last_state: job.status || 'METADATA_SELECTION', media_type: mediaProfile, @@ -21256,8 +21734,9 @@ class PipelineService extends EventEmitter { year: effectiveYear, imdb_id: effectiveImdbId, poster_url: posterValue, - selected_from_omdb: selectedFromOmdb, - omdb_json: omdbJsonValue, + selected_from_omdb: selectedFromMetadata, + omdb_json: null, + migrate_tmdb: 1, status: nextStatus, last_state: nextStatus, raw_path: updatedRawPath, @@ -21292,7 +21771,7 @@ class PipelineService extends EventEmitter { } // Bild in Cache laden (async, blockiert nicht) - if (posterValue && !thumbnailService.isLocalUrl(posterValue)) { + if (shouldQueuePosterCache && posterValue && !thumbnailService.isLocalUrl(posterValue)) { historyService.queuePosterCache(jobId, posterValue, { source: 'Poster', logFailures: true @@ -21618,7 +22097,49 @@ class PipelineService extends EventEmitter { throw error; } - await historyService.resetProcessLog(jobId); + await resetProcessLogIfLifecycleAllows(jobId, job); + try { + const startAnalyzeContext = jobMakemkvInfo?.analyzeContext && typeof jobMakemkvInfo.analyzeContext === 'object' + ? jobMakemkvInfo.analyzeContext + : {}; + if (isSeriesDiscMediaProfile(jobMediaProfile)) { + const effectiveSeriesSettings = await settingsService.getEffectiveSettingsMap(jobMediaProfile).catch(() => ({})); + const seriesHeuristicOptions = buildSeriesHeuristicOptionsFromSettings(effectiveSeriesSettings); + const startSeriesAnalysis = startAnalyzeContext?.seriesAnalysis && typeof startAnalyzeContext.seriesAnalysis === 'object' + ? startAnalyzeContext.seriesAnalysis + : null; + const startSeriesLookupHint = startAnalyzeContext?.seriesLookupHint && typeof startAnalyzeContext.seriesLookupHint === 'object' + ? startAnalyzeContext.seriesLookupHint + : null; + const startSeriesDecision = startAnalyzeContext?.seriesDecision && typeof startAnalyzeContext.seriesDecision === 'object' + ? startAnalyzeContext.seriesDecision + : deriveSeriesDecisionFromSummary( + startSeriesAnalysis?.summary || null, + startSeriesLookupHint, + seriesHeuristicOptions + ); + await appendSeriesHeuristicDecisionLog({ + jobId, + mediaProfile: jobMediaProfile, + seriesAnalysis: startSeriesAnalysis, + seriesDecision: startSeriesDecision, + seriesLookupHint: startSeriesLookupHint, + seriesHeuristicOptions + }); + } else { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Heuristik Film/Serie: Für Profil "${String(jobMediaProfile || 'unbekannt')}" nicht anwendbar.` + ); + } + } catch (error) { + logger.warn('startPreparedJob:heuristic-log-failed', { + jobId, + mediaProfile: jobMediaProfile, + error: errorToMeta(error) + }); + } if (this.isMultipartMovieMergeHistoryJob(job)) { return this.startMultipartMergeJob(jobId, { ...options, preloadedJob: job }); @@ -21928,7 +22449,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'SYSTEM', - 'Metadaten-Auswahl übersprungen. Starte Review ohne OMDb-Zuordnung.' + 'Metadaten-Auswahl übersprungen. Starte Review ohne vorherige Metadaten-Zuordnung.' ); } if (statusUpper === 'WAITING_FOR_USER_DECISION') { @@ -22554,7 +23075,7 @@ class PipelineService extends EventEmitter { } } - await historyService.resetProcessLog(sourceJobId); + await resetProcessLogIfLifecycleAllows(sourceJobId, sourceJob); await historyService.updateJob(sourceJobId, { media_type: 'audiobook', job_kind: 'audiobook', @@ -22687,7 +23208,7 @@ class PipelineService extends EventEmitter { encodeCompletedCount: 0 }); - await historyService.resetProcessLog(sourceJobId); + await resetProcessLogIfLifecycleAllows(sourceJobId, sourceJob); await historyService.updateJob(sourceJobId, { status: 'CD_RIPPING', last_state: 'CD_RIPPING', @@ -22773,7 +23294,7 @@ class PipelineService extends EventEmitter { throw error; } - await historyService.resetProcessLog(sourceJobId); + await resetProcessLogIfLifecycleAllows(sourceJobId, sourceJob); // Handle explicit folder deletion requested by the user (video/audiobook) const reencodeDeleteFolders = Array.isArray(options?.deleteFolders) ? options.deleteFolders : []; @@ -22937,18 +23458,48 @@ class PipelineService extends EventEmitter { .filter((f) => /\.wav$/i.test(f)) .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) .map((f, i) => ({ position: i + 1, title: `Track ${i + 1}`, artist: null, selected: true })); + const previousSelectedMetadata = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object' + ? mkInfo.selectedMetadata + : {}; + const resolvedMbId = String( + previousSelectedMetadata?.mbId + || previousSelectedMetadata?.musicBrainzId + || previousSelectedMetadata?.musicbrainzId + || previousSelectedMetadata?.musicbrainz_id + || previousSelectedMetadata?.music_brainz_id + || previousSelectedMetadata?.musicbrainz + || previousSelectedMetadata?.mbid + || '' + ).trim() || null; + const selectedMetadata = { + title: normalizeCdTrackText(previousSelectedMetadata?.title) + || normalizeCdTrackText(sourceJob?.title) + || normalizeCdTrackText(sourceJob?.detected_title) + || 'Audio CD', + artist: normalizeCdTrackText(previousSelectedMetadata?.artist) || null, + year: Number.isFinite(Number(previousSelectedMetadata?.year)) + ? Math.trunc(Number(previousSelectedMetadata.year)) + : (Number.isFinite(Number(sourceJob?.year)) ? Math.trunc(Number(sourceJob.year)) : null), + mbId: resolvedMbId, + coverUrl: normalizeCdTrackText( + previousSelectedMetadata?.coverUrl + || previousSelectedMetadata?.poster + || previousSelectedMetadata?.posterUrl + || sourceJob?.poster_url + ) || null + }; + const resetTracks = effectiveTocTracks.map((track, index) => { const position = Number(track?.position); const normalizedPosition = Number.isFinite(position) && position > 0 ? Math.trunc(position) : (index + 1); return { ...track, position: normalizedPosition, - title: `Track ${normalizedPosition}`, - artist: null, + title: normalizeCdTrackText(track?.title) || `Track ${normalizedPosition}`, + artist: normalizeCdTrackText(track?.artist) || selectedMetadata.artist || null, selected: track?.selected !== false }; }); - const selectedMetadata = {}; nextEncodePlan.tracks = resetTracks; nextEncodePlan.selectedTracks = resetTracks .filter((track) => track.selected !== false) @@ -22969,13 +23520,13 @@ class PipelineService extends EventEmitter { const updatedMkInfo = { ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), tracks: resetTracks, - selectedMetadata: {} + selectedMetadata }; const cdparanoiaCmd = String(mkInfo.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia'; const jobUpdatePayload = { - status: 'CD_METADATA_SELECTION', - last_state: 'CD_METADATA_SELECTION', + status: 'CD_READY_TO_RIP', + last_state: 'CD_READY_TO_RIP', start_time: null, end_time: null, error_message: null, @@ -22986,12 +23537,12 @@ class PipelineService extends EventEmitter { const virtualDrivePath = `__virtual__${jobId}`; this._setCdDriveState(virtualDrivePath, { - state: 'CD_METADATA_SELECTION', + state: 'CD_READY_TO_RIP', jobId, device: null, progress: 0, eta: null, - statusText: 'Vorprüfung (kein Laufwerk)', + statusText: 'CD bereit zum Encodieren', context: { jobId, mediaProfile: 'cd', @@ -23013,7 +23564,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'USER_ACTION', - `CD-Vorprüfung aus RAW gestartet (skipRip). ${wavFiles.length} WAV-Datei(en) in: ${resolvedRawPath}` + `CD-Vorprüfung aus RAW gestartet (skipRip, Metadaten aus letztem Lauf übernommen). ${wavFiles.length} WAV-Datei(en) in: ${resolvedRawPath}` ); return { jobId, tracks: resetTracks, selectedMetadata }; @@ -26112,6 +26663,7 @@ class PipelineService extends EventEmitter { disableMinLengthFilter, backupOutputBase, sourceArgOverride: null, + makeMkvLineFilter: createMakeMkvForeignDriveLineFilter(devicePath), runCommand: this.runCommand.bind(this) }); makemkvInfo = await ripPlugin.rip(job, ripCtx); @@ -26440,7 +26992,12 @@ class PipelineService extends EventEmitter { selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || []) }; } // end else (sourceTracks.length > 0) - } else if (!isAudiobookRetry && !isMultipartMergeRetry) { + } + + const shouldResetOldRawBeforeRetry = (isVideoDiscRetry || isCdRetry) + && !isAudiobookRetry + && !isMultipartMergeRetry; + if (shouldResetOldRawBeforeRetry) { const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile); const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig( retrySettings, @@ -26575,8 +27132,8 @@ class PipelineService extends EventEmitter { year: sourceJob.year ?? null, imdb_id: sourceJob.imdb_id || null, poster_url: sourceJob.poster_url || null, - omdb_json: sourceJob.omdb_json || null, - selected_from_omdb: Number(sourceJob.selected_from_omdb || 0), + omdb_json: null, + selected_from_omdb: 0, makemkv_info_json: sourceJob.makemkv_info_json || null, rip_successful: isAudiobookRetry ? 1 : 0, error_message: null, @@ -27112,8 +27669,8 @@ class PipelineService extends EventEmitter { year: job.year ?? null, imdb_id: job.imdb_id || null, poster_url: job.poster_url || null, - omdb_json: job.omdb_json || null, - selected_from_omdb: Number(job.selected_from_omdb || 0), + omdb_json: null, + selected_from_omdb: 0, status: 'READY_TO_ENCODE', last_state: 'READY_TO_ENCODE', error_message: null, @@ -27420,7 +27977,7 @@ class PipelineService extends EventEmitter { let activeReviewInputPath = normalizedReviewInputPath || null; if (reuseCurrentJob) { - await historyService.resetProcessLog(targetJobId); + await resetProcessLogIfLifecycleAllows(targetJobId, sourceJob); await historyService.updateJob(targetJobId, { ...jobUpdatePayload, media_type: preservedReviewMediaType, @@ -27466,8 +28023,8 @@ class PipelineService extends EventEmitter { year: sourceJob.year ?? null, imdb_id: sourceJob.imdb_id || null, poster_url: sourceJob.poster_url || null, - omdb_json: sourceJob.omdb_json || null, - selected_from_omdb: Number(sourceJob.selected_from_omdb || 0), + omdb_json: null, + selected_from_omdb: 0, disc_device: sourceJob.disc_device || null, rip_successful: Number(sourceJob.rip_successful || 0), output_path: null, @@ -27907,46 +28464,62 @@ class PipelineService extends EventEmitter { 'CD_READY_TO_RIP' ]); - if (!processHandle) { - if (SOFT_CANCELABLE_STATES.has(runningStatus)) { - // Kein laufender Prozess – Job direkt abbrechen (Review-/Ready-Phasen) - const cancelMessage = 'Vom Benutzer abgebrochen.'; - await historyService.updateJob(normalizedJobId, { - status: 'CANCELLED', - // Preserve origin state so ripper/history can distinguish review-cancelled rows. - last_state: runningStatus, - end_time: nowIso(), - error_message: cancelMessage - }); - - const cdDriveForJob = this._getCdDriveByJobId(normalizedJobId); - if (cdDriveForJob?.devicePath) { - if (String(cdDriveForJob.devicePath).startsWith('__virtual__')) { - this._removeCdDrive(cdDriveForJob.devicePath); - } else { - this._releaseCdDrive(cdDriveForJob.devicePath, { device: cdDriveForJob.device || null }); - } - } else { - this.jobProgress.delete(normalizedJobId); + if (SOFT_CANCELABLE_STATES.has(runningStatus)) { + // Review-/Ready-Phasen werden immer soft-cancelled, selbst wenn + // versehentlich ein veralteter Process-Handle vorhanden ist. + const cancelMessage = 'Vom Benutzer abgebrochen.'; + if (processHandle) { + try { + processHandle.cancel(); + } catch (_error) { + // ignore stale cancel race } - - await historyService.appendLog(normalizedJobId, 'USER_ACTION', `Abbruch im Status ${runningStatus}.`); - await this.setState('CANCELLED', { - activeJobId: normalizedJobId, - progress: 0, - eta: null, - statusText: cancelMessage, - context: { - jobId: normalizedJobId, - rawPath: runningJob?.raw_path || null, - error: cancelMessage, - cancelledFromState: runningStatus, - canRestartReviewFromRaw: Boolean(runningJob?.raw_path) - } - }); - return { cancelled: true, queuedOnly: false, jobId: normalizedJobId }; + this.activeProcesses.delete(normalizedJobId); + this.syncPrimaryActiveProcess(); } + await historyService.updateJob(normalizedJobId, { + status: 'CANCELLED', + // Preserve origin state so ripper/history can distinguish review-cancelled rows. + last_state: runningStatus, + end_time: nowIso(), + error_message: cancelMessage + }); + + // Soft-cancel must always drop any stale drive lock for this job. + this._releaseDriveLockForJob(normalizedJobId, { reason: 'soft_cancel' }); + + const cdDriveForJob = this._getCdDriveByJobId(normalizedJobId); + if (cdDriveForJob?.devicePath) { + if (String(cdDriveForJob.devicePath).startsWith('__virtual__')) { + this._removeCdDrive(cdDriveForJob.devicePath); + } else { + this._releaseCdDrive(cdDriveForJob.devicePath, { device: cdDriveForJob.device || null }); + } + } else { + this.jobProgress.delete(normalizedJobId); + } + + this.cancelRequestedByJob.delete(normalizedJobId); + await historyService.appendLog(normalizedJobId, 'USER_ACTION', `Abbruch im Status ${runningStatus}.`); + await this.setState('CANCELLED', { + activeJobId: normalizedJobId, + progress: 0, + eta: null, + statusText: cancelMessage, + context: { + jobId: normalizedJobId, + rawPath: runningJob?.raw_path || null, + error: cancelMessage, + cancelledFromState: runningStatus, + canRestartReviewFromRaw: Boolean(runningJob?.raw_path) + } + }); + return { cancelled: true, queuedOnly: false, jobId: normalizedJobId }; + } + + if (!processHandle) { + if (RUNNING_STATES.has(runningStatus)) { return forceFinalizeCancelledJob( `Abbruch erzwungen: kein aktiver Prozess-Handle gefunden (Status ${runningStatus}).`, @@ -28052,6 +28625,7 @@ class PipelineService extends EventEmitter { collectStderrLines = true, argsForLog = null, silent = false, + lineFilter = null, onStdoutLine = null, onStderrLine = null }) { @@ -28161,6 +28735,10 @@ class PipelineService extends EventEmitter { args, context: { jobId, stage, source }, onStdoutLine: (line) => { + const keepLine = typeof lineFilter === 'function' ? lineFilter(line, 'stdout') !== false : true; + if (!keepLine) { + return; + } if (collectLines && collectStdoutLines) { collectLines.push(line); } @@ -28178,6 +28756,10 @@ class PipelineService extends EventEmitter { applyLine(line, false); }, onStderrLine: (line) => { + const keepLine = typeof lineFilter === 'function' ? lineFilter(line, 'stderr') !== false : true; + if (!keepLine) { + return; + } if (collectLines && collectStderrLines) { collectLines.push(line); } @@ -28661,7 +29243,7 @@ class PipelineService extends EventEmitter { let stagedRawFilePath = null; try { - await historyService.resetProcessLog(job.id); + await resetProcessLogIfLifecycleAllows(job.id, job); await historyService.appendLog(job.id, 'SYSTEM', `AAX-Upload empfangen: ${originalName}`); logger.info('audiobook:upload:received', { jobId: job.id, @@ -28985,6 +29567,60 @@ class PipelineService extends EventEmitter { createFallback: false } ); + const selectedPreEncodeScriptIds = normalizeScriptIdList(config?.selectedPreEncodeScriptIds || []); + const selectedPostEncodeScriptIds = normalizeScriptIdList(config?.selectedPostEncodeScriptIds || []); + const selectedPreEncodeChainIds = normalizeChainIdList(config?.selectedPreEncodeChainIds || []); + const selectedPostEncodeChainIds = normalizeChainIdList(config?.selectedPostEncodeChainIds || []); + const [ + selectedPreEncodeScripts, + selectedPostEncodeScripts, + selectedPreEncodeChains, + selectedPostEncodeChains + ] = await Promise.all([ + scriptService.resolveScriptsByIds(selectedPreEncodeScriptIds, { strict: true }), + scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, { strict: true }), + scriptChainService.getChainsByIds(selectedPreEncodeChainIds), + scriptChainService.getChainsByIds(selectedPostEncodeChainIds) + ]); + + const ensureResolvedChains = (requestedIds, resolvedChains, fieldName) => { + const resolved = Array.isArray(resolvedChains) ? resolvedChains : []; + const resolvedSet = new Set( + resolved + .map((chain) => Number(chain?.id)) + .filter((id) => Number.isFinite(id) && id > 0) + .map((id) => Math.trunc(id)) + ); + const missing = requestedIds.filter((id) => !resolvedSet.has(Number(id))); + if (missing.length === 0) { + return; + } + const error = new Error(`Skriptkette(n) nicht gefunden: ${missing.join(', ')}`); + error.statusCode = 400; + error.details = [{ field: fieldName, message: `Nicht gefunden: ${missing.join(', ')}` }]; + throw error; + }; + ensureResolvedChains(selectedPreEncodeChainIds, selectedPreEncodeChains, 'selectedPreEncodeChainIds'); + ensureResolvedChains(selectedPostEncodeChainIds, selectedPostEncodeChains, 'selectedPostEncodeChainIds'); + + const toScriptDescriptor = (script) => { + const id = Number(script?.id); + const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null; + if (!normalizedId) { + return null; + } + const name = String(script?.name || '').trim() || `Skript #${normalizedId}`; + return { id: normalizedId, name }; + }; + const toChainDescriptor = (chain) => { + const id = Number(chain?.id); + const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null; + if (!normalizedId) { + return null; + } + const name = String(chain?.name || '').trim() || `Kette #${normalizedId}`; + return { id: normalizedId, name }; + }; const resolvedMetadata = { ...metadata, chapters @@ -29009,6 +29645,14 @@ class PipelineService extends EventEmitter { format, formatOptions, metadata: resolvedMetadata, + preEncodeScriptIds: selectedPreEncodeScripts.map((item) => Number(item.id)), + postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)), + preEncodeScripts: selectedPreEncodeScripts.map(toScriptDescriptor).filter(Boolean), + postEncodeScripts: selectedPostEncodeScripts.map(toScriptDescriptor).filter(Boolean), + preEncodeChainIds: selectedPreEncodeChainIds, + postEncodeChainIds: selectedPostEncodeChainIds, + preEncodeChains: selectedPreEncodeChains.map(toChainDescriptor).filter(Boolean), + postEncodeChains: selectedPostEncodeChains.map(toChainDescriptor).filter(Boolean), reviewConfirmed: true }; @@ -29030,7 +29674,9 @@ class PipelineService extends EventEmitter { await historyService.appendLog( normalizedJobId, 'USER_ACTION', - `Audiobook-Encoding konfiguriert: Format ${format.toUpperCase()} | Kapitel: ${chapters.length || 0}` + `Audiobook-Encoding konfiguriert: Format ${format.toUpperCase()} | Kapitel: ${chapters.length || 0} | ` + + `Pre-Skripte: ${selectedPreEncodeScriptIds.length} | Post-Skripte: ${selectedPostEncodeScriptIds.length} | ` + + `Pre-Ketten: ${selectedPreEncodeChainIds.length} | Post-Ketten: ${selectedPostEncodeChainIds.length}` ); const startResult = await this.startPreparedJob(normalizedJobId); @@ -29132,7 +29778,7 @@ class PipelineService extends EventEmitter { ensureDir(path.dirname(incompleteOutputPath)); } - await historyService.resetProcessLog(jobId); + await resetProcessLogIfLifecycleAllows(jobId, job); await this.setState('ENCODING', { activeJobId: jobId, progress: 0, @@ -29621,7 +30267,7 @@ class PipelineService extends EventEmitter { }); } - await historyService.resetProcessLog(jobId); + await resetProcessLogIfLifecycleAllows(jobId, job); await this.setState('ANALYZING', { activeJobId: jobId, progress: 0, @@ -29957,10 +30603,16 @@ class PipelineService extends EventEmitter { } } - async searchMusicBrainz(query) { - logger.info('musicbrainz:search', { query }); - const results = await musicBrainzService.searchByTitle(query); - logger.info('musicbrainz:search:done', { query, count: results.length }); + async searchMusicBrainz(query, options = {}) { + const expectedTrackCountRaw = Number(options?.trackCount); + const expectedTrackCount = Number.isFinite(expectedTrackCountRaw) && expectedTrackCountRaw > 0 + ? Math.trunc(expectedTrackCountRaw) + : null; + logger.info('musicbrainz:search', { query, expectedTrackCount }); + const results = await musicBrainzService.searchByTitle(query, { + trackCount: expectedTrackCount + }); + logger.info('musicbrainz:search:done', { query, expectedTrackCount, count: results.length }); return results; } @@ -30009,6 +30661,13 @@ class PipelineService extends EventEmitter { logger.info('cd:select-metadata', { jobId, title, artist, year, mbId }); const cdInfo = this.safeParseJson(job.makemkv_info_json) || {}; + const encodePlan = this.safeParseJson(job.encode_plan_json) || {}; + const mediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: cdInfo, encodePlan }); + if (mediaProfile !== 'cd') { + const error = new Error(`Job ${jobId} ist kein Audio-CD-Job.`); + error.statusCode = 400; + throw error; + } // Merge track metadata from selection into existing TOC tracks const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : []; @@ -30026,24 +30685,44 @@ class PipelineService extends EventEmitter { }; }); + let effectiveCoverUrl = String(coverUrl || '').trim() || null; + let shouldQueueCoverCache = false; + if (effectiveCoverUrl && !thumbnailService.isLocalUrl(effectiveCoverUrl)) { + try { + const cacheResult = await historyService.cacheAndPromoteExternalPoster(jobId, effectiveCoverUrl, { + source: 'Coverart', + logFailures: false + }); + if (cacheResult?.ok && cacheResult?.localUrl) { + effectiveCoverUrl = cacheResult.localUrl; + } else { + shouldQueueCoverCache = true; + } + } catch (_error) { + shouldQueueCoverCache = true; + } + } + const updatedCdInfo = { ...cdInfo, tracks: mergedTracks, - selectedMetadata: { title, artist, year, mbId, coverUrl } + selectedMetadata: { title, artist, year, mbId, coverUrl: effectiveCoverUrl } }; await historyService.updateJob(jobId, { title: title || null, year: year ? Number(year) : null, - poster_url: coverUrl || null, + poster_url: effectiveCoverUrl || null, + media_type: 'cd', + job_kind: 'cd', status: 'CD_READY_TO_RIP', last_state: 'CD_READY_TO_RIP', makemkv_info_json: JSON.stringify(updatedCdInfo) }); - // Bild in Cache laden (async, blockiert nicht) - if (coverUrl) { - historyService.queuePosterCache(jobId, coverUrl, { + // Fallback: async retry when sync cache was not successful. + if (shouldQueueCoverCache && effectiveCoverUrl && !thumbnailService.isLocalUrl(effectiveCoverUrl)) { + historyService.queuePosterCache(jobId, effectiveCoverUrl, { source: 'Coverart', logFailures: true }); @@ -30056,96 +30735,68 @@ class PipelineService extends EventEmitter { ); const resolvedDevicePath = String(job?.disc_device || '').trim() || null; + const resolvedRawWavDir = String(job?.raw_path || '').trim() || null; const resolvedCdparanoiaCmd = String(cdInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia'; const previewTrackPos = mergedTracks[0]?.position ? Number(mergedTracks[0].position) : null; const cdparanoiaCommandPreview = `${resolvedCdparanoiaCmd} -d ${resolvedDevicePath || ''} ${previewTrackPos || ''} /trackNN.cdda.wav`; const existingDrive = resolvedDevicePath ? this.cdDrives.get(resolvedDevicePath) : null; if (resolvedDevicePath) { + const isVirtualDevicePath = resolvedDevicePath.startsWith('__virtual__'); this._setCdDriveState(resolvedDevicePath, { state: 'CD_READY_TO_RIP', jobId, progress: 0, eta: null, - statusText: 'CD bereit zum Rippen', + statusText: isVirtualDevicePath ? 'CD bereit zum Encodieren' : 'CD bereit zum Rippen', context: { ...(existingDrive?.context || {}), jobId, mediaProfile: 'cd', tracks: mergedTracks, - selectedMetadata: { title, artist, year, mbId, coverUrl }, + selectedMetadata: { title, artist, year, mbId, coverUrl: effectiveCoverUrl }, devicePath: resolvedDevicePath, cdparanoiaCmd: resolvedCdparanoiaCmd, - cdparanoiaCommandPreview + cdparanoiaCommandPreview, + ...(isVirtualDevicePath + ? { + virtualDrivePath: resolvedDevicePath, + skipRip: true, + rawWavDir: resolvedRawWavDir + } + : {}) } }); } else { - // No real device — update virtual drive if present (orphan CD job) + // No real device — create/update virtual drive (orphan CD job) const virtualDrivePath = `__virtual__${jobId}`; const existingVirtualDrive = this.cdDrives.get(virtualDrivePath); - if (existingVirtualDrive) { - this._setCdDriveState(virtualDrivePath, { - state: 'CD_READY_TO_RIP', - jobId, - progress: 0, - eta: null, - statusText: 'CD bereit zum Encodieren', - context: { - ...(existingVirtualDrive.context || {}), - jobId, - mediaProfile: 'cd', - tracks: mergedTracks, - selectedMetadata: { title, artist, year, mbId, coverUrl }, - devicePath: null, - virtualDrivePath, - skipRip: true, - cdparanoiaCmd: resolvedCdparanoiaCmd - } - }); - const snapshotPayload = this.getSnapshot(); - wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); - this.emit('stateChanged', snapshotPayload); - } - } - - const canAutoStartRawRip = Boolean( - resolvedDevicePath - && !String(resolvedDevicePath).startsWith('__virtual__') - && Number(job?.rip_successful || 0) !== 1 - ); - if (canAutoStartRawRip) { - const autoRawRipConfig = { - selectedTracks: mergedTracks - .filter((track) => track?.selected !== false) - .map((track) => Number(track?.position)) - .filter((value) => Number.isFinite(value) && value > 0), - tracks: mergedTracks, - metadata: { title, artist, year, mbId, coverUrl }, - skipEncode: true - }; - await historyService.appendLog( + this._setCdDriveState(virtualDrivePath, { + state: 'CD_READY_TO_RIP', jobId, - 'SYSTEM', - 'Metadaten bestätigt. Starte automatisch RAW-Rip (Encode-Auswahl folgt danach).' - ); - this.startCdRip(Number(jobId), autoRawRipConfig).then((result) => { - logger.info('cd:auto-raw-rip:started', { - jobId: Number(jobId), - replacedSourceJob: Boolean(result?.replacedSourceJob), - replacementJobId: Number(result?.jobId || 0) || null - }); - }).catch((error) => { - logger.error('cd:auto-raw-rip:failed', { - jobId: Number(jobId), - error: errorToMeta(error) - }); - historyService.appendLog( - Number(jobId), - 'SYSTEM', - `Automatischer RAW-Rip konnte nicht gestartet werden: ${error?.message || 'unknown'}` - ).catch(() => {}); + progress: 0, + eta: null, + statusText: 'CD bereit zum Encodieren', + context: { + ...(existingVirtualDrive?.context || {}), + jobId, + mediaProfile: 'cd', + tracks: mergedTracks, + selectedMetadata: { title, artist, year, mbId, coverUrl: effectiveCoverUrl }, + devicePath: null, + virtualDrivePath, + skipRip: true, + rawWavDir: resolvedRawWavDir, + cdparanoiaCmd: resolvedCdparanoiaCmd + } }); } + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Metadaten bestätigt. Rip/Encode kann jetzt manuell über den Start-Button ausgelöst werden.' + ); + return historyService.getJobById(jobId); } @@ -30345,8 +30996,8 @@ class PipelineService extends EventEmitter { year: sourceJob.year ?? null, imdb_id: sourceJob.imdb_id || null, poster_url: sourceJob.poster_url || null, - omdb_json: sourceJob.omdb_json || null, - selected_from_omdb: Number(sourceJob.selected_from_omdb || 0), + omdb_json: null, + selected_from_omdb: 0, status: 'CD_READY_TO_RIP', last_state: 'CD_READY_TO_RIP', error_message: null, @@ -30606,8 +31257,15 @@ class PipelineService extends EventEmitter { } else { cdMetadataBase = buildRawMetadataBase({ title: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null, + artist: effectiveSelectedMeta?.artist || null, year: effectiveSelectedMeta?.year || null - }, activeJobId); + }, activeJobId, { + mediaProfile: 'cd', + selectedMetadata: effectiveSelectedMeta, + artist: effectiveSelectedMeta?.artist || null, + album: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null, + year: effectiveSelectedMeta?.year || null + }); const rawDirName = buildRawDirName(cdMetadataBase, activeJobId, { state: RAW_FOLDER_STATES.INCOMPLETE }); rawJobDir = path.join(cdRawBaseDir, rawDirName); rawWavDir = rawJobDir; @@ -30911,7 +31569,8 @@ class PipelineService extends EventEmitter { ); } let encodeStateApplied = false; - let lastProgressPercent = 0; + let lastRipProgressPercent = 0; + let lastEncodeProgressPercent = 0; const bindProcessHandle = (handle) => { currentProcessHandle = handle && typeof handle === 'object' ? handle : null; sharedHandle.child = currentProcessHandle?.child || null; @@ -30934,18 +31593,18 @@ class PipelineService extends EventEmitter { const normalizedTrackPosition = normalizePositiveInteger(trackPosition) || currentTrackPosition || null; const normalizedTrackEvent = String(trackEvent || '').trim().toLowerCase(); let clampedPercent = Math.max(0, Math.min(100, Number(percent) || 0)); - if (skipEncode && normalizedPhase === 'rip') { - clampedPercent = Math.max(0, Math.min(100, clampedPercent)); - } else if (normalizedPhase === 'rip') { - clampedPercent = Math.min(clampedPercent, 50); + if (normalizedPhase === 'rip') { + if (clampedPercent < lastRipProgressPercent) { + clampedPercent = lastRipProgressPercent; + } + lastRipProgressPercent = clampedPercent; } else { - clampedPercent = Math.max(50, clampedPercent); - } - if (clampedPercent < lastProgressPercent) { - clampedPercent = lastProgressPercent; + if (clampedPercent < lastEncodeProgressPercent) { + clampedPercent = lastEncodeProgressPercent; + } + lastEncodeProgressPercent = clampedPercent; } clampedPercent = Number(clampedPercent.toFixed(2)); - lastProgressPercent = clampedPercent; if (normalizedPhase === 'rip') { currentPhase = 'rip'; @@ -31013,8 +31672,16 @@ class PipelineService extends EventEmitter { const pluginCtx = await this.buildPluginContext(ripPlugin.id, { jobId, emitProgress: (progressPercent, statusText) => { - const fallbackPercent = Math.max(lastProgressPercent, Math.min(100, Number(progressPercent) || 0)); + const fallbackPercentRaw = Math.max(0, Math.min(100, Number(progressPercent) || 0)); const fallbackStage = currentPhase === 'encode' ? 'CD_ENCODING' : 'CD_RIPPING'; + const fallbackPercent = currentPhase === 'encode' + ? Math.max(lastEncodeProgressPercent, fallbackPercentRaw) + : Math.max(lastRipProgressPercent, fallbackPercentRaw); + if (currentPhase === 'encode') { + lastEncodeProgressPercent = fallbackPercent; + } else { + lastRipProgressPercent = fallbackPercent; + } void this.updateProgress(fallbackStage, fallbackPercent, null, statusText || null, processKey, { contextPatch: { cdLive: buildLiveContext(null) } }); @@ -31436,7 +32103,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( job.id, 'SYSTEM', - 'Metadaten sind optional. Review kann direkt gestartet werden; OMDb kann später zugeordnet werden.' + 'Metadaten sind optional. Review kann direkt gestartet werden; TMDb-Zuordnung ist jederzeit nachträglich möglich.' ); } @@ -32539,7 +33206,7 @@ class PipelineService extends EventEmitter { const metadataYear = Number.isFinite(Number(metadata?.year)) ? Math.trunc(Number(metadata.year)) : null; const metadataImdbId = String(metadata?.imdbId || '').trim() || null; const metadataPoster = String(metadata?.poster || '').trim() || null; - const selectedFromOmdb = metadataImdbId ? 1 : 0; + const selectedFromMetadata = 0; const jobPatch = { status: 'READY_TO_START', @@ -32565,7 +33232,7 @@ class PipelineService extends EventEmitter { if (metadataPoster) { jobPatch.poster_url = metadataPoster; } - jobPatch.selected_from_omdb = selectedFromOmdb; + jobPatch.selected_from_omdb = selectedFromMetadata; } await historyService.updateJob(normalizedJobId, jobPatch); @@ -32617,6 +33284,10 @@ class PipelineService extends EventEmitter { const encodePlan = this.safeParseJson(row.encode_plan_json); const handbrakeInfo = this.safeParseJson(row.handbrake_info_json); const makemkvInfo = this.safeParseJson(row.makemkv_info_json); + const liveProgressRaw = this.jobProgress.get(Number(row?.id)); + const liveProgress = liveProgressRaw && typeof liveProgressRaw === 'object' + ? this.safeParseJson(JSON.stringify(liveProgressRaw)) + : null; const rawMediaType = String(row?.media_type || '').trim().toLowerCase(); const planMode = String(encodePlan?.mode || '').trim().toLowerCase(); const planProfile = String(encodePlan?.mediaProfile || '').trim().toLowerCase(); @@ -32633,7 +33304,8 @@ class PipelineService extends EventEmitter { media_type: effectiveMediaType, encodePlan, handbrakeInfo, - makemkvInfo + makemkvInfo, + liveProgress }; }); } diff --git a/backend/src/services/scriptChainService.js b/backend/src/services/scriptChainService.js index d999b96..6293b86 100644 --- a/backend/src/services/scriptChainService.js +++ b/backend/src/services/scriptChainService.js @@ -56,24 +56,27 @@ function mapStepRow(row) { function terminateChildProcess(child, { immediate = false } = {}) { if (!child) { - return; + return false; } const signal = immediate ? 'SIGKILL' : 'SIGTERM'; const pid = Number(child.pid); + let signaled = false; if (Number.isFinite(pid) && pid > 0) { try { // For detached children this targets the full process group. process.kill(-pid, signal); - return; + signaled = true; } catch (_error) { - // Fall through to direct child signal. + // ignore and continue with direct child signal } } try { child.kill(signal); + signaled = true; } catch (_error) { - return; + // ignore } + return signaled; } function appendTailText(currentValue, nextChunk, maxChars = 12000) { @@ -443,6 +446,7 @@ class ScriptChainService { currentStepType: null, activeWaitResolve: null, activeChild: null, + activeProcessHandle: null, activeChildTermination: null }; const emitRuntimeStep = (payload = {}) => { @@ -469,6 +473,23 @@ class ScriptChainService { message: 'Abbruch angefordert', currentStep: controlState.currentStepType ? `Abbruch läuft (${controlState.currentStepType})` : 'Abbruch angefordert' }); + if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') { + controlState.activeWaitResolve('cancel'); + } else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) { + controlState.activeChildTermination = 'cancel'; + let handleCancelled = false; + if (typeof controlState.activeProcessHandle?.cancel === 'function') { + controlState.activeProcessHandle.cancel(); + handleCancelled = true; + } + const signaled = terminateChildProcess(controlState.activeChild, { immediate: true }); + if (!handleCancelled && !signaled) { + logger.warn('chain:cancel:no-signal-sent', { + chainId, + scriptPid: Number(controlState.activeChild?.pid || 0) || null + }); + } + } if (typeof appendLog === 'function') { try { await appendLog('SYSTEM', `Kette "${chain.name}" - Abbruch angefordert.`); @@ -476,12 +497,6 @@ class ScriptChainService { // ignore appendLog failures for control actions } } - if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') { - controlState.activeWaitResolve('cancel'); - } else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) { - controlState.activeChildTermination = 'cancel'; - terminateChildProcess(controlState.activeChild, { immediate: true }); - } return { accepted: true, message: 'Abbruch angefordert.' }; }; const requestNextStep = async () => { @@ -662,6 +677,7 @@ class ScriptChainService { runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line }); } }); + controlState.activeProcessHandle = processHandle; let runError = null; let exitCode = 0; let signal = null; @@ -676,6 +692,7 @@ class ScriptChainService { } const termination = controlState.activeChildTermination; controlState.activeChild = null; + controlState.activeProcessHandle = null; controlState.activeChildTermination = null; if (runError && exitCode === null && !termination) { throw runError; @@ -829,6 +846,7 @@ class ScriptChainService { break; } finally { controlState.activeChild = null; + controlState.activeProcessHandle = null; controlState.activeChildTermination = null; if (prepared?.cleanup) { await prepared.cleanup(); diff --git a/backend/src/services/scriptService.js b/backend/src/services/scriptService.js index 2e28220..74aa523 100644 --- a/backend/src/services/scriptService.js +++ b/backend/src/services/scriptService.js @@ -1,5 +1,4 @@ const fs = require('fs'); -const os = require('os'); const path = require('path'); const { spawn } = require('child_process'); const { getDb } = require('../db/database'); @@ -8,6 +7,7 @@ const settingsService = require('./settingsService'); const runtimeActivityService = require('./runtimeActivityService'); const { streamLines } = require('./processRunner'); const { errorToMeta } = require('../utils/errorMeta'); +const { tempDir } = require('../config'); const SCRIPT_NAME_MAX_LENGTH = 120; const SCRIPT_BODY_MAX_LENGTH = 200000; @@ -532,8 +532,9 @@ class ScriptService { async createExecutableScriptFile(script, context = {}) { const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`; const scriptBody = normalizeScriptBody(script?.scriptBody); - const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ripster-script-')); - const scriptPath = path.join(tempDir, 'script.sh'); + fs.mkdirSync(tempDir, { recursive: true }); + const scriptTempDir = await fs.promises.mkdtemp(path.join(tempDir, 'ripster-script-')); + const scriptPath = path.join(scriptTempDir, 'script.sh'); const wrapped = buildScriptWrapper(scriptBody, { ...context, scriptId: script?.id ?? context?.scriptId ?? '', @@ -548,19 +549,19 @@ class ScriptService { const cleanup = async () => { try { - await fs.promises.rm(tempDir, { recursive: true, force: true }); + await fs.promises.rm(scriptTempDir, { recursive: true, force: true }); } catch (error) { logger.warn('script:temp-cleanup-failed', { scriptId: script?.id ?? null, scriptName: name, - tempDir, + tempDir: scriptTempDir, error: errorToMeta(error) }); } }; return { - tempDir, + tempDir: scriptTempDir, scriptPath, cmd: '/usr/bin/env', args: ['bash', scriptPath], diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 4199aea..88fc2de 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -1,5 +1,4 @@ const fs = require('fs'); -const os = require('os'); const path = require('path'); const { spawn, spawnSync } = require('child_process'); const { getDb } = require('../db/database'); @@ -29,7 +28,8 @@ const { defaultDownloadDir: DEFAULT_DOWNLOAD_DIR, defaultConverterRawDir: DEFAULT_CONVERTER_RAW_DIR, defaultConverterMovieDir: DEFAULT_CONVERTER_MOVIE_DIR, - defaultConverterAudioDir: DEFAULT_CONVERTER_AUDIO_DIR + defaultConverterAudioDir: DEFAULT_CONVERTER_AUDIO_DIR, + tempDir: DEFAULT_TEMP_DIR } = require('../config'); const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac']; @@ -44,11 +44,15 @@ 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' ]); +const LEGACY_HIDDEN_SETTING_KEYS = new Set([ + 'omdb_api_key', + 'omdb_default_type', + 'omdb_timeout_ms' +]); const IMMUTABLE_SETTING_KEYS = new Set([ 'makemkv_command', 'mediainfo_command', @@ -197,6 +201,11 @@ function normalizeBooleanSetting(rawValue, fallback = true) { return toBoolean(normalizedRaw); } +function isHiddenSettingKey(key) { + const normalized = String(key || '').trim().toLowerCase(); + return LEGACY_HIDDEN_SETTING_KEYS.has(normalized); +} + function applyRuntimeServerConsoleLoggingSettingsFromMap(settingsMap = {}) { const source = settingsMap && typeof settingsMap === 'object' ? settingsMap : {}; const enabled = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY], true); @@ -856,7 +865,8 @@ class SettingsService { async getSchemaRows() { const db = await getDb(); - return db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC'); + const rows = await db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC'); + return rows.filter((row) => !isHiddenSettingKey(row?.key)); } async getSettingsMap(options = {}) { @@ -1012,7 +1022,9 @@ class SettingsService { ` ); - return rows.map((row) => ({ + return rows + .filter((row) => !isHiddenSettingKey(row?.key)) + .map((row) => ({ key: row.key, category: row.category, label: row.label, @@ -1025,7 +1037,7 @@ class SettingsService { value: normalizeValueByType(row.type, row.current_value ?? row.default_value), orderIndex: row.order_index, depends_on: row.depends_on ?? null - })); + })); } async getFlatSettings(options = {}) { @@ -1554,7 +1566,8 @@ class SettingsService { } const exportName = `ripster-export-${Date.now()}-${Math.floor(Math.random() * 10000)}`; - const exportFile = path.join(os.tmpdir(), `${exportName}.json`); + fs.mkdirSync(DEFAULT_TEMP_DIR, { recursive: true }); + const exportFile = path.join(DEFAULT_TEMP_DIR, `${exportName}.json`); const args = [ '--scan', '-i', diff --git a/backend/src/services/tempCleanupService.js b/backend/src/services/tempCleanupService.js index ebfc8a0..36901b8 100644 --- a/backend/src/services/tempCleanupService.js +++ b/backend/src/services/tempCleanupService.js @@ -1,9 +1,9 @@ const fs = require('fs'); -const os = require('os'); const path = require('path'); const logger = require('./logger').child('TEMP_CLEANUP'); +const { tempDir } = require('../config'); -const TMP_ROOT = os.tmpdir(); +const TMP_ROOT = tempDir; const SWEEP_INTERVAL_MS = 60 * 60 * 1000; const STALE_ENTRY_MIN_AGE_MS = 12 * 60 * 60 * 1000; @@ -65,6 +65,7 @@ class TempCleanupService { } async runSweep(reason = 'manual') { + fs.mkdirSync(TMP_ROOT, { recursive: true }); const deleted = []; const skipped = []; const failed = []; diff --git a/backend/src/services/tmdbService.js b/backend/src/services/tmdbService.js index bb7db70..766a1cf 100644 --- a/backend/src/services/tmdbService.js +++ b/backend/src/services/tmdbService.js @@ -8,6 +8,49 @@ const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p'; const TMDB_TIMEOUT_MS = 15000; class TmdbService { + hasCreditsPayload(details = null) { + const credits = details?.credits && typeof details.credits === 'object' + ? details.credits + : null; + if (!credits) { + return false; + } + const crew = Array.isArray(credits.crew) ? credits.crew : []; + const cast = Array.isArray(credits.cast) ? credits.cast : []; + return crew.length > 0 || cast.length > 0; + } + + mergeCreditsIntoDetails(details = null, credits = null) { + const sourceDetails = details && typeof details === 'object' ? details : {}; + const sourceCredits = credits && typeof credits === 'object' ? credits : null; + if (!sourceCredits) { + return sourceDetails; + } + return { + ...sourceDetails, + credits: { + ...(sourceDetails?.credits && typeof sourceDetails.credits === 'object' ? sourceDetails.credits : {}), + ...(sourceCredits && typeof sourceCredits === 'object' ? sourceCredits : {}) + } + }; + } + + extractDirectorNames(crewValues = []) { + const crew = Array.isArray(crewValues) ? crewValues : []; + const byJob = this.normalizeNameList( + crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'), + { maxItems: 5 } + ); + if (byJob.length > 0) { + return byJob; + } + // Fallback for edge cases where "Director" job is missing in localized/partial payloads. + return this.normalizeNameList( + crew.filter((member) => String(member?.department || '').trim().toLowerCase() === 'directing'), + { maxItems: 5 } + ); + } + isAbortError(error) { const name = String(error?.name || '').trim().toLowerCase(); const message = String(error?.message || '').trim().toLowerCase(); @@ -103,9 +146,11 @@ class TmdbService { async getConfig() { const settings = await settingsService.getSettingsMap(); + const fallbackLanguages = this.parseLanguageList(settings.dvd_series_fallback_languages); return { readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null, - language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE' + language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE', + fallbackLanguages }; } @@ -119,6 +164,45 @@ class TmdbService { return String(explicitLanguage || config.language || 'de-DE').trim() || 'de-DE'; } + parseLanguageList(value) { + const source = Array.isArray(value) + ? value + : String(value || '').split(','); + const normalized = []; + const seen = new Set(); + for (const item of source) { + const lang = String(item || '').trim(); + if (!lang) { + continue; + } + const dedupeKey = lang.toLowerCase(); + if (seen.has(dedupeKey)) { + continue; + } + seen.add(dedupeKey); + normalized.push(lang); + } + return normalized; + } + + buildLanguageCandidates(primaryLanguage = null, fallbackLanguages = []) { + const preferred = String(primaryLanguage || '').trim() || 'de-DE'; + const fallback = this.parseLanguageList(fallbackLanguages); + const combined = [preferred, ...fallback]; + return this.parseLanguageList(combined); + } + + async resolveLanguageCandidates(options = {}) { + const config = await this.getConfig(); + const explicitLanguage = String(options.language || '').trim() || null; + const explicitFallbackLanguages = options.fallbackLanguages !== undefined + ? this.parseLanguageList(options.fallbackLanguages) + : null; + const primaryLanguage = explicitLanguage || String(config.language || '').trim() || 'de-DE'; + const fallbackLanguages = explicitFallbackLanguages ?? config.fallbackLanguages; + return this.buildLanguageCandidates(primaryLanguage, fallbackLanguages); + } + async request(pathName, options = {}) { const config = await this.getConfig(); if (!config.readAccessToken) { @@ -177,47 +261,135 @@ class TmdbService { if (!normalizedQuery || !(await this.isConfigured())) { return []; } - const language = await this.resolveLanguage(options.language); + const languageCandidates = await this.resolveLanguageCandidates(options); + let lastFailureCode = null; + let lastFailureStatusCode = null; + let lastFailureMessage = null; - let data = null; - try { - data = await this.request('/search/tv', { - query: { + for (const language of languageCandidates) { + let data = null; + try { + 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) { + const failureCode = this.classifyRequestError(error); + lastFailureCode = failureCode; + lastFailureStatusCode = Number(error?.statusCode || 0) || null; + lastFailureMessage = error?.message || String(error); + logger.warn('search:failed', { query: normalizedQuery, - first_air_date_year: options.year || undefined, language, - page: options.page || 1, - include_adult: false - } - }); - } catch (error) { - const failureCode = this.classifyRequestError(error); - logger.warn('search:failed', { - query: normalizedQuery, - error: error?.message || String(error), - failureCode, - statusCode: Number(error?.statusCode || 0) || null - }); - return this.attachFailureCode([], failureCode); - } + error: lastFailureMessage, + failureCode, + statusCode: lastFailureStatusCode + }); + continue; + } - const rows = Array.isArray(data?.results) ? data.results : []; - const normalizedRows = 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); - return this.attachFailureCode(normalizedRows, null); + const rows = Array.isArray(data?.results) ? data.results : []; + const normalizedRows = 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); + if (normalizedRows.length > 0) { + return this.attachFailureCode(normalizedRows, null); + } + } + if (lastFailureCode) { + logger.warn('search:failed:all-languages', { + query: normalizedQuery, + error: lastFailureMessage, + failureCode: lastFailureCode, + statusCode: lastFailureStatusCode + }); + } + return this.attachFailureCode([], lastFailureCode); + } + + async searchMovies(query, options = {}) { + const normalizedQuery = String(query || '').trim(); + if (!normalizedQuery || !(await this.isConfigured())) { + return []; + } + const languageCandidates = await this.resolveLanguageCandidates(options); + let lastFailureCode = null; + let lastFailureStatusCode = null; + let lastFailureMessage = null; + + for (const language of languageCandidates) { + let data = null; + try { + data = await this.request('/search/movie', { + query: { + query: normalizedQuery, + year: options.year || undefined, + language, + page: options.page || 1, + include_adult: false + } + }); + } catch (error) { + const failureCode = this.classifyRequestError(error); + lastFailureCode = failureCode; + lastFailureStatusCode = Number(error?.statusCode || 0) || null; + lastFailureMessage = error?.message || String(error); + logger.warn('movie:search:failed', { + query: normalizedQuery, + language, + error: lastFailureMessage, + failureCode, + statusCode: lastFailureStatusCode + }); + continue; + } + + const rows = Array.isArray(data?.results) ? data.results : []; + const normalizedRows = rows + .map((row) => ({ + id: Number(row?.id || 0) || null, + title: String(row?.title || row?.original_title || '').trim() || null, + originalTitle: String(row?.original_title || '').trim() || null, + year: Number(String(row?.release_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); + if (normalizedRows.length > 0) { + return this.attachFailureCode(normalizedRows, null); + } + } + if (lastFailureCode) { + logger.warn('movie:search:failed:all-languages', { + query: normalizedQuery, + error: lastFailureMessage, + failureCode: lastFailureCode, + statusCode: lastFailureStatusCode + }); + } + return this.attachFailureCode([], lastFailureCode); } async getSeriesDetails(seriesId, options = {}) { @@ -225,24 +397,111 @@ class TmdbService { if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) { return null; } - const language = await this.resolveLanguage(options.language); + const languageCandidates = await this.resolveLanguageCandidates(options); const appendToResponse = Array.isArray(options.appendToResponse) ? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean) : []; - return this.request(`/tv/${Math.trunc(id)}`, { + let fallbackDetails = null; + for (const language of languageCandidates) { + const details = await 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), + language, + error: error?.message || String(error) + }); + return null; + }); + if (!details) { + continue; + } + const hasLocalizedPayload = Boolean( + String(details?.name || '').trim() + || String(details?.overview || '').trim() + ); + if (hasLocalizedPayload) { + return details; + } + fallbackDetails = fallbackDetails || details; + } + return fallbackDetails; + } + + async getMovieDetails(movieId, options = {}) { + const id = Number(movieId); + if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) { + return null; + } + const languageCandidates = await this.resolveLanguageCandidates(options); + const appendToResponse = Array.isArray(options.appendToResponse) + ? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean) + : []; + let fallbackDetails = null; + for (const language of languageCandidates) { + const details = await this.request(`/movie/${Math.trunc(id)}`, { + query: { + language, + append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined + } + }).catch((error) => { + logger.warn('movie:details:failed', { + movieId: Math.trunc(id), + language, + error: error?.message || String(error) + }); + return null; + }); + if (!details) { + continue; + } + const hasLocalizedPayload = Boolean( + String(details?.title || '').trim() + || String(details?.overview || '').trim() + ); + if (hasLocalizedPayload) { + return details; + } + fallbackDetails = fallbackDetails || details; + } + return fallbackDetails; + } + + async getMovieCredits(movieId, options = {}) { + const id = Number(movieId); + if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) { + return null; + } + const language = await this.resolveLanguage(options.language); + return this.request(`/movie/${Math.trunc(id)}/credits`, { query: { - language, - append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined + language } }).catch((error) => { - logger.warn('series:details:failed', { - seriesId: Math.trunc(id), + logger.warn('movie:credits:failed', { + movieId: Math.trunc(id), error: error?.message || String(error) }); return null; }); } + async getMovieDetailsWithCredits(movieId, options = {}) { + const details = await this.getMovieDetails(movieId, options); + if (!details) { + return null; + } + const ensureCredits = options?.ensureCredits === true; + if (!ensureCredits || this.hasCreditsPayload(details)) { + return details; + } + const credits = await this.getMovieCredits(movieId, options); + return this.mergeCreditsIntoDetails(details, credits); + } + async getEpisodeGroups(seriesId) { const id = Number(seriesId); if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) { @@ -263,18 +522,31 @@ class TmdbService { 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) + const languageCandidates = await this.resolveLanguageCandidates(options); + let fallbackDetails = null; + for (const language of languageCandidates) { + const details = await this.request(`/tv/episode_group/${normalizedId}`, { + query: { + language + } + }).catch((error) => { + logger.warn('episode-group:details:failed', { + groupId: normalizedId, + language, + error: error?.message || String(error) + }); + return null; }); - return null; - }); + if (!details) { + continue; + } + const hasLocalizedPayload = Boolean(String(details?.name || '').trim() || String(details?.description || '').trim()); + if (hasLocalizedPayload) { + return details; + } + fallbackDetails = fallbackDetails || details; + } + return fallbackDetails; } async getSeasonDetails(seriesId, seasonNumber, options = {}) { @@ -283,12 +555,28 @@ class TmdbService { 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 + const languageCandidates = await this.resolveLanguageCandidates(options); + let fallbackDetails = null; + for (const language of languageCandidates) { + const details = await this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, { + query: { + language + } + }).catch(() => null); + if (!details) { + continue; } - }).catch(() => null); + const hasLocalizedPayload = Boolean( + String(details?.name || '').trim() + || String(details?.overview || '').trim() + || (Array.isArray(details?.episodes) && details.episodes.length > 0) + ); + if (hasLocalizedPayload) { + return details; + } + fallbackDetails = fallbackDetails || details; + } + return fallbackDetails; } async getSeasonCredits(seriesId, seasonNumber, options = {}) { @@ -343,10 +631,7 @@ class TmdbService { 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 directorNames = this.extractDirectorNames(crew); const creatorNames = this.normalizeNameList(creators, { maxItems: 5 }); const actorNames = this.normalizeNameList(cast, { maxItems: 10 }); const genreNames = this.normalizeNameList(genres, { maxItems: 10 }); @@ -375,6 +660,39 @@ class TmdbService { }; } + buildMovieDetailsSummary(movieDetails = null) { + const details = movieDetails && typeof movieDetails === 'object' ? movieDetails : {}; + const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : []; + const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : []; + const genres = Array.isArray(details?.genres) ? details.genres : []; + const directorNames = this.extractDirectorNames(crew); + 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 || details?.imdb_id || '').trim() || null; + const releaseDate = String(details?.release_date || '').trim() || null; + const runtime = this.formatRuntimeLabel(details?.runtime); + + return { + director: directorNames.length > 0 ? directorNames.join(', ') : null, + actors: actorNames.length > 0 ? actorNames.join(', ') : null, + runtime, + runtimeLabel: runtime, + 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, + releaseDate + }; + } + buildSeriesMetadataCandidate(series = {}, options = {}) { const season = series?.season && typeof series.season === 'object' ? series.season @@ -404,6 +722,35 @@ class TmdbService { }; } + buildMovieMetadataCandidate(movie = {}, options = {}) { + const detailsSummary = options?.detailsSummary && typeof options.detailsSummary === 'object' + ? options.detailsSummary + : null; + const tmdbId = Number(movie?.id || movie?.tmdbId || 0) || null; + const releaseDate = String(movie?.releaseDate || detailsSummary?.releaseDate || '').trim() || null; + const year = Number(movie?.year || Number(String(releaseDate || '').slice(0, 4)) || 0) || null; + const imdbId = String(movie?.imdbId || detailsSummary?.imdbId || '').trim() || null; + const providerId = tmdbId !== null ? `tmdb:${tmdbId}` : null; + + return { + provider: 'tmdb', + providerId, + metadataKind: 'movie', + workflowKind: 'film', + tmdbId, + imdbId, + title: String(movie?.title || movie?.originalTitle || '').trim() || null, + originalTitle: String(movie?.originalTitle || '').trim() || null, + year, + overview: String(movie?.overview || '').trim() || null, + poster: movie?.poster || this.buildImageUrl(movie?.posterPath, 'w342'), + backdrop: movie?.backdrop || this.buildImageUrl(movie?.backdropPath, 'w780'), + runtime: detailsSummary?.runtime || null, + genre: detailsSummary?.genre || null, + voteAverage: detailsSummary?.voteAverage ?? null + }; + } + buildSeasonSummariesFromSeriesDetails(seriesDetails = null) { const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {}; const seasons = Array.isArray(details.seasons) ? details.seasons : []; @@ -483,6 +830,42 @@ class TmdbService { })); return this.attachFailureCode(normalized, failureCode); } + + async searchMoviesWithDetails(query, options = {}) { + const candidates = await this.searchMovies(query, options); + const failureCode = this.readFailureCode(candidates); + if (candidates.length === 0) { + return this.attachFailureCode([], failureCode); + } + + const limit = Math.max(1, Math.min(15, Number(options.limit || 8))); + const selectedCandidates = candidates.slice(0, limit); + const language = await this.resolveLanguage(options.language); + + const expanded = await Promise.all(selectedCandidates.map(async (candidate) => { + const details = await this.getMovieDetailsWithCredits(candidate.id, { + language, + appendToResponse: ['credits', 'external_ids'], + ensureCredits: true + }); + const summary = this.buildMovieDetailsSummary(details); + const normalized = this.buildMovieMetadataCandidate({ + ...candidate, + releaseDate: details?.release_date || null, + imdbId: summary?.imdbId || null, + posterPath: details?.poster_path || candidate?.posterPath || null + }, { + detailsSummary: summary + }); + return { + ...normalized, + tmdbDetails: summary && typeof summary === 'object' ? summary : null + }; + })); + + const normalized = expanded.filter((item) => item?.tmdbId && item?.title); + return this.attachFailureCode(normalized, failureCode); + } } module.exports = new TmdbService(); diff --git a/db/schema.sql b/db/schema.sql index fa678d5..f043db0 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -32,6 +32,7 @@ CREATE TABLE jobs ( poster_url TEXT, omdb_json TEXT, selected_from_omdb INTEGER DEFAULT 0, + migrate_tmdb INTEGER NOT NULL DEFAULT 0, start_time TEXT, end_time TEXT, status TEXT NOT NULL, @@ -429,10 +430,6 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des VALUES ('handbrake_restart_delete_incomplete_output', 'Tools', 'Encode-Neustart: unvollständige Ausgabe löschen', 'boolean', 1, 'Wenn aktiv, wird bei "Encode neu starten" der bisherige (nicht erfolgreiche) Output vor Start entfernt.', 'true', '[]', '{}', 220); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_restart_delete_incomplete_output', 'true'); -INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('handbrake_pre_metadata_scan_enabled', 'Tools', 'Vorab-HandBrake-Scan vor Metadaten', 'boolean', 1, 'Wenn deaktiviert, wird der erste HandBrake-Scan vor der Metadatenauswahl übersprungen.', 'true', '[]', '{}', 221); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_pre_metadata_scan_enabled', 'true'); - INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Max. parallele Film/Video Encodes', 'number', 1, 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.', '1', '[]', '{"min":1,"max":12}', 225); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_jobs', '1'); @@ -635,18 +632,11 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des 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'); -INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) -VALUES ('musicbrainz_enabled', 'Metadaten', 'MusicBrainz aktiviert', 'boolean', 1, 'MusicBrainz-Metadatensuche für CDs aktivieren.', 'true', '[]', '{}', 420); -INSERT OR IGNORE INTO settings_values (key, value) VALUES ('musicbrainz_enabled', 'true'); - INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('coverart_recovery_enabled', 'Metadaten', 'Coverart-Nachladen aktiv', 'boolean', 1, 'Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen.', 'true', '[]', '{}', 430); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true'); diff --git a/docs/configuration/settings-reference.md b/docs/configuration/settings-reference.md index 3516058..7f2dbf7 100644 --- a/docs/configuration/settings-reference.md +++ b/docs/configuration/settings-reference.md @@ -142,9 +142,9 @@ Typische Auswirkungen: | GUI-Feld | Key | Typ | Default | Wirkung im System | |---|---|---|---|---| | `OMDb API Key` | `omdb_api_key` | `string` | `leer` | API Key für Metadatensuche. | -| `TMDb Read Access Token` | `tmdb_api_read_access_token` | `string` | `leer` | Read Access Token für Serien-Metadaten über TheMovieDB (TMDb). | -| `DVD Serien-Sprache` | `dvd_series_language` | `string` | `de-DE` | Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US. | -| `DVD Serien-Fallback-Sprachen` | `dvd_series_fallback_languages` | `string` | `de-DE,en-US` | Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR. | +| `TMDb Read Access Token` | `tmdb_api_read_access_token` | `string` | `leer` | Read Access Token für Film- und Serien-Metadaten über TheMovieDB (TMDb). | +| `Film-Sprache` | `dvd_series_language` | `string` | `de-DE` | Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US. | +| `Fallback Sprache` | `dvd_series_fallback_languages` | `string` | `de-DE,en-US` | Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR. | | `DVD Serien-Ordnung` | `dvd_series_order_preference` | `select` | `episode_group` | Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden. | | `DVD Disc-Profile wiederverwenden` | `dvd_series_reuse_disc_profiles` | `boolean` | `true` | Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen. | | `OMDb Typ` | `omdb_default_type` | `select` | `movie` | Vorauswahl für Suche. | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5bd262..8bed059 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.16.1-6", + "version": "0.16.1-7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.16.1-6", + "version": "0.16.1-7", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 76284f0..cfcbe40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.16.1-6", + "version": "0.16.1-7", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index db3651f..255ad36 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { Navigate, Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom'; import { Button } from 'primereact/button'; -import { ProgressBar } from 'primereact/progressbar'; -import { Tag } from 'primereact/tag'; import { Toast } from 'primereact/toast'; import { ConfirmDialog } from 'primereact/confirmdialog'; import { api } from './api/client'; @@ -14,6 +12,7 @@ import DatabasePage from './pages/DatabasePage'; import DownloadsPage from './pages/DownloadsPage'; import ConverterPage from './pages/ConverterPage'; import AudiobooksPage from './pages/AudiobooksPage'; +import TmdbMigrationPage from './pages/TmdbMigrationPage'; function normalizeJobId(value) { const parsed = Number(value); @@ -23,6 +22,20 @@ function normalizeJobId(value) { return Math.trunc(parsed); } +function extractUploadJobIdFromResponse(response) { + const payload = response && typeof response === 'object' ? response : {}; + const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}; + return ( + normalizeJobId(result?.jobId) + || normalizeJobId(payload?.jobId) + || normalizeJobId(result?.id) + || normalizeJobId(payload?.id) + || normalizeJobId(result?.job?.id) + || normalizeJobId(payload?.job?.id) + || null + ); +} + function clampPercent(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) { @@ -51,25 +64,6 @@ function isTerminalStage(value) { return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED'; } -function formatBytes(value) { - const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed < 0) { - return 'n/a'; - } - if (parsed === 0) { - return '0 B'; - } - const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; - let unitIndex = 0; - let current = parsed; - while (current >= 1024 && unitIndex < units.length - 1) { - current /= 1024; - unitIndex += 1; - } - const digits = unitIndex <= 1 ? 0 : 2; - return `${current.toFixed(digits)} ${units[unitIndex]}`; -} - function createInitialAudiobookUploadState() { return { phase: 'idle', @@ -80,28 +74,14 @@ function createInitialAudiobookUploadState() { statusText: null, errorMessage: null, jobId: null, + uploadSessionId: null, + fileLastModified: null, + fileFingerprint: null, startedAt: null, finishedAt: null }; } -function getAudiobookUploadTagMeta(phase) { - const normalized = String(phase || '').trim().toLowerCase(); - if (normalized === 'uploading') { - return { label: 'Upload läuft', severity: 'warning' }; - } - if (normalized === 'processing') { - return { label: 'Server verarbeitet', severity: 'info' }; - } - if (normalized === 'completed') { - return { label: 'Bereit', severity: 'success' }; - } - if (normalized === 'error') { - return { label: 'Fehler', severity: 'danger' }; - } - return { label: 'Inaktiv', severity: 'secondary' }; -} - function getDownloadIndicatorMeta(summary) { const activeCount = Number(summary?.activeCount || 0); const failedCount = Number(summary?.failedCount || 0); @@ -144,12 +124,13 @@ function App() { const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0); const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0); const [downloadSummary, setDownloadSummary] = useState(null); - const [pendingRipperJobId, setPendingRipperJobId] = useState(null); const location = useLocation(); const navigate = useNavigate(); const globalToastRef = useRef(null); const prevCdDrivesRef = useRef({}); + const prevJobProgressStatesRef = useRef({}); const hardwareWarmupRef = useRef({ timer: null, attempts: 0 }); + const audiobookUploadAbortRef = useRef(null); const clearHardwareWarmupRetry = () => { if (hardwareWarmupRef.current?.timer) { @@ -198,6 +179,39 @@ function App() { prevCdDrivesRef.current = current; }, [pipeline?.cdDrives]); + // Trigger a jobs refresh when a tracked job transitions into a terminal + // state through PIPELINE_PROGRESS, so Ripper/History update immediately. + useEffect(() => { + const current = pipeline?.jobProgress && typeof pipeline.jobProgress === 'object' + ? pipeline.jobProgress + : {}; + const previousStates = prevJobProgressStatesRef.current || {}; + const nextStates = {}; + let shouldRefresh = false; + + for (const [jobId, entry] of Object.entries(current)) { + const currentState = normalizeStage(entry?.state); + const previousState = normalizeStage(previousStates[jobId]); + if (currentState) { + nextStates[jobId] = currentState; + } + if ( + currentState + && isTerminalStage(currentState) + && currentState !== previousState + && !isTerminalStage(previousState) + ) { + shouldRefresh = true; + } + } + + prevJobProgressStatesRef.current = nextStates; + if (shouldRefresh) { + setRipperJobsRefreshToken((token) => token + 1); + setHistoryJobsRefreshToken((token) => token + 1); + } + }, [pipeline?.jobProgress]); + const refreshPipeline = async () => { const response = await api.getPipelineState(); setPipeline(response.pipeline); @@ -221,83 +235,84 @@ function App() { return response; }; - const clearAudiobookUpload = () => { - setAudiobookUpload(createInitialAudiobookUploadState()); - }; - const handleAudiobookUpload = async (file, payload = {}) => { if (!file) { throw new Error('Bitte zuerst eine AAX-Datei auswählen.'); } - - const fallbackTotalBytes = Number.isFinite(Number(file.size)) && Number(file.size) > 0 - ? Number(file.size) - : 0; + const fileName = String(file.name || '').trim() || 'upload.aax'; + const totalBytes = Number(file.size || 0); + const abortController = new AbortController(); + audiobookUploadAbortRef.current = abortController; setAudiobookUpload({ phase: 'uploading', - fileName: String(file.name || '').trim() || 'upload.aax', + fileName, loadedBytes: 0, - totalBytes: fallbackTotalBytes, + totalBytes, progressPercent: 0, statusText: 'AAX-Datei wird hochgeladen ...', errorMessage: null, jobId: null, + uploadSessionId: null, + fileLastModified: Number.isFinite(Number(file.lastModified)) + ? Math.trunc(Number(file.lastModified)) + : null, + fileFingerprint: null, startedAt: new Date().toISOString(), finishedAt: null }); try { const response = await api.uploadAudiobook(file, payload, { + signal: abortController.signal, onProgress: ({ loaded, total, percent }) => { - const nextLoaded = Number.isFinite(Number(loaded)) && Number(loaded) >= 0 - ? Number(loaded) - : 0; - const nextTotal = Number.isFinite(Number(total)) && Number(total) > 0 - ? Number(total) - : fallbackTotalBytes; - const nextPercent = Number.isFinite(Number(percent)) + const loadedBytes = Number.isFinite(Number(loaded)) ? Number(loaded) : 0; + const totalSize = Number.isFinite(Number(total)) && Number(total) > 0 ? Number(total) : totalBytes; + const progressPercent = Number.isFinite(Number(percent)) ? clampPercent(Number(percent)) - : (nextTotal > 0 ? clampPercent((nextLoaded / nextTotal) * 100) : 0); - const transferComplete = nextTotal > 0 && nextLoaded >= nextTotal; - + : (totalSize > 0 ? clampPercent((loadedBytes / totalSize) * 100) : 0); setAudiobookUpload((prev) => ({ ...prev, - phase: transferComplete ? 'processing' : 'uploading', - loadedBytes: nextLoaded, - totalBytes: nextTotal, - progressPercent: nextPercent, - statusText: transferComplete - ? 'Upload abgeschlossen, AAX wird serverseitig verarbeitet ...' - : 'AAX-Datei wird hochgeladen ...' + phase: 'uploading', + loadedBytes, + totalBytes: totalSize, + progressPercent, + statusText: 'AAX-Datei wird hochgeladen ...', + errorMessage: null })); } }); - - const uploadedJobId = normalizeJobId(response?.result?.jobId); + const uploadedJobId = extractUploadJobIdFromResponse(response); + const started = Boolean(response?.result?.started); + const queued = Boolean(response?.result?.queued); await refreshPipeline().catch(() => null); setRipperJobsRefreshToken((prev) => prev + 1); setHistoryJobsRefreshToken((prev) => prev + 1); - if (uploadedJobId) { - setPendingRipperJobId(uploadedJobId); - } - setAudiobookUpload((prev) => ({ ...prev, phase: 'completed', - loadedBytes: prev.totalBytes || prev.loadedBytes || fallbackTotalBytes, - totalBytes: prev.totalBytes || fallbackTotalBytes, + loadedBytes: prev.totalBytes || totalBytes, + totalBytes: prev.totalBytes || totalBytes, progressPercent: 100, statusText: uploadedJobId - ? `Upload abgeschlossen. Job #${uploadedJobId} ist bereit fuer den naechsten Schritt.` + ? ( + queued + ? `Upload abgeschlossen. Job #${uploadedJobId} wurde in die Queue eingereiht.` + : (started + ? `Upload abgeschlossen. Job #${uploadedJobId} wurde gestartet.` + : `Upload abgeschlossen. Job #${uploadedJobId} ist bereit.`) + ) : 'Upload abgeschlossen.', errorMessage: null, jobId: uploadedJobId, finishedAt: new Date().toISOString() })); - return response; } catch (error) { + if (error?.name === 'AbortError') { + setAudiobookUpload(createInitialAudiobookUploadState()); + throw error; + } setAudiobookUpload((prev) => ({ ...prev, phase: 'error', @@ -306,17 +321,20 @@ function App() { finishedAt: new Date().toISOString() })); throw error; + } finally { + if (audiobookUploadAbortRef.current === abortController) { + audiobookUploadAbortRef.current = null; + } } }; - const handleRipperJobFocusConsumed = (jobId) => { - const normalizedJobId = normalizeJobId(jobId); - if (!normalizedJobId) { + const handleCancelAudiobookUpload = () => { + const activeAbortController = audiobookUploadAbortRef.current; + if (activeAbortController) { + activeAbortController.abort(); return; } - setPendingRipperJobId((prev) => ( - normalizeJobId(prev) === normalizedJobId ? null : prev - )); + setAudiobookUpload(createInitialAudiobookUploadState()); }; useEffect(() => { @@ -401,6 +419,11 @@ function App() { ) ) ); + const allowRunningTransitionFromTerminal = Boolean( + normalizedProgressJobId + && incomingIsRunning + && prevActiveJobId === normalizedProgressJobId + ); // Ignore late/stale progress packets that arrive after a terminal state // or regress a job from an interactive/ready state back into an @@ -408,6 +431,7 @@ function App() { if ( normalizedProgressJobId && !incomingIsTerminal + && !allowRunningTransitionFromTerminal && ( prevJobProgressIsTerminal || matchingCdDriveIsTerminal @@ -421,7 +445,9 @@ function App() { if (progressJobId != null) { const previousJobProgress = prev?.jobProgress?.[progressJobId] || {}; const previousJobStage = normalizeStage(previousJobProgress?.state); - const keepPreviousJobStage = isTerminalStage(previousJobStage) && !incomingIsTerminal; + const keepPreviousJobStage = isTerminalStage(previousJobStage) + && !incomingIsTerminal + && !allowRunningTransitionFromTerminal; const mergedJobContext = contextPatch ? { ...(previousJobProgress?.context && typeof previousJobProgress.context === 'object' @@ -445,7 +471,9 @@ function App() { }; } if (progressJobId === prev?.activeJobId || progressJobId == null) { - const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage) && !incomingIsTerminal; + const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage) + && !incomingIsTerminal + && !allowRunningTransitionFromTerminal; next.state = keepPreviousGlobalStage ? prev?.state : (payload.state ?? prev?.state); next.progress = keepPreviousGlobalStage ? prev?.progress : (payload.progress ?? prev?.progress); next.eta = keepPreviousGlobalStage ? prev?.eta : (payload.eta ?? prev?.eta); @@ -613,18 +641,6 @@ function App() { { label: 'Downloads', path: '/downloads' }, ...(expertMode ? [{ label: 'Database', path: '/database' }] : []) ]; - const uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase(); - const showAudiobookUploadBanner = uploadPhase !== 'idle'; - const uploadProgress = clampPercent(audiobookUpload?.progressPercent); - const uploadTagMeta = getAudiobookUploadTagMeta(uploadPhase); - const uploadLoadedBytes = Number(audiobookUpload?.loadedBytes || 0); - const uploadTotalBytes = Number(audiobookUpload?.totalBytes || 0); - const uploadBytesLabel = uploadTotalBytes > 0 - ? `${formatBytes(uploadLoadedBytes)} / ${formatBytes(uploadTotalBytes)}` - : (uploadLoadedBytes > 0 ? `${formatBytes(uploadLoadedBytes)} hochgeladen` : null); - const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error'; - const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId)); - const isAudiobooksRoute = location.pathname === '/audiobooks'; const downloadIndicator = getDownloadIndicatorMeta(downloadSummary); const isNavActive = (path) => { if (path === '/ripper') { @@ -664,57 +680,6 @@ function App() { - {showAudiobookUploadBanner ? ( -
-
-
- Audiobook Upload - -
- {audiobookUpload?.statusText || 'Upload aktiv.'} - {audiobookUpload?.fileName ? Datei: {audiobookUpload.fileName} : null} -
- -
- - - {uploadPhase === 'processing' - ? `100% | ${uploadBytesLabel || 'Upload abgeschlossen'}` - : uploadBytesLabel - ? `${Math.trunc(uploadProgress)}% | ${uploadBytesLabel}` - : `${Math.trunc(uploadProgress)}%`} - -
- -
- {hasUploadedJob && !isAudiobooksRoute ? ( -
-
- ) : null} -
@@ -738,6 +701,7 @@ function App() { } /> } /> + } /> } /> } /> } /> @@ -747,6 +711,7 @@ function App() { } /> diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index fdc8b2d..d26bf26 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -267,6 +267,12 @@ async function requestWithXhr(path, options = {}) { xhr.onerror = () => { settle(() => { + if (Number(xhr.status || 0) === 0) { + const abortLikeError = new Error('Request unterbrochen.'); + abortLikeError.name = 'AbortError'; + reject(abortLikeError); + return; + } reject(new Error('Netzwerkfehler')); }); }; @@ -564,8 +570,8 @@ export const api = { }); return result; }, - searchOmdb(q) { - return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`); + searchTmdbMovie(q) { + return request(`/pipeline/tmdb/movie/search?q=${encodeURIComponent(q)}`); }, searchTmdbSeries(q, seasonNumber = null) { const params = new URLSearchParams(); @@ -575,8 +581,14 @@ export const api = { } return request(`/pipeline/tmdb/series/search?${params.toString()}`); }, - searchMusicBrainz(q) { - return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`); + searchMusicBrainz(q, options = {}) { + const params = new URLSearchParams(); + params.set('q', String(q || '')); + const expectedTrackCount = Number(options?.trackCount); + if (Number.isFinite(expectedTrackCount) && expectedTrackCount > 0) { + params.set('trackCount', String(Math.trunc(expectedTrackCount))); + } + return request(`/pipeline/cd/musicbrainz/search?${params.toString()}`); }, getMusicBrainzRelease(mbId) { return request(`/pipeline/cd/musicbrainz/release/${encodeURIComponent(String(mbId || '').trim())}`); @@ -628,9 +640,6 @@ export const api = { getAudiobookJobs() { return request('/pipeline/audiobook/jobs'); }, - getAudiobookOutputTree() { - return request('/pipeline/audiobook/output-tree'); - }, async selectMetadata(payload) { const result = await request('/pipeline/select-metadata', { method: 'POST', @@ -816,6 +825,24 @@ export const api = { const suffix = query.toString() ? `?${query.toString()}` : ''; return request(`/history${suffix}`); }, + getTmdbMigrationPendingJobs(params = {}) { + const query = new URLSearchParams(); + if (Number.isFinite(Number(params.limit)) && Number(params.limit) > 0) { + query.set('limit', String(Math.trunc(Number(params.limit)))); + } + const suffix = query.toString() ? `?${query.toString()}` : ''; + return request(`/history/tmdb-migration/pending${suffix}`); + }, + async setJobTmdbMigrationFlag(jobId, migrated = false) { + const result = await request(`/history/${jobId}/tmdb-migration`, { + method: 'POST', + body: JSON.stringify({ + migrated: Boolean(migrated) + }) + }); + afterMutationInvalidate(['/history']); + return result; + }, getOrphanRawFolders() { return request('/history/orphan-raw'); }, @@ -827,8 +854,16 @@ export const api = { afterMutationInvalidate(['/history', '/pipeline/queue']); return result; }, - async assignJobOmdb(jobId, payload = {}) { - const result = await request(`/history/${jobId}/omdb/assign`, { + async deleteOrphanRawFolder(rawPath) { + const result = await request('/history/orphan-raw/delete', { + method: 'POST', + body: JSON.stringify({ rawPath }) + }); + afterMutationInvalidate(['/history']); + return result; + }, + async assignJobMetadata(jobId, payload = {}) { + const result = await request(`/history/${jobId}/metadata/assign`, { method: 'POST', body: JSON.stringify(payload || {}) }); diff --git a/frontend/src/components/AudiobookConfigPanel.jsx b/frontend/src/components/AudiobookConfigPanel.jsx index 0560a83..8c70e16 100644 --- a/frontend/src/components/AudiobookConfigPanel.jsx +++ b/frontend/src/components/AudiobookConfigPanel.jsx @@ -3,10 +3,10 @@ import { Dialog } from 'primereact/dialog'; import { Dropdown } from 'primereact/dropdown'; import { Slider } from 'primereact/slider'; import { Button } from 'primereact/button'; -import { ProgressBar } from 'primereact/progressbar'; import { Tag } from 'primereact/tag'; import { InputText } from 'primereact/inputtext'; import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas'; +import { api } from '../api/client'; import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation'; function normalizeJobId(value) { @@ -84,6 +84,108 @@ function normalizeEditableChapters(chapters = []) { }); } +function formatChapterDuration(startSecondsValue, endSecondsValue) { + const startSeconds = Number(startSecondsValue || 0); + const endSeconds = Number(endSecondsValue || 0); + if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds) || endSeconds <= startSeconds) { + return '-'; + } + return formatChapterTime(endSeconds - startSeconds); +} + +function normalizeTrackStageStatus(value) { + const raw = String(value || '').trim().toLowerCase(); + if (raw === 'done' || raw === 'complete' || raw === 'completed' || raw === 'ok' || raw === 'success') { + return 'done'; + } + if (raw === 'in_progress' || raw === 'running' || raw === 'active' || raw === 'processing') { + return 'in_progress'; + } + if (raw === 'error' || raw === 'failed' || raw === 'cancelled' || raw === 'aborted') { + return 'error'; + } + return 'pending'; +} + +function trackStatusTagMeta(value) { + const normalized = normalizeTrackStageStatus(value); + if (normalized === 'done') { + return { label: 'Fertig', severity: 'success' }; + } + if (normalized === 'in_progress') { + return { label: 'Läuft', severity: 'info' }; + } + if (normalized === 'error') { + return { label: 'Fehler', severity: 'danger' }; + } + return { label: 'Offen', severity: 'secondary' }; +} + +function normalizeScriptId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeChainId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function normalizeIdList(values, kind = 'script') { + const list = Array.isArray(values) ? values : []; + const seen = new Set(); + const output = []; + for (const value of list) { + const normalized = kind === 'chain' ? normalizeChainId(value) : normalizeScriptId(value); + if (normalized === null) { + continue; + } + const key = String(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function buildEncodeItemsFromConfig(config, phase) { + const source = config && typeof config === 'object' ? config : {}; + const prefix = phase === 'post' ? 'post' : 'pre'; + const explicitItems = Array.isArray(source[`${prefix}EncodeItems`]) ? source[`${prefix}EncodeItems`] : []; + const fromExplicit = explicitItems + .map((item) => { + const type = String(item?.type || '').trim().toLowerCase(); + if (type !== 'script' && type !== 'chain') { + return null; + } + const id = type === 'chain' + ? normalizeChainId(item?.id ?? item?.chainId) + : normalizeScriptId(item?.id ?? item?.scriptId); + if (!id) { + return null; + } + return { type, id }; + }) + .filter(Boolean); + if (fromExplicit.length > 0) { + return fromExplicit; + } + const scriptIds = normalizeIdList(source[`${prefix}EncodeScriptIds`], 'script'); + const chainIds = normalizeIdList(source[`${prefix}EncodeChainIds`], 'chain'); + return [ + ...scriptIds.map((id) => ({ type: 'script', id })), + ...chainIds.map((id) => ({ type: 'chain', id })) + ]; +} + function FormatField({ field, value, onChange, disabled }) { if (field.type === 'slider') { return ( @@ -128,7 +230,6 @@ export default function AudiobookConfigPanel({ pipeline, onStart, onCancel, - onRetry, onDeleteJob, busy }) { @@ -149,6 +250,18 @@ export default function AudiobookConfigPanel({ const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions)); const [editableChapters, setEditableChapters] = useState(() => normalizeEditableChapters(chapters)); const [descriptionDialogVisible, setDescriptionDialogVisible] = useState(false); + const [scriptCatalog, setScriptCatalog] = useState([]); + const [chainCatalog, setChainCatalog] = useState([]); + const [preRipItems, setPreRipItems] = useState([]); + const [postRipItems, setPostRipItems] = useState([]); + const audiobookConfigKey = JSON.stringify({ + preEncodeScriptIds: normalizeIdList(audiobookConfig?.preEncodeScriptIds, 'script'), + postEncodeScriptIds: normalizeIdList(audiobookConfig?.postEncodeScriptIds, 'script'), + preEncodeChainIds: normalizeIdList(audiobookConfig?.preEncodeChainIds, 'chain'), + postEncodeChainIds: normalizeIdList(audiobookConfig?.postEncodeChainIds, 'chain'), + preEncodeItems: Array.isArray(audiobookConfig?.preEncodeItems) ? audiobookConfig.preEncodeItems : [], + postEncodeItems: Array.isArray(audiobookConfig?.postEncodeItems) ? audiobookConfig.postEncodeItems : [] + }); useEffect(() => { const nextFormat = normalizeFormat(audiobookConfig?.format); @@ -160,13 +273,51 @@ export default function AudiobookConfigPanel({ setEditableChapters(normalizeEditableChapters(chapters)); }, [jobId, JSON.stringify(chapters || [])]); + useEffect(() => { + let cancelled = false; + const loadCatalog = async () => { + try { + const [scriptsResponse, chainsResponse] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]); + if (cancelled) { + return; + } + const scripts = scriptsResponse.status === 'fulfilled' + ? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []) + : []; + const chains = chainsResponse.status === 'fulfilled' + ? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []) + : []; + setScriptCatalog(scripts.map((item) => ({ id: item?.id, name: item?.name }))); + setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name }))); + } catch (_error) { + if (!cancelled) { + setScriptCatalog([]); + setChainCatalog([]); + } + } + }; + void loadCatalog(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + setPreRipItems(buildEncodeItemsFromConfig(audiobookConfig, 'pre')); + setPostRipItems(buildEncodeItemsFromConfig(audiobookConfig, 'post')); + }, [jobId, audiobookConfigKey]); + const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3; - const canStart = Boolean(jobId) && (state === 'READY_TO_START' || state === 'ERROR' || state === 'CANCELLED'); + const canStart = Boolean(jobId) && ( + state === 'READY_TO_START' + || state === 'READY_TO_ENCODE' + || state === 'ERROR' + || state === 'CANCELLED' + ); const isRunning = state === 'ENCODING'; const isFinished = state === 'FINISHED'; - const progress = Number.isFinite(Number(pipeline?.progress)) ? Math.max(0, Math.min(100, Number(pipeline.progress))) : 0; - const outputPath = String(context?.outputPath || '').trim() || null; const isSplitOutput = format === 'mp3' || format === 'flac'; + const showEditableChapters = !(isSplitOutput && isRunning); const currentChapter = context?.currentChapter && typeof context.currentChapter === 'object' ? context.currentChapter : null; const completedChapterCount = Number(context?.completedChapterCount ?? -1); const chapterTotal = Number(currentChapter?.total || editableChapters.length || 0); @@ -182,6 +333,88 @@ export default function AudiobookConfigPanel({ [schema, formatOptions] ); + const moveEncodeItem = (phase, index, direction) => { + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const list = Array.isArray(prev) ? [...prev] : []; + const from = Number(index); + const to = from + (direction === 'up' ? -1 : 1); + if (!Number.isInteger(from) || from < 0 || from >= list.length || to < 0 || to >= list.length) { + return list; + } + const [moved] = list.splice(from, 1); + list.splice(to, 0, moved); + return list; + }); + }; + + const addEncodeItem = (phase, type) => { + const normalizedType = type === 'chain' ? 'chain' : 'script'; + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const selectedIds = new Set( + current + .filter((item) => item?.type === normalizedType) + .map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const catalog = normalizedType === 'chain' ? chainCatalog : scriptCatalog; + const candidate = (Array.isArray(catalog) ? catalog : []) + .map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id)) + .find((id) => id !== null && !selectedIds.has(String(id))); + if (candidate === undefined || candidate === null) { + return current; + } + return [...current, { type: normalizedType, id: candidate }]; + }); + }; + + const changeEncodeItem = (phase, index, type, nextId) => { + const normalizedType = type === 'chain' ? 'chain' : 'script'; + const normalizedId = normalizedType === 'chain' ? normalizeChainId(nextId) : normalizeScriptId(nextId); + if (normalizedId === null) { + return; + } + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const rowIndex = Number(index); + if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) { + return current; + } + const duplicate = current.some((item, itemIndex) => { + if (itemIndex === rowIndex) { + return false; + } + if (item?.type !== normalizedType) { + return false; + } + const existingId = normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id); + return existingId !== null && String(existingId) === String(normalizedId); + }); + if (duplicate) { + return current; + } + const next = [...current]; + next[rowIndex] = { type: normalizedType, id: normalizedId }; + return next; + }); + }; + + const removeEncodeItem = (phase, index) => { + const updater = phase === 'post' ? setPostRipItems : setPreRipItems; + updater((prev) => { + const current = Array.isArray(prev) ? prev : []; + const rowIndex = Number(index); + if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) { + return current; + } + return current.filter((_, itemIndex) => itemIndex !== rowIndex); + }); + }; + return (
@@ -217,11 +450,8 @@ export default function AudiobookConfigPanel({
-
+
- - {metadata?.durationMs ? : null} - {posterUrl ? : null}
@@ -263,78 +493,316 @@ export default function AudiobookConfigPanel({ -
-

Kapitel

- {editableChapters.length === 0 ? ( - Keine Kapitel in der Quelle erkannt. - ) : ( -
- {editableChapters.map((chapter, index) => ( -
-
- #{chapter.index || index + 1} - - {formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)} - + {showEditableChapters ? ( +
+

Kapitel

+ {editableChapters.length === 0 ? ( + Keine Kapitel in der Quelle erkannt. + ) : ( +
+ {editableChapters.map((chapter, index) => ( +
+
+ #{chapter.index || index + 1} + + {formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)} + +
+ { + const nextTitle = event.target.value; + setEditableChapters((prev) => prev.map((entry, entryIndex) => ( + entryIndex === index + ? { ...entry, title: nextTitle } + : entry + ))); + }} + disabled={busy || isRunning} + />
- { - const nextTitle = event.target.value; - setEditableChapters((prev) => prev.map((entry, entryIndex) => ( - entryIndex === index - ? { ...entry, title: nextTitle } - : entry - ))); - }} - disabled={busy || isRunning} - /> -
- ))} -
- )} -
+ ))} +
+ )} +
+ ) : null}
- {isRunning ? ( -
- - {Math.trunc(progress)}%{currentChapter ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}: ${currentChapter.title}` : ''} -
- ) : null} - {isSplitOutput && (isRunning || isFinished) && editableChapters.length > 0 ? (
Kapitel-Status ({completedChapterCount >= 0 ? completedChapterCount : (isFinished ? editableChapters.length : 0)}/{chapterTotal || editableChapters.length} fertig) -
- {editableChapters.map((chapter, idx) => { - const chIdx = chapter.index || idx + 1; - const isDone = isFinished || (completedChapterCount >= 0 && chIdx <= completedChapterCount); - const isActive = !isDone && currentChapter?.index === chIdx; - const statusIcon = isDone ? 'pi pi-check-circle' : isActive ? 'pi pi-spin pi-spinner' : 'pi pi-circle'; - const statusClass = isDone ? 'chapter-status-done' : isActive ? 'chapter-status-active' : 'chapter-status-pending'; - return ( -
- - #{String(chIdx).padStart(2, '0')} - {chapter.title} -
- ); - })} +
+
+ + + + + + + + + + + + + {editableChapters.map((chapter, idx) => { + const chIdx = chapter.index || idx + 1; + const isDone = isFinished || (completedChapterCount >= 0 && chIdx <= completedChapterCount); + const isActive = !isDone && currentChapter?.index === chIdx; + const ripMeta = trackStatusTagMeta('done'); + const encodeMeta = trackStatusTagMeta(isDone ? 'done' : (isActive ? 'in_progress' : 'pending')); + return ( + + + + + + + + + ); + })} + +
AuswahlNrTitelLängeRipEncode
Ja{String(chIdx).padStart(2, '0')}{chapter.title || '-'}{formatChapterDuration(chapter.startSeconds, chapter.endSeconds)}
+
) : null} - {outputPath ? ( -
- Ausgabe: {outputPath} +
+
+

Pre-Rip Ausführungen (optional)

+ {scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( + Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. + ) : null} + {preRipItems.length === 0 ? ( + Keine Pre-Rip Ausführungen ausgewählt. + ) : null} + {preRipItems.map((item, rowIndex) => { + const isScript = item?.type === 'script'; + const usedScriptIds = new Set( + preRipItems + .filter((entry, index) => entry?.type === 'script' && index !== rowIndex) + .map((entry) => normalizeScriptId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const usedChainIds = new Set( + preRipItems + .filter((entry, index) => entry?.type === 'chain' && index !== rowIndex) + .map((entry) => normalizeChainId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const scriptOptions = scriptCatalog.map((entry) => ({ + label: entry?.name || `Skript #${entry?.id}`, + value: normalizeScriptId(entry?.id), + disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id))) + })).filter((entry) => entry.value !== null); + const chainOptions = chainCatalog.map((entry) => ({ + label: entry?.name || `Kette #${entry?.id}`, + value: normalizeChainId(entry?.id), + disabled: usedChainIds.has(String(normalizeChainId(entry?.id))) + })).filter((entry) => entry.value !== null); + return ( +
+ +
+
+ {isScript ? ( + changeEncodeItem('pre', rowIndex, 'script', event.value)} + className="full-width" + disabled={busy} + /> + ) : ( + changeEncodeItem('pre', rowIndex, 'chain', event.value)} + className="full-width" + disabled={busy} + /> + )} +
+ ); + })} +
+ {scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? ( +
+ Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der Encode abgebrochen.
- ) : null} -
+
+

Post-Rip Ausführungen (optional)

+ {scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( + Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. + ) : null} + {postRipItems.length === 0 ? ( + Keine Post-Rip Ausführungen ausgewählt. + ) : null} + {postRipItems.map((item, rowIndex) => { + const isScript = item?.type === 'script'; + const usedScriptIds = new Set( + postRipItems + .filter((entry, index) => entry?.type === 'script' && index !== rowIndex) + .map((entry) => normalizeScriptId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const usedChainIds = new Set( + postRipItems + .filter((entry, index) => entry?.type === 'chain' && index !== rowIndex) + .map((entry) => normalizeChainId(entry?.id)) + .filter((id) => id !== null) + .map((id) => String(id)) + ); + const scriptOptions = scriptCatalog.map((entry) => ({ + label: entry?.name || `Skript #${entry?.id}`, + value: normalizeScriptId(entry?.id), + disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id))) + })).filter((entry) => entry.value !== null); + const chainOptions = chainCatalog.map((entry) => ({ + label: entry?.name || `Kette #${entry?.id}`, + value: normalizeChainId(entry?.id), + disabled: usedChainIds.has(String(normalizeChainId(entry?.id))) + })).filter((entry) => entry.value !== null); + return ( +
+ +
+
+ {isScript ? ( + changeEncodeItem('post', rowIndex, 'script', event.value)} + className="full-width" + disabled={busy} + /> + ) : ( + changeEncodeItem('post', rowIndex, 'chain', event.value)} + className="full-width" + disabled={busy} + /> + )} +
+ ); + })} +
+ {scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? ( +
+ Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander. +
+
+ +
{canStart ? (
= 1024 && index < units.length - 1) { - current /= 1024; - index += 1; - } - return `${current.toFixed(index <= 1 ? 0 : 1)} ${units[index]}`; -} - -function formatDateTime(value) { - if (!value) { - return '-'; - } - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return '-'; - } - return parsed.toLocaleString('de-DE'); -} - -function getNodeByPath(root, targetPath) { - if (!root) { - return null; - } - if ((root.path || '') === (targetPath || '')) { - return root; - } - for (const child of (root.children || [])) { - if (child.type !== 'folder') { - continue; - } - const found = getNodeByPath(child, targetPath); - if (found) { - return found; - } - } - return null; -} - -function listChildren(node) { - if (!node || !Array.isArray(node.children)) { - return []; - } - return node.children; -} - -function buildBreadcrumb(pathValue) { - if (!pathValue) { - return []; - } - const parts = String(pathValue).split('/').filter(Boolean); - return parts.map((part, index) => ({ - name: part, - path: parts.slice(0, index + 1).join('/') - })); -} - -function filterFolderTree(node, query) { - if (!node || node.type !== 'folder') { - return null; - } - if (!query || !query.trim()) { - return node; - } - const normalized = query.toLowerCase(); - const children = (node.children || []) - .filter((child) => child.type === 'folder') - .map((child) => filterFolderTree(child, query)) - .filter(Boolean); - const nameMatches = String(node.name || '').toLowerCase().includes(normalized); - if (nameMatches || children.length > 0) { - return { ...node, children }; - } - return null; -} - -function defaultExpandedSet(tree) { - const next = new Set(['']); - const firstLevel = Array.isArray(tree?.children) ? tree.children : []; - for (const entry of firstLevel) { - if (entry?.type === 'folder' && entry?.path) { - next.add(entry.path); - } - } - return next; -} - -export default function AudiobookOutputExplorer({ refreshToken = 0 }) { - const [tree, setTree] = useState(null); - const [outputDir, setOutputDir] = useState(null); - const [loading, setLoading] = useState(false); - const [errorMessage, setErrorMessage] = useState(''); - const [currentPath, setCurrentPath] = useState(''); - const [expandedFolders, setExpandedFolders] = useState(() => new Set([''])); - const [sidebarQuery, setSidebarQuery] = useState(''); - - const loadTree = useCallback(async () => { - setLoading(true); - setErrorMessage(''); - try { - const response = await api.getAudiobookOutputTree(); - const nextTree = response?.tree || null; - setTree(nextTree); - setOutputDir(response?.outputDir || null); - setExpandedFolders(defaultExpandedSet(nextTree)); - setCurrentPath(''); - } catch (error) { - setTree(null); - setErrorMessage(error?.message || 'Explorer konnte nicht geladen werden.'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void loadTree(); - }, [loadTree, refreshToken]); - - const navigateTo = (pathValue) => { - const normalized = String(pathValue || '').trim(); - const node = getNodeByPath(tree, normalized); - if (node && node.type === 'folder') { - setCurrentPath(normalized); - } - }; - - const toggleFolder = (pathValue) => { - const normalized = String(pathValue || '').trim(); - setExpandedFolders((prev) => { - const next = new Set(prev); - if (next.has(normalized)) { - next.delete(normalized); - } else { - next.add(normalized); - } - return next; - }); - }; - - const renderTreeNode = (node, depth = 0) => { - if (!node || node.type !== 'folder') { - return null; - } - const key = node.path || ''; - const isExpanded = expandedFolders.has(key); - const isCurrent = currentPath === key; - const children = Array.isArray(node.children) ? node.children.filter((entry) => entry.type === 'folder') : []; - - return ( -
- - {isExpanded && children.map((child) => renderTreeNode(child, depth + 1))} -
- ); - }; - - const filteredTree = useMemo(() => filterFolderTree(tree, sidebarQuery), [tree, sidebarQuery]); - const currentNode = getNodeByPath(tree, currentPath); - const currentChildren = listChildren(currentNode); - const breadcrumb = buildBreadcrumb(currentPath); - - if (loading && !tree) { - return ( -
- -
- ); - } - - if (!tree) { - return ( -
- {errorMessage ? ( - {errorMessage} - ) : ( - Kein Audiobook-Output vorhanden. Ausgabepfad: {outputDir || 'nicht konfiguriert'} - )} -
-
-
- ); - } - - return ( -
-
-
- setSidebarQuery(event.target.value)} - className="sidebar-search" - /> -
-
- {filteredTree ? renderTreeNode(filteredTree, 0) : Keine Ordner gefunden.} -
-
- -
-
-
-
- -
-
- Name - Größe - Geändert -
- - {currentChildren.length === 0 ? ( -
- Keine Einträge in diesem Ordner. - - -
- ) : ( - currentChildren.map((entry) => ( - - )) - )} -
- -
- - Root: {outputDir || '-'} - -
-
-
- ); -} diff --git a/frontend/src/components/AudiobookUploadPanel.jsx b/frontend/src/components/AudiobookUploadPanel.jsx index 3b207f7..9a0b6f0 100644 --- a/frontend/src/components/AudiobookUploadPanel.jsx +++ b/frontend/src/components/AudiobookUploadPanel.jsx @@ -1,7 +1,6 @@ -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { FileUpload } from 'primereact/fileupload'; import { Button } from 'primereact/button'; -import { Tag } from 'primereact/tag'; import { ProgressBar } from 'primereact/progressbar'; import { Toast } from 'primereact/toast'; @@ -32,15 +31,30 @@ function normalizeJobId(value) { return Math.trunc(parsed); } +function extractUploadJobIdFromResponse(response) { + const payload = response && typeof response === 'object' ? response : {}; + const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}; + return ( + normalizeJobId(result?.jobId) + || normalizeJobId(payload?.jobId) + || normalizeJobId(result?.id) + || normalizeJobId(payload?.id) + || normalizeJobId(result?.job?.id) + || normalizeJobId(payload?.job?.id) + || null + ); +} + export default function AudiobookUploadPanel({ audiobookUpload, onAudiobookUpload, + onCancelUpload = null, onUploaded = null }) { const toastRef = useRef(null); const fileUploadRef = useRef(null); + const fallbackFileInputRef = useRef(null); const [uploadFile, setUploadFile] = useState(null); - const [statusVisible, setStatusVisible] = useState(false); const phase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase(); const uploadBusy = phase === 'uploading' || phase === 'processing'; @@ -49,40 +63,15 @@ export default function AudiobookUploadPanel({ : 0; const loadedBytes = Number(audiobookUpload?.loadedBytes || 0); const totalBytes = Number(audiobookUpload?.totalBytes || 0); - const fileName = String(audiobookUpload?.fileName || '').trim() - || String(uploadFile?.name || '').trim() - || null; - const statusTone = phase === 'error' - ? 'danger' - : phase === 'completed' - ? 'success' - : phase === 'processing' - ? 'info' - : phase === 'uploading' - ? 'warning' - : 'secondary'; - const statusLabel = phase === 'uploading' - ? 'Upload laeuft' - : phase === 'processing' - ? 'Server verarbeitet' - : phase === 'completed' - ? 'Bereit' - : phase === 'error' - ? 'Fehler' - : 'Inaktiv'; - - useEffect(() => { - if (phase === 'idle') { - setStatusVisible(false); - return; - } - setStatusVisible(true); - if (phase === 'completed') { - const timer = setTimeout(() => setStatusVisible(false), 5000); - return () => clearTimeout(timer); - } - return undefined; - }, [phase]); + const progressLabel = phase === 'processing' + ? '100% | Upload fertig, Job wird vorbereitet ...' + : totalBytes > 0 + ? `${Math.trunc(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}` + : `${Math.trunc(progress)}%`; + const hasHeaderStatus = phase !== 'idle' || Boolean(uploadFile); + const canStartUpload = Boolean(uploadFile) && !uploadBusy; + const canCancelUpload = uploadBusy && typeof onCancelUpload === 'function'; + const canClearSelection = Boolean(uploadFile) && !uploadBusy; const handleUpload = async () => { if (!uploadFile) { @@ -96,7 +85,7 @@ export default function AudiobookUploadPanel({ } try { const response = await onAudiobookUpload?.(uploadFile, { startImmediately: false }); - const uploadedJobId = normalizeJobId(response?.result?.jobId); + const uploadedJobId = extractUploadJobIdFromResponse(response); if (uploadedJobId) { toastRef.current?.show({ severity: 'success', @@ -116,6 +105,15 @@ export default function AudiobookUploadPanel({ fileUploadRef.current?.clear?.(); onUploaded?.(uploadedJobId, response); } catch (error) { + if (error?.name === 'AbortError') { + toastRef.current?.show({ + severity: 'info', + summary: 'Upload abgebrochen', + detail: 'Der Audiobook-Upload wurde gestoppt.', + life: 2800 + }); + return; + } toastRef.current?.show({ severity: 'error', summary: 'Upload fehlgeschlagen', @@ -125,6 +123,72 @@ export default function AudiobookUploadPanel({ } }; + const handleFileSelected = (file) => { + if (!file) { + setUploadFile(null); + return; + } + setUploadFile(file); + }; + + const handleFileCleared = () => { + setUploadFile(null); + }; + + const handleChooseFile = () => { + if (uploadBusy) { + return; + } + fallbackFileInputRef.current?.click(); + }; + + const handleFileItemAction = (onRemove = null) => { + if (canCancelUpload) { + void onCancelUpload(); + onRemove?.(); + fileUploadRef.current?.clear?.(); + handleFileCleared(); + return; + } + onRemove?.(); + fileUploadRef.current?.clear?.(); + handleFileCleared(); + }; + + const renderFileRow = (file, onRemove = null) => ( +
+ +
+ {file?.name || 'upload.aax'} + {formatBytes(Number(file?.size || 0))} + {hasHeaderStatus ? ( +
+ <> + {progressLabel} + + {audiobookUpload?.statusText ? ( + {audiobookUpload.statusText} + ) : null} + +
+ ) : null} +
+
+ ); + return (
@@ -135,66 +199,91 @@ export default function AudiobookUploadPanel({ customUpload uploadHandler={() => void handleUpload()} disabled={uploadBusy} - onSelect={(event) => setUploadFile(event.files[0] || null)} - onClear={() => setUploadFile(null)} - onRemove={() => setUploadFile(null)} + onSelect={(event) => { + handleFileSelected(event.files[0] || null); + }} + onClear={() => { + handleFileCleared(); + }} + onRemove={() => { + handleFileCleared(); + }} chooseOptions={{ icon: 'pi pi-images', iconOnly: true, className: 'p-button-rounded p-button-outlined' }} uploadOptions={{ icon: 'pi pi-cloud-upload', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-success' }} cancelOptions={{ icon: 'pi pi-times', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-danger' }} - itemTemplate={(file, options) => ( -
- -
- {file.name} - {options.formatSize} + headerTemplate={(options) => ( +
+
+
+
-
)} + itemTemplate={(file, options) => ( + renderFileRow(file, () => { + options.onRemove?.(); + }) + )} emptyTemplate={() => ( -
- -

AAX-Datei hier ablegen

- oder oben "Auswaehlen" klicken -
+ uploadFile + ? renderFileRow(uploadFile, () => { + // handled by handleFileItemAction + }) + : ( +
+ +

AAX-Datei hier ablegen

+ oder oben "Auswaehlen" klicken +
+ ) )} /> - - {statusVisible ? ( -
-
- {statusLabel} - -
- {audiobookUpload?.statusText ? {audiobookUpload.statusText} : null} - {fileName ? ( - - Datei: {fileName} - - ) : null} -
- - - {phase === 'processing' - ? '100% | Upload fertig, Job wird vorbereitet ...' - : totalBytes > 0 - ? `${Math.trunc(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}` - : `${Math.trunc(progress)}%`} - -
-
- ) : null} + { + const file = event.target?.files?.[0] || null; + handleFileSelected(file); + if (event.target) { + event.target.value = ''; + } + }} + />
); } diff --git a/frontend/src/components/CdMetadataDialog.jsx b/frontend/src/components/CdMetadataDialog.jsx index 67d93a9..139dc97 100644 --- a/frontend/src/components/CdMetadataDialog.jsx +++ b/frontend/src/components/CdMetadataDialog.jsx @@ -142,7 +142,10 @@ export default function CdMetadataDialog({ const searchRunId = searchRunRef.current + 1; searchRunRef.current = searchRunId; try { - const searchResults = await onSearch(trimmedQuery); + const expectedTrackCount = Array.isArray(tocTracks) ? tocTracks.length : 0; + const searchResults = await onSearch(trimmedQuery, { + trackCount: expectedTrackCount > 0 ? expectedTrackCount : null + }); const normalizedResults = Array.isArray(searchResults) ? searchResults : []; await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl))); if (searchRunRef.current !== searchRunId) { @@ -264,6 +267,7 @@ export default function CdMetadataDialog({ emptyMessage="Keine Treffer" > + diff --git a/frontend/src/components/CdRipConfigPanel.jsx b/frontend/src/components/CdRipConfigPanel.jsx index e9a7c8c..1633b58 100644 --- a/frontend/src/components/CdRipConfigPanel.jsx +++ b/frontend/src/components/CdRipConfigPanel.jsx @@ -239,6 +239,7 @@ export default function CdRipConfigPanel({ const selectedMeta = context.selectedMetadata || {}; const state = String(pipeline?.state || '').trim().toUpperCase(); const jobId = normalizePosition(context?.jobId); + const isPostRipWorkflow = context?.skipRip === true; const isRipping = state === 'CD_RIPPING' || state === 'CD_ENCODING'; const isFinished = state === 'FINISHED'; @@ -662,6 +663,7 @@ export default function CdRipConfigPanel({ || '' ) || null; const devicePath = normalizeTrackText(context?.devicePath) || '-'; + const rawPath = normalizeTrackText(context?.rawPath) || '-'; const outputPath = normalizeTrackText(context?.outputPath) || '-'; const formatValue = String(cdRipConfig?.format || '').trim().toLowerCase(); const formatLabel = (Array.isArray(CD_FORMATS) @@ -795,7 +797,9 @@ export default function CdRipConfigPanel({ displayValueTemplate={(value) => formatProgressLabel(value)} /> - {`Fortschritt: ${formatProgressLabel(wholeProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode`} + {isPostRipWorkflow + ? `Fortschritt: ${formatProgressLabel(wholeProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode` + : `Fortschritt: ${formatProgressLabel(wholeProgress)}`} {eta ? `ETA ${eta}` : 'ETA unbekannt'}
@@ -892,25 +896,30 @@ export default function CdRipConfigPanel({
Jahr: {albumYear}
MusicBrainz: {musicBrainzId}
Status: {stateLabel}
-
Format: {formatLabel}
-
Rip fertig: {completedRipCount} / {selectedTrackRows.length || effectiveTrackRows.length}
-
Encode fertig: {completedEncodeCount} / {selectedTrackRows.length || effectiveTrackRows.length}
-
Aktueller Track: {liveCurrentTrackPosition ? String(liveCurrentTrackPosition).padStart(2, '0') : '-'}
-
Pre-Skripte: {preScriptNames.length > 0 ? preScriptNames.join(' | ') : '-'}
-
Pre-Ketten: {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}
-
Post-Skripte: {postScriptNames.length > 0 ? postScriptNames.join(' | ') : '-'}
-
Post-Ketten: {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}
-
Auswahl: {selectedTrackNumbers || '-'}
-
Gesamtdauer: {formatTotalDuration(selectedTrackDurationSec)}
-
Laufwerk: {devicePath}
-
Output-Pfad: {outputPath}
+ {!isRipping ?
Format: {formatLabel}
: null} + {isPostRipWorkflow ? ( + <> +
Rip fertig: {completedRipCount} / {selectedTrackRows.length || effectiveTrackRows.length}
+
Encode fertig: {completedEncodeCount} / {selectedTrackRows.length || effectiveTrackRows.length}
+
Aktueller Track: {liveCurrentTrackPosition ? String(liveCurrentTrackPosition).padStart(2, '0') : '-'}
+
Pre-Skripte: {preScriptNames.length > 0 ? preScriptNames.join(' | ') : '-'}
+
Pre-Ketten: {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}
+
Post-Skripte: {postScriptNames.length > 0 ? postScriptNames.join(' | ') : '-'}
+
Post-Ketten: {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}
+
Auswahl: {selectedTrackNumbers || '-'}
+
Gesamtdauer: {formatTotalDuration(selectedTrackDurationSec)}
+ + ) : null} + {!isRipping ?
Laufwerk: {devicePath}
: null} +
RAW-Pfad: {rawPath}
+ {!isRipping ?
Output-Pfad: {outputPath}
: null} {lastState ?
Letzter Pipeline-State: {lastStateLabel}
: null} - {jobId ?
Job-ID: #{jobId}
: null} + {!isRipping && jobId ?
Job-ID: #{jobId}
: null}
- {displayTrackRows.length > 0 ? ( + {isPostRipWorkflow && displayTrackRows.length > 0 ? (
Zu rippende Tracks ({displayTrackRows.length})
@@ -951,6 +960,58 @@ export default function CdRipConfigPanel({ ); } + if (!isPostRipWorkflow) { + return ( +
+
+ + {statusText || 'Bereit'} +
+
+ CD-Details +
+
Album: {albumTitle}
+
Interpret: {albumArtist}
+
Jahr: {albumYear}
+
MusicBrainz: {musicBrainzId}
+
Status: {stateLabel}
+
Laufwerk: {devicePath}
+
RAW-Pfad: {rawPath}
+
Output-Pfad: {outputPath}
+ {lastState ?
Letzter Pipeline-State: {lastStateLabel}
: null} + {jobId ?
Job-ID: #{jobId}
: null} +
+
+
+
+
+ ); + } + return (

CD-Rip Konfiguration

@@ -1082,15 +1143,16 @@ export default function CdRipConfigPanel({
) : null} -
-

Pre-Rip Ausführungen (optional)

- {scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( - Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. - ) : null} - {preRipItems.length === 0 ? ( - Keine Pre-Rip Ausführungen ausgewählt. - ) : null} - {preRipItems.map((item, rowIndex) => { +
+
+

Pre-Rip Ausführungen (optional)

+ {scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( + Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. + ) : null} + {preRipItems.length === 0 ? ( + Keine Pre-Rip Ausführungen ausgewählt. + ) : null} + {preRipItems.map((item, rowIndex) => { const isScript = item?.type === 'script'; const usedScriptIds = new Set( preRipItems @@ -1116,8 +1178,8 @@ export default function CdRipConfigPanel({ value: normalizeChainId(entry?.id), disabled: usedChainIds.has(String(normalizeChainId(entry?.id))) })).filter((entry) => entry.value !== null); - return ( -
+ return ( +
- ); - })} -
- {scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? ( -
+ ); + })} +
+ {scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? ( +
+ Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen.
- Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen. -
-
-

Post-Rip Ausführungen (optional)

- {scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( - Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. - ) : null} - {postRipItems.length === 0 ? ( - Keine Post-Rip Ausführungen ausgewählt. - ) : null} - {postRipItems.map((item, rowIndex) => { +
+

Post-Rip Ausführungen (optional)

+ {scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( + Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. + ) : null} + {postRipItems.length === 0 ? ( + Keine Post-Rip Ausführungen ausgewählt. + ) : null} + {postRipItems.map((item, rowIndex) => { const isScript = item?.type === 'script'; const usedScriptIds = new Set( postRipItems @@ -1229,8 +1291,8 @@ export default function CdRipConfigPanel({ value: normalizeChainId(entry?.id), disabled: usedChainIds.has(String(normalizeChainId(entry?.id))) })).filter((entry) => entry.value !== null); - return ( -
+ return ( +
- ); - })} -
- {scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? ( -
+ ); + })} +
+ {scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? ( +
+ Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.
- Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.
{/* Actions */} diff --git a/frontend/src/components/ConverterJobCard.jsx b/frontend/src/components/ConverterJobCard.jsx index f4a3eb8..f82ade6 100644 --- a/frontend/src/components/ConverterJobCard.jsx +++ b/frontend/src/components/ConverterJobCard.jsx @@ -154,6 +154,59 @@ function deriveAudioTracks(plan) { return []; } +function normalizePositiveInt(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function buildVideoMetadataPayload(sourceMetadata, fallbackTitle = '') { + const source = sourceMetadata && typeof sourceMetadata === 'object' ? sourceMetadata : {}; + const normalized = { + title: String(source?.title || fallbackTitle || '').trim() || null, + year: Number.isFinite(Number(source?.year)) ? Math.trunc(Number(source.year)) : null, + imdbId: String(source?.imdbId || '').trim() || null, + poster: String(source?.poster || '').trim() || null + }; + + const metadataProvider = String(source?.metadataProvider || '').trim() || null; + const workflowKind = String(source?.workflowKind || '').trim() || null; + const providerId = String(source?.providerId || '').trim() || null; + const metadataKind = String(source?.metadataKind || '').trim() || null; + const seasonName = String(source?.seasonName || '').trim() || null; + const imdbRating = String(source?.imdbRating || '').trim() || null; + const tmdbId = normalizePositiveInt(source?.tmdbId); + const seasonNumber = normalizePositiveInt(source?.seasonNumber); + const discNumber = normalizePositiveInt(source?.discNumber); + const episodeCount = Number(source?.episodeCount || 0); + const voteAverageRaw = Number(source?.voteAverage || 0); + const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0 + ? Number(voteAverageRaw.toFixed(1)) + : null; + const episodes = Array.isArray(source?.episodes) ? source.episodes : []; + const tmdbDetails = source?.tmdbDetails && typeof source.tmdbDetails === 'object' && !Array.isArray(source.tmdbDetails) + ? source.tmdbDetails + : null; + + if (metadataProvider) normalized.metadataProvider = metadataProvider; + if (workflowKind) normalized.workflowKind = workflowKind; + if (providerId) normalized.providerId = providerId; + if (metadataKind) normalized.metadataKind = metadataKind; + if (tmdbId) normalized.tmdbId = tmdbId; + if (seasonNumber) normalized.seasonNumber = seasonNumber; + if (seasonName) normalized.seasonName = seasonName; + if (discNumber) normalized.discNumber = discNumber; + if (Number.isFinite(episodeCount) && episodeCount > 0) normalized.episodeCount = Math.trunc(episodeCount); + if (episodes.length > 0) normalized.episodes = episodes; + if (voteAverage !== null) normalized.voteAverage = voteAverage; + if (imdbRating) normalized.imdbRating = imdbRating; + if (tmdbDetails) normalized.tmdbDetails = tmdbDetails; + + return normalized; +} + // ── Status helpers ──────────────────────────────────────────────────────────── function mediaTypeBadge(type) { @@ -260,10 +313,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh // ── Video metadata ────────────────────────────────────────────────────────── const [videoMetadata, setVideoMetadata] = useState(() => ({ - title: String(planMetadata?.title || job.title || job.detected_title || '').trim(), - year: Number.isFinite(Number(planMetadata?.year)) ? Math.trunc(Number(planMetadata.year)) : null, - imdbId: String(planMetadata?.imdbId || '').trim() || null, - poster: String(planMetadata?.poster || '').trim() || null + ...buildVideoMetadataPayload( + planMetadata, + String(job.title || job.detected_title || '').trim() + ) })); // ── Script / Chain catalog ────────────────────────────────────────────────── @@ -378,10 +431,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh coverUrl: coverUrl || null } : { - title: String(videoMetadata?.title || job.title || job.detected_title || '').trim() || null, - year: Number.isFinite(Number(videoMetadata?.year)) ? Math.trunc(Number(videoMetadata.year)) : null, - imdbId: String(videoMetadata?.imdbId || '').trim() || null, - poster: String(videoMetadata?.poster || '').trim() || null + ...buildVideoMetadataPayload( + videoMetadata, + String(job.title || job.detected_title || '').trim() + ) }; return { @@ -436,12 +489,13 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh jobId: job.id, detectedTitle: videoMetadata?.title || job.title || job.detected_title || '', selectedMetadata: { + ...videoMetadata, title: videoMetadata?.title || job.title || job.detected_title || '', year: videoMetadata?.year || null, imdbId: videoMetadata?.imdbId || null, poster: videoMetadata?.poster || null }, - omdbCandidates: [] + metadataCandidates: [] }), [job.id, job.title, job.detected_title, videoMetadata]); const cdMetadataDialogContext = useMemo(() => ({ @@ -454,31 +508,28 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh }), [job.id, audioTracks, trackFields]); // ── Dialog handlers ───────────────────────────────────────────────────────── - const handleOmdbSearch = async (query) => { + const handleMetadataSearch = async (query) => { try { - const response = await api.searchOmdb(query); + const response = await api.searchTmdbMovie(query); return Array.isArray(response?.results) ? response.results : []; } catch { return []; } }; - const handleOmdbSubmit = async (payload) => { + const handleMetadataSubmit = async (payload) => { setSearchDialogBusy(true); try { - setVideoMetadata({ - title: String(payload?.title || job.title || job.detected_title || '').trim(), - year: Number.isFinite(Number(payload?.year)) ? Math.trunc(Number(payload.year)) : null, - imdbId: String(payload?.imdbId || '').trim() || null, - poster: String(payload?.poster || '').trim() || null - }); + setVideoMetadata(buildVideoMetadataPayload(payload, String(job.title || job.detected_title || '').trim())); setMetadataDialogVisible(false); } finally { setSearchDialogBusy(false); } }; - const handleMusicBrainzSearchDialog = async (query) => { + const handleMusicBrainzSearchDialog = async (query, options = {}) => { try { - const response = await api.searchMusicBrainz(query); + const response = await api.searchMusicBrainz(query, { + trackCount: Number(options?.trackCount || 0) > 0 ? Math.trunc(Number(options.trackCount)) : null + }); return Array.isArray(response?.results) ? response.results : []; } catch { return []; } }; @@ -642,10 +693,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh coverUrl: coverUrl || null } : { - title: String(videoMetadata?.title || job.title || job.detected_title || '').trim() || null, - year: Number.isFinite(Number(videoMetadata?.year)) ? Math.trunc(Number(videoMetadata.year)) : null, - imdbId: String(videoMetadata?.imdbId || '').trim() || null, - poster: String(videoMetadata?.poster || '').trim() || null + ...buildVideoMetadataPayload( + videoMetadata, + String(job.title || job.detected_title || '').trim() + ) }; await api.startConverterJob(job.id, { @@ -880,8 +931,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh )} {/* Pre / Post Ausführungen */} - {renderEncodeSection('pre', preItems)} - {renderEncodeSection('post', postItems)} +
+ {renderEncodeSection('pre', preItems)} + {renderEncodeSection('post', postItems)} +
{/* Aktionen */}
@@ -1025,8 +1078,8 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh visible={metadataDialogVisible} context={metadataDialogContext} onHide={() => setMetadataDialogVisible(false)} - onSubmit={handleOmdbSubmit} - onSearch={handleOmdbSearch} + onSubmit={handleMetadataSubmit} + onSearch={handleMetadataSearch} busy={searchDialogBusy} />
diff --git a/frontend/src/components/CronJobsTab.jsx b/frontend/src/components/CronJobsTab.jsx index 1917944..4d6decf 100644 --- a/frontend/src/components/CronJobsTab.jsx +++ b/frontend/src/components/CronJobsTab.jsx @@ -6,6 +6,7 @@ import { Dropdown } from 'primereact/dropdown'; import { InputSwitch } from 'primereact/inputswitch'; import { Toast } from 'primereact/toast'; import { api } from '../api/client'; +import { useWebSocket } from '../hooks/useWebSocket'; import { confirmModal } from '../utils/confirmModal'; // ── Hilfsfunktionen ────────────────────────────────────────────────────────── @@ -61,7 +62,7 @@ const EMPTY_FORM = { // ── Hauptkomponente ─────────────────────────────────────────────────────────── -export default function CronJobsTab({ onWsMessage }) { +export default function CronJobsTab() { const toastRef = useRef(null); const [jobs, setJobs] = useState([]); @@ -89,6 +90,7 @@ export default function CronJobsTab({ onWsMessage }) { // Aktionen Busy-State per Job-ID const [busyId, setBusyId] = useState(null); + const wsReloadTimerRef = useRef(null); // ── Daten laden ────────────────────────────────────────────────────────────── @@ -135,6 +137,16 @@ export default function CronJobsTab({ onWsMessage }) { }; }, []); + const scheduleLoadAll = (delayMs = 120) => { + if (wsReloadTimerRef.current) { + return; + } + wsReloadTimerRef.current = setTimeout(() => { + wsReloadTimerRef.current = null; + void loadAll(); + }, delayMs); + }; + const activeCronRunByJobId = useMemo(() => { const map = new Map(); for (const item of activeCronRuns) { @@ -146,11 +158,28 @@ export default function CronJobsTab({ onWsMessage }) { return map; }, [activeCronRuns]); - // WebSocket: Cronjob-Updates empfangen - useEffect(() => { - if (!onWsMessage) return; - // onWsMessage ist eine Funktion, die wir anmelden - }, [onWsMessage]); + useEffect(() => () => { + if (wsReloadTimerRef.current) { + clearTimeout(wsReloadTimerRef.current); + wsReloadTimerRef.current = null; + } + }, []); + + useWebSocket({ + onMessage: (message) => { + const type = String(message?.type || '').trim().toUpperCase(); + if (!type) { + return; + } + if (type === 'RUNTIME_ACTIVITY_CHANGED') { + setActiveCronRuns(normalizeActiveCronRuns(message?.payload)); + return; + } + if (type === 'CRON_JOBS_UPDATED' || type === 'CRON_JOB_UPDATED') { + scheduleLoadAll(80); + } + } + }); // ── Cron-Ausdruck validieren (debounced) ───────────────────────────────────── diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index 64bfe6e..9be3f90 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -28,7 +28,6 @@ const GENERAL_TOOL_KEYS = new Set([ 'mkvmerge_command', 'ffprobe_command', 'handbrake_restart_delete_incomplete_output', - 'handbrake_pre_metadata_scan_enabled', 'script_test_timeout_ms' ]); @@ -58,7 +57,8 @@ const ALWAYS_HIDDEN_SETTING_KEYS = new Set([ 'makemkv_rip_mode', 'makemkv_rip_mode_bluray', 'makemkv_rip_mode_dvd', - 'makemkv_backup_mode' + 'makemkv_backup_mode', + 'handbrake_pre_metadata_scan_enabled' ]); const EXPERT_ONLY_SETTING_KEYS = new Set([ 'pushover_device', @@ -78,6 +78,9 @@ const EXPERT_ONLY_SETTING_KEYS = new Set([ 'cdparanoia_command', 'script_test_timeout_ms' ]); +const EXPERT_ONLY_CATEGORY_NAMES = new Set([ + 'logging' +]); function toBoolean(value) { if (typeof value === 'boolean') { @@ -436,6 +439,14 @@ function filterSettingsByVisibility(settings, expertModeEnabled) { return list.filter((setting) => !shouldHideSettingByExpertMode(setting?.key, expertModeEnabled)); } +function shouldHideCategoryByExpertMode(categoryName, expertModeEnabled) { + const normalizedCategory = normalizeText(categoryName); + if (!normalizedCategory) { + return false; + } + return !expertModeEnabled && EXPERT_ONLY_CATEGORY_NAMES.has(normalizedCategory); +} + function buildToolSections(settings) { const list = Array.isArray(settings) ? settings : []; const generalBucket = { @@ -1118,6 +1129,7 @@ export default function DynamicSettingsForm({ const safeCategories = Array.isArray(categories) ? categories : []; const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]); const visibleCategories = safeCategories + .filter((category) => !shouldHideCategoryByExpertMode(category?.category, expertModeEnabled)) .map((category) => ({ ...category, settings: filterSettingsByVisibility(category?.settings, expertModeEnabled) diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index aae6cf8..38777c5 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -641,24 +641,39 @@ function statusBadgeMeta(status, queued = false, options = {}) { if (normalized === 'READY_TO_ENCODE' || normalized === 'READY_TO_START') { return { label, icon: 'pi-play-circle', tone: 'info' }; } + if (normalized === 'CD_READY_TO_RIP') { + return { label, icon: 'pi-play-circle', tone: 'info' }; + } if (normalized === 'WAITING_FOR_USER_DECISION') { return { label, icon: 'pi-exclamation-circle', tone: 'warning' }; } if (normalized === 'METADATA_SELECTION') { return { label, icon: 'pi-list', tone: 'warning' }; } + if (normalized === 'CD_METADATA_SELECTION') { + return { label, icon: 'pi-list', tone: 'warning' }; + } if (normalized === 'ANALYZING') { return { label, icon: 'pi-search', tone: 'warning' }; } + if (normalized === 'CD_ANALYZING') { + return { label, icon: 'pi-search', tone: 'warning' }; + } if (normalized === 'RIPPING') { return { label, icon: 'pi-download', tone: 'warning' }; } + if (normalized === 'CD_RIPPING') { + return { label, icon: 'pi-download', tone: 'warning' }; + } if (normalized === 'MEDIAINFO_CHECK') { return { label, icon: 'pi-sliders-h', tone: 'warning' }; } if (normalized === 'ENCODING') { return { label, icon: 'pi-cog', tone: 'warning' }; } + if (normalized === 'CD_ENCODING') { + return { label, icon: 'pi-cog', tone: 'warning' }; + } if (normalized === 'MERGING') { return { label, icon: 'pi-spinner pi-spin', tone: 'warning' }; } @@ -669,6 +684,25 @@ function normalizePathForCompare(value) { return String(value || '').trim().replace(/[\\/]+$/, ''); } +function isIncompleteRawPath(value) { + const normalized = String(value || '').trim().replace(/[\\]+/g, '/'); + if (!normalized) { + return false; + } + const segments = normalized.split('/').filter(Boolean); + const baseName = segments[segments.length - 1] || ''; + return /^incomplete_/i.test(baseName); +} + +function isIncompleteOutputPath(value) { + const normalized = String(value || '').trim().replace(/[\\]+/g, '/'); + if (!normalized) { + return false; + } + return /(^|\/)incomplete_job-\d+(\/|$)/i.test(normalized) + || /(^|\/)incomplete_merge_[^/]+_job_\d+(\/|$)/i.test(normalized); +} + function buildMergeToolLogFallback(job = null) { const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object' ? job.handbrakeInfo @@ -695,19 +729,13 @@ function buildMergeToolLogFallback(job = null) { return lines.join('\n'); } -function omdbField(value) { +function metadataField(value) { const raw = String(value || '').trim(); return raw || '-'; } -function omdbRottenTomatoesScore(omdbInfo) { - const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : []; - const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === 'rotten tomatoes'); - return omdbField(entry?.Value); -} - function normalizeMetadataProvider(value) { - return String(value || '').trim().toLowerCase() || 'omdb'; + return String(value || '').trim().toLowerCase() || 'tmdb'; } function resolveJobMetadataContext(job) { @@ -734,7 +762,7 @@ function resolveJobMetadataContext(job) { const metadataProvider = normalizeMetadataProvider( mergedSelectedMetadata?.metadataProvider || analyzeContext?.metadataProvider - || 'omdb' + || 'tmdb' ); return { analyzeContext, @@ -744,7 +772,13 @@ function resolveJobMetadataContext(job) { } function uniqueNameList(values, maxItems = 10) { - const source = Array.isArray(values) ? values : []; + const source = Array.isArray(values) + ? values + : (typeof values === 'string' + ? values.split(',') + : (values && typeof values === 'object' + ? [values] + : [])); const limit = Math.max(1, Number(maxItems || 10)); const output = []; const seen = new Set(); @@ -787,53 +821,34 @@ function formatRuntimeLabel(value) { return text || null; } -function resolveMetadataDetailsForDisplay(job, omdbInfo = {}) { +function resolveMetadataDetailsForDisplay(job) { const metadataContext = resolveJobMetadataContext(job); - const provider = metadataContext.metadataProvider; const selectedMetadata = metadataContext.selectedMetadata; const tmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object' ? selectedMetadata.tmdbDetails : {}; - const isTmdbProvider = provider === 'tmdb' || provider === 'themoviedb'; - - if (isTmdbProvider) { - const genre = uniqueNameList(tmdbDetails?.genres || tmdbDetails?.genre, 3).join(', ') || null; - const actors = uniqueNameList(tmdbDetails?.seasonCast || tmdbDetails?.actors, 6).join(', ') || null; - const director = uniqueNameList(tmdbDetails?.createdBy || tmdbDetails?.director, 3).join(', ') || null; - const runtime = tmdbDetails?.seasonRuntime || tmdbDetails?.runtime || formatRuntimeLabel(selectedMetadata?.episodeRunTime); - const ratingNumber = Number(tmdbDetails?.seasonVoteAverage ?? tmdbDetails?.voteAverage ?? selectedMetadata?.voteAverage); - const tmdbRating = Number.isFinite(ratingNumber) && ratingNumber > 0 - ? ratingNumber.toFixed(1) - : null; - const imdbId = String(selectedMetadata?.imdbId || tmdbDetails?.imdbId || '').trim() || null; - const hasMatch = Boolean(selectedMetadata?.tmdbId || tmdbDetails?.tmdbId); - - return { - provider: 'tmdb', - title: 'TMDb Details', - matchLabel: 'TMDb Match', - hasMatch, - imdbId, - director: director || '-', - actors: actors || '-', - runtime: runtime || '-', - genre: genre || '-', - tmdbRating: tmdbRating || '-' - }; - } + const genre = uniqueNameList(tmdbDetails?.genres || tmdbDetails?.genre, 3).join(', ') || null; + const actors = uniqueNameList(tmdbDetails?.seasonCast || tmdbDetails?.actors, 6).join(', ') || null; + const director = uniqueNameList(tmdbDetails?.createdBy || tmdbDetails?.director, 3).join(', ') || null; + const runtime = tmdbDetails?.seasonRuntime || tmdbDetails?.runtime || formatRuntimeLabel(selectedMetadata?.episodeRunTime); + const ratingNumber = Number(tmdbDetails?.seasonVoteAverage ?? tmdbDetails?.voteAverage ?? selectedMetadata?.voteAverage); + const tmdbRating = Number.isFinite(ratingNumber) && ratingNumber > 0 + ? ratingNumber.toFixed(1) + : null; + const imdbId = String(selectedMetadata?.imdbId || tmdbDetails?.imdbId || '').trim() || null; + const hasMatch = Boolean(selectedMetadata?.tmdbId || tmdbDetails?.tmdbId); return { - provider: 'omdb', - title: 'OMDb Details', - matchLabel: 'OMDb Match', - hasMatch: Boolean(job?.selected_from_omdb), - imdbId: String(job?.imdb_id || '').trim() || null, - director: omdbField(omdbInfo?.Director), - actors: omdbField(omdbInfo?.Actors), - runtime: omdbField(omdbInfo?.Runtime), - genre: omdbField(omdbInfo?.Genre), - rottenTomatoes: omdbRottenTomatoesScore(omdbInfo), - imdbRating: omdbField(omdbInfo?.imdbRating) + provider: 'tmdb', + title: 'TMDb Details', + matchLabel: 'TMDb Match', + hasMatch, + imdbId, + director: director || '-', + actors: actors || '-', + runtime: runtime || '-', + genre: genre || '-', + tmdbRating: tmdbRating || '-' }; } @@ -1058,7 +1073,7 @@ export default function JobDetailDialog({ detailLoading = false, onLoadLog, logLoadingMode = null, - onAssignOmdb, + onAssignMetadata, onAssignCdMetadata, onGenerateNfo, onAcknowledgeError, @@ -1076,7 +1091,7 @@ export default function JobDetailDialog({ onRestoreMultipartMerge, onCancel, isQueued = false, - omdbAssignBusy = false, + metadataAssignBusy = false, cdMetadataAssignBusy = false, acknowledgeErrorBusy = false, generateNfoBusy = false, @@ -1234,8 +1249,7 @@ export default function JobDetailDialog({ job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && !running - && mediaType !== 'audiobook' - && !blockMetadataAndReviewUntilRetry + && (isAudiobook || !blockMetadataAndReviewUntilRetry) && typeof onRestartReview === 'function' ); const converterPlan = isConverter && job?.encodePlan && typeof job.encodePlan === 'object' @@ -1328,9 +1342,8 @@ export default function JobDetailDialog({ queueLocked, { label: isMultipartMergeJob && statusUpper === 'ENCODING' ? 'Merging' : '' } ); - const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {}; const metadataContext = resolveJobMetadataContext(job); - const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo); + const metadataDetails = resolveMetadataDetailsForDisplay(job); const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null; const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt'; const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container'; @@ -1455,17 +1468,15 @@ export default function JobDetailDialog({ && Number(multipartMergeSummary?.inputReady || 0) >= Number(multipartMergeSummary?.inputExpected || 0) ); const displayedImdbId = metadataDetails?.imdbId || job?.imdb_id || null; - const metadataJsonTitle = metadataDetails.provider === 'tmdb' ? 'TMDb Info' : 'OMDb Info'; - const metadataJsonValue = metadataDetails.provider === 'tmdb' - ? (metadataContext?.selectedMetadata?.tmdbDetails || metadataContext?.selectedMetadata || null) - : job?.omdbInfo; + const metadataJsonTitle = 'TMDb Info'; + const metadataJsonValue = metadataContext?.selectedMetadata?.tmdbDetails || metadataContext?.selectedMetadata || null; const metadataAssignProvider = isDvdSeries ? 'tmdb' - : (metadataDetails.provider === 'tmdb' ? 'tmdb' : 'omdb'); - const metadataAssignButtonLabel = metadataAssignProvider === 'tmdb' ? 'TMDb neu zuweisen' : 'OMDb neu zuweisen'; + : 'tmdb'; + const metadataAssignButtonLabel = 'TMDb neu zuweisen'; const metadataAssignActionDescription = metadataAssignProvider === 'tmdb' - ? 'Öffnet die TMDb-Suche, um Serien-Metadaten (Titel, Staffel, Episoden, Poster) neu zuzuweisen.' - : 'Öffnet die OMDb-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.'; + ? 'Öffnet die TMDb-Suche, um Metadaten (Titel, Staffel/Episoden bzw. Film) neu zuzuweisen.' + : 'Öffnet die TMDb-Suche, um Metadaten neu zuzuweisen.'; const configuredSelection = buildConfiguredScriptAndChainSelection(job); const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0 || configuredSelection.postScriptIds.length > 0 @@ -1490,8 +1501,24 @@ export default function JobDetailDialog({ || '' ).trim() || null; const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null; - const canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.exists && typeof onDownloadArchive === 'function'); - const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function'); + const jobMediaType = resolveMediaType(job); + const isDiscMediaJob = ['dvd', 'bluray', 'cd'].includes(jobMediaType); + const jobStatusUpper = String(job?.status || '').trim().toUpperCase(); + const outputDownloadReady = Boolean( + job?.output_path + && job?.outputStatus?.exists + && !isIncompleteOutputPath(job.output_path) + && (job?.encodeSuccess || jobStatusUpper === 'FINISHED') + ); + const rawDownloadReady = Boolean( + resolvedRawPath + && job?.rawStatus?.exists + && job?.rawStatus?.isEmpty !== true + && !isIncompleteRawPath(resolvedRawPath) + && (!isDiscMediaJob || Boolean(job?.ripSuccessful)) + ); + const canDownloadRaw = Boolean(rawDownloadReady && typeof onDownloadArchive === 'function'); + const canDownloadOutput = Boolean(outputDownloadReady && typeof onDownloadArchive === 'function'); const outputFolders = (() => { const baseFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : []; if (!isDiskContainer) { @@ -1507,7 +1534,9 @@ export default function JobDetailDialog({ seen.add(outputPath); merged.push({ id: folder?.id ?? fallbackId ?? outputPath, - output_path: outputPath + output_path: outputPath, + job_id: normalizePositiveInteger(folder?.job_id ?? folder?.jobId ?? null) || null, + exists: folder?.exists === undefined ? undefined : Boolean(folder?.exists) }); }; for (const folder of baseFolders) { @@ -1516,11 +1545,17 @@ export default function JobDetailDialog({ for (const child of childJobs) { const childOutputFolders = Array.isArray(child?.outputFolders) ? child.outputFolders : []; for (const childFolder of childOutputFolders) { - addFolder(childFolder, `child-folder-${child?.id || 'x'}-${String(childFolder?.output_path || '')}`); + addFolder( + { + ...childFolder, + job_id: childFolder?.job_id ?? child?.id ?? null + }, + `child-folder-${child?.id || 'x'}-${String(childFolder?.output_path || '')}` + ); } if (child?.output_path) { addFolder( - { output_path: child.output_path }, + { output_path: child.output_path, job_id: child?.id ?? null }, `child-output-${child?.id || 'x'}-${String(child.output_path || '')}` ); } @@ -1843,37 +1878,24 @@ export default function JobDetailDialog({
Regisseur: - {omdbField(metadataDetails.director)} + {metadataField(metadataDetails.director)}
Schauspieler: - {omdbField(metadataDetails.actors)} + {metadataField(metadataDetails.actors)}
Laufzeit: - {omdbField(metadataDetails.runtime)} + {metadataField(metadataDetails.runtime)}
Genre: - {omdbField(metadataDetails.genre)} + {metadataField(metadataDetails.genre)} +
+
+ TMDb Rating: + {metadataField(metadataDetails.tmdbRating)}
- {metadataDetails.provider === 'tmdb' ? ( -
- TMDb Rating: - {omdbField(metadataDetails.tmdbRating)} -
- ) : ( - <> -
- Rotten Tomatoes: - {omdbField(metadataDetails.rottenTomatoes)} -
-
- imdbRating: - {omdbField(metadataDetails.imdbRating)} -
- - )}
) : null} @@ -1954,7 +1976,16 @@ export default function JobDetailDialog({ const childDiscNumber = resolveSeriesDiscNumber(child); const childLabel = childDiscNumber ? `Disk ${childDiscNumber} RAW:` : 'Disk RAW:'; const childRawPath = child?.raw_path || child?.output_path || null; - const canDownloadChildRaw = Boolean(childRawPath && child?.rawStatus?.exists && typeof onDownloadArchive === 'function'); + const childMediaType = resolveMediaType(child); + const childIsDiscMedia = ['dvd', 'bluray', 'cd'].includes(childMediaType); + const childRawDownloadReady = Boolean( + childRawPath + && child?.rawStatus?.exists + && child?.rawStatus?.isEmpty !== true + && !isIncompleteRawPath(childRawPath) + && (!childIsDiscMedia || Boolean(child?.ripSuccessful)) + ); + const canDownloadChildRaw = Boolean(childRawDownloadReady && typeof onDownloadArchive === 'function'); return ( normalizePositiveInteger(child?.id) === folderOwnerJobId) || job); + const folderOwnerStatusUpper = String(folderOwner?.status || '').trim().toUpperCase(); + const folderExists = folder?.exists !== undefined + ? Boolean(folder?.exists) + : Boolean(folderOwner?.outputStatus?.exists); + const folderDownloadReady = Boolean( + folderExists + && !isIncompleteOutputPath(folderPath) + && (folderOwner?.encodeSuccess || folderOwnerStatusUpper === 'FINISHED') + ); const normalizedFolderPath = normalizePathForCompare(folderPath); const isMergeOutput = Boolean( (normalizedFolderPath && mergeOutputPathCandidates.has(normalizedFolderPath)) @@ -1980,13 +2024,13 @@ export default function JobDetailDialog({ ); const outputLabel = outputFolders.length > 1 ? `Output ${idx + 1}` : 'Output'; const label = isMergeOutput ? `${outputLabel} (Merge):` : `${outputLabel}:`; - const canDownloadFolder = Boolean(typeof onDownloadOutputFolder === 'function'); + const canDownloadFolder = Boolean(folderDownloadReady && typeof onDownloadOutputFolder === 'function'); return ( onDownloadOutputFolder?.(job, folderPath) : null} + onDownload={canDownloadFolder ? () => onDownloadOutputFolder?.(job, folderPath, folderOwnerJobId) : null} downloadDisabled={!canDownloadFolder} downloadLoading={downloadFolderBusyPath === folderPath} /> @@ -2610,7 +2654,7 @@ export default function JobDetailDialog({
) : null} - {!isCd && !isAudiobook && !isConverter && !childActionState.childRetryRipRequired && typeof onAssignOmdb === 'function' ? ( + {!isCd && !isAudiobook && !isConverter && !childActionState.childRetryRipRequired && typeof onAssignMetadata === 'function' ? (
Metadaten
@@ -2620,8 +2664,8 @@ export default function JobDetailDialog({ severity="secondary" outlined size="small" - onClick={() => onAssignOmdb?.(child)} - loading={omdbAssignBusy} + onClick={() => onAssignMetadata?.(child)} + loading={metadataAssignBusy} disabled={!childActionState.childCanAssignMetadata} /> {metadataAssignActionDescription} @@ -2917,7 +2961,7 @@ export default function JobDetailDialog({ loading={actionBusy} disabled={!canCdStartReview} /> - Öffnet den Vorprüfungs-Workflow: MusicBrainz-Suche, Trackauswahl, Ausgabeeinstellungen — dann Encode aus vorhandenen WAV-Daten starten. + Öffnet den Vorprüfungs-Workflow mit den zuletzt verwendeten CD-Metadaten: Trackauswahl und Ausgabeeinstellungen prüfen, dann Encode aus vorhandenen WAV-Daten starten.
+ ) : isAudiobook ? ( +
+
Audiobook
+ {typeof onReencode === 'function' ? ( +
+
+ ) : null} + {typeof onRestartReview === 'function' ? ( +
+
+ ) : null} +
) : ( canShowGeneralEncodeActions ? (
@@ -3025,7 +3102,7 @@ export default function JobDetailDialog({
) : null} - {!isCd && !isAudiobook && !isConverter && !blockMetadataAndReviewUntilRetry && typeof onAssignOmdb === 'function' ? ( + {!isCd && !isAudiobook && !isConverter && !blockMetadataAndReviewUntilRetry && typeof onAssignMetadata === 'function' ? (
Metadaten
@@ -3035,8 +3112,8 @@ export default function JobDetailDialog({ severity="secondary" outlined size="small" - onClick={() => onAssignOmdb?.(job)} - loading={omdbAssignBusy} + onClick={() => onAssignMetadata?.(job)} + loading={metadataAssignBusy} disabled={running} /> {metadataAssignActionDescription} diff --git a/frontend/src/components/MediaInfoReviewPanel.jsx b/frontend/src/components/MediaInfoReviewPanel.jsx index b0b1381..7f473e4 100644 --- a/frontend/src/components/MediaInfoReviewPanel.jsx +++ b/frontend/src/components/MediaInfoReviewPanel.jsx @@ -301,6 +301,17 @@ function resolveSubtitleTrackVariantFlags(track) { return { isForcedOnly, fullHasForced }; } +function isClosedCaptionSubtitleTrack(track) { + if (Boolean(track?.sdhLikely)) { + return true; + } + const title = String(track?.title || track?.description || '').trim(); + if (!title) { + return false; + } + return /\b(closed\s*caption|cc|sdh|hoh|hearing\s*impaired)\b/i.test(title); +} + function compareForcedSubtitleCandidate(a, b) { const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag)); if (defaultDiff !== 0) { @@ -1153,6 +1164,7 @@ function TrackList({ if (type === 'subtitle') { const selectedVariants = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {}); + const selectedTrackIdSet = new Set(normalizeTrackIdList(selectedTrackIds).map((id) => String(id))); const sortedSubtitleTracks = orderedTracks .filter((track) => !isDuplicateSubtitleTrack(track)); @@ -1206,10 +1218,13 @@ function TrackList({
{sortedSubtitleTracks.map((track) => { const burned = isBurnedSubtitleTrack(track); + const trackId = normalizeTrackId(track?.id); const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und'); const displayLanguage = toLang2(track?.language || track?.languageLabel || 'und'); const displayCodec = simplifyCodec('subtitle', track?.format, track?.description || track?.title); const flags = resolveSubtitleTrackVariantFlags(track); + const isClosedCaption = isClosedCaptionSubtitleTrack(track); + const ccSuffix = isClosedCaption ? ' | Closed Caption (CC/SDH)' : ''; const confidenceRaw = String(track?.sourceConfidence || '').trim().toLowerCase(); const confidence = confidenceRaw === 'high' || confidenceRaw === 'medium' || confidenceRaw === 'low' ? confidenceRaw @@ -1232,7 +1247,7 @@ function TrackList({ } onToggleSubtitleVariant(language, 'forced', event.target.checked); }, - text: `#${track.id} | ${displayLanguage} | Automatische Übersetzungen (${displayCodec})`, + text: `#${track.id} | ${displayLanguage} | Automatische Übersetzungen (${displayCodec})${ccSuffix}`, confidence })} Untertitel: {actionInfo} @@ -1248,7 +1263,7 @@ function TrackList({ return (
- {`#${track.id} | ${displayLanguage}`} + {`#${track.id} | ${displayLanguage}${ccSuffix}`}
{renderVariantRow({ key: `${title}-${track.id}-combo-full`, @@ -1282,7 +1297,9 @@ function TrackList({ ); } - const checked = allowSelection ? Boolean(languageSelection.full) : Boolean(track?.selectedForEncode); + const checked = allowSelection + ? (trackId !== null && selectedTrackIdSet.has(String(trackId))) + : Boolean(track?.selectedForEncode); const disabled = !allowSelection || burned; const actionInfo = resolveSubtitleActionInfo(track, checked); return ( @@ -1292,12 +1309,12 @@ function TrackList({ checked, disabled, onChange: (event) => { - if (disabled || typeof onToggleSubtitleVariant !== 'function') { + if (disabled || typeof onToggleTrack !== 'function' || trackId === null) { return; } - onToggleSubtitleVariant(language, 'full', event.target.checked); + onToggleTrack(trackId, event.target.checked); }, - text: `#${track.id} | ${displayLanguage} | Komplette Untertitel (${displayCodec})` + text: `#${track.id} | ${displayLanguage} | Komplette Untertitel (${displayCodec})${ccSuffix}` })} Untertitel: {actionInfo}
@@ -2177,9 +2194,11 @@ export default function MediaInfoReviewPanel({
) : null} - {/* Pre-Encode Items (scripts + chains unified) */} - {!compactTitleSelection && (allowEncodeItemSelection || preEncodeItems.length > 0) ? ( -
+ {!compactTitleSelection ? ( +
+ {/* Pre-Encode Items (scripts + chains unified) */} + {(allowEncodeItemSelection || preEncodeItems.length > 0) ? ( +

Pre-Encode Ausführungen (optional)

{scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. @@ -2294,13 +2313,12 @@ export default function MediaInfoReviewPanel({ ) : null}
) : null} - Ausführung vor dem Encoding, strikt nacheinander. Bei Fehler wird der Encode abgebrochen. -
- ) : null} + Ausführung vor dem Encoding, strikt nacheinander. Bei Fehler wird der Encode abgebrochen. +
+ ) : null} - {/* Post-Encode Items (scripts + chains unified) */} - {!compactTitleSelection ? ( -
+ {/* Post-Encode Items (scripts + chains unified) */} +

Post-Encode Ausführungen (optional)

{scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. @@ -2415,7 +2433,8 @@ export default function MediaInfoReviewPanel({ ) : null}
) : null} - Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich). + Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich). +
) : null} @@ -2716,6 +2735,12 @@ export default function MediaInfoReviewPanel({ selectedTrackIds={selectedSubtitleTrackIds} selectedSubtitleVariantSelection={selectedSubtitleVariantSelection} selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder} + onToggleTrack={(trackId, checked) => { + if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') { + return; + } + onTrackSelectionChange(title.id, 'subtitle', trackId, checked); + }} onToggleSubtitleVariant={(language, variant, checked) => { if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') { return; @@ -2751,6 +2776,12 @@ export default function MediaInfoReviewPanel({ selectedTrackIds={selectedSubtitleTrackIds} selectedSubtitleVariantSelection={selectedSubtitleVariantSelection} selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder} + onToggleTrack={(trackId, checked) => { + if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') { + return; + } + onTrackSelectionChange(title.id, 'subtitle', trackId, checked); + }} onToggleSubtitleVariant={(language, variant, checked) => { if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') { return; diff --git a/frontend/src/components/MetadataSelectionDialog.jsx b/frontend/src/components/MetadataSelectionDialog.jsx index 972482b..8407a34 100644 --- a/frontend/src/components/MetadataSelectionDialog.jsx +++ b/frontend/src/components/MetadataSelectionDialog.jsx @@ -4,7 +4,6 @@ import { Button } from 'primereact/button'; import { DataTable } from 'primereact/datatable'; import { Column } from 'primereact/column'; import { InputText } from 'primereact/inputtext'; -import { Dropdown } from 'primereact/dropdown'; export default function MetadataSelectionDialog({ visible, @@ -105,6 +104,13 @@ export default function MetadataSelectionDialog({ } return null; }; + const normalizeSeriesClassification = (value) => { + const raw = String(value || '').trim().toLowerCase(); + if (raw === 'series' || raw === 'film' || raw === 'ambiguous') { + return raw; + } + return null; + }; const parseDiscNumber = (rawValue) => { const text = String(rawValue ?? '').trim(); if (!/^\d+$/.test(text)) { @@ -133,45 +139,78 @@ export default function MetadataSelectionDialog({ const [conflictError, setConflictError] = useState(''); const [seasonFilter, setSeasonFilter] = useState(''); const [discValidationError, setDiscValidationError] = useState(''); + const [manualWorkflowKind, setManualWorkflowKind] = useState(null); const activeSearchRequestRef = useRef(0); const autoSearchDoneRef = useRef(false); + const autoSearchWorkflowRef = useRef(null); + const wasVisibleRef = useRef(false); + const lastVisibleContextIdentityRef = useRef(null); const contextWorkflowKind = normalizeWorkflowKind( context?.selectedMetadata?.workflowKind || context?.workflowKind || null ); - const contextSelectedMetadataProvider = String( - context?.selectedMetadata?.metadataProvider - || '' - ).trim().toLowerCase(); - const contextMetadataProvider = ( - contextWorkflowKind === 'series' - ? 'tmdb' - : (contextWorkflowKind === 'film' - ? 'omdb' - : (contextSelectedMetadataProvider || String(context?.metadataProvider || 'omdb').trim().toLowerCase())) - ) || 'omdb'; - const [selectedMetadataProvider, setSelectedMetadataProvider] = useState(contextMetadataProvider); const mediaProfile = String( context?.mediaProfile || context?.selectedMetadata?.mediaProfile || '' ).trim().toLowerCase(); - const metadataProvider = selectedMetadataProvider === 'tmdb' ? 'tmdb' : 'omdb'; - const providerOptions = mediaProfile === 'dvd' - ? [ - { label: 'Film (OMDb)', value: 'omdb' }, - { label: 'Serie (TMDb)', value: 'tmdb' } - ] - : [{ label: contextMetadataProvider === 'tmdb' ? 'TMDb' : 'OMDb', value: contextMetadataProvider }]; - const providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'; - const supportsExternalIdInput = metadataProvider === 'omdb'; - const requiresDiscNumber = metadataProvider === 'tmdb' && (mediaProfile === 'dvd' || !mediaProfile); + const metadataProvider = 'tmdb'; + const providerLabel = 'TMDb'; + const seriesDecisionClassification = normalizeSeriesClassification(context?.seriesDecision?.classification); + const hasSeriesSignals = useMemo(() => { + if (contextWorkflowKind === 'series') { + return true; + } + const selectedMetadata = context?.selectedMetadata && typeof context.selectedMetadata === 'object' + ? context.selectedMetadata + : {}; + const metadataKind = String(selectedMetadata?.metadataKind || '').trim().toLowerCase(); + if (metadataKind === 'series' || metadataKind === 'season') { + return true; + } + if (Number(selectedMetadata?.seasonNumber || 0) > 0) { + return true; + } + if (Number(context?.seriesLookupHint?.seasonNumber || 0) > 0) { + return true; + } + if (Boolean(context?.seriesAnalysis?.summary?.seriesLike)) { + return true; + } + const contextCandidates = Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : []; + return contextCandidates.some((row) => Number(row?.seasonNumber || 0) > 0); + }, [context, contextWorkflowKind]); + const recommendedWorkflowKind = normalizeWorkflowKind( + context?.seriesDecision?.recommendedWorkflowKind + || contextWorkflowKind + || null + ) || (hasSeriesSignals ? 'series' : 'film'); + const effectiveWorkflowKind = manualWorkflowKind || recommendedWorkflowKind; + const allowsWorkflowSwitch = ( + (mediaProfile === 'dvd' || mediaProfile === 'bluray') + && seriesDecisionClassification === 'ambiguous' + ); + const supportsExternalIdInput = false; + const requiresDiscNumber = metadataProvider === 'tmdb' + && effectiveWorkflowKind === 'series' + && (mediaProfile === 'dvd' || mediaProfile === 'bluray'); const effectiveDiscNumber = parseDiscNumber(manualDiscNumber); const hasValidDiscNumber = effectiveDiscNumber !== null; + const contextJobId = Number(context?.jobId || 0) || null; + const contextIdentity = contextJobId + ? `job:${contextJobId}` + : `detected:${String(context?.detectedTitle || '').trim().toLowerCase()}`; useEffect(() => { if (!visible) { + wasVisibleRef.current = false; + lastVisibleContextIdentityRef.current = null; + return; + } + const openingNow = !wasVisibleRef.current; + const contextChanged = lastVisibleContextIdentityRef.current !== contextIdentity; + if (!openingNow && !contextChanged) { return; } @@ -198,10 +237,13 @@ export default function MetadataSelectionDialog({ setConflictError(''); setSeasonFilter(''); setDiscValidationError(''); - setSelectedMetadataProvider(contextMetadataProvider); + setManualWorkflowKind(allowsWorkflowSwitch ? recommendedWorkflowKind : null); autoSearchDoneRef.current = false; + autoSearchWorkflowRef.current = null; activeSearchRequestRef.current += 1; - }, [visible, context]); + wasVisibleRef.current = true; + lastVisibleContextIdentityRef.current = contextIdentity; + }, [visible, contextIdentity, context, allowsWorkflowSwitch, recommendedWorkflowKind]); useEffect(() => { if (!discValidationError) { @@ -221,7 +263,7 @@ export default function MetadataSelectionDialog({ const rows = useMemo(() => { const base = Array.isArray(context?.metadataCandidates) ? context.metadataCandidates - : (context?.omdbCandidates || []); + : []; const sourceRows = hasSearchOverride ? extraResults : base; const all = Array.isArray(sourceRows) ? sourceRows : []; const map = new Map(); @@ -246,7 +288,7 @@ export default function MetadataSelectionDialog({ }, [context, extraResults, hasSearchOverride]); const filteredRows = useMemo(() => { - if (metadataProvider !== 'tmdb') { + if (effectiveWorkflowKind !== 'series') { return rows; } const normalizedFilter = String(seasonFilter || '').trim().toLowerCase(); @@ -261,7 +303,7 @@ export default function MetadataSelectionDialog({ }, [metadataProvider, rows, seasonFilter]); const titleWithPosterBody = (row) => ( -
+
{row.poster && row.poster !== 'N/A' ? ( {row.title} ) : ( @@ -299,7 +341,8 @@ export default function MetadataSelectionDialog({ let effectiveResults = []; for (const candidateQuery of queryVariants) { const results = await onSearch(candidateQuery, { - metadataProvider + metadataProvider, + workflowKind: effectiveWorkflowKind }); if (activeSearchRequestRef.current !== requestId) { return; @@ -323,20 +366,26 @@ export default function MetadataSelectionDialog({ }; useEffect(() => { - if (!visible || autoSearchDoneRef.current) { + if (!visible) { return; } if (typeof onSearch !== 'function') { autoSearchDoneRef.current = true; + autoSearchWorkflowRef.current = effectiveWorkflowKind; return; } const trimmedQuery = String(query || '').trim(); if (!trimmedQuery) { return; } + const workflowChanged = autoSearchWorkflowRef.current !== effectiveWorkflowKind; + if (autoSearchDoneRef.current && !workflowChanged) { + return; + } autoSearchDoneRef.current = true; + autoSearchWorkflowRef.current = effectiveWorkflowKind; void handleSearch(); - }, [visible, query, metadataProvider, onSearch]); + }, [visible, query, effectiveWorkflowKind, onSearch]); const handleSubmitError = (error, pendingPayload = null) => { const details = Array.isArray(error?.details) ? error.details : []; @@ -393,12 +442,22 @@ export default function MetadataSelectionDialog({ year: selected.year, imdbId: selected.imdbId, poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null, - fromOmdb: metadataProvider === 'omdb', metadataProvider, - workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film', + workflowKind: effectiveWorkflowKind, providerId: selected.providerId || null, tmdbId: selected.tmdbId || null, metadataKind: selected.metadataKind || null, + voteAverage: Number.isFinite(Number(selected?.voteAverage)) + ? Number(Number(selected.voteAverage).toFixed(1)) + : null, + imdbRating: String( + selected?.imdbRating + || selected?.tmdbDetails?.imdbRating + || '' + ).trim() || null, + tmdbDetails: selected?.tmdbDetails && typeof selected.tmdbDetails === 'object' + ? selected.tmdbDetails + : null, seasonNumber: selected.seasonNumber || null, seasonName: selected.seasonName || null, episodeCount: selected.episodeCount || 0, @@ -409,11 +468,10 @@ export default function MetadataSelectionDialog({ jobId: context.jobId, title: manualTitle, year: manualYear, - imdbId: supportsExternalIdInput ? manualImdb : null, + imdbId: null, poster: null, - fromOmdb: false, metadataProvider, - workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film', + workflowKind: effectiveWorkflowKind, seasonNumber: null, ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}) }; @@ -572,26 +630,6 @@ export default function MetadataSelectionDialog({ modal >
- {providerOptions.length > 1 ? ( - { - const nextProvider = String(event?.value || 'omdb').trim().toLowerCase() || 'omdb'; - setSelectedMetadataProvider(nextProvider); - setSelected(null); - setExtraResults([]); - setHasSearchOverride(false); - setSearchError(''); - setSubmitError(''); - setDiscValidationError(''); - }} - disabled={searchBusy || busy} - style={{ minWidth: '12.5rem' }} - /> - ) : null} setQuery(event.target.value)} @@ -605,8 +643,26 @@ export default function MetadataSelectionDialog({ />
-
- {metadataProvider === 'tmdb' ? ( + {allowsWorkflowSwitch ? ( +
+
+ ) : null} +
+ {effectiveWorkflowKind === 'series' ? ( setSeasonFilter(event.target.value)} @@ -650,8 +706,8 @@ export default function MetadataSelectionDialog({ (metadataProvider === 'tmdb' + header="Staffel" + body={(row) => (effectiveWorkflowKind === 'series' ? (row.seasonNumber ? `S${row.seasonNumber}` : '-') : (row.imdbId || '-'))} style={{ width: '10rem' }} diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx index 181e98a..b78b91c 100644 --- a/frontend/src/components/PipelineStatusCard.jsx +++ b/frontend/src/components/PipelineStatusCard.jsx @@ -316,6 +316,14 @@ function normalizeSubtitleLanguageOrder(rawOrder = []) { return output; } +function normalizeSubtitleSelectionMode(value, fallback = 'variants') { + const mode = String(value || '').trim().toLowerCase(); + if (mode === 'track_ids' || mode === 'variants') { + return mode; + } + return fallback; +} + function resolveSubtitleTrackVariantFlags(track) { const subtitleType = String(track?.subtitleType || '').trim().toLowerCase(); const isForcedOnly = Boolean( @@ -430,6 +438,15 @@ function buildDefaultTrackSelection(review) { const manualSubtitleLanguageOrder = manualSelectionMatchesTitle ? normalizeSubtitleLanguageOrder(manualSelection?.subtitleLanguageOrder || []) : []; + const manualHasVariants = Object.keys(manualSubtitleVariantSelection).length > 0; + const subtitleSelectionMode = manualSelectionMatchesTitle + ? normalizeSubtitleSelectionMode( + manualSelection?.subtitleSelectionMode, + manualHasVariants + ? 'variants' + : (manualSubtitleTrackIds.length > 0 ? 'track_ids' : 'variants') + ) + : 'variants'; selection[titleId] = { audioTrackIds: manualAudioTrackIds.length > 0 @@ -447,7 +464,8 @@ function buildDefaultTrackSelection(review) { : defaultSubtitleVariantSelection.subtitleVariantSelection, subtitleLanguageOrder: manualSubtitleLanguageOrder.length > 0 ? manualSubtitleLanguageOrder - : defaultSubtitleVariantSelection.subtitleLanguageOrder + : defaultSubtitleVariantSelection.subtitleLanguageOrder, + subtitleSelectionMode }; } @@ -460,7 +478,8 @@ function defaultTrackSelectionForTitle(review, titleId) { audioTrackIds: [], subtitleTrackIds: [], subtitleVariantSelection: {}, - subtitleLanguageOrder: [] + subtitleLanguageOrder: [], + subtitleSelectionMode: 'variants' }; } @@ -569,15 +588,22 @@ function hasSeriesMetadataShape(source = null) { const metadata = source && typeof source === 'object' ? source : {}; const seasonNumber = normalizePositiveInt(metadata?.seasonNumber ?? metadata?.season ?? null); const episodes = Array.isArray(metadata?.episodes) ? metadata.episodes : []; - const metadataProvider = String(metadata?.metadataProvider || '').trim().toLowerCase(); const workflowKind = String(metadata?.workflowKind || metadata?.kind || '').trim().toLowerCase(); + const metadataKind = String(metadata?.metadataKind || '').trim().toLowerCase(); + const episodeCount = Number(metadata?.episodeCount || 0) || 0; + if (['film', 'movie', 'feature'].includes(workflowKind) || ['film', 'movie', 'feature'].includes(metadataKind)) { + return false; + } return Boolean( seasonNumber + || episodeCount > 0 || episodes.length > 0 - || metadataProvider === 'tmdb' - || metadataProvider === 'themoviedb' || workflowKind === 'series' || workflowKind === 'season' + || workflowKind === 'tv' + || metadataKind === 'series' + || metadataKind === 'season' + || metadataKind === 'tv' ); } @@ -631,24 +657,14 @@ function resolveDiscWorkflowKind({ return normalized; } } - const providerCandidates = [ - selected?.metadataProvider, - analyze?.metadataProvider, - pipelineCtx?.metadataProvider - ]; - for (const candidate of providerCandidates) { - const provider = String(candidate || '').trim().toLowerCase(); - if (!provider) { - continue; - } - if (provider === 'tmdb' || provider === 'themoviedb') { - return 'series'; - } - if (provider === 'omdb') { - return 'film'; - } + if ( + hasSeriesMetadataShape(selected) + || hasSeriesMetadataShape(analyze) + || hasSeriesMetadataShape(pipelineCtx) + ) { + return 'series'; } - return fallbackIsSeries ? 'series' : 'film'; + return null; } function getDiscWorkflowBadgeLabel(mediaProfile, workflowKind) { @@ -656,7 +672,10 @@ function getDiscWorkflowBadgeLabel(mediaProfile, workflowKind) { if (normalizedMediaProfile !== 'dvd' && normalizedMediaProfile !== 'bluray') { return null; } - const kind = normalizeDiscWorkflowKind(workflowKind) || 'film'; + const kind = normalizeDiscWorkflowKind(workflowKind); + if (!kind) { + return null; + } if (normalizedMediaProfile === 'bluray') { return kind === 'series' ? 'Blu-ray-Serie' : 'Blu-ray-Film'; } @@ -1555,7 +1574,7 @@ export default function PipelineStatusCard({ onAnalyze, onReanalyze, onOpenMetadata, - onReassignOmdb, + onReassignMetadata, onStart, onRemoveFromQueue, onRestartEncode, @@ -3496,11 +3515,24 @@ export default function PipelineStatusCard({ const hasExplicitVariantSelection = Object.keys(filteredVariantSelection).length > 0; const filteredSubtitleTrackIds = normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || []) .filter((id) => !blockedSubtitleTrackIds.has(String(id))); + const subtitleSelectionMode = normalizeSubtitleSelectionMode( + effectiveSelection?.subtitleSelectionMode, + hasExplicitVariantSelection + ? 'variants' + : (filteredSubtitleTrackIds.length > 0 ? 'track_ids' : 'variants') + ); selectedTrackSelection[titleId] = { audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []), - subtitleTrackIds: hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds, - subtitleVariantSelection: filteredVariantSelection, - subtitleLanguageOrder: normalizedLanguageOrder + subtitleSelectionMode, + subtitleTrackIds: subtitleSelectionMode === 'track_ids' + ? filteredSubtitleTrackIds + : (hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds), + ...(subtitleSelectionMode === 'variants' + ? { + subtitleVariantSelection: filteredVariantSelection, + subtitleLanguageOrder: normalizedLanguageOrder + } + : {}) }; } const episodeAssignments = {}; @@ -3721,7 +3753,7 @@ export default function PipelineStatusCard({ && state !== 'IDLE' && state !== 'DISC_DETECTED' && retryJobId - && typeof onReassignOmdb === 'function' + && typeof onReassignMetadata === 'function' && !isOrphanCancelled && !blockMetadataAndReviewForRipRetry && !(jobMediaProfile === 'converter' && state === 'READY_TO_START' && !selectedMetadata?.imdbId) ? ( @@ -3730,7 +3762,7 @@ export default function PipelineStatusCard({ icon="pi pi-search" severity="info" size="small" - onClick={() => onReassignOmdb?.(retryJobId)} + onClick={() => onReassignMetadata?.(retryJobId)} loading={busy} /> ) : null} @@ -4357,7 +4389,8 @@ export default function PipelineStatusCard({ audioTrackIds: [], subtitleTrackIds: [], subtitleVariantSelection: {}, - subtitleLanguageOrder: [] + subtitleLanguageOrder: [], + subtitleSelectionMode: 'variants' }; const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds'; const existing = normalizeTrackIdList(current?.[key] || []); @@ -4369,7 +4402,14 @@ export default function PipelineStatusCard({ ...prev, [normalizedTitleId]: { ...current, - [key]: next + [key]: next, + ...(trackType === 'subtitle' + ? { + subtitleSelectionMode: 'track_ids', + subtitleVariantSelection: {}, + subtitleLanguageOrder: [] + } + : {}) } }; }); @@ -4387,7 +4427,8 @@ export default function PipelineStatusCard({ audioTrackIds: [], subtitleTrackIds: [], subtitleVariantSelection: {}, - subtitleLanguageOrder: [] + subtitleLanguageOrder: [], + subtitleSelectionMode: 'variants' }; const currentMap = normalizeSubtitleVariantSelectionMap(current?.subtitleVariantSelection || {}); const nextMap = { ...currentMap }; @@ -4415,6 +4456,7 @@ export default function PipelineStatusCard({ ...prev, [normalizedTitleId]: { ...current, + subtitleSelectionMode: 'variants', subtitleVariantSelection: nextMap, subtitleLanguageOrder: nextOrder } diff --git a/frontend/src/hooks/useWebSocket.js b/frontend/src/hooks/useWebSocket.js index c6030c5..2603c72 100644 --- a/frontend/src/hooks/useWebSocket.js +++ b/frontend/src/hooks/useWebSocket.js @@ -18,7 +18,7 @@ export function useWebSocket({ onMessage }) { let reconnectTimer; let progressFlushTimer = null; let progressFlushRaf = null; - let latestProgressMessage = null; + const latestProgressMessagesByJob = new Map(); let isUnmounted = false; const clearProgressFlush = () => { @@ -35,12 +35,14 @@ export function useWebSocket({ onMessage }) { const flushProgressMessage = () => { progressFlushRaf = null; progressFlushTimer = null; - const message = latestProgressMessage; - latestProgressMessage = null; - if (!message) { + if (latestProgressMessagesByJob.size === 0) { return; } - onMessageRef.current?.(message); + const messages = Array.from(latestProgressMessagesByJob.values()); + latestProgressMessagesByJob.clear(); + for (const message of messages) { + onMessageRef.current?.(message); + } }; const scheduleProgressFlush = () => { @@ -65,7 +67,9 @@ export function useWebSocket({ onMessage }) { try { const message = JSON.parse(event.data); if (message?.type === 'PIPELINE_PROGRESS') { - latestProgressMessage = message; + const progressJobId = message?.payload?.activeJobId; + const jobKey = progressJobId == null ? '__global__' : String(progressJobId); + latestProgressMessagesByJob.set(jobKey, message); scheduleProgressFlush(); return; } diff --git a/frontend/src/pages/AudiobooksPage.jsx b/frontend/src/pages/AudiobooksPage.jsx index 6658e13..22ced22 100644 --- a/frontend/src/pages/AudiobooksPage.jsx +++ b/frontend/src/pages/AudiobooksPage.jsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; import { Button } from 'primereact/button'; import { Card } from 'primereact/card'; import { Toast } from 'primereact/toast'; @@ -10,7 +9,6 @@ import { api } from '../api/client'; import { useWebSocket } from '../hooks/useWebSocket'; import AudiobookUploadPanel from '../components/AudiobookUploadPanel'; import AudiobookConfigPanel from '../components/AudiobookConfigPanel'; -import AudiobookOutputExplorer from '../components/AudiobookOutputExplorer'; import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation'; import { resolveMediaType } from '../utils/jobTaxonomy'; import { confirmModal } from '../utils/confirmModal'; @@ -25,6 +23,44 @@ function normalizeJobId(value) { return Math.trunc(parsed); } +function hasOwn(source, key) { + return source != null && Object.prototype.hasOwnProperty.call(source, key); +} + +function normalizeLiveProgressForJob(job) { + const live = job?.liveProgress && typeof job.liveProgress === 'object' ? job.liveProgress : null; + if (!live) { + return null; + } + const liveContext = live?.context && typeof live.context === 'object' ? live.context : {}; + return { + progress: live?.progress ?? null, + eta: live?.eta ?? null, + statusText: live?.statusText ?? null, + state: live?.state ?? null, + currentChapter: liveContext?.currentChapter && typeof liveContext.currentChapter === 'object' + ? liveContext.currentChapter + : null, + completedChapterCount: Number.isFinite(Number(liveContext?.completedChapterCount)) + ? Number(liveContext.completedChapterCount) + : null + }; +} + +function extractUploadJobIdFromResponse(response) { + const payload = response && typeof response === 'object' ? response : {}; + const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}; + return ( + normalizeJobId(result?.jobId) + || normalizeJobId(payload?.jobId) + || normalizeJobId(result?.id) + || normalizeJobId(payload?.id) + || normalizeJobId(result?.job?.id) + || normalizeJobId(payload?.job?.id) + || null + ); +} + function parseEncodePlan(job) { if (job?.encodePlan && typeof job.encodePlan === 'object') { return job.encodePlan; @@ -71,6 +107,22 @@ function resolveAudiobookMetadata(job, encodePlan) { }; } +function buildAudiobookJobMetaLine(jobId, metadata = {}, fallbackYear = null) { + const author = String(metadata?.author || metadata?.artist || '').trim() || null; + const narrator = String(metadata?.narrator || '').trim() || null; + const chapterCount = Array.isArray(metadata?.chapters) ? metadata.chapters.length : 0; + const year = Number.isFinite(Number(metadata?.year)) + ? Math.trunc(Number(metadata.year)) + : (Number.isFinite(Number(fallbackYear)) ? Math.trunc(Number(fallbackYear)) : null); + return ( + `#${jobId}` + + (author ? ` | ${author}` : '') + + (narrator ? ` | ${narrator}` : '') + + (chapterCount > 0 ? ` | ${chapterCount} Kapitel` : '') + + (year ? ` | ${year}` : '') + ); +} + function buildAudiobookPipeline(job, progress = null) { const encodePlan = parseEncodePlan(job); const selectedMetadata = resolveAudiobookMetadata(job, encodePlan); @@ -93,7 +145,13 @@ function buildAudiobookPipeline(job, progress = null) { format: String(encodePlan?.format || job?.handbrakeInfo?.format || 'mp3').trim().toLowerCase() || 'mp3', formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object' ? encodePlan.formatOptions - : {} + : {}, + preEncodeScriptIds: Array.isArray(encodePlan?.preEncodeScriptIds) ? encodePlan.preEncodeScriptIds : [], + postEncodeScriptIds: Array.isArray(encodePlan?.postEncodeScriptIds) ? encodePlan.postEncodeScriptIds : [], + preEncodeChainIds: Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [], + postEncodeChainIds: Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : [], + preEncodeItems: Array.isArray(encodePlan?.preEncodeItems) ? encodePlan.preEncodeItems : [], + postEncodeItems: Array.isArray(encodePlan?.postEncodeItems) ? encodePlan.postEncodeItems : [] }, mediaInfoReview: reviewData, outputPath: String(job?.output_path || encodePlan?.outputPath || '').trim() || null, @@ -109,16 +167,15 @@ function buildAudiobookPipeline(job, progress = null) { export default function AudiobooksPage({ audiobookUpload, - onAudiobookUpload + onAudiobookUpload, + onCancelAudiobookUpload = null }) { const toastRef = useRef(null); - const navigate = useNavigate(); const [jobs, setJobs] = useState([]); const [loadingJobs, setLoadingJobs] = useState(false); const [expandedJobId, setExpandedJobId] = useState(undefined); const [jobProgress, setJobProgress] = useState({}); const [actionBusyJobIds, setActionBusyJobIds] = useState(() => new Set()); - const explorerRefreshToken = 0; const setActionBusy = (jobId, busy) => { const normalizedJobId = normalizeJobId(jobId); @@ -146,21 +203,32 @@ export default function AudiobooksPage({ .sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0)); setJobs(audiobookRows); setJobProgress((prev) => { - const next = { ...prev }; + const next = {}; for (const job of audiobookRows) { const jobId = normalizeJobId(job?.id); if (!jobId) { continue; } const status = String(job?.status || '').trim().toUpperCase(); - if (!['ANALYZING', 'ENCODING'].includes(status)) { - delete next[jobId]; + const isLiveStatus = ['ANALYZING', 'ENCODING'].includes(status); + if (!isLiveStatus) { + continue; + } + const serverProgress = normalizeLiveProgressForJob(job); + if (serverProgress) { + next[jobId] = serverProgress; + continue; + } + if (prev?.[jobId] && typeof prev[jobId] === 'object') { + next[jobId] = prev[jobId]; } } return next; }); + return audiobookRows; } catch (error) { console.error('AudiobooksPage load jobs error:', error); + return []; } finally { setLoadingJobs(false); } @@ -183,17 +251,31 @@ export default function AudiobooksPage({ if (!jobId) { return; } - setJobProgress((prev) => ({ - ...prev, - [jobId]: { - progress: payload?.progress ?? null, - eta: payload?.eta ?? null, - statusText: payload?.statusText ?? null, - state: payload?.state ?? null, - currentChapter: payload?.contextPatch?.currentChapter ?? null, - completedChapterCount: payload?.contextPatch?.completedChapterCount ?? null - } - })); + setJobProgress((prev) => { + const previousEntry = prev?.[jobId] && typeof prev[jobId] === 'object' ? prev[jobId] : {}; + const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object' + ? payload.contextPatch + : null; + const nextCurrentChapter = hasOwn(contextPatch, 'currentChapter') + ? (contextPatch?.currentChapter ?? null) + : (previousEntry?.currentChapter ?? null); + const nextCompletedChapterCount = hasOwn(contextPatch, 'completedChapterCount') + ? (Number.isFinite(Number(contextPatch?.completedChapterCount)) + ? Number(contextPatch.completedChapterCount) + : null) + : (previousEntry?.completedChapterCount ?? null); + return { + ...prev, + [jobId]: { + progress: payload?.progress ?? previousEntry?.progress ?? null, + eta: payload?.eta ?? previousEntry?.eta ?? null, + statusText: payload?.statusText ?? previousEntry?.statusText ?? null, + state: payload?.state ?? previousEntry?.state ?? null, + currentChapter: nextCurrentChapter, + completedChapterCount: nextCompletedChapterCount + } + }; + }); } if ( message.type === 'PIPELINE_UPDATE' @@ -262,7 +344,7 @@ export default function AudiobooksPage({ } setActionBusy(normalizedJobId, true); try { - await api.cancelJob(normalizedJobId); + await api.cancelPipeline(normalizedJobId); toastRef.current?.show({ severity: 'info', summary: 'Abbruch angefordert', @@ -282,39 +364,6 @@ export default function AudiobooksPage({ } }; - const handleRetry = async (jobId) => { - const normalizedJobId = normalizeJobId(jobId); - if (!normalizedJobId) { - return; - } - setActionBusy(normalizedJobId, true); - try { - const response = await api.retryJob(normalizedJobId); - const retryJobId = normalizeJobId(response?.retryJob?.id); - toastRef.current?.show({ - severity: 'success', - summary: 'Retry erstellt', - detail: retryJobId - ? `Neuer Job #${retryJobId} wurde angelegt.` - : `Retry fuer Job #${normalizedJobId} wurde angelegt.`, - life: 3200 - }); - await loadJobs(); - if (retryJobId) { - setExpandedJobId(retryJobId); - } - } catch (error) { - toastRef.current?.show({ - severity: 'error', - summary: 'Retry fehlgeschlagen', - detail: error?.message || 'Bitte Logs pruefen.', - life: 4200 - }); - } finally { - setActionBusy(normalizedJobId, false); - } - }; - const handleDelete = async (jobId) => { const normalizedJobId = normalizeJobId(jobId); if (!normalizedJobId) { @@ -357,11 +406,14 @@ export default function AudiobooksPage({ } }; - const handleUploaded = async (uploadedJobId) => { - const normalizedJobId = normalizeJobId(uploadedJobId); - await loadJobs(); - if (normalizedJobId) { - setExpandedJobId(normalizedJobId); + const handleUploaded = async (uploadedJobId, response = null) => { + const normalizedJobId = normalizeJobId(uploadedJobId) + || extractUploadJobIdFromResponse(response); + const rows = await loadJobs(); + const fallbackJobId = normalizeJobId(rows?.[0]?.id); + const targetJobId = normalizedJobId || fallbackJobId; + if (targetJobId) { + setExpandedJobId(targetJobId); } }; @@ -393,6 +445,7 @@ export default function AudiobooksPage({ @@ -417,7 +470,17 @@ export default function AudiobooksPage({ const progressValue = Number.isFinite(Number(pipelineForJob?.progress)) ? Math.max(0, Math.min(100, Number(pipelineForJob.progress))) : 0; - const showProgress = ['ANALYZING', 'ENCODING'].includes(state) && progressValue > 0; + const isRunning = ['ANALYZING', 'ENCODING'].includes(state); + const showProgress = isRunning || progressValue > 0; + const progressLabel = `${Math.trunc(progressValue)}%`; + const etaLabel = String(pipelineForJob?.eta || '').trim(); + const currentChapter = pipelineForJob?.context?.currentChapter && typeof pipelineForJob.context.currentChapter === 'object' + ? pipelineForJob.context.currentChapter + : null; + const chapterLabel = currentChapter?.index && currentChapter?.total + ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}${currentChapter.title ? `: ${currentChapter.title}` : ''}` + : ''; + const metaLine = buildAudiobookJobMetaLine(jobId, pipelineForJob?.context?.selectedMetadata, job?.year); if (!isExpanded) { return ( @@ -427,25 +490,30 @@ export default function AudiobooksPage({ className="ripper-job-row" onClick={() => setExpandedJobId(jobId)} > -
Kein Cover
+ {(job?.poster_url && job.poster_url !== 'N/A') ? ( + {title} + ) : ( +
Kein Cover
+ )}
{title} - #{jobId} + {metaLine}
-
{showProgress ? (
+ {etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}
) : null}
+