diff --git a/.claude/settings.json b/.claude/settings.json index 4a56436..0ba7fca 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -22,7 +22,8 @@ "Bash(lsblk -J -o NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL)", "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(e\\) for e in d.get\\(''''blockdevices'''',[]\\)]\")", "Bash(lsblk -o NAME,TYPE,MODEL)", - "Bash(node --check /home/michael/ripster/backend/src/routes/pipelineRoutes.js)" + "Bash(node --check /home/michael/ripster/backend/src/routes/pipelineRoutes.js)", + "Bash(grep -n \"inferMediaProfile\" /home/michael/ripster/backend/src/services/*.js)" ] } } diff --git a/backend/package-lock.json b/backend/package-lock.json index 889cc9a..73ca7c9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.12.0", + "version": "0.12.0-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.12.0", + "version": "0.12.0-1", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index be26a68..cbec49e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.12.0", + "version": "0.12.0-1", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 875d6eb..46d9c7c 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -530,6 +530,7 @@ async function openAndPrepareDatabase() { await removeDeprecatedSettings(dbInstance); await migrateSettingsSchemaMetadata(dbInstance); await ensurePipelineStateRow(dbInstance); + await backfillJobOutputFolders(dbInstance); const syncedLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true }); logger.info('log-root:synced', { configured: syncedLogRoot.configured || null, @@ -974,51 +975,214 @@ async function migrateSettingsSchemaMetadata(db) { ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('auto_eject_after_rip', 'false')`); - // Plugin-Architektur Toggle (Phase 2) + await db.run( + `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)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true')`); + + await db.run( + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) + VALUES ('coverart_recovery_interval_hours', 'Metadaten', 'Coverart-Prüfintervall (Stunden)', 'number', 1, 'Intervall für automatische Prüfung fehlender Coverarts in Stunden.', '6', '[]', '{"min":1,"max":168}', 431)` + ); + await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6')`); + + // depends_on-Spalte zu settings_schema hinzufügen (falls noch nicht vorhanden) + { + const cols = await db.all(`PRAGMA table_info(settings_schema)`); + if (!cols.some((c) => c.name === 'depends_on')) { + await db.run(`ALTER TABLE settings_schema ADD COLUMN depends_on TEXT DEFAULT NULL`); + logger.info('migrate:settings-schema-add-depends_on'); + } + } + + // Plugin-Architektur Toggle (Phase 2) — hierarchisch mit depends_on + // Struktur: global → medium (depends_on: global) → phase (depends_on: medium) + // + // Implementierungsstand: + // ✅ = plugin-pfad aktiv ⏳ = noch nicht implementiert (readonly, immer aus) + // + // BluRay / DVD: Analyse ✅ Rip ✅ Review ⏳ Encode ⏳ + // CD: Analyse ✅ Rip ⏳ + // Audiobook: Analyse ✅ Encode ⏳ + const pluginArchitectureSettings = [ + // ── Global ────────────────────────────────────────────────────────────── { key: 'use_plugin_architecture', label: 'Neue Plugin-Architektur (Beta)', - description: 'Aktiviert die neue modulare Plugin-Architektur (Phase 2). Legacy-Code bleibt aktiv solange dieser Toggle deaktiviert ist. Nur für Testzwecke aktivieren.', + description: 'Masterschalter: Aktiviert die neue modulare Plugin-Architektur (Phase 2). Alle Medium-Schalter darunter sind nur wirksam wenn dieser aktiv ist.', defaultValue: 'false', - orderIndex: 9999 + orderIndex: 9999, + dependsOn: null, + validationJson: '{}' }, + // ── Blu-ray ────────────────────────────────────────────────────────────── { key: 'use_plugin_architecture_bluray', - label: 'Beta: Blu-ray Plugin aktiv', - description: 'Aktiviert das Blu-ray Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', + label: 'Blu-ray Plugin', + description: 'Aktiviert das Blu-ray Plugin. Deaktiviert = Legacy-Pfad für alle Blu-ray-Phasen.', defaultValue: 'true', - orderIndex: 10000 + orderIndex: 10000, + dependsOn: 'use_plugin_architecture', + validationJson: '{}' }, + { + key: 'use_plugin_architecture_bluray_analyze', + label: 'Analyse (Blu-ray)', + description: 'Disc-Erkennung und OMDB-Suche laufen über das Plugin.', + defaultValue: 'true', + orderIndex: 10001, + dependsOn: 'use_plugin_architecture_bluray', + validationJson: '{}' + }, + { + key: 'use_plugin_architecture_bluray_rip', + label: 'Rip (Blu-ray)', + description: 'MakeMKV-Rip läuft über das Plugin.', + defaultValue: 'true', + orderIndex: 10002, + dependsOn: 'use_plugin_architecture_bluray', + validationJson: '{}' + }, + { + key: 'use_plugin_architecture_bluray_review', + label: 'Review / Mediainfo-Prüfung (Blu-ray)', + description: 'Noch nicht implementiert — kommt in einem späteren Sprint.', + defaultValue: 'false', + orderIndex: 10003, + dependsOn: 'use_plugin_architecture_bluray', + validationJson: '{"readonly":true}' + }, + { + key: 'use_plugin_architecture_bluray_encode', + label: 'Encoding (Blu-ray)', + description: 'Noch nicht implementiert — kommt in einem späteren Sprint.', + defaultValue: 'false', + orderIndex: 10004, + dependsOn: 'use_plugin_architecture_bluray', + validationJson: '{"readonly":true}' + }, + // ── DVD ────────────────────────────────────────────────────────────────── { key: 'use_plugin_architecture_dvd', - label: 'Beta: DVD Plugin aktiv', - description: 'Aktiviert das DVD Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', + label: 'DVD Plugin', + description: 'Aktiviert das DVD Plugin. Deaktiviert = Legacy-Pfad für alle DVD-Phasen.', defaultValue: 'true', - orderIndex: 10001 + orderIndex: 10010, + dependsOn: 'use_plugin_architecture', + validationJson: '{}' }, + { + key: 'use_plugin_architecture_dvd_analyze', + label: 'Analyse (DVD)', + description: 'Disc-Erkennung und OMDB-Suche laufen über das Plugin.', + defaultValue: 'true', + orderIndex: 10011, + dependsOn: 'use_plugin_architecture_dvd', + validationJson: '{}' + }, + { + key: 'use_plugin_architecture_dvd_rip', + label: 'Rip (DVD)', + description: 'MakeMKV-Rip läuft über das Plugin.', + defaultValue: 'true', + orderIndex: 10012, + dependsOn: 'use_plugin_architecture_dvd', + validationJson: '{}' + }, + { + key: 'use_plugin_architecture_dvd_review', + label: 'Review / Mediainfo-Prüfung (DVD)', + description: 'Noch nicht implementiert — kommt in einem späteren Sprint.', + defaultValue: 'false', + orderIndex: 10013, + dependsOn: 'use_plugin_architecture_dvd', + validationJson: '{"readonly":true}' + }, + { + key: 'use_plugin_architecture_dvd_encode', + label: 'Encoding (DVD)', + description: 'Noch nicht implementiert — kommt in einem späteren Sprint.', + defaultValue: 'false', + orderIndex: 10014, + dependsOn: 'use_plugin_architecture_dvd', + validationJson: '{"readonly":true}' + }, + // ── Audio-CD ───────────────────────────────────────────────────────────── { key: 'use_plugin_architecture_cd', - label: 'Beta: Audio-CD Plugin aktiv', - description: 'Aktiviert das Audio-CD Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', + label: 'Audio-CD Plugin', + description: 'Aktiviert das Audio-CD Plugin. Deaktiviert = Legacy-Pfad für alle CD-Phasen.', defaultValue: 'true', - orderIndex: 10002 + orderIndex: 10020, + dependsOn: 'use_plugin_architecture', + validationJson: '{}' }, { - key: 'use_plugin_architecture_audiobook', - label: 'Beta: Audiobook Plugin aktiv', - description: 'Aktiviert das Audiobook Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', + key: 'use_plugin_architecture_cd_analyze', + label: 'Analyse (Audio-CD)', + description: 'TOC-Auslesung läuft über das Plugin.', defaultValue: 'true', - orderIndex: 10003 + orderIndex: 10021, + dependsOn: 'use_plugin_architecture_cd', + validationJson: '{}' + }, + { + key: 'use_plugin_architecture_cd_rip', + label: 'Rip + Encode (Audio-CD)', + description: 'Noch nicht implementiert — cdparanoia-Rip läuft noch über Legacy.', + defaultValue: 'false', + orderIndex: 10022, + dependsOn: 'use_plugin_architecture_cd', + validationJson: '{"readonly":true}' + }, + // ── Audiobook ──────────────────────────────────────────────────────────── + { + key: 'use_plugin_architecture_audiobook', + label: 'Audiobook Plugin', + description: 'Aktiviert das Audiobook Plugin. Deaktiviert = Legacy-Pfad für alle Audiobook-Phasen.', + defaultValue: 'true', + orderIndex: 10030, + dependsOn: 'use_plugin_architecture', + validationJson: '{}' + }, + { + key: 'use_plugin_architecture_audiobook_analyze', + label: 'Analyse (Audiobook)', + description: 'ffprobe-Metadatenanalyse läuft über das Plugin.', + defaultValue: 'true', + orderIndex: 10031, + dependsOn: 'use_plugin_architecture_audiobook', + validationJson: '{}' + }, + { + key: 'use_plugin_architecture_audiobook_encode', + label: 'Encoding (Audiobook)', + description: 'Noch nicht implementiert — Encoding läuft noch über Legacy.', + defaultValue: 'false', + orderIndex: 10032, + dependsOn: 'use_plugin_architecture_audiobook', + validationJson: '{"readonly":true}' } ]; for (const setting of pluginArchitectureSettings) { await db.run( - `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) - VALUES (?, 'Erweitert', ?, 'boolean', 1, ?, ?, '[]', '{}', ?)`, - [setting.key, setting.label, setting.description, setting.defaultValue, setting.orderIndex] + `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on) + VALUES (?, 'Erweitert', ?, 'boolean', 1, ?, ?, '[]', ?, ?, ?)`, + [setting.key, setting.label, setting.description, setting.defaultValue, setting.validationJson, setting.orderIndex, setting.dependsOn ?? null] + ); + // depends_on und order_index für bereits existierende Zeilen aktualisieren + await db.run( + `UPDATE settings_schema SET depends_on = ?, order_index = ?, validation_json = ?, updated_at = CURRENT_TIMESTAMP + WHERE key = ? AND (depends_on IS NOT ? OR order_index != ? OR validation_json != ?)`, + [setting.dependsOn ?? null, setting.orderIndex, setting.validationJson, setting.key, setting.dependsOn ?? null, setting.orderIndex, setting.validationJson] ); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES (?, ?)`, [setting.key, setting.defaultValue]); + // Readonly-Toggles immer auf 'false' zurücksetzen (nie aktivierbar) + if (setting.validationJson === '{"readonly":true}') { + await db.run(`UPDATE settings_values SET value = 'false' WHERE key = ?`, [setting.key]); + } } await db.run(` @@ -1030,6 +1194,44 @@ async function migrateSettingsSchemaMetadata(db) { `); } +async function backfillJobOutputFolders(db) { + // Remove duplicate (job_id, output_path) rows before unique index is enforced. + await db.run(` + DELETE FROM job_output_folders + WHERE id NOT IN ( + SELECT MIN(id) FROM job_output_folders GROUP BY job_id, output_path + ) + `); + + // Populate job_output_folders from jobs.output_path for pre-feature jobs. + // Only inserts rows where no entry exists for the job yet. + const rows = await db.all(` + SELECT j.id AS job_id, j.output_path + FROM jobs j + WHERE j.output_path IS NOT NULL + AND j.output_path != '' + AND NOT EXISTS ( + SELECT 1 FROM job_output_folders f WHERE f.job_id = j.id + ) + `); + if (!Array.isArray(rows) || rows.length === 0) return; + let inserted = 0; + for (const row of rows) { + try { + await db.run( + 'INSERT OR IGNORE INTO job_output_folders (job_id, output_path) VALUES (?, ?)', + [row.job_id, row.output_path] + ); + inserted += 1; + } catch (err) { + logger.warn('backfill:job-output-folders:insert-failed', { jobId: row.job_id, error: err?.message }); + } + } + if (inserted > 0) { + logger.info('backfill:job-output-folders:done', { inserted }); + } +} + async function getDb() { return initDatabase(); } diff --git a/backend/src/index.js b/backend/src/index.js index cde9e65..cb5accb 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -19,6 +19,7 @@ const cronService = require('./services/cronService'); const downloadService = require('./services/downloadService'); const diskDetectionService = require('./services/diskDetectionService'); const hardwareMonitorService = require('./services/hardwareMonitorService'); +const coverArtRecoveryService = require('./services/coverArtRecoveryService'); const logger = require('./services/logger').child('BOOT'); const { errorToMeta } = require('./utils/errorMeta'); const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService'); @@ -29,6 +30,7 @@ async function start() { await pipelineService.init(); await cronService.init(); await downloadService.init(); + await coverArtRecoveryService.init(); const app = express(); app.use(cors({ origin: corsOrigin })); @@ -85,6 +87,7 @@ async function start() { const shutdown = () => { logger.warn('backend:shutdown:received'); diskDetectionService.stop(); + coverArtRecoveryService.stop(); hardwareMonitorService.stop(); cronService.stop(); server.close(() => { diff --git a/backend/src/routes/downloadRoutes.js b/backend/src/routes/downloadRoutes.js index 7906581..48eb3c0 100644 --- a/backend/src/routes/downloadRoutes.js +++ b/backend/src/routes/downloadRoutes.js @@ -30,12 +30,14 @@ router.post( asyncHandler(async (req, res) => { const jobId = Number(req.params.jobId); const target = String(req.body?.target || 'raw').trim(); + const outputPath = String(req.body?.outputPath || '').trim() || null; logger.info('post:downloads:history', { reqId: req.reqId, jobId, - target + target, + outputPath }); - const result = await downloadService.enqueueHistoryJob(jobId, target); + const result = await downloadService.enqueueHistoryJob(jobId, target, { outputPath }); res.status(result.created ? 201 : 200).json({ ...result, summary: downloadService.getSummary() diff --git a/backend/src/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js index b3eb49c..3a81174 100644 --- a/backend/src/routes/historyRoutes.js +++ b/backend/src/routes/historyRoutes.js @@ -148,15 +148,21 @@ router.post( const id = Number(req.params.id); const target = String(req.body?.target || 'none'); const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase()); + const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths) + ? req.body.selectedMoviePaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; logger.warn('post:delete-job', { reqId: req.reqId, id, target, - includeRelated + includeRelated, + selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0 }); - const result = await historyService.deleteJob(id, target, { includeRelated }); + const result = await historyService.deleteJob(id, target, { includeRelated, selectedMoviePaths }); const uiReset = await pipelineService.resetFrontendState('history_delete'); res.json({ ...result, uiReset }); }) diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index af1872c..7adda3c 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -4,6 +4,7 @@ const path = require('path'); const multer = require('multer'); const asyncHandler = require('../middleware/asyncHandler'); const pipelineService = require('../services/pipelineService'); +const historyService = require('../services/historyService'); const diskDetectionService = require('../services/diskDetectionService'); const hardwareMonitorService = require('../services/hardwareMonitorService'); const logger = require('../services/logger').child('PIPELINE_ROUTE'); @@ -346,12 +347,34 @@ router.post( }) ); +router.get( + '/output-folders/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const folders = await historyService.getJobOutputFoldersForLineage(jobId); + res.json({ folders }); + }) +); + +router.post( + '/delete-output-folders/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const folderPaths = Array.isArray(req.body?.folderPaths) ? req.body.folderPaths : []; + logger.info('post:delete-output-folders', { reqId: req.reqId, jobId, count: folderPaths.length }); + const result = await historyService.deleteSpecificOutputFolders(jobId, folderPaths); + res.json({ result }); + }) +); + router.post( '/reencode/:jobId', asyncHandler(async (req, res) => { const jobId = Number(req.params.jobId); - logger.info('post:reencode', { reqId: req.reqId, jobId }); - const result = await pipelineService.reencodeFromRaw(jobId); + const keepBoth = Boolean(req.body?.keepBoth); + const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; + logger.info('post:reencode', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length }); + const result = await pipelineService.reencodeFromRaw(jobId, { keepBoth, deleteFolders }); res.json({ result }); }) ); @@ -360,8 +383,10 @@ router.post( '/restart-review/:jobId', asyncHandler(async (req, res) => { const jobId = Number(req.params.jobId); - logger.info('post:restart-review', { reqId: req.reqId, jobId }); - const result = await pipelineService.restartReviewFromRaw(jobId); + const keepBoth = Boolean(req.body?.keepBoth); + const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; + logger.info('post:restart-review', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length }); + const result = await pipelineService.restartReviewFromRaw(jobId, { keepBoth, deleteFolders }); res.json({ result }); }) ); @@ -370,8 +395,22 @@ router.post( '/restart-encode/:jobId', asyncHandler(async (req, res) => { const jobId = Number(req.params.jobId); - logger.info('post:restart-encode', { reqId: req.reqId, jobId }); - const result = await pipelineService.restartEncodeWithLastSettings(jobId); + const keepBoth = Boolean(req.body?.keepBoth); + const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; + logger.info('post:restart-encode', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length }); + const result = await pipelineService.restartEncodeWithLastSettings(jobId, { keepBoth, deleteFolders }); + res.json({ result }); + }) +); + +router.post( + '/restart-cd-review/:jobId', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const keepBoth = Boolean(req.body?.keepBoth); + const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : []; + logger.info('post:restart-cd-review', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length }); + const result = await pipelineService.restartCdReviewFromRaw(jobId, { keepBoth, deleteFolders }); res.json({ result }); }) ); diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index d2ca698..ebfe4c7 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -10,6 +10,7 @@ const hardwareMonitorService = require('../services/hardwareMonitorService'); const userPresetService = require('../services/userPresetService'); const activationBytesService = require('../services/activationBytesService'); const diskDetectionService = require('../services/diskDetectionService'); +const coverArtRecoveryService = require('../services/coverArtRecoveryService'); const logger = require('../services/logger').child('SETTINGS_ROUTE'); const { getDb } = require('../db/database'); @@ -210,6 +211,22 @@ router.delete( }) ); +router.post( + '/coverart/recover', + asyncHandler(async (req, res) => { + logger.info('post:settings:coverart:recover', { reqId: req.reqId }); + const result = await coverArtRecoveryService.runNow({ + trigger: 'manual', + force: true, + logFailures: true + }); + res.json({ + result, + scheduler: coverArtRecoveryService.getStatus() + }); + }) +); + router.put( '/:key', asyncHandler(async (req, res) => { @@ -259,6 +276,18 @@ router.put( } }); } + try { + await coverArtRecoveryService.handleSettingsChanged([key]); + } catch (error) { + logger.warn('put:setting:coverart-scheduler-refresh-failed', { + reqId: req.reqId, + key, + error: { + name: error?.name, + message: error?.message + } + }); + } wsService.broadcast('SETTINGS_UPDATED', updated); res.json({ setting: updated, reviewRefresh }); @@ -312,6 +341,17 @@ router.put( } }); } + try { + await coverArtRecoveryService.handleSettingsChanged(changes.map((item) => item.key)); + } catch (error) { + logger.warn('put:settings:bulk:coverart-scheduler-refresh-failed', { + reqId: req.reqId, + error: { + name: error?.name, + message: error?.message + } + }); + } wsService.broadcast('SETTINGS_BULK_UPDATED', { count: changes.length, keys: changes.map((item) => item.key) }); res.json({ changes, reviewRefresh }); diff --git a/backend/src/services/cdRipService.js b/backend/src/services/cdRipService.js index fd05490..5a3e1d6 100644 --- a/backend/src/services/cdRipService.js +++ b/backend/src/services/cdRipService.js @@ -244,7 +244,19 @@ function outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir) { } const offset = outputSegments.length - relativeSegments.length; for (let i = 0; i < relativeSegments.length; i++) { - if (outputSegments[offset + i] !== relativeSegments[i]) { + const outputPart = outputSegments[offset + i]; + const relativePart = relativeSegments[i]; + if (outputPart === relativePart) { + continue; + } + // Numbered conflict folders end with "_X" (e.g. ".../Album (2007)_2"). + // Treat this as containing the same relative directory to avoid nesting: + // Album (2007)_2/Album (2007)/... + const isLastRelativeSegment = i === relativeSegments.length - 1; + const matchesNumberedSuffix = isLastRelativeSegment + && outputPart.startsWith(`${relativePart}_`) + && /^\d+$/.test(outputPart.slice(relativePart.length + 1)); + if (!matchesNumberedSuffix) { return false; } } diff --git a/backend/src/services/coverArtRecoveryService.js b/backend/src/services/coverArtRecoveryService.js new file mode 100644 index 0000000..1bc2646 --- /dev/null +++ b/backend/src/services/coverArtRecoveryService.js @@ -0,0 +1,393 @@ +const { getDb } = require('../db/database'); +const logger = require('./logger').child('COVERART_RECOVERY'); +const settingsService = require('./settingsService'); +const historyService = require('./historyService'); +const thumbnailService = require('./thumbnailService'); + +const COVERART_RECOVERY_ENABLED_KEY = 'coverart_recovery_enabled'; +const COVERART_RECOVERY_INTERVAL_HOURS_KEY = 'coverart_recovery_interval_hours'; +const DEFAULT_INTERVAL_HOURS = 6; +const MIN_INTERVAL_HOURS = 1; +const MAX_INTERVAL_HOURS = 168; +const RUNNING_JOB_STATUSES = new Set([ + 'ANALYZING', + 'RIPPING', + 'MEDIAINFO_CHECK', + 'ENCODING', + 'CD_ANALYZING', + 'CD_RIPPING', + 'CD_ENCODING' +]); + +function parseJsonSafe(raw, fallback = null) { + if (!raw) { + return fallback; + } + try { + return JSON.parse(raw); + } catch (_error) { + return fallback; + } +} + +function toBoolean(value, fallback = false) { + if (value === null || value === undefined) { + return fallback; + } + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value !== 0; + } + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized) { + return fallback; + } + return ['1', 'true', 'yes', 'on'].includes(normalized); +} + +function normalizeIntervalHours(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return DEFAULT_INTERVAL_HOURS; + } + return Math.max(MIN_INTERVAL_HOURS, Math.min(MAX_INTERVAL_HOURS, Math.trunc(parsed))); +} + +function normalizeExternalUrl(value) { + const normalized = String(value || '').trim(); + if (!normalized) { + return null; + } + if (!/^https?:\/\//i.test(normalized)) { + return null; + } + return normalized; +} + +function deriveCoverArtArchiveUrl(mbId) { + const normalized = String(mbId || '').trim(); + if (!normalized) { + return null; + } + return `https://coverartarchive.org/release/${encodeURIComponent(normalized)}/front-250`; +} + +function isLikelyMusicBrainzId(value) { + const normalized = String(value || '').trim(); + if (!normalized) { + return false; + } + if (/^tt\d{6,12}$/i.test(normalized)) { + return false; + } + return /^[a-z0-9-]{8,}$/i.test(normalized); +} + +function collectCoverCandidates(row) { + const candidates = []; + const seen = new Set(); + const push = (url, source) => { + const normalized = normalizeExternalUrl(url); + if (!normalized) { + return; + } + const dedupeKey = normalized.toLowerCase(); + if (seen.has(dedupeKey)) { + return; + } + seen.add(dedupeKey); + candidates.push({ + url: normalized, + source: String(source || '').trim() || null + }); + }; + + push(row?.poster_url, 'job.poster_url'); + + const makemkvInfo = parseJsonSafe(row?.makemkv_info_json, {}); + const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object' + ? makemkvInfo.selectedMetadata + : {}; + + push(selectedMetadata?.coverUrl, 'selectedMetadata.coverUrl'); + push(selectedMetadata?.poster, 'selectedMetadata.poster'); + push(selectedMetadata?.posterUrl, 'selectedMetadata.posterUrl'); + + const mbId = String( + selectedMetadata?.mbId + || selectedMetadata?.musicBrainzId + || selectedMetadata?.musicbrainzId + || selectedMetadata?.musicbrainz_id + || selectedMetadata?.music_brainz_id + || selectedMetadata?.musicbrainz + || selectedMetadata?.mbid + || row?.imdb_id + || '' + ).trim(); + if (isLikelyMusicBrainzId(mbId)) { + 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; +} + +class CoverArtRecoveryService { + constructor() { + this.timer = null; + this.inFlight = null; + this.nextRunAt = null; + this.schedulerEnabled = false; + this.intervalHours = DEFAULT_INTERVAL_HOURS; + this.lastRunSummary = null; + } + + getStatus() { + return { + enabled: this.schedulerEnabled, + intervalHours: this.intervalHours, + nextRunAt: this.nextRunAt, + running: Boolean(this.inFlight), + lastRunSummary: this.lastRunSummary + }; + } + + stop() { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.nextRunAt = null; + } + + async init() { + await this.refreshSchedule({ runStartupCheck: true }); + } + + async handleSettingsChanged(changedKeys = []) { + const normalizedKeys = Array.isArray(changedKeys) + ? changedKeys.map((key) => String(key || '').trim().toLowerCase()).filter(Boolean) + : []; + if ( + normalizedKeys.length > 0 + && !normalizedKeys.includes(COVERART_RECOVERY_ENABLED_KEY) + && !normalizedKeys.includes(COVERART_RECOVERY_INTERVAL_HOURS_KEY) + ) { + return this.getStatus(); + } + return this.refreshSchedule({ runStartupCheck: false }); + } + + async refreshSchedule(options = {}) { + const runStartupCheck = options?.runStartupCheck !== false; + this.stop(); + + const settings = await settingsService.getSettingsMap(); + this.schedulerEnabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true); + this.intervalHours = normalizeIntervalHours(settings?.[COVERART_RECOVERY_INTERVAL_HOURS_KEY]); + + logger.info('scheduler:refresh', { + enabled: this.schedulerEnabled, + intervalHours: this.intervalHours, + runStartupCheck + }); + + if (this.schedulerEnabled && runStartupCheck) { + this.runNow({ trigger: 'startup' }).catch((error) => { + logger.warn('scheduler:startup-run-failed', { + error: error?.message || String(error) + }); + }); + } + + if (this.schedulerEnabled) { + this.scheduleNextAutoRun(); + } + return this.getStatus(); + } + + scheduleNextAutoRun() { + this.stop(); + if (!this.schedulerEnabled) { + return; + } + const delayMs = this.intervalHours * 60 * 60 * 1000; + this.nextRunAt = new Date(Date.now() + delayMs).toISOString(); + this.timer = setTimeout(() => { + this.runNow({ trigger: 'auto' }) + .catch((error) => { + logger.warn('scheduler:auto-run-failed', { + error: error?.message || String(error) + }); + }) + .finally(() => { + this.scheduleNextAutoRun(); + }); + }, delayMs); + } + + async runNow(options = {}) { + if (this.inFlight) { + return this.inFlight; + } + let promise = null; + promise = this._runNowInternal(options) + .finally(() => { + if (this.inFlight === promise) { + this.inFlight = null; + } + }); + this.inFlight = promise; + return promise; + } + + async _runNowInternal(options = {}) { + const trigger = String(options?.trigger || 'manual').trim().toLowerCase() || 'manual'; + const force = Boolean(options?.force); + const logFailures = options?.logFailures !== false; + const startedMs = Date.now(); + const startedAt = new Date().toISOString(); + const settings = await settingsService.getSettingsMap(); + const enabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true); + + if (!enabled && !force) { + const skipped = { + trigger, + startedAt, + finishedAt: new Date().toISOString(), + durationMs: 0, + skipped: true, + reason: 'disabled' + }; + this.lastRunSummary = skipped; + return skipped; + } + + const db = await getDb(); + const rows = await db.all( + ` + SELECT + id, + title, + detected_title, + status, + poster_url, + imdb_id, + makemkv_info_json, + omdb_json + FROM jobs + ORDER BY COALESCE(updated_at, created_at) DESC, id DESC + ` + ); + + const summary = { + trigger, + startedAt, + finishedAt: null, + durationMs: 0, + scannedJobs: Array.isArray(rows) ? rows.length : 0, + runningSkipped: 0, + alreadyLocal: 0, + noCandidate: 0, + recovered: 0, + failed: 0, + failedJobs: [] + }; + + for (const row of rows || []) { + const jobId = Number(row?.id || 0); + if (!jobId) { + continue; + } + + const status = String(row?.status || '').trim().toUpperCase(); + if (RUNNING_JOB_STATUSES.has(status)) { + summary.runningSkipped += 1; + continue; + } + + const currentPosterUrl = String(row?.poster_url || '').trim(); + if ( + currentPosterUrl + && thumbnailService.isLocalUrl(currentPosterUrl) + && thumbnailService.localThumbnailUrlExists(currentPosterUrl) + ) { + summary.alreadyLocal += 1; + continue; + } + + const candidates = collectCoverCandidates(row); + if (candidates.length === 0) { + summary.noCandidate += 1; + continue; + } + + let recovered = false; + let lastError = null; + for (const candidate of candidates) { + const result = await historyService.cacheAndPromoteExternalPoster(jobId, candidate.url, { + source: 'Coverart', + logFailures: false + }); + if (result?.ok && result?.localUrl) { + recovered = true; + summary.recovered += 1; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Coverart nachgeladen (${trigger}): ${candidate.url}${candidate?.source ? ` [${candidate.source}]` : ''}` + ); + break; + } + lastError = String(result?.error || result?.reason || 'download_failed'); + } + + if (!recovered) { + summary.failed += 1; + const failedInfo = { + jobId, + title: String(row?.title || row?.detected_title || `Job #${jobId}`), + attemptedUrls: candidates.map((item) => item.url), + error: lastError + }; + summary.failedJobs.push(failedInfo); + if (logFailures && trigger !== 'auto') { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Coverart-Download fehlgeschlagen (${trigger}). Versuchte Links: ${failedInfo.attemptedUrls.join(', ')}${lastError ? ` | Fehler: ${lastError}` : ''}` + ); + } + } + } + + const finishedAt = new Date().toISOString(); + const durationMs = Math.max(0, Date.now() - startedMs); + const result = { + ...summary, + finishedAt, + durationMs + }; + this.lastRunSummary = result; + logger.info('recovery:done', { + trigger: result.trigger, + scannedJobs: result.scannedJobs, + recovered: result.recovered, + failed: result.failed, + alreadyLocal: result.alreadyLocal, + noCandidate: result.noCandidate, + runningSkipped: result.runningSkipped, + durationMs: result.durationMs + }); + return result; + } +} + +module.exports = new CoverArtRecoveryService(); diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js index f69525a..7a5ac79 100644 --- a/backend/src/services/diskDetectionService.js +++ b/backend/src/services/diskDetectionService.js @@ -170,14 +170,41 @@ function inferMediaProfileFromUdevProperties(properties = {}) { return acc; }, {}); + const parseTrackCount = (rawValue) => { + const normalized = String(rawValue ?? '').trim(); + if (!normalized) { + return null; + } + const parsed = Number.parseInt(normalized, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + return parsed; + }; + const hasFlag = (prefix) => Object.entries(flags).some(([key, value]) => key.startsWith(prefix) && value === '1'); - if (hasFlag('ID_CDROM_MEDIA_BD')) { + const hasBD = hasFlag('ID_CDROM_MEDIA_BD'); + const hasDVD = hasFlag('ID_CDROM_MEDIA_DVD'); + const hasCD = hasFlag('ID_CDROM_MEDIA_CD'); + const audioTrackCount = parseTrackCount(flags.ID_CDROM_MEDIA_TRACK_COUNT_AUDIO); + const dataTrackCount = parseTrackCount(flags.ID_CDROM_MEDIA_TRACK_COUNT_DATA); + const hasAudioTracks = Number.isFinite(audioTrackCount) && audioTrackCount > 0; + const hasDataTracks = Number.isFinite(dataTrackCount) && dataTrackCount > 0; + + // Prefer audio-CD detection when udev exposes track counters. + if (hasCD && hasAudioTracks && !hasDataTracks) { + return 'cd'; + } + if (hasCD && !hasDVD && !hasBD) { + return 'cd'; + } + if (hasBD) { return 'bluray'; } - if (hasFlag('ID_CDROM_MEDIA_DVD')) { + if (hasDVD) { return 'dvd'; } - if (hasFlag('ID_CDROM_MEDIA_CD')) { + if (hasCD) { return 'cd'; } return null; @@ -479,6 +506,26 @@ class DiskDetectionService extends EventEmitter { } } + // Supplement: any drive already tracked in detectedDiscs that was not found by auto-scan + // gets a second chance via detectExplicit (which includes the sysfs-size fallback). + // This prevents polling from falsely emitting discRemoved for drives that were manually + // rescanned but cannot be auto-detected (e.g. VM/passthrough devices invisible to lsblk). + const foundPaths = new Set(autoResults.map((d) => String(d.path || ''))); + const supplementChecks = []; + for (const [trackedPath] of this.detectedDiscs) { + if (!trackedPath.startsWith('__virtual__') && !foundPaths.has(trackedPath)) { + supplementChecks.push(this.detectExplicit(trackedPath)); + } + } + if (supplementChecks.length > 0) { + const supplementResults = await Promise.all(supplementChecks); + for (const result of supplementResults) { + if (result) { + autoResults.push(result); + } + } + } + return autoResults; } @@ -590,16 +637,24 @@ class DiskDetectionService extends EventEmitter { return null; } + const details = await this.getBlockDeviceInfo(); + const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {}; + + // Always call checkMediaPresent to get the filesystem type (needed for accurate + // mediaProfile detection). Use lsblk SIZE as fallback presence indicator for + // drives where blkid/udevadm fail (VM/passthrough). const mediaState = await this.checkMediaPresent(devicePath); - if (!mediaState.hasMedia) { + const hasSizeMedia = (match.sizeBytes || 0) > 0; + if (!mediaState.hasMedia && !hasSizeMedia) { logger.debug('detect:explicit:no-media', { devicePath }); return null; } + if (!mediaState.hasMedia && hasSizeMedia) { + logger.debug('detect:explicit:media-by-size-fallback', { devicePath, sizeBytes: match.sizeBytes }); + } + const mediaType = mediaState.type; const discLabel = await this.getDiscLabel(devicePath); - - const details = await this.getBlockDeviceInfo(); - const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {}; - const detectedFsType = String(match.fstype || mediaState.type || '').trim() || null; + const detectedFsType = String(match.fstype || mediaType || '').trim() || null; const mediaProfile = await this.inferMediaProfile(devicePath, { discLabel, @@ -697,12 +752,21 @@ class DiskDetectionService extends EventEmitter { continue; } + // Always call checkMediaPresent to get the filesystem type (needed for accurate + // mediaProfile detection via inferMediaProfile). Use lsblk SIZE as a fallback + // presence indicator for drives where blkid/udevadm fail (VM/passthrough). const mediaState = await this.checkMediaPresent(path); - if (!mediaState.hasMedia) { + const hasSizeMedia = item.sizeBytes > 0; + if (!mediaState.hasMedia && !hasSizeMedia) { + logger.debug('detect:all-auto:no-media', { path, sizeBytes: item.sizeBytes }); continue; } + if (!mediaState.hasMedia && hasSizeMedia) { + logger.debug('detect:all-auto:media-by-size-fallback', { path, sizeBytes: item.sizeBytes }); + } + const mediaType = mediaState.type; const discLabel = await this.getDiscLabel(path); - const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null; + const detectedFsType = String(item.fstype || mediaType || '').trim() || null; const mediaProfile = await this.inferMediaProfile(path, { discLabel, @@ -736,8 +800,9 @@ class DiskDetectionService extends EventEmitter { try { const { stdout } = await execFileAsync('lsblk', [ '-J', + '-b', '-o', - 'NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL' + 'NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL,SIZE' ]); const parsed = JSON.parse(stdout); const devices = flattenDevices(parsed.blockdevices || []).map((entry) => ({ @@ -747,7 +812,8 @@ class DiskDetectionService extends EventEmitter { mountpoint: entry.mountpoint, fstype: entry.fstype, label: entry.label, - model: entry.model + model: entry.model, + sizeBytes: Number(entry.size) || 0 })); logger.debug('lsblk:ok', { deviceCount: devices.length }); return devices; @@ -757,6 +823,38 @@ class DiskDetectionService extends EventEmitter { } } + async probeAudioCdWithCdparanoia(devicePath, command = 'cdparanoia') { + const cdparanoiaCmd = String(command || '').trim() || 'cdparanoia'; + try { + const { stdout, stderr } = await execFileAsync(cdparanoiaCmd, ['-Q', '-d', devicePath], { timeout: 10000 }); + const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`); + if (tracks.length > 0) { + logger.debug('cdparanoia:audio-cd', { devicePath, cmd: cdparanoiaCmd, trackCount: tracks.length }); + return true; + } + logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath, cmd: cdparanoiaCmd }); + return true; + } catch (error) { + const stderr = String(error?.stderr || ''); + const stdout = String(error?.stdout || ''); + const tracks = parseToc(`${stderr}\n${stdout}`); + if (tracks.length > 0) { + logger.debug('cdparanoia:audio-cd-from-error-streams', { + devicePath, + cmd: cdparanoiaCmd, + trackCount: tracks.length + }); + return true; + } + logger.debug('cdparanoia:no-audio-cd', { + devicePath, + cmd: cdparanoiaCmd, + error: errorToMeta(error) + }); + return false; + } + } + async checkMediaPresent(devicePath) { let blkidType = null; let blkidError = null; @@ -789,21 +887,22 @@ class DiskDetectionService extends EventEmitter { } props[line.slice(0, idx).trim().toUpperCase()] = line.slice(idx + 1).trim(); } - const hasBD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_BD') && props[k] === '1'); - const hasDVD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_DVD') && props[k] === '1'); - const hasCD = props['ID_CDROM_MEDIA_CD'] === '1'; - logger.info('check-media:udevadm', { devicePath, hasBD, hasDVD, hasCD }); - if (hasCD && !hasDVD && !hasBD) { + const inferredByUdev = inferMediaProfileFromUdevProperties(props); + const audioTrackCount = Number.parseInt(String(props.ID_CDROM_MEDIA_TRACK_COUNT_AUDIO || '').trim(), 10); + const dataTrackCount = Number.parseInt(String(props.ID_CDROM_MEDIA_TRACK_COUNT_DATA || '').trim(), 10); + logger.info('check-media:udevadm', { + devicePath, + inferredByUdev, + audioTrackCount: Number.isFinite(audioTrackCount) ? audioTrackCount : null, + dataTrackCount: Number.isFinite(dataTrackCount) ? dataTrackCount : null + }); + if (inferredByUdev === 'cd') { logger.debug('udevadm:audio-cd', { devicePath }); return { hasMedia: true, type: 'audio_cd' }; } - if (hasBD) { - logger.debug('udevadm:bluray', { devicePath }); - return { hasMedia: true, type: 'udf' }; - } - if (hasDVD) { - logger.debug('udevadm:dvd', { devicePath }); - return { hasMedia: true, type: 'udf' }; + if (inferredByUdev === 'bluray' || inferredByUdev === 'dvd') { + logger.debug('udevadm:optical-media', { devicePath, inferredByUdev }); + return { hasMedia: true, type: null }; } } catch (_udevError) { // udevadm not available or failed – ignore @@ -815,24 +914,27 @@ class DiskDetectionService extends EventEmitter { // stdout/stderr and treat valid TOC lines as "audio CD present". // Keep compatibility with previous behavior: exit 0 counts as media even // when TOC output format cannot be parsed. - try { - const { stdout, stderr } = await execFileAsync('cdparanoia', ['-Q', '-d', devicePath], { timeout: 10000 }); - const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`); - if (tracks.length > 0) { - logger.debug('cdparanoia:audio-cd', { devicePath, trackCount: tracks.length }); - return { hasMedia: true, type: 'audio_cd' }; - } - logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath }); + const hasAudioCdToc = await this.probeAudioCdWithCdparanoia(devicePath); + if (hasAudioCdToc) { return { hasMedia: true, type: 'audio_cd' }; - } catch (cdError) { - const stderr = String(cdError?.stderr || ''); - const stdout = String(cdError?.stdout || ''); - const tracks = parseToc(`${stderr}\n${stdout}`); - if (tracks.length > 0) { - logger.debug('cdparanoia:audio-cd-from-error-streams', { devicePath, trackCount: tracks.length }); - return { hasMedia: true, type: 'audio_cd' }; + } + + // Final fallback: check block device size via sysfs. + // In VM/passthrough environments udev metadata may be absent even though + // the kernel reports a valid disc size (visible in lsblk). A non-zero + // 512-byte block count means media is physically present. + try { + const devName = String(devicePath || '').split('/').pop(); + if (devName) { + const sizeStr = fs.readFileSync(`/sys/block/${devName}/size`, 'utf8').trim(); + const sizeBlocks = parseInt(sizeStr, 10); + if (Number.isFinite(sizeBlocks) && sizeBlocks > 0) { + logger.info('check-media:sysfs-size', { devicePath, sizeBlocks }); + return { hasMedia: true, type: null }; + } } - // cdparanoia failed and no TOC output could be parsed. + } catch (_sysError) { + // sysfs not available or device not found there } logger.debug('blkid:no-media-or-fail', { devicePath }); @@ -941,7 +1043,10 @@ class DiskDetectionService extends EventEmitter { // UDF is used for both Blu-ray (UDF 2.x) and DVD (UDF 1.x). Without a clear model // marker identifying it as Blu-ray, a 'dvd' result from UDF is ambiguous. Skip the // early return and fall through to the blkid check which uses the UDF version number. - if (byFsTypeHint && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')) { + // Also guard: when hintFstype is empty (no filesystem info at all), the drive model + // alone is not a reliable disc-type indicator — a BD-RE drive can contain a DVD. + // In that case skip this early return and let blkid -p determine the actual disc type. + if (hintFstype && byFsTypeHint && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')) { return byFsTypeHint; } @@ -1006,6 +1111,11 @@ class DiskDetectionService extends EventEmitter { return udfHintFallback; } + const hasAudioCdToc = await this.probeAudioCdWithCdparanoia(devicePath); + if (hasAudioCdToc) { + return 'cd'; + } + return 'other'; } diff --git a/backend/src/services/downloadService.js b/backend/src/services/downloadService.js index 6f8fb70..1982654 100644 --- a/backend/src/services/downloadService.js +++ b/backend/src/services/downloadService.js @@ -295,9 +295,9 @@ class DownloadService { return this.items.get(normalizedId); } - async enqueueHistoryJob(jobId, target) { + async enqueueHistoryJob(jobId, target, options = {}) { await this.init(); - const descriptor = await historyService.getJobArchiveDescriptor(jobId, target); + const descriptor = await historyService.getJobArchiveDescriptor(jobId, target, options); const settings = await settingsService.getEffectiveSettingsMap(null); const downloadDir = String(settings?.download_dir || '').trim(); const ownerSpec = String(settings?.download_dir_owner || '').trim() || null; diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 87a8775..06dfeb7 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -644,6 +644,122 @@ function normalizeComparablePath(inputPath) { return resolveSafe(String(inputPath || '')).replace(/[\\/]+$/, ''); } +function escapeRegExp(input) { + return String(input || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function parseNumberedFolderName(folderName) { + const raw = String(folderName || '').trim(); + if (!raw) { + return { baseName: '', suffixNumber: 0, numbered: false }; + } + const match = raw.match(/^(.*)_([0-9]+)$/); + if (!match) { + return { baseName: raw, suffixNumber: 0, numbered: false }; + } + const baseName = String(match[1] || '').trim() || raw; + const suffixNumber = Number(match[2] || 0); + return { + baseName, + suffixNumber: Number.isFinite(suffixNumber) && suffixNumber > 0 ? Math.trunc(suffixNumber) : 0, + numbered: true + }; +} + +function inferSiblingOutputFolders(outputPath) { + const normalizedOutputPath = normalizeComparablePath(outputPath); + if (!normalizedOutputPath) { + return []; + } + + let outputStat = null; + try { + outputStat = fs.statSync(normalizedOutputPath); + } catch (_error) { + return []; + } + if (!outputStat?.isDirectory?.()) { + return []; + } + + const parentDir = path.dirname(normalizedOutputPath); + const folderName = path.basename(normalizedOutputPath); + const parsedName = parseNumberedFolderName(folderName); + const baseName = parsedName.baseName || folderName; + if (!baseName) { + return [normalizedOutputPath]; + } + + const siblingRegex = new RegExp(`^${escapeRegExp(baseName)}(?:_(\\d+))?$`); + let entries = []; + try { + entries = fs.readdirSync(parentDir, { withFileTypes: true }); + } catch (_error) { + return [normalizedOutputPath]; + } + + const candidates = []; + for (const entry of entries) { + if (!entry?.isDirectory?.()) { + continue; + } + const name = String(entry.name || '').trim(); + if (!name) { + continue; + } + const match = name.match(siblingRegex); + if (!match) { + continue; + } + const suffixNumber = match[1] ? Number(match[1]) : 0; + const fullPath = normalizeComparablePath(path.join(parentDir, name)); + candidates.push({ + path: fullPath, + suffixNumber: Number.isFinite(suffixNumber) && suffixNumber > 0 ? Math.trunc(suffixNumber) : 0 + }); + } + + if (candidates.length === 0) { + return [normalizedOutputPath]; + } + + candidates.sort((left, right) => { + if (left.suffixNumber !== right.suffixNumber) { + return left.suffixNumber - right.suffixNumber; + } + return String(left.path || '').localeCompare(String(right.path || ''), 'de-DE'); + }); + return candidates.map((item) => item.path); +} + +function compareOutputFolderPaths(leftPath, rightPath) { + const leftNormalized = normalizeComparablePath(leftPath); + const rightNormalized = normalizeComparablePath(rightPath); + const leftParent = normalizeComparablePath(path.dirname(leftNormalized)); + const rightParent = normalizeComparablePath(path.dirname(rightNormalized)); + const parentCompare = leftParent.localeCompare(rightParent, 'de-DE'); + if (parentCompare !== 0) { + return parentCompare; + } + + const leftName = String(path.basename(leftNormalized) || '').trim(); + const rightName = String(path.basename(rightNormalized) || '').trim(); + const leftParsed = parseNumberedFolderName(leftName); + const rightParsed = parseNumberedFolderName(rightName); + const baseCompare = String(leftParsed.baseName || '').localeCompare(String(rightParsed.baseName || ''), 'de-DE'); + if (baseCompare !== 0) { + return baseCompare; + } + + const leftSuffix = Number(leftParsed?.suffixNumber || 0); + const rightSuffix = Number(rightParsed?.suffixNumber || 0); + if (leftSuffix !== rightSuffix) { + return leftSuffix - rightSuffix; + } + + return leftNormalized.localeCompare(rightNormalized, 'de-DE'); +} + function stripRawFolderStatePrefix(folderName) { const rawName = String(folderName || '').trim(); if (!rawName) { @@ -730,13 +846,82 @@ function normalizeCdFormat(value) { } function normalizeAudioPathToken(value) { - const raw = String(value || '').trim().replace(/^[("'`]+|[)"'`,;]+$/g, ''); + let raw = String(value || '').trim(); + if (!raw) { + return null; + } + + // Unwrap common wrappers like "(...)" or quotes around the full token. + let changed = true; + while (changed && raw.length > 0) { + changed = false; + if ( + (raw.startsWith('"') && raw.endsWith('"')) + || (raw.startsWith("'") && raw.endsWith("'")) + || (raw.startsWith('`') && raw.endsWith('`')) + || (raw.startsWith('(') && raw.endsWith(')')) + ) { + raw = raw.slice(1, -1).trim(); + changed = true; + } + } + + // Strip leading quotes and trailing punctuation (but keep balanced ')'). + raw = raw + .replace(/^["'`]+/, '') + .replace(/[,"'`;]+$/g, '') + .trim(); + + // Remove only dangling unmatched ')' at the end (e.g. ".../path)") + // while preserving valid directory names like "... (2007)". + let openCount = (raw.match(/\(/g) || []).length; + let closeCount = (raw.match(/\)/g) || []).length; + while (closeCount > openCount && raw.endsWith(')')) { + raw = raw.slice(0, -1).trimEnd(); + closeCount -= 1; + } + if (!raw.startsWith('/')) { return null; } return raw; } +function resolveExistingAudioPathCandidate(candidate) { + const normalized = normalizeAudioPathToken(candidate) || String(candidate || '').trim() || null; + if (!normalized || !normalized.startsWith('/')) { + return null; + } + + try { + if (fs.existsSync(normalized)) { + return normalized; + } + } catch (_error) { + // ignore fs errors while probing possible output paths + } + + const openCount = (normalized.match(/\(/g) || []).length; + const closeCount = (normalized.match(/\)/g) || []).length; + if (openCount <= closeCount) { + return null; + } + + let repaired = normalized; + for (let missing = closeCount; missing < openCount; missing += 1) { + repaired = `${repaired})`; + try { + if (fs.existsSync(repaired)) { + return repaired; + } + } catch (_error) { + // ignore fs errors while probing repaired variants + } + } + + return null; +} + function extractAbsolutePathTokens(text) { const source = String(text || ''); const tokens = []; @@ -822,12 +1007,9 @@ function pickPreferredExistingPath(candidates = []) { for (let index = normalizedCandidates.length - 1; index >= 0; index -= 1) { const candidate = normalizedCandidates[index]; - try { - if (fs.existsSync(candidate)) { - return candidate; - } - } catch (_error) { - // ignore fs errors while preferring an existing path + const existingCandidate = resolveExistingAudioPathCandidate(candidate); + if (existingCandidate) { + return existingCandidate; } } @@ -1815,6 +1997,23 @@ class HistoryService { ); } + // Preserve known output folders when replacing a job. + await db.run( + ` + INSERT OR IGNORE INTO job_output_folders (job_id, output_path, label, created_at) + SELECT ?, output_path, label, created_at + FROM job_output_folders + WHERE job_id = ? + `, + [toJobId, fromJobId] + ); + if (outputPath) { + await db.run( + 'INSERT OR IGNORE INTO job_output_folders (job_id, output_path, label) VALUES (?, ?, ?)', + [toJobId, outputPath, 'Lineage-Output'] + ); + } + await db.exec('COMMIT'); } catch (error) { await db.exec('ROLLBACK'); @@ -1901,6 +2100,89 @@ class HistoryService { this.appendProcessLog(jobId, source, message); } + async cacheAndPromoteExternalPoster(jobId, posterUrl, options = {}) { + const normalizedJobId = normalizeJobIdValue(jobId); + const normalizedPosterUrl = String(posterUrl || '').trim(); + const logFailures = options?.logFailures !== false; + const sourceLabel = String(options?.source || 'Poster').trim() || 'Poster'; + const logPrefix = `${sourceLabel} Link`; + + if (!normalizedJobId) { + return { ok: false, reason: 'invalid_job_id', localUrl: null }; + } + if (!normalizedPosterUrl || thumbnailService.isLocalUrl(normalizedPosterUrl)) { + return { ok: false, reason: 'no_external_url', localUrl: null }; + } + + const cacheResult = await thumbnailService.cacheJobThumbnailDetailed(normalizedJobId, normalizedPosterUrl); + if (!cacheResult?.ok) { + if (logFailures) { + await this.appendLog( + normalizedJobId, + 'SYSTEM', + `${logPrefix} konnte nicht heruntergeladen werden: ${normalizedPosterUrl}${cacheResult?.error ? ` (${cacheResult.error})` : ''}` + ); + } + return { + ok: false, + reason: 'cache_failed', + localUrl: null, + error: cacheResult?.error || null + }; + } + + const promotedUrl = thumbnailService.promoteJobThumbnail(normalizedJobId); + if (!promotedUrl) { + if (logFailures) { + await this.appendLog( + normalizedJobId, + 'SYSTEM', + `${logPrefix} konnte nicht finalisiert werden: ${normalizedPosterUrl}` + ); + } + return { ok: false, reason: 'promote_failed', localUrl: null }; + } + + try { + await this.updateJob(normalizedJobId, { poster_url: promotedUrl }); + return { ok: true, reason: 'ok', localUrl: promotedUrl, sourceUrl: normalizedPosterUrl }; + } catch (error) { + logger.warn('thumbnail:update-job-failed', { + jobId: normalizedJobId, + posterUrl: normalizedPosterUrl, + promotedUrl, + error: error?.message || String(error) + }); + if (logFailures) { + await this.appendLog( + normalizedJobId, + 'SYSTEM', + `${logPrefix} wurde heruntergeladen, konnte aber nicht am Job gespeichert werden: ${normalizedPosterUrl}` + ); + } + return { + ok: false, + reason: 'update_failed', + localUrl: null, + error: error?.message || String(error) + }; + } + } + + queuePosterCache(jobId, posterUrl, options = {}) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) { + return; + } + this.cacheAndPromoteExternalPoster(normalizedJobId, posterUrl, options).catch((error) => { + logger.warn('thumbnail:queue-cache-failed', { + jobId: normalizedJobId, + posterUrl: String(posterUrl || '').trim() || null, + error: error?.message || String(error) + }); + }); + } + appendProcessLog(jobId, source, message) { const filePath = toProcessLogPath(jobId); const streamKey = toProcessLogStreamKey(jobId); @@ -2322,6 +2604,9 @@ class HistoryService { || selectedMetadata?.mbId || selectedMetadata?.musicBrainzId || selectedMetadata?.musicbrainzId + || selectedMetadata?.musicbrainz_id + || selectedMetadata?.music_brainz_id + || selectedMetadata?.musicbrainz || selectedMetadata?.mbid ); const isFinished = String(row?.status || row?.last_state || '').trim().toUpperCase() === 'FINISHED'; @@ -2405,18 +2690,14 @@ class HistoryService { return null; } - const cachedPath = await thumbnailService.cacheJobThumbnail(normalizedJobId, preferredPosterUrl); - if (!cachedPath) { + const result = await this.cacheAndPromoteExternalPoster(normalizedJobId, preferredPosterUrl, { + source: 'Coverart', + logFailures: true + }); + if (!result?.ok || !result.localUrl) { return null; } - - const promotedUrl = thumbnailService.promoteJobThumbnail(normalizedJobId); - if (!promotedUrl) { - return null; - } - - await this.updateJob(normalizedJobId, { poster_url: promotedUrl }); - return promotedUrl; + return result.localUrl; } async repairImportedCdJobArtifacts(job, settings = null) { @@ -2436,9 +2717,17 @@ class HistoryService { const hasTracks = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0; const hasEncodePlan = Boolean(String(job?.encode_plan_json || '').trim()); - const hasOutputPath = Boolean(String(job?.output_path || '').trim()); + const outputPathRaw = String(job?.output_path || '').trim(); + const hasOutputPath = Boolean(outputPathRaw); + const outputPathExists = hasOutputPath && (() => { + try { + return fs.existsSync(outputPathRaw); + } catch (_error) { + return false; + } + })(); const hasExistingLog = hasProcessLogFile(job.id); - if (hasTracks && hasEncodePlan && hasOutputPath && hasExistingLog) { + if (hasTracks && hasEncodePlan && hasOutputPath && outputPathExists && hasExistingLog) { return job; } @@ -2510,8 +2799,26 @@ class HistoryService { if (!hasEncodePlan && nextEncodePlan) { patch.encode_plan_json = JSON.stringify(nextEncodePlan); } - if (!hasOutputPath && recovery.outputPath) { - patch.output_path = recovery.outputPath; + const recoveredOutputPath = String(recovery.outputPath || '').trim() || null; + const normalizedOutputPath = hasOutputPath ? normalizeComparablePath(outputPathRaw) : null; + const normalizedRecoveredOutputPath = recoveredOutputPath ? normalizeComparablePath(recoveredOutputPath) : null; + const recoveredOutputPathExists = Boolean(recovery.outputPathExists); + const shouldPatchOutputPath = Boolean( + recoveredOutputPath + && ( + !hasOutputPath + || ( + !outputPathExists + && ( + recoveredOutputPathExists + || recovery.logSources.length > 0 + ) + ) + ) + && normalizedRecoveredOutputPath !== normalizedOutputPath + ); + if (shouldPatchOutputPath) { + patch.output_path = recoveredOutputPath; } if (!job?.title && nextMakemkvInfo?.selectedMetadata?.title) { patch.title = nextMakemkvInfo.selectedMetadata.title; @@ -2528,6 +2835,9 @@ class HistoryService { sourceSelectedMetadata?.mbId || sourceSelectedMetadata?.musicBrainzId || sourceSelectedMetadata?.musicbrainzId + || sourceSelectedMetadata?.musicbrainz_id + || sourceSelectedMetadata?.music_brainz_id + || sourceSelectedMetadata?.musicbrainz || sourceSelectedMetadata?.mbid || sourceJob?.imdb_id || '' @@ -2608,7 +2918,7 @@ class HistoryService { SELECT j.* FROM jobs j ${whereClause} - ORDER BY j.created_at DESC + ORDER BY COALESCE(j.updated_at, j.created_at) DESC, j.id DESC LIMIT ${limit} `, values @@ -2728,9 +3038,12 @@ class HistoryService { const hasProcessLog = (!shouldLoadLogs && includeFsChecks) ? hasProcessLogFile(jobId) : false; const baseLogCount = hasProcessLog ? 1 : 0; + const outputFolders = await this.getJobOutputFoldersForLineage(jobId); + if (!shouldLoadLogs) { return { ...enrichJobRow(job, settings, { includeFsChecks }), + outputFolders, log_count: baseLogCount, logs: [], log: '', @@ -2750,6 +3063,7 @@ class HistoryService { return { ...enrichJobRow(job, settings, { includeFsChecks }), + outputFolders, log_count: processLog.exists ? processLog.total : 0, logs: [], log: processLog.lines.join('\n'), @@ -2762,7 +3076,7 @@ class HistoryService { }; } - async getJobArchiveDescriptor(jobId, target) { + async getJobArchiveDescriptor(jobId, target, options = {}) { const normalizedJobId = normalizeJobIdValue(jobId); if (!normalizedJobId) { const error = new Error('Ungültige Job-ID.'); @@ -2789,10 +3103,44 @@ class HistoryService { } const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); - const sourcePath = normalizedTarget === 'raw' + const requestedOutputPath = normalizedTarget === 'output' + ? (String(options?.outputPath || '').trim() || null) + : null; + let sourcePath = normalizedTarget === 'raw' ? resolvedPaths.effectiveRawPath : resolvedPaths.effectiveOutputPath; + if (normalizedTarget === 'output' && requestedOutputPath) { + const trackedFolders = await this.getJobOutputFoldersForLineage(normalizedJobId); + const allowedOutputPathMap = new Map(); + const registerAllowedOutputPath = (candidatePath) => { + const raw = String(candidatePath || '').trim(); + if (!raw) { + return; + } + const normalized = normalizeComparablePath(raw); + if (!normalized || allowedOutputPathMap.has(normalized)) { + return; + } + allowedOutputPathMap.set(normalized, raw); + }; + + registerAllowedOutputPath(resolvedPaths.effectiveOutputPath); + registerAllowedOutputPath(job?.output_path); + for (const folder of trackedFolders) { + registerAllowedOutputPath(folder?.output_path); + } + + const normalizedRequestedOutputPath = normalizeComparablePath(requestedOutputPath); + if (!normalizedRequestedOutputPath || !allowedOutputPathMap.has(normalizedRequestedOutputPath)) { + const error = new Error('Der angeforderte Output-Ordner gehört nicht zu diesem Job.'); + error.statusCode = 404; + throw error; + } + + sourcePath = allowedOutputPathMap.get(normalizedRequestedOutputPath); + } + if (!sourcePath) { const error = new Error( normalizedTarget === 'raw' @@ -3110,6 +3458,9 @@ class HistoryService { || sourceSelectedMetadata?.mbId || sourceSelectedMetadata?.musicBrainzId || sourceSelectedMetadata?.musicbrainzId + || sourceSelectedMetadata?.musicbrainz_id + || sourceSelectedMetadata?.music_brainz_id + || sourceSelectedMetadata?.musicbrainz || sourceSelectedMetadata?.mbid || sourceJob?.imdb_id || '' @@ -3251,12 +3602,10 @@ class HistoryService { // Bild herunterladen, in persistenten Ordner verschieben und poster_url aktualisieren if (posterUrl && !thumbnailService.isLocalUrl(posterUrl)) { - thumbnailService.cacheJobThumbnail(jobId, posterUrl) - .then(() => { - const promotedUrl = thumbnailService.promoteJobThumbnail(jobId); - if (promotedUrl) return this.updateJob(jobId, { poster_url: promotedUrl }); - }) - .catch(() => {}); + this.queuePosterCache(jobId, posterUrl, { + source: 'OMDb Poster', + logFailures: true + }); } await this.appendLog( @@ -3337,12 +3686,10 @@ class HistoryService { }); if (coverUrl && !thumbnailService.isLocalUrl(coverUrl)) { - thumbnailService.cacheJobThumbnail(jobId, coverUrl) - .then(() => { - const promotedUrl = thumbnailService.promoteJobThumbnail(jobId); - if (promotedUrl) return this.updateJob(jobId, { poster_url: promotedUrl }); - }) - .catch(() => {}); + this.queuePosterCache(jobId, coverUrl, { + source: 'Coverart', + logFailures: true + }); } await this.appendLog( @@ -3360,6 +3707,7 @@ class HistoryService { async _resolveRelatedJobsForDeletion(jobId, options = {}) { const includeRelated = options?.includeRelated !== false; + const includeLogLinks = options?.includeLogLinks !== false; const normalizedJobId = normalizeJobIdValue(jobId); if (!normalizedJobId) { const error = new Error('Ungültige Job-ID.'); @@ -3436,14 +3784,16 @@ class HistoryService { enqueue(childId); } - try { - const processLog = await this.readProcessLogLines(currentId, { includeAll: true }); - const linkedJobIds = parseRetryLinkedJobIdsFromLogLines(processLog.lines); - for (const linkedId of linkedJobIds) { - enqueue(linkedId); + if (includeLogLinks) { + try { + const processLog = await this.readProcessLogLines(currentId, { includeAll: true }); + const linkedJobIds = parseRetryLinkedJobIdsFromLogLines(processLog.lines); + for (const linkedId of linkedJobIds) { + enqueue(linkedId); + } + } catch (_error) { + // optional fallback links from process logs; ignore read errors } - } catch (_error) { - // optional fallback links from process logs; ignore read errors } } @@ -3457,6 +3807,9 @@ class HistoryService { const normalizedJobId = normalizeJobIdValue(job?.id); const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); const lineageArtifacts = Array.isArray(options?.lineageArtifacts) ? options.lineageArtifacts : []; + const trackedOutputPaths = Array.isArray(options?.trackedOutputPaths) + ? options.trackedOutputPaths + : []; const toNormalizedPath = (value) => { const raw = String(value || '').trim(); if (!raw) { @@ -3479,25 +3832,31 @@ class HistoryService { toNormalizedPath(resolvedPaths?.effectiveRawPath), ...artifactRawPaths ]); - const explicitMoviePaths = unique([ + const explicitMovieSeedPaths = unique([ toNormalizedPath(job?.output_path), toNormalizedPath(resolvedPaths?.effectiveOutputPath), + ...trackedOutputPaths.map((candidatePath) => toNormalizedPath(candidatePath)), ...artifactMoviePaths ]); + const inferredSiblingMoviePaths = explicitMovieSeedPaths.flatMap((candidatePath) => inferSiblingOutputFolders(candidatePath)); + const explicitMoviePaths = unique([ + ...explicitMovieSeedPaths, + ...inferredSiblingMoviePaths.map((candidatePath) => toNormalizedPath(candidatePath)) + ]); const rawRoots = sanitizeRoots([ ...getConfiguredMediaPathList(settings || {}, 'raw_dir'), toNormalizedPath(resolvedPaths?.rawDir), ...explicitRawPaths.map((candidatePath) => toNormalizedPath(path.dirname(candidatePath))) ]); - // Only configured base dirs become protectedRoots (must never be deleted themselves) + // Only effective base dir for this job becomes protectedRoot (must never be deleted itself) const movieRoots = sanitizeRoots([ - ...getConfiguredMediaPathList(settings || {}, 'movie_dir'), toNormalizedPath(resolvedPaths?.movieDir) ]); // Includes parent dirs of output files for addCandidate safety checks const movieAllowedPaths = sanitizeRoots([ ...movieRoots, + ...explicitMoviePaths, ...explicitMoviePaths.map((candidatePath) => toNormalizedPath(path.dirname(candidatePath))) ]); @@ -3603,7 +3962,7 @@ class HistoryService { } } - if (normalizedJobId) { + if (normalizedJobId && resolvedPaths?.mediaType !== 'cd') { const incompleteName = `Incomplete_job-${normalizedJobId}`; for (const rootPath of movieRoots) { addCandidate(movieCandidates, 'movie', path.join(rootPath, incompleteName), 'movie_incomplete_folder', movieRoots); @@ -3642,9 +4001,10 @@ class HistoryService { }; } - _buildDeletePreviewFromJobs(jobs = [], settings = null, lineageArtifactsByJobId = null) { + _buildDeletePreviewFromJobs(jobs = [], settings = null, lineageArtifactsByJobId = null, outputFoldersByJobId = null) { const rows = Array.isArray(jobs) ? jobs : []; const artifactsMap = lineageArtifactsByJobId instanceof Map ? lineageArtifactsByJobId : new Map(); + const trackedFoldersMap = outputFoldersByJobId instanceof Map ? outputFoldersByJobId : new Map(); const candidateMap = new Map(); const protectedRoots = { raw: new Set(), @@ -3678,7 +4038,13 @@ class HistoryService { for (const job of rows) { const lineageArtifacts = artifactsMap.get(normalizeJobIdValue(job?.id)) || []; - const collected = this._collectDeleteCandidatesForJob(job, settings, { lineageArtifacts }); + const trackedFolders = trackedFoldersMap.get(normalizeJobIdValue(job?.id)) || []; + const collected = this._collectDeleteCandidatesForJob(job, settings, { + lineageArtifacts, + trackedOutputPaths: trackedFolders + .map((folder) => String(folder?.output_path || '').trim()) + .filter(Boolean) + }); for (const rootPath of collected.rawRoots || []) { protectedRoots.raw.add(rootPath); } @@ -3732,10 +4098,17 @@ class HistoryService { const jobs = await this._resolveRelatedJobsForDeletion(normalizedJobId, { includeRelated }); const settings = await settingsService.getSettingsMap(); - const lineageArtifactsByJobId = await this.listJobLineageArtifactsByJobIds( - jobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean) + const relatedJobIds = jobs.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean); + const [lineageArtifactsByJobId, outputFoldersByJobId] = await Promise.all([ + this.listJobLineageArtifactsByJobIds(relatedJobIds), + this.listJobOutputFoldersByJobIds(relatedJobIds) + ]); + const preview = this._buildDeletePreviewFromJobs( + jobs, + settings, + lineageArtifactsByJobId, + outputFoldersByJobId ); - const preview = this._buildDeletePreviewFromJobs(jobs, settings, lineageArtifactsByJobId); const relatedJobs = jobs.map((job) => ({ id: Number(job.id), parentJobId: normalizeJobIdValue(job.parent_job_id), @@ -3763,23 +4136,41 @@ class HistoryService { }; } - _deletePathsFromPreview(preview, target = 'both') { + _deletePathsFromPreview(preview, target = 'both', options = {}) { const normalizedTarget = String(target || 'both').trim().toLowerCase(); const includesRaw = normalizedTarget === 'raw' || normalizedTarget === 'both'; const includesMovie = normalizedTarget === 'movie' || normalizedTarget === 'both'; + const selectedMoviePathFilter = new Set( + (Array.isArray(options?.selectedMoviePaths) ? options.selectedMoviePaths : []) + .map((moviePath) => String(moviePath || '').trim()) + .filter(Boolean) + .map((moviePath) => normalizeComparablePath(moviePath)) + ); const summary = { target: normalizedTarget, raw: { attempted: includesRaw, deleted: false, filesDeleted: 0, dirsRemoved: 0, pathsDeleted: 0, reason: null }, movie: { attempted: includesMovie, deleted: false, filesDeleted: 0, dirsRemoved: 0, pathsDeleted: 0, reason: null }, + selectedMoviePaths: Array.from(selectedMoviePathFilter), deletedPaths: [] }; const applyTarget = (targetKey) => { - const candidates = (Array.isArray(preview?.pathCandidates?.[targetKey]) ? preview.pathCandidates[targetKey] : []) + let candidates = (Array.isArray(preview?.pathCandidates?.[targetKey]) ? preview.pathCandidates[targetKey] : []) .filter((item) => Boolean(item?.exists) && (Boolean(item?.isDirectory) || Boolean(item?.isFile))); + if (targetKey === 'movie' && selectedMoviePathFilter.size > 0) { + candidates = candidates.filter((item) => { + const candidatePath = String(item?.path || '').trim(); + if (!candidatePath) { + return false; + } + return selectedMoviePathFilter.has(normalizeComparablePath(candidatePath)); + }); + } if (candidates.length === 0) { - summary[targetKey].reason = 'Keine passenden Dateien/Ordner gefunden.'; + summary[targetKey].reason = targetKey === 'movie' && selectedMoviePathFilter.size > 0 + ? 'Keine ausgewählten Audio/Video-Ordner gefunden.' + : 'Keine passenden Dateien/Ordner gefunden.'; return; } @@ -3970,6 +4361,11 @@ class HistoryService { } } + // Remove deleted movie paths from output folders tracking + if (summary.movie?.deleted && effectiveOutputPath) { + await this.removeJobOutputFolder(jobId, effectiveOutputPath); + } + await this.appendLog( jobId, 'USER_ACTION', @@ -3987,6 +4383,299 @@ class HistoryService { }; } + async addJobOutputFolder(jobId, outputPath, label = null) { + const normalizedJobId = Number(jobId); + const normalizedPath = String(outputPath || '').trim(); + if (!normalizedJobId || !normalizedPath) return null; + const db = await getDb(); + await db.run( + 'INSERT OR IGNORE INTO job_output_folders (job_id, output_path, label) VALUES (?, ?, ?)', + [normalizedJobId, normalizedPath, label || null] + ); + } + + async getJobOutputFolders(jobId) { + const normalizedJobId = Number(jobId); + if (!normalizedJobId) return []; + const db = await getDb(); + const rows = await db.all( + 'SELECT id, job_id, output_path, label, created_at FROM job_output_folders WHERE job_id = ? ORDER BY id ASC', + [normalizedJobId] + ); + return rows || []; + } + + async listJobOutputFoldersByJobIds(jobIds = []) { + const normalizedJobIds = Array.isArray(jobIds) + ? jobIds + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + : []; + if (normalizedJobIds.length === 0) { + return new Map(); + } + + const db = await getDb(); + const placeholders = normalizedJobIds.map(() => '?').join(', '); + const rows = await db.all( + ` + SELECT id, job_id, output_path, label, created_at + FROM job_output_folders + WHERE job_id IN (${placeholders}) + ORDER BY id ASC + `, + normalizedJobIds + ); + + const byJobId = new Map(); + for (const row of (Array.isArray(rows) ? rows : [])) { + const ownerJobId = normalizeJobIdValue(row?.job_id); + if (!ownerJobId) { + continue; + } + if (!byJobId.has(ownerJobId)) { + byJobId.set(ownerJobId, []); + } + byJobId.get(ownerJobId).push({ + id: normalizeJobIdValue(row?.id), + job_id: ownerJobId, + output_path: String(row?.output_path || '').trim() || null, + label: String(row?.label || '').trim() || null, + created_at: String(row?.created_at || '').trim() || null + }); + } + + return byJobId; + } + + async getJobOutputFoldersForLineage(jobId, options = {}) { + const normalizedJobId = normalizeJobIdValue(jobId); + if (!normalizedJobId) return []; + const includeMissing = Boolean(options?.includeMissing); + + const relatedJobs = await this._resolveRelatedJobsForDeletion(normalizedJobId, { + includeRelated: true, + includeLogLinks: false + }); + const relatedJobIds = Array.from(new Set( + relatedJobs + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean) + )); + if (relatedJobIds.length === 0) { + return []; + } + + const db = await getDb(); + const placeholders = relatedJobIds.map(() => '?').join(', '); + const trackedRows = await db.all( + ` + SELECT id, job_id, output_path, label, created_at + FROM job_output_folders + WHERE job_id IN (${placeholders}) + ORDER BY id ASC + `, + relatedJobIds + ); + const artifactsByJobId = await this.listJobLineageArtifactsByJobIds(relatedJobIds); + + const merged = []; + const seen = new Set(); + const addFolder = (rawFolder, defaults = {}) => { + const outputPath = String(rawFolder?.output_path || rawFolder?.outputPath || '').trim(); + if (!outputPath) { + return; + } + const normalized = normalizeComparablePath(outputPath); + if (!normalized || seen.has(normalized)) { + return; + } + let exists = false; + try { + exists = fs.existsSync(normalized); + } catch (_error) { + exists = false; + } + if (!includeMissing && !exists) { + return; + } + seen.add(normalized); + merged.push({ + id: normalizeJobIdValue(rawFolder?.id), + job_id: normalizeJobIdValue(rawFolder?.job_id ?? rawFolder?.jobId ?? defaults.job_id ?? normalizedJobId), + output_path: outputPath, + label: String(rawFolder?.label || defaults.label || '').trim() || null, + created_at: String(rawFolder?.created_at || rawFolder?.createdAt || '').trim() || null, + exists + }); + }; + + for (const row of (Array.isArray(trackedRows) ? trackedRows : [])) { + addFolder(row); + } + for (const relatedJob of relatedJobs) { + addFolder({ + output_path: relatedJob?.output_path || null, + job_id: relatedJob?.id || null + }); + const artifacts = artifactsByJobId.get(normalizeJobIdValue(relatedJob?.id)) || []; + for (const artifact of artifacts) { + addFolder({ + output_path: artifact?.outputPath || null, + job_id: relatedJob?.id || null, + label: 'Lineage-Output', + created_at: artifact?.createdAt || null + }); + } + } + + // Fallback for legacy runs before output-folder tracking: + // detect numbered sibling directories on filesystem + // /path/Album (1995), /path/Album (1995)_2, /path/Album (1995)_3, ... + const seedPaths = merged + .filter((row) => Boolean(row?.exists)) + .map((row) => String(row?.output_path || '').trim()) + .filter(Boolean); + for (const seedPath of seedPaths) { + const siblings = inferSiblingOutputFolders(seedPath); + for (const siblingPath of siblings) { + addFolder({ + output_path: siblingPath, + job_id: normalizedJobId, + label: 'Auto-erkannt' + }); + } + } + + merged.sort((left, right) => compareOutputFolderPaths(left?.output_path, right?.output_path)); + return merged; + } + + async removeJobOutputFolder(jobId, outputPath) { + const normalizedJobId = Number(jobId); + const normalizedPath = normalizeComparablePath(outputPath); + if (!normalizedJobId || !normalizedPath) return; + const db = await getDb(); + const rows = await db.all( + 'SELECT id, output_path FROM job_output_folders WHERE job_id = ?', + [normalizedJobId] + ); + for (const row of (Array.isArray(rows) ? rows : [])) { + const candidatePath = normalizeComparablePath(row?.output_path); + if (!candidatePath || candidatePath !== normalizedPath) { + continue; + } + await db.run('DELETE FROM job_output_folders WHERE id = ?', [Number(row.id)]); + } + } + + async removeJobOutputFolderFromJobs(jobIds = [], outputPath) { + const normalizedPath = normalizeComparablePath(outputPath); + const normalizedJobIds = Array.isArray(jobIds) + ? jobIds + .map((value) => normalizeJobIdValue(value)) + .filter(Boolean) + : []; + if (!normalizedPath || normalizedJobIds.length === 0) return; + const db = await getDb(); + const placeholders = normalizedJobIds.map(() => '?').join(', '); + const rows = await db.all( + `SELECT id, output_path FROM job_output_folders WHERE job_id IN (${placeholders})`, + normalizedJobIds + ); + for (const row of (Array.isArray(rows) ? rows : [])) { + const candidatePath = normalizeComparablePath(row?.output_path); + if (!candidatePath || candidatePath !== normalizedPath) { + continue; + } + await db.run('DELETE FROM job_output_folders WHERE id = ?', [Number(row.id)]); + } + } + + async deleteSpecificOutputFolders(jobId, folderPaths = []) { + const paths = Array.isArray(folderPaths) ? folderPaths.filter((p) => typeof p === 'string' && p.trim()) : []; + if (paths.length === 0) return { deleted: [], failed: [] }; + + const job = await this.getJobById(jobId); + if (!job) { + const error = new Error('Job nicht gefunden.'); + error.statusCode = 404; + throw error; + } + + const settings = await settingsService.getSettingsMap(); + const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job); + const effectiveMovieDir = resolvedPaths.movieDir; + if (!effectiveMovieDir) { + const error = new Error('Kein gültiger Movie-Basispfad konfiguriert.'); + error.statusCode = 400; + throw error; + } + + const deleted = []; + const failed = []; + const relatedJobs = await this._resolveRelatedJobsForDeletion(jobId, { + includeRelated: true, + includeLogLinks: false + }); + const relatedJobIds = Array.from(new Set( + relatedJobs + .map((row) => normalizeJobIdValue(row?.id)) + .filter(Boolean) + )); + const trackedOutputFolders = await this.getJobOutputFoldersForLineage(jobId, { includeMissing: true }); + const trackedOutputPathSet = new Set( + trackedOutputFolders + .map((folder) => normalizeComparablePath(folder?.output_path)) + .filter(Boolean) + ); + + for (const folderPath of paths) { + const normalizedFolderPath = normalizeComparablePath(folderPath); + const isInsideEffectiveRoot = isPathInside(effectiveMovieDir, normalizedFolderPath); + const isTrackedPath = trackedOutputPathSet.has(normalizedFolderPath); + if (!isInsideEffectiveRoot && !isTrackedPath) { + failed.push({ path: folderPath, reason: 'Pfad liegt außerhalb des Output-Verzeichnisses.' }); + continue; + } + if (!fs.existsSync(normalizedFolderPath)) { + await this.removeJobOutputFolderFromJobs(relatedJobIds, normalizedFolderPath); + deleted.push(normalizedFolderPath); + continue; + } + try { + const stat = fs.lstatSync(normalizedFolderPath); + const movieRoot = normalizeComparablePath(effectiveMovieDir); + if (stat.isDirectory()) { + const keepRoot = normalizedFolderPath === movieRoot; + deleteFilesRecursively(normalizedFolderPath, keepRoot); + } else { + const parentDir = normalizeComparablePath(path.dirname(normalizedFolderPath)); + const canDeleteParent = parentDir && parentDir !== movieRoot + && isPathInside(movieRoot, parentDir) + && fs.existsSync(parentDir) + && fs.lstatSync(parentDir).isDirectory(); + if (canDeleteParent) { + deleteFilesRecursively(parentDir, false); + } else { + fs.unlinkSync(normalizedFolderPath); + } + } + await this.removeJobOutputFolderFromJobs(relatedJobIds, normalizedFolderPath); + deleted.push(normalizedFolderPath); + } catch (error) { + failed.push({ path: folderPath, reason: error.message }); + } + } + + await this.appendLog( + jobId, + 'USER_ACTION', + `Output-Ordner gelöscht: ${deleted.length > 0 ? deleted.join(', ') : 'keine'}${failed.length > 0 ? ` | Fehler: ${failed.map((f) => f.path).join(', ')}` : ''}` + ); + return { deleted, failed }; + } + async deleteJob(jobId, fileTarget = 'none', options = {}) { const allowedTargets = new Set(['none', 'raw', 'movie', 'both']); if (!allowedTargets.has(fileTarget)) { @@ -4007,7 +4696,9 @@ class HistoryService { let fileSummary = null; if (fileTarget !== 'none') { const preview = await this.getJobDeletePreview(jobId, { includeRelated: false }); - fileSummary = this._deletePathsFromPreview(preview, fileTarget); + fileSummary = this._deletePathsFromPreview(preview, fileTarget, { + selectedMoviePaths: options?.selectedMoviePaths + }); } const db = await getDb(); @@ -4114,7 +4805,9 @@ class HistoryService { let fileSummary = null; if (fileTarget !== 'none') { - fileSummary = this._deletePathsFromPreview(preview, fileTarget); + fileSummary = this._deletePathsFromPreview(preview, fileTarget, { + selectedMoviePaths: options?.selectedMoviePaths + }); } await db.exec('BEGIN'); diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 1c1b9f4..25bc859 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -117,6 +117,90 @@ function normalizeCdTrackPositionList(values = []) { return output; } +function isCdPlaceholderTrackTitle(value) { + const normalized = normalizeCdTrackText(value).toLowerCase(); + if (!normalized) { + return true; + } + return /^track\s*\d+$/i.test(normalized); +} + +function evaluateCdDirectReencodeEligibility({ job = {}, encodePlan = {}, mkInfo = {}, handbrakeInfo = {} } = {}) { + const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; + const info = mkInfo && typeof mkInfo === 'object' ? mkInfo : {}; + const hbInfo = handbrakeInfo && typeof handbrakeInfo === 'object' ? handbrakeInfo : {}; + const selectedMetadata = info?.selectedMetadata && typeof info.selectedMetadata === 'object' + ? info.selectedMetadata + : {}; + const tracks = Array.isArray(info?.tracks) && info.tracks.length > 0 + ? info.tracks + : (Array.isArray(plan?.tracks) ? plan.tracks : []); + const selectedTrackPositions = normalizeCdTrackPositionList( + Array.isArray(plan?.selectedTracks) && plan.selectedTracks.length > 0 + ? plan.selectedTracks + : tracks + .filter((track) => track?.selected !== false) + .map((track) => normalizePositiveInteger(track?.position)) + ); + const trackByPosition = new Map( + tracks + .map((track) => { + const position = normalizePositiveInteger(track?.position); + if (!position) { + return null; + } + return [position, track]; + }) + .filter(Boolean) + ); + + const normalizedFormat = String(plan?.format || '').trim().toLowerCase(); + const hasSupportedFormat = Boolean(normalizedFormat && cdRipService.SUPPORTED_FORMATS.has(normalizedFormat)); + const hasTracks = tracks.length > 0; + const hasSelectedTracks = selectedTrackPositions.length > 0; + const albumTitle = normalizeCdTrackText( + selectedMetadata?.title + || selectedMetadata?.album + || job?.title + || job?.detected_title + || '' + ); + const albumArtist = normalizeCdTrackText(selectedMetadata?.artist || ''); + const hasCoreMetadata = Boolean(albumTitle && albumArtist); + const hasMeaningfulTrackTitles = selectedTrackPositions.some((position) => { + const track = trackByPosition.get(position) || null; + const title = normalizeCdTrackText(track?.title || ''); + return Boolean(title) && !isCdPlaceholderTrackTitle(title); + }); + const hasPriorRunEvidence = Boolean( + plan?.directReencodeReady + || (Array.isArray(hbInfo?.tracks) && hbInfo.tracks.length > 0) + || String(hbInfo?.status || '').trim().toUpperCase() === 'SUCCESS' + ); + + const reasons = []; + if (!hasSupportedFormat) { + reasons.push('Kein valides Ausgabeformat aus einem vorherigen Lauf gefunden.'); + } + if (!hasTracks || !hasSelectedTracks) { + reasons.push('Keine verwertbare Trackauswahl aus einem vorherigen Lauf vorhanden.'); + } + if (!hasCoreMetadata) { + reasons.push('Album-Metadaten sind unvollständig (Titel/Interpret fehlen).'); + } + if (!hasMeaningfulTrackTitles) { + reasons.push('Tracktitel sind nicht aus einem bestätigten Metadaten-Lauf übernommen.'); + } + if (!hasPriorRunEvidence) { + reasons.push('Kein bestätigter Vorlauf mit gültigen CD-Metadaten gefunden.'); + } + + return { + eligible: reasons.length === 0, + reasons + }; +} + function parseCdTrackDurationSec(track = null) { const durationSec = Number(track?.durationSec); if (Number.isFinite(durationSec) && durationSec > 0) { @@ -564,7 +648,6 @@ function ensureUniqueOutputPath(outputPath) { return outputPath; } - const ts = fileTimestamp(); let stat = null; try { stat = fs.statSync(outputPath); @@ -572,17 +655,27 @@ function ensureUniqueOutputPath(outputPath) { stat = null; } const isDirectory = Boolean(stat?.isDirectory?.()); - let attempt = isDirectory + + // Use sequential numbering (_2, _3, ...) for directories and parent-folder of files + if (isDirectory) { + for (let i = 2; i < 200; i++) { + const attempt = `${outputPath}_${i}`; + if (!fs.existsSync(attempt)) return attempt; + } + } else { + const parentDir = path.dirname(outputPath); + const fileName = path.basename(outputPath); + for (let i = 2; i < 200; i++) { + const attempt = path.join(`${parentDir}_${i}`, fileName); + if (!fs.existsSync(attempt)) return attempt; + } + } + + // Fallback to timestamp if sequential limit exceeded + const ts = fileTimestamp(); + return isDirectory ? withTimestampSuffix(outputPath, ts) : withTimestampBeforeExtension(outputPath, ts); - let i = 1; - while (fs.existsSync(attempt)) { - attempt = isDirectory - ? withTimestampSuffix(outputPath, `${ts}-${i}`) - : withTimestampBeforeExtension(outputPath, `${ts}-${i}`); - i += 1; - } - return attempt; } function chownRecursive(targetPath, ownerSpec) { @@ -3931,7 +4024,7 @@ class PipelineService extends EventEmitter { return byPluginId[normalizedPluginId] || null; } - isPluginArchitectureEnabledForSettings(settingsMap = null, pluginId = null) { + isPluginArchitectureEnabledForSettings(settingsMap = null, pluginId = null, phase = null) { const settings = settingsMap && typeof settingsMap === 'object' ? settingsMap : {}; const globalEnabled = this.normalizeBooleanSetting(settings.use_plugin_architecture); if (!globalEnabled) { @@ -3947,23 +4040,37 @@ class PipelineService extends EventEmitter { return true; } - return this.normalizeBooleanSetting(settings[pluginSettingKey]); + if (!this.normalizeBooleanSetting(settings[pluginSettingKey])) { + return false; + } + + // Phasen-spezifischer Toggle: use_plugin_architecture_{id}_{phase} + if (phase) { + const normalizedId = String(pluginId || '').trim().toLowerCase(); + const phaseKey = `use_plugin_architecture_${normalizedId}_${phase}`; + if (phaseKey in settings && !this.normalizeBooleanSetting(settings[phaseKey])) { + return false; + } + } + + return true; } - async isPluginArchitectureEnabledForPlugin(pluginId = null) { + async isPluginArchitectureEnabledForPlugin(pluginId = null, phase = null) { try { const settingsMap = await settingsService.getSettingsMap(); - return this.isPluginArchitectureEnabledForSettings(settingsMap, pluginId); + return this.isPluginArchitectureEnabledForSettings(settingsMap, pluginId, phase); } catch (error) { logger.warn('plugin-architecture:settings-read-failed', { error: errorToMeta(error), - pluginId: pluginId || null + pluginId: pluginId || null, + phase: phase || null }); return false; } } - async resolveAnalyzePlugin(discInfo = null) { + async resolveAnalyzePlugin(discInfo = null, phase = 'analyze') { const enabled = await this.isPluginArchitectureEnabledForPlugin(null); if (!enabled) { return null; @@ -3976,10 +4083,11 @@ class PipelineService extends EventEmitter { if (!plugin?.id) { return null; } - const pluginEnabled = await this.isPluginArchitectureEnabledForPlugin(plugin.id); + const pluginEnabled = await this.isPluginArchitectureEnabledForPlugin(plugin.id, phase); if (!pluginEnabled) { - logger.info('plugin-architecture:plugin-disabled', { + logger.info('plugin-architecture:plugin-phase-disabled', { pluginId: plugin.id, + phase, mediaProfile: discInfo?.mediaProfile || null }); return null; @@ -4513,7 +4621,8 @@ class PipelineService extends EventEmitter { const staleRows = await db.all(` SELECT id, status, last_state FROM jobs - WHERE status IN ('ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING') + WHERE status IN ('ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', + 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING') ORDER BY updated_at ASC, id ASC `); const rows = Array.isArray(staleRows) ? staleRows : []; @@ -4570,6 +4679,34 @@ class PipelineService extends EventEmitter { } } + if (stage === 'CD_ENCODING') { + // CD was encoding WAVs — try to restart the encode from existing WAV files + try { + await historyService.appendLog(jobId, 'SYSTEM', message); + } catch (_error) { + // keep recovery path even if log append fails + } + try { + await this.reencodeFromRaw(jobId, { immediate: true }); + preparedReadyToEncode += 1; + continue; + } catch (error) { + logger.warn('startup:recover-stale-cd-encoding:restart-failed', { + jobId, + error: errorToMeta(error) + }); + try { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Startup-Recovery CD-Encode fehlgeschlagen, setze Job auf ERROR: ${error?.message || 'unknown'}` + ); + } catch (_logError) { + // ignore logging fallback errors + } + } + } + await historyService.updateJobStatus(jobId, 'ERROR', { end_time: nowIso(), error_message: message @@ -4618,11 +4755,16 @@ class PipelineService extends EventEmitter { for (const [devicePath, driveState] of this.cdDrives) { cdDrives[devicePath] = driveState; } + const detectedDiscs = {}; + for (const [path, device] of diskDetectionService.detectedDiscs) { + detectedDiscs[path] = device; + } return { ...this.snapshot, jobProgress, queue: this.lastQueueSnapshot, - cdDrives + cdDrives, + detectedDiscs }; } @@ -4644,6 +4786,17 @@ class PipelineService extends EventEmitter { }; this.cdDrives.set(devicePath, next); + // If a drive is detached from a job (or reassigned), clear stale live + // progress for the previous job so dashboards do not stay in CD_ENCODING. + const previousJobId = Number(existing?.jobId || 0); + const nextJobId = Number(next?.jobId || 0); + if (Number.isFinite(previousJobId) && previousJobId > 0) { + const previousJobStillBound = Number.isFinite(nextJobId) && nextJobId > 0 && nextJobId === previousJobId; + if (!previousJobStillBound) { + this.jobProgress.delete(previousJobId); + } + } + // Sync jobProgress so per-job queries work const resolvedJobId = Number(next.jobId || 0); if (Number.isFinite(resolvedJobId) && resolvedJobId > 0) { @@ -6362,8 +6515,10 @@ class PipelineService extends EventEmitter { device = this.detectedDisc; } if (!device) { - // Minimal device object — cdRipService only needs path - device = { path: devicePath, mediaProfile: 'cd' }; + // Try the disk detection service's per-drive map — covers non-CD drives + // not yet tracked by the global state machine (e.g. second DVD drive). + const diskDetected = diskDetectionService.detectedDiscs.get(devicePath); + device = diskDetected || { path: devicePath }; } } else { device = this.detectedDisc || this.snapshot.context?.device; @@ -6376,24 +6531,59 @@ class PipelineService extends EventEmitter { throw error; } + // Use only disc-specific labels — never the drive hardware model (device.model + // reflects the drive hardware, e.g. "BD-RE BH16NS55", not the disc content). const detectedTitle = String( device.discLabel || device.label - || device.model || 'Unknown Disc' ).trim(); const explicitProfile = normalizeMediaProfile(device?.mediaProfile); const inferredProfile = inferMediaProfileFromDeviceInfo(device); - const mediaProfile = isSpecificMediaProfile(explicitProfile) + let mediaProfile = isSpecificMediaProfile(explicitProfile) ? explicitProfile : (isSpecificMediaProfile(inferredProfile) ? inferredProfile : (explicitProfile || inferredProfile || 'other')); - const deviceWithProfile = { + let deviceWithProfile = { ...device, mediaProfile }; - const analyzePlugin = await this.resolveAnalyzePlugin(deviceWithProfile); + + // Fallback for Audio-CDs without filesystem markers: + // if profile inference ended up as non-CD but the drive reports no FS type, + // probe the TOC directly and force CD routing when tracks are present. + if ( + mediaProfile !== 'cd' + && String(device?.path || '').trim() + && !String(device?.fstype || '').trim() + ) { + try { + const settingsMap = await settingsService.getSettingsMap(); + const cdparanoiaCmd = String(settingsMap?.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia'; + const tocTracks = await cdRipService.readToc(device.path, cdparanoiaCmd); + if (tocTracks.length > 0) { + logger.info('analyze:media-profile-override-to-cd', { + devicePath: device.path, + previousMediaProfile: mediaProfile, + trackCount: tocTracks.length + }); + mediaProfile = 'cd'; + deviceWithProfile = { + ...deviceWithProfile, + mediaProfile: 'cd', + fstype: deviceWithProfile.fstype || 'audio_cd' + }; + } + } catch (error) { + logger.debug('analyze:media-profile-cd-probe-failed', { + devicePath: device.path, + error: errorToMeta(error) + }); + } + } + + const analyzePlugin = await this.resolveAnalyzePlugin(deviceWithProfile, 'analyze'); // Route audio CDs to the dedicated CD pipeline if (mediaProfile === 'cd') { @@ -7815,7 +8005,10 @@ class PipelineService extends EventEmitter { // Bild in Cache laden (async, blockiert nicht) if (posterValue && !thumbnailService.isLocalUrl(posterValue)) { - thumbnailService.cacheJobThumbnail(jobId, posterValue).catch(() => {}); + historyService.queuePosterCache(jobId, posterValue, { + source: 'Poster', + logFailures: true + }); } const runningJobs = await historyService.getRunningJobs(); @@ -8471,6 +8664,25 @@ class PipelineService extends EventEmitter { encodeInputPath: rawInput.path }; + const reencodeDeleteFolders = Array.isArray(options?.deleteFolders) + ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) + : []; + if (reencodeDeleteFolders.length > 0) { + try { + const specificDeleteResult = await historyService.deleteSpecificOutputFolders(sourceJobId, reencodeDeleteFolders); + await historyService.appendLog( + sourceJobId, + 'USER_ACTION', + `Audiobook-Re-Encode: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` + ); + } catch (error) { + logger.warn('reencodeFromRaw:audiobook:delete-specific-folders-failed', { + jobId: sourceJobId, + error: errorToMeta(error) + }); + } + } + await historyService.resetProcessLog(sourceJobId); await historyService.updateJob(sourceJobId, { status: 'READY_TO_START', @@ -8520,6 +8732,7 @@ class PipelineService extends EventEmitter { const cdEncodePlan = sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {}; const cdMkInfo = mkInfo && typeof mkInfo === 'object' ? mkInfo : {}; + const cdHandbrakeInfo = this.safeParseJson(sourceJob.handbrake_info_json) || {}; const selectedMeta = cdMkInfo?.selectedMetadata && typeof cdMkInfo.selectedMetadata === 'object' ? cdMkInfo.selectedMetadata : {}; @@ -8535,6 +8748,29 @@ class PipelineService extends EventEmitter { const formatOptions = cdEncodePlan?.formatOptions && typeof cdEncodePlan.formatOptions === 'object' ? cdEncodePlan.formatOptions : {}; + const directReencodeEligibility = evaluateCdDirectReencodeEligibility({ + job: sourceJob, + encodePlan: cdEncodePlan, + mkInfo: cdMkInfo, + handbrakeInfo: cdHandbrakeInfo + }); + if (!directReencodeEligibility.eligible) { + const reasonText = directReencodeEligibility.reasons.join(' | '); + await historyService.appendLog( + sourceJobId, + 'SYSTEM', + `CD-Encode aus RAW blockiert. Bitte zuerst "Vorprüfung starten". Gründe: ${reasonText}` + ).catch(() => {}); + const error = new Error( + `Direktes CD-Encode ist nicht erlaubt: ${reasonText} Bitte zuerst "Vorprüfung starten".` + ); + error.statusCode = 409; + error.details = directReencodeEligibility.reasons.map((message) => ({ + field: 'cd_reencode', + message + })); + throw error; + } const cdOutputTemplate = String( cdReencodeSettings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE ).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; @@ -8542,7 +8778,41 @@ class PipelineService extends EventEmitter { || String(cdReencodeSettings.raw_dir || '').trim() || settingsService.DEFAULT_CD_DIR; const cdOutputOwner = String(cdReencodeSettings.movie_dir_owner || cdReencodeSettings.raw_dir_owner || '').trim(); - const outputDir = cdRipService.buildOutputDir(selectedMeta, cdOutputBaseDir, cdOutputTemplate); + const baseOutputDir = cdRipService.buildOutputDir(selectedMeta, cdOutputBaseDir, cdOutputTemplate); + + // Handle output conflict resolution + const keepBoth = Boolean(options?.keepBoth); + const deleteFolders = Array.isArray(options?.deleteFolders) + ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) + : []; + if (deleteFolders.length > 0) { + await historyService.deleteSpecificOutputFolders(sourceJobId, deleteFolders); + } + const baseOutputStillExists = (() => { + try { + return fs.existsSync(baseOutputDir); + } catch (_error) { + return false; + } + })(); + // Conflict strategy: + // - keepBoth => always number (_X) + // - replace + remaining sibling folders => continue numbering (_X) + // - replace + all removed => reuse base folder without numbering + const needsNumberedOutput = keepBoth || baseOutputStillExists; + const outputDir = needsNumberedOutput ? ensureUniqueOutputPath(baseOutputDir) : baseOutputDir; + + const reencodeVirtualDrivePath = `__virtual__${sourceJobId}`; + const cdLiveTrackRowsReenc = buildCdLiveTrackRows(selectedTrackPositions, tocTracks, selectedMeta?.artist); + const initialCdLiveReenc = buildCdLiveProgressSnapshot({ + trackRows: cdLiveTrackRowsReenc, + phase: 'encode', + trackIndex: cdLiveTrackRowsReenc.length > 0 ? 1 : 0, + trackTotal: cdLiveTrackRowsReenc.length, + trackPosition: cdLiveTrackRowsReenc[0]?.position || null, + ripCompletedCount: cdLiveTrackRowsReenc.length, + encodeCompletedCount: 0 + }); await historyService.resetProcessLog(sourceJobId); await historyService.updateJob(sourceJobId, { @@ -8559,9 +8829,32 @@ class PipelineService extends EventEmitter { `CD-Encode aus RAW angefordert (skipRip). Format=${format}, Tracks=${selectedTrackPositions.join(',') || 'alle'}, RAW=${resolvedCdRawPath}` ); + this._setCdDriveState(reencodeVirtualDrivePath, { + state: 'CD_RIPPING', + jobId: sourceJobId, + progress: 0, + eta: null, + statusText: 'Tracks werden encodiert …', + context: { + jobId: sourceJobId, + mediaProfile: 'cd', + tracks: tocTracks, + selectedMetadata: selectedMeta, + devicePath: null, + virtualDrivePath: reencodeVirtualDrivePath, + skipRip: true, + cdparanoiaCmd: 'cdparanoia', + rawWavDir: resolvedCdRawPath, + outputPath: outputDir, + outputTemplate: cdOutputTemplate, + cdRipConfig: cdEncodePlan, + cdLive: initialCdLiveReenc + } + }); + this._runCdRip({ jobId: sourceJobId, - devicePath: null, + devicePath: reencodeVirtualDrivePath, cdparanoiaCmd: 'cdparanoia', rawWavDir: resolvedCdRawPath, rawBaseDir: null, @@ -8609,6 +8902,24 @@ class PipelineService extends EventEmitter { await historyService.resetProcessLog(sourceJobId); + // Handle explicit folder deletion requested by the user (video/audiobook) + const reencodeDeleteFolders = Array.isArray(options?.deleteFolders) ? options.deleteFolders : []; + if (reencodeDeleteFolders.length > 0) { + try { + const specificDeleteResult = await historyService.deleteSpecificOutputFolders(sourceJobId, reencodeDeleteFolders); + await historyService.appendLog( + sourceJobId, + 'USER_ACTION', + `Re-Encode: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` + ); + } catch (error) { + logger.warn('reencodeFromRaw:delete-specific-folders-failed', { + jobId: sourceJobId, + error: errorToMeta(error) + }); + } + } + const rawInput = findPreferredRawInput(resolvedReencodeRawPath); if (!rawInput) { const error = new Error('Re-Encode nicht möglich: keine Datei im RAW-Pfad gefunden.'); @@ -8675,6 +8986,162 @@ class PipelineService extends EventEmitter { }; } + async restartCdReviewFromRaw(jobId, options = {}) { + this.ensureNotBusy('restartCdReviewFromRaw', jobId); + logger.info('restartCdReviewFromRaw:requested', { jobId, options }); + this.cancelRequestedByJob.delete(Number(jobId)); + + const sourceJob = await historyService.getJobById(jobId); + if (!sourceJob) { + const error = new Error(`Job ${jobId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const currentStatus = String(sourceJob.status || '').trim().toUpperCase(); + if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(currentStatus)) { + const error = new Error(`CD-Vorprüfung nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`); + error.statusCode = 409; + throw error; + } + + const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json) || {}; + const encodePlan = this.safeParseJson(sourceJob.encode_plan_json) || {}; + const keepBoth = Boolean(options?.keepBoth); + const reviewDeleteFolders = Array.isArray(options?.deleteFolders) + ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) + : []; + const mediaProfile = this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: mkInfo, encodePlan }); + + if (mediaProfile !== 'cd') { + const error = new Error(`CD-Vorprüfung nicht möglich: Job ${jobId} ist kein Audio-CD-Job (erkanntes Profil: ${mediaProfile || 'unbekannt'}).`); + error.statusCode = 400; + throw error; + } + + const cdSettings = await settingsService.getEffectiveSettingsMap('cd'); + const resolvedRawPath = this.resolveCurrentRawPathForSettings(cdSettings, 'cd', sourceJob.raw_path); + if (!resolvedRawPath) { + const error = new Error(`CD-Vorprüfung nicht möglich: RAW-Verzeichnis nicht erreichbar (${sourceJob.raw_path}).`); + error.statusCode = 400; + throw error; + } + + const wavFiles = fs.existsSync(resolvedRawPath) + ? fs.readdirSync(resolvedRawPath).filter((f) => f.endsWith('.cdda.wav')) + : []; + if (wavFiles.length === 0) { + const error = new Error('CD-Vorprüfung nicht möglich: keine WAV-Dateien im RAW-Verzeichnis gefunden.'); + error.statusCode = 400; + throw error; + } + + if (reviewDeleteFolders.length > 0) { + try { + const specificDeleteResult = await historyService.deleteSpecificOutputFolders(jobId, reviewDeleteFolders); + await historyService.appendLog( + jobId, + 'USER_ACTION', + `CD-Vorprüfung: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` + ); + } catch (error) { + logger.warn('restartCdReviewFromRaw:delete-specific-folders-failed', { + jobId, + error: errorToMeta(error) + }); + } + } + + const shouldPersistOutputConflict = keepBoth || reviewDeleteFolders.length > 0; + const nextEncodePlan = encodePlan && typeof encodePlan === 'object' + ? { ...encodePlan } + : {}; + + const tocTracks = Array.isArray(mkInfo.tracks) && mkInfo.tracks.length > 0 + ? mkInfo.tracks + : (Array.isArray(encodePlan.tracks) ? encodePlan.tracks : []); + const resetTracks = tocTracks.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, + selected: track?.selected !== false + }; + }); + const selectedMetadata = {}; + nextEncodePlan.tracks = resetTracks; + nextEncodePlan.selectedTracks = resetTracks + .filter((track) => track.selected !== false) + .map((track) => Number(track.position)) + .filter((position) => Number.isFinite(position) && position > 0); + nextEncodePlan.directReencodeReady = false; + nextEncodePlan.directReencodeReadyAt = null; + if (shouldPersistOutputConflict) { + nextEncodePlan.outputConflict = { + strategy: keepBoth ? 'keep_both' : 'replace', + keepBoth, + requestedAt: nowIso(), + deletedFolderCount: reviewDeleteFolders.length + }; + } else { + delete nextEncodePlan.outputConflict; + } + const updatedMkInfo = { + ...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}), + tracks: resetTracks, + selectedMetadata: {} + }; + const cdparanoiaCmd = String(mkInfo.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia'; + + const jobUpdatePayload = { + status: 'CD_METADATA_SELECTION', + last_state: 'CD_METADATA_SELECTION', + start_time: null, + end_time: null, + error_message: null, + encode_plan_json: JSON.stringify(nextEncodePlan), + makemkv_info_json: JSON.stringify(updatedMkInfo) + }; + await historyService.updateJob(jobId, jobUpdatePayload); + + const virtualDrivePath = `__virtual__${jobId}`; + this._setCdDriveState(virtualDrivePath, { + state: 'CD_METADATA_SELECTION', + jobId, + device: null, + progress: 0, + eta: null, + statusText: 'Vorprüfung (kein Laufwerk)', + context: { + jobId, + mediaProfile: 'cd', + devicePath: null, + virtualDrivePath, + skipRip: true, + rawWavDir: resolvedRawPath, + cdparanoiaCmd, + tracks: resetTracks, + selectedMetadata, + detectedTitle: sourceJob.detected_title || sourceJob.title || 'Audio CD' + } + }); + + const snapshotPayload = this.getSnapshot(); + wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload); + this.emit('stateChanged', snapshotPayload); + + await historyService.appendLog( + jobId, + 'USER_ACTION', + `CD-Vorprüfung aus RAW gestartet (skipRip). ${wavFiles.length} WAV-Datei(en) in: ${resolvedRawPath}` + ); + + return { jobId, tracks: resetTracks, selectedMetadata }; + } + async runMediainfoForFile(jobId, inputPath, options = {}) { const lines = []; const config = await settingsService.buildMediaInfoConfig(inputPath, { @@ -10132,7 +10599,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'SYSTEM', - `Finaler Output existierte bereits. Neuer Zielpfad mit Timestamp: ${finalizedOutputPath}` + `Finaler Output existierte bereits. Neuer Zielpfad (nummeriert): ${finalizedOutputPath}` ); } await historyService.appendLog( @@ -10140,6 +10607,9 @@ class PipelineService extends EventEmitter { 'SYSTEM', `Encode-Output finalisiert: ${finalizedOutputPath}` ); + historyService.addJobOutputFolder(jobId, finalizedOutputPath).catch((e) => { + logger.warn('encode:record-output-folder-failed', { jobId, error: e?.message }); + }); let postEncodeScriptsSummary = { configured: 0, attempted: 0, @@ -10479,7 +10949,7 @@ class PipelineService extends EventEmitter { `${decisionContext.detailText} Rip läuft ohne Vorauswahl. Finale Titelwahl erfolgt in der Mediainfo-Prüfung per Checkbox.` ); } - const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }).catch(() => null); + const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'rip').catch(() => null); if (devicePath) { diskDetectionService.lockDevice(devicePath, { @@ -10728,7 +11198,14 @@ class PipelineService extends EventEmitter { title: sourceMakemkvInfo?.selectedMetadata?.title || sourceJob.title || sourceJob.detected_title || 'Audio CD', artist: sourceMakemkvInfo?.selectedMetadata?.artist || null, year: sourceMakemkvInfo?.selectedMetadata?.year ?? sourceJob.year ?? null, - mbId: sourceMakemkvInfo?.selectedMetadata?.mbId || null, + mbId: sourceMakemkvInfo?.selectedMetadata?.mbId + || sourceMakemkvInfo?.selectedMetadata?.musicBrainzId + || sourceMakemkvInfo?.selectedMetadata?.musicbrainzId + || sourceMakemkvInfo?.selectedMetadata?.musicbrainz_id + || sourceMakemkvInfo?.selectedMetadata?.music_brainz_id + || sourceMakemkvInfo?.selectedMetadata?.musicbrainz + || sourceMakemkvInfo?.selectedMetadata?.mbid + || null, coverUrl: sourceMakemkvInfo?.selectedMetadata?.coverUrl || sourceJob.poster_url || null }, selectedPreEncodeScriptIds: [], @@ -10763,6 +11240,9 @@ class PipelineService extends EventEmitter { mbId: selectedMetadata?.mbId || selectedMetadata?.musicBrainzId || selectedMetadata?.musicbrainzId + || selectedMetadata?.musicbrainz_id + || selectedMetadata?.music_brainz_id + || selectedMetadata?.musicbrainz || selectedMetadata?.mbid || null, coverUrl: selectedMetadata?.coverUrl @@ -11119,7 +11599,25 @@ class PipelineService extends EventEmitter { const encodePreviouslySuccessful = String(handBrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; const previousOutputPath = String(job.output_path || '').trim() || null; - if (previousOutputPath && restartDeleteIncompleteOutput && !encodePreviouslySuccessful) { + const keepBoth = Boolean(options?.keepBoth); + const deleteFolders = Array.isArray(options?.deleteFolders) ? options.deleteFolders : []; + + // Handle explicit folder deletion requested by the user + if (deleteFolders.length > 0) { + try { + const specificDeleteResult = await historyService.deleteSpecificOutputFolders(jobId, deleteFolders); + await historyService.appendLog( + jobId, + 'USER_ACTION', + `Encode-Neustart: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` + ); + } catch (error) { + logger.warn('restartEncodeWithLastSettings:delete-specific-folders-failed', { + jobId, + error: errorToMeta(error) + }); + } + } else if (previousOutputPath && restartDeleteIncompleteOutput && !encodePreviouslySuccessful && !keepBoth) { try { const deleteResult = await historyService.deleteJobFiles(jobId, 'movie'); await historyService.appendLog( @@ -11222,6 +11720,14 @@ class PipelineService extends EventEmitter { encode_input_path: inputPath, encode_review_confirmed: 0 }); + + // Keep local poster thumbnails valid for the replacement job id. + if (thumbnailService.isLocalUrl(job.poster_url)) { + const copiedUrl = thumbnailService.copyThumbnail(Number(jobId), replacementJobId); + if (copiedUrl) { + await historyService.updateJob(replacementJobId, { poster_url: copiedUrl }).catch(() => {}); + } + } const loadedSelectionText = ( previousOutputPath ? `Letzte bestätigte Auswahl wurde geladen und kann angepasst werden. Vorheriger Output-Pfad: ${previousOutputPath}. autoDeleteIncomplete=${restartDeleteIncompleteOutput ? 'on' : 'off'}` @@ -11360,6 +11866,25 @@ class PipelineService extends EventEmitter { throw error; } + const reviewDeleteFolders = Array.isArray(options?.deleteFolders) + ? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) + : []; + if (reviewDeleteFolders.length > 0) { + try { + const specificDeleteResult = await historyService.deleteSpecificOutputFolders(jobId, reviewDeleteFolders); + await historyService.appendLog( + jobId, + 'USER_ACTION', + `Review-Neustart: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` + ); + } catch (error) { + logger.warn('restartReviewFromRaw:delete-specific-folders-failed', { + jobId, + error: errorToMeta(error) + }); + } + } + const staleQueueIndex = this.findQueueEntryIndexByJobId(Number(jobId)); let removedQueueActionLabel = null; if (staleQueueIndex >= 0) { @@ -11619,26 +12144,49 @@ class PipelineService extends EventEmitter { || this.snapshot.state || '' ).trim().toUpperCase(); + const SOFT_CANCELABLE_STATES = new Set([ + 'READY_TO_START', + 'READY_TO_ENCODE', + 'METADATA_SELECTION', + 'WAITING_FOR_USER_DECISION', + 'CD_METADATA_SELECTION', + 'CD_READY_TO_RIP' + ]); if (!processHandle) { - if (runningStatus === 'READY_TO_ENCODE') { - // Kein laufender Prozess – Job direkt abbrechen + 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', - last_state: 'CANCELLED', + // Preserve origin state so dashboard/history can distinguish review-cancelled rows. + last_state: runningStatus, end_time: nowIso(), - error_message: 'Vom Benutzer abgebrochen.' + error_message: cancelMessage }); - await historyService.appendLog(normalizedJobId, 'USER_ACTION', 'Abbruch im Status READY_TO_ENCODE.'); + + 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); + } + + await historyService.appendLog(normalizedJobId, 'USER_ACTION', `Abbruch im Status ${runningStatus}.`); await this.setState('CANCELLED', { activeJobId: normalizedJobId, progress: 0, eta: null, - statusText: 'Vom Benutzer abgebrochen.', + statusText: cancelMessage, context: { jobId: normalizedJobId, rawPath: runningJob?.raw_path || null, - error: 'Vom Benutzer abgebrochen.', + error: cancelMessage, + cancelledFromState: runningStatus, canRestartReviewFromRaw: Boolean(runningJob?.raw_path) } }); @@ -12022,6 +12570,9 @@ class PipelineService extends EventEmitter { cdSelectedMetadata?.mbId || cdSelectedMetadata?.musicBrainzId || cdSelectedMetadata?.musicbrainzId + || cdSelectedMetadata?.musicbrainz_id + || cdSelectedMetadata?.music_brainz_id + || cdSelectedMetadata?.musicbrainz || cdSelectedMetadata?.mbid || '' ).trim() || null; @@ -12085,19 +12636,34 @@ class PipelineService extends EventEmitter { }; if (isCdFailure) { - const cdDevicePath = String(job?.disc_device || jobProgressContext?.devicePath || '').trim() || null; - if (cdDevicePath) { - this._setCdDriveState(cdDevicePath, { + const resolvedCdDrivePath = ( + String(job?.disc_device || '').trim() + || String(jobProgressContext?.devicePath || '').trim() + || String(jobProgressContext?.virtualDrivePath || '').trim() + || String(this._getCdDriveByJobId(jobId)?.devicePath || '').trim() + || null + ); + + if (resolvedCdDrivePath) { + this._setCdDriveState(resolvedCdDrivePath, { state: finalState, jobId, - progress: this.cdDrives.get(cdDevicePath)?.progress ?? 0, + progress: this.cdDrives.get(resolvedCdDrivePath)?.progress ?? 0, eta: null, statusText: message, context: failContext }); if (isCancelled) { - this._releaseCdDrive(cdDevicePath); + if (String(resolvedCdDrivePath).startsWith('__virtual__')) { + this._removeCdDrive(resolvedCdDrivePath); + } else { + this._releaseCdDrive(resolvedCdDrivePath); + } } + } else { + // No drive mapping available (e.g. orphan/skipRip import path). + // Ensure stale live progress does not keep the job "running" in the dashboard. + this.jobProgress.delete(Number(jobId)); } } else { await this.setState(finalState, { @@ -12895,7 +13461,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'SYSTEM', - `Finaler Audiobook-Output existierte bereits. Zielpfad mit Timestamp verwendet: ${finalizedOutputPath}` + `Finaler Audiobook-Output existierte bereits. Zielpfad nummeriert: ${finalizedOutputPath}` ); } @@ -12904,6 +13470,9 @@ class PipelineService extends EventEmitter { 'SYSTEM', `Audiobook-Output finalisiert: ${finalizedOutputPath}` ); + historyService.addJobOutputFolder(jobId, finalizedOutputPath).catch((e) => { + logger.warn('audiobook:record-output-folder-failed', { jobId, error: e?.message }); + }); const finalizedOutputFiles = isSplitOutput ? (Array.isArray(preferredChapterPlan?.outputFiles) @@ -13014,7 +13583,7 @@ class PipelineService extends EventEmitter { async analyzeCd(device, options = {}) { const devicePath = String(device?.path || '').trim(); const detectedTitle = String( - device?.discLabel || device?.label || device?.model || 'Audio CD' + device?.discLabel || device?.label || 'Audio CD' ).trim(); logger.info('cd:analyze:start', { devicePath, detectedTitle }); @@ -13228,7 +13797,10 @@ class PipelineService extends EventEmitter { // Bild in Cache laden (async, blockiert nicht) if (coverUrl) { - thumbnailService.cacheJobThumbnail(jobId, coverUrl).catch(() => {}); + historyService.queuePosterCache(jobId, coverUrl, { + source: 'Coverart', + logFailures: true + }); } await historyService.appendLog( @@ -13260,6 +13832,33 @@ class PipelineService extends EventEmitter { cdparanoiaCommandPreview } }); + } else { + // No real device — update virtual drive if present (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); + } } return historyService.getJobById(jobId); @@ -13462,13 +14061,19 @@ class PipelineService extends EventEmitter { const cdInfo = this.safeParseJson(activeJob.makemkv_info_json) || {}; const devicePath = String(activeJob.disc_device || '').trim(); + const skipRip = Boolean(ripConfig?.skipRip || cdInfo?.skipRip); - if (!devicePath) { + if (!devicePath && !skipRip) { const error = new Error('Kein CD-Laufwerk bekannt.'); error.statusCode = 400; throw error; } + // For skipRip (orphan CD): look up virtual drive path or derive it + const effectiveDevicePath = devicePath || ( + this._getCdDriveByJobId(activeJobId)?.devicePath || `__virtual__${activeJobId}` + ); + const format = String(ripConfig?.format || 'flac').trim().toLowerCase(); const formatOptions = ripConfig?.formatOptions || {}; const normalizeTrackPosition = (value) => { @@ -13552,6 +14157,38 @@ class PipelineService extends EventEmitter { const selectedPostEncodeScriptIds = normalizeScriptIdList(ripConfig?.selectedPostEncodeScriptIds || []); const selectedPreEncodeChainIds = normalizeChainIdList(ripConfig?.selectedPreEncodeChainIds || []); const selectedPostEncodeChainIds = normalizeChainIdList(ripConfig?.selectedPostEncodeChainIds || []); + const activeEncodePlan = this.safeParseJson(activeJob.encode_plan_json) || {}; + const plannedOutputConflict = activeEncodePlan?.outputConflict && typeof activeEncodePlan.outputConflict === 'object' + ? activeEncodePlan.outputConflict + : {}; + const incomingOutputConflict = ripConfig?.outputConflict && typeof ripConfig.outputConflict === 'object' + ? ripConfig.outputConflict + : {}; + const outputConflictStrategy = String( + incomingOutputConflict.strategy + || plannedOutputConflict.strategy + || '' + ).trim().toLowerCase(); + const keepBothOutput = outputConflictStrategy === 'keep_both' + || Boolean(incomingOutputConflict.keepBoth || plannedOutputConflict.keepBoth || ripConfig?.keepBoth); + const explicitDeleteFolders = Array.isArray(ripConfig?.deleteFolders) + ? ripConfig.deleteFolders.filter((value) => typeof value === 'string' && value.trim()) + : []; + if (explicitDeleteFolders.length > 0) { + try { + const specificDeleteResult = await historyService.deleteSpecificOutputFolders(activeJobId, explicitDeleteFolders); + await historyService.appendLog( + activeJobId, + 'USER_ACTION', + `CD-Start: gewählte Output-Ordner entfernt (${specificDeleteResult.deleted.length} gelöscht).` + ); + } catch (error) { + logger.warn('startCdRip:delete-specific-folders-failed', { + jobId: activeJobId, + error: errorToMeta(error) + }); + } + } const [ selectedPreEncodeScripts, @@ -13613,22 +14250,52 @@ class PipelineService extends EventEmitter { const cdOutputBaseDir = String(settings.movie_dir || '').trim() || cdRawBaseDir; const cdRawOwner = String(settings.raw_dir_owner || '').trim(); const cdOutputOwner = String(settings.movie_dir_owner || settings.raw_dir_owner || '').trim(); - const cdMetadataBase = buildRawMetadataBase({ - title: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null, - year: effectiveSelectedMeta?.year || null - }, activeJobId); - const rawDirName = buildRawDirName(cdMetadataBase, activeJobId, { state: RAW_FOLDER_STATES.INCOMPLETE }); - const rawJobDir = path.join(cdRawBaseDir, rawDirName); - const rawWavDir = rawJobDir; - const outputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdOutputBaseDir, cdOutputTemplate); - ensureDir(cdRawBaseDir); - ensureDir(rawJobDir); + + // For skipRip: use existing WAV dir instead of creating a new raw directory + let rawWavDir; + let rawJobDir; + let cdMetadataBase = null; + if (skipRip) { + const existingRawPath = this.resolveCurrentRawPathForSettings(settings, 'cd', activeJob.raw_path); + if (!existingRawPath) { + const error = new Error(`CD-Encode nicht möglich: RAW-Verzeichnis nicht erreichbar (${activeJob.raw_path}).`); + error.statusCode = 400; + throw error; + } + rawWavDir = existingRawPath; + rawJobDir = existingRawPath; + } else { + cdMetadataBase = buildRawMetadataBase({ + title: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null, + year: effectiveSelectedMeta?.year || null + }, activeJobId); + const rawDirName = buildRawDirName(cdMetadataBase, activeJobId, { state: RAW_FOLDER_STATES.INCOMPLETE }); + rawJobDir = path.join(cdRawBaseDir, rawDirName); + rawWavDir = rawJobDir; + ensureDir(cdRawBaseDir); + ensureDir(rawJobDir); + chownRecursive(rawJobDir, cdRawOwner); + } + + const baseOutputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdOutputBaseDir, cdOutputTemplate); + const baseOutputStillExists = (() => { + try { + return fs.existsSync(baseOutputDir); + } catch (_error) { + return false; + } + })(); + // Conflict strategy: + // - keepBoth => always number (_X) + // - replace + remaining sibling folders => continue numbering (_X) + // - replace + all removed => reuse base folder without numbering + const needsNumberedOutput = keepBothOutput || baseOutputStillExists; + const outputDir = needsNumberedOutput ? ensureUniqueOutputPath(baseOutputDir) : baseOutputDir; ensureDir(outputDir); - chownRecursive(rawJobDir, cdRawOwner); chownRecursive(outputDir, cdOutputOwner); const previewTrackPos = effectiveSelectedTrackPositions[0] || mergedTracks[0]?.position || 1; const previewWavPath = path.join(rawWavDir, `track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`); - const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath} ${previewTrackPos} ${previewWavPath}`; + const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${effectiveDevicePath || ''} ${previewTrackPos} ${previewWavPath}`; const cdLiveTrackRows = buildCdLiveTrackRows( effectiveSelectedTrackPositions, mergedTracks, @@ -13636,19 +14303,26 @@ class PipelineService extends EventEmitter { ); const initialCdLive = buildCdLiveProgressSnapshot({ trackRows: cdLiveTrackRows, - phase: 'rip', + phase: skipRip ? 'encode' : 'rip', trackIndex: cdLiveTrackRows.length > 0 ? 1 : 0, trackTotal: cdLiveTrackRows.length, trackPosition: cdLiveTrackRows[0]?.position || null, - ripCompletedCount: 0, + ripCompletedCount: skipRip ? cdLiveTrackRows.length : 0, encodeCompletedCount: 0 }); + const startedAt = nowIso(); const cdEncodePlan = { format, formatOptions, selectedTracks: effectiveSelectedTrackPositions, tracks: mergedTracks, + directReencodeReady: true, + directReencodeReadyAt: startedAt, outputTemplate: cdOutputTemplate, + outputConflict: { + strategy: keepBothOutput ? 'keep_both' : 'replace', + keepBoth: keepBothOutput + }, preEncodeScriptIds: selectedPreEncodeScripts.map((item) => Number(item.id)), postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)), preEncodeScripts: selectedPreEncodeScripts.map(toScriptDescriptor).filter(Boolean), @@ -13664,35 +14338,42 @@ class PipelineService extends EventEmitter { tracks: mergedTracks, selectedMetadata: effectiveSelectedMeta }; - - await historyService.updateJob(activeJobId, { + const jobUpdate = { title: effectiveSelectedMeta?.title || null, year: normalizeOptionalYear(effectiveSelectedMeta?.year), status: 'CD_RIPPING', last_state: 'CD_RIPPING', + start_time: startedAt, + end_time: null, error_message: null, - raw_path: rawJobDir, output_path: outputDir, handbrake_info_json: null, mediainfo_info_json: null, makemkv_info_json: JSON.stringify(updatedCdInfo), encode_plan_json: JSON.stringify(cdEncodePlan) - }); + }; + // Only overwrite raw_path for normal rips; skipRip keeps the existing WAV directory + if (!skipRip) { + jobUpdate.raw_path = rawJobDir; + } + await historyService.updateJob(activeJobId, jobUpdate); - const existingCdDrive = this.cdDrives.get(devicePath); - this._setCdDriveState(devicePath, { + const existingCdDrive = this.cdDrives.get(effectiveDevicePath); + this._setCdDriveState(effectiveDevicePath, { state: 'CD_RIPPING', jobId: activeJobId, progress: 0, eta: null, - statusText: 'CD wird gerippt …', + statusText: skipRip ? 'Tracks werden encodiert …' : 'CD wird gerippt …', context: { ...(existingCdDrive?.context || {}), jobId: activeJobId, mediaProfile: 'cd', tracks: mergedTracks, selectedMetadata: effectiveSelectedMeta, - devicePath, + devicePath: devicePath || null, + virtualDrivePath: skipRip ? effectiveDevicePath : undefined, + skipRip: skipRip || undefined, cdparanoiaCmd, rawWavDir, outputPath: outputDir, @@ -13703,11 +14384,13 @@ class PipelineService extends EventEmitter { } }); - logger.info('cd:rip:start', { jobId: activeJobId, devicePath, format, trackCount: effectiveSelectedTrackPositions.length }); + logger.info('cd:rip:start', { jobId: activeJobId, devicePath: effectiveDevicePath, skipRip, format, trackCount: effectiveSelectedTrackPositions.length }); await historyService.appendLog( activeJobId, 'SYSTEM', - `CD-Rip gestartet: Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` + skipRip + ? `CD-Encode aus RAW gestartet (skipRip): Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` + : `CD-Rip gestartet: Format=${format}, Tracks=${effectiveSelectedTrackPositions.join(',') || 'alle'}` ); if ( selectedPreEncodeScripts.length > 0 @@ -13726,10 +14409,10 @@ class PipelineService extends EventEmitter { // Run asynchronously so the HTTP response returns immediately this._runCdRip({ jobId: activeJobId, - devicePath, + devicePath: effectiveDevicePath, cdparanoiaCmd, rawWavDir, - rawBaseDir: cdRawBaseDir, + rawBaseDir: skipRip ? null : cdRawBaseDir, cdMetadataBase, outputDir, format, @@ -13740,7 +14423,8 @@ class PipelineService extends EventEmitter { selectedTrackPositions: effectiveSelectedTrackPositions, tocTracks: mergedTracks, selectedMeta: effectiveSelectedMeta, - encodePlan: cdEncodePlan + encodePlan: cdEncodePlan, + skipRip }).catch((error) => { logger.error('cd:rip:unhandled', { jobId: activeJobId, error: errorToMeta(error) }); }); @@ -14037,6 +14721,9 @@ class PipelineService extends EventEmitter { chownRecursive(activeRawDir, rawOwner || outputOwner); chownRecursive(outputDir, outputOwner); await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip abgeschlossen. Ausgabe: ${outputDir}`); + historyService.addJobOutputFolder(jobId, outputDir).catch((e) => { + logger.warn('cd:record-output-folder-failed', { jobId, error: e?.message }); + }); const finishedStatusText = postEncodeScriptsSummary.failed > 0 ? `CD-Rip abgeschlossen (${postEncodeScriptsSummary.failed} Skript(e) fehlgeschlagen)` : 'CD-Rip abgeschlossen'; @@ -14064,13 +14751,20 @@ class PipelineService extends EventEmitter { selectedMetadata: selectedMeta } }); - this._releaseCdDrive(devicePath); + // Virtual drives (orphan CD jobs without a real device) are removed after encode; real drives reset to DISC_DETECTED + if (String(devicePath || '').startsWith('__virtual__')) { + this._removeCdDrive(devicePath); + } else { + this._releaseCdDrive(devicePath); + } void this.notifyPushover('job_finished', { title: 'Ripster - CD Rip erfolgreich', message: `Job #${jobId}: ${selectedMeta?.title || 'Audio CD'}` }); - void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath)); + if (!String(devicePath || '').startsWith('__virtual__')) { + void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath)); + } } catch (error) { settleLifecycle(); const failedCdLive = buildLiveContext(currentTrackPosition || null); diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 4b33697..0e51178 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -829,6 +829,7 @@ class SettingsService { s.options_json, s.validation_json, s.order_index, + s.depends_on, v.value as current_value FROM settings_schema s LEFT JOIN settings_values v ON v.key = s.key @@ -847,7 +848,8 @@ class SettingsService { options: parseJson(row.options_json, []), validation: parseJson(row.validation_json, {}), value: normalizeValueByType(row.type, row.current_value ?? row.default_value), - orderIndex: row.order_index + orderIndex: row.order_index, + depends_on: row.depends_on ?? null })); } diff --git a/backend/src/services/thumbnailService.js b/backend/src/services/thumbnailService.js index 8e3c23c..67d2907 100644 --- a/backend/src/services/thumbnailService.js +++ b/backend/src/services/thumbnailService.js @@ -33,6 +33,31 @@ function isLocalUrl(url) { return typeof url === 'string' && url.startsWith('/api/thumbnails/'); } +function resolveLocalThumbnailPath(url) { + const raw = String(url || '').trim(); + const match = raw.match(/^\/api\/thumbnails\/job-(\d+)\.jpg$/i); + if (!match) { + return null; + } + const jobId = Number(match[1]); + if (!Number.isFinite(jobId) || jobId <= 0) { + return null; + } + return persistentFilePath(Math.trunc(jobId)); +} + +function localThumbnailUrlExists(url) { + const targetPath = resolveLocalThumbnailPath(url); + if (!targetPath) { + return false; + } + try { + return fs.existsSync(targetPath); + } catch (_error) { + return false; + } +} + function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) { return new Promise((resolve, reject) => { if (redirectsLeft <= 0) { @@ -82,21 +107,48 @@ function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) { * Wird aufgerufen sobald poster_url bekannt ist (vor Rip-Start). * @returns {Promise} lokaler Pfad oder null */ -async function cacheJobThumbnail(jobId, posterUrl) { - if (!posterUrl || isLocalUrl(posterUrl)) return null; +async function cacheJobThumbnailDetailed(jobId, posterUrl) { + const normalizedPosterUrl = String(posterUrl || '').trim(); + if (!normalizedPosterUrl || isLocalUrl(normalizedPosterUrl)) { + return { + ok: false, + jobId, + posterUrl: normalizedPosterUrl || null, + cachedPath: null, + error: 'Kein externer Poster-Link vorhanden.' + }; + } try { ensureDirs(); const dest = cacheFilePath(jobId); - await downloadImage(posterUrl, dest); - logger.info('thumbnail:cached', { jobId, posterUrl, dest }); - return dest; + await downloadImage(normalizedPosterUrl, dest); + logger.info('thumbnail:cached', { jobId, posterUrl: normalizedPosterUrl, dest }); + return { + ok: true, + jobId, + posterUrl: normalizedPosterUrl, + cachedPath: dest, + error: null + }; } catch (err) { - logger.warn('thumbnail:cache:failed', { jobId, posterUrl, error: err.message }); - return null; + const message = String(err?.message || err || 'Bild-Download fehlgeschlagen'); + logger.warn('thumbnail:cache:failed', { jobId, posterUrl: normalizedPosterUrl, error: message }); + return { + ok: false, + jobId, + posterUrl: normalizedPosterUrl, + cachedPath: null, + error: message + }; } } +async function cacheJobThumbnail(jobId, posterUrl) { + const result = await cacheJobThumbnailDetailed(jobId, posterUrl); + return result?.ok ? result.cachedPath : null; +} + /** * Verschiebt das gecachte Bild in den persistenten Ordner. * Gibt die lokale API-URL zurück, oder null wenn kein Bild vorhanden. @@ -251,11 +303,14 @@ async function migrateExistingThumbnails() { module.exports = { cacheJobThumbnail, + cacheJobThumbnailDetailed, promoteJobThumbnail, copyThumbnail, storeLocalThumbnail, deleteThumbnail, getThumbnailsDir, migrateExistingThumbnails, - isLocalUrl + isLocalUrl, + resolveLocalThumbnailPath, + localThumbnailUrlExists }; diff --git a/db/schema.sql b/db/schema.sql index ffbee00..541fda2 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -11,6 +11,7 @@ CREATE TABLE settings_schema ( options_json TEXT, validation_json TEXT, order_index INTEGER NOT NULL DEFAULT 0, + depends_on TEXT DEFAULT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -73,6 +74,18 @@ CREATE TABLE job_lineage_artifacts ( CREATE INDEX idx_job_lineage_artifacts_job_id ON job_lineage_artifacts(job_id); CREATE INDEX idx_job_lineage_artifacts_source_job_id ON job_lineage_artifacts(source_job_id); +CREATE TABLE job_output_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + output_path TEXT NOT NULL, + label TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE +); + +CREATE INDEX idx_job_output_folders_job_id ON job_output_folders(job_id); +CREATE UNIQUE INDEX idx_job_output_folders_unique ON job_output_folders(job_id, output_path); + CREATE TABLE scripts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -452,6 +465,14 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des 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'); + +INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) +VALUES ('coverart_recovery_interval_hours', 'Metadaten', 'Coverart-Prüfintervall (Stunden)', 'number', 1, 'Intervall für automatische Prüfung fehlender Coverarts in Stunden.', '6', '[]', '{"min":1,"max":168}', 431); +INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6'); + -- Benachrichtigungen INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) VALUES ('ui_expert_mode', 'Benachrichtigungen', 'Expertenmodus', 'boolean', 1, 'Schaltet erweiterte Einstellungen in der UI ein.', 'false', '[]', '{}', 495); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 17dd1a6..3f6089f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.12.0", + "version": "0.12.0-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.12.0", + "version": "0.12.0-1", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index d9efa0c..4a3ec05 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.12.0", + "version": "0.12.0-1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 92dc4e8..2af43d5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -120,6 +120,47 @@ function App() { const location = useLocation(); const navigate = useNavigate(); const globalToastRef = useRef(null); + const prevCdDrivesRef = useRef({}); + + // When a virtual CD drive is removed (CD encode/rip finished or failed), + // or when a CD drive transitions away from an active job, force both + // Dashboard and History to re-fetch so jobs leave the live list reliably. + useEffect(() => { + const current = pipeline?.cdDrives || {}; + const prev = prevCdDrivesRef.current; + const normalizeState = (value) => String(value || '').trim().toUpperCase(); + const ACTIVE_CD_STATES = new Set(['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING']); + const TERMINAL_CD_STATES = new Set(['FINISHED', 'ERROR', 'CANCELLED']); + let shouldRefresh = false; + + for (const [devicePath, previousDrive] of Object.entries(prev)) { + const previousJobId = normalizeJobId(previousDrive?.jobId); + if (!previousJobId) { + continue; + } + const previousState = normalizeState(previousDrive?.state); + const currentDrive = current?.[devicePath] || null; + const currentJobId = normalizeJobId(currentDrive?.jobId); + const currentState = normalizeState(currentDrive?.state); + const driveRemoved = !currentDrive; + const jobChanged = currentJobId !== previousJobId; + const movedToTerminal = currentJobId === previousJobId + && TERMINAL_CD_STATES.has(currentState) + && currentState !== previousState; + const leftActiveLifecycle = ACTIVE_CD_STATES.has(previousState) + && (driveRemoved || jobChanged || !ACTIVE_CD_STATES.has(currentState)); + if (movedToTerminal || leftActiveLifecycle) { + shouldRefresh = true; + break; + } + } + + if (shouldRefresh) { + setDashboardJobsRefreshToken((t) => t + 1); + setHistoryJobsRefreshToken((t) => t + 1); + } + prevCdDrivesRef.current = current; + }, [pipeline?.cdDrives]); const refreshPipeline = async () => { const response = await api.getPipelineState(); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index f6f53c1..c259462 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -449,6 +449,14 @@ export const api = { body: JSON.stringify(payload) }); }, + async recoverMissingCoverArt(payload = {}) { + const result = await request('/settings/coverart/recover', { + method: 'POST', + body: JSON.stringify(payload || {}) + }); + afterMutationInvalidate(['/history', '/settings']); + return result; + }, getPipelineState() { return request('/pipeline/state'); }, @@ -594,23 +602,57 @@ export const api = { afterMutationInvalidate(['/history', '/pipeline/queue']); return result; }, - async reencodeJob(jobId) { + async reencodeJob(jobId, options = {}) { + const body = {}; + if (options.keepBoth) body.keepBoth = true; + if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders; const result = await request(`/pipeline/reencode/${jobId}`, { - method: 'POST' + method: 'POST', + body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined }); afterMutationInvalidate(['/history', '/pipeline/queue']); return result; }, - async restartReviewFromRaw(jobId) { + async restartReviewFromRaw(jobId, options = {}) { + const body = {}; + if (options.keepBoth) body.keepBoth = true; + if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders; const result = await request(`/pipeline/restart-review/${jobId}`, { - method: 'POST' + method: 'POST', + body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined }); afterMutationInvalidate(['/history', '/pipeline/queue']); return result; }, - async restartEncodeWithLastSettings(jobId) { + async restartEncodeWithLastSettings(jobId, options = {}) { + const body = {}; + if (options.keepBoth) body.keepBoth = true; + if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders; const result = await request(`/pipeline/restart-encode/${jobId}`, { - method: 'POST' + method: 'POST', + body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, + getOutputFolders(jobId) { + return request(`/pipeline/output-folders/${jobId}`); + }, + async deleteOutputFolders(jobId, folderPaths = []) { + const result = await request(`/pipeline/delete-output-folders/${jobId}`, { + method: 'POST', + body: JSON.stringify({ folderPaths }) + }); + afterMutationInvalidate(['/history']); + return result; + }, + async restartCdReviewFromRaw(jobId, options = {}) { + const body = {}; + if (options.keepBoth) body.keepBoth = true; + if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders; + const result = await request(`/pipeline/restart-cd-review/${jobId}`, { + method: 'POST', + body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined }); afterMutationInvalidate(['/history', '/pipeline/queue']); return result; @@ -700,17 +742,30 @@ export const api = { }, async deleteJobEntry(jobId, target = 'none', options = {}) { const includeRelated = Boolean(options?.includeRelated); + const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths) + ? options.selectedMoviePaths + .map((item) => String(item || '').trim()) + .filter(Boolean) + : null; const result = await request(`/history/${jobId}/delete`, { method: 'POST', - body: JSON.stringify({ target, includeRelated }) + body: JSON.stringify({ + target, + includeRelated, + ...(selectedMoviePaths ? { selectedMoviePaths } : {}) + }) }); afterMutationInvalidate(['/history', '/pipeline/queue']); return result; }, - requestJobArchive(jobId, target = 'raw') { + requestJobArchive(jobId, target = 'raw', options = {}) { + const outputPath = String(options?.outputPath || '').trim(); return request(`/downloads/history/${jobId}`, { method: 'POST', - body: JSON.stringify({ target }) + body: JSON.stringify({ + target, + ...(outputPath ? { outputPath } : {}) + }) }); }, getDownloads() { diff --git a/frontend/src/components/CdRipConfigPanel.jsx b/frontend/src/components/CdRipConfigPanel.jsx index e5a8adf..f3ab8c5 100644 --- a/frontend/src/components/CdRipConfigPanel.jsx +++ b/frontend/src/components/CdRipConfigPanel.jsx @@ -227,6 +227,7 @@ export default function CdRipConfigPanel({ onStart, onCancel, onRetry, + onRestartReview, onOpenMetadata, busy }) { @@ -523,7 +524,8 @@ export default function CdRipConfigPanel({ }); }; - const handleStart = () => { + const handleStart = (options = {}) => { + const forceSkipRip = Boolean(options?.forceSkipRip); const albumTitle = normalizeTrackText(metaFields?.title) || normalizeTrackText(selectedMeta?.title) || normalizeTrackText(context?.detectedTitle) @@ -591,6 +593,7 @@ export default function CdRipConfigPanel({ selectedPostEncodeScriptIds, selectedPreEncodeChainIds, selectedPostEncodeChainIds, + skipRip: forceSkipRip || context.skipRip === true ? true : undefined, metadata: { title: albumTitle, artist: albumArtist, @@ -727,6 +730,19 @@ export default function CdRipConfigPanel({ const completedEncodeCount = selectedTrackRows.filter((track) => track.encodeStatus === 'done').length; const liveCurrentTrackPosition = normalizePosition(cdLive?.trackPosition); const lastState = String(context?.lastState || '').trim().toUpperCase(); + const failureStage = String(context?.stage || '').trim().toUpperCase(); + const normalizedLivePhase = String(cdLive?.phase || '').trim().toLowerCase(); + const normalizedStatusText = String(statusText || '').trim().toUpperCase(); + const isEncodingFailure = Boolean( + isTerminalFailure + && ( + lastState === 'CD_ENCODING' + || failureStage === 'CD_ENCODING' + || context?.skipRip === true + || normalizedLivePhase === 'encode' + || normalizedStatusText.includes('CD_ENCODING') + ) + ); const preScriptNamesFromConfig = (Array.isArray(cdRipConfig?.preEncodeScripts) ? cdRipConfig.preEncodeScripts : []) .map((item) => String(item?.name || '').trim()) .filter(Boolean); @@ -797,22 +813,44 @@ export default function CdRipConfigPanel({ ) : null} {isTerminalFailure ? (
- {jobId ? ( + {isEncodingFailure ? (
) : null} @@ -1267,7 +1305,7 @@ export default function CdRipConfigPanel({ disabled={busy} />