Compare commits
8 Commits
d00191324b
...
fb5ee7e4dd
| Author | SHA1 | Date | |
|---|---|---|---|
| fb5ee7e4dd | |||
| b773c2aa1d | |||
| adbc5e51b5 | |||
| c0350644b9 | |||
| e99cdf1895 | |||
| d9969dfbe5 | |||
| 24955a956d | |||
| 0a9cf6969f |
@@ -22,7 +22,8 @@
|
|||||||
"Bash(lsblk -J -o NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL)",
|
"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(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(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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"watch": [
|
||||||
|
"src",
|
||||||
|
".env"
|
||||||
|
],
|
||||||
|
"ext": "js,json,env",
|
||||||
|
"ignore": [
|
||||||
|
"data/**",
|
||||||
|
"logs/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0-7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0-7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0-7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -530,6 +530,7 @@ async function openAndPrepareDatabase() {
|
|||||||
await removeDeprecatedSettings(dbInstance);
|
await removeDeprecatedSettings(dbInstance);
|
||||||
await migrateSettingsSchemaMetadata(dbInstance);
|
await migrateSettingsSchemaMetadata(dbInstance);
|
||||||
await ensurePipelineStateRow(dbInstance);
|
await ensurePipelineStateRow(dbInstance);
|
||||||
|
await backfillJobOutputFolders(dbInstance);
|
||||||
const syncedLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true });
|
const syncedLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true });
|
||||||
logger.info('log-root:synced', {
|
logger.info('log-root:synced', {
|
||||||
configured: syncedLogRoot.configured || null,
|
configured: syncedLogRoot.configured || null,
|
||||||
@@ -789,7 +790,24 @@ async function removeDeprecatedSettings(db) {
|
|||||||
'filename_template_dvd',
|
'filename_template_dvd',
|
||||||
'output_folder_template_bluray',
|
'output_folder_template_bluray',
|
||||||
'output_folder_template_dvd',
|
'output_folder_template_dvd',
|
||||||
'output_extension_audiobook'
|
'output_extension_audiobook',
|
||||||
|
'use_plugin_architecture',
|
||||||
|
'use_plugin_architecture_bluray',
|
||||||
|
'use_plugin_architecture_bluray_analyze',
|
||||||
|
'use_plugin_architecture_bluray_rip',
|
||||||
|
'use_plugin_architecture_bluray_review',
|
||||||
|
'use_plugin_architecture_bluray_encode',
|
||||||
|
'use_plugin_architecture_dvd',
|
||||||
|
'use_plugin_architecture_dvd_analyze',
|
||||||
|
'use_plugin_architecture_dvd_rip',
|
||||||
|
'use_plugin_architecture_dvd_review',
|
||||||
|
'use_plugin_architecture_dvd_encode',
|
||||||
|
'use_plugin_architecture_cd',
|
||||||
|
'use_plugin_architecture_cd_analyze',
|
||||||
|
'use_plugin_architecture_cd_rip',
|
||||||
|
'use_plugin_architecture_audiobook',
|
||||||
|
'use_plugin_architecture_audiobook_analyze',
|
||||||
|
'use_plugin_architecture_audiobook_encode'
|
||||||
];
|
];
|
||||||
for (const key of deprecatedKeys) {
|
for (const key of deprecatedKeys) {
|
||||||
const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
|
const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
|
||||||
@@ -974,6 +992,27 @@ async function migrateSettingsSchemaMetadata(db) {
|
|||||||
);
|
);
|
||||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('auto_eject_after_rip', 'false')`);
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('auto_eject_after_rip', 'false')`);
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db.run(`
|
await db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -983,6 +1022,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() {
|
async function getDb() {
|
||||||
return initDatabase();
|
return initDatabase();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const cronService = require('./services/cronService');
|
|||||||
const downloadService = require('./services/downloadService');
|
const downloadService = require('./services/downloadService');
|
||||||
const diskDetectionService = require('./services/diskDetectionService');
|
const diskDetectionService = require('./services/diskDetectionService');
|
||||||
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
||||||
|
const coverArtRecoveryService = require('./services/coverArtRecoveryService');
|
||||||
const logger = require('./services/logger').child('BOOT');
|
const logger = require('./services/logger').child('BOOT');
|
||||||
const { errorToMeta } = require('./utils/errorMeta');
|
const { errorToMeta } = require('./utils/errorMeta');
|
||||||
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
|
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
|
||||||
@@ -29,6 +30,7 @@ async function start() {
|
|||||||
await pipelineService.init();
|
await pipelineService.init();
|
||||||
await cronService.init();
|
await cronService.init();
|
||||||
await downloadService.init();
|
await downloadService.init();
|
||||||
|
await coverArtRecoveryService.init();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors({ origin: corsOrigin }));
|
app.use(cors({ origin: corsOrigin }));
|
||||||
@@ -85,6 +87,7 @@ async function start() {
|
|||||||
const shutdown = () => {
|
const shutdown = () => {
|
||||||
logger.warn('backend:shutdown:received');
|
logger.warn('backend:shutdown:received');
|
||||||
diskDetectionService.stop();
|
diskDetectionService.stop();
|
||||||
|
coverArtRecoveryService.stop();
|
||||||
hardwareMonitorService.stop();
|
hardwareMonitorService.stop();
|
||||||
cronService.stop();
|
cronService.stop();
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
|
|||||||
@@ -0,0 +1,460 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { SourcePlugin } = require('./PluginBase');
|
||||||
|
const audiobookService = require('../services/audiobookService');
|
||||||
|
const { spawnTrackedProcess } = require('../services/processRunner');
|
||||||
|
const { ensureDir } = require('../utils/files');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source-Plugin für Audiobooks (AAX-Dateien).
|
||||||
|
*
|
||||||
|
* Audiobooks unterscheiden sich von Disc-Medien grundlegend:
|
||||||
|
* - Kein Laufwerk — der Nutzer lädt eine .aax-Datei hoch
|
||||||
|
* - Kein Rip-Schritt — die AAX-Datei ist der Input
|
||||||
|
* - Encode mit ffmpeg (Gesamt oder kapitelweise)
|
||||||
|
*
|
||||||
|
* Deshalb:
|
||||||
|
* rip() → No-Op (Datei liegt bereits im rawDir)
|
||||||
|
* review() → null (kein HandBrake-Preview)
|
||||||
|
* encode() → ffmpeg Single-File oder kapitelweise
|
||||||
|
*
|
||||||
|
* detect() prüft das mediaProfile-Feld (gesetzt vom Orchestrator für
|
||||||
|
* Datei-Uploads) oder eine Dateiendung im discInfo.
|
||||||
|
*
|
||||||
|
* Pflicht-Felder in ctx.extra für analyze():
|
||||||
|
* filePath - Absoluter Pfad zur .aax-Datei
|
||||||
|
*
|
||||||
|
* Pflicht-Felder in ctx.extra für encode():
|
||||||
|
* inputPath - Absoluter Pfad zur .aax-Input-Datei
|
||||||
|
* outputPath - Absoluter Pfad für die Ausgabe (Datei oder Verzeichnis)
|
||||||
|
* outputFormat - 'm4b' | 'mp3' | 'flac'
|
||||||
|
* formatOptions - Encoder-Optionen { flacCompression, mp3Mode, mp3Bitrate, ... }
|
||||||
|
* metadata - Metadata-Objekt aus analyze() oder encode_plan_json
|
||||||
|
* activationBytes - AAX-Aktivierungsbytes (String, kann null sein bei nicht-DRM)
|
||||||
|
* isSplitOutput - boolean: true = kapitelweise, false = Einzel-Datei
|
||||||
|
* chapterPlan - { outputFiles, chapters } — nur benötigt wenn isSplitOutput = true
|
||||||
|
* isCancelled - () => boolean
|
||||||
|
* onProcessHandle - (handle) => void [optional]
|
||||||
|
*/
|
||||||
|
class AudiobookPlugin extends SourcePlugin {
|
||||||
|
get id() {
|
||||||
|
return 'audiobook';
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return 'Audiobook (AAX)';
|
||||||
|
}
|
||||||
|
|
||||||
|
get priority() {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt Audiobooks.
|
||||||
|
* Wird entweder via mediaProfile ('audiobook') oder Dateiendung erkannt.
|
||||||
|
*/
|
||||||
|
detect(discInfo) {
|
||||||
|
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
|
||||||
|
if (profile === 'audiobook' || profile === 'audio_book') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const ext = path.extname(String(discInfo?.filePath || discInfo?.fileName || '')).trim().toLowerCase();
|
||||||
|
return audiobookService.SUPPORTED_INPUT_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analysiert eine .aax-Datei mit ffprobe.
|
||||||
|
* Gibt die extrahierten Metadaten zurück (Titel, Autor, Kapitel, Cover, etc.).
|
||||||
|
*
|
||||||
|
* @param {string} filePath - Pfad zur .aax-Datei (in ctx.extra.filePath ODER als devicePath)
|
||||||
|
* @param {object} job
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<object>} Metadata-Objekt aus audiobookService.buildMetadataFromProbe()
|
||||||
|
*/
|
||||||
|
async analyze(filePath, job, ctx) {
|
||||||
|
ctx.markExecution('analyze', {
|
||||||
|
pluginId: this.id,
|
||||||
|
pluginName: this.name,
|
||||||
|
pluginFile: 'AudiobookPlugin.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputPath = ctx.extra?.filePath || filePath;
|
||||||
|
|
||||||
|
if (!inputPath || !fs.existsSync(inputPath)) {
|
||||||
|
const error = new Error(`AudiobookPlugin.analyze(): Datei nicht gefunden: ${inputPath}`);
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await ctx.settings.getSettingsMap();
|
||||||
|
const ffprobeCommand = String(settings.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
||||||
|
const originalName = path.basename(inputPath);
|
||||||
|
|
||||||
|
ctx.logger.info('audiobook:analyze:start', { jobId: job?.id, inputPath, ffprobeCommand });
|
||||||
|
|
||||||
|
const probeConfig = audiobookService.buildProbeCommand(ffprobeCommand, inputPath);
|
||||||
|
const captured = await _runCaptured(probeConfig.cmd, probeConfig.args, { jobId: job?.id });
|
||||||
|
|
||||||
|
const probe = audiobookService.parseProbeOutput(captured.stdout);
|
||||||
|
if (!probe) {
|
||||||
|
const error = new Error('FFprobe-Ausgabe konnte nicht gelesen werden (kein gültiges JSON).');
|
||||||
|
error.statusCode = 500;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = audiobookService.buildMetadataFromProbe(probe, originalName);
|
||||||
|
|
||||||
|
ctx.logger.info('audiobook:analyze:done', {
|
||||||
|
jobId: job?.id,
|
||||||
|
title: metadata.title,
|
||||||
|
author: metadata.author,
|
||||||
|
chapterCount: metadata.chapters?.length || 0,
|
||||||
|
durationMs: metadata.durationMs
|
||||||
|
});
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-Op: Audiobooks haben keinen Rip-Schritt.
|
||||||
|
* Die .aax-Datei wurde bereits beim Upload ins rawDir verschoben.
|
||||||
|
*/
|
||||||
|
async rip(_job, _ctx) {
|
||||||
|
_ctx.markExecution('rip', {
|
||||||
|
pluginId: this.id,
|
||||||
|
pluginName: this.name,
|
||||||
|
pluginFile: 'AudiobookPlugin.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Für Audiobooks gibt es keinen separaten Rip-Schritt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodiert die .aax-Datei mit ffmpeg — als Einzel-Datei oder kapitelweise.
|
||||||
|
*
|
||||||
|
* @param {object} job
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<{outputPath: string, format: string, chapterCount: number}>}
|
||||||
|
*/
|
||||||
|
async encode(job, ctx) {
|
||||||
|
ctx.markExecution('encode', {
|
||||||
|
pluginId: this.id,
|
||||||
|
pluginName: this.name,
|
||||||
|
pluginFile: 'AudiobookPlugin.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await ctx.settings.getSettingsMap();
|
||||||
|
if (!settings.audiobook_encoding_enabled) {
|
||||||
|
ctx.logger.info('audiobook:encode:disabled', { jobId: job?.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
inputPath,
|
||||||
|
outputPath,
|
||||||
|
outputFormat = 'mp3',
|
||||||
|
formatOptions = {},
|
||||||
|
metadata = {},
|
||||||
|
activationBytes = null,
|
||||||
|
isSplitOutput = false,
|
||||||
|
chapterPlan = null,
|
||||||
|
isCancelled,
|
||||||
|
onProcessHandle
|
||||||
|
} = ctx.extra || {};
|
||||||
|
|
||||||
|
if (!inputPath) {
|
||||||
|
throw new Error('AudiobookPlugin.encode(): ctx.extra.inputPath fehlt');
|
||||||
|
}
|
||||||
|
if (!outputPath) {
|
||||||
|
throw new Error('AudiobookPlugin.encode(): ctx.extra.outputPath fehlt');
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(inputPath)) {
|
||||||
|
const error = new Error(`AudiobookPlugin.encode(): Input-Datei nicht gefunden: ${inputPath}`);
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ffmpegCommand = String(settings.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg';
|
||||||
|
const normalizedFormat = audiobookService.normalizeOutputFormat(outputFormat);
|
||||||
|
const normalizedOptions = audiobookService.normalizeFormatOptions(normalizedFormat, formatOptions);
|
||||||
|
|
||||||
|
ctx.logger.info('audiobook:encode:start', {
|
||||||
|
jobId: job?.id,
|
||||||
|
format: normalizedFormat,
|
||||||
|
isSplitOutput,
|
||||||
|
inputPath
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSplitOutput) {
|
||||||
|
await this._encodeChapters({
|
||||||
|
job, ctx, ffmpegCommand, inputPath, chapterPlan,
|
||||||
|
normalizedFormat, normalizedOptions, metadata, activationBytes,
|
||||||
|
isCancelled, onProcessHandle
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this._encodeSingleFile({
|
||||||
|
job, ctx, ffmpegCommand, inputPath, outputPath,
|
||||||
|
normalizedFormat, normalizedOptions, metadata, activationBytes,
|
||||||
|
isCancelled, onProcessHandle
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.logger.info('audiobook:encode:done', {
|
||||||
|
jobId: job?.id,
|
||||||
|
format: normalizedFormat,
|
||||||
|
outputPath
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.extra.encodeResult = {
|
||||||
|
outputPath,
|
||||||
|
format: normalizedFormat,
|
||||||
|
chapterCount: isSplitOutput
|
||||||
|
? (chapterPlan?.outputFiles?.length || 0)
|
||||||
|
: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
return ctx.extra.encodeResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audiobooks brauchen keine Encode-Review (kein HandBrake).
|
||||||
|
*/
|
||||||
|
async review(_job, _ctx) {
|
||||||
|
_ctx.markExecution('review', {
|
||||||
|
pluginId: this.id,
|
||||||
|
pluginName: this.name,
|
||||||
|
pluginFile: 'AudiobookPlugin.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin-spezifische Settings für Audiobooks.
|
||||||
|
*/
|
||||||
|
getSettingsSchema() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'audiobook_encoding_enabled',
|
||||||
|
category: 'Features',
|
||||||
|
label: 'Audiobook Encoding',
|
||||||
|
type: 'boolean',
|
||||||
|
required: 1,
|
||||||
|
description: 'Audiobook-Encoding über das Plugin aktivieren.',
|
||||||
|
default_value: 'true',
|
||||||
|
options_json: '[]',
|
||||||
|
validation_json: '{}',
|
||||||
|
order_index: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ffmpeg_command',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'FFmpeg Kommando',
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.',
|
||||||
|
default_value: 'ffmpeg',
|
||||||
|
options_json: '[]',
|
||||||
|
validation_json: '{"minLength":1}',
|
||||||
|
order_index: 232
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ffprobe_command',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'FFprobe Kommando',
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.',
|
||||||
|
default_value: 'ffprobe',
|
||||||
|
options_json: '[]',
|
||||||
|
validation_json: '{"minLength":1}',
|
||||||
|
order_index: 233
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'output_template_audiobook',
|
||||||
|
category: 'Pfade',
|
||||||
|
label: 'Output Template (Audiobook)',
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner über "/".',
|
||||||
|
default_value: audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
|
||||||
|
options_json: '[]',
|
||||||
|
validation_json: '{"minLength":1}',
|
||||||
|
order_index: 735
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private Encode-Methoden ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async _encodeSingleFile({ job, ctx, ffmpegCommand, inputPath, outputPath, normalizedFormat, normalizedOptions, metadata, activationBytes, isCancelled, onProcessHandle }) {
|
||||||
|
ensureDir(path.dirname(outputPath));
|
||||||
|
|
||||||
|
const ffmpegConfig = audiobookService.buildEncodeCommand(
|
||||||
|
ffmpegCommand,
|
||||||
|
inputPath,
|
||||||
|
outputPath,
|
||||||
|
normalizedFormat,
|
||||||
|
normalizedOptions,
|
||||||
|
{ activationBytes, metadata }
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.logger.info('audiobook:encode:single-file', {
|
||||||
|
jobId: job?.id,
|
||||||
|
cmd: ffmpegConfig.cmd,
|
||||||
|
outputPath
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressParser = audiobookService.buildProgressParser(metadata?.durationMs || 0);
|
||||||
|
await _spawnAndWait({
|
||||||
|
cmd: ffmpegConfig.cmd,
|
||||||
|
args: ffmpegConfig.args,
|
||||||
|
jobId: job?.id,
|
||||||
|
progressParser,
|
||||||
|
onProgress: (pct) => ctx.emitProgress(Math.round(pct), 'Audiobook wird encodiert …'),
|
||||||
|
isCancelled,
|
||||||
|
onProcessHandle,
|
||||||
|
context: { jobId: job?.id, source: 'AudiobookPlugin' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _encodeChapters({ job, ctx, ffmpegCommand, inputPath, chapterPlan, normalizedFormat, normalizedOptions, metadata, activationBytes, isCancelled, onProcessHandle }) {
|
||||||
|
const outputFiles = Array.isArray(chapterPlan?.outputFiles) ? chapterPlan.outputFiles : [];
|
||||||
|
if (outputFiles.length === 0) {
|
||||||
|
throw new Error('AudiobookPlugin: Keine Kapitel im chapterPlan für kapitelweise Ausgabe.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < outputFiles.length; index++) {
|
||||||
|
_assertNotCancelled(isCancelled);
|
||||||
|
|
||||||
|
const entry = outputFiles[index];
|
||||||
|
const chapter = entry?.chapter || {};
|
||||||
|
const chapterTitle = String(chapter?.title || `Kapitel ${index + 1}`).trim();
|
||||||
|
const startPct = (index / outputFiles.length) * 100;
|
||||||
|
const endPct = ((index + 1) / outputFiles.length) * 100;
|
||||||
|
|
||||||
|
ensureDir(path.dirname(entry.outputPath));
|
||||||
|
|
||||||
|
const ffmpegConfig = audiobookService.buildChapterEncodeCommand(
|
||||||
|
ffmpegCommand,
|
||||||
|
inputPath,
|
||||||
|
entry.outputPath,
|
||||||
|
normalizedFormat,
|
||||||
|
normalizedOptions,
|
||||||
|
metadata,
|
||||||
|
chapter,
|
||||||
|
outputFiles.length,
|
||||||
|
{ activationBytes }
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.logger.info('audiobook:encode:chapter', {
|
||||||
|
jobId: job?.id,
|
||||||
|
chapterIndex: index + 1,
|
||||||
|
chapterTotal: outputFiles.length,
|
||||||
|
chapterTitle,
|
||||||
|
outputPath: entry.outputPath
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseParser = audiobookService.buildProgressParser(chapter?.durationMs || 0);
|
||||||
|
const scaledParser = baseParser
|
||||||
|
? (line) => {
|
||||||
|
const progress = baseParser(line);
|
||||||
|
if (!progress || progress.percent == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
percent: startPct + (endPct - startPct) * (progress.percent / 100),
|
||||||
|
eta: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
ctx.emitProgress(
|
||||||
|
Math.round(startPct),
|
||||||
|
`Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await _spawnAndWait({
|
||||||
|
cmd: ffmpegConfig.cmd,
|
||||||
|
args: ffmpegConfig.args,
|
||||||
|
jobId: job?.id,
|
||||||
|
progressParser: scaledParser,
|
||||||
|
onProgress: (pct) => ctx.emitProgress(
|
||||||
|
Math.round(pct),
|
||||||
|
`Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`
|
||||||
|
),
|
||||||
|
isCancelled,
|
||||||
|
onProcessHandle,
|
||||||
|
context: { jobId: job?.id, source: 'AudiobookPlugin', chapterIndex: index + 1 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hilfsfunktionen (privat) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _assertNotCancelled(isCancelled) {
|
||||||
|
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||||
|
const error = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _runCaptured(cmd, args, _context = {}) {
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
try {
|
||||||
|
return await execFileAsync(cmd, args, { timeout: 30000, maxBuffer: 8 * 1024 * 1024 });
|
||||||
|
} catch (error) {
|
||||||
|
// execFile wirft bei non-zero exit; stdout/stderr können trotzdem valide sein
|
||||||
|
return {
|
||||||
|
stdout: String(error?.stdout || ''),
|
||||||
|
stderr: String(error?.stderr || ''),
|
||||||
|
exitCode: error?.code ?? 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _spawnAndWait({ cmd, args, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
|
||||||
|
_assertNotCancelled(isCancelled);
|
||||||
|
|
||||||
|
const handle = spawnTrackedProcess({
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
onStdoutLine: () => {},
|
||||||
|
onStderrLine: (line) => {
|
||||||
|
if (progressParser && typeof onProgress === 'function') {
|
||||||
|
const progress = progressParser(line);
|
||||||
|
if (progress?.percent != null) {
|
||||||
|
onProgress(progress.percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
context: context || { jobId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof onProcessHandle === 'function') {
|
||||||
|
onProcessHandle(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||||
|
handle.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await handle.promise;
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||||
|
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||||
|
cancelError.statusCode = 409;
|
||||||
|
throw cancelError;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { AudiobookPlugin };
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { VideoDiscPlugin } = require('./VideoDiscPlugin');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source-Plugin für Blu-ray Discs.
|
||||||
|
*
|
||||||
|
* Erbt die gesamte Rip/Encode-Logik von VideoDiscPlugin.
|
||||||
|
* Überschreibt nur die Identifikations-Properties und detect().
|
||||||
|
*
|
||||||
|
* Erkennungsmerkmale:
|
||||||
|
* - mediaProfile === 'bluray'
|
||||||
|
* - Dateisystem: UDF (Versionen 2.5 / 2.6)
|
||||||
|
* - Laufwerksmodell enthält 'blu-ray', 'bdrom', 'bd-r', 'bd-re'
|
||||||
|
*/
|
||||||
|
class BluRayPlugin extends VideoDiscPlugin {
|
||||||
|
get id() {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return 'Blu-ray';
|
||||||
|
}
|
||||||
|
|
||||||
|
get mediaProfile() {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Höhere Priorität als DVD (10 > 5) damit ein UDF-Laufwerk mit
|
||||||
|
* blu-ray-Modell-Marker korrekt erkannt wird, auch wenn beide Plugins
|
||||||
|
* theoretisch auf UDF-Discs matchen könnten.
|
||||||
|
*/
|
||||||
|
get priority() {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt Blu-ray Discs.
|
||||||
|
* Prüft das vom DiskDetectionService gesetzte mediaProfile-Feld.
|
||||||
|
*
|
||||||
|
* @param {object} discInfo
|
||||||
|
* @param {string} [discInfo.mediaProfile] - 'bluray'
|
||||||
|
* @param {string} [discInfo.fstype] - 'udf'
|
||||||
|
* @param {string} [discInfo.driveModel]
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
detect(discInfo) {
|
||||||
|
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
|
||||||
|
if (profile === 'bluray' || profile === 'blu-ray' || profile === 'blu_ray') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Wenn mediaProfile explizit auf einen anderen Medientyp gesetzt ist
|
||||||
|
// (z.B. 'dvd' für eine DVD im Blu-ray-Laufwerk), dem DiskDetectionService
|
||||||
|
// vertrauen und nicht via Modell-Marker überschreiben.
|
||||||
|
if (profile) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Fallback: nur wenn kein mediaProfile bekannt ist.
|
||||||
|
// UDF-Dateisystem + Laufwerks-Modell enthält Blu-ray-Marker.
|
||||||
|
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
|
||||||
|
const model = String(discInfo?.driveModel || discInfo?.model || '').trim().toLowerCase();
|
||||||
|
if (fstype === 'udf' && /(blu[\s-]?ray|bd[\s_-]?rom|\bbd-?r\b|\bbd-?re\b|\bbdr\b)/i.test(model)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { BluRayPlugin };
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { SourcePlugin } = require('./PluginBase');
|
||||||
|
const cdRipService = require('../services/cdRipService');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source-Plugin für Audio-CDs.
|
||||||
|
*
|
||||||
|
* Delegiert die gesamte Arbeit an den bereits fertig ausgelagerten
|
||||||
|
* cdRipService — dieser Plugin ist ein dünner Adapter-Wrapper.
|
||||||
|
*
|
||||||
|
* CD-Besonderheit: Rip und Encode sind bei CDs Track-für-Track
|
||||||
|
* verzahnt (rip track → encode track → nächster Track).
|
||||||
|
* Deshalb ist encode() ein No-Op; die gesamte Arbeit liegt in rip().
|
||||||
|
*
|
||||||
|
* Erwartete ctx.extra-Felder für rip():
|
||||||
|
* ripConfig - Das ripConfig-Objekt aus dem API-Request (format, formatOptions,
|
||||||
|
* selectedTracks, tracks, metadata, ...)
|
||||||
|
* rawWavDir - Absoluter Pfad zum WAV-Temp-/RAW-Ordner
|
||||||
|
* outputDir - Absoluter Pfad zum finalen Ausgabeordner
|
||||||
|
* outputTemplate - CD-Output-Template String
|
||||||
|
* isCancelled - () => boolean — Abbruchsignal vom Orchestrator
|
||||||
|
* onProcessHandle - Callback mit dem Prozess-Handle (für Abbruch)
|
||||||
|
*/
|
||||||
|
class CdPlugin extends SourcePlugin {
|
||||||
|
get id() {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return 'Audio CD';
|
||||||
|
}
|
||||||
|
|
||||||
|
get priority() {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt Audio-CDs anhand des mediaProfile-Feldes, das vom
|
||||||
|
* DiskDetectionService / diskDetectionService.inferMediaProfile() gesetzt wird.
|
||||||
|
*
|
||||||
|
* Akzeptierte Werte: 'cd', 'audio_cd'
|
||||||
|
*/
|
||||||
|
detect(discInfo) {
|
||||||
|
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
|
||||||
|
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
|
||||||
|
return profile === 'cd' || profile === 'audio_cd' || fstype === 'audio_cd';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest das TOC der CD mit cdparanoia -Q und gibt die Trackliste zurück.
|
||||||
|
*
|
||||||
|
* @param {string} devicePath - z.B. '/dev/sr0'
|
||||||
|
* @param {object} job - Job-Record (id wird für Logging genutzt)
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<{tracks: Array, cdparanoiaCmd: string, detectedTitle: string}>}
|
||||||
|
*/
|
||||||
|
async analyze(devicePath, job, ctx) {
|
||||||
|
ctx.markExecution('analyze', {
|
||||||
|
pluginId: this.id,
|
||||||
|
pluginName: this.name,
|
||||||
|
pluginFile: 'CdPlugin.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await ctx.settings.getSettingsMap();
|
||||||
|
const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
|
||||||
|
const detectedTitle = String(
|
||||||
|
ctx.extra?.discInfo?.discLabel || ctx.extra?.discInfo?.label || ctx.extra?.discInfo?.model || 'Audio CD'
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
ctx.logger.info('cd:analyze:start', { devicePath, jobId: job?.id, detectedTitle });
|
||||||
|
|
||||||
|
const tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd);
|
||||||
|
|
||||||
|
if (!tracks.length) {
|
||||||
|
const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.logger.info('cd:analyze:done', { devicePath, jobId: job?.id, trackCount: tracks.length });
|
||||||
|
|
||||||
|
return {
|
||||||
|
tracks,
|
||||||
|
cdparanoiaCmd,
|
||||||
|
detectedTitle
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rippt und encodiert alle ausgewählten Tracks der CD.
|
||||||
|
*
|
||||||
|
* Bei CDs sind Rip und Encode Track-für-Track verzahnt — alles
|
||||||
|
* läuft hier in einem einzigen cdRipService.ripAndEncode()-Aufruf.
|
||||||
|
*
|
||||||
|
* Pflicht-Felder in ctx.extra:
|
||||||
|
* ripConfig - { format, formatOptions, selectedTracks, tracks, metadata, skipRip, skipEncode }
|
||||||
|
* rawWavDir - Pfad für temporäre WAV-Dateien (= RAW-Ordner)
|
||||||
|
* outputDir - Finaler Ausgabeordner (optional bei skipEncode=true)
|
||||||
|
* outputTemplate - Template-String für Dateinamen
|
||||||
|
* isCancelled - () => boolean
|
||||||
|
* onProcessHandle - (handle) => void [optional]
|
||||||
|
*
|
||||||
|
* @param {object} job
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<{outputDir, format, trackCount, encodeResults}>}
|
||||||
|
*/
|
||||||
|
async rip(job, ctx) {
|
||||||
|
ctx.markExecution('rip', {
|
||||||
|
pluginId: this.id,
|
||||||
|
pluginName: this.name,
|
||||||
|
pluginFile: 'CdPlugin.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
ripConfig = {},
|
||||||
|
rawWavDir,
|
||||||
|
outputDir,
|
||||||
|
outputTemplate,
|
||||||
|
isCancelled,
|
||||||
|
onProcessHandle,
|
||||||
|
onProgress: onRawProgress,
|
||||||
|
onLog: onExternalLog
|
||||||
|
} = ctx.extra || {};
|
||||||
|
|
||||||
|
if (!rawWavDir) {
|
||||||
|
throw new Error('CdPlugin.rip(): ctx.extra.rawWavDir fehlt');
|
||||||
|
}
|
||||||
|
const skipRip = Boolean(ripConfig?.skipRip);
|
||||||
|
const skipEncode = Boolean(ripConfig?.skipEncode);
|
||||||
|
|
||||||
|
if (!skipEncode && !outputDir) {
|
||||||
|
throw new Error('CdPlugin.rip(): ctx.extra.outputDir fehlt');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cdInfo = _safeParseJson(job?.makemkv_info_json) || {};
|
||||||
|
const settings = await ctx.settings.getSettingsMap();
|
||||||
|
const cdparanoiaCmd = String(cdInfo.cdparanoiaCmd || settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
|
||||||
|
const devicePath = String(job?.disc_device || '').trim();
|
||||||
|
if (!devicePath && !skipRip) {
|
||||||
|
throw new Error('CdPlugin.rip(): Kein CD-Gerätepfad im Job gefunden (disc_device leer).');
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = String(ripConfig.format || 'flac').trim().toLowerCase();
|
||||||
|
const formatOptions = ripConfig.formatOptions || {};
|
||||||
|
const effectiveTemplate = outputTemplate || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
|
||||||
|
|
||||||
|
// Trackliste: TOC-Tracks aus DB, optional mit Metadaten aus ripConfig überschrieben
|
||||||
|
const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : [];
|
||||||
|
const incomingTracks = Array.isArray(ripConfig.tracks) ? ripConfig.tracks : [];
|
||||||
|
const incomingByPos = new Map(
|
||||||
|
incomingTracks
|
||||||
|
.filter((t) => Number.isFinite(Number(t?.position)) && Number(t.position) > 0)
|
||||||
|
.map((t) => [Math.trunc(Number(t.position)), t])
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedTracks = tocTracks
|
||||||
|
.map((track) => {
|
||||||
|
const pos = Math.trunc(Number(track?.position));
|
||||||
|
if (!Number.isFinite(pos) || pos <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const incoming = incomingByPos.get(pos) || null;
|
||||||
|
return {
|
||||||
|
...track,
|
||||||
|
position: pos,
|
||||||
|
title: incoming?.title || track.title || `Track ${pos}`,
|
||||||
|
artist: incoming?.artist || track.artist || null,
|
||||||
|
selected: incoming ? Boolean(incoming.selected) : (track.selected !== false)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const selectedMeta = cdInfo.selectedMetadata || {};
|
||||||
|
const incomingMeta = (ripConfig.metadata && typeof ripConfig.metadata === 'object')
|
||||||
|
? ripConfig.metadata
|
||||||
|
: {};
|
||||||
|
const meta = {
|
||||||
|
title: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD',
|
||||||
|
artist: _normText(incomingMeta.artist) || _normText(selectedMeta.artist) || null,
|
||||||
|
year: _normYear(incomingMeta.year) ?? _normYear(selectedMeta.year) ?? _normYear(job?.year) ?? null,
|
||||||
|
album: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD'
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawSelectedPositions = Array.isArray(ripConfig.selectedTracks)
|
||||||
|
? ripConfig.selectedTracks
|
||||||
|
.map((v) => Math.trunc(Number(v)))
|
||||||
|
.filter((v) => Number.isFinite(v) && v > 0)
|
||||||
|
: [];
|
||||||
|
const selectedTrackPositions = rawSelectedPositions.length > 0
|
||||||
|
? rawSelectedPositions
|
||||||
|
: mergedTracks.filter((t) => t.selected !== false).map((t) => t.position);
|
||||||
|
|
||||||
|
ctx.logger.info('cd:rip:start', {
|
||||||
|
jobId: job?.id,
|
||||||
|
devicePath: devicePath || null,
|
||||||
|
skipRip,
|
||||||
|
skipEncode,
|
||||||
|
format,
|
||||||
|
trackCount: selectedTrackPositions.length
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await cdRipService.ripAndEncode({
|
||||||
|
jobId: job?.id,
|
||||||
|
devicePath: devicePath || null,
|
||||||
|
cdparanoiaCmd,
|
||||||
|
rawWavDir,
|
||||||
|
outputDir,
|
||||||
|
format,
|
||||||
|
formatOptions,
|
||||||
|
selectedTracks: selectedTrackPositions,
|
||||||
|
tracks: mergedTracks,
|
||||||
|
meta,
|
||||||
|
outputTemplate: effectiveTemplate,
|
||||||
|
skipRip,
|
||||||
|
skipEncode,
|
||||||
|
onProgress: (progressEvent) => {
|
||||||
|
if (typeof onRawProgress === 'function') {
|
||||||
|
onRawProgress(progressEvent);
|
||||||
|
}
|
||||||
|
const pct = Number(progressEvent?.percent ?? 0);
|
||||||
|
const phase = progressEvent?.phase === 'encode' ? 'Encodiere' : 'Rippe';
|
||||||
|
const trackIdx = progressEvent?.trackIndex || 1;
|
||||||
|
const trackTotal = progressEvent?.trackTotal || 1;
|
||||||
|
ctx.emitProgress(
|
||||||
|
Math.round(pct),
|
||||||
|
`${phase} Track ${trackIdx}/${trackTotal} …`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onLog: (level, msg) => {
|
||||||
|
if (ctx.logger[level]) {
|
||||||
|
ctx.logger[level](msg, { jobId: job?.id });
|
||||||
|
}
|
||||||
|
if (typeof onExternalLog === 'function') {
|
||||||
|
onExternalLog(level, msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onProcessHandle,
|
||||||
|
isCancelled,
|
||||||
|
context: { jobId: job?.id, source: 'CdPlugin' }
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.logger.info('cd:rip:done', {
|
||||||
|
jobId: job?.id,
|
||||||
|
format,
|
||||||
|
trackCount: result.trackCount,
|
||||||
|
outputDir: result.outputDir
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ergebnis in ctx.extra ablegen, damit der Orchestrator darauf zugreifen kann
|
||||||
|
ctx.extra.ripResult = result;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-Op: Bei CDs ist der Encode-Schritt bereits in rip() integriert.
|
||||||
|
*/
|
||||||
|
async encode(_job, _ctx) {
|
||||||
|
_ctx.markExecution('encode', {
|
||||||
|
pluginId: this.id,
|
||||||
|
pluginName: this.name,
|
||||||
|
pluginFile: 'CdPlugin.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
// CD Rip und Encode sind in rip() verzahnt — nichts zu tun
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CDs brauchen keine Encode-Review (kein HandBrake).
|
||||||
|
*/
|
||||||
|
async review(_job, _ctx) {
|
||||||
|
_ctx.markExecution('review', {
|
||||||
|
pluginId: this.id,
|
||||||
|
pluginName: this.name,
|
||||||
|
pluginFile: 'CdPlugin.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin-spezifische Settings für Audio CDs.
|
||||||
|
*/
|
||||||
|
getSettingsSchema() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'cdparanoia_command',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'cdparanoia Kommando',
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: 'Pfad oder Befehl für cdparanoia. Wird für Audio-CD-Ripping genutzt.',
|
||||||
|
default_value: 'cdparanoia',
|
||||||
|
options_json: '[]',
|
||||||
|
validation_json: '{"minLength":1}',
|
||||||
|
order_index: 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cd_output_template',
|
||||||
|
category: 'Pfade',
|
||||||
|
label: 'CD Output Template',
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: `Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {trackNr}, {title}. Unterordner über "/".`,
|
||||||
|
default_value: cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE,
|
||||||
|
options_json: '[]',
|
||||||
|
validation_json: '{"minLength":1}',
|
||||||
|
order_index: 730
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hilfsfunktionen (privat, nicht exportiert) ────────────────────────────────
|
||||||
|
|
||||||
|
function _safeParseJson(value) {
|
||||||
|
try {
|
||||||
|
return value ? JSON.parse(value) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normText(value) {
|
||||||
|
const s = String(value == null ? '' : value)
|
||||||
|
.normalize('NFC')
|
||||||
|
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
|
||||||
|
.replace(/\p{C}+/gu, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return s || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normYear(value) {
|
||||||
|
if (value === null || value === undefined || String(value).trim() === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CdPlugin };
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { VideoDiscPlugin } = require('./VideoDiscPlugin');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source-Plugin für DVD-Discs.
|
||||||
|
*
|
||||||
|
* Erbt die gesamte Rip/Encode-Logik von VideoDiscPlugin.
|
||||||
|
* Überschreibt nur die Identifikations-Properties und detect().
|
||||||
|
*
|
||||||
|
* Erkennungsmerkmale:
|
||||||
|
* - mediaProfile === 'dvd'
|
||||||
|
* - Dateisystem: UDF (1.02) oder ISO9660
|
||||||
|
* - Laufwerksmodell enthält 'dvd', aber KEIN Blu-ray-Marker
|
||||||
|
*/
|
||||||
|
class DVDPlugin extends VideoDiscPlugin {
|
||||||
|
get id() {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return 'DVD';
|
||||||
|
}
|
||||||
|
|
||||||
|
get mediaProfile() {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Niedrigere Priorität als Blu-ray (5 < 10), da Blu-ray-Laufwerke
|
||||||
|
* auch DVDs lesen können — BluRayPlugin soll für BD-Discs bevorzugt werden.
|
||||||
|
*/
|
||||||
|
get priority() {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt DVD-Discs.
|
||||||
|
* Prüft das vom DiskDetectionService gesetzte mediaProfile-Feld.
|
||||||
|
*
|
||||||
|
* @param {object} discInfo
|
||||||
|
* @param {string} [discInfo.mediaProfile] - 'dvd'
|
||||||
|
* @param {string} [discInfo.fstype] - 'udf' | 'iso9660'
|
||||||
|
* @param {string} [discInfo.driveModel]
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
detect(discInfo) {
|
||||||
|
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
|
||||||
|
if (profile === 'dvd') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fallback: ISO9660 oder UDF ohne Blu-ray-Modell-Marker
|
||||||
|
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
|
||||||
|
const model = String(discInfo?.driveModel || discInfo?.model || '').trim().toLowerCase();
|
||||||
|
const hasBlurayMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r\b|bd-re\b)/i.test(model);
|
||||||
|
if (hasBlurayMarker) {
|
||||||
|
return false; // Blu-ray-Laufwerk → BluRayPlugin übernimmt
|
||||||
|
}
|
||||||
|
if (fstype === 'iso9660' || (fstype === 'udf' && /dvd/i.test(model))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { DVDPlugin };
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstrakte Basisklasse für Source-Plugins.
|
||||||
|
* Jedes Plugin implementiert die Pipeline-Phasen für einen Medientyp.
|
||||||
|
*
|
||||||
|
* Lifecycle: detect() → analyze() → rip() → review() → encode() → finalize()
|
||||||
|
*
|
||||||
|
* Alle async-Methoden können einen Fehler werfen — der Orchestrator fängt
|
||||||
|
* diese ab und schreibt sie als Job-Fehler in die Datenbank.
|
||||||
|
*/
|
||||||
|
class SourcePlugin {
|
||||||
|
/**
|
||||||
|
* Eindeutiger Bezeichner des Plugins.
|
||||||
|
* Erlaubte Werte: 'bluray' | 'dvd' | 'cd' | 'audiobook' | eigener String
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
get id() {
|
||||||
|
throw new Error(`${this.constructor.name}: id nicht implementiert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anzeigename des Plugins (für Logs und UI).
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
get name() {
|
||||||
|
throw new Error(`${this.constructor.name}: name nicht implementiert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priorität bei detect()-Konflikten. Höhere Zahl = höhere Priorität.
|
||||||
|
* Standard: 0
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
get priority() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob dieses Plugin für die erkannte Disc/Datei zuständig ist.
|
||||||
|
* Wird vom PluginRegistry.findPlugin() aufgerufen.
|
||||||
|
*
|
||||||
|
* @param {object} discInfo - Disc-Informationen vom DiskDetectionService
|
||||||
|
* @param {string} [discInfo.devicePath]
|
||||||
|
* @param {string} [discInfo.discType] - 'bluray' | 'dvd' | 'cd' | 'data'
|
||||||
|
* @param {string} [discInfo.filesystem] - z.B. 'UDF', 'ISO9660', 'CDFS'
|
||||||
|
* @param {string} [discInfo.driveModel]
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
detect(discInfo) { // eslint-disable-line no-unused-vars
|
||||||
|
throw new Error(`${this.constructor.name}: detect() nicht implementiert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analysiert das Medium (z.B. makemkvcon info, cdparanoia -Q, ffprobe).
|
||||||
|
* Gibt die Rohdaten zurück, die der Orchestrator im Job speichert.
|
||||||
|
*
|
||||||
|
* @param {string} devicePath - Gerätepfad, z.B. '/dev/sr0'
|
||||||
|
* @param {object} job - Bestehender Job-Record aus der DB
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<object>} Analyse-Ergebnis
|
||||||
|
* Empfohlene Felder: { encodePlan, handbrakeInfo, rawDiscInfo, ... }
|
||||||
|
*/
|
||||||
|
async analyze(devicePath, job, ctx) { // eslint-disable-line no-unused-vars
|
||||||
|
throw new Error(`${this.constructor.name}: analyze() nicht implementiert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rippt das Medium in den RAW-Ordner.
|
||||||
|
* Fortschritt wird über ctx.emitProgress() gemeldet.
|
||||||
|
*
|
||||||
|
* @param {object} job
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async rip(job, ctx) { // eslint-disable-line no-unused-vars
|
||||||
|
throw new Error(`${this.constructor.name}: rip() nicht implementiert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bereitet die Encode-Review vor (MediaInfo-Analyse, Einstellungsvorschau).
|
||||||
|
* Optional — Plugins, die keine Review benötigen, können die Basis-Implementierung
|
||||||
|
* behalten (gibt null zurück, Orchestrator überspringt dann den Review-Schritt).
|
||||||
|
*
|
||||||
|
* @param {object} job
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<object|null>} Review-Daten oder null
|
||||||
|
*/
|
||||||
|
async review(job, ctx) { // eslint-disable-line no-unused-vars
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodiert die gerippten Dateien (HandBrake, ffmpeg, etc.).
|
||||||
|
* Fortschritt wird über ctx.emitProgress() gemeldet.
|
||||||
|
*
|
||||||
|
* @param {object} job
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async encode(job, ctx) { // eslint-disable-line no-unused-vars
|
||||||
|
throw new Error(`${this.constructor.name}: encode() nicht implementiert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abschlussarbeiten: Umbenennen, Aufräumen, Notifications, Post-Encode-Scripts usw.
|
||||||
|
* Optional — Default-Implementierung tut nichts.
|
||||||
|
*
|
||||||
|
* @param {object} job
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async finalize(job, ctx) { // eslint-disable-line no-unused-vars
|
||||||
|
// Default: kein Finalize-Schritt notwendig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abbruch-Handler: Laufende Prozesse beenden, temporäre Dateien aufräumen.
|
||||||
|
* Wird vom Orchestrator bei cancel() aufgerufen.
|
||||||
|
* Optional — Default-Implementierung tut nichts.
|
||||||
|
*
|
||||||
|
* @param {object} job
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async onCancel(job, ctx) { // eslint-disable-line no-unused-vars
|
||||||
|
// Default: nichts zu tun
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry-Handler: Zustand zurücksetzen vor einem erneuten Versuch.
|
||||||
|
* Wird vom Orchestrator vor retry() aufgerufen.
|
||||||
|
* Optional — Default-Implementierung tut nichts.
|
||||||
|
*
|
||||||
|
* @param {object} job
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async onRetry(job, ctx) { // eslint-disable-line no-unused-vars
|
||||||
|
// Default: nichts zu tun
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin-spezifische Settings-Schema-Einträge.
|
||||||
|
* Diese werden vom PluginRegistry.getSettingsSchemas() aggregiert
|
||||||
|
* und können zur Laufzeit in die DB eingetragen werden.
|
||||||
|
*
|
||||||
|
* Format je Eintrag:
|
||||||
|
* { key, category, label, type, required, description,
|
||||||
|
* default_value, options_json, validation_json, order_index }
|
||||||
|
*
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
getSettingsSchema() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SourcePlugin };
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
function normalizeExecutionStage(value) {
|
||||||
|
const stage = String(value || '').trim().toLowerCase();
|
||||||
|
return stage || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePluginExecutionState(value) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const pluginId = String(value.pluginId || '').trim().toLowerCase() || 'unknown';
|
||||||
|
const pluginName = String(value.pluginName || '').trim() || pluginId;
|
||||||
|
const pluginFile = String(value.pluginFile || '').trim() || null;
|
||||||
|
const markerSource = String(value.markerSource || '').trim().toLowerCase() || 'plugin-file';
|
||||||
|
const firstMarkedAt = String(value.firstMarkedAt || '').trim() || null;
|
||||||
|
const lastMarkedAt = String(value.lastMarkedAt || '').trim() || null;
|
||||||
|
const rawLastStage = String(value.lastStage || '').trim();
|
||||||
|
const lastStage = rawLastStage ? normalizeExecutionStage(rawLastStage) : null;
|
||||||
|
const jobIdRaw = Number(value.jobId);
|
||||||
|
const jobId = Number.isFinite(jobIdRaw) && jobIdRaw > 0 ? Math.trunc(jobIdRaw) : null;
|
||||||
|
const byStageRaw = value.byStage && typeof value.byStage === 'object' && !Array.isArray(value.byStage)
|
||||||
|
? value.byStage
|
||||||
|
: {};
|
||||||
|
const byStage = {};
|
||||||
|
for (const [stageKey, stageMeta] of Object.entries(byStageRaw)) {
|
||||||
|
const normalizedStage = normalizeExecutionStage(stageKey);
|
||||||
|
const count = Number(stageMeta?.count);
|
||||||
|
byStage[normalizedStage] = {
|
||||||
|
count: Number.isFinite(count) && count > 0 ? Math.trunc(count) : 1,
|
||||||
|
lastMarkedAt: String(stageMeta?.lastMarkedAt || '').trim() || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const explicitStages = Array.isArray(value.stages)
|
||||||
|
? value.stages.map((stage) => normalizeExecutionStage(stage)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const stages = Array.from(new Set([
|
||||||
|
...explicitStages,
|
||||||
|
...Object.keys(byStage),
|
||||||
|
...(lastStage ? [lastStage] : [])
|
||||||
|
]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
markerSource,
|
||||||
|
pluginId,
|
||||||
|
pluginName,
|
||||||
|
pluginFile,
|
||||||
|
jobId,
|
||||||
|
firstMarkedAt,
|
||||||
|
lastMarkedAt,
|
||||||
|
lastStage,
|
||||||
|
stages,
|
||||||
|
byStage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePluginExecutionState(existingState, marker) {
|
||||||
|
const existing = normalizePluginExecutionState(existingState);
|
||||||
|
const normalizedMarker = marker && typeof marker === 'object' ? marker : null;
|
||||||
|
if (!normalizedMarker) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = normalizeExecutionStage(normalizedMarker.stage);
|
||||||
|
const markedAt = String(normalizedMarker.markedAt || '').trim() || new Date().toISOString();
|
||||||
|
const previousStageMeta = existing?.byStage?.[stage] || null;
|
||||||
|
const nextStageCount = Number(previousStageMeta?.count || 0) + 1;
|
||||||
|
const nextByStage = {
|
||||||
|
...(existing?.byStage || {}),
|
||||||
|
[stage]: {
|
||||||
|
count: nextStageCount,
|
||||||
|
lastMarkedAt: markedAt
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const nextStages = Array.from(new Set([
|
||||||
|
...(Array.isArray(existing?.stages) ? existing.stages : []),
|
||||||
|
stage
|
||||||
|
]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
markerSource: 'plugin-file',
|
||||||
|
pluginId: String(normalizedMarker.pluginId || existing?.pluginId || '').trim().toLowerCase() || 'unknown',
|
||||||
|
pluginName: String(normalizedMarker.pluginName || existing?.pluginName || '').trim()
|
||||||
|
|| String(normalizedMarker.pluginId || existing?.pluginId || '').trim()
|
||||||
|
|| 'unknown',
|
||||||
|
pluginFile: String(normalizedMarker.pluginFile || existing?.pluginFile || '').trim() || null,
|
||||||
|
jobId: Number.isFinite(Number(normalizedMarker.jobId)) && Number(normalizedMarker.jobId) > 0
|
||||||
|
? Math.trunc(Number(normalizedMarker.jobId))
|
||||||
|
: (existing?.jobId || null),
|
||||||
|
firstMarkedAt: existing?.firstMarkedAt || markedAt,
|
||||||
|
lastMarkedAt: markedAt,
|
||||||
|
lastStage: stage,
|
||||||
|
stages: nextStages,
|
||||||
|
byStage: nextByStage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kontext-Objekt, das an alle Plugin-Methoden weitergegeben wird.
|
||||||
|
* Kapselt den Zugriff auf alle Services, die ein Plugin benötigt,
|
||||||
|
* ohne direkte Abhängigkeiten auf Singletons zu erzwingen.
|
||||||
|
*
|
||||||
|
* Wird vom PluginOrchestrator befüllt und ist read-only für Plugins.
|
||||||
|
*/
|
||||||
|
class PluginContext {
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* @param {object} options.settings - settingsService (mit get(), getAll() etc.)
|
||||||
|
* @param {object} options.db - SQLite-Datenbankinstanz (getDb())
|
||||||
|
* @param {object} options.logger - Logger-Instanz (child-Logger empfohlen)
|
||||||
|
* @param {object} options.websocket - websocketService (broadcast() etc.)
|
||||||
|
* @param {object} options.processRunner - processRunner (spawnTrackedProcess etc.)
|
||||||
|
* @param {Function} options.emitProgress - (progress: number, statusText: string, eta?: number) => void
|
||||||
|
* @param {Function} options.emitState - (newState: string, context?: object) => void
|
||||||
|
* @param {Function} options.onPluginExecution - Callback für echte Plugin-Datei-Ausführung
|
||||||
|
* @param {object} [options.extra] - Beliebige plugin-spezifische Extras
|
||||||
|
*/
|
||||||
|
constructor({
|
||||||
|
settings,
|
||||||
|
db,
|
||||||
|
logger,
|
||||||
|
websocket,
|
||||||
|
processRunner,
|
||||||
|
emitProgress,
|
||||||
|
emitState,
|
||||||
|
onPluginExecution,
|
||||||
|
extra = {}
|
||||||
|
} = {}) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.db = db;
|
||||||
|
this.logger = logger;
|
||||||
|
this.websocket = websocket;
|
||||||
|
this.processRunner = processRunner;
|
||||||
|
this.emitProgress = typeof emitProgress === 'function' ? emitProgress : () => {};
|
||||||
|
this.emitState = typeof emitState === 'function' ? emitState : () => {};
|
||||||
|
this.onPluginExecution = typeof onPluginExecution === 'function' ? onPluginExecution : () => {};
|
||||||
|
this.extra = extra;
|
||||||
|
this.pluginExecution = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
markExecution(stage, payload = {}) {
|
||||||
|
const jobIdRaw = Number(payload?.jobId ?? this.extra?.jobId ?? this.extra?.job?.id ?? 0);
|
||||||
|
const marker = {
|
||||||
|
markerSource: 'plugin-file',
|
||||||
|
pluginId: String(payload?.pluginId || this.extra?.pluginId || '').trim().toLowerCase() || 'unknown',
|
||||||
|
pluginName: String(payload?.pluginName || '').trim()
|
||||||
|
|| String(payload?.pluginId || this.extra?.pluginId || '').trim()
|
||||||
|
|| 'unknown',
|
||||||
|
pluginFile: String(payload?.pluginFile || '').trim() || null,
|
||||||
|
jobId: Number.isFinite(jobIdRaw) && jobIdRaw > 0 ? Math.trunc(jobIdRaw) : null,
|
||||||
|
stage: normalizeExecutionStage(stage),
|
||||||
|
markedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pluginExecution = mergePluginExecutionState(this.pluginExecution, marker);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.onPluginExecution(marker, this.getPluginExecution());
|
||||||
|
} catch (error) {
|
||||||
|
if (this.logger && typeof this.logger.warn === 'function') {
|
||||||
|
this.logger.warn('plugin:execution:callback-failed', {
|
||||||
|
pluginId: marker.pluginId,
|
||||||
|
pluginFile: marker.pluginFile,
|
||||||
|
stage: marker.stage,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getPluginExecution();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPluginExecution() {
|
||||||
|
const normalized = normalizePluginExecutionState(this.pluginExecution);
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
stages: [...normalized.stages],
|
||||||
|
byStage: Object.fromEntries(
|
||||||
|
Object.entries(normalized.byStage || {}).map(([stage, meta]) => [
|
||||||
|
stage,
|
||||||
|
{
|
||||||
|
count: Number(meta?.count || 1),
|
||||||
|
lastMarkedAt: meta?.lastMarkedAt || null
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PluginContext };
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { SourcePlugin } = require('./PluginBase');
|
||||||
|
const logger = require('../services/logger').child('PLUGIN-REGISTRY');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry für alle Source-Plugins.
|
||||||
|
* Plugins werden beim Start registriert und anhand von detect() ausgewählt.
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* const { registry } = require('./PluginRegistry');
|
||||||
|
* registry.register(new BluRayPlugin());
|
||||||
|
* const plugin = registry.findPlugin(discInfo); // → BluRayPlugin | null
|
||||||
|
*/
|
||||||
|
class PluginRegistry {
|
||||||
|
constructor() {
|
||||||
|
/** @type {Map<string, SourcePlugin>} */
|
||||||
|
this._plugins = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registriert ein Plugin.
|
||||||
|
* Wirft einen Fehler wenn das übergebene Objekt keine SourcePlugin-Instanz ist.
|
||||||
|
* Überschreibt ein bereits vorhandenes Plugin mit gleicher ID (mit Warning).
|
||||||
|
*
|
||||||
|
* @param {SourcePlugin} plugin
|
||||||
|
*/
|
||||||
|
register(plugin) {
|
||||||
|
if (!(plugin instanceof SourcePlugin)) {
|
||||||
|
throw new Error(
|
||||||
|
`Plugin muss eine Instanz von SourcePlugin sein: ${plugin?.constructor?.name || typeof plugin}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const id = plugin.id;
|
||||||
|
if (!id || typeof id !== 'string') {
|
||||||
|
throw new Error(`Plugin muss eine nicht-leere String-ID haben (got: ${JSON.stringify(id)})`);
|
||||||
|
}
|
||||||
|
if (this._plugins.has(id)) {
|
||||||
|
logger.warn('registry:plugin-overwrite', { id, existingName: this._plugins.get(id).name });
|
||||||
|
}
|
||||||
|
this._plugins.set(id, plugin);
|
||||||
|
logger.info('registry:plugin-registered', { id, name: plugin.name, priority: plugin.priority });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Findet das passende Plugin für eine erkannte Disc.
|
||||||
|
*
|
||||||
|
* Alle Plugins werden nach priority (absteigend) sortiert. Das erste,
|
||||||
|
* dessen detect() true zurückgibt, wird verwendet.
|
||||||
|
* Fehler in detect() werden geloggt und ignoriert (Plugin übersprungen).
|
||||||
|
*
|
||||||
|
* @param {object} discInfo - Disc-Informationen vom DiskDetectionService
|
||||||
|
* @returns {SourcePlugin|null} Passendes Plugin oder null wenn keines matched
|
||||||
|
*/
|
||||||
|
findPlugin(discInfo) {
|
||||||
|
const sorted = [...this._plugins.values()]
|
||||||
|
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||||
|
|
||||||
|
for (const plugin of sorted) {
|
||||||
|
try {
|
||||||
|
if (plugin.detect(discInfo)) {
|
||||||
|
logger.debug('registry:plugin-matched', { id: plugin.id, discType: discInfo?.discType });
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('registry:detect-error', {
|
||||||
|
id: plugin.id,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('registry:no-plugin-matched', { discType: discInfo?.discType, filesystem: discInfo?.filesystem });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt ein Plugin anhand seiner ID zurück.
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {SourcePlugin|null}
|
||||||
|
*/
|
||||||
|
getPlugin(id) {
|
||||||
|
return this._plugins.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt alle registrierten Plugins zurück (Reihenfolge: Registrierungsreihenfolge).
|
||||||
|
*
|
||||||
|
* @returns {SourcePlugin[]}
|
||||||
|
*/
|
||||||
|
getAllPlugins() {
|
||||||
|
return [...this._plugins.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregiert die Settings-Schemata aller registrierten Plugins.
|
||||||
|
* Kann genutzt werden, um Plugin-Settings dynamisch in die DB einzutragen.
|
||||||
|
*
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
getSettingsSchemas() {
|
||||||
|
const schemas = [];
|
||||||
|
for (const plugin of this._plugins.values()) {
|
||||||
|
try {
|
||||||
|
const pluginSchemas = plugin.getSettingsSchema();
|
||||||
|
if (Array.isArray(pluginSchemas) && pluginSchemas.length > 0) {
|
||||||
|
schemas.push(...pluginSchemas);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('registry:schema-error', {
|
||||||
|
id: plugin.id,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return schemas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die Anzahl der registrierten Plugins zurück.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
get size() {
|
||||||
|
return this._plugins.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modul-Level-Singleton — wird beim Start einmalig befüllt.
|
||||||
|
const registry = new PluginRegistry();
|
||||||
|
|
||||||
|
module.exports = { PluginRegistry, registry };
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const { SourcePlugin } = require('./PluginBase');
|
||||||
|
const omdbService = require('../services/omdbService');
|
||||||
|
const { spawnTrackedProcess } = require('../services/processRunner');
|
||||||
|
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
|
||||||
|
const { ensureDir } = require('../utils/files');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsame Basisklasse für Blu-ray und DVD Plugins.
|
||||||
|
*
|
||||||
|
* Implementiert die gemeinsame Pipeline-Logik für alle Video-Disc-Medien:
|
||||||
|
* analyze() → OMDB-Suche + Disc-Metadaten vorbereiten
|
||||||
|
* review() → HandBrake-Scan (liefert rohe Scandaten für den Orchestrator)
|
||||||
|
* rip() → makemkvcon backup/mkv
|
||||||
|
* encode() → HandBrakeCLI
|
||||||
|
*
|
||||||
|
* Subklassen müssen implementieren:
|
||||||
|
* get id() → 'bluray' | 'dvd'
|
||||||
|
* get name() → Anzeigename
|
||||||
|
* get mediaProfile() → 'bluray' | 'dvd'
|
||||||
|
* detect(discInfo) → boolean
|
||||||
|
*
|
||||||
|
* ctx.extra-Felder für rip():
|
||||||
|
* rawJobDir - Absoluter Pfad des RAW-Ausgabeordners
|
||||||
|
* deviceInfo - { path, index, mediaProfile } vom DiskDetectionService
|
||||||
|
* selectedTitleId - MakeMKV-Titel-ID (optional, nur für mkv-Modus)
|
||||||
|
* ripMode - 'backup' | 'mkv' (Default: aus Settings)
|
||||||
|
* backupOutputBase - Basisname für DVD-Backup (nur für backup+DVD)
|
||||||
|
* isCancelled - () => boolean
|
||||||
|
* onProcessHandle - (handle) => void [optional]
|
||||||
|
*
|
||||||
|
* ctx.extra-Felder für encode():
|
||||||
|
* inputPath - Absoluter Pfad zur gerippten Quelldatei/Verzeichnis
|
||||||
|
* outputPath - Absoluter Pfad des fertigen Ausgabefiles (inkl. Temp-Präfix)
|
||||||
|
* encodePlan - { handBrakeTitleId, trackSelection, userPreset } aus confirm-Review
|
||||||
|
* isCancelled - () => boolean
|
||||||
|
* onProcessHandle - (handle) => void [optional]
|
||||||
|
*
|
||||||
|
* ctx.extra-Felder für review():
|
||||||
|
* deviceInfo - Disc-Device (für Pre-Rip-Scan) ODER
|
||||||
|
* rawJobDir - RAW-Ordner (für Post-Rip-Scan)
|
||||||
|
* reviewMode - 'pre_rip' | 'rip' (Default: 'pre_rip')
|
||||||
|
*/
|
||||||
|
class VideoDiscPlugin extends SourcePlugin {
|
||||||
|
/**
|
||||||
|
* Gibt das Media-Profil zurück, das settingsService-Methoden verwenden.
|
||||||
|
* Muss von Subklassen implementiert werden.
|
||||||
|
* @returns {'bluray'|'dvd'}
|
||||||
|
*/
|
||||||
|
get mediaProfile() {
|
||||||
|
throw new Error(`${this.constructor.name}: mediaProfile nicht implementiert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt eine OMDB-Suche für den erkannten Disc-Titel durch.
|
||||||
|
* Gibt { detectedTitle, omdbCandidates } zurück.
|
||||||
|
*
|
||||||
|
* @param {string} devicePath
|
||||||
|
* @param {object} job - Vorbereiteter Job-Record (noch ohne Metadaten)
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<{detectedTitle: string, omdbCandidates: Array}>}
|
||||||
|
*/
|
||||||
|
async analyze(devicePath, job, ctx) {
|
||||||
|
ctx.markExecution('analyze', {
|
||||||
|
pluginId: this.id,
|
||||||
|
pluginName: this.name,
|
||||||
|
pluginFile: 'VideoDiscPlugin.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
const discInfo = ctx.extra?.discInfo || {};
|
||||||
|
const detectedTitle = String(
|
||||||
|
discInfo.discLabel || discInfo.label || discInfo.model || job?.detected_title || 'Unknown Disc'
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
ctx.logger.info(`${this.id}:analyze:start`, { devicePath, jobId: job?.id, detectedTitle });
|
||||||
|
|
||||||
|
const omdbCandidates = await omdbService.search(detectedTitle).catch((error) => {
|
||||||
|
ctx.logger.warn(`${this.id}:analyze:omdb-failed`, {
|
||||||
|
jobId: job?.id,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.logger.info(`${this.id}:analyze:done`, {
|
||||||
|
jobId: job?.id,
|
||||||
|
detectedTitle,
|
||||||
|
omdbCandidates: omdbCandidates.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return { detectedTitle, omdbCandidates };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt einen HandBrake-Scan durch und gibt die rohen Scandaten zurück.
|
||||||
|
* Der Orchestrator übernimmt die komplexe Auswertung (buildDiscScanReview).
|
||||||
|
*
|
||||||
|
* ctx.extra.reviewMode:
|
||||||
|
* 'pre_rip' → Scan direkt vom Laufwerk (deviceInfo)
|
||||||
|
* 'rip' → Scan aus dem RAW-Ordner (rawJobDir)
|
||||||
|
*
|
||||||
|
* @param {object} job
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<{scanLines: string[], runInfo: object}|null>}
|
||||||
|
*/
|
||||||
|
async review(job, ctx) {
|
||||||
|
ctx.markExecution('review', {
|
||||||
|
pluginId: this.id,
|
||||||
|
pluginName: this.name,
|
||||||
|
pluginFile: 'VideoDiscPlugin.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewMode = String(ctx.extra?.reviewMode || 'pre_rip').trim().toLowerCase();
|
||||||
|
const deviceInfo = ctx.extra?.deviceInfo || null;
|
||||||
|
const rawJobDir = ctx.extra?.rawJobDir || null;
|
||||||
|
|
||||||
|
ctx.logger.info(`${this.id}:review:start`, {
|
||||||
|
jobId: job?.id,
|
||||||
|
reviewMode,
|
||||||
|
deviceInfo: deviceInfo?.path || null,
|
||||||
|
rawJobDir: rawJobDir || null
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
|
||||||
|
|
||||||
|
let scanConfig;
|
||||||
|
if (reviewMode === 'rip' && rawJobDir) {
|
||||||
|
// Post-Rip scan: scan from the ripped RAW folder
|
||||||
|
scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(rawJobDir, {
|
||||||
|
mediaProfile: this.mediaProfile,
|
||||||
|
settingsMap: settings
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Pre-Rip scan: scan directly from disc device
|
||||||
|
scanConfig = await ctx.settings.buildHandBrakeScanConfig(deviceInfo, {
|
||||||
|
mediaProfile: this.mediaProfile,
|
||||||
|
settingsMap: settings
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.logger.info(`${this.id}:review:scan-command`, {
|
||||||
|
jobId: job?.id,
|
||||||
|
cmd: scanConfig.cmd,
|
||||||
|
args: scanConfig.args
|
||||||
|
});
|
||||||
|
|
||||||
|
const scanLines = [];
|
||||||
|
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
|
||||||
|
const reviewStage = String(ctx.extra?.reviewStage || 'MEDIAINFO_CHECK').trim().toUpperCase() || 'MEDIAINFO_CHECK';
|
||||||
|
const reviewSource = String(ctx.extra?.reviewSource || 'HANDBRAKE_SCAN').trim().toUpperCase() || 'HANDBRAKE_SCAN';
|
||||||
|
const reviewSilent = Boolean(ctx.extra?.reviewSilent);
|
||||||
|
let runInfo;
|
||||||
|
if (runCommandFn) {
|
||||||
|
try {
|
||||||
|
runInfo = await runCommandFn({
|
||||||
|
jobId: job?.id,
|
||||||
|
stage: reviewStage,
|
||||||
|
source: reviewSource,
|
||||||
|
cmd: scanConfig.cmd,
|
||||||
|
args: scanConfig.args,
|
||||||
|
collectLines: scanLines,
|
||||||
|
collectStderrLines: false,
|
||||||
|
silent: reviewSilent
|
||||||
|
});
|
||||||
|
} catch (cmdError) {
|
||||||
|
// runCommand collected stdout lines via callbacks before throwing.
|
||||||
|
// Recover the runInfo from the error so the caller can still parse
|
||||||
|
// whatever output HandBrakeCLI produced (e.g. exit code 2 with valid JSON).
|
||||||
|
runInfo = cmdError?.runInfo || {
|
||||||
|
status: 'ERROR',
|
||||||
|
exitCode: typeof cmdError?.code === 'number' ? cmdError.code : 1,
|
||||||
|
errorMessage: cmdError?.message || String(cmdError)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
runInfo = await _runCommandCaptured({
|
||||||
|
cmd: scanConfig.cmd,
|
||||||
|
args: scanConfig.args,
|
||||||
|
onStdoutLine: (line) => scanLines.push(line),
|
||||||
|
context: { jobId: job?.id, source: `${this.id}Plugin.review` }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.logger.info(`${this.id}:review:scan-done`, {
|
||||||
|
jobId: job?.id,
|
||||||
|
exitCode: runInfo.exitCode,
|
||||||
|
lineCount: scanLines.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return raw scan data — the Orchestrator (or legacy pipelineService)
|
||||||
|
// processes this with buildDiscScanReview() / parseMediainfoJsonOutput().
|
||||||
|
return {
|
||||||
|
scanLines,
|
||||||
|
runInfo,
|
||||||
|
reviewMode,
|
||||||
|
mediaProfile: this.mediaProfile,
|
||||||
|
sourceArg: scanConfig.sourceArg || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rippt die Disc mit makemkvcon in den rawJobDir.
|
||||||
|
*
|
||||||
|
* @param {object} job
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<object>} runInfo vom makemkvcon-Prozess
|
||||||
|
*/
|
||||||
|
async rip(job, ctx) {
|
||||||
|
ctx.markExecution('rip', {
|
||||||
|
pluginId: this.id,
|
||||||
|
pluginName: this.name,
|
||||||
|
pluginFile: 'VideoDiscPlugin.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
rawJobDir,
|
||||||
|
deviceInfo,
|
||||||
|
selectedTitleId = null,
|
||||||
|
backupOutputBase = null,
|
||||||
|
isCancelled,
|
||||||
|
onProcessHandle
|
||||||
|
} = ctx.extra || {};
|
||||||
|
|
||||||
|
if (!rawJobDir) {
|
||||||
|
throw new Error(`${this.constructor.name}.rip(): ctx.extra.rawJobDir fehlt`);
|
||||||
|
}
|
||||||
|
if (!deviceInfo) {
|
||||||
|
throw new Error(`${this.constructor.name}.rip(): ctx.extra.deviceInfo fehlt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDir(rawJobDir);
|
||||||
|
|
||||||
|
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
|
||||||
|
const ripConfig = await ctx.settings.buildMakeMKVRipConfig(rawJobDir, deviceInfo, {
|
||||||
|
selectedTitleId,
|
||||||
|
mediaProfile: this.mediaProfile,
|
||||||
|
settingsMap: settings,
|
||||||
|
backupOutputBase
|
||||||
|
});
|
||||||
|
|
||||||
|
const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv')
|
||||||
|
.trim().toLowerCase() === 'backup' ? 'backup' : 'mkv';
|
||||||
|
|
||||||
|
ctx.logger.info(`${this.id}:rip:start`, {
|
||||||
|
jobId: job?.id,
|
||||||
|
cmd: ripConfig.cmd,
|
||||||
|
args: ripConfig.args,
|
||||||
|
ripMode,
|
||||||
|
rawJobDir,
|
||||||
|
selectedTitleId
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.emitProgress(0, ripMode === 'backup'
|
||||||
|
? `${this.name}: Backup läuft …`
|
||||||
|
: `${this.name}: Ripping läuft …`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pipeline-integrierter Pfad: ctx.extra.runCommand delegiert an this.runCommand()
|
||||||
|
// des PipelineService und liefert das volle runInfo (inkl. highlights, stdout-Log,
|
||||||
|
// Prozess-Tracking, Cancellation). Fallback: _spawnAndWait für Standalone/Tests.
|
||||||
|
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
|
||||||
|
let runInfo;
|
||||||
|
if (runCommandFn) {
|
||||||
|
runInfo = await runCommandFn({
|
||||||
|
jobId: job?.id,
|
||||||
|
stage: 'RIPPING',
|
||||||
|
source: 'MAKEMKV_RIP',
|
||||||
|
cmd: ripConfig.cmd,
|
||||||
|
args: ripConfig.args,
|
||||||
|
parser: parseMakeMkvProgress
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
runInfo = await _spawnAndWait({
|
||||||
|
cmd: ripConfig.cmd,
|
||||||
|
args: ripConfig.args,
|
||||||
|
cwd: rawJobDir,
|
||||||
|
jobId: job?.id,
|
||||||
|
progressParser: parseMakeMkvProgress,
|
||||||
|
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText),
|
||||||
|
isCancelled,
|
||||||
|
onProcessHandle,
|
||||||
|
context: { jobId: job?.id, source: `${this.id}Plugin.rip`, stage: 'RIPPING' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.logger.info(`${this.id}:rip:done`, {
|
||||||
|
jobId: job?.id,
|
||||||
|
rawJobDir,
|
||||||
|
exitCode: runInfo?.exitCode ?? runInfo?.code ?? null
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.extra.ripRunInfo = runInfo;
|
||||||
|
return runInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodiert die gerippten Dateien mit HandBrakeCLI.
|
||||||
|
*
|
||||||
|
* @param {object} job
|
||||||
|
* @param {PluginContext} ctx
|
||||||
|
* @returns {Promise<object>} runInfo vom HandBrake-Prozess
|
||||||
|
*/
|
||||||
|
async encode(job, ctx) {
|
||||||
|
ctx.markExecution('encode', {
|
||||||
|
pluginId: this.id,
|
||||||
|
pluginName: this.name,
|
||||||
|
pluginFile: 'VideoDiscPlugin.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
inputPath,
|
||||||
|
outputPath,
|
||||||
|
encodePlan = {},
|
||||||
|
isCancelled,
|
||||||
|
onProcessHandle
|
||||||
|
} = ctx.extra || {};
|
||||||
|
|
||||||
|
if (!inputPath) {
|
||||||
|
throw new Error(`${this.constructor.name}.encode(): ctx.extra.inputPath fehlt`);
|
||||||
|
}
|
||||||
|
if (!outputPath) {
|
||||||
|
throw new Error(`${this.constructor.name}.encode(): ctx.extra.outputPath fehlt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDir(path.dirname(outputPath));
|
||||||
|
|
||||||
|
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
|
||||||
|
const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, {
|
||||||
|
trackSelection: encodePlan.trackSelection || null,
|
||||||
|
titleId: encodePlan.handBrakeTitleId || null,
|
||||||
|
mediaProfile: this.mediaProfile,
|
||||||
|
settingsMap: settings,
|
||||||
|
userPreset: encodePlan.userPreset || null
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.logger.info(`${this.id}:encode:start`, {
|
||||||
|
jobId: job?.id,
|
||||||
|
cmd: handBrakeConfig.cmd,
|
||||||
|
args: handBrakeConfig.args,
|
||||||
|
titleId: encodePlan.handBrakeTitleId || null
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.emitProgress(0, `${this.name}: Encoding läuft …`);
|
||||||
|
|
||||||
|
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
|
||||||
|
const encodeSource = String(ctx.extra?.encodeSource || 'HANDBRAKE').trim().toUpperCase() || 'HANDBRAKE';
|
||||||
|
const encodeStage = String(ctx.extra?.encodeStage || 'ENCODING').trim().toUpperCase() || 'ENCODING';
|
||||||
|
const encodeParser = typeof ctx.extra?.progressParser === 'function'
|
||||||
|
? ctx.extra.progressParser
|
||||||
|
: parseHandBrakeProgress;
|
||||||
|
|
||||||
|
let runInfo;
|
||||||
|
if (runCommandFn) {
|
||||||
|
runInfo = await runCommandFn({
|
||||||
|
jobId: job?.id,
|
||||||
|
stage: encodeStage,
|
||||||
|
source: encodeSource,
|
||||||
|
cmd: handBrakeConfig.cmd,
|
||||||
|
args: handBrakeConfig.args,
|
||||||
|
parser: encodeParser
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
runInfo = await _spawnAndWait({
|
||||||
|
cmd: handBrakeConfig.cmd,
|
||||||
|
args: handBrakeConfig.args,
|
||||||
|
jobId: job?.id,
|
||||||
|
progressParser: parseHandBrakeProgress,
|
||||||
|
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText || `${this.name}: Encoding ${pct}%`),
|
||||||
|
isCancelled,
|
||||||
|
onProcessHandle,
|
||||||
|
context: { jobId: job?.id, source: `${this.id}Plugin.encode`, stage: 'ENCODING' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.logger.info(`${this.id}:encode:done`, {
|
||||||
|
jobId: job?.id,
|
||||||
|
outputPath,
|
||||||
|
exitCode: runInfo.exitCode
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.extra.encodeRunInfo = runInfo;
|
||||||
|
return runInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin-spezifische Settings (gemeinsam für BluRay + DVD, via this.mediaProfile).
|
||||||
|
*/
|
||||||
|
getSettingsSchema() {
|
||||||
|
const profile = this.mediaProfile;
|
||||||
|
const label = profile === 'bluray' ? 'Blu-ray' : 'DVD';
|
||||||
|
const orderBase = profile === 'bluray' ? 200 : 220;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: `handbrake_preset_${profile}`,
|
||||||
|
category: 'Tools',
|
||||||
|
label: `HandBrake Preset (${label})`,
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: `Preset Name für -Z (${label}). Leer = kein Preset, nur CLI-Parameter werden verwendet.`,
|
||||||
|
default_value: null,
|
||||||
|
options_json: '[]',
|
||||||
|
validation_json: '{}',
|
||||||
|
order_index: orderBase
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `output_extension_${profile}`,
|
||||||
|
category: 'Tools',
|
||||||
|
label: `Ausgabe-Format (${label})`,
|
||||||
|
type: 'select',
|
||||||
|
required: 1,
|
||||||
|
description: `Dateiendung des encodierten ${label}-Outputs.`,
|
||||||
|
default_value: 'mkv',
|
||||||
|
options_json: '["mkv","mp4"]',
|
||||||
|
validation_json: '{}',
|
||||||
|
order_index: orderBase + 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `output_template_${profile}`,
|
||||||
|
category: 'Pfade',
|
||||||
|
label: `Output Template (${label})`,
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: `Template für relative ${label}-Ausgabepfade ohne Dateiendung. Platzhalter: {title}, {year}. Unterordner über "/".`,
|
||||||
|
default_value: '{title} ({year})/{title} ({year})',
|
||||||
|
options_json: '[]',
|
||||||
|
validation_json: '{"minLength":1}',
|
||||||
|
order_index: 700 + (profile === 'bluray' ? 0 : 5)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hilfsfunktionen (privat) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _assertNotCancelled(isCancelled) {
|
||||||
|
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||||
|
const error = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _runCommandCaptured({ cmd, args, onStdoutLine, context }) {
|
||||||
|
const lines = [];
|
||||||
|
const handle = spawnTrackedProcess({
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
onStdoutLine: (line) => {
|
||||||
|
lines.push(line);
|
||||||
|
if (typeof onStdoutLine === 'function') {
|
||||||
|
onStdoutLine(line);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStderrLine: () => {},
|
||||||
|
context: context || {}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handle.promise;
|
||||||
|
return { exitCode: 0, lines };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
exitCode: typeof error?.code === 'number' ? error.code : 1,
|
||||||
|
lines,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _spawnAndWait({ cmd, args, cwd, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
|
||||||
|
_assertNotCancelled(isCancelled);
|
||||||
|
|
||||||
|
const handle = spawnTrackedProcess({
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
cwd,
|
||||||
|
onStdoutLine: (line) => {
|
||||||
|
if (progressParser && typeof onProgress === 'function') {
|
||||||
|
const progress = progressParser(line);
|
||||||
|
if (progress?.percent != null) {
|
||||||
|
onProgress(progress.percent, progress.statusText || null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStderrLine: (line) => {
|
||||||
|
if (progressParser && typeof onProgress === 'function') {
|
||||||
|
const progress = progressParser(line);
|
||||||
|
if (progress?.percent != null) {
|
||||||
|
onProgress(progress.percent, progress.statusText || null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
context: context || { jobId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof onProcessHandle === 'function') {
|
||||||
|
onProcessHandle(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||||
|
handle.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await handle.promise;
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||||
|
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||||
|
cancelError.statusCode = 409;
|
||||||
|
throw cancelError;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { VideoDiscPlugin };
|
||||||
@@ -30,12 +30,14 @@ router.post(
|
|||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const jobId = Number(req.params.jobId);
|
const jobId = Number(req.params.jobId);
|
||||||
const target = String(req.body?.target || 'raw').trim();
|
const target = String(req.body?.target || 'raw').trim();
|
||||||
|
const outputPath = String(req.body?.outputPath || '').trim() || null;
|
||||||
logger.info('post:downloads:history', {
|
logger.info('post:downloads:history', {
|
||||||
reqId: req.reqId,
|
reqId: req.reqId,
|
||||||
jobId,
|
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({
|
res.status(result.created ? 201 : 200).json({
|
||||||
...result,
|
...result,
|
||||||
summary: downloadService.getSummary()
|
summary: downloadService.getSummary()
|
||||||
|
|||||||
@@ -148,16 +148,44 @@ router.post(
|
|||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const target = String(req.body?.target || 'none');
|
const target = String(req.body?.target || 'none');
|
||||||
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
||||||
|
const resetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
|
||||||
|
const keepDetectedDevice = req.body?.keepDetectedDevice !== undefined
|
||||||
|
? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase())
|
||||||
|
: !resetDriveState;
|
||||||
|
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
|
||||||
|
? req.body.selectedMoviePaths
|
||||||
|
.map((item) => String(item || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: null;
|
||||||
|
|
||||||
logger.warn('post:delete-job', {
|
logger.warn('post:delete-job', {
|
||||||
reqId: req.reqId,
|
reqId: req.reqId,
|
||||||
id,
|
id,
|
||||||
target,
|
target,
|
||||||
includeRelated
|
includeRelated,
|
||||||
|
resetDriveState,
|
||||||
|
keepDetectedDevice,
|
||||||
|
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await historyService.deleteJob(id, target, { includeRelated });
|
const preview = resetDriveState
|
||||||
const uiReset = await pipelineService.resetFrontendState('history_delete');
|
? await historyService.getJobDeletePreview(id, { includeRelated })
|
||||||
|
: null;
|
||||||
|
const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
|
||||||
|
? preview.relatedJobs
|
||||||
|
.map((row) => String(row?.discDevice || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const result = await historyService.deleteJob(id, target, { includeRelated, selectedMoviePaths });
|
||||||
|
await pipelineService.onJobsDeleted(result?.deletedJobIds || [id], {
|
||||||
|
resetDriveState,
|
||||||
|
devicePaths: relatedDevicePaths
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.warn('post:delete-job:cleanup-failed', { id, error: error?.message || String(error) });
|
||||||
|
});
|
||||||
|
const uiReset = await pipelineService.resetFrontendState('history_delete', {
|
||||||
|
keepDetectedDevice
|
||||||
|
});
|
||||||
res.json({ ...result, uiReset });
|
res.json({ ...result, uiReset });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const path = require('path');
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const asyncHandler = require('../middleware/asyncHandler');
|
const asyncHandler = require('../middleware/asyncHandler');
|
||||||
const pipelineService = require('../services/pipelineService');
|
const pipelineService = require('../services/pipelineService');
|
||||||
|
const historyService = require('../services/historyService');
|
||||||
const diskDetectionService = require('../services/diskDetectionService');
|
const diskDetectionService = require('../services/diskDetectionService');
|
||||||
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||||
const logger = require('../services/logger').child('PIPELINE_ROUTE');
|
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(
|
router.post(
|
||||||
'/reencode/:jobId',
|
'/reencode/:jobId',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const jobId = Number(req.params.jobId);
|
const jobId = Number(req.params.jobId);
|
||||||
logger.info('post:reencode', { reqId: req.reqId, jobId });
|
const keepBoth = Boolean(req.body?.keepBoth);
|
||||||
const result = await pipelineService.reencodeFromRaw(jobId);
|
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 });
|
res.json({ result });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -360,8 +383,10 @@ router.post(
|
|||||||
'/restart-review/:jobId',
|
'/restart-review/:jobId',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const jobId = Number(req.params.jobId);
|
const jobId = Number(req.params.jobId);
|
||||||
logger.info('post:restart-review', { reqId: req.reqId, jobId });
|
const keepBoth = Boolean(req.body?.keepBoth);
|
||||||
const result = await pipelineService.restartReviewFromRaw(jobId);
|
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 });
|
res.json({ result });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -370,8 +395,22 @@ router.post(
|
|||||||
'/restart-encode/:jobId',
|
'/restart-encode/:jobId',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const jobId = Number(req.params.jobId);
|
const jobId = Number(req.params.jobId);
|
||||||
logger.info('post:restart-encode', { reqId: req.reqId, jobId });
|
const keepBoth = Boolean(req.body?.keepBoth);
|
||||||
const result = await pipelineService.restartEncodeWithLastSettings(jobId);
|
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 });
|
res.json({ result });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const hardwareMonitorService = require('../services/hardwareMonitorService');
|
|||||||
const userPresetService = require('../services/userPresetService');
|
const userPresetService = require('../services/userPresetService');
|
||||||
const activationBytesService = require('../services/activationBytesService');
|
const activationBytesService = require('../services/activationBytesService');
|
||||||
const diskDetectionService = require('../services/diskDetectionService');
|
const diskDetectionService = require('../services/diskDetectionService');
|
||||||
|
const coverArtRecoveryService = require('../services/coverArtRecoveryService');
|
||||||
const logger = require('../services/logger').child('SETTINGS_ROUTE');
|
const logger = require('../services/logger').child('SETTINGS_ROUTE');
|
||||||
|
|
||||||
const { getDb } = require('../db/database');
|
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(
|
router.put(
|
||||||
'/:key',
|
'/:key',
|
||||||
asyncHandler(async (req, res) => {
|
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);
|
wsService.broadcast('SETTINGS_UPDATED', updated);
|
||||||
|
|
||||||
res.json({ setting: updated, reviewRefresh });
|
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) });
|
wsService.broadcast('SETTINGS_BULK_UPDATED', { count: changes.length, keys: changes.map((item) => item.key) });
|
||||||
|
|
||||||
res.json({ changes, reviewRefresh });
|
res.json({ changes, reviewRefresh });
|
||||||
@@ -414,8 +454,10 @@ router.get(
|
|||||||
.map((d) => {
|
.map((d) => {
|
||||||
const name = String(d.name || '');
|
const name = String(d.name || '');
|
||||||
const devicePath = d.path || (name ? `/dev/${name}` : null);
|
const devicePath = d.path || (name ? `/dev/${name}` : null);
|
||||||
const match = name.match(/sr(\d+)$/);
|
const resolvedIndex = Number(d.makemkvIndex);
|
||||||
const discIndex = match ? Number(match[1]) : null;
|
const discIndex = Number.isFinite(resolvedIndex) && resolvedIndex >= 0
|
||||||
|
? Math.trunc(resolvedIndex)
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
path: devicePath,
|
path: devicePath,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -22,13 +22,16 @@ const DEFAULT_CD_OUTPUT_TEMPLATE = '{artist} - {album} ({year})/{trackNr} {artis
|
|||||||
function parseToc(tocOutput) {
|
function parseToc(tocOutput) {
|
||||||
const lines = String(tocOutput || '').split(/\r?\n/);
|
const lines = String(tocOutput || '').split(/\r?\n/);
|
||||||
const tracks = [];
|
const tracks = [];
|
||||||
|
const tocTimePattern = /[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/g;
|
||||||
|
const tocTablePattern =
|
||||||
|
/^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/i;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i);
|
const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i);
|
||||||
if (trackMatch) {
|
if (trackMatch) {
|
||||||
const position = Number(trackMatch[1]);
|
const position = Number(trackMatch[1]);
|
||||||
const payloadWithoutTimes = String(trackMatch[2] || '')
|
const payloadWithoutTimes = String(trackMatch[2] || '')
|
||||||
.replace(/[\(\[]\s*\d+:\d+\.\d+\s*[\)\]]/g, ' ');
|
.replace(tocTimePattern, ' ');
|
||||||
const sectorValues = payloadWithoutTimes.match(/\d+/g) || [];
|
const sectorValues = payloadWithoutTimes.match(/\d+/g) || [];
|
||||||
if (sectorValues.length < 2) {
|
if (sectorValues.length < 2) {
|
||||||
continue;
|
continue;
|
||||||
@@ -58,9 +61,7 @@ function parseToc(tocOutput) {
|
|||||||
// Alternative cdparanoia -Q table style:
|
// Alternative cdparanoia -Q table style:
|
||||||
// 1. 16503 [03:40.03] 0 [00:00.00] no no 2
|
// 1. 16503 [03:40.03] 0 [00:00.00] no no 2
|
||||||
// ^ length sectors ^ start sector
|
// ^ length sectors ^ start sector
|
||||||
const tableMatch = line.match(
|
const tableMatch = line.match(tocTablePattern);
|
||||||
/^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]/i
|
|
||||||
);
|
|
||||||
if (!tableMatch) {
|
if (!tableMatch) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -244,7 +245,19 @@ function outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir) {
|
|||||||
}
|
}
|
||||||
const offset = outputSegments.length - relativeSegments.length;
|
const offset = outputSegments.length - relativeSegments.length;
|
||||||
for (let i = 0; i < relativeSegments.length; i++) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,6 +396,7 @@ async function runProcessTracked({
|
|||||||
* @param {object[]} options.tracks - TOC track list [{position, durationMs, title}]
|
* @param {object[]} options.tracks - TOC track list [{position, durationMs, title}]
|
||||||
* @param {object} options.meta - album metadata {title, artist, year}
|
* @param {object} options.meta - album metadata {title, artist, year}
|
||||||
* @param {string} options.outputTemplate - template for relative output path without extension
|
* @param {string} options.outputTemplate - template for relative output path without extension
|
||||||
|
* @param {boolean} options.skipEncode - true => rip only (no encode)
|
||||||
* @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void
|
* @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void
|
||||||
* @param {Function} options.onLog - (level, msg) => void
|
* @param {Function} options.onLog - (level, msg) => void
|
||||||
* @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration
|
* @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration
|
||||||
@@ -406,10 +420,12 @@ async function ripAndEncode(options) {
|
|||||||
onLog,
|
onLog,
|
||||||
onProcessHandle,
|
onProcessHandle,
|
||||||
isCancelled,
|
isCancelled,
|
||||||
context
|
context,
|
||||||
|
skipRip = false,
|
||||||
|
skipEncode = false
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
if (!SUPPORTED_FORMATS.has(format)) {
|
if (!skipEncode && !SUPPORTED_FORMATS.has(format)) {
|
||||||
throw new Error(`Unbekanntes Ausgabeformat: ${format}`);
|
throw new Error(`Unbekanntes Ausgabeformat: ${format}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +438,9 @@ async function ripAndEncode(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ensureDir(rawWavDir);
|
await ensureDir(rawWavDir);
|
||||||
await ensureDir(outputDir);
|
if (!skipEncode) {
|
||||||
|
await ensureDir(outputDir);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('rip:start', {
|
logger.info('rip:start', {
|
||||||
jobId,
|
jobId,
|
||||||
@@ -435,8 +453,20 @@ async function ripAndEncode(options) {
|
|||||||
logger[level] && logger[level](msg, { jobId });
|
logger[level] && logger[level](msg, { jobId });
|
||||||
onLog && onLog(level, msg);
|
onLog && onLog(level, msg);
|
||||||
};
|
};
|
||||||
|
const ripPercentSpan = skipEncode ? 100 : 50;
|
||||||
|
const encodePercentStart = ripPercentSpan;
|
||||||
|
const encodePercentSpan = Math.max(0, 100 - encodePercentStart);
|
||||||
|
|
||||||
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
|
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
|
||||||
|
if (skipRip) {
|
||||||
|
// Encode-only: WAV-Dateien müssen bereits vorhanden sein
|
||||||
|
for (const track of tracksToRip) {
|
||||||
|
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
|
||||||
|
if (!fs.existsSync(wavFile)) {
|
||||||
|
throw new Error(`Encode-only: WAV-Datei fehlt für Track ${track.position} (${wavFile})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
for (let i = 0; i < tracksToRip.length; i++) {
|
for (let i = 0; i < tracksToRip.length; i++) {
|
||||||
assertNotCancelled(isCancelled);
|
assertNotCancelled(isCancelled);
|
||||||
const track = tracksToRip[i];
|
const track = tracksToRip[i];
|
||||||
@@ -450,7 +480,7 @@ async function ripAndEncode(options) {
|
|||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
trackPercent: 0,
|
trackPercent: 0,
|
||||||
percent: (i / tracksToRip.length) * 50
|
percent: (i / tracksToRip.length) * ripPercentSpan
|
||||||
});
|
});
|
||||||
|
|
||||||
log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`);
|
log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`);
|
||||||
@@ -464,7 +494,7 @@ async function ripAndEncode(options) {
|
|||||||
onStderrLine(line) {
|
onStderrLine(line) {
|
||||||
const parsed = parseCdParanoiaProgress(line);
|
const parsed = parseCdParanoiaProgress(line);
|
||||||
if (parsed && parsed.percent !== null) {
|
if (parsed && parsed.percent !== null) {
|
||||||
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50;
|
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * ripPercentSpan;
|
||||||
onProgress && onProgress({
|
onProgress && onProgress({
|
||||||
phase: 'rip',
|
phase: 'rip',
|
||||||
trackEvent: 'progress',
|
trackEvent: 'progress',
|
||||||
@@ -496,11 +526,21 @@ async function ripAndEncode(options) {
|
|||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
trackPercent: 100,
|
trackPercent: 100,
|
||||||
percent: ((i + 1) / tracksToRip.length) * 50
|
percent: ((i + 1) / tracksToRip.length) * ripPercentSpan
|
||||||
});
|
});
|
||||||
|
|
||||||
log('info', `Track ${track.position} gerippt.`);
|
log('info', `Track ${track.position} gerippt.`);
|
||||||
}
|
}
|
||||||
|
} // end if (!skipRip)
|
||||||
|
|
||||||
|
if (skipEncode) {
|
||||||
|
return {
|
||||||
|
outputDir: null,
|
||||||
|
format: 'raw',
|
||||||
|
trackCount: tracksToRip.length,
|
||||||
|
encodeResults: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
||||||
const encodeResults = [];
|
const encodeResults = [];
|
||||||
@@ -519,7 +559,7 @@ async function ripAndEncode(options) {
|
|||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
trackPercent: 0,
|
trackPercent: 0,
|
||||||
percent: 50 + ((i / tracksToRip.length) * 50)
|
percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
|
||||||
});
|
});
|
||||||
ensureDir(path.dirname(outFile));
|
ensureDir(path.dirname(outFile));
|
||||||
log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
|
log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
|
||||||
@@ -531,7 +571,7 @@ async function ripAndEncode(options) {
|
|||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
trackPercent: 100,
|
trackPercent: 100,
|
||||||
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
|
||||||
});
|
});
|
||||||
log('info', `WAV für Track ${track.position} gespeichert.`);
|
log('info', `WAV für Track ${track.position} gespeichert.`);
|
||||||
encodeResults.push({ position: track.position, title: track.title || '', outputFile: path.basename(outFile), success: true });
|
encodeResults.push({ position: track.position, title: track.title || '', outputFile: path.basename(outFile), success: true });
|
||||||
@@ -558,7 +598,7 @@ async function ripAndEncode(options) {
|
|||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
trackPercent: 0,
|
trackPercent: 0,
|
||||||
percent: 50 + ((i / tracksToRip.length) * 50)
|
percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
|
||||||
});
|
});
|
||||||
|
|
||||||
log('info', `Encodiere Track ${track.position} → ${outFilename} …`);
|
log('info', `Encodiere Track ${track.position} → ${outFilename} …`);
|
||||||
@@ -605,7 +645,7 @@ async function ripAndEncode(options) {
|
|||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
trackPercent: 100,
|
trackPercent: 100,
|
||||||
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
|
||||||
});
|
});
|
||||||
|
|
||||||
log('info', `Track ${track.position} encodiert.`);
|
log('info', `Track ${track.position} encodiert.`);
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -52,6 +52,40 @@ function flattenDevices(nodes, acc = []) {
|
|||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOpticalDevicePath(entry) {
|
||||||
|
const directPath = String(entry?.path || '').trim();
|
||||||
|
if (directPath) {
|
||||||
|
return directPath;
|
||||||
|
}
|
||||||
|
const name = String(entry?.name || '').trim();
|
||||||
|
return name ? `/dev/${name}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareOpticalDevicePaths(left, right) {
|
||||||
|
return String(left || '').localeCompare(String(right || ''), undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMakeMkvIndexByDevicePath(entries = []) {
|
||||||
|
const candidates = (Array.isArray(entries) ? entries : [])
|
||||||
|
.filter((entry) => String(entry?.type || '').trim() === 'rom')
|
||||||
|
.map((entry) => normalizeOpticalDevicePath(entry))
|
||||||
|
.filter(Boolean);
|
||||||
|
const sortedUniquePaths = Array.from(new Set(candidates)).sort(compareOpticalDevicePaths);
|
||||||
|
const map = new Map();
|
||||||
|
for (let index = 0; index < sortedUniquePaths.length; index += 1) {
|
||||||
|
const devicePath = sortedUniquePaths[index];
|
||||||
|
map.set(devicePath, index);
|
||||||
|
const devName = devicePath.startsWith('/dev/') ? devicePath.slice(5) : '';
|
||||||
|
if (devName) {
|
||||||
|
map.set(devName, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
function buildSignature(info) {
|
function buildSignature(info) {
|
||||||
return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}|${info.mediaProfile || ''}`;
|
return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}|${info.mediaProfile || ''}`;
|
||||||
}
|
}
|
||||||
@@ -159,6 +193,11 @@ function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isIsoLikeFsType(rawFsType) {
|
||||||
|
const fstype = String(rawFsType || '').trim().toLowerCase();
|
||||||
|
return fstype.includes('iso9660') || fstype.includes('cdfs');
|
||||||
|
}
|
||||||
|
|
||||||
function inferMediaProfileFromUdevProperties(properties = {}) {
|
function inferMediaProfileFromUdevProperties(properties = {}) {
|
||||||
const flags = Object.entries(properties).reduce((acc, [key, rawValue]) => {
|
const flags = Object.entries(properties).reduce((acc, [key, rawValue]) => {
|
||||||
const normalizedKey = String(key || '').trim().toUpperCase();
|
const normalizedKey = String(key || '').trim().toUpperCase();
|
||||||
@@ -170,14 +209,44 @@ function inferMediaProfileFromUdevProperties(properties = {}) {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const hasFlag = (prefix) => Object.entries(flags).some(([key, value]) => key.startsWith(prefix) && value === '1');
|
const parseTrackCount = (rawValue) => {
|
||||||
if (hasFlag('ID_CDROM_MEDIA_BD')) {
|
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 hasExactFlag = (key) => flags[String(key || '').trim().toUpperCase()] === '1';
|
||||||
|
// Only use exact media-presence keys here. Prefix matching would also catch
|
||||||
|
// drive capability flags (e.g. ID_CDROM_MEDIA_BD_R=1) and misclassify DVDs
|
||||||
|
// in BD-capable drives as Blu-ray media.
|
||||||
|
const hasBD = hasExactFlag('ID_CDROM_MEDIA_BD');
|
||||||
|
const hasDVD = hasExactFlag('ID_CDROM_MEDIA_DVD');
|
||||||
|
const hasCD = hasExactFlag('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';
|
return 'bluray';
|
||||||
}
|
}
|
||||||
if (hasFlag('ID_CDROM_MEDIA_DVD')) {
|
if (hasDVD) {
|
||||||
return 'dvd';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
if (hasFlag('ID_CDROM_MEDIA_CD')) {
|
if (hasCD) {
|
||||||
return 'cd';
|
return 'cd';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -363,6 +432,28 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
forceUnlockDevice(devicePath, options = {}) {
|
||||||
|
const normalized = this.normalizeDevicePath(devicePath);
|
||||||
|
if (!normalized) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = this.deviceLocks.get(normalized);
|
||||||
|
if (!entry) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousCount = Math.max(0, Number(entry.count) || 0);
|
||||||
|
this.deviceLocks.delete(normalized);
|
||||||
|
logger.warn('lock:force-remove', {
|
||||||
|
devicePath: normalized,
|
||||||
|
previousCount,
|
||||||
|
reason: String(options?.reason || '').trim() || null,
|
||||||
|
deletedJobIds: Array.isArray(options?.deletedJobIds) ? options.deletedJobIds : []
|
||||||
|
});
|
||||||
|
return previousCount;
|
||||||
|
}
|
||||||
|
|
||||||
isDeviceLocked(devicePath) {
|
isDeviceLocked(devicePath) {
|
||||||
const normalized = this.normalizeDevicePath(devicePath);
|
const normalized = this.normalizeDevicePath(devicePath);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -479,6 +570,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;
|
return autoResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,6 +599,19 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return { present: false, emitted: 'none', device: null };
|
return { present: false, emitted: 'none', device: null };
|
||||||
}
|
}
|
||||||
|
if (this.isDeviceLocked(normalized)) {
|
||||||
|
const existing = this.detectedDiscs.get(normalized) || null;
|
||||||
|
logger.info('rescan-drive:skip-locked', {
|
||||||
|
devicePath: normalized,
|
||||||
|
activeLocks: this.getActiveLocks()
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
present: Boolean(existing),
|
||||||
|
emitted: 'none',
|
||||||
|
device: existing,
|
||||||
|
locked: true
|
||||||
|
};
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
logger.info('rescan-drive:requested', { devicePath: normalized });
|
logger.info('rescan-drive:requested', { devicePath: normalized });
|
||||||
const detected = await this.detectExplicit(normalized);
|
const detected = await this.detectExplicit(normalized);
|
||||||
@@ -551,6 +675,16 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve currently tracked locked devices that are intentionally skipped
|
||||||
|
// by detectAll* while a rip lock is active.
|
||||||
|
for (const [devicePath, device] of this.detectedDiscs) {
|
||||||
|
if (newMap.has(devicePath) || !this.isDeviceLocked(devicePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
newMap.set(devicePath, device);
|
||||||
|
results.push({ path: devicePath, emitted: 'none', device, locked: true });
|
||||||
|
}
|
||||||
|
|
||||||
// Check for removed devices
|
// Check for removed devices
|
||||||
for (const [devicePath, device] of this.detectedDiscs) {
|
for (const [devicePath, device] of this.detectedDiscs) {
|
||||||
if (!newMap.has(devicePath)) {
|
if (!newMap.has(devicePath)) {
|
||||||
@@ -590,16 +724,36 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const details = await this.getBlockDeviceInfo();
|
||||||
|
const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details);
|
||||||
|
const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {};
|
||||||
|
const inferredIndex = Number(
|
||||||
|
makemkvIndexByPath.get(devicePath)
|
||||||
|
?? makemkvIndexByPath.get(match.name || '')
|
||||||
|
?? match.makemkvIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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);
|
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 });
|
logger.debug('detect:explicit:no-media', { devicePath });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!mediaState.hasMedia && hasSizeMedia) {
|
||||||
|
logger.debug('detect:explicit:media-by-size-fallback', { devicePath, sizeBytes: match.sizeBytes });
|
||||||
|
}
|
||||||
|
const mediaType = String(mediaState.type || '').trim().toLowerCase() || null;
|
||||||
const discLabel = await this.getDiscLabel(devicePath);
|
const discLabel = await this.getDiscLabel(devicePath);
|
||||||
|
// Preserve explicit audio-CD detection from checkMediaPresent even if lsblk
|
||||||
const details = await this.getBlockDeviceInfo();
|
// reports ambiguous optical fs markers like iso9660/cdfs.
|
||||||
const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {};
|
const detectedFsType = String(
|
||||||
const detectedFsType = String(match.fstype || mediaState.type || '').trim() || null;
|
mediaType === 'audio_cd'
|
||||||
|
? mediaType
|
||||||
|
: (match.fstype || mediaType || '')
|
||||||
|
).trim() || null;
|
||||||
|
|
||||||
const mediaProfile = await this.inferMediaProfile(devicePath, {
|
const mediaProfile = await this.inferMediaProfile(devicePath, {
|
||||||
discLabel,
|
discLabel,
|
||||||
@@ -619,7 +773,9 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
mountpoint: match.mountpoint || null,
|
mountpoint: match.mountpoint || null,
|
||||||
fstype: detectedFsType,
|
fstype: detectedFsType,
|
||||||
mediaProfile: mediaProfile || null,
|
mediaProfile: mediaProfile || null,
|
||||||
index: this.guessDiscIndex(match.name || devicePath)
|
index: Number.isFinite(inferredIndex) && inferredIndex >= 0
|
||||||
|
? Math.trunc(inferredIndex)
|
||||||
|
: this.guessDiscIndex(match.name || devicePath)
|
||||||
};
|
};
|
||||||
logger.debug('detect:explicit:success', { detected });
|
logger.debug('detect:explicit:success', { detected });
|
||||||
return detected;
|
return detected;
|
||||||
@@ -627,6 +783,7 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
|
|
||||||
async detectAuto() {
|
async detectAuto() {
|
||||||
const details = await this.getBlockDeviceInfo();
|
const details = await this.getBlockDeviceInfo();
|
||||||
|
const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details);
|
||||||
const romCandidates = details.filter((entry) => entry.type === 'rom');
|
const romCandidates = details.filter((entry) => entry.type === 'rom');
|
||||||
|
|
||||||
for (const item of romCandidates) {
|
for (const item of romCandidates) {
|
||||||
@@ -647,8 +804,13 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
if (!mediaState.hasMedia) {
|
if (!mediaState.hasMedia) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const mediaType = String(mediaState.type || '').trim().toLowerCase() || null;
|
||||||
const discLabel = await this.getDiscLabel(path);
|
const discLabel = await this.getDiscLabel(path);
|
||||||
const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null;
|
const detectedFsType = String(
|
||||||
|
mediaType === 'audio_cd'
|
||||||
|
? mediaType
|
||||||
|
: (item.fstype || mediaType || '')
|
||||||
|
).trim() || null;
|
||||||
|
|
||||||
const mediaProfile = await this.inferMediaProfile(path, {
|
const mediaProfile = await this.inferMediaProfile(path, {
|
||||||
discLabel,
|
discLabel,
|
||||||
@@ -657,6 +819,11 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
fstype: detectedFsType,
|
fstype: detectedFsType,
|
||||||
mountpoint: item.mountpoint
|
mountpoint: item.mountpoint
|
||||||
});
|
});
|
||||||
|
const detectedIndex = Number(
|
||||||
|
makemkvIndexByPath.get(path)
|
||||||
|
?? makemkvIndexByPath.get(item.name || '')
|
||||||
|
?? item.makemkvIndex
|
||||||
|
);
|
||||||
|
|
||||||
const detected = {
|
const detected = {
|
||||||
mode: 'auto',
|
mode: 'auto',
|
||||||
@@ -668,7 +835,9 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
mountpoint: item.mountpoint || null,
|
mountpoint: item.mountpoint || null,
|
||||||
fstype: detectedFsType,
|
fstype: detectedFsType,
|
||||||
mediaProfile: mediaProfile || null,
|
mediaProfile: mediaProfile || null,
|
||||||
index: this.guessDiscIndex(item.name)
|
index: Number.isFinite(detectedIndex) && detectedIndex >= 0
|
||||||
|
? Math.trunc(detectedIndex)
|
||||||
|
: this.guessDiscIndex(item.name)
|
||||||
};
|
};
|
||||||
logger.debug('detect:auto:success', { detected });
|
logger.debug('detect:auto:success', { detected });
|
||||||
return detected;
|
return detected;
|
||||||
@@ -680,6 +849,7 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
|
|
||||||
async detectAllAuto() {
|
async detectAllAuto() {
|
||||||
const details = await this.getBlockDeviceInfo();
|
const details = await this.getBlockDeviceInfo();
|
||||||
|
const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details);
|
||||||
const romCandidates = details.filter((entry) => entry.type === 'rom');
|
const romCandidates = details.filter((entry) => entry.type === 'rom');
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
@@ -697,12 +867,25 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
continue;
|
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);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!mediaState.hasMedia && hasSizeMedia) {
|
||||||
|
logger.debug('detect:all-auto:media-by-size-fallback', { path, sizeBytes: item.sizeBytes });
|
||||||
|
}
|
||||||
|
const mediaType = String(mediaState.type || '').trim().toLowerCase() || null;
|
||||||
const discLabel = await this.getDiscLabel(path);
|
const discLabel = await this.getDiscLabel(path);
|
||||||
const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null;
|
const detectedFsType = String(
|
||||||
|
mediaType === 'audio_cd'
|
||||||
|
? mediaType
|
||||||
|
: (item.fstype || mediaType || '')
|
||||||
|
).trim() || null;
|
||||||
|
|
||||||
const mediaProfile = await this.inferMediaProfile(path, {
|
const mediaProfile = await this.inferMediaProfile(path, {
|
||||||
discLabel,
|
discLabel,
|
||||||
@@ -711,6 +894,11 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
fstype: detectedFsType,
|
fstype: detectedFsType,
|
||||||
mountpoint: item.mountpoint
|
mountpoint: item.mountpoint
|
||||||
});
|
});
|
||||||
|
const detectedIndex = Number(
|
||||||
|
makemkvIndexByPath.get(path)
|
||||||
|
?? makemkvIndexByPath.get(item.name || '')
|
||||||
|
?? item.makemkvIndex
|
||||||
|
);
|
||||||
|
|
||||||
const detected = {
|
const detected = {
|
||||||
mode: 'auto',
|
mode: 'auto',
|
||||||
@@ -722,7 +910,9 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
mountpoint: item.mountpoint || null,
|
mountpoint: item.mountpoint || null,
|
||||||
fstype: detectedFsType,
|
fstype: detectedFsType,
|
||||||
mediaProfile: mediaProfile || null,
|
mediaProfile: mediaProfile || null,
|
||||||
index: this.guessDiscIndex(item.name)
|
index: Number.isFinite(detectedIndex) && detectedIndex >= 0
|
||||||
|
? Math.trunc(detectedIndex)
|
||||||
|
: this.guessDiscIndex(item.name)
|
||||||
};
|
};
|
||||||
logger.debug('detect:all-auto:found', { detected });
|
logger.debug('detect:all-auto:found', { detected });
|
||||||
results.push(detected);
|
results.push(detected);
|
||||||
@@ -736,8 +926,9 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync('lsblk', [
|
const { stdout } = await execFileAsync('lsblk', [
|
||||||
'-J',
|
'-J',
|
||||||
|
'-b',
|
||||||
'-o',
|
'-o',
|
||||||
'NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL'
|
'NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL,SIZE'
|
||||||
]);
|
]);
|
||||||
const parsed = JSON.parse(stdout);
|
const parsed = JSON.parse(stdout);
|
||||||
const devices = flattenDevices(parsed.blockdevices || []).map((entry) => ({
|
const devices = flattenDevices(parsed.blockdevices || []).map((entry) => ({
|
||||||
@@ -747,30 +938,84 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
mountpoint: entry.mountpoint,
|
mountpoint: entry.mountpoint,
|
||||||
fstype: entry.fstype,
|
fstype: entry.fstype,
|
||||||
label: entry.label,
|
label: entry.label,
|
||||||
model: entry.model
|
model: entry.model,
|
||||||
|
sizeBytes: Number(entry.size) || 0
|
||||||
}));
|
}));
|
||||||
|
const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(devices);
|
||||||
|
const withMakeMkvIndex = devices.map((entry) => {
|
||||||
|
if (entry.type !== 'rom') {
|
||||||
|
return { ...entry, makemkvIndex: null };
|
||||||
|
}
|
||||||
|
const devicePath = normalizeOpticalDevicePath(entry);
|
||||||
|
const inferredIndex = Number(
|
||||||
|
makemkvIndexByPath.get(devicePath)
|
||||||
|
?? makemkvIndexByPath.get(entry.name || '')
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
makemkvIndex: Number.isFinite(inferredIndex) && inferredIndex >= 0
|
||||||
|
? Math.trunc(inferredIndex)
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
});
|
||||||
logger.debug('lsblk:ok', { deviceCount: devices.length });
|
logger.debug('lsblk:ok', { deviceCount: devices.length });
|
||||||
return devices;
|
return withMakeMkvIndex;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('lsblk:failed', { error: errorToMeta(error) });
|
logger.warn('lsblk:failed', { error: errorToMeta(error) });
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async checkMediaPresent(devicePath) {
|
||||||
let blkidType = null;
|
let blkidType = null;
|
||||||
|
let blkidError = null;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
|
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
|
||||||
blkidType = String(stdout || '').trim().toLowerCase() || null;
|
blkidType = String(stdout || '').trim().toLowerCase() || null;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
|
blkidError = String(_error?.message || _error || 'unknown');
|
||||||
// blkid failed – could mean no disc, or an audio CD (no filesystem type)
|
// blkid failed – could mean no disc, or an audio CD (no filesystem type)
|
||||||
}
|
}
|
||||||
|
logger.info('check-media:blkid', { devicePath, blkidType, blkidError });
|
||||||
|
|
||||||
if (blkidType) {
|
if (blkidType) {
|
||||||
logger.debug('blkid:result', { devicePath, hasMedia: true, type: blkidType });
|
|
||||||
return { hasMedia: true, type: blkidType };
|
return { hasMedia: true, type: blkidType };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hasOpticalMediaHintFromUdev = false;
|
||||||
|
|
||||||
// blkid found nothing – audio CDs have no filesystem, so fall back to udevadm
|
// blkid found nothing – audio CDs have no filesystem, so fall back to udevadm
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync('udevadm', [
|
const { stdout } = await execFileAsync('udevadm', [
|
||||||
@@ -787,13 +1032,26 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
props[line.slice(0, idx).trim().toUpperCase()] = line.slice(idx + 1).trim();
|
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 inferredByUdev = inferMediaProfileFromUdevProperties(props);
|
||||||
const hasDVD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_DVD') && props[k] === '1');
|
const audioTrackCount = Number.parseInt(String(props.ID_CDROM_MEDIA_TRACK_COUNT_AUDIO || '').trim(), 10);
|
||||||
const hasCD = props['ID_CDROM_MEDIA_CD'] === '1';
|
const dataTrackCount = Number.parseInt(String(props.ID_CDROM_MEDIA_TRACK_COUNT_DATA || '').trim(), 10);
|
||||||
if (hasCD && !hasDVD && !hasBD) {
|
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 });
|
logger.debug('udevadm:audio-cd', { devicePath });
|
||||||
return { hasMedia: true, type: 'audio_cd' };
|
return { hasMedia: true, type: 'audio_cd' };
|
||||||
}
|
}
|
||||||
|
if (inferredByUdev === 'bluray' || inferredByUdev === 'dvd') {
|
||||||
|
logger.debug('udevadm:optical-media', { devicePath, inferredByUdev });
|
||||||
|
// Keep this as a presence hint, but still probe cdparanoia. Some drives
|
||||||
|
// expose mixed DVD/CD flags for audio CDs and would otherwise be
|
||||||
|
// downgraded to "other" before TOC probing.
|
||||||
|
hasOpticalMediaHintFromUdev = true;
|
||||||
|
}
|
||||||
} catch (_udevError) {
|
} catch (_udevError) {
|
||||||
// udevadm not available or failed – ignore
|
// udevadm not available or failed – ignore
|
||||||
}
|
}
|
||||||
@@ -804,24 +1062,31 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
// stdout/stderr and treat valid TOC lines as "audio CD present".
|
// stdout/stderr and treat valid TOC lines as "audio CD present".
|
||||||
// Keep compatibility with previous behavior: exit 0 counts as media even
|
// Keep compatibility with previous behavior: exit 0 counts as media even
|
||||||
// when TOC output format cannot be parsed.
|
// when TOC output format cannot be parsed.
|
||||||
try {
|
const hasAudioCdToc = await this.probeAudioCdWithCdparanoia(devicePath);
|
||||||
const { stdout, stderr } = await execFileAsync('cdparanoia', ['-Q', '-d', devicePath], { timeout: 10000 });
|
if (hasAudioCdToc) {
|
||||||
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
|
|
||||||
if (tracks.length > 0) {
|
|
||||||
logger.debug('cdparanoia:audio-cd', { devicePath, trackCount: tracks.length });
|
|
||||||
return { hasMedia: true, type: 'audio_cd' };
|
|
||||||
}
|
|
||||||
logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath });
|
|
||||||
return { hasMedia: true, type: 'audio_cd' };
|
return { hasMedia: true, type: 'audio_cd' };
|
||||||
} catch (cdError) {
|
}
|
||||||
const stderr = String(cdError?.stderr || '');
|
|
||||||
const stdout = String(cdError?.stdout || '');
|
if (hasOpticalMediaHintFromUdev) {
|
||||||
const tracks = parseToc(`${stderr}\n${stdout}`);
|
return { hasMedia: true, type: null };
|
||||||
if (tracks.length > 0) {
|
}
|
||||||
logger.debug('cdparanoia:audio-cd-from-error-streams', { devicePath, trackCount: tracks.length });
|
|
||||||
return { hasMedia: true, type: 'audio_cd' };
|
// Final fallback: check block device size via sysfs.
|
||||||
|
// In VM/passthrough environments udev metadata may be absent even though
|
||||||
|
// the kernel reports a valid disc size (visible in lsblk). A non-zero
|
||||||
|
// 512-byte block count means media is physically present.
|
||||||
|
try {
|
||||||
|
const devName = String(devicePath || '').split('/').pop();
|
||||||
|
if (devName) {
|
||||||
|
const sizeStr = fs.readFileSync(`/sys/block/${devName}/size`, 'utf8').trim();
|
||||||
|
const sizeBlocks = parseInt(sizeStr, 10);
|
||||||
|
if (Number.isFinite(sizeBlocks) && sizeBlocks > 0) {
|
||||||
|
logger.info('check-media:sysfs-size', { devicePath, sizeBlocks });
|
||||||
|
return { hasMedia: true, type: null };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// cdparanoia failed and no TOC output could be parsed.
|
} catch (_sysError) {
|
||||||
|
// sysfs not available or device not found there
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('blkid:no-media-or-fail', { devicePath });
|
logger.debug('blkid:no-media-or-fail', { devicePath });
|
||||||
@@ -930,7 +1195,15 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
// UDF is used for both Blu-ray (UDF 2.x) and DVD (UDF 1.x). Without a clear model
|
// 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
|
// 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.
|
// 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')
|
||||||
|
&& !(isIsoLikeFsType(hintFstype) && byFsTypeHint === 'dvd')
|
||||||
|
) {
|
||||||
return byFsTypeHint;
|
return byFsTypeHint;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,14 +1247,14 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const byBlkidFsType = inferMediaProfileFromFsTypeAndModel(type, hints?.model);
|
const byBlkidFsType = inferMediaProfileFromFsTypeAndModel(type, hints?.model);
|
||||||
if (byBlkidFsType) {
|
if (byBlkidFsType && !(isIsoLikeFsType(type) && byBlkidFsType === 'dvd')) {
|
||||||
return byBlkidFsType;
|
return byBlkidFsType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last resort for drives that only expose TYPE=udf without VERSION/APPLICATION_ID:
|
// Last resort for drives that only expose TYPE=udf without VERSION/APPLICATION_ID:
|
||||||
// prefer DVD over "other" so DVDs in BD-capable drives do not fall back to Misc.
|
// prefer DVD over "other" so DVDs in BD-capable drives do not fall back to Misc.
|
||||||
const byBlkidFsTypeWithoutModel = inferMediaProfileFromFsTypeAndModel(type, null);
|
const byBlkidFsTypeWithoutModel = inferMediaProfileFromFsTypeAndModel(type, null);
|
||||||
if (byBlkidFsTypeWithoutModel) {
|
if (byBlkidFsTypeWithoutModel && !(isIsoLikeFsType(type) && byBlkidFsTypeWithoutModel === 'dvd')) {
|
||||||
return byBlkidFsTypeWithoutModel;
|
return byBlkidFsTypeWithoutModel;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -995,6 +1268,11 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
return udfHintFallback;
|
return udfHintFallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasAudioCdToc = await this.probeAudioCdWithCdparanoia(devicePath);
|
||||||
|
if (hasAudioCdToc) {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ class DownloadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
|
const pendingResumeIds = [];
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
||||||
@@ -136,11 +137,34 @@ class DownloadService {
|
|||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
if (item.status === 'queued' || item.status === 'processing') {
|
if (item.status === 'queued' || item.status === 'processing') {
|
||||||
item.status = 'failed';
|
const archiveExists = await this._pathExists(item.archivePath);
|
||||||
item.errorMessage = 'ZIP-Erstellung wurde durch einen Server-Neustart unterbrochen.';
|
if (archiveExists) {
|
||||||
item.finishedAt = nowIso;
|
const archiveStat = await fs.promises.stat(item.archivePath).catch(() => null);
|
||||||
changed = true;
|
item.status = 'ready';
|
||||||
await this._safeUnlink(item.partialPath);
|
item.errorMessage = null;
|
||||||
|
item.finishedAt = item.finishedAt || nowIso;
|
||||||
|
item.sizeBytes = Number.isFinite(Number(archiveStat?.size))
|
||||||
|
? archiveStat.size
|
||||||
|
: item.sizeBytes;
|
||||||
|
changed = true;
|
||||||
|
logger.warn('download:init:recovered-ready-archive', {
|
||||||
|
id: item.id,
|
||||||
|
archiveName: item.archiveName
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
item.status = 'queued';
|
||||||
|
item.startedAt = null;
|
||||||
|
item.finishedAt = null;
|
||||||
|
item.errorMessage = null;
|
||||||
|
item.sizeBytes = null;
|
||||||
|
changed = true;
|
||||||
|
pendingResumeIds.push(item.id);
|
||||||
|
await this._safeUnlink(item.partialPath);
|
||||||
|
logger.warn('download:init:requeue-interrupted-job', {
|
||||||
|
id: item.id,
|
||||||
|
archiveName: item.archiveName
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (item.status === 'ready') {
|
} else if (item.status === 'ready') {
|
||||||
const exists = await this._pathExists(item.archivePath);
|
const exists = await this._pathExists(item.archivePath);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
@@ -157,6 +181,17 @@ class DownloadService {
|
|||||||
await this._persistItem(item);
|
await this._persistItem(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pendingResumeIds.length > 0) {
|
||||||
|
logger.warn('download:init:resume-pending-jobs', {
|
||||||
|
count: pendingResumeIds.length
|
||||||
|
});
|
||||||
|
setImmediate(() => {
|
||||||
|
for (const id of pendingResumeIds) {
|
||||||
|
void this._startArchiveJob(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_normalizeLoadedItem(rawItem, fallbackDir) {
|
_normalizeLoadedItem(rawItem, fallbackDir) {
|
||||||
@@ -260,9 +295,9 @@ class DownloadService {
|
|||||||
return this.items.get(normalizedId);
|
return this.items.get(normalizedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async enqueueHistoryJob(jobId, target) {
|
async enqueueHistoryJob(jobId, target, options = {}) {
|
||||||
await this.init();
|
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 settings = await settingsService.getEffectiveSettingsMap(null);
|
||||||
const downloadDir = String(settings?.download_dir || '').trim();
|
const downloadDir = String(settings?.download_dir || '').trim();
|
||||||
const ownerSpec = String(settings?.download_dir_owner || '').trim() || null;
|
const ownerSpec = String(settings?.download_dir_owner || '').trim() || null;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+2520
-471
File diff suppressed because it is too large
Load Diff
@@ -576,7 +576,8 @@ function mapPresetEntriesToOptions(entries) {
|
|||||||
* Parses the drive_devices JSON setting into a normalized array of {path, makemkvIndex}.
|
* Parses the drive_devices JSON setting into a normalized array of {path, makemkvIndex}.
|
||||||
* Supports both the legacy string format ["/dev/sr0"] and the object format
|
* Supports both the legacy string format ["/dev/sr0"] and the object format
|
||||||
* [{"path": "/dev/sr0", "makemkvIndex": 0}].
|
* [{"path": "/dev/sr0", "makemkvIndex": 0}].
|
||||||
* When makemkvIndex is missing, it is auto-derived from the device name (sr0→0, sr1→1).
|
* When makemkvIndex is missing, it is assigned by list order (0,1,2,...) so
|
||||||
|
* sparse kernel names like /dev/sr2 still map to contiguous MakeMKV indices.
|
||||||
*/
|
*/
|
||||||
function parseDriveDeviceEntries(raw) {
|
function parseDriveDeviceEntries(raw) {
|
||||||
try {
|
try {
|
||||||
@@ -584,28 +585,49 @@ function parseDriveDeviceEntries(raw) {
|
|||||||
if (!Array.isArray(arr)) {
|
if (!Array.isArray(arr)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return arr.map((entry) => {
|
const normalized = arr.map((entry) => {
|
||||||
if (typeof entry === 'string') {
|
if (typeof entry === 'string') {
|
||||||
const p = entry.trim();
|
const p = entry.trim();
|
||||||
if (!p) {
|
if (!p) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const m = p.match(/sr(\d+)$/);
|
return { path: p, makemkvIndex: null };
|
||||||
return { path: p, makemkvIndex: m ? Number(m[1]) : 0 };
|
|
||||||
}
|
}
|
||||||
if (entry && typeof entry === 'object' && entry.path) {
|
if (entry && typeof entry === 'object' && entry.path) {
|
||||||
const p = String(entry.path || '').trim();
|
const p = String(entry.path || '').trim();
|
||||||
if (!p) {
|
if (!p) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const idxRaw = entry.makemkvIndex != null ? Number(entry.makemkvIndex) : null;
|
const idxRaw = Number(entry.makemkvIndex);
|
||||||
const m = p.match(/sr(\d+)$/);
|
return {
|
||||||
const derived = m ? Number(m[1]) : 0;
|
path: p,
|
||||||
const idx = Number.isFinite(idxRaw) && idxRaw >= 0 ? Math.trunc(idxRaw) : derived;
|
makemkvIndex: Number.isFinite(idxRaw) && idxRaw >= 0 ? Math.trunc(idxRaw) : null
|
||||||
return { path: p, makemkvIndex: idx };
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
const used = new Set();
|
||||||
|
let nextAutoIndex = 0;
|
||||||
|
return normalized.map((entry) => {
|
||||||
|
if (entry.makemkvIndex != null) {
|
||||||
|
used.add(entry.makemkvIndex);
|
||||||
|
if (entry.makemkvIndex >= nextAutoIndex) {
|
||||||
|
nextAutoIndex = entry.makemkvIndex + 1;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
while (used.has(nextAutoIndex)) {
|
||||||
|
nextAutoIndex += 1;
|
||||||
|
}
|
||||||
|
const resolved = {
|
||||||
|
path: entry.path,
|
||||||
|
makemkvIndex: nextAutoIndex
|
||||||
|
};
|
||||||
|
used.add(nextAutoIndex);
|
||||||
|
nextAutoIndex += 1;
|
||||||
|
return resolved;
|
||||||
|
});
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -829,6 +851,7 @@ class SettingsService {
|
|||||||
s.options_json,
|
s.options_json,
|
||||||
s.validation_json,
|
s.validation_json,
|
||||||
s.order_index,
|
s.order_index,
|
||||||
|
s.depends_on,
|
||||||
v.value as current_value
|
v.value as current_value
|
||||||
FROM settings_schema s
|
FROM settings_schema s
|
||||||
LEFT JOIN settings_values v ON v.key = s.key
|
LEFT JOIN settings_values v ON v.key = s.key
|
||||||
@@ -847,7 +870,8 @@ class SettingsService {
|
|||||||
options: parseJson(row.options_json, []),
|
options: parseJson(row.options_json, []),
|
||||||
validation: parseJson(row.validation_json, {}),
|
validation: parseJson(row.validation_json, {}),
|
||||||
value: normalizeValueByType(row.type, row.current_value ?? row.default_value),
|
value: normalizeValueByType(row.type, row.current_value ?? row.default_value),
|
||||||
orderIndex: row.order_index
|
orderIndex: row.order_index,
|
||||||
|
depends_on: row.depends_on ?? null
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1411,17 +1435,31 @@ class SettingsService {
|
|||||||
|
|
||||||
resolveSourceArg(map, deviceInfo = null) {
|
resolveSourceArg(map, deviceInfo = null) {
|
||||||
const devicePath = String(deviceInfo?.path || '').trim();
|
const devicePath = String(deviceInfo?.path || '').trim();
|
||||||
|
const deviceIndex = Number(deviceInfo?.makemkvIndex ?? deviceInfo?.index);
|
||||||
|
const configuredEntries = parseDriveDeviceEntries(map.drive_devices);
|
||||||
|
|
||||||
// In explicit mode: look up per-drive makemkvIndex from drive_devices
|
// In explicit mode: look up per-drive makemkvIndex from drive_devices
|
||||||
if (devicePath && map.drive_mode === 'explicit') {
|
if (devicePath && map.drive_mode === 'explicit') {
|
||||||
const entries = parseDriveDeviceEntries(map.drive_devices);
|
const entry = configuredEntries.find((e) => e.path === devicePath);
|
||||||
const entry = entries.find((e) => e.path === devicePath);
|
|
||||||
if (entry) {
|
if (entry) {
|
||||||
return `disc:${entry.makemkvIndex}`;
|
return `disc:${entry.makemkvIndex}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-derive from device name (/dev/sr0 → disc:0, /dev/sr1 → disc:1)
|
// Prefer configured per-drive index when a path match exists (also in auto mode).
|
||||||
|
if (devicePath) {
|
||||||
|
const configured = configuredEntries.find((e) => e.path === devicePath);
|
||||||
|
if (configured) {
|
||||||
|
return `disc:${configured.makemkvIndex}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer device-provided MakeMKV index from disk detection service.
|
||||||
|
if (Number.isFinite(deviceIndex) && deviceIndex >= 0) {
|
||||||
|
return `disc:${Math.trunc(deviceIndex)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last automatic fallback: derive from device name (/dev/sr0 → disc:0).
|
||||||
if (devicePath) {
|
if (devicePath) {
|
||||||
const match = devicePath.match(/sr(\d+)$/);
|
const match = devicePath.match(/sr(\d+)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|||||||
@@ -33,6 +33,31 @@ function isLocalUrl(url) {
|
|||||||
return typeof url === 'string' && url.startsWith('/api/thumbnails/');
|
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) {
|
function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (redirectsLeft <= 0) {
|
if (redirectsLeft <= 0) {
|
||||||
@@ -82,21 +107,48 @@ function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) {
|
|||||||
* Wird aufgerufen sobald poster_url bekannt ist (vor Rip-Start).
|
* Wird aufgerufen sobald poster_url bekannt ist (vor Rip-Start).
|
||||||
* @returns {Promise<string|null>} lokaler Pfad oder null
|
* @returns {Promise<string|null>} lokaler Pfad oder null
|
||||||
*/
|
*/
|
||||||
async function cacheJobThumbnail(jobId, posterUrl) {
|
async function cacheJobThumbnailDetailed(jobId, posterUrl) {
|
||||||
if (!posterUrl || isLocalUrl(posterUrl)) return null;
|
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 {
|
try {
|
||||||
ensureDirs();
|
ensureDirs();
|
||||||
const dest = cacheFilePath(jobId);
|
const dest = cacheFilePath(jobId);
|
||||||
await downloadImage(posterUrl, dest);
|
await downloadImage(normalizedPosterUrl, dest);
|
||||||
logger.info('thumbnail:cached', { jobId, posterUrl, dest });
|
logger.info('thumbnail:cached', { jobId, posterUrl: normalizedPosterUrl, dest });
|
||||||
return dest;
|
return {
|
||||||
|
ok: true,
|
||||||
|
jobId,
|
||||||
|
posterUrl: normalizedPosterUrl,
|
||||||
|
cachedPath: dest,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('thumbnail:cache:failed', { jobId, posterUrl, error: err.message });
|
const message = String(err?.message || err || 'Bild-Download fehlgeschlagen');
|
||||||
return null;
|
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.
|
* Verschiebt das gecachte Bild in den persistenten Ordner.
|
||||||
* Gibt die lokale API-URL zurück, oder null wenn kein Bild vorhanden.
|
* Gibt die lokale API-URL zurück, oder null wenn kein Bild vorhanden.
|
||||||
@@ -251,11 +303,14 @@ async function migrateExistingThumbnails() {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
cacheJobThumbnail,
|
cacheJobThumbnail,
|
||||||
|
cacheJobThumbnailDetailed,
|
||||||
promoteJobThumbnail,
|
promoteJobThumbnail,
|
||||||
copyThumbnail,
|
copyThumbnail,
|
||||||
storeLocalThumbnail,
|
storeLocalThumbnail,
|
||||||
deleteThumbnail,
|
deleteThumbnail,
|
||||||
getThumbnailsDir,
|
getThumbnailsDir,
|
||||||
migrateExistingThumbnails,
|
migrateExistingThumbnails,
|
||||||
isLocalUrl
|
isLocalUrl,
|
||||||
|
resolveLocalThumbnailPath,
|
||||||
|
localThumbnailUrlExists
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ class WebSocketService {
|
|||||||
this.clients = new Set();
|
this.clients = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_removeClient(socket, logLevel = 'info', event = 'client:removed') {
|
||||||
|
if (!socket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deleted = this.clients.delete(socket);
|
||||||
|
if (!deleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger[logLevel](event, { clients: this.clients.size });
|
||||||
|
}
|
||||||
|
|
||||||
init(httpServer) {
|
init(httpServer) {
|
||||||
if (this.wss) {
|
if (this.wss) {
|
||||||
return;
|
return;
|
||||||
@@ -18,21 +29,32 @@ class WebSocketService {
|
|||||||
this.clients.add(socket);
|
this.clients.add(socket);
|
||||||
logger.info('client:connected', { clients: this.clients.size });
|
logger.info('client:connected', { clients: this.clients.size });
|
||||||
|
|
||||||
socket.send(
|
try {
|
||||||
JSON.stringify({
|
socket.send(
|
||||||
type: 'WS_CONNECTED',
|
JSON.stringify({
|
||||||
payload: { connectedAt: new Date().toISOString() }
|
type: 'WS_CONNECTED',
|
||||||
})
|
payload: { connectedAt: new Date().toISOString() }
|
||||||
);
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('client:connected:initial-send-failed', {
|
||||||
|
clients: this.clients.size,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
this._removeClient(socket, 'warn', 'client:connected:removed-after-send-failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
this.clients.delete(socket);
|
this._removeClient(socket, 'info', 'client:closed');
|
||||||
logger.info('client:closed', { clients: this.clients.size });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', () => {
|
socket.on('error', (error) => {
|
||||||
this.clients.delete(socket);
|
logger.warn('client:error', {
|
||||||
logger.warn('client:error', { clients: this.clients.size });
|
clients: this.clients.size,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
this._removeClient(socket, 'warn', 'client:error:removed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,8 +77,42 @@ class WebSocketService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const client of this.clients) {
|
for (const client of this.clients) {
|
||||||
if (client.readyState === client.OPEN) {
|
if (!client || client.readyState !== client.OPEN) {
|
||||||
client.send(message);
|
if (client && client.readyState === client.CLOSED) {
|
||||||
|
this._removeClient(client, 'info', 'client:pruned-closed');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.send(message, (error) => {
|
||||||
|
if (!error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.warn('broadcast:send-failed', {
|
||||||
|
type,
|
||||||
|
clients: this.clients.size,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
this._removeClient(client, 'warn', 'client:removed-after-send-failure');
|
||||||
|
try {
|
||||||
|
client.terminate();
|
||||||
|
} catch (_error) {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('broadcast:send-threw', {
|
||||||
|
type,
|
||||||
|
clients: this.clients.size,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
this._removeClient(client, 'warn', 'client:removed-after-send-throw');
|
||||||
|
try {
|
||||||
|
client.terminate();
|
||||||
|
} catch (_terminateError) {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ CREATE TABLE settings_schema (
|
|||||||
options_json TEXT,
|
options_json TEXT,
|
||||||
validation_json TEXT,
|
validation_json TEXT,
|
||||||
order_index INTEGER NOT NULL DEFAULT 0,
|
order_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
depends_on TEXT DEFAULT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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_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 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 (
|
CREATE TABLE scripts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
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);
|
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_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
|
-- Benachrichtigungen
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
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);
|
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",
|
"name": "ripster-frontend",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0-7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0-7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0-7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+168
-15
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
import { ProgressBar } from 'primereact/progressbar';
|
import { ProgressBar } from 'primereact/progressbar';
|
||||||
import { Tag } from 'primereact/tag';
|
import { Tag } from 'primereact/tag';
|
||||||
@@ -28,6 +28,15 @@ function clampPercent(value) {
|
|||||||
return Math.max(0, Math.min(100, parsed));
|
return Math.max(0, Math.min(100, parsed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStage(value) {
|
||||||
|
return String(value || '').trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalStage(value) {
|
||||||
|
const normalized = normalizeStage(value);
|
||||||
|
return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED';
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(value) {
|
function formatBytes(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
@@ -120,6 +129,47 @@ function App() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const globalToastRef = useRef(null);
|
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 refreshPipeline = async () => {
|
||||||
const response = await api.getPipelineState();
|
const response = await api.getPipelineState();
|
||||||
@@ -256,8 +306,49 @@ function App() {
|
|||||||
: null;
|
: null;
|
||||||
setPipeline((prev) => {
|
setPipeline((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
|
const normalizedProgressJobId = normalizeJobId(progressJobId);
|
||||||
|
const progressStage = normalizeStage(payload?.state);
|
||||||
|
const isCdProgressStage = progressStage === 'CD_ANALYZING'
|
||||||
|
|| progressStage === 'CD_RIPPING'
|
||||||
|
|| progressStage === 'CD_ENCODING';
|
||||||
|
const incomingIsTerminal = isTerminalStage(progressStage);
|
||||||
|
const prevActiveJobId = normalizeJobId(prev?.activeJobId || prev?.context?.jobId);
|
||||||
|
const prevJobProgress = normalizedProgressJobId
|
||||||
|
? (prev?.jobProgress?.[normalizedProgressJobId] || null)
|
||||||
|
: null;
|
||||||
|
const prevJobProgressState = normalizeStage(prevJobProgress?.state);
|
||||||
|
const prevJobProgressIsTerminal = isTerminalStage(prevJobProgressState);
|
||||||
|
const matchingCdDriveEntry = normalizedProgressJobId
|
||||||
|
? Object.values(prev?.cdDrives || {}).find((driveState) => normalizeJobId(driveState?.jobId) === normalizedProgressJobId)
|
||||||
|
: null;
|
||||||
|
const matchingCdDriveIsTerminal = isTerminalStage(matchingCdDriveEntry?.state);
|
||||||
|
const hasKnownBinding = Boolean(
|
||||||
|
normalizedProgressJobId
|
||||||
|
&& (
|
||||||
|
prevActiveJobId === normalizedProgressJobId
|
||||||
|
|| prevJobProgress
|
||||||
|
|| matchingCdDriveEntry
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ignore late/stale progress packets that arrive after a terminal state
|
||||||
|
// or for jobs that are no longer bound in pipeline state.
|
||||||
|
if (
|
||||||
|
normalizedProgressJobId
|
||||||
|
&& !incomingIsTerminal
|
||||||
|
&& (
|
||||||
|
prevJobProgressIsTerminal
|
||||||
|
|| matchingCdDriveIsTerminal
|
||||||
|
|| !hasKnownBinding
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
if (progressJobId != null) {
|
if (progressJobId != null) {
|
||||||
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
|
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
|
||||||
|
const previousJobStage = normalizeStage(previousJobProgress?.state);
|
||||||
|
const keepPreviousJobStage = isTerminalStage(previousJobStage) && !incomingIsTerminal;
|
||||||
const mergedJobContext = contextPatch
|
const mergedJobContext = contextPatch
|
||||||
? {
|
? {
|
||||||
...(previousJobProgress?.context && typeof previousJobProgress.context === 'object'
|
...(previousJobProgress?.context && typeof previousJobProgress.context === 'object'
|
||||||
@@ -272,19 +363,21 @@ function App() {
|
|||||||
...(prev?.jobProgress || {}),
|
...(prev?.jobProgress || {}),
|
||||||
[progressJobId]: {
|
[progressJobId]: {
|
||||||
...previousJobProgress,
|
...previousJobProgress,
|
||||||
state: payload.state,
|
state: keepPreviousJobStage ? previousJobProgress.state : payload.state,
|
||||||
progress: payload.progress,
|
progress: keepPreviousJobStage ? previousJobProgress.progress : payload.progress,
|
||||||
eta: payload.eta,
|
eta: keepPreviousJobStage ? previousJobProgress.eta : payload.eta,
|
||||||
statusText: payload.statusText,
|
statusText: keepPreviousJobStage ? previousJobProgress.statusText : payload.statusText,
|
||||||
...(mergedJobContext !== undefined ? { context: mergedJobContext } : {})
|
...(mergedJobContext !== undefined ? { context: mergedJobContext } : {})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (progressJobId === prev?.activeJobId || progressJobId == null) {
|
if (progressJobId === prev?.activeJobId || progressJobId == null) {
|
||||||
next.state = payload.state ?? prev?.state;
|
const previousGlobalStage = normalizeStage(prev?.state);
|
||||||
next.progress = payload.progress ?? prev?.progress;
|
const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage) && !incomingIsTerminal;
|
||||||
next.eta = payload.eta ?? prev?.eta;
|
next.state = keepPreviousGlobalStage ? prev?.state : (payload.state ?? prev?.state);
|
||||||
next.statusText = payload.statusText ?? prev?.statusText;
|
next.progress = keepPreviousGlobalStage ? prev?.progress : (payload.progress ?? prev?.progress);
|
||||||
|
next.eta = keepPreviousGlobalStage ? prev?.eta : (payload.eta ?? prev?.eta);
|
||||||
|
next.statusText = keepPreviousGlobalStage ? prev?.statusText : (payload.statusText ?? prev?.statusText);
|
||||||
if (contextPatch) {
|
if (contextPatch) {
|
||||||
next.context = {
|
next.context = {
|
||||||
...(prev?.context && typeof prev.context === 'object' ? prev.context : {}),
|
...(prev?.context && typeof prev.context === 'object' ? prev.context : {}),
|
||||||
@@ -292,6 +385,63 @@ function App() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep per-drive CD progress in sync with live progress events.
|
||||||
|
// Backend sends frequent PIPELINE_PROGRESS updates, while cdDrives
|
||||||
|
// snapshots are only broadcast on state transitions.
|
||||||
|
if (isCdProgressStage && prev?.cdDrives && typeof prev.cdDrives === 'object') {
|
||||||
|
const cdDrivesEntries = Object.entries(prev.cdDrives);
|
||||||
|
const nextCdDrives = { ...prev.cdDrives };
|
||||||
|
const patchDrive = (driveState) => {
|
||||||
|
const currentDriveStage = normalizeStage(driveState?.state);
|
||||||
|
if (isTerminalStage(currentDriveStage) && !incomingIsTerminal) {
|
||||||
|
return driveState;
|
||||||
|
}
|
||||||
|
const mergedContext = contextPatch
|
||||||
|
? {
|
||||||
|
...(driveState?.context && typeof driveState.context === 'object' ? driveState.context : {}),
|
||||||
|
...contextPatch
|
||||||
|
}
|
||||||
|
: driveState?.context;
|
||||||
|
return {
|
||||||
|
...driveState,
|
||||||
|
state: payload?.state ?? driveState?.state,
|
||||||
|
progress: payload?.progress ?? driveState?.progress,
|
||||||
|
eta: payload?.eta ?? driveState?.eta,
|
||||||
|
statusText: payload?.statusText ?? driveState?.statusText,
|
||||||
|
...(mergedContext !== undefined ? { context: mergedContext } : {})
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let updated = false;
|
||||||
|
if (normalizedProgressJobId) {
|
||||||
|
for (const [drivePath, driveState] of cdDrivesEntries) {
|
||||||
|
if (normalizeJobId(driveState?.jobId) === normalizedProgressJobId) {
|
||||||
|
nextCdDrives[drivePath] = patchDrive(driveState);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
const activeCdEntries = cdDrivesEntries.filter(([, driveState]) => {
|
||||||
|
const driveStage = String(driveState?.state || '').trim().toUpperCase();
|
||||||
|
return driveStage === 'CD_ANALYZING'
|
||||||
|
|| driveStage === 'CD_RIPPING'
|
||||||
|
|| driveStage === 'CD_ENCODING';
|
||||||
|
});
|
||||||
|
if (activeCdEntries.length === 1) {
|
||||||
|
const [drivePath, driveState] = activeCdEntries[0];
|
||||||
|
nextCdDrives[drivePath] = patchDrive(driveState);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
next.cdDrives = nextCdDrives;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -493,13 +643,16 @@ function App() {
|
|||||||
pendingExpandedJobId={pendingDashboardJobId}
|
pendingExpandedJobId={pendingDashboardJobId}
|
||||||
onPendingExpandedJobHandled={handleDashboardJobFocusConsumed}
|
onPendingExpandedJobHandled={handleDashboardJobFocusConsumed}
|
||||||
downloadSummary={downloadSummary}
|
downloadSummary={downloadSummary}
|
||||||
/>
|
>
|
||||||
|
<Outlet />
|
||||||
|
</DashboardPage>
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="/history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
|
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
|
||||||
<Route path="/downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
|
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
|
||||||
<Route path="/database" element={<DatabasePage />} />
|
<Route path="database" element={<DatabasePage />} />
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+71
-10
@@ -449,6 +449,14 @@ export const api = {
|
|||||||
body: JSON.stringify(payload)
|
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() {
|
getPipelineState() {
|
||||||
return request('/pipeline/state');
|
return request('/pipeline/state');
|
||||||
},
|
},
|
||||||
@@ -594,23 +602,57 @@ export const api = {
|
|||||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
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}`, {
|
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']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
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}`, {
|
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']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
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}`, {
|
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']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
return result;
|
||||||
@@ -700,17 +742,37 @@ export const api = {
|
|||||||
},
|
},
|
||||||
async deleteJobEntry(jobId, target = 'none', options = {}) {
|
async deleteJobEntry(jobId, target = 'none', options = {}) {
|
||||||
const includeRelated = Boolean(options?.includeRelated);
|
const includeRelated = Boolean(options?.includeRelated);
|
||||||
|
const resetDriveState = Boolean(options?.resetDriveState);
|
||||||
|
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
|
||||||
|
? options.selectedMoviePaths
|
||||||
|
.map((item) => String(item || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: null;
|
||||||
|
const hasKeepDetectedDevice = options?.keepDetectedDevice !== undefined;
|
||||||
|
const keepDetectedDevice = hasKeepDetectedDevice
|
||||||
|
? Boolean(options.keepDetectedDevice)
|
||||||
|
: null;
|
||||||
const result = await request(`/history/${jobId}/delete`, {
|
const result = await request(`/history/${jobId}/delete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ target, includeRelated })
|
body: JSON.stringify({
|
||||||
|
target,
|
||||||
|
includeRelated,
|
||||||
|
resetDriveState,
|
||||||
|
...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}),
|
||||||
|
...(selectedMoviePaths ? { selectedMoviePaths } : {})
|
||||||
|
})
|
||||||
});
|
});
|
||||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
requestJobArchive(jobId, target = 'raw') {
|
requestJobArchive(jobId, target = 'raw', options = {}) {
|
||||||
|
const outputPath = String(options?.outputPath || '').trim();
|
||||||
return request(`/downloads/history/${jobId}`, {
|
return request(`/downloads/history/${jobId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ target })
|
body: JSON.stringify({
|
||||||
|
target,
|
||||||
|
...(outputPath ? { outputPath } : {})
|
||||||
|
})
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getDownloads() {
|
getDownloads() {
|
||||||
@@ -764,7 +826,6 @@ export const api = {
|
|||||||
forceRefresh: options.forceRefresh
|
forceRefresh: options.forceRefresh
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── User Presets ───────────────────────────────────────────────────────────
|
// ── User Presets ───────────────────────────────────────────────────────────
|
||||||
getUserPresets(mediaType = null, options = {}) {
|
getUserPresets(mediaType = null, options = {}) {
|
||||||
const suffix = mediaType ? `?media_type=${encodeURIComponent(mediaType)}` : '';
|
const suffix = mediaType ? `?media_type=${encodeURIComponent(mediaType)}` : '';
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export default function AudiobookConfigPanel({
|
|||||||
onStart,
|
onStart,
|
||||||
onCancel,
|
onCancel,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
onDeleteJob,
|
||||||
busy
|
busy
|
||||||
}) {
|
}) {
|
||||||
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
||||||
@@ -353,6 +354,17 @@ export default function AudiobookConfigPanel({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{jobId ? (
|
||||||
|
<Button
|
||||||
|
label="Job löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => onDeleteJob?.(jobId)}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isRunning ? (
|
{isRunning ? (
|
||||||
<Button
|
<Button
|
||||||
label="Abbrechen"
|
label="Abbrechen"
|
||||||
|
|||||||
@@ -227,7 +227,9 @@ export default function CdRipConfigPanel({
|
|||||||
onStart,
|
onStart,
|
||||||
onCancel,
|
onCancel,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
onRestartReview,
|
||||||
onOpenMetadata,
|
onOpenMetadata,
|
||||||
|
onDeleteJob,
|
||||||
busy
|
busy
|
||||||
}) {
|
}) {
|
||||||
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
||||||
@@ -523,7 +525,8 @@ export default function CdRipConfigPanel({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = (options = {}) => {
|
||||||
|
const forceSkipRip = Boolean(options?.forceSkipRip);
|
||||||
const albumTitle = normalizeTrackText(metaFields?.title)
|
const albumTitle = normalizeTrackText(metaFields?.title)
|
||||||
|| normalizeTrackText(selectedMeta?.title)
|
|| normalizeTrackText(selectedMeta?.title)
|
||||||
|| normalizeTrackText(context?.detectedTitle)
|
|| normalizeTrackText(context?.detectedTitle)
|
||||||
@@ -591,6 +594,7 @@ export default function CdRipConfigPanel({
|
|||||||
selectedPostEncodeScriptIds,
|
selectedPostEncodeScriptIds,
|
||||||
selectedPreEncodeChainIds,
|
selectedPreEncodeChainIds,
|
||||||
selectedPostEncodeChainIds,
|
selectedPostEncodeChainIds,
|
||||||
|
skipRip: forceSkipRip || context.skipRip === true ? true : undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
title: albumTitle,
|
title: albumTitle,
|
||||||
artist: albumArtist,
|
artist: albumArtist,
|
||||||
@@ -727,6 +731,20 @@ export default function CdRipConfigPanel({
|
|||||||
const completedEncodeCount = selectedTrackRows.filter((track) => track.encodeStatus === 'done').length;
|
const completedEncodeCount = selectedTrackRows.filter((track) => track.encodeStatus === 'done').length;
|
||||||
const liveCurrentTrackPosition = normalizePosition(cdLive?.trackPosition);
|
const liveCurrentTrackPosition = normalizePosition(cdLive?.trackPosition);
|
||||||
const lastState = String(context?.lastState || '').trim().toUpperCase();
|
const lastState = String(context?.lastState || '').trim().toUpperCase();
|
||||||
|
const lastStateLabel = getStatusLabel(lastState);
|
||||||
|
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 : [])
|
const preScriptNamesFromConfig = (Array.isArray(cdRipConfig?.preEncodeScripts) ? cdRipConfig.preEncodeScripts : [])
|
||||||
.map((item) => String(item?.name || '').trim())
|
.map((item) => String(item?.name || '').trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -793,26 +811,68 @@ export default function CdRipConfigPanel({
|
|||||||
onClick={() => onCancel && onCancel()}
|
onClick={() => onCancel && onCancel()}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
|
{jobId ? (
|
||||||
|
<Button
|
||||||
|
label="Job löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => onDeleteJob && onDeleteJob(jobId)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isTerminalFailure ? (
|
{isTerminalFailure ? (
|
||||||
<div className="actions-row">
|
<div className="actions-row">
|
||||||
{jobId ? (
|
{isEncodingFailure ? (
|
||||||
<Button
|
<Button
|
||||||
label="Retry Rippen"
|
label="Encode starten"
|
||||||
icon="pi pi-refresh"
|
icon="pi pi-play"
|
||||||
severity="warning"
|
severity="success"
|
||||||
onClick={() => onRetry && onRetry()}
|
onClick={() => handleStart({ forceSkipRip: true })}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{jobId ? (
|
||||||
|
<Button
|
||||||
|
label="Retry Rippen"
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
severity="warning"
|
||||||
|
onClick={() => onRetry && onRetry()}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
label="Metadaten ändern"
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
severity="secondary"
|
||||||
|
onClick={() => onOpenMetadata && onOpenMetadata()}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isEncodingFailure ? (
|
||||||
|
<Button
|
||||||
|
label="Review starten"
|
||||||
|
icon="pi pi-search"
|
||||||
|
severity="info"
|
||||||
|
outlined
|
||||||
|
onClick={() => onRestartReview && onRestartReview()}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{jobId ? (
|
||||||
|
<Button
|
||||||
|
label="Job löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => onDeleteJob && onDeleteJob(jobId)}
|
||||||
loading={busy}
|
loading={busy}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
|
||||||
label="Metadaten ändern"
|
|
||||||
icon="pi pi-pencil"
|
|
||||||
severity="secondary"
|
|
||||||
onClick={() => onOpenMetadata && onOpenMetadata()}
|
|
||||||
loading={busy}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -838,12 +898,11 @@ export default function CdRipConfigPanel({
|
|||||||
<div><strong>Pre-Ketten:</strong> {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}</div>
|
<div><strong>Pre-Ketten:</strong> {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}</div>
|
||||||
<div><strong>Post-Skripte:</strong> {postScriptNames.length > 0 ? postScriptNames.join(' | ') : '-'}</div>
|
<div><strong>Post-Skripte:</strong> {postScriptNames.length > 0 ? postScriptNames.join(' | ') : '-'}</div>
|
||||||
<div><strong>Post-Ketten:</strong> {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}</div>
|
<div><strong>Post-Ketten:</strong> {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}</div>
|
||||||
<div><strong>Tracks fertig:</strong> {completedEncodeCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
|
|
||||||
<div><strong>Auswahl:</strong> {selectedTrackNumbers || '-'}</div>
|
<div><strong>Auswahl:</strong> {selectedTrackNumbers || '-'}</div>
|
||||||
<div><strong>Gesamtdauer:</strong> {formatTotalDuration(selectedTrackDurationSec)}</div>
|
<div><strong>Gesamtdauer:</strong> {formatTotalDuration(selectedTrackDurationSec)}</div>
|
||||||
<div><strong>Laufwerk:</strong> {devicePath}</div>
|
<div><strong>Laufwerk:</strong> {devicePath}</div>
|
||||||
<div><strong>Output-Pfad:</strong> {outputPath}</div>
|
<div><strong>Output-Pfad:</strong> {outputPath}</div>
|
||||||
{lastState ? <div><strong>Letzter Pipeline-State:</strong> {lastState}</div> : null}
|
{lastState ? <div><strong>Letzter Pipeline-State:</strong> {lastStateLabel}</div> : null}
|
||||||
{jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null}
|
{jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1259,6 +1318,16 @@ export default function CdRipConfigPanel({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{jobId ? (
|
||||||
|
<Button
|
||||||
|
label="Job löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => onDeleteJob && onDeleteJob(jobId)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
label="Abbrechen"
|
label="Abbrechen"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
@@ -1267,7 +1336,7 @@ export default function CdRipConfigPanel({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Rip starten"
|
label={context.skipRip === true ? 'Encode starten' : 'Rip starten'}
|
||||||
icon="pi pi-play"
|
icon="pi pi-play"
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
loading={busy}
|
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 { TabView, TabPanel } from 'primereact/tabview';
|
||||||
import { Accordion, AccordionTab } from 'primereact/accordion';
|
import { Accordion, AccordionTab } from 'primereact/accordion';
|
||||||
import { InputText } from 'primereact/inputtext';
|
import { InputText } from 'primereact/inputtext';
|
||||||
@@ -94,16 +94,40 @@ function DriveDevicesEditor({ value, onChange, settingKey }) {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(val || '[]');
|
const parsed = JSON.parse(val || '[]');
|
||||||
if (!Array.isArray(parsed)) return [];
|
if (!Array.isArray(parsed)) return [];
|
||||||
return parsed.map((e) => {
|
const normalized = parsed.map((e) => {
|
||||||
if (typeof e === 'string') {
|
if (typeof e === 'string') {
|
||||||
const p = e.trim();
|
const p = e.trim();
|
||||||
const m = p.match(/sr(\d+)$/);
|
if (!p) return null;
|
||||||
return { path: p, makemkvIndex: m ? Number(m[1]) : 0 };
|
return { path: p, makemkvIndex: null };
|
||||||
}
|
}
|
||||||
if (e && typeof e === 'object') {
|
if (e && typeof e === 'object') {
|
||||||
return { path: String(e.path || '').trim(), makemkvIndex: Number.isFinite(Number(e.makemkvIndex)) ? Number(e.makemkvIndex) : 0 };
|
const p = String(e.path || '').trim();
|
||||||
|
if (!p) return null;
|
||||||
|
const idxRaw = Number(e.makemkvIndex);
|
||||||
|
return {
|
||||||
|
path: p,
|
||||||
|
makemkvIndex: Number.isFinite(idxRaw) && idxRaw >= 0 ? Math.trunc(idxRaw) : null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { path: '', makemkvIndex: 0 };
|
return null;
|
||||||
|
}).filter(Boolean);
|
||||||
|
const used = new Set();
|
||||||
|
let nextAutoIndex = 0;
|
||||||
|
return normalized.map((entry) => {
|
||||||
|
if (entry.makemkvIndex != null) {
|
||||||
|
used.add(entry.makemkvIndex);
|
||||||
|
if (entry.makemkvIndex >= nextAutoIndex) {
|
||||||
|
nextAutoIndex = entry.makemkvIndex + 1;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
while (used.has(nextAutoIndex)) {
|
||||||
|
nextAutoIndex += 1;
|
||||||
|
}
|
||||||
|
const resolved = { path: entry.path, makemkvIndex: nextAutoIndex };
|
||||||
|
used.add(nextAutoIndex);
|
||||||
|
nextAutoIndex += 1;
|
||||||
|
return resolved;
|
||||||
});
|
});
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
return [];
|
return [];
|
||||||
@@ -129,8 +153,7 @@ function DriveDevicesEditor({ value, onChange, settingKey }) {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...devices];
|
const next = [...devices];
|
||||||
const p = e.target.value;
|
const p = e.target.value;
|
||||||
const m = p.match(/sr(\d+)$/);
|
next[idx] = { ...next[idx], path: p };
|
||||||
next[idx] = { ...next[idx], path: p, makemkvIndex: m ? Number(m[1]) : next[idx].makemkvIndex };
|
|
||||||
updateDevices(next);
|
updateDevices(next);
|
||||||
}}
|
}}
|
||||||
className="drive-device-input"
|
className="drive-device-input"
|
||||||
@@ -357,6 +380,17 @@ function isNotificationEventToggleSetting(setting) {
|
|||||||
return setting?.type === 'boolean' && NOTIFICATION_EVENT_TOGGLE_KEYS.has(normalizeSettingKey(setting?.key));
|
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({
|
function SettingField({
|
||||||
setting,
|
setting,
|
||||||
value,
|
value,
|
||||||
@@ -367,7 +401,8 @@ function SettingField({
|
|||||||
ownerError,
|
ownerError,
|
||||||
ownerDirty,
|
ownerDirty,
|
||||||
onChange,
|
onChange,
|
||||||
variant = 'default'
|
variant = 'default',
|
||||||
|
disabled = false
|
||||||
}) {
|
}) {
|
||||||
const ownerKey = ownerSetting?.key;
|
const ownerKey = ownerSetting?.key;
|
||||||
const pathHasValue = Boolean(String(value ?? '').trim());
|
const pathHasValue = Boolean(String(value ?? '').trim());
|
||||||
@@ -384,13 +419,15 @@ function SettingField({
|
|||||||
<InputSwitch
|
<InputSwitch
|
||||||
id={setting.key}
|
id={setting.key}
|
||||||
checked={Boolean(value)}
|
checked={Boolean(value)}
|
||||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
disabled={disabled}
|
||||||
|
onChange={disabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<label htmlFor={setting.key}>
|
<label htmlFor={setting.key} className={disabled ? 'setting-label--readonly' : undefined}>
|
||||||
{setting.label}
|
{setting.label}
|
||||||
{setting.required && <span className="required">*</span>}
|
{setting.required && <span className="required">*</span>}
|
||||||
|
{disabled ? <span className="setting-badge--coming-soon"> (bald)</span> : null}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -424,7 +461,8 @@ function SettingField({
|
|||||||
<InputSwitch
|
<InputSwitch
|
||||||
id={setting.key}
|
id={setting.key}
|
||||||
checked={Boolean(value)}
|
checked={Boolean(value)}
|
||||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
disabled={disabled}
|
||||||
|
onChange={disabled ? undefined : (event) => onChange?.(setting.key, event.value)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -780,10 +818,22 @@ export default function DynamicSettingsForm({
|
|||||||
const notificationToggleKeys = new Set(
|
const notificationToggleKeys = new Set(
|
||||||
notificationToggleSettings.map((setting) => normalizeSettingKey(setting?.key))
|
notificationToggleSettings.map((setting) => normalizeSettingKey(setting?.key))
|
||||||
);
|
);
|
||||||
const regularSettings = baseSettings.filter(
|
|
||||||
(setting) => !notificationToggleKeys.has(normalizeSettingKey(setting?.key))
|
// Abhängigkeitsbaum aufbauen: depends_on → Kinder
|
||||||
);
|
const dependentsByParent = new Map();
|
||||||
const renderSetting = (setting, variant = 'default') => {
|
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 value = values?.[setting.key];
|
||||||
const error = errors?.[setting.key] || null;
|
const error = errors?.[setting.key] || null;
|
||||||
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||||
@@ -805,17 +855,53 @@ export default function DynamicSettingsForm({
|
|||||||
ownerValue={ownerValue}
|
ownerValue={ownerValue}
|
||||||
ownerError={ownerError}
|
ownerError={ownerError}
|
||||||
ownerDirty={ownerDirty}
|
ownerDirty={ownerDirty}
|
||||||
onChange={onChange}
|
onChange={onChangeFn}
|
||||||
variant={variant}
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{regularSettings.length > 0 ? (
|
{rootSettings.length > 0 ? (
|
||||||
<div className="settings-grid">
|
<div className="settings-grid">
|
||||||
{regularSettings.map((setting) => renderSetting(setting))}
|
{rootSettings.map((setting) => renderWithChildren(setting, 0))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{pushoverEnabled && notificationToggleSettings.length > 0 ? (
|
{pushoverEnabled && notificationToggleSettings.length > 0 ? (
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import { Dialog } from 'primereact/dialog';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
import { getStatusLabel } from '../utils/statusPresentation';
|
import { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation';
|
||||||
|
|
||||||
const CD_FORMAT_LABELS = {
|
const CD_FORMAT_LABELS = {
|
||||||
flac: 'FLAC',
|
flac: 'FLAC',
|
||||||
@@ -24,9 +25,10 @@ function JsonView({ title, value }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ScriptResultRow({ result }) {
|
function ScriptResultRow({ result }) {
|
||||||
const status = String(result?.status || '').toUpperCase();
|
const statusCode = String(result?.status || '').toUpperCase();
|
||||||
const isSuccess = status === 'SUCCESS';
|
const status = getProcessStatusLabel(statusCode);
|
||||||
const isError = status === 'ERROR';
|
const isSuccess = statusCode === 'SUCCESS';
|
||||||
|
const isError = statusCode === 'ERROR';
|
||||||
const icon = isSuccess ? 'pi-check-circle' : isError ? 'pi-times-circle' : 'pi-minus-circle';
|
const icon = isSuccess ? 'pi-check-circle' : isError ? 'pi-times-circle' : 'pi-minus-circle';
|
||||||
const tone = isSuccess ? 'success' : isError ? 'danger' : 'warning';
|
const tone = isSuccess ? 'success' : isError ? 'danger' : 'warning';
|
||||||
return (
|
return (
|
||||||
@@ -89,6 +91,21 @@ function normalizePositiveInteger(value) {
|
|||||||
return Math.trunc(parsed);
|
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) {
|
function formatDurationSeconds(totalSeconds) {
|
||||||
const parsed = Number(totalSeconds);
|
const parsed = Number(totalSeconds);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
@@ -350,7 +367,11 @@ function resolveCdDetails(job) {
|
|||||||
selectedMetadata?.mbId
|
selectedMetadata?.mbId
|
||||||
|| selectedMetadata?.musicBrainzId
|
|| selectedMetadata?.musicBrainzId
|
||||||
|| selectedMetadata?.musicbrainzId
|
|| selectedMetadata?.musicbrainzId
|
||||||
|
|| selectedMetadata?.musicbrainz_id
|
||||||
|
|| selectedMetadata?.music_brainz_id
|
||||||
|
|| selectedMetadata?.musicbrainz
|
||||||
|| selectedMetadata?.mbid
|
|| selectedMetadata?.mbid
|
||||||
|
|| job?.imdb_id
|
||||||
|| ''
|
|| ''
|
||||||
).trim() || null;
|
).trim() || null;
|
||||||
|
|
||||||
@@ -465,6 +486,59 @@ function BoolState({ value }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSelectionStateMeta({ selected, outcome }) {
|
||||||
|
if (!selected) {
|
||||||
|
return {
|
||||||
|
label: 'Nicht ausgewählt',
|
||||||
|
icon: 'pi-minus-circle',
|
||||||
|
className: 'track-selection-inline-neutral',
|
||||||
|
title: 'Nicht ausgewählt'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (outcome === 'success') {
|
||||||
|
return {
|
||||||
|
label: 'Ausgewählt',
|
||||||
|
icon: 'pi-check-circle',
|
||||||
|
className: 'job-step-inline-ok',
|
||||||
|
title: 'Ausgewählt und erfolgreich'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (outcome === 'error') {
|
||||||
|
return {
|
||||||
|
label: 'Ausgewählt',
|
||||||
|
icon: 'pi-times-circle',
|
||||||
|
className: 'job-step-inline-no',
|
||||||
|
title: 'Ausgewählt, aber nicht erfolgreich'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (outcome === 'cancelled') {
|
||||||
|
return {
|
||||||
|
label: 'Ausgewählt',
|
||||||
|
icon: 'pi-ban',
|
||||||
|
className: 'job-step-inline-warn',
|
||||||
|
title: 'Ausgewählt, aber abgebrochen'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: 'Ausgewählt',
|
||||||
|
icon: 'pi-clock',
|
||||||
|
className: 'track-selection-inline-neutral',
|
||||||
|
title: 'Ausgewählt'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectionStateNote({ selected, outcome }) {
|
||||||
|
const meta = resolveSelectionStateMeta({ selected, outcome });
|
||||||
|
return (
|
||||||
|
<small className="track-action-note">
|
||||||
|
<span className={meta.className} title={meta.title}>
|
||||||
|
<span>{meta.label}</span>
|
||||||
|
<i className={`pi ${meta.icon}`} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</small>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PathField({
|
function PathField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -513,11 +587,13 @@ export default function JobDetailDialog({
|
|||||||
onResumeReady,
|
onResumeReady,
|
||||||
onRestartEncode,
|
onRestartEncode,
|
||||||
onRestartReview,
|
onRestartReview,
|
||||||
|
onRestartCdReview,
|
||||||
onReencode,
|
onReencode,
|
||||||
onRetry,
|
onRetry,
|
||||||
onDeleteFiles,
|
onDeleteFiles,
|
||||||
onDeleteEntry,
|
onDeleteEntry,
|
||||||
onDownloadArchive,
|
onDownloadArchive,
|
||||||
|
onDownloadOutputFolder,
|
||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
isQueued = false,
|
isQueued = false,
|
||||||
omdbAssignBusy = false,
|
omdbAssignBusy = false,
|
||||||
@@ -525,20 +601,90 @@ export default function JobDetailDialog({
|
|||||||
actionBusy = false,
|
actionBusy = false,
|
||||||
reencodeBusy = false,
|
reencodeBusy = false,
|
||||||
deleteEntryBusy = false,
|
deleteEntryBusy = false,
|
||||||
downloadBusyTarget = null
|
downloadBusyTarget = null,
|
||||||
|
downloadFolderBusyPath = null
|
||||||
}) {
|
}) {
|
||||||
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
||||||
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
|
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(job?.status);
|
||||||
const showFinalLog = !running;
|
const showFinalLog = !running;
|
||||||
const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && mkDone && !running);
|
const mediaType = resolveMediaType(job);
|
||||||
|
const isCd = mediaType === 'cd';
|
||||||
|
const isAudiobook = mediaType === 'audiobook';
|
||||||
|
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(
|
const canResumeReady = Boolean(
|
||||||
(String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' || String(job?.last_state || '').trim().toUpperCase() === 'READY_TO_ENCODE')
|
(String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' || String(job?.last_state || '').trim().toUpperCase() === 'READY_TO_ENCODE')
|
||||||
&& !running
|
&& !running
|
||||||
&& typeof onResumeReady === 'function'
|
&& typeof onResumeReady === 'function'
|
||||||
);
|
);
|
||||||
const mediaType = resolveMediaType(job);
|
|
||||||
const isCd = mediaType === 'cd';
|
|
||||||
const isAudiobook = mediaType === 'audiobook';
|
|
||||||
const hasConfirmedPlan = Boolean(
|
const hasConfirmedPlan = Boolean(
|
||||||
job?.encodePlan
|
job?.encodePlan
|
||||||
&& Array.isArray(job?.encodePlan?.titles)
|
&& Array.isArray(job?.encodePlan?.titles)
|
||||||
@@ -590,6 +736,8 @@ export default function JobDetailDialog({
|
|||||||
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
|
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
|
||||||
const canDownloadRaw = Boolean(job?.raw_path && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
|
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 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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -804,13 +952,34 @@ export default function JobDetailDialog({
|
|||||||
downloadDisabled={!canDownloadRaw}
|
downloadDisabled={!canDownloadRaw}
|
||||||
downloadLoading={downloadBusyTarget === 'raw'}
|
downloadLoading={downloadBusyTarget === 'raw'}
|
||||||
/>
|
/>
|
||||||
<PathField
|
{outputFolders.length > 0 ? (
|
||||||
label="Output:"
|
outputFolders.map((folder, idx) => {
|
||||||
value={job.output_path}
|
const folderPath = String(folder?.output_path || '').trim();
|
||||||
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
|
if (!folderPath) {
|
||||||
downloadDisabled={!canDownloadOutput}
|
return null;
|
||||||
downloadLoading={downloadBusyTarget === 'output'}
|
}
|
||||||
/>
|
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}
|
||||||
|
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
|
||||||
|
downloadDisabled={!canDownloadOutput}
|
||||||
|
downloadLoading={downloadBusyTarget === 'output'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{job.error_message ? (
|
{job.error_message ? (
|
||||||
<div><strong>Fehler:</strong> {job.error_message}</div>
|
<div><strong>Fehler:</strong> {job.error_message}</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -940,31 +1109,26 @@ export default function JobDetailDialog({
|
|||||||
{tracksRaw.map((track, i) => {
|
{tracksRaw.map((track, i) => {
|
||||||
const pos = Number(track.position ?? (i + 1));
|
const pos = Number(track.position ?? (i + 1));
|
||||||
const label = String(track.title || track.name || `Track ${pos}`).trim();
|
const label = String(track.title || track.name || `Track ${pos}`).trim();
|
||||||
const artist = String(track.artist || '').trim();
|
const artist = String(track.artist || '').trim() || String(job?.makemkvInfo?.selectedMetadata?.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 selected = selectedSet ? selectedSet.has(pos) : track.selected !== false;
|
const selected = selectedSet ? selectedSet.has(pos) : track.selected !== false;
|
||||||
const encodeResult = encodeResultMap.get(pos);
|
const encodeResult = encodeResultMap.get(pos);
|
||||||
const encodeLabel = !selected ? 'Nicht übernommen'
|
const outcome = !selected
|
||||||
: encodeResult ? (encodeResult.success ? 'Erfolgreich' : 'Fehler')
|
? null
|
||||||
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'Erfolgreich' : 'Fehler') : 'Ausgewählt');
|
: encodeResult
|
||||||
const encodeClass = !selected ? ''
|
? (encodeResult.success ? 'success' : 'error')
|
||||||
: encodeResult ? (encodeResult.success ? 'tone-ok' : 'tone-no')
|
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'success' : 'error') : null);
|
||||||
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'tone-ok' : 'tone-no') : '');
|
|
||||||
return (
|
return (
|
||||||
<div key={pos} className="track-item">
|
<div key={pos} className="track-item track-item-inline-status">
|
||||||
<span>#{pos} | {label}{artist ? ` | ${artist}` : ''} | {durLabel}</span>
|
<span className="track-item-main">#{pos} {artist ? `${artist} - ` : ''}{label}</span>
|
||||||
<small className={`track-action-note ${encodeClass}`}>Encode: {encodeLabel}</small>
|
<SelectionStateNote selected={selected} outcome={outcome} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})() : (() => {
|
})() : (() => {
|
||||||
|
const audiobookFormat = String(job?.handbrakeInfo?.format || job?.encodePlan?.format || '').trim().toLowerCase();
|
||||||
|
const isSplitAudiobook = Boolean(audiobookFormat) && audiobookFormat !== 'm4b';
|
||||||
const chapters = Array.isArray(job.handbrakeInfo?.metadata?.chapters) && job.handbrakeInfo.metadata.chapters.length > 0
|
const chapters = Array.isArray(job.handbrakeInfo?.metadata?.chapters) && job.handbrakeInfo.metadata.chapters.length > 0
|
||||||
? job.handbrakeInfo.metadata.chapters
|
? job.handbrakeInfo.metadata.chapters
|
||||||
: (Array.isArray(job.makemkvInfo?.chapters) && job.makemkvInfo.chapters.length > 0
|
: (Array.isArray(job.makemkvInfo?.chapters) && job.makemkvInfo.chapters.length > 0
|
||||||
@@ -988,6 +1152,15 @@ export default function JobDetailDialog({
|
|||||||
: '-';
|
: '-';
|
||||||
const step = stepsByIndex.get(id);
|
const step = stepsByIndex.get(id);
|
||||||
const stepStatus = step ? String(step.status || '').toUpperCase() : null;
|
const stepStatus = step ? String(step.status || '').toUpperCase() : null;
|
||||||
|
const outcome = stepStatus === 'SUCCESS'
|
||||||
|
? 'success'
|
||||||
|
: stepStatus === 'ERROR'
|
||||||
|
? 'error'
|
||||||
|
: stepStatus === 'CANCELLED'
|
||||||
|
? 'cancelled'
|
||||||
|
: (job?.encodeSuccess != null
|
||||||
|
? (job.encodeSuccess ? 'success' : (String(job?.status || '').trim().toUpperCase() === 'CANCELLED' ? 'cancelled' : 'error'))
|
||||||
|
: null);
|
||||||
const encodeLabel = stepStatus === 'SUCCESS' ? 'Erfolgreich'
|
const encodeLabel = stepStatus === 'SUCCESS' ? 'Erfolgreich'
|
||||||
: stepStatus === 'ERROR' ? 'Fehler'
|
: stepStatus === 'ERROR' ? 'Fehler'
|
||||||
: stepStatus === 'CANCELLED' ? 'Abgebrochen'
|
: stepStatus === 'CANCELLED' ? 'Abgebrochen'
|
||||||
@@ -997,9 +1170,13 @@ export default function JobDetailDialog({
|
|||||||
: stepStatus === 'ERROR' ? 'tone-no'
|
: stepStatus === 'ERROR' ? 'tone-no'
|
||||||
: '';
|
: '';
|
||||||
return (
|
return (
|
||||||
<div key={id} className="track-item">
|
<div key={id} className={`track-item${isSplitAudiobook ? ' track-item-inline-status' : ''}`}>
|
||||||
<span>#{id} | {label} | {durLabel}</span>
|
<span className={isSplitAudiobook ? 'track-item-main' : undefined}>#{id} | {label} | {durLabel}</span>
|
||||||
<small className={`track-action-note ${encodeClass}`}>Encode: {encodeLabel}</small>
|
{isSplitAudiobook ? (
|
||||||
|
<SelectionStateNote selected outcome={outcome} />
|
||||||
|
) : (
|
||||||
|
<small className={`track-action-note ${encodeClass}`}>Encode: {encodeLabel}</small>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1076,7 +1253,53 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="actions-section">
|
<div className="actions-section">
|
||||||
{!isCd ? (
|
{isCd ? (
|
||||||
|
<div className="actions-group">
|
||||||
|
<div className="actions-group-label"><i className="pi pi-play-circle" /> Audio CD</div>
|
||||||
|
<div className="action-item">
|
||||||
|
<Button
|
||||||
|
label="Encode neu starten"
|
||||||
|
icon="pi pi-sync"
|
||||||
|
severity="info"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onReencode?.(job)}
|
||||||
|
loading={reencodeBusy}
|
||||||
|
disabled={!canCdDirectEncode || typeof onReencode !== 'function'}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
<div className="action-item">
|
||||||
|
<Button
|
||||||
|
label="MusicBrainz neu zuweisen"
|
||||||
|
icon="pi pi-tag"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
size="small"
|
||||||
|
onClick={() => onAssignCdMetadata?.(job)}
|
||||||
|
loading={cdMetadataAssignBusy}
|
||||||
|
disabled={running || typeof onAssignCdMetadata !== 'function'}
|
||||||
|
/>
|
||||||
|
<span className="action-desc">Öffnet die MusicBrainz-Suche, um Album-Metadaten (Titel, Interpret, Tracks) neu zuzuweisen.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="actions-group">
|
<div className="actions-group">
|
||||||
<div className="actions-group-label"><i className="pi pi-play-circle" /> Kodierung</div>
|
<div className="actions-group-label"><i className="pi pi-play-circle" /> Kodierung</div>
|
||||||
{canResumeReady ? (
|
{canResumeReady ? (
|
||||||
@@ -1141,6 +1364,25 @@ export default function JobDetailDialog({
|
|||||||
}</span>
|
}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isCd && !isAudiobook && typeof onAssignOmdb === 'function' ? (
|
||||||
|
<div className="actions-group">
|
||||||
|
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
|
||||||
|
<div className="action-item">
|
||||||
|
<Button
|
||||||
|
label="OMDB neu zuweisen"
|
||||||
|
icon="pi pi-search"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
size="small"
|
||||||
|
onClick={() => onAssignOmdb?.(job)}
|
||||||
|
loading={omdbAssignBusy}
|
||||||
|
disabled={running}
|
||||||
|
/>
|
||||||
|
<span className="action-desc">Öffnet die OMDB-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="actions-group">
|
<div className="actions-group">
|
||||||
@@ -1167,7 +1409,7 @@ export default function JobDetailDialog({
|
|||||||
size="small"
|
size="small"
|
||||||
onClick={() => onDeleteFiles?.(job, 'movie')}
|
onClick={() => onDeleteFiles?.(job, 'movie')}
|
||||||
loading={actionBusy}
|
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>
|
<span className="action-desc">Löscht {isCd ? 'die fertig gerippten Audiodateien' : isAudiobook ? 'die fertig konvertierte Audiobook-Ausgabe' : 'die fertig encodierte Filmdatei'}.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ export default function PipelineStatusCard({
|
|||||||
onSelectHandBrakeTitle,
|
onSelectHandBrakeTitle,
|
||||||
onCancel,
|
onCancel,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
onDeleteJob,
|
||||||
isQueued = false,
|
isQueued = false,
|
||||||
busy,
|
busy,
|
||||||
liveJobLog = ''
|
liveJobLog = ''
|
||||||
@@ -832,6 +833,16 @@ export default function PipelineStatusCard({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{retryJobId && typeof onDeleteJob === 'function' ? (
|
||||||
|
<Button
|
||||||
|
label="Job löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => onDeleteJob?.(retryJobId)}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{running ? (
|
{running ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import { Card } from 'primereact/card';
|
import { Card } from 'primereact/card';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
@@ -24,6 +24,7 @@ import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/sta
|
|||||||
|
|
||||||
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
|
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
|
||||||
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
|
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
|
||||||
|
const activeCdDriveStates = ['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING'];
|
||||||
const dashboardStatuses = new Set([
|
const dashboardStatuses = new Set([
|
||||||
'ANALYZING',
|
'ANALYZING',
|
||||||
'METADATA_SELECTION',
|
'METADATA_SELECTION',
|
||||||
@@ -41,6 +42,14 @@ const dashboardStatuses = new Set([
|
|||||||
'CD_RIPPING',
|
'CD_RIPPING',
|
||||||
'CD_ENCODING'
|
'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) {
|
function normalizeJobId(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
@@ -187,6 +196,40 @@ function runtimeOutcomeMeta(outcome, status) {
|
|||||||
return runtimeStatusMeta(status);
|
return runtimeStatusMeta(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function driveStateIconMeta(stateLabel) {
|
||||||
|
const normalized = String(stateLabel || '').trim().toUpperCase();
|
||||||
|
if (!normalized || normalized === 'LEER' || normalized === 'IDLE') {
|
||||||
|
return { icon: 'pi-stop', spin: false };
|
||||||
|
}
|
||||||
|
if (normalized === 'DISC_DETECTED') {
|
||||||
|
return { icon: 'pi-check', spin: false };
|
||||||
|
}
|
||||||
|
if (normalized === 'FINISHED') {
|
||||||
|
return { icon: 'pi-check-circle', spin: false };
|
||||||
|
}
|
||||||
|
if (normalized === 'ERROR' || normalized === 'CANCELLED') {
|
||||||
|
return { icon: 'pi-times-circle', spin: false };
|
||||||
|
}
|
||||||
|
if (processingStates.includes(normalized)) {
|
||||||
|
return { icon: 'pi-spinner', spin: true };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === 'METADATA_SELECTION'
|
||||||
|
|| normalized === 'CD_METADATA_SELECTION'
|
||||||
|
|| normalized === 'WAITING_FOR_USER_DECISION'
|
||||||
|
) {
|
||||||
|
return { icon: 'pi-list', spin: false };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === 'READY_TO_START'
|
||||||
|
|| normalized === 'READY_TO_ENCODE'
|
||||||
|
|| normalized === 'CD_READY_TO_RIP'
|
||||||
|
) {
|
||||||
|
return { icon: 'pi-play', spin: false };
|
||||||
|
}
|
||||||
|
return { icon: 'pi-question-circle', spin: false };
|
||||||
|
}
|
||||||
|
|
||||||
function hasRuntimeOutputDetails(item) {
|
function hasRuntimeOutputDetails(item) {
|
||||||
if (!item || typeof item !== 'object') {
|
if (!item || typeof item !== 'object') {
|
||||||
return false;
|
return false;
|
||||||
@@ -294,6 +337,20 @@ function normalizeQueue(queue) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCdDriveLifecycleKey(cdDrives) {
|
||||||
|
if (!cdDrives || typeof cdDrives !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const entries = Object.entries(cdDrives)
|
||||||
|
.map(([devicePath, driveState]) => {
|
||||||
|
const state = String(driveState?.state || '').trim().toUpperCase();
|
||||||
|
const jobId = normalizeJobId(driveState?.jobId || driveState?.context?.jobId) || 0;
|
||||||
|
return `${devicePath}|${jobId}|${state}`;
|
||||||
|
})
|
||||||
|
.sort();
|
||||||
|
return entries.join('::');
|
||||||
|
}
|
||||||
|
|
||||||
function getQueueActionResult(response) {
|
function getQueueActionResult(response) {
|
||||||
return response?.result && typeof response.result === 'object' ? response.result : {};
|
return response?.result && typeof response.result === 'object' ? response.result : {};
|
||||||
}
|
}
|
||||||
@@ -721,6 +778,16 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
&& !processingStates.includes(jobStatus)
|
&& !processingStates.includes(jobStatus)
|
||||||
&& resolvedMediaType !== 'audiobook'
|
&& resolvedMediaType !== 'audiobook'
|
||||||
);
|
);
|
||||||
|
const cdSkipRipReady = Boolean(
|
||||||
|
resolvedMediaType === 'cd'
|
||||||
|
&& (
|
||||||
|
Boolean(encodePlan?.skipRip)
|
||||||
|
|| (
|
||||||
|
Number(job?.rip_successful || 0) === 1
|
||||||
|
&& Boolean(String(job?.raw_path || '').trim())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
const computedContext = {
|
const computedContext = {
|
||||||
jobId,
|
jobId,
|
||||||
rawPath: job?.raw_path || null,
|
rawPath: job?.raw_path || null,
|
||||||
@@ -731,6 +798,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
devicePath,
|
devicePath,
|
||||||
cdparanoiaCmd,
|
cdparanoiaCmd,
|
||||||
cdparanoiaCommandPreview,
|
cdparanoiaCommandPreview,
|
||||||
|
skipRip: cdSkipRipReady ? true : undefined,
|
||||||
cdRipConfig,
|
cdRipConfig,
|
||||||
tracks: cdTracks,
|
tracks: cdTracks,
|
||||||
inputPath,
|
inputPath,
|
||||||
@@ -790,6 +858,9 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
const liveContext = liveJobProgress?.context && typeof liveJobProgress.context === 'object'
|
const liveContext = liveJobProgress?.context && typeof liveJobProgress.context === 'object'
|
||||||
? liveJobProgress.context
|
? liveJobProgress.context
|
||||||
: null;
|
: 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
|
const mergedContext = liveContext
|
||||||
? {
|
? {
|
||||||
...computedContext,
|
...computedContext,
|
||||||
@@ -803,11 +874,15 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
: computedContext;
|
: computedContext;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: liveJobProgress?.state || jobStatus,
|
state: ignoreStaleLiveProgress ? jobStatus : (liveJobProgress?.state || jobStatus),
|
||||||
activeJobId: jobId,
|
activeJobId: jobId,
|
||||||
progress: liveJobProgress != null ? Number(liveJobProgress.progress ?? 0) : 0,
|
progress: ignoreStaleLiveProgress
|
||||||
eta: liveJobProgress?.eta || null,
|
? 0
|
||||||
statusText: liveJobProgress?.statusText || job?.error_message || null,
|
: (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
|
context: mergedContext
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -822,8 +897,10 @@ export default function DashboardPage({
|
|||||||
jobsRefreshToken,
|
jobsRefreshToken,
|
||||||
pendingExpandedJobId,
|
pendingExpandedJobId,
|
||||||
onPendingExpandedJobHandled,
|
onPendingExpandedJobHandled,
|
||||||
downloadSummary = null
|
downloadSummary = null,
|
||||||
|
children = null
|
||||||
}) {
|
}) {
|
||||||
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [busyJobIds, setBusyJobIds] = useState(() => new Set());
|
const [busyJobIds, setBusyJobIds] = useState(() => new Set());
|
||||||
@@ -872,6 +949,7 @@ export default function DashboardPage({
|
|||||||
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
|
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
|
||||||
const [knownDrives, setKnownDrives] = useState([]);
|
const [knownDrives, setKnownDrives] = useState([]);
|
||||||
const [driveRescanBusy, setDriveRescanBusy] = useState(() => new Set());
|
const [driveRescanBusy, setDriveRescanBusy] = useState(() => new Set());
|
||||||
|
const [driveAnalyzeBusy, setDriveAnalyzeBusy] = useState(() => new Set());
|
||||||
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
|
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
|
||||||
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
|
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
|
||||||
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
|
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
|
||||||
@@ -886,6 +964,10 @@ export default function DashboardPage({
|
|||||||
() => normalizeHardwareMonitoringPayload(hardwareMonitoring),
|
() => normalizeHardwareMonitoringPayload(hardwareMonitoring),
|
||||||
[hardwareMonitoring]
|
[hardwareMonitoring]
|
||||||
);
|
);
|
||||||
|
const cdDriveLifecycleKey = useMemo(
|
||||||
|
() => buildCdDriveLifecycleKey(pipeline?.cdDrives),
|
||||||
|
[pipeline?.cdDrives]
|
||||||
|
);
|
||||||
const monitoringSample = monitoringState.sample;
|
const monitoringSample = monitoringState.sample;
|
||||||
const cpuMetrics = monitoringSample?.cpu || null;
|
const cpuMetrics = monitoringSample?.cpu || null;
|
||||||
const memoryMetrics = monitoringSample?.memory || null;
|
const memoryMetrics = monitoringSample?.memory || null;
|
||||||
@@ -955,6 +1037,7 @@ export default function DashboardPage({
|
|||||||
: audiobookUploadPhase === 'error'
|
: audiobookUploadPhase === 'error'
|
||||||
? 'Fehler'
|
? 'Fehler'
|
||||||
: 'Inaktiv';
|
: 'Inaktiv';
|
||||||
|
const isSubpageRoute = location.pathname !== '/';
|
||||||
|
|
||||||
const loadDashboardJobs = async () => {
|
const loadDashboardJobs = async () => {
|
||||||
setJobsLoading(true);
|
setJobsLoading(true);
|
||||||
@@ -973,18 +1056,45 @@ export default function DashboardPage({
|
|||||||
if (queueResponse.status === 'fulfilled') {
|
if (queueResponse.status === 'fulfilled') {
|
||||||
setQueueState(normalizeQueue(queueResponse.value?.queue));
|
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
|
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));
|
.sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0));
|
||||||
|
|
||||||
if (currentPipelineJobId && !next.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
|
const pinnedJobIds = new Set();
|
||||||
try {
|
if (currentPipelineJobId) {
|
||||||
const active = await api.getJob(currentPipelineJobId, { lite: true });
|
pinnedJobIds.add(currentPipelineJobId);
|
||||||
if (active?.job) {
|
}
|
||||||
next.unshift(active.job);
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,13 +1166,43 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
}, [pipeline?.cdDrives]);
|
}, [pipeline?.cdDrives]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cdMetadataDialogVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dialogJobId = normalizeJobId(cdMetadataDialogContext?.jobId);
|
||||||
|
if (!dialogJobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cdDrives = pipeline?.cdDrives;
|
||||||
|
if (!cdDrives || typeof cdDrives !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stillInMetadataSelection = false;
|
||||||
|
for (const drive of Object.values(cdDrives)) {
|
||||||
|
const driveState = String(drive?.state || '').trim().toUpperCase();
|
||||||
|
const ctx = drive?.context && typeof drive.context === 'object' ? drive.context : null;
|
||||||
|
const driveJobId = normalizeJobId(ctx?.jobId || drive?.jobId);
|
||||||
|
if (driveJobId === dialogJobId && driveState === 'CD_METADATA_SELECTION') {
|
||||||
|
stillInMetadataSelection = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stillInMetadataSelection) {
|
||||||
|
setCdMetadataDialogVisible(false);
|
||||||
|
setCdMetadataDialogContext(null);
|
||||||
|
}
|
||||||
|
}, [cdMetadataDialogVisible, cdMetadataDialogContext?.jobId, pipeline?.cdDrives]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQueueState(normalizeQueue(pipeline?.queue));
|
setQueueState(normalizeQueue(pipeline?.queue));
|
||||||
}, [pipeline?.queue]);
|
}, [pipeline?.queue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadDashboardJobs();
|
void loadDashboardJobs();
|
||||||
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, jobsRefreshToken]);
|
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, cdDriveLifecycleKey, jobsRefreshToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getDetectedDrives()
|
api.getDetectedDrives()
|
||||||
@@ -1323,13 +1463,54 @@ export default function DashboardPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAnalyzeForDrive = async (devicePath) => {
|
const handleAnalyzeForDrive = async (devicePath) => {
|
||||||
setBusy(true);
|
setDriveAnalyzeBusy((prev) => new Set([...prev, devicePath]));
|
||||||
try {
|
try {
|
||||||
await api.analyzeDisc(devicePath);
|
await api.analyzeDisc(devicePath);
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
await loadDashboardJobs();
|
await loadDashboardJobs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error);
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
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 {
|
||||||
|
const response = await api.rescanDisc();
|
||||||
|
refreshKnownDrives();
|
||||||
|
const allDetected = Array.isArray(response?.result?.allDetected) ? response.result.allDetected : [];
|
||||||
|
const count = allDetected.length;
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: count > 0 ? 'success' : 'info',
|
||||||
|
summary: 'Laufwerke neu gelesen',
|
||||||
|
detail: count > 0
|
||||||
|
? `${count > 1 ? `${count} Medien` : '1 Medium'} erkannt.`
|
||||||
|
: 'Kein Medium erkannt.',
|
||||||
|
life: 2800
|
||||||
|
});
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -1341,32 +1522,6 @@ export default function DashboardPage({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRescan = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await api.rescanDisc();
|
|
||||||
refreshKnownDrives();
|
|
||||||
const allDetected = response?.result?.allDetected || [];
|
|
||||||
const emitted = response?.result?.emitted || 'none';
|
|
||||||
const count = allDetected.length;
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: count > 0 ? 'success' : 'info',
|
|
||||||
summary: 'Laufwerke neu gelesen',
|
|
||||||
detail: count > 0
|
|
||||||
? `${count} Medium${count > 1 ? 'en' : ''} erkannt.`
|
|
||||||
: 'Kein Medium erkannt.',
|
|
||||||
life: 2800
|
|
||||||
});
|
|
||||||
void emitted; // used implicitly via count
|
|
||||||
await refreshPipeline();
|
|
||||||
await loadDashboardJobs();
|
|
||||||
} catch (error) {
|
|
||||||
showError(error);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRescanDrive = async (devicePath) => {
|
const handleRescanDrive = async (devicePath) => {
|
||||||
setDriveRescanBusy((prev) => new Set([...prev, devicePath]));
|
setDriveRescanBusy((prev) => new Set([...prev, devicePath]));
|
||||||
try {
|
try {
|
||||||
@@ -1479,18 +1634,28 @@ export default function DashboardPage({
|
|||||||
try {
|
try {
|
||||||
const response = await api.deleteJobFiles(jobId, effectiveTarget);
|
const response = await api.deleteJobFiles(jobId, effectiveTarget);
|
||||||
const summary = response?.summary || {};
|
const summary = response?.summary || {};
|
||||||
const deletedFiles = effectiveTarget === 'raw'
|
const targetSummary = effectiveTarget === 'raw'
|
||||||
? (summary.raw?.filesDeleted ?? 0)
|
? (summary.raw || {})
|
||||||
: (summary.movie?.filesDeleted ?? 0);
|
: (summary.movie || {});
|
||||||
const removedDirs = effectiveTarget === 'raw'
|
const deletedFiles = targetSummary.filesDeleted ?? 0;
|
||||||
? (summary.raw?.dirsRemoved ?? 0)
|
const removedDirs = targetSummary.dirsRemoved ?? 0;
|
||||||
: (summary.movie?.dirsRemoved ?? 0);
|
const deletedSomething = Boolean(targetSummary.deleted);
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'success',
|
if (!deletedSomething) {
|
||||||
summary: effectiveTarget === 'raw' ? 'RAW gelöscht' : 'Movie gelöscht',
|
toastRef.current?.show({
|
||||||
detail: `Entfernt: ${deletedFiles} Datei(en), ${removedDirs} Ordner.`,
|
severity: 'warn',
|
||||||
life: 4000
|
summary: effectiveTarget === 'raw' ? 'RAW nicht gelöscht' : 'Movie nicht gelöscht',
|
||||||
});
|
detail: targetSummary.reason || 'Keine passenden Dateien/Ordner gefunden.',
|
||||||
|
life: 4200
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: effectiveTarget === 'raw' ? 'RAW gelöscht' : 'Movie gelöscht',
|
||||||
|
detail: `Entfernt: ${deletedFiles} Datei(en), ${removedDirs} Ordner.`,
|
||||||
|
life: 4000
|
||||||
|
});
|
||||||
|
}
|
||||||
await loadDashboardJobs();
|
await loadDashboardJobs();
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null });
|
setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null });
|
||||||
@@ -1714,6 +1879,58 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteDashboardJob = async (jobId) => {
|
||||||
|
const normalizedJobId = normalizeJobId(jobId);
|
||||||
|
if (!normalizedJobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
||||||
|
const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim();
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Job #${normalizedJobId} wirklich löschen?\n` +
|
||||||
|
`${title}\n\n` +
|
||||||
|
'Hinweis: Dateien bleiben erhalten. Das zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.'
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setJobBusy(normalizedJobId, true);
|
||||||
|
try {
|
||||||
|
const statusBeforeDelete = String(job?.status || '').trim().toUpperCase();
|
||||||
|
if (processingStates.includes(statusBeforeDelete)) {
|
||||||
|
await api.cancelPipeline(normalizedJobId);
|
||||||
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||||
|
const latest = await api.getJob(normalizedJobId, { lite: true, forceRefresh: true }).catch(() => null);
|
||||||
|
const latestStatus = String(latest?.job?.status || latest?.job?.last_state || '').trim().toUpperCase();
|
||||||
|
if (!processingStates.includes(latestStatus)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await wait(250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await api.deleteJobEntry(normalizedJobId, 'none', {
|
||||||
|
includeRelated: false,
|
||||||
|
resetDriveState: true,
|
||||||
|
keepDetectedDevice: false
|
||||||
|
});
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
setExpandedJobId((prev) => (normalizeJobId(prev) === normalizedJobId ? null : prev));
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Job gelöscht',
|
||||||
|
detail: `Job #${normalizedJobId} wurde entfernt.`,
|
||||||
|
life: 3200
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setJobBusy(normalizedJobId, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRestartEncodeWithLastSettings = async (jobId) => {
|
const handleRestartEncodeWithLastSettings = async (jobId) => {
|
||||||
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
|
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
|
||||||
const title = job?.title || job?.detected_title || `Job #${jobId}`;
|
const title = job?.title || job?.detected_title || `Job #${jobId}`;
|
||||||
@@ -1768,6 +1985,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 handleQueueDragEnter = (targetEntryId) => {
|
||||||
const targetId = Number(targetEntryId);
|
const targetId = Number(targetEntryId);
|
||||||
const draggedId = Number(draggingQueueEntryId);
|
const draggedId = Number(draggingQueueEntryId);
|
||||||
@@ -2029,7 +2271,6 @@ export default function DashboardPage({
|
|||||||
const lastNonCdDiscEvent = lastDiscEvent?.mediaProfile !== 'cd' ? lastDiscEvent : null;
|
const lastNonCdDiscEvent = lastDiscEvent?.mediaProfile !== 'cd' ? lastDiscEvent : null;
|
||||||
const contextDevice = pipeline?.context?.device;
|
const contextDevice = pipeline?.context?.device;
|
||||||
const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null);
|
const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null);
|
||||||
const cdDriveEntries = Object.entries(pipeline?.cdDrives || {});
|
|
||||||
|
|
||||||
// Unified list of all known drives: merge lsblk drives + CD state + non-CD pipeline state
|
// Unified list of all known drives: merge lsblk drives + CD state + non-CD pipeline state
|
||||||
const allDrives = useMemo(() => {
|
const allDrives = useMemo(() => {
|
||||||
@@ -2045,11 +2286,20 @@ export default function DashboardPage({
|
|||||||
const base = driveMap.get(device.path) || { path: device.path, model: device.model };
|
const base = driveMap.get(device.path) || { path: device.path, model: device.model };
|
||||||
driveMap.set(device.path, { ...base, pipelineDevice: device, pipelineState: state });
|
driveMap.set(device.path, { ...base, pipelineDevice: device, pipelineState: state });
|
||||||
}
|
}
|
||||||
return Array.from(driveMap.values()).sort((a, b) => (a.path || '').localeCompare(b.path || ''));
|
// For drives that the detection service found but aren't tracked by cdDrives or the global
|
||||||
}, [knownDrives, pipeline?.cdDrives, device, state]);
|
// 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 isDriveActive = driveActiveStates.includes(state);
|
||||||
const canRescan = !isDriveActive;
|
const canRescan = !isDriveActive;
|
||||||
const canReanalyze = !isDriveActive && (state === 'ENCODING' ? Boolean(device) : !processingStates.includes(state));
|
|
||||||
const canOpenMetadataModal = Boolean(defaultMetadataDialogContext?.jobId);
|
const canOpenMetadataModal = Boolean(defaultMetadataDialogContext?.jobId);
|
||||||
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
|
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
|
||||||
const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
|
const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
|
||||||
@@ -2289,33 +2539,7 @@ export default function DashboardPage({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Disk-Information">
|
<Card title="Disk-Information">
|
||||||
{/* Global action buttons (non-CD / DVD / Bluray) */}
|
{/* Per-drive list */}
|
||||||
<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 */}
|
|
||||||
{allDrives.length > 0 ? (
|
{allDrives.length > 0 ? (
|
||||||
<div className="drive-list">
|
<div className="drive-list">
|
||||||
{allDrives.map((drv) => {
|
{allDrives.map((drv) => {
|
||||||
@@ -2323,6 +2547,7 @@ export default function DashboardPage({
|
|||||||
const cdDrive = drv.cdDrive;
|
const cdDrive = drv.cdDrive;
|
||||||
const pipelineDevice = drv.pipelineDevice;
|
const pipelineDevice = drv.pipelineDevice;
|
||||||
const isRescanBusy = driveRescanBusy.has(drivePath);
|
const isRescanBusy = driveRescanBusy.has(drivePath);
|
||||||
|
const isAnalyzeBusy = driveAnalyzeBusy.has(drivePath);
|
||||||
|
|
||||||
// Determine display state
|
// Determine display state
|
||||||
let stateLabel = null;
|
let stateLabel = null;
|
||||||
@@ -2352,6 +2577,10 @@ export default function DashboardPage({
|
|||||||
: processingStates.includes(s) ? 'warning'
|
: processingStates.includes(s) ? 'warning'
|
||||||
: 'info';
|
: 'info';
|
||||||
discInfo = pipelineDevice.discLabel || pipelineDevice.label || null;
|
discInfo = pipelineDevice.discLabel || pipelineDevice.label || null;
|
||||||
|
} else if (drv.detectedDisc) {
|
||||||
|
stateLabel = 'DISC_DETECTED';
|
||||||
|
stateSeverity = 'info';
|
||||||
|
discInfo = drv.detectedDisc.discLabel || drv.detectedDisc.label || null;
|
||||||
} else {
|
} else {
|
||||||
stateLabel = 'LEER';
|
stateLabel = 'LEER';
|
||||||
stateSeverity = 'secondary';
|
stateSeverity = 'secondary';
|
||||||
@@ -2360,6 +2589,13 @@ export default function DashboardPage({
|
|||||||
const driveJobId = normalizeJobId(cdDrive?.jobId);
|
const driveJobId = normalizeJobId(cdDrive?.jobId);
|
||||||
const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {};
|
const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {};
|
||||||
const cdState = cdDrive ? String(cdDrive.state || '').toUpperCase() : null;
|
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);
|
||||||
|
const stateIconMeta = driveStateIconMeta(stateLabel);
|
||||||
|
const discArg = String(drv.discArg || '').trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={drivePath} className="drive-list-item">
|
<div key={drivePath} className="drive-list-item">
|
||||||
@@ -2367,10 +2603,27 @@ export default function DashboardPage({
|
|||||||
<div className="drive-list-info">
|
<div className="drive-list-info">
|
||||||
<code className="drive-list-path">{drivePath}</code>
|
<code className="drive-list-path">{drivePath}</code>
|
||||||
{drv.model && <span className="drive-list-model">{drv.model}</span>}
|
{drv.model && <span className="drive-list-model">{drv.model}</span>}
|
||||||
{drv.discArg && <span className="drive-list-disc-arg">{drv.discArg}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="drive-list-state">
|
<div className="drive-list-state">
|
||||||
{stateLabel && <Tag value={stateLabel} severity={stateSeverity} />}
|
{stateLabel ? (
|
||||||
|
<span
|
||||||
|
className={`drive-list-status-icon tone-${stateSeverity}`}
|
||||||
|
title={getStatusLabel(stateLabel)}
|
||||||
|
aria-label={`Status: ${getStatusLabel(stateLabel)}`}
|
||||||
|
>
|
||||||
|
<i className={`pi ${stateIconMeta.icon}${stateIconMeta.spin ? ' pi-spin' : ''}`} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{isCdDriveLocked ? (
|
||||||
|
<span
|
||||||
|
className="drive-list-lock-indicator"
|
||||||
|
title="Laufwerk ist gesperrt, bis Rip/Encode abgeschlossen ist."
|
||||||
|
aria-label="Laufwerk gesperrt"
|
||||||
|
>
|
||||||
|
<i className="pi pi-lock" aria-hidden="true" />
|
||||||
|
<span>gesperrt</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="drive-list-actions">
|
<div className="drive-list-actions">
|
||||||
<Button
|
<Button
|
||||||
@@ -2379,49 +2632,68 @@ export default function DashboardPage({
|
|||||||
rounded
|
rounded
|
||||||
size="small"
|
size="small"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
|
className="drive-list-action-btn"
|
||||||
loading={isRescanBusy}
|
loading={isRescanBusy}
|
||||||
disabled={isRescanBusy || isDriveActive}
|
disabled={isRescanBusy || isDriveActive || isCdDriveLocked}
|
||||||
onClick={() => handleRescanDrive(drivePath)}
|
onClick={() => handleRescanDrive(drivePath)}
|
||||||
title="Laufwerk neu lesen"
|
title="Laufwerk neu lesen"
|
||||||
aria-label="Laufwerk neu lesen"
|
aria-label="Laufwerk neu lesen"
|
||||||
/>
|
/>
|
||||||
{cdState === 'DISC_DETECTED' && (
|
{canAnalyzeDrive && (
|
||||||
<Button
|
<Button
|
||||||
label="Analysieren"
|
|
||||||
icon="pi pi-search"
|
icon="pi pi-search"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
size="small"
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
className="drive-list-action-btn"
|
||||||
|
loading={isAnalyzeBusy}
|
||||||
|
disabled={isAnalyzeBusy}
|
||||||
onClick={() => handleAnalyzeForDrive(drivePath)}
|
onClick={() => handleAnalyzeForDrive(drivePath)}
|
||||||
loading={busy}
|
title="Disk analysieren"
|
||||||
disabled={busy}
|
aria-label="Disk analysieren"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{cdState === 'CD_METADATA_SELECTION' && (
|
{cdState === 'CD_METADATA_SELECTION' && (
|
||||||
<Button
|
<Button
|
||||||
label="Metadaten"
|
|
||||||
icon="pi pi-list"
|
icon="pi pi-list"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
size="small"
|
size="small"
|
||||||
severity="info"
|
severity="info"
|
||||||
|
className="drive-list-action-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCdMetadataDialogContext({ ...driveCtx, jobId: driveJobId });
|
setCdMetadataDialogContext({ ...driveCtx, jobId: driveJobId });
|
||||||
setCdMetadataDialogVisible(true);
|
setCdMetadataDialogVisible(true);
|
||||||
}}
|
}}
|
||||||
disabled={!driveJobId}
|
disabled={!driveJobId}
|
||||||
|
title="Metadaten auswählen"
|
||||||
|
aria-label="Metadaten auswählen"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(cdState === 'ERROR' || cdState === 'CANCELLED') && driveJobId && (
|
{(cdState === 'ERROR' || cdState === 'CANCELLED') && driveJobId && (
|
||||||
<Button
|
<Button
|
||||||
label="Retry"
|
|
||||||
icon="pi pi-replay"
|
icon="pi pi-replay"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
size="small"
|
size="small"
|
||||||
severity="warning"
|
severity="warning"
|
||||||
|
className="drive-list-action-btn"
|
||||||
onClick={() => handleAnalyzeForDrive(drivePath)}
|
onClick={() => handleAnalyzeForDrive(drivePath)}
|
||||||
loading={busy}
|
loading={isAnalyzeBusy}
|
||||||
disabled={busy}
|
disabled={isAnalyzeBusy}
|
||||||
|
title="Erneut analysieren"
|
||||||
|
aria-label="Erneut analysieren"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{discInfo && <div className="drive-list-disc-label">{discInfo}</div>}
|
{(discArg || discInfo) ? (
|
||||||
|
<div className="drive-list-disc-meta">
|
||||||
|
{discArg ? <span className="drive-list-disc-arg">{discArg}</span> : null}
|
||||||
|
{discInfo ? <span className="drive-list-disc-label" title={discInfo}>{discInfo}</span> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{showProgress && (
|
{showProgress && (
|
||||||
<ProgressBar value={progressValue} style={{ height: '5px', margin: '0.2rem 0 0' }} showValue={false} />
|
<ProgressBar value={progressValue} style={{ height: '5px', margin: '0.2rem 0 0' }} showValue={false} />
|
||||||
)}
|
)}
|
||||||
@@ -2435,6 +2707,32 @@ export default function DashboardPage({
|
|||||||
) : (
|
) : (
|
||||||
<p className="drive-list-empty">Keine Laufwerke erkannt.</p>
|
<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>
|
||||||
|
|
||||||
<Card title="Freier Speicher">
|
<Card title="Freier Speicher">
|
||||||
@@ -2481,8 +2779,13 @@ export default function DashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-col dashboard-col-center">
|
<div className="dashboard-col dashboard-col-center">
|
||||||
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
|
{isSubpageRoute ? (
|
||||||
{jobsLoading ? (
|
<div className="dashboard-subpage-content">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
|
||||||
|
{jobsLoading && dashboardJobs.length === 0 ? (
|
||||||
<p>Jobs werden geladen ...</p>
|
<p>Jobs werden geladen ...</p>
|
||||||
) : dashboardJobs.length === 0 ? (
|
) : dashboardJobs.length === 0 ? (
|
||||||
<p>Keine relevanten Jobs im Dashboard (aktive/fortsetzbare Status).</p>
|
<p>Keine relevanten Jobs im Dashboard (aktive/fortsetzbare Status).</p>
|
||||||
@@ -2530,7 +2833,6 @@ export default function DashboardPage({
|
|||||||
? pipelineForJob.context.selectedMetadata
|
? pipelineForJob.context.selectedMetadata
|
||||||
: {};
|
: {};
|
||||||
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
|
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
|
||||||
|
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
return (
|
return (
|
||||||
<div key={jobId} className="dashboard-job-expanded">
|
<div key={jobId} className="dashboard-job-expanded">
|
||||||
@@ -2579,6 +2881,8 @@ export default function DashboardPage({
|
|||||||
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
|
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
|
||||||
onCancel={() => handleCancel(jobId, jobState)}
|
onCancel={() => handleCancel(jobId, jobState)}
|
||||||
onRetry={() => handleRetry(jobId)}
|
onRetry={() => handleRetry(jobId)}
|
||||||
|
onRestartReview={() => handleRestartCdReviewFromRaw(jobId)}
|
||||||
|
onDeleteJob={() => handleDeleteDashboardJob(jobId)}
|
||||||
onOpenMetadata={() => {
|
onOpenMetadata={() => {
|
||||||
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
||||||
? pipelineForJob.context
|
? pipelineForJob.context
|
||||||
@@ -2610,6 +2914,7 @@ export default function DashboardPage({
|
|||||||
onStart={(config) => handleAudiobookStart(jobId, config)}
|
onStart={(config) => handleAudiobookStart(jobId, config)}
|
||||||
onCancel={() => handleCancel(jobId, jobState)}
|
onCancel={() => handleCancel(jobId, jobState)}
|
||||||
onRetry={() => handleRetry(jobId)}
|
onRetry={() => handleRetry(jobId)}
|
||||||
|
onDeleteJob={() => handleDeleteDashboardJob(jobId)}
|
||||||
busy={busyJobIds.has(jobId) || needsBytes}
|
busy={busyJobIds.has(jobId) || needsBytes}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -2632,6 +2937,7 @@ export default function DashboardPage({
|
|||||||
onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
|
onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onRetry={handleRetry}
|
onRetry={handleRetry}
|
||||||
|
onDeleteJob={handleDeleteDashboardJob}
|
||||||
onRemoveFromQueue={handleRemoveQueuedJob}
|
onRemoveFromQueue={handleRemoveQueuedJob}
|
||||||
isQueued={isQueued}
|
isQueued={isQueued}
|
||||||
busy={busyJobIds.has(jobId)}
|
busy={busyJobIds.has(jobId)}
|
||||||
@@ -2700,6 +3006,7 @@ export default function DashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-col dashboard-col-right">
|
<div className="dashboard-col dashboard-col-right">
|
||||||
@@ -3051,7 +3358,7 @@ export default function DashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{monitoringState.enabled ? (
|
{!isSubpageRoute && monitoringState.enabled ? (
|
||||||
<Card title="Hardware Monitoring">
|
<Card title="Hardware Monitoring">
|
||||||
<div className="hardware-monitor-head">
|
<div className="hardware-monitor-head">
|
||||||
{!monitoringState.enabled ? (
|
{!monitoringState.enabled ? (
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function DatabasePage() {
|
|||||||
|
|
||||||
const handleImportOrphanRaw = async (row) => {
|
const handleImportOrphanRaw = async (row) => {
|
||||||
const target = row?.rawPath || row?.folderName || '-';
|
const target = row?.rawPath || row?.folderName || '-';
|
||||||
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen und direkt scannen?`);
|
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`);
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -57,31 +57,12 @@ export default function DatabasePage() {
|
|||||||
try {
|
try {
|
||||||
const response = await api.importOrphanRawFolder(row.rawPath);
|
const response = await api.importOrphanRawFolder(row.rawPath);
|
||||||
const newJobId = response?.job?.id;
|
const newJobId = response?.job?.id;
|
||||||
if (newJobId) {
|
toastRef.current?.show({
|
||||||
try {
|
severity: 'success',
|
||||||
await api.reencodeJob(newJobId);
|
summary: 'Job angelegt',
|
||||||
toastRef.current?.show({
|
detail: newJobId ? `Historieneintrag #${newJobId} erstellt.` : 'Historieneintrag wurde erstellt.',
|
||||||
severity: 'success',
|
life: 3500
|
||||||
summary: 'Job angelegt & Scan gestartet',
|
});
|
||||||
detail: `Historieneintrag #${newJobId} erstellt, Mediainfo-Scan läuft.`,
|
|
||||||
life: 4000
|
|
||||||
});
|
|
||||||
} catch (scanError) {
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'info',
|
|
||||||
summary: 'Job angelegt',
|
|
||||||
detail: `Historieneintrag #${newJobId} erstellt. Scan konnte nicht automatisch gestartet werden: ${scanError.message}`,
|
|
||||||
life: 6000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toastRef.current?.show({
|
|
||||||
severity: 'success',
|
|
||||||
summary: 'Job angelegt',
|
|
||||||
detail: `Historieneintrag wurde erstellt.`,
|
|
||||||
life: 3500
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await loadOrphans();
|
await loadOrphans();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
|
|||||||
+575
-112
@@ -10,6 +10,9 @@ import { Toast } from 'primereact/toast';
|
|||||||
import { Dialog } from 'primereact/dialog';
|
import { Dialog } from 'primereact/dialog';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import JobDetailDialog from '../components/JobDetailDialog';
|
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 blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
@@ -30,10 +33,10 @@ const MEDIA_FILTER_OPTIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const SORT_OPTIONS = [
|
const SORT_OPTIONS = [
|
||||||
{ label: 'Startzeit: Neu -> Alt', value: '!start_time' },
|
{ label: 'Startzeit: Neu -> Alt', value: '!sortStartTime' },
|
||||||
{ label: 'Startzeit: Alt -> Neu', value: 'start_time' },
|
{ label: 'Startzeit: Alt -> Neu', value: 'sortStartTime' },
|
||||||
{ label: 'Endzeit: Neu -> Alt', value: '!end_time' },
|
{ label: 'Endzeit: Neu -> Alt', value: '!sortEndTime' },
|
||||||
{ label: 'Endzeit: Alt -> Neu', value: 'end_time' },
|
{ label: 'Endzeit: Alt -> Neu', value: 'sortEndTime' },
|
||||||
{ label: 'Titel: A -> Z', value: 'sortTitle' },
|
{ label: 'Titel: A -> Z', value: 'sortTitle' },
|
||||||
{ label: 'Titel: Z -> A', value: '!sortTitle' },
|
{ label: 'Titel: Z -> A', value: '!sortTitle' },
|
||||||
{ label: 'Medium: A -> Z', value: 'sortMediaType' },
|
{ label: 'Medium: A -> Z', value: 'sortMediaType' },
|
||||||
@@ -220,7 +223,11 @@ function resolveCdDetails(row) {
|
|||||||
selectedMetadata?.mbId
|
selectedMetadata?.mbId
|
||||||
|| selectedMetadata?.musicBrainzId
|
|| selectedMetadata?.musicBrainzId
|
||||||
|| selectedMetadata?.musicbrainzId
|
|| selectedMetadata?.musicbrainzId
|
||||||
|
|| selectedMetadata?.musicbrainz_id
|
||||||
|
|| selectedMetadata?.music_brainz_id
|
||||||
|
|| selectedMetadata?.musicbrainz
|
||||||
|| selectedMetadata?.mbid
|
|| selectedMetadata?.mbid
|
||||||
|
|| row?.imdb_id
|
||||||
|| ''
|
|| ''
|
||||||
).trim() || null;
|
).trim() || null;
|
||||||
|
|
||||||
@@ -347,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 }) {
|
export default function HistoryPage({ refreshToken = 0 }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -355,8 +380,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const [status, setStatus] = useState('');
|
const [status, setStatus] = useState('');
|
||||||
const [mediumFilter, setMediumFilter] = useState('');
|
const [mediumFilter, setMediumFilter] = useState('');
|
||||||
const [layout, setLayout] = useState('grid');
|
const [layout, setLayout] = useState('grid');
|
||||||
const [sortKey, setSortKey] = useState('!start_time');
|
const [sortKey, setSortKey] = useState('!sortStartTime');
|
||||||
const [sortField, setSortField] = useState('start_time');
|
const [sortField, setSortField] = useState('sortStartTime');
|
||||||
const [sortOrder, setSortOrder] = useState(-1);
|
const [sortOrder, setSortOrder] = useState(-1);
|
||||||
const [selectedJob, setSelectedJob] = useState(null);
|
const [selectedJob, setSelectedJob] = useState(null);
|
||||||
const [detailVisible, setDetailVisible] = useState(false);
|
const [detailVisible, setDetailVisible] = useState(false);
|
||||||
@@ -370,9 +395,23 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const [deleteEntryPreview, setDeleteEntryPreview] = useState(null);
|
const [deleteEntryPreview, setDeleteEntryPreview] = useState(null);
|
||||||
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
|
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
|
||||||
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
|
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
|
||||||
|
const [deleteEntrySelectedMoviePaths, setDeleteEntrySelectedMoviePaths] = useState([]);
|
||||||
const [downloadBusyTarget, setDownloadBusyTarget] = useState(null);
|
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);
|
||||||
|
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
||||||
|
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
||||||
|
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
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 toastRef = useRef(null);
|
||||||
|
|
||||||
const queuedJobIdSet = useMemo(() => {
|
const queuedJobIdSet = useMemo(() => {
|
||||||
@@ -390,7 +429,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
() => jobs.map((job) => ({
|
() => jobs.map((job) => ({
|
||||||
...job,
|
...job,
|
||||||
sortTitle: normalizeSortText(job?.title || job?.detected_title || ''),
|
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]
|
[jobs]
|
||||||
);
|
);
|
||||||
@@ -462,8 +503,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const onSortChange = (event) => {
|
const onSortChange = (event) => {
|
||||||
const value = String(event.value || '').trim();
|
const value = String(event.value || '').trim();
|
||||||
if (!value) {
|
if (!value) {
|
||||||
setSortKey('!start_time');
|
setSortKey('!sortStartTime');
|
||||||
setSortField('start_time');
|
setSortField('sortStartTime');
|
||||||
setSortOrder(-1);
|
setSortOrder(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -499,7 +540,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.getJob(jobId, { includeLogs: false });
|
const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true });
|
||||||
setSelectedJob(response.job);
|
setSelectedJob(response.job);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||||
@@ -533,11 +574,242 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
if (!detailVisible || Number(selectedJob?.id || 0) !== Number(jobId || 0)) {
|
if (!detailVisible || Number(selectedJob?.id || 0) !== Number(jobId || 0)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const response = await api.getJob(jobId, { includeLogs: false });
|
const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true });
|
||||||
setSelectedJob(response.job);
|
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) => {
|
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 outputLabel = getOutputLabelForRow(row);
|
||||||
const outputShortLabel = getOutputShortLabelForRow(row);
|
const outputShortLabel = getOutputShortLabelForRow(row);
|
||||||
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
|
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
|
||||||
@@ -551,12 +823,34 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
try {
|
try {
|
||||||
const response = await api.deleteJobFiles(row.id, target);
|
const response = await api.deleteJobFiles(row.id, target);
|
||||||
const summary = response.summary || {};
|
const summary = response.summary || {};
|
||||||
toastRef.current?.show({
|
const rawSummary = summary.raw || {};
|
||||||
severity: 'success',
|
const movieSummary = summary.movie || {};
|
||||||
summary: 'Dateien gelöscht',
|
const deletedSomething = target === 'raw'
|
||||||
detail: `RAW: ${summary.raw?.filesDeleted ?? 0}, ${outputShortLabel}: ${summary.movie?.filesDeleted ?? 0}`,
|
? Boolean(rawSummary.deleted)
|
||||||
life: 3500
|
: target === 'movie'
|
||||||
});
|
? Boolean(movieSummary.deleted)
|
||||||
|
: Boolean(rawSummary.deleted || movieSummary.deleted);
|
||||||
|
|
||||||
|
if (!deletedSomething) {
|
||||||
|
const reason = target === 'raw'
|
||||||
|
? (rawSummary.reason || 'Keine passenden RAW-Dateien/Ordner gefunden.')
|
||||||
|
: target === 'movie'
|
||||||
|
? (movieSummary.reason || `Keine passenden ${outputShortLabel}-Dateien/Ordner gefunden.`)
|
||||||
|
: (movieSummary.reason || rawSummary.reason || 'Keine passenden Dateien/Ordner gefunden.');
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Nichts gelöscht',
|
||||||
|
detail: reason,
|
||||||
|
life: 4200
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Dateien gelöscht',
|
||||||
|
detail: `RAW: ${rawSummary.filesDeleted ?? 0}, ${outputShortLabel}: ${movieSummary.filesDeleted ?? 0}`,
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
}
|
||||||
await load();
|
await load();
|
||||||
await refreshDetailIfOpen(row.id);
|
await refreshDetailIfOpen(row.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -600,97 +894,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 handleReencode = async (row) => {
|
||||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
if (await maybeOpenOutputConflictModal(row, 'reencode')) {
|
||||||
const confirmed = window.confirm(`RAW neu encodieren für "${title}" starten?`);
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await executeHistoryAction(row, 'reencode');
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestartEncode = async (row) => {
|
const handleRestartEncode = async (row) => {
|
||||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
if (await maybeOpenOutputConflictModal(row, 'restart_encode')) {
|
||||||
if (row?.encodeSuccess) {
|
return;
|
||||||
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) {
|
|
||||||
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 handleRestartReview = async (row) => {
|
||||||
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
if (await maybeOpenOutputConflictModal(row, 'restart_review')) {
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
await executeHistoryAction(row, 'restart_review');
|
||||||
|
};
|
||||||
|
|
||||||
setActionBusy(true);
|
const handleRestartCdReview = async (row) => {
|
||||||
try {
|
if (await maybeOpenOutputConflictModal(row, 'restart_cd_review')) {
|
||||||
await api.restartReviewFromRaw(row.id);
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
await executeHistoryAction(row, 'restart_cd_review');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRetry = async (row) => {
|
const handleRetry = async (row) => {
|
||||||
@@ -717,7 +978,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
if (replacementJobId) {
|
if (replacementJobId) {
|
||||||
const detailResponse = await api.getJob(replacementJobId, { includeLogs: false });
|
const detailResponse = await api.getJob(replacementJobId, { includeLogs: false, forceRefresh: true });
|
||||||
setSelectedJob(detailResponse.job);
|
setSelectedJob(detailResponse.job);
|
||||||
setDetailVisible(true);
|
setDetailVisible(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -735,6 +996,104 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAssignOmdb = (row) => {
|
||||||
|
const jobId = Number(row?.id || 0);
|
||||||
|
if (!jobId) return;
|
||||||
|
const context = {
|
||||||
|
jobId,
|
||||||
|
detectedTitle: row?.detected_title || row?.title || '',
|
||||||
|
selectedMetadata: {
|
||||||
|
title: row?.title || row?.detected_title || '',
|
||||||
|
year: row?.year || null,
|
||||||
|
imdbId: row?.imdb_id || null,
|
||||||
|
poster: row?.poster_url || null
|
||||||
|
},
|
||||||
|
omdbCandidates: []
|
||||||
|
};
|
||||||
|
setMetadataDialogContext(context);
|
||||||
|
setMetadataDialogVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOmdbSearch = async (query) => {
|
||||||
|
try {
|
||||||
|
const response = await api.searchOmdb(query);
|
||||||
|
return response.results || [];
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Suche fehlgeschlagen', detail: error.message });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOmdbSubmit = async (payload) => {
|
||||||
|
setOmdbAssignBusy(true);
|
||||||
|
try {
|
||||||
|
await api.assignJobOmdb(payload.jobId, payload);
|
||||||
|
toastRef.current?.show({ severity: 'success', summary: 'Metadaten zugewiesen', detail: 'OMDB-Metadaten wurden aktualisiert.', life: 3000 });
|
||||||
|
setMetadataDialogVisible(false);
|
||||||
|
setMetadataDialogContext(null);
|
||||||
|
await load();
|
||||||
|
await refreshDetailIfOpen(payload.jobId);
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Zuweisung fehlgeschlagen', detail: error.message });
|
||||||
|
} finally {
|
||||||
|
setOmdbAssignBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssignCdMetadata = (row) => {
|
||||||
|
const jobId = Number(row?.id || 0);
|
||||||
|
if (!jobId) return;
|
||||||
|
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||||
|
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
|
||||||
|
const tracks = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0
|
||||||
|
? makemkvInfo.tracks
|
||||||
|
: (Array.isArray(encodePlan?.tracks) ? encodePlan.tracks : []);
|
||||||
|
const context = {
|
||||||
|
jobId,
|
||||||
|
detectedTitle: row?.detected_title || row?.title || '',
|
||||||
|
tracks,
|
||||||
|
selectedMetadata: makemkvInfo?.selectedMetadata || {}
|
||||||
|
};
|
||||||
|
setCdMetadataDialogContext(context);
|
||||||
|
setCdMetadataDialogVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMusicBrainzSearch = async (query) => {
|
||||||
|
try {
|
||||||
|
const response = await api.searchMusicBrainz(query);
|
||||||
|
return response.results || [];
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz-Suche fehlgeschlagen', detail: error.message });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMusicBrainzReleaseFetch = async (mbId) => {
|
||||||
|
try {
|
||||||
|
const response = await api.getMusicBrainzRelease(mbId);
|
||||||
|
return response?.release || null;
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz-Abruf fehlgeschlagen', detail: error.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCdMetadataSubmit = async (payload) => {
|
||||||
|
setCdMetadataAssignBusy(true);
|
||||||
|
try {
|
||||||
|
await api.assignJobCdMetadata(payload.jobId, payload);
|
||||||
|
toastRef.current?.show({ severity: 'success', summary: 'Metadaten zugewiesen', detail: 'MusicBrainz-Metadaten wurden aktualisiert.', life: 3000 });
|
||||||
|
setCdMetadataDialogVisible(false);
|
||||||
|
setCdMetadataDialogContext(null);
|
||||||
|
await load();
|
||||||
|
await refreshDetailIfOpen(payload.jobId);
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Metadaten-Zuweisung fehlgeschlagen', detail: error.message });
|
||||||
|
} finally {
|
||||||
|
setCdMetadataAssignBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const closeDeleteEntryDialog = () => {
|
const closeDeleteEntryDialog = () => {
|
||||||
if (deleteEntryTargetBusy) {
|
if (deleteEntryTargetBusy) {
|
||||||
return;
|
return;
|
||||||
@@ -744,6 +1103,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
setDeleteEntryPreview(null);
|
setDeleteEntryPreview(null);
|
||||||
setDeleteEntryPreviewLoading(false);
|
setDeleteEntryPreviewLoading(false);
|
||||||
setDeleteEntryTargetBusy(null);
|
setDeleteEntryTargetBusy(null);
|
||||||
|
setDeleteEntrySelectedMoviePaths([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteEntry = async (row) => {
|
const handleDeleteEntry = async (row) => {
|
||||||
@@ -753,12 +1113,20 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
}
|
}
|
||||||
setDeleteEntryDialogRow(row);
|
setDeleteEntryDialogRow(row);
|
||||||
setDeleteEntryPreview(null);
|
setDeleteEntryPreview(null);
|
||||||
|
setDeleteEntrySelectedMoviePaths([]);
|
||||||
setDeleteEntryDialogVisible(true);
|
setDeleteEntryDialogVisible(true);
|
||||||
setDeleteEntryPreviewLoading(true);
|
setDeleteEntryPreviewLoading(true);
|
||||||
setDeleteEntryBusy(true);
|
setDeleteEntryBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.getJobDeletePreview(jobId, { includeRelated: true });
|
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) {
|
} catch (error) {
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
@@ -784,11 +1152,27 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
return;
|
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);
|
setDeleteEntryBusy(true);
|
||||||
setDeleteEntryTargetBusy(normalizedTarget);
|
setDeleteEntryTargetBusy(normalizedTarget);
|
||||||
try {
|
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 deletedJobIds = Array.isArray(response?.deletedJobIds) ? response.deletedJobIds : [];
|
||||||
const fileSummary = response?.fileSummary || {};
|
const fileSummary = response?.fileSummary || {};
|
||||||
const rawFiles = Number(fileSummary?.raw?.filesDeleted || 0);
|
const rawFiles = Number(fileSummary?.raw?.filesDeleted || 0);
|
||||||
@@ -823,6 +1207,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 handleRemoveFromQueue = async (row) => {
|
||||||
const jobId = normalizeJobId(row?.id || row);
|
const jobId = normalizeJobId(row?.id || row);
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
@@ -1096,6 +1498,13 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
|
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
|
||||||
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
|
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
|
||||||
const previewMovieExisting = previewMoviePaths.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 deleteTargetActionsDisabled = deleteEntryPreviewLoading || Boolean(deleteEntryTargetBusy) || !deleteEntryPreview;
|
||||||
const deleteEntryOutputLabel = getOutputLabelForRow(deleteEntryDialogRow);
|
const deleteEntryOutputLabel = getOutputLabelForRow(deleteEntryDialogRow);
|
||||||
const deleteEntryOutputShortLabel = getOutputShortLabelForRow(deleteEntryDialogRow);
|
const deleteEntryOutputShortLabel = getOutputShortLabelForRow(deleteEntryDialogRow);
|
||||||
@@ -1175,22 +1584,30 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
logLoadingMode={logLoadingMode}
|
logLoadingMode={logLoadingMode}
|
||||||
onRestartEncode={handleRestartEncode}
|
onRestartEncode={handleRestartEncode}
|
||||||
onRestartReview={handleRestartReview}
|
onRestartReview={handleRestartReview}
|
||||||
|
onRestartCdReview={handleRestartCdReview}
|
||||||
onReencode={handleReencode}
|
onReencode={handleReencode}
|
||||||
onRetry={handleRetry}
|
onRetry={handleRetry}
|
||||||
|
onAssignOmdb={handleAssignOmdb}
|
||||||
|
onAssignCdMetadata={handleAssignCdMetadata}
|
||||||
onDeleteFiles={handleDeleteFiles}
|
onDeleteFiles={handleDeleteFiles}
|
||||||
onDeleteEntry={handleDeleteEntry}
|
onDeleteEntry={handleDeleteEntry}
|
||||||
onDownloadArchive={handleDownloadArchive}
|
onDownloadArchive={handleDownloadArchive}
|
||||||
|
onDownloadOutputFolder={handleDownloadOutputFolder}
|
||||||
onRemoveFromQueue={handleRemoveFromQueue}
|
onRemoveFromQueue={handleRemoveFromQueue}
|
||||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||||
actionBusy={actionBusy}
|
actionBusy={actionBusy}
|
||||||
|
omdbAssignBusy={omdbAssignBusy}
|
||||||
|
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
||||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||||
deleteEntryBusy={deleteEntryBusy}
|
deleteEntryBusy={deleteEntryBusy}
|
||||||
downloadBusyTarget={downloadBusyTarget}
|
downloadBusyTarget={downloadBusyTarget}
|
||||||
|
downloadFolderBusyPath={downloadFolderBusyPath}
|
||||||
onHide={() => {
|
onHide={() => {
|
||||||
setDetailVisible(false);
|
setDetailVisible(false);
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
setLogLoadingMode(null);
|
setLogLoadingMode(null);
|
||||||
setDownloadBusyTarget(null);
|
setDownloadBusyTarget(null);
|
||||||
|
setDownloadFolderBusyPath(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1222,7 +1639,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
<ul className="history-delete-preview-list">
|
<ul className="history-delete-preview-list">
|
||||||
{previewRelatedJobs.map((item) => (
|
{previewRelatedJobs.map((item) => (
|
||||||
<li key={`delete-related-${item.id}`}>
|
<li key={`delete-related-${item.id}`}>
|
||||||
<strong>#{item.id}</strong> | {item.title || '-'} | {item.status || '-'} {item.isPrimary ? '(aktuell)' : '(Alt-Eintrag)'}
|
<strong>#{item.id}</strong> | {item.title || '-'} | {getStatusLabel(item.status)} {item.isPrimary ? '(aktuell)' : '(Alt-Eintrag)'}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1234,12 +1651,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
<div>
|
<div>
|
||||||
<h4>RAW</h4>
|
<h4>RAW</h4>
|
||||||
{previewRawPaths.length > 0 ? (() => {
|
{previewRawPaths.length > 0 ? (() => {
|
||||||
const display = previewRawPaths.filter(p => p.exists).length > 0
|
|
||||||
? previewRawPaths.filter(p => p.exists)
|
|
||||||
: previewRawPaths.slice(0, 1);
|
|
||||||
return (
|
return (
|
||||||
<ul className="history-delete-preview-list">
|
<ul className="history-delete-preview-list">
|
||||||
{display.map((item) => (
|
{previewRawDisplay.map((item) => (
|
||||||
<li key={`delete-raw-${item.path}`}>
|
<li key={`delete-raw-${item.path}`}>
|
||||||
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
||||||
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
||||||
@@ -1257,17 +1671,30 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
<div>
|
<div>
|
||||||
<h4>{deleteEntryOutputShortLabel}</h4>
|
<h4>{deleteEntryOutputShortLabel}</h4>
|
||||||
{previewMoviePaths.length > 0 ? (() => {
|
{previewMoviePaths.length > 0 ? (() => {
|
||||||
const display = previewMoviePaths.filter(p => p.exists).length > 0
|
|
||||||
? previewMoviePaths.filter(p => p.exists)
|
|
||||||
: previewMoviePaths.slice(0, 1);
|
|
||||||
return (
|
return (
|
||||||
<ul className="history-delete-preview-list">
|
<ul className="history-delete-preview-list">
|
||||||
{display.map((item) => (
|
{previewMovieDisplay.map((item) => (
|
||||||
<li key={`delete-movie-${item.path}`}>
|
<li key={`delete-movie-${item.path}`}>
|
||||||
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
{item.exists ? (
|
||||||
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
<label className="history-delete-preview-checkbox-row">
|
||||||
</span>
|
<input
|
||||||
{' '}| {item.path}
|
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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1296,7 +1723,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
outlined
|
outlined
|
||||||
onClick={() => confirmDeleteEntry('movie')}
|
onClick={() => confirmDeleteEntry('movie')}
|
||||||
loading={deleteEntryTargetBusy === 'movie'}
|
loading={deleteEntryTargetBusy === 'movie'}
|
||||||
disabled={deleteTargetActionsDisabled}
|
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Beides löschen"
|
label="Beides löschen"
|
||||||
@@ -1304,7 +1731,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
severity="danger"
|
severity="danger"
|
||||||
onClick={() => confirmDeleteEntry('both')}
|
onClick={() => confirmDeleteEntry('both')}
|
||||||
loading={deleteEntryTargetBusy === 'both'}
|
loading={deleteEntryTargetBusy === 'both'}
|
||||||
disabled={deleteTargetActionsDisabled}
|
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Nur Eintrag löschen"
|
label="Nur Eintrag löschen"
|
||||||
@@ -1324,6 +1751,42 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<MetadataSelectionDialog
|
||||||
|
visible={metadataDialogVisible}
|
||||||
|
context={metadataDialogContext || {}}
|
||||||
|
onHide={() => {
|
||||||
|
setMetadataDialogVisible(false);
|
||||||
|
setMetadataDialogContext(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleOmdbSubmit}
|
||||||
|
onSearch={handleOmdbSearch}
|
||||||
|
busy={omdbAssignBusy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CdMetadataDialog
|
||||||
|
visible={cdMetadataDialogVisible}
|
||||||
|
context={cdMetadataDialogContext || {}}
|
||||||
|
onHide={() => {
|
||||||
|
setCdMetadataDialogVisible(false);
|
||||||
|
setCdMetadataDialogContext(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleCdMetadataSubmit}
|
||||||
|
onSearch={handleMusicBrainzSearch}
|
||||||
|
onFetchRelease={handleMusicBrainzReleaseFetch}
|
||||||
|
busy={cdMetadataAssignBusy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReencodeConflictModal
|
||||||
|
visible={conflictModalVisible}
|
||||||
|
onHide={closeConflictModal}
|
||||||
|
job={conflictModalJob}
|
||||||
|
existingFolders={conflictModalFolders}
|
||||||
|
mode={conflictModalMode}
|
||||||
|
onKeepBoth={handleConflictKeepBoth}
|
||||||
|
onDeleteSelected={handleConflictDeleteSelected}
|
||||||
|
busy={conflictModalBusy}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export default function SettingsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [testingPushover, setTestingPushover] = useState(false);
|
const [testingPushover, setTestingPushover] = useState(false);
|
||||||
|
const [coverArtRecoveryBusy, setCoverArtRecoveryBusy] = useState(false);
|
||||||
const [updatingExpertMode, setUpdatingExpertMode] = useState(false);
|
const [updatingExpertMode, setUpdatingExpertMode] = useState(false);
|
||||||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||||
const [initialValues, setInitialValues] = useState({});
|
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) => {
|
const handleScriptEditorChange = (key, value) => {
|
||||||
setScriptEditor((prev) => ({
|
setScriptEditor((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -1035,6 +1061,14 @@ export default function SettingsPage() {
|
|||||||
loading={testingPushover}
|
loading={testingPushover}
|
||||||
disabled={saving || updatingExpertMode}
|
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">
|
<div className="settings-expert-toggle">
|
||||||
<span>Expertenmodus</span>
|
<span>Expertenmodus</span>
|
||||||
<InputSwitch
|
<InputSwitch
|
||||||
|
|||||||
+323
-20
@@ -13,6 +13,7 @@
|
|||||||
--rip-muted: #6a4d38;
|
--rip-muted: #6a4d38;
|
||||||
--rip-panel: #fffaf1;
|
--rip-panel: #fffaf1;
|
||||||
--rip-panel-soft: #fdf5e7;
|
--rip-panel-soft: #fdf5e7;
|
||||||
|
--dashboard-side-width: 20rem;
|
||||||
|
|
||||||
/* PrimeReact theme tokens */
|
/* PrimeReact theme tokens */
|
||||||
--primary-color: var(--rip-brown-600);
|
--primary-color: var(--rip-brown-600);
|
||||||
@@ -238,13 +239,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
width: min(1280px, 96vw);
|
width: 100%;
|
||||||
margin: 1rem auto 2rem;
|
max-width: none;
|
||||||
|
margin: 1rem 0 2rem;
|
||||||
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-upload-banner {
|
.app-upload-banner {
|
||||||
width: min(1280px, 96vw);
|
width: auto;
|
||||||
margin: 0.9rem auto 0;
|
max-width: none;
|
||||||
|
margin: 0.9rem 1rem 0;
|
||||||
padding: 0.8rem 0.95rem;
|
padding: 0.8rem 0.95rem;
|
||||||
border: 1px solid var(--rip-border);
|
border: 1px solid var(--rip-border);
|
||||||
border-radius: 0.7rem;
|
border-radius: 0.7rem;
|
||||||
@@ -316,7 +320,8 @@ body {
|
|||||||
|
|
||||||
.dashboard-3col-grid {
|
.dashboard-3col-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
|
width: 100%;
|
||||||
|
grid-template-columns: var(--dashboard-side-width) minmax(0, 1fr) var(--dashboard-side-width);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -328,6 +333,18 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-subpage-content {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-subpage-content > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.dashboard-3col-grid {
|
.dashboard-3col-grid {
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
|
||||||
@@ -1648,6 +1665,44 @@ body {
|
|||||||
gap: 0.5rem;
|
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 {
|
.settings-tabview {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -1817,61 +1872,138 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drive-list-item {
|
.drive-list-item {
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.35rem 0.55rem;
|
||||||
border: 1px solid var(--rip-border);
|
border: 1px solid var(--rip-border);
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
background: var(--rip-panel-soft);
|
background: var(--rip-panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drive-list-row {
|
.drive-list-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.35rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drive-list-info {
|
.drive-list-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
flex-wrap: wrap;
|
||||||
flex: 1;
|
gap: 0.28rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drive-list-path {
|
.drive-list-path {
|
||||||
|
display: inline-block;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--rip-muted);
|
color: var(--rip-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drive-list-model {
|
.drive-list-model {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--rip-muted);
|
color: var(--rip-muted);
|
||||||
|
max-width: 14rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drive-list-disc-arg {
|
.drive-list-disc-arg {
|
||||||
font-size: 0.7rem;
|
font-size: 0.72rem;
|
||||||
color: var(--rip-muted);
|
color: var(--rip-muted);
|
||||||
opacity: 0.7;
|
opacity: 0.85;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drive-list-state {
|
.drive-list-state {
|
||||||
flex-shrink: 0;
|
justify-self: start;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-list-status-icon {
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-list-status-icon.tone-success {
|
||||||
|
color: #1c8a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-list-status-icon.tone-danger {
|
||||||
|
color: #9c2d2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-list-status-icon.tone-warning {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-list-status-icon.tone-info {
|
||||||
|
color: #24588b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-list-status-icon.tone-secondary {
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-list-lock-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.08rem 0.32rem;
|
||||||
|
border: 1px solid rgba(155, 111, 22, 0.4);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(251, 233, 194, 0.65);
|
||||||
|
color: #8a5a0a;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-list-lock-indicator .pi {
|
||||||
|
font-size: 0.62rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drive-list-actions {
|
.drive-list-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.16rem;
|
||||||
flex-shrink: 0;
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-list-action-btn.p-button.p-button-icon-only {
|
||||||
|
width: 1.65rem;
|
||||||
|
height: 1.65rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-list-action-btn .p-button-icon {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-list-disc-meta {
|
||||||
|
margin-top: 0.08rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drive-list-disc-label {
|
.drive-list-disc-label {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
margin-top: 0.2rem;
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1885,6 +2017,38 @@ body {
|
|||||||
text-overflow: ellipsis;
|
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) */
|
/* Legacy per-CD-drive section (kept for compat) */
|
||||||
.cd-drives-section {
|
.cd-drives-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2661,6 +2825,17 @@ body {
|
|||||||
word-break: break-word;
|
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 {
|
.history-delete-preview-list .exists-yes {
|
||||||
color: #176635;
|
color: #176635;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -2791,7 +2966,7 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 0.15rem 0.4rem;
|
gap: 0.15rem 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2799,8 +2974,8 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-path-field-value > span {
|
.job-path-field-value > span {
|
||||||
@@ -2813,6 +2988,16 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-path-download-button.p-button.p-button-icon-only {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-path-download-button .p-button-icon {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.job-meta-col-span-2 {
|
.job-meta-col-span-2 {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
@@ -3295,6 +3480,19 @@ body {
|
|||||||
color: var(--rip-muted);
|
color: var(--rip-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.track-selection-inline-neutral {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-selection-inline-neutral .pi {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.spurauswahl-block {
|
.spurauswahl-block {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -3363,6 +3561,21 @@ body {
|
|||||||
font-size: 0.85rem;
|
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 {
|
.media-title-block {
|
||||||
border: 1px solid var(--rip-border);
|
border: 1px solid var(--rip-border);
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
@@ -4561,3 +4774,93 @@ body {
|
|||||||
grid-template-columns: 1fr;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const STATUS_LABELS = {
|
|||||||
METADATA_SELECTION: 'Metadatenauswahl',
|
METADATA_SELECTION: 'Metadatenauswahl',
|
||||||
WAITING_FOR_USER_DECISION: 'Warte auf Auswahl',
|
WAITING_FOR_USER_DECISION: 'Warte auf Auswahl',
|
||||||
READY_TO_START: 'Startbereit',
|
READY_TO_START: 'Startbereit',
|
||||||
MEDIAINFO_CHECK: 'Mediainfo-Pruefung',
|
MEDIAINFO_CHECK: 'Mediainfo-Prüfung',
|
||||||
READY_TO_ENCODE: 'Bereit zum Encodieren',
|
READY_TO_ENCODE: 'Bereit zum Encodieren',
|
||||||
RIPPING: 'Rippen',
|
RIPPING: 'Rippen',
|
||||||
ENCODING: 'Encodieren',
|
ENCODING: 'Encodieren',
|
||||||
@@ -15,20 +15,83 @@ const STATUS_LABELS = {
|
|||||||
ERROR: 'Fehler',
|
ERROR: 'Fehler',
|
||||||
CD_ANALYZING: 'CD-Analyse',
|
CD_ANALYZING: 'CD-Analyse',
|
||||||
CD_METADATA_SELECTION: 'CD-Metadatenauswahl',
|
CD_METADATA_SELECTION: 'CD-Metadatenauswahl',
|
||||||
CD_READY_TO_RIP: 'CD bereit zum Rippen',
|
CD_READY_TO_RIP: 'CD bereit (Rip/Encode)',
|
||||||
CD_RIPPING: 'CD rippen',
|
CD_RIPPING: 'CD-Rippen',
|
||||||
CD_ENCODING: 'CD encodieren'
|
CD_ENCODING: 'CD-Encodieren'
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROCESS_STATUS_LABELS = {
|
const PROCESS_STATUS_LABELS = {
|
||||||
SUCCESS: 'Erfolgreich',
|
SUCCESS: 'Erfolgreich',
|
||||||
ERROR: 'Fehler',
|
ERROR: 'Fehler',
|
||||||
CANCELLED: 'Abgebrochen',
|
CANCELLED: 'Abgebrochen',
|
||||||
RUNNING: 'Laeuft',
|
RUNNING: 'Läuft',
|
||||||
STARTED: 'Gestartet',
|
STARTED: 'Gestartet',
|
||||||
PENDING: 'Ausstehend'
|
PENDING: 'Ausstehend',
|
||||||
|
DONE: 'Erledigt',
|
||||||
|
FAILED: 'Fehlgeschlagen',
|
||||||
|
COMPLETE: 'Abgeschlossen',
|
||||||
|
COMPLETED: 'Abgeschlossen',
|
||||||
|
SKIPPED: 'Übersprungen',
|
||||||
|
OK: 'OK'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FALLBACK_TOKEN_LABELS = {
|
||||||
|
IDLE: 'Bereit',
|
||||||
|
DISC: 'Medium',
|
||||||
|
DETECTED: 'erkannt',
|
||||||
|
READY: 'bereit',
|
||||||
|
TO: 'zu',
|
||||||
|
RIP: 'rippen',
|
||||||
|
RIPPING: 'rippen',
|
||||||
|
ENCODE: 'encodieren',
|
||||||
|
ENCODING: 'encodieren',
|
||||||
|
ANALYZING: 'analyse',
|
||||||
|
METADATA: 'metadaten',
|
||||||
|
SELECTION: 'auswahl',
|
||||||
|
WAITING: 'warte',
|
||||||
|
FOR: 'auf',
|
||||||
|
USER: 'Benutzer',
|
||||||
|
DECISION: 'entscheidung',
|
||||||
|
CHECK: 'prüfung',
|
||||||
|
POST: 'post',
|
||||||
|
SCRIPTS: 'skripte',
|
||||||
|
FINISHED: 'fertig',
|
||||||
|
CANCELLED: 'abgebrochen',
|
||||||
|
ERROR: 'fehler',
|
||||||
|
SUCCESS: 'erfolgreich',
|
||||||
|
RUNNING: 'läuft',
|
||||||
|
STARTED: 'gestartet',
|
||||||
|
PENDING: 'ausstehend',
|
||||||
|
FAILED: 'fehlgeschlagen',
|
||||||
|
SKIPPED: 'übersprungen',
|
||||||
|
CD: 'CD',
|
||||||
|
DVD: 'DVD',
|
||||||
|
RAW: 'RAW'
|
||||||
|
};
|
||||||
|
|
||||||
|
function prettifyUnknownStatus(status) {
|
||||||
|
const raw = String(status || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
if (!raw.includes('_')) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
const parts = raw
|
||||||
|
.split('_')
|
||||||
|
.map((part) => String(part || '').trim().toUpperCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
const mapped = parts.map((token) => FALLBACK_TOKEN_LABELS[token] || token.toLowerCase());
|
||||||
|
const joined = mapped.join(' ').trim();
|
||||||
|
if (!joined) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return joined.charAt(0).toUpperCase() + joined.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeStatus(status) {
|
export function normalizeStatus(status) {
|
||||||
return String(status || '').trim().toUpperCase();
|
return String(status || '').trim().toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -38,7 +101,7 @@ export function getStatusLabel(status, options = {}) {
|
|||||||
return 'In der Queue';
|
return 'In der Queue';
|
||||||
}
|
}
|
||||||
const normalized = normalizeStatus(status);
|
const normalized = normalizeStatus(status);
|
||||||
return STATUS_LABELS[normalized] || (String(status || '').trim() || '-');
|
return STATUS_LABELS[normalized] || prettifyUnknownStatus(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStatusSeverity(status, options = {}) {
|
export function getStatusSeverity(status, options = {}) {
|
||||||
@@ -71,7 +134,7 @@ export function getStatusSeverity(status, options = {}) {
|
|||||||
|
|
||||||
export function getProcessStatusLabel(status) {
|
export function getProcessStatusLabel(status) {
|
||||||
const normalized = normalizeStatus(status);
|
const normalized = normalizeStatus(status);
|
||||||
return PROCESS_STATUS_LABELS[normalized] || (String(status || '').trim() || '-');
|
return PROCESS_STATUS_LABELS[normalized] || prettifyUnknownStatus(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STATUS_FILTER_OPTIONS = [
|
export const STATUS_FILTER_OPTIONS = [
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0-7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0-7",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -1,13 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.11.0-5",
|
"version": "0.12.0-7",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
"dev:frontend": "npm run dev --prefix frontend",
|
"dev:frontend": "npm run dev --prefix frontend",
|
||||||
"start": "npm run start --prefix backend",
|
"start": "npm run start --prefix backend",
|
||||||
"build:frontend": "npm run build --prefix frontend",
|
"build:frontend": "npm run build --prefix frontend",
|
||||||
|
"qa:cd:analysis": "node ./scripts/smoketest/qa-cd-analysis.js",
|
||||||
|
"qa:dvd:analysis": "node ./scripts/smoketest/qa-dvd-analysis.js",
|
||||||
|
"qa:bluray:analysis": "node ./scripts/smoketest/qa-bluray-analysis.js",
|
||||||
|
"qa:audiobook:analysis": "node ./scripts/smoketest/qa-audiobook-analysis.js",
|
||||||
|
"qa:cd:check": "bash ./scripts/smoketest/qa-cd-check.sh",
|
||||||
|
"qa:audiobook:check": "bash ./scripts/smoketest/qa-audiobook-check.sh",
|
||||||
|
"qa:logs:dashboard": "node ./scripts/smoketest/qa-log-dashboard.js",
|
||||||
"release:interactive": "bash ./scripts/release.sh"
|
"release:interactive": "bash ./scripts/release.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user