0.12.0-1 Plugins und diverses
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
+221
-19
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 });
|
||||
})
|
||||
|
||||
@@ -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 });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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.
|
||||
const hasAudioCdToc = await this.probeAudioCdWithCdparanoia(devicePath);
|
||||
if (hasAudioCdToc) {
|
||||
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 { 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' };
|
||||
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 };
|
||||
}
|
||||
logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath });
|
||||
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' };
|
||||
}
|
||||
// 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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string|null>} 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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,6 +813,16 @@ export default function CdRipConfigPanel({
|
||||
) : null}
|
||||
{isTerminalFailure ? (
|
||||
<div className="actions-row">
|
||||
{isEncodingFailure ? (
|
||||
<Button
|
||||
label="Encode starten"
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
onClick={() => handleStart({ forceSkipRip: true })}
|
||||
loading={busy}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{jobId ? (
|
||||
<Button
|
||||
label="Retry Rippen"
|
||||
@@ -813,6 +839,18 @@ export default function CdRipConfigPanel({
|
||||
onClick={() => onOpenMetadata && onOpenMetadata()}
|
||||
loading={busy}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isEncodingFailure ? (
|
||||
<Button
|
||||
label="Review starten"
|
||||
icon="pi pi-search"
|
||||
severity="info"
|
||||
outlined
|
||||
onClick={() => onRestartReview && onRestartReview()}
|
||||
loading={busy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1267,7 +1305,7 @@ export default function CdRipConfigPanel({
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button
|
||||
label="Rip starten"
|
||||
label={context.skipRip === true ? 'Encode starten' : 'Rip starten'}
|
||||
icon="pi pi-play"
|
||||
onClick={handleStart}
|
||||
loading={busy}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { TabView, TabPanel } from 'primereact/tabview';
|
||||
import { Accordion, AccordionTab } from 'primereact/accordion';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
@@ -357,6 +357,17 @@ function isNotificationEventToggleSetting(setting) {
|
||||
return setting?.type === 'boolean' && NOTIFICATION_EVENT_TOGGLE_KEYS.has(normalizeSettingKey(setting?.key));
|
||||
}
|
||||
|
||||
function isReadonlySetting(setting) {
|
||||
try {
|
||||
// API gibt validation als parsed object zurück, nicht validation_json als String
|
||||
const v = setting?.validation;
|
||||
if (v && typeof v === 'object') return v.readonly === true;
|
||||
return JSON.parse(setting?.validation_json || '{}')?.readonly === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function SettingField({
|
||||
setting,
|
||||
value,
|
||||
@@ -367,7 +378,8 @@ function SettingField({
|
||||
ownerError,
|
||||
ownerDirty,
|
||||
onChange,
|
||||
variant = 'default'
|
||||
variant = 'default',
|
||||
disabled = false
|
||||
}) {
|
||||
const ownerKey = ownerSetting?.key;
|
||||
const pathHasValue = Boolean(String(value ?? '').trim());
|
||||
@@ -384,13 +396,15 @@ function SettingField({
|
||||
<InputSwitch
|
||||
id={setting.key}
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
disabled={disabled}
|
||||
onChange={disabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<label htmlFor={setting.key}>
|
||||
<label htmlFor={setting.key} className={disabled ? 'setting-label--readonly' : undefined}>
|
||||
{setting.label}
|
||||
{setting.required && <span className="required">*</span>}
|
||||
{disabled ? <span className="setting-badge--coming-soon"> (bald)</span> : null}
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -424,7 +438,8 @@ function SettingField({
|
||||
<InputSwitch
|
||||
id={setting.key}
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
disabled={disabled}
|
||||
onChange={disabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -780,10 +795,22 @@ export default function DynamicSettingsForm({
|
||||
const notificationToggleKeys = new Set(
|
||||
notificationToggleSettings.map((setting) => normalizeSettingKey(setting?.key))
|
||||
);
|
||||
const regularSettings = baseSettings.filter(
|
||||
(setting) => !notificationToggleKeys.has(normalizeSettingKey(setting?.key))
|
||||
);
|
||||
const renderSetting = (setting, variant = 'default') => {
|
||||
|
||||
// Abhängigkeitsbaum aufbauen: depends_on → Kinder
|
||||
const dependentsByParent = new Map();
|
||||
const rootSettings = [];
|
||||
for (const setting of baseSettings) {
|
||||
if (notificationToggleKeys.has(normalizeSettingKey(setting?.key))) continue;
|
||||
const dep = setting?.depends_on;
|
||||
if (dep) {
|
||||
if (!dependentsByParent.has(dep)) dependentsByParent.set(dep, []);
|
||||
dependentsByParent.get(dep).push(setting);
|
||||
} else {
|
||||
rootSettings.push(setting);
|
||||
}
|
||||
}
|
||||
|
||||
const renderSetting = (setting, variant = 'default', disabled = false, onChangeFn = onChange) => {
|
||||
const value = values?.[setting.key];
|
||||
const error = errors?.[setting.key] || null;
|
||||
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||
@@ -805,17 +832,53 @@ export default function DynamicSettingsForm({
|
||||
ownerValue={ownerValue}
|
||||
ownerError={ownerError}
|
||||
ownerDirty={ownerDirty}
|
||||
onChange={onChange}
|
||||
onChange={onChangeFn}
|
||||
variant={variant}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Alle Nachkommen eines Keys rekursiv auf false setzen
|
||||
const cascadeOff = (key) => {
|
||||
const children = dependentsByParent.get(key) || [];
|
||||
for (const child of children) {
|
||||
onChange?.(child.key, false);
|
||||
cascadeOff(child.key);
|
||||
}
|
||||
};
|
||||
|
||||
// Rekursiv: Setting + seine Kinder rendern
|
||||
// Wenn ein Parent auf false geht → alle Kinder werden auf false gesetzt
|
||||
const renderWithChildren = (setting, depth = 0) => {
|
||||
const children = dependentsByParent.get(setting.key) || [];
|
||||
const readonly = depth > 0 && isReadonlySetting(setting);
|
||||
const active = toBoolean(values?.[setting.key]);
|
||||
|
||||
const effectiveOnChange = children.length > 0
|
||||
? (key, value) => {
|
||||
onChange?.(key, value);
|
||||
if (!value) cascadeOff(key);
|
||||
}
|
||||
: onChange;
|
||||
|
||||
return (
|
||||
<React.Fragment key={setting.key}>
|
||||
{renderSetting(setting, 'default', readonly, effectiveOnChange)}
|
||||
{children.length > 0 && active && !readonly ? (
|
||||
<div className={`settings-grid settings-grid--depth-${depth + 1}`}>
|
||||
{children.map((child) => renderWithChildren(child, depth + 1))}
|
||||
</div>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{regularSettings.length > 0 ? (
|
||||
{rootSettings.length > 0 ? (
|
||||
<div className="settings-grid">
|
||||
{regularSettings.map((setting) => renderSetting(setting))}
|
||||
{rootSettings.map((setting) => renderWithChildren(setting, 0))}
|
||||
</div>
|
||||
) : null}
|
||||
{pushoverEnabled && notificationToggleSettings.length > 0 ? (
|
||||
|
||||
@@ -91,6 +91,21 @@ function normalizePositiveInteger(value) {
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeCdText(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFC')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isPlaceholderCdTrackTitle(value) {
|
||||
const normalized = normalizeCdText(value).toLowerCase();
|
||||
if (!normalized) {
|
||||
return true;
|
||||
}
|
||||
return /^track\s*\d+$/i.test(normalized);
|
||||
}
|
||||
|
||||
function formatDurationSeconds(totalSeconds) {
|
||||
const parsed = Number(totalSeconds);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
@@ -352,7 +367,11 @@ function resolveCdDetails(job) {
|
||||
selectedMetadata?.mbId
|
||||
|| selectedMetadata?.musicBrainzId
|
||||
|| selectedMetadata?.musicbrainzId
|
||||
|| selectedMetadata?.musicbrainz_id
|
||||
|| selectedMetadata?.music_brainz_id
|
||||
|| selectedMetadata?.musicbrainz
|
||||
|| selectedMetadata?.mbid
|
||||
|| job?.imdb_id
|
||||
|| ''
|
||||
).trim() || null;
|
||||
|
||||
@@ -568,11 +587,13 @@ export default function JobDetailDialog({
|
||||
onResumeReady,
|
||||
onRestartEncode,
|
||||
onRestartReview,
|
||||
onRestartCdReview,
|
||||
onReencode,
|
||||
onRetry,
|
||||
onDeleteFiles,
|
||||
onDeleteEntry,
|
||||
onDownloadArchive,
|
||||
onDownloadOutputFolder,
|
||||
onRemoveFromQueue,
|
||||
isQueued = false,
|
||||
omdbAssignBusy = false,
|
||||
@@ -580,7 +601,8 @@ export default function JobDetailDialog({
|
||||
actionBusy = false,
|
||||
reencodeBusy = false,
|
||||
deleteEntryBusy = false,
|
||||
downloadBusyTarget = null
|
||||
downloadBusyTarget = null,
|
||||
downloadFolderBusyPath = null
|
||||
}) {
|
||||
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
||||
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(job?.status);
|
||||
@@ -588,8 +610,76 @@ export default function JobDetailDialog({
|
||||
const mediaType = resolveMediaType(job);
|
||||
const isCd = mediaType === 'cd';
|
||||
const isAudiobook = mediaType === 'audiobook';
|
||||
const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && !running
|
||||
&& (isCd || mkDone));
|
||||
const cdRawPathCandidates = [
|
||||
job?.raw_path,
|
||||
job?.makemkvInfo?.rawPath,
|
||||
job?.makemkvInfo?.importContext?.requestedRawPath,
|
||||
job?.makemkvInfo?.importContext?.originalRawPath
|
||||
]
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean);
|
||||
const hasCdRawInputCandidate = Boolean(job?.rawStatus?.exists) || cdRawPathCandidates.length > 0;
|
||||
const hasReencodeRawInput = isCd
|
||||
? hasCdRawInputCandidate
|
||||
: Boolean(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true);
|
||||
const canReencode = !!(hasReencodeRawInput && !running && (isCd || mkDone));
|
||||
// For CDs: direct re-encode requires track data from the last run
|
||||
const cdEncodePlan = isCd && job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
||||
const cdSelectedMeta = isCd && job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
|
||||
? job.makemkvInfo.selectedMetadata
|
||||
: {};
|
||||
const cdPlanTracks = isCd && Array.isArray(cdEncodePlan?.tracks)
|
||||
? cdEncodePlan.tracks
|
||||
: [];
|
||||
const cdSelectedTrackPositions = isCd
|
||||
? (
|
||||
Array.isArray(cdEncodePlan?.selectedTracks) && cdEncodePlan.selectedTracks.length > 0
|
||||
? cdEncodePlan.selectedTracks
|
||||
: cdPlanTracks.filter((track) => track?.selected !== false).map((track) => track?.position)
|
||||
)
|
||||
.map((value) => normalizePositiveInteger(value))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const cdTrackByPosition = new Map(
|
||||
cdPlanTracks
|
||||
.map((track) => {
|
||||
const position = normalizePositiveInteger(track?.position);
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
return [position, track];
|
||||
})
|
||||
.filter(Boolean)
|
||||
);
|
||||
const cdHasCoreMetadata = Boolean(
|
||||
normalizeCdText(cdSelectedMeta?.title || cdSelectedMeta?.album || job?.title || job?.detected_title)
|
||||
&& normalizeCdText(cdSelectedMeta?.artist)
|
||||
);
|
||||
const cdHasMeaningfulTrackTitles = cdSelectedTrackPositions.some((position) => {
|
||||
const track = cdTrackByPosition.get(position);
|
||||
const title = normalizeCdText(track?.title);
|
||||
return Boolean(title) && !isPlaceholderCdTrackTitle(title);
|
||||
});
|
||||
const cdHasPriorRunEvidence = Boolean(
|
||||
cdEncodePlan?.directReencodeReady
|
||||
|| (Array.isArray(job?.handbrakeInfo?.tracks) && job.handbrakeInfo.tracks.length > 0)
|
||||
|| String(job?.handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'
|
||||
);
|
||||
const cdHasLastRunData = Boolean(
|
||||
cdEncodePlan
|
||||
&& Array.isArray(cdEncodePlan.tracks)
|
||||
&& cdEncodePlan.tracks.length > 0
|
||||
&& cdEncodePlan.format
|
||||
);
|
||||
const canCdDirectEncode = Boolean(
|
||||
canReencode
|
||||
&& cdHasLastRunData
|
||||
&& cdHasCoreMetadata
|
||||
&& cdHasMeaningfulTrackTitles
|
||||
&& cdHasPriorRunEvidence
|
||||
);
|
||||
const canCdStartReview = !!(hasCdRawInputCandidate && !running
|
||||
&& isCd && typeof onRestartCdReview === 'function');
|
||||
const canResumeReady = Boolean(
|
||||
(String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' || String(job?.last_state || '').trim().toUpperCase() === 'READY_TO_ENCODE')
|
||||
&& !running
|
||||
@@ -634,7 +724,9 @@ export default function JobDetailDialog({
|
||||
const mediaTypeAlt = mediaTypeLabel;
|
||||
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
||||
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||
const pluginExecution = resolveJobPluginExecution(job, null);
|
||||
const pluginExecution = resolveJobPluginExecution(job, null, {
|
||||
currentStatus: job?.status
|
||||
});
|
||||
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
|
||||
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|
||||
|| configuredSelection.postScriptIds.length > 0
|
||||
@@ -647,6 +739,8 @@ export default function JobDetailDialog({
|
||||
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
|
||||
const canDownloadRaw = Boolean(job?.raw_path && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
|
||||
const hasAnyOutputFolder = outputFolders.length > 0 || Boolean(job?.outputStatus?.exists);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -867,6 +961,26 @@ export default function JobDetailDialog({
|
||||
downloadDisabled={!canDownloadRaw}
|
||||
downloadLoading={downloadBusyTarget === 'raw'}
|
||||
/>
|
||||
{outputFolders.length > 0 ? (
|
||||
outputFolders.map((folder, idx) => {
|
||||
const folderPath = String(folder?.output_path || '').trim();
|
||||
if (!folderPath) {
|
||||
return null;
|
||||
}
|
||||
const label = outputFolders.length > 1 ? `Output ${idx + 1}:` : 'Output:';
|
||||
const canDownloadFolder = Boolean(typeof onDownloadOutputFolder === 'function');
|
||||
return (
|
||||
<PathField
|
||||
key={folder.id || folderPath}
|
||||
label={label}
|
||||
value={folderPath}
|
||||
onDownload={canDownloadFolder ? () => onDownloadOutputFolder?.(job, folderPath) : null}
|
||||
downloadDisabled={!canDownloadFolder}
|
||||
downloadLoading={downloadFolderBusyPath === folderPath}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<PathField
|
||||
label="Output:"
|
||||
value={job.output_path}
|
||||
@@ -874,6 +988,7 @@ export default function JobDetailDialog({
|
||||
downloadDisabled={!canDownloadOutput}
|
||||
downloadLoading={downloadBusyTarget === 'output'}
|
||||
/>
|
||||
)}
|
||||
{job.error_message ? (
|
||||
<div><strong>Fehler:</strong> {job.error_message}</div>
|
||||
) : null}
|
||||
@@ -1003,13 +1118,7 @@ export default function JobDetailDialog({
|
||||
{tracksRaw.map((track, i) => {
|
||||
const pos = Number(track.position ?? (i + 1));
|
||||
const label = String(track.title || track.name || `Track ${pos}`).trim();
|
||||
const artist = String(track.artist || '').trim();
|
||||
const durationSec = Number(track.durationMs || 0) > 0
|
||||
? Number(track.durationMs) / 1000
|
||||
: Number(track.durationSec || 0);
|
||||
const durLabel = durationSec > 0
|
||||
? `${Math.floor(durationSec / 60)}:${String(Math.floor(durationSec % 60)).padStart(2, '0')} min`
|
||||
: '-';
|
||||
const artist = String(track.artist || '').trim() || String(job?.makemkvInfo?.selectedMetadata?.artist || '').trim();
|
||||
const selected = selectedSet ? selectedSet.has(pos) : track.selected !== false;
|
||||
const encodeResult = encodeResultMap.get(pos);
|
||||
const outcome = !selected
|
||||
@@ -1018,8 +1127,8 @@ export default function JobDetailDialog({
|
||||
? (encodeResult.success ? 'success' : 'error')
|
||||
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'success' : 'error') : null);
|
||||
return (
|
||||
<div key={pos} className="track-item">
|
||||
<span>#{pos} | {label}{artist ? ` | ${artist}` : ''} | {durLabel}</span>
|
||||
<div key={pos} className="track-item track-item-inline-status">
|
||||
<span className="track-item-main">#{pos} {artist ? `${artist} - ` : ''}{label}</span>
|
||||
<SelectionStateNote selected={selected} outcome={outcome} />
|
||||
</div>
|
||||
);
|
||||
@@ -1070,8 +1179,8 @@ export default function JobDetailDialog({
|
||||
: stepStatus === 'ERROR' ? 'tone-no'
|
||||
: '';
|
||||
return (
|
||||
<div key={id} className="track-item">
|
||||
<span>#{id} | {label} | {durLabel}</span>
|
||||
<div key={id} className={`track-item${isSplitAudiobook ? ' track-item-inline-status' : ''}`}>
|
||||
<span className={isSplitAudiobook ? 'track-item-main' : undefined}>#{id} | {label} | {durLabel}</span>
|
||||
{isSplitAudiobook ? (
|
||||
<SelectionStateNote selected outcome={outcome} />
|
||||
) : (
|
||||
@@ -1164,25 +1273,40 @@ export default function JobDetailDialog({
|
||||
size="small"
|
||||
onClick={() => onReencode?.(job)}
|
||||
loading={reencodeBusy}
|
||||
disabled={!canReencode || typeof onReencode !== 'function'}
|
||||
disabled={!canCdDirectEncode || typeof onReencode !== 'function'}
|
||||
/>
|
||||
<span className="action-desc">Encodiert die vorhandenen WAV-Rohdaten erneut — ohne die CD neu zu lesen. Nützlich wenn sich Format oder Metadaten geändert haben.</span>
|
||||
<span className="action-desc">
|
||||
{canCdDirectEncode
|
||||
? 'Encodiert die vorhandenen WAV-Rohdaten mit den zuletzt bestätigten Einstellungen erneut — ohne die CD neu zu lesen.'
|
||||
: 'Direktes Encoding ist gesperrt, solange kein bestätigter Vorlauf mit vollständigen Metadaten vorhanden ist. Bitte zuerst "Vorprüfung starten".'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="action-item">
|
||||
<Button
|
||||
label="Vorprüfung starten"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onRestartCdReview?.(job)}
|
||||
loading={actionBusy}
|
||||
disabled={!canCdStartReview}
|
||||
/>
|
||||
<span className="action-desc">Öffnet den Vorprüfungs-Workflow: MusicBrainz-Suche, Trackauswahl, Ausgabeeinstellungen — dann Encode aus vorhandenen WAV-Daten starten.</span>
|
||||
</div>
|
||||
{typeof onAssignCdMetadata === 'function' ? (
|
||||
<div className="action-item">
|
||||
<Button
|
||||
label="MusicBrainz neu zuweisen"
|
||||
icon="pi pi-search"
|
||||
icon="pi pi-tag"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onAssignCdMetadata?.(job)}
|
||||
loading={cdMetadataAssignBusy}
|
||||
disabled={running}
|
||||
disabled={running || typeof onAssignCdMetadata !== 'function'}
|
||||
/>
|
||||
<span className="action-desc">Öffnet die MusicBrainz-Suche, um Album-Metadaten (Titel, Interpret, Tracks) neu zuzuweisen.</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="actions-group">
|
||||
@@ -1294,7 +1418,7 @@ export default function JobDetailDialog({
|
||||
size="small"
|
||||
onClick={() => onDeleteFiles?.(job, 'movie')}
|
||||
loading={actionBusy}
|
||||
disabled={!job.outputStatus?.exists || typeof onDeleteFiles !== 'function'}
|
||||
disabled={!hasAnyOutputFolder || typeof onDeleteFiles !== 'function'}
|
||||
/>
|
||||
<span className="action-desc">Löscht {isCd ? 'die fertig gerippten Audiodateien' : isAudiobook ? 'die fertig konvertierte Audiobook-Ausgabe' : 'die fertig encodierte Filmdatei'}.</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
|
||||
/**
|
||||
* Modal zur Konfliktlösung wenn bereits encodierte Ausgabe-Ordner existieren.
|
||||
*
|
||||
* Props:
|
||||
* - visible: boolean
|
||||
* - onHide: () => void
|
||||
* - job: object (das Job-Objekt)
|
||||
* - existingFolders: Array<{ id, output_path, label, created_at }>
|
||||
* - onKeepBoth: () => void – "Beide behalten" geklickt
|
||||
* - onDeleteSelected: (selectedPaths: string[]) => void – "Auswahl löschen & neu encodieren" geklickt
|
||||
* - busy: boolean
|
||||
*/
|
||||
export default function ReencodeConflictModal({
|
||||
visible = false,
|
||||
onHide,
|
||||
job,
|
||||
existingFolders = [],
|
||||
mode = 'reencode',
|
||||
onKeepBoth,
|
||||
onDeleteSelected,
|
||||
busy = false
|
||||
}) {
|
||||
const [checkedPaths, setCheckedPaths] = useState(new Set());
|
||||
const isDeleteMode = String(mode || '').trim().toLowerCase() === 'delete';
|
||||
|
||||
// Reset on open
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCheckedPaths(new Set());
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const togglePath = (p) => {
|
||||
setCheckedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(p)) {
|
||||
next.delete(p);
|
||||
} else {
|
||||
next.add(p);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (checkedPaths.size === existingFolders.length) {
|
||||
setCheckedPaths(new Set());
|
||||
} else {
|
||||
setCheckedPaths(new Set(existingFolders.map((f) => f.output_path)));
|
||||
}
|
||||
};
|
||||
|
||||
const allChecked = existingFolders.length > 0 && checkedPaths.size === existingFolders.length;
|
||||
const someChecked = checkedPaths.size > 0 && !allChecked;
|
||||
const noneChecked = checkedPaths.size === 0;
|
||||
|
||||
const title = job?.title || job?.detected_title || `Job #${job?.id || ''}`;
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
const selected = [...checkedPaths];
|
||||
onDeleteSelected?.(selected);
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<div className="reencode-conflict-footer">
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={onHide}
|
||||
disabled={busy}
|
||||
/>
|
||||
{!isDeleteMode ? (
|
||||
<Button
|
||||
label="Beide behalten"
|
||||
icon="pi pi-copy"
|
||||
severity="info"
|
||||
size="small"
|
||||
onClick={onKeepBoth}
|
||||
loading={busy}
|
||||
title="Bestehende Ausgabe belassen – neuer Encode erhält eine Nummerierung (_2, _3 …)"
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
label={isDeleteMode
|
||||
? (noneChecked ? 'Alle Ordner löschen' : `${checkedPaths.size} Ordner löschen`)
|
||||
: (noneChecked
|
||||
? 'Alle löschen & neu encodieren'
|
||||
: `${checkedPaths.size} Ordner löschen & neu encodieren`)}
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
size="small"
|
||||
onClick={handleDeleteSelected}
|
||||
loading={busy}
|
||||
disabled={false}
|
||||
title={isDeleteMode
|
||||
? (noneChecked
|
||||
? 'Alle bestehenden Ausgabe-Ordner löschen'
|
||||
: 'Ausgewählte Ausgabe-Ordner löschen')
|
||||
: (noneChecked
|
||||
? 'Alle bestehenden Ausgabe-Ordner löschen, dann neu encodieren'
|
||||
: 'Ausgewählte Ausgabe-Ordner löschen, dann neu encodieren')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header={isDeleteMode ? 'Ausgabe-Ordner löschen' : 'Ausgabe bereits vorhanden'}
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
style={{ width: '44rem', maxWidth: '96vw' }}
|
||||
modal
|
||||
footer={footer}
|
||||
className="reencode-conflict-modal"
|
||||
>
|
||||
<p className="reencode-conflict-intro">
|
||||
{isDeleteMode ? (
|
||||
<>
|
||||
Für <strong>{title}</strong> wurden mehrere Ausgabe-Ordner erkannt.
|
||||
Welche Ordner sollen gelöscht werden?
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Für <strong>{title}</strong> existieren bereits encodierte Dateien.
|
||||
Wie soll mit den vorhandenen Ausgabe-Ordnern umgegangen werden?
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{existingFolders.length > 0 ? (
|
||||
<div className="reencode-conflict-folders">
|
||||
<div className="reencode-conflict-folders-header">
|
||||
<label className="reencode-conflict-check-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(el) => { if (el) el.indeterminate = someChecked; }}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
<span>Alle auswählen (zum Löschen)</span>
|
||||
</label>
|
||||
</div>
|
||||
{existingFolders.map((folder) => {
|
||||
const p = folder.output_path;
|
||||
const checked = checkedPaths.has(p);
|
||||
const folderName = p.split(/[/\\]/).filter(Boolean).pop() || p;
|
||||
return (
|
||||
<label key={p} className={`reencode-conflict-folder-row${checked ? ' is-checked' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => togglePath(p)}
|
||||
/>
|
||||
<span className="reencode-conflict-folder-name" title={p}>{folderName}</span>
|
||||
{folder.created_at ? (
|
||||
<small className="reencode-conflict-folder-date">
|
||||
{new Date(folder.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
})}
|
||||
</small>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="reencode-conflict-no-folders">
|
||||
{isDeleteMode
|
||||
? 'Es wurden keine gespeicherten Ausgabe-Ordner gefunden.'
|
||||
: 'Es wurden keine gespeicherten Ausgabe-Ordner gefunden. Der neue Encode wird ggf. automatisch nummeriert.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isDeleteMode ? (
|
||||
<div className="reencode-conflict-hint">
|
||||
<strong>Beide behalten:</strong> Bestehende Ordner bleiben. Neuer Encode erhält eine Nummerierung (_2, _3 …).<br />
|
||||
<strong>Löschen & neu encodieren:</strong> Ausgewählte (oder alle) Ordner werden gelöscht.
|
||||
Wenn alle gelöscht wurden, erhält der neue Encode keine Nummerierung.
|
||||
Wenn nur eine Auswahl gelöscht wurde, setzt die Nummerierung fort.
|
||||
</div>
|
||||
) : null}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,14 @@ const dashboardStatuses = new Set([
|
||||
'CD_RIPPING',
|
||||
'CD_ENCODING'
|
||||
]);
|
||||
const hiddenCancelledReviewOrigins = new Set([
|
||||
'READY_TO_START',
|
||||
'READY_TO_ENCODE',
|
||||
'METADATA_SELECTION',
|
||||
'WAITING_FOR_USER_DECISION',
|
||||
'CD_METADATA_SELECTION',
|
||||
'CD_READY_TO_RIP'
|
||||
]);
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
@@ -792,6 +800,9 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
const liveContext = liveJobProgress?.context && typeof liveJobProgress.context === 'object'
|
||||
? liveJobProgress.context
|
||||
: null;
|
||||
const liveState = String(liveJobProgress?.state || '').trim().toUpperCase();
|
||||
const isTerminalJobStatus = jobStatus === 'FINISHED' || jobStatus === 'ERROR' || jobStatus === 'CANCELLED';
|
||||
const ignoreStaleLiveProgress = isTerminalJobStatus && processingStates.includes(liveState);
|
||||
const mergedContext = liveContext
|
||||
? {
|
||||
...computedContext,
|
||||
@@ -805,11 +816,15 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
: computedContext;
|
||||
|
||||
return {
|
||||
state: liveJobProgress?.state || jobStatus,
|
||||
state: ignoreStaleLiveProgress ? jobStatus : (liveJobProgress?.state || jobStatus),
|
||||
activeJobId: jobId,
|
||||
progress: liveJobProgress != null ? Number(liveJobProgress.progress ?? 0) : 0,
|
||||
eta: liveJobProgress?.eta || null,
|
||||
statusText: liveJobProgress?.statusText || job?.error_message || null,
|
||||
progress: ignoreStaleLiveProgress
|
||||
? 0
|
||||
: (liveJobProgress != null ? Number(liveJobProgress.progress ?? 0) : 0),
|
||||
eta: ignoreStaleLiveProgress ? null : (liveJobProgress?.eta || null),
|
||||
statusText: ignoreStaleLiveProgress
|
||||
? (job?.error_message || null)
|
||||
: (liveJobProgress?.statusText || job?.error_message || null),
|
||||
context: mergedContext
|
||||
};
|
||||
}
|
||||
@@ -876,6 +891,7 @@ export default function DashboardPage({
|
||||
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
|
||||
const [knownDrives, setKnownDrives] = useState([]);
|
||||
const [driveRescanBusy, setDriveRescanBusy] = useState(() => new Set());
|
||||
const [driveAnalyzeBusy, setDriveAnalyzeBusy] = useState(() => new Set());
|
||||
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
|
||||
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
|
||||
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
|
||||
@@ -978,18 +994,45 @@ export default function DashboardPage({
|
||||
if (queueResponse.status === 'fulfilled') {
|
||||
setQueueState(normalizeQueue(queueResponse.value?.queue));
|
||||
}
|
||||
const shouldDisplayOnDashboard = (job) => {
|
||||
const normalizedStatus = String(job?.status || '').trim().toUpperCase();
|
||||
if (!dashboardStatuses.has(normalizedStatus)) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedStatus !== 'CANCELLED') {
|
||||
return true;
|
||||
}
|
||||
const cancelledOrigin = String(job?.last_state || '').trim().toUpperCase();
|
||||
return !hiddenCancelledReviewOrigins.has(cancelledOrigin);
|
||||
};
|
||||
const next = allJobs
|
||||
.filter((job) => dashboardStatuses.has(String(job?.status || '').trim().toUpperCase()))
|
||||
.filter((job) => shouldDisplayOnDashboard(job))
|
||||
.sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0));
|
||||
|
||||
if (currentPipelineJobId && !next.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
|
||||
try {
|
||||
const active = await api.getJob(currentPipelineJobId, { lite: true });
|
||||
if (active?.job) {
|
||||
next.unshift(active.job);
|
||||
const pinnedJobIds = new Set();
|
||||
if (currentPipelineJobId) {
|
||||
pinnedJobIds.add(currentPipelineJobId);
|
||||
}
|
||||
for (const driveState of Object.values(pipeline?.cdDrives || {})) {
|
||||
const driveJobId = normalizeJobId(driveState?.jobId);
|
||||
if (driveJobId) {
|
||||
pinnedJobIds.add(driveJobId);
|
||||
}
|
||||
}
|
||||
const missingPinnedJobIds = Array.from(pinnedJobIds)
|
||||
.filter((jobId) => !next.some((job) => normalizeJobId(job?.id) === jobId));
|
||||
if (missingPinnedJobIds.length > 0) {
|
||||
const pinnedResults = await Promise.allSettled(
|
||||
missingPinnedJobIds.map((jobId) => api.getJob(jobId, { lite: true, forceRefresh: true }))
|
||||
);
|
||||
for (const result of pinnedResults) {
|
||||
if (result.status !== 'fulfilled') {
|
||||
continue;
|
||||
}
|
||||
const job = result.value?.job;
|
||||
if (job && shouldDisplayOnDashboard(job)) {
|
||||
next.unshift(job);
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore; dashboard still shows available rows
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,7 +1110,7 @@ export default function DashboardPage({
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboardJobs();
|
||||
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, jobsRefreshToken]);
|
||||
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, pipeline?.cdDrives, jobsRefreshToken]);
|
||||
|
||||
useEffect(() => {
|
||||
api.getDetectedDrives()
|
||||
@@ -1328,7 +1371,7 @@ export default function DashboardPage({
|
||||
};
|
||||
|
||||
const handleAnalyzeForDrive = async (devicePath) => {
|
||||
setBusy(true);
|
||||
setDriveAnalyzeBusy((prev) => new Set([...prev, devicePath]));
|
||||
try {
|
||||
await api.analyzeDisc(devicePath);
|
||||
await refreshPipeline();
|
||||
@@ -1336,10 +1379,27 @@ export default function DashboardPage({
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setDriveAnalyzeBusy((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(devicePath);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnalyzeAll = async () => {
|
||||
const drivesToAnalyze = allDrives
|
||||
.filter((drv) => {
|
||||
const cdState = drv.cdDrive ? String(drv.cdDrive.state || '').toUpperCase() : null;
|
||||
if (cdState) return cdState === 'DISC_DETECTED';
|
||||
if (Boolean(drv.pipelineDevice) && String(drv.pipelineState || '').toUpperCase() === 'DISC_DETECTED') return true;
|
||||
return Boolean(drv.detectedDisc);
|
||||
})
|
||||
.map((drv) => drv.path);
|
||||
if (drivesToAnalyze.length === 0) return;
|
||||
await Promise.all(drivesToAnalyze.map((path) => handleAnalyzeForDrive(path)));
|
||||
};
|
||||
|
||||
const handleRescan = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -1351,7 +1411,7 @@ export default function DashboardPage({
|
||||
severity: count > 0 ? 'success' : 'info',
|
||||
summary: 'Laufwerke neu gelesen',
|
||||
detail: count > 0
|
||||
? `${count} Medium${count > 1 ? 'en' : ''} erkannt.`
|
||||
? `${count > 1 ? `${count} Medien` : '1 Medium'} erkannt.`
|
||||
: 'Kein Medium erkannt.',
|
||||
life: 2800
|
||||
});
|
||||
@@ -1771,6 +1831,31 @@ export default function DashboardPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestartCdReviewFromRaw = async (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setJobBusy(normalizedJobId, true);
|
||||
try {
|
||||
const response = await api.restartCdReviewFromRaw(normalizedJobId);
|
||||
const result = getQueueActionResult(response);
|
||||
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
if (result.queued) {
|
||||
showQueuedToast(toastRef, 'CD-Vorprüfung', result);
|
||||
} else {
|
||||
setExpandedJobId(replacementJobId);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setJobBusy(normalizedJobId, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQueueDragEnter = (targetEntryId) => {
|
||||
const targetId = Number(targetEntryId);
|
||||
const draggedId = Number(draggingQueueEntryId);
|
||||
@@ -2047,11 +2132,20 @@ export default function DashboardPage({
|
||||
const base = driveMap.get(device.path) || { path: device.path, model: device.model };
|
||||
driveMap.set(device.path, { ...base, pipelineDevice: device, pipelineState: state });
|
||||
}
|
||||
return Array.from(driveMap.values()).sort((a, b) => (a.path || '').localeCompare(b.path || ''));
|
||||
}, [knownDrives, pipeline?.cdDrives, device, state]);
|
||||
// For drives that the detection service found but aren't tracked by cdDrives or the global
|
||||
// state machine (e.g. a second non-CD disc), show DISC_DETECTED using detectedDiscs.
|
||||
for (const [drivePath, discDevice] of Object.entries(pipeline?.detectedDiscs || {})) {
|
||||
const existing = driveMap.get(drivePath);
|
||||
if (existing && !existing.cdDrive && !existing.pipelineDevice) {
|
||||
driveMap.set(drivePath, { ...existing, detectedDisc: discDevice });
|
||||
}
|
||||
}
|
||||
return Array.from(driveMap.values())
|
||||
.filter((drv) => !String(drv.path || '').startsWith('__virtual__'))
|
||||
.sort((a, b) => (a.path || '').localeCompare(b.path || ''));
|
||||
}, [knownDrives, pipeline?.cdDrives, pipeline?.detectedDiscs, device, state]);
|
||||
const isDriveActive = driveActiveStates.includes(state);
|
||||
const canRescan = !isDriveActive;
|
||||
const canReanalyze = !isDriveActive && (state === 'ENCODING' ? Boolean(device) : !processingStates.includes(state));
|
||||
const canOpenMetadataModal = Boolean(defaultMetadataDialogContext?.jobId);
|
||||
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
|
||||
const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
|
||||
@@ -2291,33 +2385,7 @@ export default function DashboardPage({
|
||||
</Card>
|
||||
|
||||
<Card title="Disk-Information">
|
||||
{/* Global action buttons (non-CD / DVD / Bluray) */}
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
label="Alle neu lesen"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
onClick={handleRescan}
|
||||
loading={busy}
|
||||
disabled={!canRescan}
|
||||
/>
|
||||
<Button
|
||||
label="Disk neu analysieren"
|
||||
icon="pi pi-search"
|
||||
severity="warning"
|
||||
onClick={handleReanalyze}
|
||||
loading={busy}
|
||||
disabled={!canReanalyze}
|
||||
/>
|
||||
<Button
|
||||
label="Metadaten-Modal öffnen"
|
||||
icon="pi pi-list"
|
||||
onClick={() => handleOpenMetadataDialog()}
|
||||
disabled={!canOpenMetadataModal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Compact per-drive list */}
|
||||
{/* Per-drive list */}
|
||||
{allDrives.length > 0 ? (
|
||||
<div className="drive-list">
|
||||
{allDrives.map((drv) => {
|
||||
@@ -2325,6 +2393,7 @@ export default function DashboardPage({
|
||||
const cdDrive = drv.cdDrive;
|
||||
const pipelineDevice = drv.pipelineDevice;
|
||||
const isRescanBusy = driveRescanBusy.has(drivePath);
|
||||
const isAnalyzeBusy = driveAnalyzeBusy.has(drivePath);
|
||||
|
||||
// Determine display state
|
||||
let stateLabel = null;
|
||||
@@ -2354,6 +2423,10 @@ export default function DashboardPage({
|
||||
: processingStates.includes(s) ? 'warning'
|
||||
: 'info';
|
||||
discInfo = pipelineDevice.discLabel || pipelineDevice.label || null;
|
||||
} else if (drv.detectedDisc) {
|
||||
stateLabel = 'DISC_DETECTED';
|
||||
stateSeverity = 'info';
|
||||
discInfo = drv.detectedDisc.discLabel || drv.detectedDisc.label || null;
|
||||
} else {
|
||||
stateLabel = 'LEER';
|
||||
stateSeverity = 'secondary';
|
||||
@@ -2362,7 +2435,11 @@ export default function DashboardPage({
|
||||
const driveJobId = normalizeJobId(cdDrive?.jobId);
|
||||
const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {};
|
||||
const cdState = cdDrive ? String(cdDrive.state || '').toUpperCase() : null;
|
||||
const pipelineState = String(drv.pipelineState || '').toUpperCase();
|
||||
const isCdDriveLocked = cdState ? activeCdDriveStates.includes(cdState) : false;
|
||||
const canAnalyzeDrive = cdState
|
||||
? cdState === 'DISC_DETECTED'
|
||||
: (Boolean(pipelineDevice) && pipelineState === 'DISC_DETECTED') || Boolean(drv.detectedDisc);
|
||||
|
||||
return (
|
||||
<div key={drivePath} className="drive-list-item">
|
||||
@@ -2373,7 +2450,7 @@ export default function DashboardPage({
|
||||
{drv.discArg && <span className="drive-list-disc-arg">{drv.discArg}</span>}
|
||||
</div>
|
||||
<div className="drive-list-state">
|
||||
{stateLabel && <Tag value={stateLabel} severity={stateSeverity} />}
|
||||
{stateLabel && <Tag value={stateLabel} severity={stateSeverity} className="drive-list-status-tag" />}
|
||||
</div>
|
||||
<div className="drive-list-actions">
|
||||
<Button
|
||||
@@ -2388,14 +2465,18 @@ export default function DashboardPage({
|
||||
title="Laufwerk neu lesen"
|
||||
aria-label="Laufwerk neu lesen"
|
||||
/>
|
||||
{cdState === 'DISC_DETECTED' && (
|
||||
{canAnalyzeDrive && (
|
||||
<Button
|
||||
label="Analysieren"
|
||||
icon="pi pi-search"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="secondary"
|
||||
loading={isAnalyzeBusy}
|
||||
disabled={isAnalyzeBusy}
|
||||
onClick={() => handleAnalyzeForDrive(drivePath)}
|
||||
loading={busy}
|
||||
disabled={busy}
|
||||
title="Disk analysieren"
|
||||
aria-label="Disk analysieren"
|
||||
/>
|
||||
)}
|
||||
{cdState === 'CD_METADATA_SELECTION' && (
|
||||
@@ -2413,13 +2494,16 @@ export default function DashboardPage({
|
||||
)}
|
||||
{(cdState === 'ERROR' || cdState === 'CANCELLED') && driveJobId && (
|
||||
<Button
|
||||
label="Retry"
|
||||
icon="pi pi-replay"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="warning"
|
||||
onClick={() => handleAnalyzeForDrive(drivePath)}
|
||||
loading={busy}
|
||||
disabled={busy}
|
||||
loading={isAnalyzeBusy}
|
||||
disabled={isAnalyzeBusy}
|
||||
title="Erneut analysieren"
|
||||
aria-label="Erneut analysieren"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -2438,6 +2522,32 @@ export default function DashboardPage({
|
||||
) : (
|
||||
<p className="drive-list-empty">Keine Laufwerke erkannt.</p>
|
||||
)}
|
||||
|
||||
{/* Global action buttons — below drive list, side by side */}
|
||||
<div className="drive-list-global-actions">
|
||||
<Button
|
||||
label="Alle neu lesen"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
onClick={handleRescan}
|
||||
loading={busy}
|
||||
disabled={!canRescan}
|
||||
/>
|
||||
<Button
|
||||
label="Alle analysieren"
|
||||
icon="pi pi-search"
|
||||
severity="warning"
|
||||
size="small"
|
||||
onClick={handleAnalyzeAll}
|
||||
disabled={allDrives.every((drv) => {
|
||||
const cs = drv.cdDrive ? String(drv.cdDrive.state || '').toUpperCase() : null;
|
||||
if (cs) return cs !== 'DISC_DETECTED';
|
||||
if (Boolean(drv.pipelineDevice) && String(drv.pipelineState || '').toUpperCase() === 'DISC_DETECTED') return false;
|
||||
return !drv.detectedDisc;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Freier Speicher">
|
||||
@@ -2538,7 +2648,9 @@ export default function DashboardPage({
|
||||
? pipelineForJob.context.selectedMetadata
|
||||
: {};
|
||||
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
|
||||
const pluginExecution = resolveJobPluginExecution(job, pipelineForJob?.context);
|
||||
const pluginExecution = resolveJobPluginExecution(job, pipelineForJob?.context, {
|
||||
currentStatus: jobState
|
||||
});
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
@@ -2589,6 +2701,7 @@ export default function DashboardPage({
|
||||
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
|
||||
onCancel={() => handleCancel(jobId, jobState)}
|
||||
onRetry={() => handleRetry(jobId)}
|
||||
onRestartReview={() => handleRestartCdReviewFromRaw(jobId)}
|
||||
onOpenMetadata={() => {
|
||||
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
||||
? pipelineForJob.context
|
||||
|
||||
+406
-100
@@ -12,6 +12,7 @@ import { api } from '../api/client';
|
||||
import JobDetailDialog from '../components/JobDetailDialog';
|
||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||
import CdMetadataDialog from '../components/CdMetadataDialog';
|
||||
import ReencodeConflictModal from '../components/ReencodeConflictModal';
|
||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||
@@ -32,10 +33,10 @@ const MEDIA_FILTER_OPTIONS = [
|
||||
];
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ label: 'Startzeit: Neu -> Alt', value: '!start_time' },
|
||||
{ label: 'Startzeit: Alt -> Neu', value: 'start_time' },
|
||||
{ label: 'Endzeit: Neu -> Alt', value: '!end_time' },
|
||||
{ label: 'Endzeit: Alt -> Neu', value: 'end_time' },
|
||||
{ label: 'Startzeit: Neu -> Alt', value: '!sortStartTime' },
|
||||
{ label: 'Startzeit: Alt -> Neu', value: 'sortStartTime' },
|
||||
{ label: 'Endzeit: Neu -> Alt', value: '!sortEndTime' },
|
||||
{ label: 'Endzeit: Alt -> Neu', value: 'sortEndTime' },
|
||||
{ label: 'Titel: A -> Z', value: 'sortTitle' },
|
||||
{ label: 'Titel: Z -> A', value: '!sortTitle' },
|
||||
{ label: 'Medium: A -> Z', value: 'sortMediaType' },
|
||||
@@ -222,7 +223,11 @@ function resolveCdDetails(row) {
|
||||
selectedMetadata?.mbId
|
||||
|| selectedMetadata?.musicBrainzId
|
||||
|| selectedMetadata?.musicbrainzId
|
||||
|| selectedMetadata?.musicbrainz_id
|
||||
|| selectedMetadata?.music_brainz_id
|
||||
|| selectedMetadata?.musicbrainz
|
||||
|| selectedMetadata?.mbid
|
||||
|| row?.imdb_id
|
||||
|| ''
|
||||
).trim() || null;
|
||||
|
||||
@@ -349,6 +354,24 @@ function formatDateTime(value) {
|
||||
});
|
||||
}
|
||||
|
||||
function parseSortableTimestamp(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const ts = Date.parse(value);
|
||||
return Number.isFinite(ts) ? ts : null;
|
||||
}
|
||||
|
||||
function resolveSortTimestamp(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
const ts = parseSortableTimestamp(candidate);
|
||||
if (ts != null) {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -357,8 +380,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const [status, setStatus] = useState('');
|
||||
const [mediumFilter, setMediumFilter] = useState('');
|
||||
const [layout, setLayout] = useState('grid');
|
||||
const [sortKey, setSortKey] = useState('!start_time');
|
||||
const [sortField, setSortField] = useState('start_time');
|
||||
const [sortKey, setSortKey] = useState('!sortStartTime');
|
||||
const [sortField, setSortField] = useState('sortStartTime');
|
||||
const [sortOrder, setSortOrder] = useState(-1);
|
||||
const [selectedJob, setSelectedJob] = useState(null);
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
@@ -372,7 +395,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const [deleteEntryPreview, setDeleteEntryPreview] = useState(null);
|
||||
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
|
||||
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
|
||||
const [deleteEntrySelectedMoviePaths, setDeleteEntrySelectedMoviePaths] = useState([]);
|
||||
const [downloadBusyTarget, setDownloadBusyTarget] = useState(null);
|
||||
const [downloadFolderBusyPath, setDownloadFolderBusyPath] = useState(null);
|
||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
|
||||
@@ -381,6 +406,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||
const [conflictModalVisible, setConflictModalVisible] = useState(false);
|
||||
const [conflictModalJob, setConflictModalJob] = useState(null);
|
||||
const [conflictModalFolders, setConflictModalFolders] = useState([]);
|
||||
const [conflictModalAction, setConflictModalAction] = useState(null); // 'reencode' | 'restart_encode' | 'restart_review' | 'restart_cd_review' | 'delete_output'
|
||||
const [conflictModalMode, setConflictModalMode] = useState('reencode'); // 'reencode' | 'delete'
|
||||
const [conflictModalBusy, setConflictModalBusy] = useState(false);
|
||||
const toastRef = useRef(null);
|
||||
|
||||
const queuedJobIdSet = useMemo(() => {
|
||||
@@ -398,7 +429,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
() => jobs.map((job) => ({
|
||||
...job,
|
||||
sortTitle: normalizeSortText(job?.title || job?.detected_title || ''),
|
||||
sortMediaType: resolveMediaType(job)
|
||||
sortMediaType: resolveMediaType(job),
|
||||
sortStartTime: resolveSortTimestamp(job?.start_time, job?.updated_at, job?.created_at, job?.end_time),
|
||||
sortEndTime: resolveSortTimestamp(job?.end_time, job?.updated_at, job?.created_at, job?.start_time)
|
||||
})),
|
||||
[jobs]
|
||||
);
|
||||
@@ -470,8 +503,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const onSortChange = (event) => {
|
||||
const value = String(event.value || '').trim();
|
||||
if (!value) {
|
||||
setSortKey('!start_time');
|
||||
setSortField('start_time');
|
||||
setSortKey('!sortStartTime');
|
||||
setSortField('sortStartTime');
|
||||
setSortOrder(-1);
|
||||
return;
|
||||
}
|
||||
@@ -507,7 +540,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
setDetailLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.getJob(jobId, { includeLogs: false });
|
||||
const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true });
|
||||
setSelectedJob(response.job);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||
@@ -541,11 +574,242 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (!detailVisible || Number(selectedJob?.id || 0) !== Number(jobId || 0)) {
|
||||
return;
|
||||
}
|
||||
const response = await api.getJob(jobId, { includeLogs: false });
|
||||
const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true });
|
||||
setSelectedJob(response.job);
|
||||
};
|
||||
|
||||
// ── Conflict Modal Helpers ─────────────────────────────────────────────────
|
||||
|
||||
const mergeOutputFoldersForJob = (job, folders = []) => {
|
||||
const normalizedJobId = Number(job?.id || 0);
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
const addFolder = (folder) => {
|
||||
const outputPath = String(folder?.output_path || '').trim();
|
||||
if (!outputPath || seen.has(outputPath)) {
|
||||
return;
|
||||
}
|
||||
seen.add(outputPath);
|
||||
merged.push({
|
||||
id: folder?.id ?? 0,
|
||||
job_id: folder?.job_id ?? (normalizedJobId || null),
|
||||
output_path: outputPath,
|
||||
label: folder?.label || null,
|
||||
created_at: folder?.created_at || null
|
||||
});
|
||||
};
|
||||
|
||||
for (const folder of (Array.isArray(folders) ? folders : [])) {
|
||||
addFolder(folder);
|
||||
}
|
||||
const currentPath = String(job?.output_path || '').trim();
|
||||
if (currentPath && !seen.has(currentPath) && Boolean(job?.outputStatus?.exists)) {
|
||||
addFolder({
|
||||
id: 0,
|
||||
job_id: normalizedJobId,
|
||||
output_path: currentPath,
|
||||
label: 'Aktuelle Ausgabe',
|
||||
created_at: null
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
const loadOutputFoldersForJob = async (job) => {
|
||||
if (!job?.id) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const response = await api.getOutputFolders(job.id);
|
||||
return mergeOutputFoldersForJob(job, response?.folders);
|
||||
} catch (_error) {
|
||||
return mergeOutputFoldersForJob(job, []);
|
||||
}
|
||||
};
|
||||
|
||||
const openConflictModal = async (job, action, mode = 'reencode', preloadedFolders = null) => {
|
||||
const folders = Array.isArray(preloadedFolders)
|
||||
? mergeOutputFoldersForJob(job, preloadedFolders)
|
||||
: await loadOutputFoldersForJob(job);
|
||||
if (folders.length === 0) {
|
||||
return false;
|
||||
}
|
||||
setConflictModalJob(job);
|
||||
setConflictModalAction(action);
|
||||
setConflictModalMode(mode);
|
||||
setConflictModalFolders(folders);
|
||||
setConflictModalVisible(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
const closeConflictModal = () => {
|
||||
if (conflictModalBusy) return;
|
||||
setConflictModalVisible(false);
|
||||
setConflictModalJob(null);
|
||||
setConflictModalAction(null);
|
||||
setConflictModalMode('reencode');
|
||||
setConflictModalFolders([]);
|
||||
};
|
||||
|
||||
const executeHistoryAction = async (job, action, options = {}, executionOptions = {}) => {
|
||||
const skipConfirm = Boolean(executionOptions?.skipConfirm);
|
||||
if (action === 'reencode') {
|
||||
setReencodeBusyJobId(job.id);
|
||||
try {
|
||||
await api.reencodeJob(job.id, options);
|
||||
toastRef.current?.show({ severity: 'success', summary: 'Re-Encode gestartet', detail: 'Job wurde in die Mediainfo-Prüfung gesetzt.', life: 3500 });
|
||||
await load();
|
||||
await refreshDetailIfOpen(job.id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Re-Encode fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setReencodeBusyJobId(null);
|
||||
}
|
||||
} else if (action === 'restart_encode') {
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const response = await api.restartEncodeWithLastSettings(job.id, options);
|
||||
const result = getQueueActionResult(response);
|
||||
if (result.queued) {
|
||||
toastRef.current?.show({ severity: 'info', summary: 'Encode-Neustart in Queue', detail: result.queuePosition > 0 ? `Position ${result.queuePosition}` : 'In der Warteschlange eingeplant.', life: 3500 });
|
||||
} else {
|
||||
toastRef.current?.show({ severity: 'success', summary: 'Encode-Neustart gestartet', detail: 'Letzte bestätigte Einstellungen werden verwendet.', life: 3500 });
|
||||
}
|
||||
await load();
|
||||
await refreshDetailIfOpen(job.id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Encode-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
} else if (action === 'restart_review') {
|
||||
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
|
||||
if (!skipConfirm) {
|
||||
const confirmed = window.confirm(`Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Dashboard neu getroffen werden.`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setActionBusy(true);
|
||||
try {
|
||||
await api.restartReviewFromRaw(job.id, options);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Review-Neustart',
|
||||
detail: 'Analyse gestartet. Job ist jetzt im Dashboard verfügbar.',
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
await refreshDetailIfOpen(job.id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
} else if (action === 'restart_cd_review') {
|
||||
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
|
||||
if (!skipConfirm) {
|
||||
const confirmed = window.confirm(`CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Dashboard geöffnet.`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setActionBusy(true);
|
||||
try {
|
||||
await api.restartCdReviewFromRaw(job.id, options);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'CD-Vorprüfung gestartet',
|
||||
detail: 'Job ist jetzt im Dashboard verfügbar — bitte Metadaten und Einstellungen wählen.',
|
||||
life: 4000
|
||||
});
|
||||
await load();
|
||||
await refreshDetailIfOpen(job.id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'CD-Vorprüfung fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
} else if (action === 'delete_output') {
|
||||
const deleteFolders = Array.isArray(options?.deleteFolders)
|
||||
? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim())
|
||||
: [];
|
||||
if (deleteFolders.length === 0) {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Keine Ordner gewählt',
|
||||
detail: 'Es wurden keine Output-Ordner zum Löschen erkannt.',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const response = await api.deleteOutputFolders(job.id, deleteFolders);
|
||||
const result = response?.result && typeof response.result === 'object' ? response.result : {};
|
||||
const deletedCount = Array.isArray(result.deleted) ? result.deleted.length : 0;
|
||||
const failedCount = Array.isArray(result.failed) ? result.failed.length : 0;
|
||||
toastRef.current?.show({
|
||||
severity: failedCount > 0 ? 'warn' : 'success',
|
||||
summary: failedCount > 0 ? 'Output teilweise gelöscht' : 'Output gelöscht',
|
||||
detail: failedCount > 0
|
||||
? `${deletedCount} Ordner gelöscht, ${failedCount} mit Fehler.`
|
||||
: `${deletedCount} Ordner gelöscht.`,
|
||||
life: 4000
|
||||
});
|
||||
await load();
|
||||
await refreshDetailIfOpen(job.id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Output löschen fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleConflictKeepBoth = async () => {
|
||||
const job = conflictModalJob;
|
||||
const action = conflictModalAction;
|
||||
setConflictModalBusy(true);
|
||||
try {
|
||||
await executeHistoryAction(job, action, { keepBoth: true }, { skipConfirm: true });
|
||||
closeConflictModal();
|
||||
} finally {
|
||||
setConflictModalBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConflictDeleteSelected = async (selectedPaths) => {
|
||||
const job = conflictModalJob;
|
||||
const action = conflictModalAction;
|
||||
const selected = Array.isArray(selectedPaths)
|
||||
? selectedPaths.filter((value) => typeof value === 'string' && value.trim())
|
||||
: [];
|
||||
const allKnownPaths = conflictModalFolders
|
||||
.map((folder) => String(folder?.output_path || '').trim())
|
||||
.filter(Boolean);
|
||||
const deleteFolders = selected.length > 0 ? selected : allKnownPaths;
|
||||
setConflictModalBusy(true);
|
||||
try {
|
||||
const options = deleteFolders.length > 0 ? { deleteFolders } : {};
|
||||
await executeHistoryAction(job, action, options, { skipConfirm: true });
|
||||
closeConflictModal();
|
||||
} finally {
|
||||
setConflictModalBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── File deletion ──────────────────────────────────────────────────────────
|
||||
|
||||
const handleDeleteFiles = async (row, target) => {
|
||||
if (target === 'movie') {
|
||||
const outputFolders = await loadOutputFoldersForJob(row);
|
||||
if (outputFolders.length > 1) {
|
||||
await openConflictModal(row, 'delete_output', 'delete', outputFolders);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const outputLabel = getOutputLabelForRow(row);
|
||||
const outputShortLabel = getOutputShortLabelForRow(row);
|
||||
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
|
||||
@@ -608,97 +872,64 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadOutputFolder = async (row, folderPath) => {
|
||||
const jobId = Number(row?.id || selectedJob?.id || 0);
|
||||
if (!jobId || !folderPath) return;
|
||||
setDownloadFolderBusyPath(folderPath);
|
||||
try {
|
||||
const response = await api.requestJobArchive(jobId, 'output', { outputPath: folderPath });
|
||||
const item = response?.item && typeof response.item === 'object' ? response.item : null;
|
||||
const isReady = String(item?.status || '').trim().toLowerCase() === 'ready';
|
||||
toastRef.current?.show({
|
||||
severity: isReady ? 'success' : 'info',
|
||||
summary: isReady ? 'ZIP bereit' : 'ZIP wird erstellt',
|
||||
detail: isReady
|
||||
? 'Output-ZIP ist bereits auf der Downloads-Seite verfügbar.'
|
||||
: 'Output-ZIP wird im Hintergrund erstellt und erscheint danach auf der Downloads-Seite.',
|
||||
life: 4000
|
||||
});
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Download fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setDownloadFolderBusyPath(null);
|
||||
}
|
||||
};
|
||||
|
||||
const maybeOpenOutputConflictModal = async (row, action) => {
|
||||
const outputFolders = await loadOutputFoldersForJob(row);
|
||||
if (outputFolders.length === 0) {
|
||||
return false;
|
||||
}
|
||||
await openConflictModal(row, action, 'reencode', outputFolders);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleReencode = async (row) => {
|
||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||
const confirmed = window.confirm(`RAW neu encodieren für "${title}" starten?`);
|
||||
if (!confirmed) {
|
||||
if (await maybeOpenOutputConflictModal(row, 'reencode')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReencodeBusyJobId(row.id);
|
||||
try {
|
||||
await api.reencodeJob(row.id);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Re-Encode gestartet',
|
||||
detail: 'Job wurde in die Mediainfo-Prüfung gesetzt.',
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
await refreshDetailIfOpen(row.id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Re-Encode fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setReencodeBusyJobId(null);
|
||||
}
|
||||
await executeHistoryAction(row, 'reencode');
|
||||
};
|
||||
|
||||
const handleRestartEncode = async (row) => {
|
||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||
if (row?.encodeSuccess) {
|
||||
const confirmed = window.confirm(
|
||||
`Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n`
|
||||
+ 'Es wird eine neue Datei mit Kollisionsprüfung angelegt.'
|
||||
);
|
||||
if (!confirmed) {
|
||||
if (await maybeOpenOutputConflictModal(row, 'restart_encode')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const response = await api.restartEncodeWithLastSettings(row.id);
|
||||
const result = getQueueActionResult(response);
|
||||
if (result.queued) {
|
||||
const queuePosition = Number(result?.queuePosition || 0);
|
||||
toastRef.current?.show({
|
||||
severity: 'info',
|
||||
summary: 'Encode-Neustart in Queue',
|
||||
detail: queuePosition > 0
|
||||
? `Job wurde auf Position ${queuePosition} eingeplant.`
|
||||
: 'Job wurde in die Warteschlange eingeplant.',
|
||||
life: 3500
|
||||
});
|
||||
} else {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Encode-Neustart gestartet',
|
||||
detail: 'Letzte bestätigte Einstellungen werden verwendet.',
|
||||
life: 3500
|
||||
});
|
||||
}
|
||||
await load();
|
||||
await refreshDetailIfOpen(row.id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Encode-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
await executeHistoryAction(row, 'restart_encode');
|
||||
};
|
||||
|
||||
const handleRestartReview = async (row) => {
|
||||
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
||||
const confirmed = window.confirm(`Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Dashboard neu getroffen werden.`);
|
||||
if (!confirmed) {
|
||||
if (await maybeOpenOutputConflictModal(row, 'restart_review')) {
|
||||
return;
|
||||
}
|
||||
await executeHistoryAction(row, 'restart_review');
|
||||
};
|
||||
|
||||
setActionBusy(true);
|
||||
try {
|
||||
await api.restartReviewFromRaw(row.id);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Review-Neustart',
|
||||
detail: 'Analyse gestartet. Job ist jetzt im Dashboard verfügbar.',
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
await refreshDetailIfOpen(row.id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
const handleRestartCdReview = async (row) => {
|
||||
if (await maybeOpenOutputConflictModal(row, 'restart_cd_review')) {
|
||||
return;
|
||||
}
|
||||
await executeHistoryAction(row, 'restart_cd_review');
|
||||
};
|
||||
|
||||
const handleRetry = async (row) => {
|
||||
@@ -725,7 +956,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
});
|
||||
await load();
|
||||
if (replacementJobId) {
|
||||
const detailResponse = await api.getJob(replacementJobId, { includeLogs: false });
|
||||
const detailResponse = await api.getJob(replacementJobId, { includeLogs: false, forceRefresh: true });
|
||||
setSelectedJob(detailResponse.job);
|
||||
setDetailVisible(true);
|
||||
} else {
|
||||
@@ -850,6 +1081,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
setDeleteEntryPreview(null);
|
||||
setDeleteEntryPreviewLoading(false);
|
||||
setDeleteEntryTargetBusy(null);
|
||||
setDeleteEntrySelectedMoviePaths([]);
|
||||
};
|
||||
|
||||
const handleDeleteEntry = async (row) => {
|
||||
@@ -859,12 +1091,20 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
setDeleteEntryDialogRow(row);
|
||||
setDeleteEntryPreview(null);
|
||||
setDeleteEntrySelectedMoviePaths([]);
|
||||
setDeleteEntryDialogVisible(true);
|
||||
setDeleteEntryPreviewLoading(true);
|
||||
setDeleteEntryBusy(true);
|
||||
try {
|
||||
const response = await api.getJobDeletePreview(jobId, { includeRelated: true });
|
||||
setDeleteEntryPreview(response?.preview || null);
|
||||
const preview = response?.preview || null;
|
||||
setDeleteEntryPreview(preview);
|
||||
const movieCandidates = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [];
|
||||
const defaultSelectedMoviePaths = movieCandidates
|
||||
.filter((item) => Boolean(item?.exists))
|
||||
.map((item) => String(item?.path || '').trim())
|
||||
.filter(Boolean);
|
||||
setDeleteEntrySelectedMoviePaths(defaultSelectedMoviePaths);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
@@ -890,11 +1130,27 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(normalizedTarget === 'movie' || normalizedTarget === 'both')
|
||||
&& previewMovieExisting.length > 0
|
||||
&& deleteEntrySelectedMoviePaths.length === 0
|
||||
) {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: `${deleteEntryOutputShortLabel}-Ordner auswählen`,
|
||||
detail: `Bitte mindestens einen ${deleteEntryOutputShortLabel}-Ordner zum Löschen auswählen.`,
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteEntryBusy(true);
|
||||
setDeleteEntryTargetBusy(normalizedTarget);
|
||||
try {
|
||||
const response = await api.deleteJobEntry(jobId, normalizedTarget, { includeRelated: true });
|
||||
const response = await api.deleteJobEntry(jobId, normalizedTarget, {
|
||||
includeRelated: true,
|
||||
selectedMoviePaths: deleteEntrySelectedMoviePaths
|
||||
});
|
||||
const deletedJobIds = Array.isArray(response?.deletedJobIds) ? response.deletedJobIds : [];
|
||||
const fileSummary = response?.fileSummary || {};
|
||||
const rawFiles = Number(fileSummary?.raw?.filesDeleted || 0);
|
||||
@@ -929,6 +1185,24 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDeleteMoviePathSelection = (moviePath, checked) => {
|
||||
const normalizedPath = String(moviePath || '').trim();
|
||||
if (!normalizedPath) {
|
||||
return;
|
||||
}
|
||||
setDeleteEntrySelectedMoviePaths((previous) => {
|
||||
const nextSet = new Set((Array.isArray(previous) ? previous : []).map((item) => String(item || '').trim()).filter(Boolean));
|
||||
if (checked) {
|
||||
nextSet.add(normalizedPath);
|
||||
} else {
|
||||
nextSet.delete(normalizedPath);
|
||||
}
|
||||
return previewMoviePaths
|
||||
.map((item) => String(item?.path || '').trim())
|
||||
.filter((itemPath) => itemPath && nextSet.has(itemPath));
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromQueue = async (row) => {
|
||||
const jobId = normalizeJobId(row?.id || row);
|
||||
if (!jobId) {
|
||||
@@ -1202,6 +1476,13 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
|
||||
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
|
||||
const previewMovieExisting = previewMoviePaths.filter((item) => Boolean(item?.exists));
|
||||
const previewRawDisplay = previewRawExisting.length > 0 ? previewRawExisting : previewRawPaths.slice(0, 1);
|
||||
const previewMovieDisplay = previewMovieExisting.length > 0 ? previewMovieExisting : previewMoviePaths.slice(0, 1);
|
||||
const selectedDeleteMoviePathSet = useMemo(
|
||||
() => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)),
|
||||
[deleteEntrySelectedMoviePaths]
|
||||
);
|
||||
const movieDeleteSelectionRequired = previewMovieExisting.length > 0 && deleteEntrySelectedMoviePaths.length === 0;
|
||||
const deleteTargetActionsDisabled = deleteEntryPreviewLoading || Boolean(deleteEntryTargetBusy) || !deleteEntryPreview;
|
||||
const deleteEntryOutputLabel = getOutputLabelForRow(deleteEntryDialogRow);
|
||||
const deleteEntryOutputShortLabel = getOutputShortLabelForRow(deleteEntryDialogRow);
|
||||
@@ -1281,6 +1562,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
logLoadingMode={logLoadingMode}
|
||||
onRestartEncode={handleRestartEncode}
|
||||
onRestartReview={handleRestartReview}
|
||||
onRestartCdReview={handleRestartCdReview}
|
||||
onReencode={handleReencode}
|
||||
onRetry={handleRetry}
|
||||
onAssignOmdb={handleAssignOmdb}
|
||||
@@ -1288,6 +1570,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
onDeleteFiles={handleDeleteFiles}
|
||||
onDeleteEntry={handleDeleteEntry}
|
||||
onDownloadArchive={handleDownloadArchive}
|
||||
onDownloadOutputFolder={handleDownloadOutputFolder}
|
||||
onRemoveFromQueue={handleRemoveFromQueue}
|
||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||
actionBusy={actionBusy}
|
||||
@@ -1296,11 +1579,13 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||
deleteEntryBusy={deleteEntryBusy}
|
||||
downloadBusyTarget={downloadBusyTarget}
|
||||
downloadFolderBusyPath={downloadFolderBusyPath}
|
||||
onHide={() => {
|
||||
setDetailVisible(false);
|
||||
setDetailLoading(false);
|
||||
setLogLoadingMode(null);
|
||||
setDownloadBusyTarget(null);
|
||||
setDownloadFolderBusyPath(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1344,12 +1629,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
<div>
|
||||
<h4>RAW</h4>
|
||||
{previewRawPaths.length > 0 ? (() => {
|
||||
const display = previewRawPaths.filter(p => p.exists).length > 0
|
||||
? previewRawPaths.filter(p => p.exists)
|
||||
: previewRawPaths.slice(0, 1);
|
||||
return (
|
||||
<ul className="history-delete-preview-list">
|
||||
{display.map((item) => (
|
||||
{previewRawDisplay.map((item) => (
|
||||
<li key={`delete-raw-${item.path}`}>
|
||||
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
||||
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
||||
@@ -1367,17 +1649,30 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
<div>
|
||||
<h4>{deleteEntryOutputShortLabel}</h4>
|
||||
{previewMoviePaths.length > 0 ? (() => {
|
||||
const display = previewMoviePaths.filter(p => p.exists).length > 0
|
||||
? previewMoviePaths.filter(p => p.exists)
|
||||
: previewMoviePaths.slice(0, 1);
|
||||
return (
|
||||
<ul className="history-delete-preview-list">
|
||||
{display.map((item) => (
|
||||
{previewMovieDisplay.map((item) => (
|
||||
<li key={`delete-movie-${item.path}`}>
|
||||
{item.exists ? (
|
||||
<label className="history-delete-preview-checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDeleteMoviePathSet.has(String(item.path || '').trim())}
|
||||
onChange={(event) => toggleDeleteMoviePathSelection(item.path, Boolean(event.target.checked))}
|
||||
/>
|
||||
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
||||
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
||||
</span>
|
||||
<span>| {item.path}</span>
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
||||
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
||||
</span>
|
||||
{' '}| {item.path}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -1406,7 +1701,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('movie')}
|
||||
loading={deleteEntryTargetBusy === 'movie'}
|
||||
disabled={deleteTargetActionsDisabled}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
||||
/>
|
||||
<Button
|
||||
label="Beides löschen"
|
||||
@@ -1414,7 +1709,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
severity="danger"
|
||||
onClick={() => confirmDeleteEntry('both')}
|
||||
loading={deleteEntryTargetBusy === 'both'}
|
||||
disabled={deleteTargetActionsDisabled}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
||||
/>
|
||||
<Button
|
||||
label="Nur Eintrag löschen"
|
||||
@@ -1459,6 +1754,17 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
onFetchRelease={handleMusicBrainzReleaseFetch}
|
||||
busy={cdMetadataAssignBusy}
|
||||
/>
|
||||
|
||||
<ReencodeConflictModal
|
||||
visible={conflictModalVisible}
|
||||
onHide={closeConflictModal}
|
||||
job={conflictModalJob}
|
||||
existingFolders={conflictModalFolders}
|
||||
mode={conflictModalMode}
|
||||
onKeepBoth={handleConflictKeepBoth}
|
||||
onDeleteSelected={handleConflictDeleteSelected}
|
||||
busy={conflictModalBusy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ export default function SettingsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testingPushover, setTestingPushover] = useState(false);
|
||||
const [coverArtRecoveryBusy, setCoverArtRecoveryBusy] = useState(false);
|
||||
const [updatingExpertMode, setUpdatingExpertMode] = useState(false);
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||
const [initialValues, setInitialValues] = useState({});
|
||||
@@ -535,6 +536,31 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCoverArtRecovery = async () => {
|
||||
setCoverArtRecoveryBusy(true);
|
||||
try {
|
||||
const response = await api.recoverMissingCoverArt({});
|
||||
const result = response?.result || {};
|
||||
const recovered = Number(result?.recovered || 0);
|
||||
const failed = Number(result?.failed || 0);
|
||||
const scanned = Number(result?.scannedJobs || 0);
|
||||
toastRef.current?.show({
|
||||
severity: failed > 0 ? 'warn' : 'success',
|
||||
summary: 'Coverart-Prüfung',
|
||||
detail: `Geprüft: ${scanned} | Nachgeladen: ${recovered} | Fehlgeschlagen: ${failed}`,
|
||||
life: 5000
|
||||
});
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Coverart-Prüfung fehlgeschlagen',
|
||||
detail: error.message
|
||||
});
|
||||
} finally {
|
||||
setCoverArtRecoveryBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScriptEditorChange = (key, value) => {
|
||||
setScriptEditor((prev) => ({
|
||||
...prev,
|
||||
@@ -1035,6 +1061,14 @@ export default function SettingsPage() {
|
||||
loading={testingPushover}
|
||||
disabled={saving || updatingExpertMode}
|
||||
/>
|
||||
<Button
|
||||
label="Coverarts prüfen"
|
||||
icon="pi pi-images"
|
||||
severity="help"
|
||||
onClick={handleCoverArtRecovery}
|
||||
loading={coverArtRecoveryBusy}
|
||||
disabled={saving || updatingExpertMode || testingPushover}
|
||||
/>
|
||||
<div className="settings-expert-toggle">
|
||||
<span>Expertenmodus</span>
|
||||
<InputSwitch
|
||||
|
||||
+205
-5
@@ -1665,6 +1665,44 @@ body {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Hierarchische Settings — Baumstruktur */
|
||||
.settings-grid--depth-1 {
|
||||
margin-left: 1.5rem;
|
||||
padding-left: 0.9rem;
|
||||
border-left: 3px solid var(--rip-amber, #f59e0b);
|
||||
margin-top: 0.2rem;
|
||||
margin-bottom: 0.1rem;
|
||||
background: rgba(245, 158, 11, 0.04);
|
||||
border-radius: 0 0.35rem 0.35rem 0;
|
||||
}
|
||||
|
||||
.settings-grid--depth-2 {
|
||||
margin-left: 1.25rem;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 2px solid var(--rip-border, #e5e7eb);
|
||||
margin-top: 0.15rem;
|
||||
border-radius: 0 0.25rem 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Label für readonly/not-yet-implemented Toggles */
|
||||
.setting-label--readonly {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* Readonly-Zeile leicht ausgrauen */
|
||||
.settings-grid--depth-1 .setting-row:has(.p-inputswitch.p-disabled),
|
||||
.settings-grid--depth-2 .setting-row:has(.p-inputswitch.p-disabled) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.setting-badge--coming-soon {
|
||||
font-size: 0.68rem;
|
||||
color: var(--rip-muted, #9ca3af);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
.settings-tabview {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
@@ -1841,24 +1879,28 @@ body {
|
||||
}
|
||||
|
||||
.drive-list-row {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.drive-list-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drive-list-path {
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--rip-muted);
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.drive-list-model {
|
||||
@@ -1876,14 +1918,24 @@ body {
|
||||
}
|
||||
|
||||
.drive-list-state {
|
||||
flex-shrink: 0;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.drive-list-status-tag.p-tag {
|
||||
max-width: 10rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.drive-list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-shrink: 0;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.drive-list-disc-label {
|
||||
@@ -1902,6 +1954,38 @@ body {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.drive-list-global-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.drive-list-global-actions .p-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.drive-list-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"info info"
|
||||
"state actions";
|
||||
}
|
||||
|
||||
.drive-list-info {
|
||||
grid-area: info;
|
||||
}
|
||||
|
||||
.drive-list-state {
|
||||
grid-area: state;
|
||||
}
|
||||
|
||||
.drive-list-actions {
|
||||
grid-area: actions;
|
||||
}
|
||||
}
|
||||
|
||||
/* Legacy per-CD-drive section (kept for compat) */
|
||||
.cd-drives-section {
|
||||
display: flex;
|
||||
@@ -2678,6 +2762,17 @@ body {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.history-delete-preview-checkbox-row {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-delete-preview-checkbox-row input[type="checkbox"] {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.history-delete-preview-list .exists-yes {
|
||||
color: #176635;
|
||||
font-weight: 600;
|
||||
@@ -3393,6 +3488,21 @@ body {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.track-item-inline-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.track-item-inline-status .track-item-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-item-inline-status .track-action-note {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.media-title-block {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
@@ -4591,3 +4701,93 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ReencodeConflictModal ──────────────────────────────────────────────── */
|
||||
.reencode-conflict-modal .p-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.reencode-conflict-intro {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.reencode-conflict-folders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--surface-border, #d8d3c6);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
background: var(--surface-ground, #f7f7f7);
|
||||
}
|
||||
|
||||
.reencode-conflict-folders-header {
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--surface-border, #d8d3c6);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.reencode-conflict-check-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.reencode-conflict-folder-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.3rem 0.25rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.reencode-conflict-folder-row:hover {
|
||||
background: var(--surface-hover, #eeebe4);
|
||||
}
|
||||
|
||||
.reencode-conflict-folder-row.is-checked {
|
||||
background: var(--red-50, #fff5f5);
|
||||
}
|
||||
|
||||
.reencode-conflict-folder-name {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.reencode-conflict-folder-date {
|
||||
color: var(--text-color-secondary, #6c757d);
|
||||
white-space: nowrap;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.reencode-conflict-hint {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-color-secondary, #6c757d);
|
||||
line-height: 1.5;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-left: 3px solid var(--primary-color, #a89f91);
|
||||
background: var(--surface-ground, #f7f7f7);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.reencode-conflict-no-folders {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary, #6c757d);
|
||||
}
|
||||
|
||||
.reencode-conflict-footer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,57 @@ function normalizeStage(value) {
|
||||
return stage || null;
|
||||
}
|
||||
|
||||
function normalizeStatus(value) {
|
||||
return String(value || '').trim().toUpperCase() || null;
|
||||
}
|
||||
|
||||
function inferExpectedPluginStage({ currentStatus = null, currentStage = null } = {}) {
|
||||
const explicitStage = normalizeStage(currentStage);
|
||||
if (explicitStage) {
|
||||
return explicitStage;
|
||||
}
|
||||
|
||||
const status = normalizeStatus(currentStatus);
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
status === 'ANALYZING'
|
||||
|| status === 'CD_ANALYZING'
|
||||
|| status === 'METADATA_SELECTION'
|
||||
|| status === 'CD_METADATA_SELECTION'
|
||||
|| status === 'CD_READY_TO_RIP'
|
||||
) {
|
||||
return 'analyze';
|
||||
}
|
||||
if (status === 'RIPPING' || status === 'CD_RIPPING') {
|
||||
return 'rip';
|
||||
}
|
||||
if (status === 'MEDIAINFO_CHECK' || status === 'WAITING_FOR_USER_DECISION' || status === 'READY_TO_ENCODE') {
|
||||
return 'review';
|
||||
}
|
||||
if (status === 'ENCODING' || status === 'CD_ENCODING') {
|
||||
return 'encode';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchesExpectedStage(executionState, expectedStage) {
|
||||
if (!executionState) {
|
||||
return false;
|
||||
}
|
||||
if (!expectedStage) {
|
||||
return true;
|
||||
}
|
||||
const stages = Array.isArray(executionState.stages) ? executionState.stages : [];
|
||||
if (stages.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return stages.includes(expectedStage);
|
||||
}
|
||||
|
||||
const STAGE_LABELS = {
|
||||
analyze: 'Analyse',
|
||||
rip: 'Rip',
|
||||
@@ -57,21 +108,30 @@ export function normalizePluginExecutionState(rawState) {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveJobPluginExecution(job = null, liveContext = null) {
|
||||
export function resolveJobPluginExecution(job = null, liveContext = null, options = {}) {
|
||||
const liveExecution = normalizePluginExecutionState(liveContext?.pluginExecution);
|
||||
if (liveExecution) {
|
||||
return liveExecution;
|
||||
}
|
||||
|
||||
const makemkvExecution = normalizePluginExecutionState(job?.makemkvInfo?.pluginExecution);
|
||||
if (makemkvExecution) {
|
||||
return makemkvExecution;
|
||||
}
|
||||
|
||||
const handbrakeExecution = normalizePluginExecutionState(job?.handbrakeInfo?.pluginExecution);
|
||||
if (handbrakeExecution) {
|
||||
return handbrakeExecution;
|
||||
const jobExecution = normalizePluginExecutionState(job?.pluginExecution);
|
||||
|
||||
const expectedStage = inferExpectedPluginStage(options);
|
||||
const candidates = [liveExecution, makemkvExecution, handbrakeExecution, jobExecution].filter(Boolean);
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizePluginExecutionState(job?.pluginExecution);
|
||||
if (!expectedStage) {
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
const stageMatch = candidates.find((state) => matchesExpectedStage(state, expectedStage));
|
||||
if (stageMatch) {
|
||||
return stageMatch;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function inferPluginStageFromStatus(status) {
|
||||
return inferExpectedPluginStage({ currentStatus: status });
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-1",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"private": true,
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.0-1",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||
"dev:backend": "npm run dev --prefix backend",
|
||||
|
||||
Reference in New Issue
Block a user