0.12.0-1 Plugins und diverses
This commit is contained in:
+221
-19
@@ -530,6 +530,7 @@ async function openAndPrepareDatabase() {
|
||||
await removeDeprecatedSettings(dbInstance);
|
||||
await migrateSettingsSchemaMetadata(dbInstance);
|
||||
await ensurePipelineStateRow(dbInstance);
|
||||
await backfillJobOutputFolders(dbInstance);
|
||||
const syncedLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true });
|
||||
logger.info('log-root:synced', {
|
||||
configured: syncedLogRoot.configured || null,
|
||||
@@ -974,51 +975,214 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('auto_eject_after_rip', 'false')`);
|
||||
|
||||
// Plugin-Architektur Toggle (Phase 2)
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('coverart_recovery_enabled', 'Metadaten', 'Coverart-Nachladen aktiv', 'boolean', 1, 'Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen.', 'true', '[]', '{}', 430)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('coverart_recovery_interval_hours', 'Metadaten', 'Coverart-Prüfintervall (Stunden)', 'number', 1, 'Intervall für automatische Prüfung fehlender Coverarts in Stunden.', '6', '[]', '{"min":1,"max":168}', 431)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6')`);
|
||||
|
||||
// depends_on-Spalte zu settings_schema hinzufügen (falls noch nicht vorhanden)
|
||||
{
|
||||
const cols = await db.all(`PRAGMA table_info(settings_schema)`);
|
||||
if (!cols.some((c) => c.name === 'depends_on')) {
|
||||
await db.run(`ALTER TABLE settings_schema ADD COLUMN depends_on TEXT DEFAULT NULL`);
|
||||
logger.info('migrate:settings-schema-add-depends_on');
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin-Architektur Toggle (Phase 2) — hierarchisch mit depends_on
|
||||
// Struktur: global → medium (depends_on: global) → phase (depends_on: medium)
|
||||
//
|
||||
// Implementierungsstand:
|
||||
// ✅ = plugin-pfad aktiv ⏳ = noch nicht implementiert (readonly, immer aus)
|
||||
//
|
||||
// BluRay / DVD: Analyse ✅ Rip ✅ Review ⏳ Encode ⏳
|
||||
// CD: Analyse ✅ Rip ⏳
|
||||
// Audiobook: Analyse ✅ Encode ⏳
|
||||
|
||||
const pluginArchitectureSettings = [
|
||||
// ── Global ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
key: 'use_plugin_architecture',
|
||||
label: 'Neue Plugin-Architektur (Beta)',
|
||||
description: 'Aktiviert die neue modulare Plugin-Architektur (Phase 2). Legacy-Code bleibt aktiv solange dieser Toggle deaktiviert ist. Nur für Testzwecke aktivieren.',
|
||||
description: 'Masterschalter: Aktiviert die neue modulare Plugin-Architektur (Phase 2). Alle Medium-Schalter darunter sind nur wirksam wenn dieser aktiv ist.',
|
||||
defaultValue: 'false',
|
||||
orderIndex: 9999
|
||||
orderIndex: 9999,
|
||||
dependsOn: null,
|
||||
validationJson: '{}'
|
||||
},
|
||||
// ── Blu-ray ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
key: 'use_plugin_architecture_bluray',
|
||||
label: 'Beta: Blu-ray Plugin aktiv',
|
||||
description: 'Aktiviert das Blu-ray Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.',
|
||||
label: 'Blu-ray Plugin',
|
||||
description: 'Aktiviert das Blu-ray Plugin. Deaktiviert = Legacy-Pfad für alle Blu-ray-Phasen.',
|
||||
defaultValue: 'true',
|
||||
orderIndex: 10000
|
||||
orderIndex: 10000,
|
||||
dependsOn: 'use_plugin_architecture',
|
||||
validationJson: '{}'
|
||||
},
|
||||
{
|
||||
key: 'use_plugin_architecture_bluray_analyze',
|
||||
label: 'Analyse (Blu-ray)',
|
||||
description: 'Disc-Erkennung und OMDB-Suche laufen über das Plugin.',
|
||||
defaultValue: 'true',
|
||||
orderIndex: 10001,
|
||||
dependsOn: 'use_plugin_architecture_bluray',
|
||||
validationJson: '{}'
|
||||
},
|
||||
{
|
||||
key: 'use_plugin_architecture_bluray_rip',
|
||||
label: 'Rip (Blu-ray)',
|
||||
description: 'MakeMKV-Rip läuft über das Plugin.',
|
||||
defaultValue: 'true',
|
||||
orderIndex: 10002,
|
||||
dependsOn: 'use_plugin_architecture_bluray',
|
||||
validationJson: '{}'
|
||||
},
|
||||
{
|
||||
key: 'use_plugin_architecture_bluray_review',
|
||||
label: 'Review / Mediainfo-Prüfung (Blu-ray)',
|
||||
description: 'Noch nicht implementiert — kommt in einem späteren Sprint.',
|
||||
defaultValue: 'false',
|
||||
orderIndex: 10003,
|
||||
dependsOn: 'use_plugin_architecture_bluray',
|
||||
validationJson: '{"readonly":true}'
|
||||
},
|
||||
{
|
||||
key: 'use_plugin_architecture_bluray_encode',
|
||||
label: 'Encoding (Blu-ray)',
|
||||
description: 'Noch nicht implementiert — kommt in einem späteren Sprint.',
|
||||
defaultValue: 'false',
|
||||
orderIndex: 10004,
|
||||
dependsOn: 'use_plugin_architecture_bluray',
|
||||
validationJson: '{"readonly":true}'
|
||||
},
|
||||
// ── DVD ──────────────────────────────────────────────────────────────────
|
||||
{
|
||||
key: 'use_plugin_architecture_dvd',
|
||||
label: 'Beta: DVD Plugin aktiv',
|
||||
description: 'Aktiviert das DVD Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.',
|
||||
label: 'DVD Plugin',
|
||||
description: 'Aktiviert das DVD Plugin. Deaktiviert = Legacy-Pfad für alle DVD-Phasen.',
|
||||
defaultValue: 'true',
|
||||
orderIndex: 10001
|
||||
orderIndex: 10010,
|
||||
dependsOn: 'use_plugin_architecture',
|
||||
validationJson: '{}'
|
||||
},
|
||||
{
|
||||
key: 'use_plugin_architecture_dvd_analyze',
|
||||
label: 'Analyse (DVD)',
|
||||
description: 'Disc-Erkennung und OMDB-Suche laufen über das Plugin.',
|
||||
defaultValue: 'true',
|
||||
orderIndex: 10011,
|
||||
dependsOn: 'use_plugin_architecture_dvd',
|
||||
validationJson: '{}'
|
||||
},
|
||||
{
|
||||
key: 'use_plugin_architecture_dvd_rip',
|
||||
label: 'Rip (DVD)',
|
||||
description: 'MakeMKV-Rip läuft über das Plugin.',
|
||||
defaultValue: 'true',
|
||||
orderIndex: 10012,
|
||||
dependsOn: 'use_plugin_architecture_dvd',
|
||||
validationJson: '{}'
|
||||
},
|
||||
{
|
||||
key: 'use_plugin_architecture_dvd_review',
|
||||
label: 'Review / Mediainfo-Prüfung (DVD)',
|
||||
description: 'Noch nicht implementiert — kommt in einem späteren Sprint.',
|
||||
defaultValue: 'false',
|
||||
orderIndex: 10013,
|
||||
dependsOn: 'use_plugin_architecture_dvd',
|
||||
validationJson: '{"readonly":true}'
|
||||
},
|
||||
{
|
||||
key: 'use_plugin_architecture_dvd_encode',
|
||||
label: 'Encoding (DVD)',
|
||||
description: 'Noch nicht implementiert — kommt in einem späteren Sprint.',
|
||||
defaultValue: 'false',
|
||||
orderIndex: 10014,
|
||||
dependsOn: 'use_plugin_architecture_dvd',
|
||||
validationJson: '{"readonly":true}'
|
||||
},
|
||||
// ── Audio-CD ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
key: 'use_plugin_architecture_cd',
|
||||
label: 'Beta: Audio-CD Plugin aktiv',
|
||||
description: 'Aktiviert das Audio-CD Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.',
|
||||
label: 'Audio-CD Plugin',
|
||||
description: 'Aktiviert das Audio-CD Plugin. Deaktiviert = Legacy-Pfad für alle CD-Phasen.',
|
||||
defaultValue: 'true',
|
||||
orderIndex: 10002
|
||||
orderIndex: 10020,
|
||||
dependsOn: 'use_plugin_architecture',
|
||||
validationJson: '{}'
|
||||
},
|
||||
{
|
||||
key: 'use_plugin_architecture_audiobook',
|
||||
label: 'Beta: Audiobook Plugin aktiv',
|
||||
description: 'Aktiviert das Audiobook Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.',
|
||||
key: 'use_plugin_architecture_cd_analyze',
|
||||
label: 'Analyse (Audio-CD)',
|
||||
description: 'TOC-Auslesung läuft über das Plugin.',
|
||||
defaultValue: 'true',
|
||||
orderIndex: 10003
|
||||
orderIndex: 10021,
|
||||
dependsOn: 'use_plugin_architecture_cd',
|
||||
validationJson: '{}'
|
||||
},
|
||||
{
|
||||
key: 'use_plugin_architecture_cd_rip',
|
||||
label: 'Rip + Encode (Audio-CD)',
|
||||
description: 'Noch nicht implementiert — cdparanoia-Rip läuft noch über Legacy.',
|
||||
defaultValue: 'false',
|
||||
orderIndex: 10022,
|
||||
dependsOn: 'use_plugin_architecture_cd',
|
||||
validationJson: '{"readonly":true}'
|
||||
},
|
||||
// ── Audiobook ────────────────────────────────────────────────────────────
|
||||
{
|
||||
key: 'use_plugin_architecture_audiobook',
|
||||
label: 'Audiobook Plugin',
|
||||
description: 'Aktiviert das Audiobook Plugin. Deaktiviert = Legacy-Pfad für alle Audiobook-Phasen.',
|
||||
defaultValue: 'true',
|
||||
orderIndex: 10030,
|
||||
dependsOn: 'use_plugin_architecture',
|
||||
validationJson: '{}'
|
||||
},
|
||||
{
|
||||
key: 'use_plugin_architecture_audiobook_analyze',
|
||||
label: 'Analyse (Audiobook)',
|
||||
description: 'ffprobe-Metadatenanalyse läuft über das Plugin.',
|
||||
defaultValue: 'true',
|
||||
orderIndex: 10031,
|
||||
dependsOn: 'use_plugin_architecture_audiobook',
|
||||
validationJson: '{}'
|
||||
},
|
||||
{
|
||||
key: 'use_plugin_architecture_audiobook_encode',
|
||||
label: 'Encoding (Audiobook)',
|
||||
description: 'Noch nicht implementiert — Encoding läuft noch über Legacy.',
|
||||
defaultValue: 'false',
|
||||
orderIndex: 10032,
|
||||
dependsOn: 'use_plugin_architecture_audiobook',
|
||||
validationJson: '{"readonly":true}'
|
||||
}
|
||||
];
|
||||
for (const setting of pluginArchitectureSettings) {
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES (?, 'Erweitert', ?, 'boolean', 1, ?, ?, '[]', '{}', ?)`,
|
||||
[setting.key, setting.label, setting.description, setting.defaultValue, setting.orderIndex]
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||
VALUES (?, 'Erweitert', ?, 'boolean', 1, ?, ?, '[]', ?, ?, ?)`,
|
||||
[setting.key, setting.label, setting.description, setting.defaultValue, setting.validationJson, setting.orderIndex, setting.dependsOn ?? null]
|
||||
);
|
||||
// depends_on und order_index für bereits existierende Zeilen aktualisieren
|
||||
await db.run(
|
||||
`UPDATE settings_schema SET depends_on = ?, order_index = ?, validation_json = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = ? AND (depends_on IS NOT ? OR order_index != ? OR validation_json != ?)`,
|
||||
[setting.dependsOn ?? null, setting.orderIndex, setting.validationJson, setting.key, setting.dependsOn ?? null, setting.orderIndex, setting.validationJson]
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES (?, ?)`, [setting.key, setting.defaultValue]);
|
||||
// Readonly-Toggles immer auf 'false' zurücksetzen (nie aktivierbar)
|
||||
if (setting.validationJson === '{"readonly":true}') {
|
||||
await db.run(`UPDATE settings_values SET value = 'false' WHERE key = ?`, [setting.key]);
|
||||
}
|
||||
}
|
||||
|
||||
await db.run(`
|
||||
@@ -1030,6 +1194,44 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
`);
|
||||
}
|
||||
|
||||
async function backfillJobOutputFolders(db) {
|
||||
// Remove duplicate (job_id, output_path) rows before unique index is enforced.
|
||||
await db.run(`
|
||||
DELETE FROM job_output_folders
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM job_output_folders GROUP BY job_id, output_path
|
||||
)
|
||||
`);
|
||||
|
||||
// Populate job_output_folders from jobs.output_path for pre-feature jobs.
|
||||
// Only inserts rows where no entry exists for the job yet.
|
||||
const rows = await db.all(`
|
||||
SELECT j.id AS job_id, j.output_path
|
||||
FROM jobs j
|
||||
WHERE j.output_path IS NOT NULL
|
||||
AND j.output_path != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM job_output_folders f WHERE f.job_id = j.id
|
||||
)
|
||||
`);
|
||||
if (!Array.isArray(rows) || rows.length === 0) return;
|
||||
let inserted = 0;
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await db.run(
|
||||
'INSERT OR IGNORE INTO job_output_folders (job_id, output_path) VALUES (?, ?)',
|
||||
[row.job_id, row.output_path]
|
||||
);
|
||||
inserted += 1;
|
||||
} catch (err) {
|
||||
logger.warn('backfill:job-output-folders:insert-failed', { jobId: row.job_id, error: err?.message });
|
||||
}
|
||||
}
|
||||
if (inserted > 0) {
|
||||
logger.info('backfill:job-output-folders:done', { inserted });
|
||||
}
|
||||
}
|
||||
|
||||
async function getDb() {
|
||||
return initDatabase();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const cronService = require('./services/cronService');
|
||||
const downloadService = require('./services/downloadService');
|
||||
const diskDetectionService = require('./services/diskDetectionService');
|
||||
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
||||
const coverArtRecoveryService = require('./services/coverArtRecoveryService');
|
||||
const logger = require('./services/logger').child('BOOT');
|
||||
const { errorToMeta } = require('./utils/errorMeta');
|
||||
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
|
||||
@@ -29,6 +30,7 @@ async function start() {
|
||||
await pipelineService.init();
|
||||
await cronService.init();
|
||||
await downloadService.init();
|
||||
await coverArtRecoveryService.init();
|
||||
|
||||
const app = express();
|
||||
app.use(cors({ origin: corsOrigin }));
|
||||
@@ -85,6 +87,7 @@ async function start() {
|
||||
const shutdown = () => {
|
||||
logger.warn('backend:shutdown:received');
|
||||
diskDetectionService.stop();
|
||||
coverArtRecoveryService.stop();
|
||||
hardwareMonitorService.stop();
|
||||
cronService.stop();
|
||||
server.close(() => {
|
||||
|
||||
@@ -30,12 +30,14 @@ router.post(
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const target = String(req.body?.target || 'raw').trim();
|
||||
const outputPath = String(req.body?.outputPath || '').trim() || null;
|
||||
logger.info('post:downloads:history', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
target
|
||||
target,
|
||||
outputPath
|
||||
});
|
||||
const result = await downloadService.enqueueHistoryJob(jobId, target);
|
||||
const result = await downloadService.enqueueHistoryJob(jobId, target, { outputPath });
|
||||
res.status(result.created ? 201 : 200).json({
|
||||
...result,
|
||||
summary: downloadService.getSummary()
|
||||
|
||||
@@ -148,15 +148,21 @@ router.post(
|
||||
const id = Number(req.params.id);
|
||||
const target = String(req.body?.target || 'none');
|
||||
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
||||
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
|
||||
? req.body.selectedMoviePaths
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
|
||||
logger.warn('post:delete-job', {
|
||||
reqId: req.reqId,
|
||||
id,
|
||||
target,
|
||||
includeRelated
|
||||
includeRelated,
|
||||
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
||||
});
|
||||
|
||||
const result = await historyService.deleteJob(id, target, { includeRelated });
|
||||
const result = await historyService.deleteJob(id, target, { includeRelated, selectedMoviePaths });
|
||||
const uiReset = await pipelineService.resetFrontendState('history_delete');
|
||||
res.json({ ...result, uiReset });
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ const path = require('path');
|
||||
const multer = require('multer');
|
||||
const asyncHandler = require('../middleware/asyncHandler');
|
||||
const pipelineService = require('../services/pipelineService');
|
||||
const historyService = require('../services/historyService');
|
||||
const diskDetectionService = require('../services/diskDetectionService');
|
||||
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||
const logger = require('../services/logger').child('PIPELINE_ROUTE');
|
||||
@@ -346,12 +347,34 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/output-folders/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const folders = await historyService.getJobOutputFoldersForLineage(jobId);
|
||||
res.json({ folders });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/delete-output-folders/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const folderPaths = Array.isArray(req.body?.folderPaths) ? req.body.folderPaths : [];
|
||||
logger.info('post:delete-output-folders', { reqId: req.reqId, jobId, count: folderPaths.length });
|
||||
const result = await historyService.deleteSpecificOutputFolders(jobId, folderPaths);
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/reencode/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('post:reencode', { reqId: req.reqId, jobId });
|
||||
const result = await pipelineService.reencodeFromRaw(jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
logger.info('post:reencode', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
|
||||
const result = await pipelineService.reencodeFromRaw(jobId, { keepBoth, deleteFolders });
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
@@ -360,8 +383,10 @@ router.post(
|
||||
'/restart-review/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('post:restart-review', { reqId: req.reqId, jobId });
|
||||
const result = await pipelineService.restartReviewFromRaw(jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
logger.info('post:restart-review', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
|
||||
const result = await pipelineService.restartReviewFromRaw(jobId, { keepBoth, deleteFolders });
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
@@ -370,8 +395,22 @@ router.post(
|
||||
'/restart-encode/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
logger.info('post:restart-encode', { reqId: req.reqId, jobId });
|
||||
const result = await pipelineService.restartEncodeWithLastSettings(jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
logger.info('post:restart-encode', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
|
||||
const result = await pipelineService.restartEncodeWithLastSettings(jobId, { keepBoth, deleteFolders });
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/restart-cd-review/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
logger.info('post:restart-cd-review', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
|
||||
const result = await pipelineService.restartCdReviewFromRaw(jobId, { keepBoth, deleteFolders });
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||
const userPresetService = require('../services/userPresetService');
|
||||
const activationBytesService = require('../services/activationBytesService');
|
||||
const diskDetectionService = require('../services/diskDetectionService');
|
||||
const coverArtRecoveryService = require('../services/coverArtRecoveryService');
|
||||
const logger = require('../services/logger').child('SETTINGS_ROUTE');
|
||||
|
||||
const { getDb } = require('../db/database');
|
||||
@@ -210,6 +211,22 @@ router.delete(
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/coverart/recover',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.info('post:settings:coverart:recover', { reqId: req.reqId });
|
||||
const result = await coverArtRecoveryService.runNow({
|
||||
trigger: 'manual',
|
||||
force: true,
|
||||
logFailures: true
|
||||
});
|
||||
res.json({
|
||||
result,
|
||||
scheduler: coverArtRecoveryService.getStatus()
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:key',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -259,6 +276,18 @@ router.put(
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
await coverArtRecoveryService.handleSettingsChanged([key]);
|
||||
} catch (error) {
|
||||
logger.warn('put:setting:coverart-scheduler-refresh-failed', {
|
||||
reqId: req.reqId,
|
||||
key,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message
|
||||
}
|
||||
});
|
||||
}
|
||||
wsService.broadcast('SETTINGS_UPDATED', updated);
|
||||
|
||||
res.json({ setting: updated, reviewRefresh });
|
||||
@@ -312,6 +341,17 @@ router.put(
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
await coverArtRecoveryService.handleSettingsChanged(changes.map((item) => item.key));
|
||||
} catch (error) {
|
||||
logger.warn('put:settings:bulk:coverart-scheduler-refresh-failed', {
|
||||
reqId: req.reqId,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message
|
||||
}
|
||||
});
|
||||
}
|
||||
wsService.broadcast('SETTINGS_BULK_UPDATED', { count: changes.length, keys: changes.map((item) => item.key) });
|
||||
|
||||
res.json({ changes, reviewRefresh });
|
||||
|
||||
@@ -244,7 +244,19 @@ function outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir) {
|
||||
}
|
||||
const offset = outputSegments.length - relativeSegments.length;
|
||||
for (let i = 0; i < relativeSegments.length; i++) {
|
||||
if (outputSegments[offset + i] !== relativeSegments[i]) {
|
||||
const outputPart = outputSegments[offset + i];
|
||||
const relativePart = relativeSegments[i];
|
||||
if (outputPart === relativePart) {
|
||||
continue;
|
||||
}
|
||||
// Numbered conflict folders end with "_X" (e.g. ".../Album (2007)_2").
|
||||
// Treat this as containing the same relative directory to avoid nesting:
|
||||
// Album (2007)_2/Album (2007)/...
|
||||
const isLastRelativeSegment = i === relativeSegments.length - 1;
|
||||
const matchesNumberedSuffix = isLastRelativeSegment
|
||||
&& outputPart.startsWith(`${relativePart}_`)
|
||||
&& /^\d+$/.test(outputPart.slice(relativePart.length + 1));
|
||||
if (!matchesNumberedSuffix) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('COVERART_RECOVERY');
|
||||
const settingsService = require('./settingsService');
|
||||
const historyService = require('./historyService');
|
||||
const thumbnailService = require('./thumbnailService');
|
||||
|
||||
const COVERART_RECOVERY_ENABLED_KEY = 'coverart_recovery_enabled';
|
||||
const COVERART_RECOVERY_INTERVAL_HOURS_KEY = 'coverart_recovery_interval_hours';
|
||||
const DEFAULT_INTERVAL_HOURS = 6;
|
||||
const MIN_INTERVAL_HOURS = 1;
|
||||
const MAX_INTERVAL_HOURS = 168;
|
||||
const RUNNING_JOB_STATUSES = new Set([
|
||||
'ANALYZING',
|
||||
'RIPPING',
|
||||
'MEDIAINFO_CHECK',
|
||||
'ENCODING',
|
||||
'CD_ANALYZING',
|
||||
'CD_RIPPING',
|
||||
'CD_ENCODING'
|
||||
]);
|
||||
|
||||
function parseJsonSafe(raw, fallback = null) {
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function toBoolean(value, fallback = false) {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
return ['1', 'true', 'yes', 'on'].includes(normalized);
|
||||
}
|
||||
|
||||
function normalizeIntervalHours(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_INTERVAL_HOURS;
|
||||
}
|
||||
return Math.max(MIN_INTERVAL_HOURS, Math.min(MAX_INTERVAL_HOURS, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
function normalizeExternalUrl(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (!/^https?:\/\//i.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function deriveCoverArtArchiveUrl(mbId) {
|
||||
const normalized = String(mbId || '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return `https://coverartarchive.org/release/${encodeURIComponent(normalized)}/front-250`;
|
||||
}
|
||||
|
||||
function isLikelyMusicBrainzId(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (/^tt\d{6,12}$/i.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return /^[a-z0-9-]{8,}$/i.test(normalized);
|
||||
}
|
||||
|
||||
function collectCoverCandidates(row) {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const push = (url, source) => {
|
||||
const normalized = normalizeExternalUrl(url);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const dedupeKey = normalized.toLowerCase();
|
||||
if (seen.has(dedupeKey)) {
|
||||
return;
|
||||
}
|
||||
seen.add(dedupeKey);
|
||||
candidates.push({
|
||||
url: normalized,
|
||||
source: String(source || '').trim() || null
|
||||
});
|
||||
};
|
||||
|
||||
push(row?.poster_url, 'job.poster_url');
|
||||
|
||||
const makemkvInfo = parseJsonSafe(row?.makemkv_info_json, {});
|
||||
const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {};
|
||||
|
||||
push(selectedMetadata?.coverUrl, 'selectedMetadata.coverUrl');
|
||||
push(selectedMetadata?.poster, 'selectedMetadata.poster');
|
||||
push(selectedMetadata?.posterUrl, 'selectedMetadata.posterUrl');
|
||||
|
||||
const mbId = String(
|
||||
selectedMetadata?.mbId
|
||||
|| selectedMetadata?.musicBrainzId
|
||||
|| selectedMetadata?.musicbrainzId
|
||||
|| selectedMetadata?.musicbrainz_id
|
||||
|| selectedMetadata?.music_brainz_id
|
||||
|| selectedMetadata?.musicbrainz
|
||||
|| selectedMetadata?.mbid
|
||||
|| row?.imdb_id
|
||||
|| ''
|
||||
).trim();
|
||||
if (isLikelyMusicBrainzId(mbId)) {
|
||||
push(deriveCoverArtArchiveUrl(mbId), 'musicbrainz.coverartarchive');
|
||||
}
|
||||
|
||||
const omdbInfo = parseJsonSafe(row?.omdb_json, {});
|
||||
const omdbPoster = String(omdbInfo?.Poster || '').trim();
|
||||
if (omdbPoster && omdbPoster.toUpperCase() !== 'N/A') {
|
||||
push(omdbPoster, 'omdb_json.Poster');
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
class CoverArtRecoveryService {
|
||||
constructor() {
|
||||
this.timer = null;
|
||||
this.inFlight = null;
|
||||
this.nextRunAt = null;
|
||||
this.schedulerEnabled = false;
|
||||
this.intervalHours = DEFAULT_INTERVAL_HOURS;
|
||||
this.lastRunSummary = null;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
enabled: this.schedulerEnabled,
|
||||
intervalHours: this.intervalHours,
|
||||
nextRunAt: this.nextRunAt,
|
||||
running: Boolean(this.inFlight),
|
||||
lastRunSummary: this.lastRunSummary
|
||||
};
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.nextRunAt = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.refreshSchedule({ runStartupCheck: true });
|
||||
}
|
||||
|
||||
async handleSettingsChanged(changedKeys = []) {
|
||||
const normalizedKeys = Array.isArray(changedKeys)
|
||||
? changedKeys.map((key) => String(key || '').trim().toLowerCase()).filter(Boolean)
|
||||
: [];
|
||||
if (
|
||||
normalizedKeys.length > 0
|
||||
&& !normalizedKeys.includes(COVERART_RECOVERY_ENABLED_KEY)
|
||||
&& !normalizedKeys.includes(COVERART_RECOVERY_INTERVAL_HOURS_KEY)
|
||||
) {
|
||||
return this.getStatus();
|
||||
}
|
||||
return this.refreshSchedule({ runStartupCheck: false });
|
||||
}
|
||||
|
||||
async refreshSchedule(options = {}) {
|
||||
const runStartupCheck = options?.runStartupCheck !== false;
|
||||
this.stop();
|
||||
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
this.schedulerEnabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true);
|
||||
this.intervalHours = normalizeIntervalHours(settings?.[COVERART_RECOVERY_INTERVAL_HOURS_KEY]);
|
||||
|
||||
logger.info('scheduler:refresh', {
|
||||
enabled: this.schedulerEnabled,
|
||||
intervalHours: this.intervalHours,
|
||||
runStartupCheck
|
||||
});
|
||||
|
||||
if (this.schedulerEnabled && runStartupCheck) {
|
||||
this.runNow({ trigger: 'startup' }).catch((error) => {
|
||||
logger.warn('scheduler:startup-run-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.schedulerEnabled) {
|
||||
this.scheduleNextAutoRun();
|
||||
}
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
scheduleNextAutoRun() {
|
||||
this.stop();
|
||||
if (!this.schedulerEnabled) {
|
||||
return;
|
||||
}
|
||||
const delayMs = this.intervalHours * 60 * 60 * 1000;
|
||||
this.nextRunAt = new Date(Date.now() + delayMs).toISOString();
|
||||
this.timer = setTimeout(() => {
|
||||
this.runNow({ trigger: 'auto' })
|
||||
.catch((error) => {
|
||||
logger.warn('scheduler:auto-run-failed', {
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.scheduleNextAutoRun();
|
||||
});
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async runNow(options = {}) {
|
||||
if (this.inFlight) {
|
||||
return this.inFlight;
|
||||
}
|
||||
let promise = null;
|
||||
promise = this._runNowInternal(options)
|
||||
.finally(() => {
|
||||
if (this.inFlight === promise) {
|
||||
this.inFlight = null;
|
||||
}
|
||||
});
|
||||
this.inFlight = promise;
|
||||
return promise;
|
||||
}
|
||||
|
||||
async _runNowInternal(options = {}) {
|
||||
const trigger = String(options?.trigger || 'manual').trim().toLowerCase() || 'manual';
|
||||
const force = Boolean(options?.force);
|
||||
const logFailures = options?.logFailures !== false;
|
||||
const startedMs = Date.now();
|
||||
const startedAt = new Date().toISOString();
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const enabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true);
|
||||
|
||||
if (!enabled && !force) {
|
||||
const skipped = {
|
||||
trigger,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
durationMs: 0,
|
||||
skipped: true,
|
||||
reason: 'disabled'
|
||||
};
|
||||
this.lastRunSummary = skipped;
|
||||
return skipped;
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
detected_title,
|
||||
status,
|
||||
poster_url,
|
||||
imdb_id,
|
||||
makemkv_info_json,
|
||||
omdb_json
|
||||
FROM jobs
|
||||
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
|
||||
`
|
||||
);
|
||||
|
||||
const summary = {
|
||||
trigger,
|
||||
startedAt,
|
||||
finishedAt: null,
|
||||
durationMs: 0,
|
||||
scannedJobs: Array.isArray(rows) ? rows.length : 0,
|
||||
runningSkipped: 0,
|
||||
alreadyLocal: 0,
|
||||
noCandidate: 0,
|
||||
recovered: 0,
|
||||
failed: 0,
|
||||
failedJobs: []
|
||||
};
|
||||
|
||||
for (const row of rows || []) {
|
||||
const jobId = Number(row?.id || 0);
|
||||
if (!jobId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = String(row?.status || '').trim().toUpperCase();
|
||||
if (RUNNING_JOB_STATUSES.has(status)) {
|
||||
summary.runningSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentPosterUrl = String(row?.poster_url || '').trim();
|
||||
if (
|
||||
currentPosterUrl
|
||||
&& thumbnailService.isLocalUrl(currentPosterUrl)
|
||||
&& thumbnailService.localThumbnailUrlExists(currentPosterUrl)
|
||||
) {
|
||||
summary.alreadyLocal += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidates = collectCoverCandidates(row);
|
||||
if (candidates.length === 0) {
|
||||
summary.noCandidate += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let recovered = false;
|
||||
let lastError = null;
|
||||
for (const candidate of candidates) {
|
||||
const result = await historyService.cacheAndPromoteExternalPoster(jobId, candidate.url, {
|
||||
source: 'Coverart',
|
||||
logFailures: false
|
||||
});
|
||||
if (result?.ok && result?.localUrl) {
|
||||
recovered = true;
|
||||
summary.recovered += 1;
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Coverart nachgeladen (${trigger}): ${candidate.url}${candidate?.source ? ` [${candidate.source}]` : ''}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
lastError = String(result?.error || result?.reason || 'download_failed');
|
||||
}
|
||||
|
||||
if (!recovered) {
|
||||
summary.failed += 1;
|
||||
const failedInfo = {
|
||||
jobId,
|
||||
title: String(row?.title || row?.detected_title || `Job #${jobId}`),
|
||||
attemptedUrls: candidates.map((item) => item.url),
|
||||
error: lastError
|
||||
};
|
||||
summary.failedJobs.push(failedInfo);
|
||||
if (logFailures && trigger !== 'auto') {
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Coverart-Download fehlgeschlagen (${trigger}). Versuchte Links: ${failedInfo.attemptedUrls.join(', ')}${lastError ? ` | Fehler: ${lastError}` : ''}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const durationMs = Math.max(0, Date.now() - startedMs);
|
||||
const result = {
|
||||
...summary,
|
||||
finishedAt,
|
||||
durationMs
|
||||
};
|
||||
this.lastRunSummary = result;
|
||||
logger.info('recovery:done', {
|
||||
trigger: result.trigger,
|
||||
scannedJobs: result.scannedJobs,
|
||||
recovered: result.recovered,
|
||||
failed: result.failed,
|
||||
alreadyLocal: result.alreadyLocal,
|
||||
noCandidate: result.noCandidate,
|
||||
runningSkipped: result.runningSkipped,
|
||||
durationMs: result.durationMs
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CoverArtRecoveryService();
|
||||
@@ -170,14 +170,41 @@ function inferMediaProfileFromUdevProperties(properties = {}) {
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const parseTrackCount = (rawValue) => {
|
||||
const normalized = String(rawValue ?? '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(normalized, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const hasFlag = (prefix) => Object.entries(flags).some(([key, value]) => key.startsWith(prefix) && value === '1');
|
||||
if (hasFlag('ID_CDROM_MEDIA_BD')) {
|
||||
const hasBD = hasFlag('ID_CDROM_MEDIA_BD');
|
||||
const hasDVD = hasFlag('ID_CDROM_MEDIA_DVD');
|
||||
const hasCD = hasFlag('ID_CDROM_MEDIA_CD');
|
||||
const audioTrackCount = parseTrackCount(flags.ID_CDROM_MEDIA_TRACK_COUNT_AUDIO);
|
||||
const dataTrackCount = parseTrackCount(flags.ID_CDROM_MEDIA_TRACK_COUNT_DATA);
|
||||
const hasAudioTracks = Number.isFinite(audioTrackCount) && audioTrackCount > 0;
|
||||
const hasDataTracks = Number.isFinite(dataTrackCount) && dataTrackCount > 0;
|
||||
|
||||
// Prefer audio-CD detection when udev exposes track counters.
|
||||
if (hasCD && hasAudioTracks && !hasDataTracks) {
|
||||
return 'cd';
|
||||
}
|
||||
if (hasCD && !hasDVD && !hasBD) {
|
||||
return 'cd';
|
||||
}
|
||||
if (hasBD) {
|
||||
return 'bluray';
|
||||
}
|
||||
if (hasFlag('ID_CDROM_MEDIA_DVD')) {
|
||||
if (hasDVD) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (hasFlag('ID_CDROM_MEDIA_CD')) {
|
||||
if (hasCD) {
|
||||
return 'cd';
|
||||
}
|
||||
return null;
|
||||
@@ -479,6 +506,26 @@ class DiskDetectionService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Supplement: any drive already tracked in detectedDiscs that was not found by auto-scan
|
||||
// gets a second chance via detectExplicit (which includes the sysfs-size fallback).
|
||||
// This prevents polling from falsely emitting discRemoved for drives that were manually
|
||||
// rescanned but cannot be auto-detected (e.g. VM/passthrough devices invisible to lsblk).
|
||||
const foundPaths = new Set(autoResults.map((d) => String(d.path || '')));
|
||||
const supplementChecks = [];
|
||||
for (const [trackedPath] of this.detectedDiscs) {
|
||||
if (!trackedPath.startsWith('__virtual__') && !foundPaths.has(trackedPath)) {
|
||||
supplementChecks.push(this.detectExplicit(trackedPath));
|
||||
}
|
||||
}
|
||||
if (supplementChecks.length > 0) {
|
||||
const supplementResults = await Promise.all(supplementChecks);
|
||||
for (const result of supplementResults) {
|
||||
if (result) {
|
||||
autoResults.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return autoResults;
|
||||
}
|
||||
|
||||
@@ -590,16 +637,24 @@ class DiskDetectionService extends EventEmitter {
|
||||
return null;
|
||||
}
|
||||
|
||||
const details = await this.getBlockDeviceInfo();
|
||||
const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {};
|
||||
|
||||
// Always call checkMediaPresent to get the filesystem type (needed for accurate
|
||||
// mediaProfile detection). Use lsblk SIZE as fallback presence indicator for
|
||||
// drives where blkid/udevadm fail (VM/passthrough).
|
||||
const mediaState = await this.checkMediaPresent(devicePath);
|
||||
if (!mediaState.hasMedia) {
|
||||
const hasSizeMedia = (match.sizeBytes || 0) > 0;
|
||||
if (!mediaState.hasMedia && !hasSizeMedia) {
|
||||
logger.debug('detect:explicit:no-media', { devicePath });
|
||||
return null;
|
||||
}
|
||||
if (!mediaState.hasMedia && hasSizeMedia) {
|
||||
logger.debug('detect:explicit:media-by-size-fallback', { devicePath, sizeBytes: match.sizeBytes });
|
||||
}
|
||||
const mediaType = mediaState.type;
|
||||
const discLabel = await this.getDiscLabel(devicePath);
|
||||
|
||||
const details = await this.getBlockDeviceInfo();
|
||||
const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {};
|
||||
const detectedFsType = String(match.fstype || mediaState.type || '').trim() || null;
|
||||
const detectedFsType = String(match.fstype || mediaType || '').trim() || null;
|
||||
|
||||
const mediaProfile = await this.inferMediaProfile(devicePath, {
|
||||
discLabel,
|
||||
@@ -697,12 +752,21 @@ class DiskDetectionService extends EventEmitter {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Always call checkMediaPresent to get the filesystem type (needed for accurate
|
||||
// mediaProfile detection via inferMediaProfile). Use lsblk SIZE as a fallback
|
||||
// presence indicator for drives where blkid/udevadm fail (VM/passthrough).
|
||||
const mediaState = await this.checkMediaPresent(path);
|
||||
if (!mediaState.hasMedia) {
|
||||
const hasSizeMedia = item.sizeBytes > 0;
|
||||
if (!mediaState.hasMedia && !hasSizeMedia) {
|
||||
logger.debug('detect:all-auto:no-media', { path, sizeBytes: item.sizeBytes });
|
||||
continue;
|
||||
}
|
||||
if (!mediaState.hasMedia && hasSizeMedia) {
|
||||
logger.debug('detect:all-auto:media-by-size-fallback', { path, sizeBytes: item.sizeBytes });
|
||||
}
|
||||
const mediaType = mediaState.type;
|
||||
const discLabel = await this.getDiscLabel(path);
|
||||
const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null;
|
||||
const detectedFsType = String(item.fstype || mediaType || '').trim() || null;
|
||||
|
||||
const mediaProfile = await this.inferMediaProfile(path, {
|
||||
discLabel,
|
||||
@@ -736,8 +800,9 @@ class DiskDetectionService extends EventEmitter {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('lsblk', [
|
||||
'-J',
|
||||
'-b',
|
||||
'-o',
|
||||
'NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL'
|
||||
'NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL,SIZE'
|
||||
]);
|
||||
const parsed = JSON.parse(stdout);
|
||||
const devices = flattenDevices(parsed.blockdevices || []).map((entry) => ({
|
||||
@@ -747,7 +812,8 @@ class DiskDetectionService extends EventEmitter {
|
||||
mountpoint: entry.mountpoint,
|
||||
fstype: entry.fstype,
|
||||
label: entry.label,
|
||||
model: entry.model
|
||||
model: entry.model,
|
||||
sizeBytes: Number(entry.size) || 0
|
||||
}));
|
||||
logger.debug('lsblk:ok', { deviceCount: devices.length });
|
||||
return devices;
|
||||
@@ -757,6 +823,38 @@ class DiskDetectionService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
async probeAudioCdWithCdparanoia(devicePath, command = 'cdparanoia') {
|
||||
const cdparanoiaCmd = String(command || '').trim() || 'cdparanoia';
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(cdparanoiaCmd, ['-Q', '-d', devicePath], { timeout: 10000 });
|
||||
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
|
||||
if (tracks.length > 0) {
|
||||
logger.debug('cdparanoia:audio-cd', { devicePath, cmd: cdparanoiaCmd, trackCount: tracks.length });
|
||||
return true;
|
||||
}
|
||||
logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath, cmd: cdparanoiaCmd });
|
||||
return true;
|
||||
} catch (error) {
|
||||
const stderr = String(error?.stderr || '');
|
||||
const stdout = String(error?.stdout || '');
|
||||
const tracks = parseToc(`${stderr}\n${stdout}`);
|
||||
if (tracks.length > 0) {
|
||||
logger.debug('cdparanoia:audio-cd-from-error-streams', {
|
||||
devicePath,
|
||||
cmd: cdparanoiaCmd,
|
||||
trackCount: tracks.length
|
||||
});
|
||||
return true;
|
||||
}
|
||||
logger.debug('cdparanoia:no-audio-cd', {
|
||||
devicePath,
|
||||
cmd: cdparanoiaCmd,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkMediaPresent(devicePath) {
|
||||
let blkidType = null;
|
||||
let blkidError = null;
|
||||
@@ -789,21 +887,22 @@ class DiskDetectionService extends EventEmitter {
|
||||
}
|
||||
props[line.slice(0, idx).trim().toUpperCase()] = line.slice(idx + 1).trim();
|
||||
}
|
||||
const hasBD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_BD') && props[k] === '1');
|
||||
const hasDVD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_DVD') && props[k] === '1');
|
||||
const hasCD = props['ID_CDROM_MEDIA_CD'] === '1';
|
||||
logger.info('check-media:udevadm', { devicePath, hasBD, hasDVD, hasCD });
|
||||
if (hasCD && !hasDVD && !hasBD) {
|
||||
const inferredByUdev = inferMediaProfileFromUdevProperties(props);
|
||||
const audioTrackCount = Number.parseInt(String(props.ID_CDROM_MEDIA_TRACK_COUNT_AUDIO || '').trim(), 10);
|
||||
const dataTrackCount = Number.parseInt(String(props.ID_CDROM_MEDIA_TRACK_COUNT_DATA || '').trim(), 10);
|
||||
logger.info('check-media:udevadm', {
|
||||
devicePath,
|
||||
inferredByUdev,
|
||||
audioTrackCount: Number.isFinite(audioTrackCount) ? audioTrackCount : null,
|
||||
dataTrackCount: Number.isFinite(dataTrackCount) ? dataTrackCount : null
|
||||
});
|
||||
if (inferredByUdev === 'cd') {
|
||||
logger.debug('udevadm:audio-cd', { devicePath });
|
||||
return { hasMedia: true, type: 'audio_cd' };
|
||||
}
|
||||
if (hasBD) {
|
||||
logger.debug('udevadm:bluray', { devicePath });
|
||||
return { hasMedia: true, type: 'udf' };
|
||||
}
|
||||
if (hasDVD) {
|
||||
logger.debug('udevadm:dvd', { devicePath });
|
||||
return { hasMedia: true, type: 'udf' };
|
||||
if (inferredByUdev === 'bluray' || inferredByUdev === 'dvd') {
|
||||
logger.debug('udevadm:optical-media', { devicePath, inferredByUdev });
|
||||
return { hasMedia: true, type: null };
|
||||
}
|
||||
} catch (_udevError) {
|
||||
// udevadm not available or failed – ignore
|
||||
@@ -815,24 +914,27 @@ class DiskDetectionService extends EventEmitter {
|
||||
// stdout/stderr and treat valid TOC lines as "audio CD present".
|
||||
// Keep compatibility with previous behavior: exit 0 counts as media even
|
||||
// when TOC output format cannot be parsed.
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('cdparanoia', ['-Q', '-d', devicePath], { timeout: 10000 });
|
||||
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
|
||||
if (tracks.length > 0) {
|
||||
logger.debug('cdparanoia:audio-cd', { devicePath, trackCount: tracks.length });
|
||||
return { hasMedia: true, type: 'audio_cd' };
|
||||
}
|
||||
logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath });
|
||||
const hasAudioCdToc = await this.probeAudioCdWithCdparanoia(devicePath);
|
||||
if (hasAudioCdToc) {
|
||||
return { hasMedia: true, type: 'audio_cd' };
|
||||
} catch (cdError) {
|
||||
const stderr = String(cdError?.stderr || '');
|
||||
const stdout = String(cdError?.stdout || '');
|
||||
const tracks = parseToc(`${stderr}\n${stdout}`);
|
||||
if (tracks.length > 0) {
|
||||
logger.debug('cdparanoia:audio-cd-from-error-streams', { devicePath, trackCount: tracks.length });
|
||||
return { hasMedia: true, type: 'audio_cd' };
|
||||
}
|
||||
|
||||
// Final fallback: check block device size via sysfs.
|
||||
// In VM/passthrough environments udev metadata may be absent even though
|
||||
// the kernel reports a valid disc size (visible in lsblk). A non-zero
|
||||
// 512-byte block count means media is physically present.
|
||||
try {
|
||||
const devName = String(devicePath || '').split('/').pop();
|
||||
if (devName) {
|
||||
const sizeStr = fs.readFileSync(`/sys/block/${devName}/size`, 'utf8').trim();
|
||||
const sizeBlocks = parseInt(sizeStr, 10);
|
||||
if (Number.isFinite(sizeBlocks) && sizeBlocks > 0) {
|
||||
logger.info('check-media:sysfs-size', { devicePath, sizeBlocks });
|
||||
return { hasMedia: true, type: null };
|
||||
}
|
||||
}
|
||||
// cdparanoia failed and no TOC output could be parsed.
|
||||
} catch (_sysError) {
|
||||
// sysfs not available or device not found there
|
||||
}
|
||||
|
||||
logger.debug('blkid:no-media-or-fail', { devicePath });
|
||||
@@ -941,7 +1043,10 @@ class DiskDetectionService extends EventEmitter {
|
||||
// UDF is used for both Blu-ray (UDF 2.x) and DVD (UDF 1.x). Without a clear model
|
||||
// marker identifying it as Blu-ray, a 'dvd' result from UDF is ambiguous. Skip the
|
||||
// early return and fall through to the blkid check which uses the UDF version number.
|
||||
if (byFsTypeHint && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')) {
|
||||
// Also guard: when hintFstype is empty (no filesystem info at all), the drive model
|
||||
// alone is not a reliable disc-type indicator — a BD-RE drive can contain a DVD.
|
||||
// In that case skip this early return and let blkid -p determine the actual disc type.
|
||||
if (hintFstype && byFsTypeHint && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')) {
|
||||
return byFsTypeHint;
|
||||
}
|
||||
|
||||
@@ -1006,6 +1111,11 @@ class DiskDetectionService extends EventEmitter {
|
||||
return udfHintFallback;
|
||||
}
|
||||
|
||||
const hasAudioCdToc = await this.probeAudioCdWithCdparanoia(devicePath);
|
||||
if (hasAudioCdToc) {
|
||||
return 'cd';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
|
||||
@@ -295,9 +295,9 @@ class DownloadService {
|
||||
return this.items.get(normalizedId);
|
||||
}
|
||||
|
||||
async enqueueHistoryJob(jobId, target) {
|
||||
async enqueueHistoryJob(jobId, target, options = {}) {
|
||||
await this.init();
|
||||
const descriptor = await historyService.getJobArchiveDescriptor(jobId, target);
|
||||
const descriptor = await historyService.getJobArchiveDescriptor(jobId, target, options);
|
||||
const settings = await settingsService.getEffectiveSettingsMap(null);
|
||||
const downloadDir = String(settings?.download_dir || '').trim();
|
||||
const ownerSpec = String(settings?.download_dir_owner || '').trim() || null;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -829,6 +829,7 @@ class SettingsService {
|
||||
s.options_json,
|
||||
s.validation_json,
|
||||
s.order_index,
|
||||
s.depends_on,
|
||||
v.value as current_value
|
||||
FROM settings_schema s
|
||||
LEFT JOIN settings_values v ON v.key = s.key
|
||||
@@ -847,7 +848,8 @@ class SettingsService {
|
||||
options: parseJson(row.options_json, []),
|
||||
validation: parseJson(row.validation_json, {}),
|
||||
value: normalizeValueByType(row.type, row.current_value ?? row.default_value),
|
||||
orderIndex: row.order_index
|
||||
orderIndex: row.order_index,
|
||||
depends_on: row.depends_on ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,31 @@ function isLocalUrl(url) {
|
||||
return typeof url === 'string' && url.startsWith('/api/thumbnails/');
|
||||
}
|
||||
|
||||
function resolveLocalThumbnailPath(url) {
|
||||
const raw = String(url || '').trim();
|
||||
const match = raw.match(/^\/api\/thumbnails\/job-(\d+)\.jpg$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const jobId = Number(match[1]);
|
||||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||
return null;
|
||||
}
|
||||
return persistentFilePath(Math.trunc(jobId));
|
||||
}
|
||||
|
||||
function localThumbnailUrlExists(url) {
|
||||
const targetPath = resolveLocalThumbnailPath(url);
|
||||
if (!targetPath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return fs.existsSync(targetPath);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirectsLeft <= 0) {
|
||||
@@ -82,21 +107,48 @@ function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) {
|
||||
* Wird aufgerufen sobald poster_url bekannt ist (vor Rip-Start).
|
||||
* @returns {Promise<string|null>} lokaler Pfad oder null
|
||||
*/
|
||||
async function cacheJobThumbnail(jobId, posterUrl) {
|
||||
if (!posterUrl || isLocalUrl(posterUrl)) return null;
|
||||
async function cacheJobThumbnailDetailed(jobId, posterUrl) {
|
||||
const normalizedPosterUrl = String(posterUrl || '').trim();
|
||||
if (!normalizedPosterUrl || isLocalUrl(normalizedPosterUrl)) {
|
||||
return {
|
||||
ok: false,
|
||||
jobId,
|
||||
posterUrl: normalizedPosterUrl || null,
|
||||
cachedPath: null,
|
||||
error: 'Kein externer Poster-Link vorhanden.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
ensureDirs();
|
||||
const dest = cacheFilePath(jobId);
|
||||
await downloadImage(posterUrl, dest);
|
||||
logger.info('thumbnail:cached', { jobId, posterUrl, dest });
|
||||
return dest;
|
||||
await downloadImage(normalizedPosterUrl, dest);
|
||||
logger.info('thumbnail:cached', { jobId, posterUrl: normalizedPosterUrl, dest });
|
||||
return {
|
||||
ok: true,
|
||||
jobId,
|
||||
posterUrl: normalizedPosterUrl,
|
||||
cachedPath: dest,
|
||||
error: null
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn('thumbnail:cache:failed', { jobId, posterUrl, error: err.message });
|
||||
return null;
|
||||
const message = String(err?.message || err || 'Bild-Download fehlgeschlagen');
|
||||
logger.warn('thumbnail:cache:failed', { jobId, posterUrl: normalizedPosterUrl, error: message });
|
||||
return {
|
||||
ok: false,
|
||||
jobId,
|
||||
posterUrl: normalizedPosterUrl,
|
||||
cachedPath: null,
|
||||
error: message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheJobThumbnail(jobId, posterUrl) {
|
||||
const result = await cacheJobThumbnailDetailed(jobId, posterUrl);
|
||||
return result?.ok ? result.cachedPath : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschiebt das gecachte Bild in den persistenten Ordner.
|
||||
* Gibt die lokale API-URL zurück, oder null wenn kein Bild vorhanden.
|
||||
@@ -251,11 +303,14 @@ async function migrateExistingThumbnails() {
|
||||
|
||||
module.exports = {
|
||||
cacheJobThumbnail,
|
||||
cacheJobThumbnailDetailed,
|
||||
promoteJobThumbnail,
|
||||
copyThumbnail,
|
||||
storeLocalThumbnail,
|
||||
deleteThumbnail,
|
||||
getThumbnailsDir,
|
||||
migrateExistingThumbnails,
|
||||
isLocalUrl
|
||||
isLocalUrl,
|
||||
resolveLocalThumbnailPath,
|
||||
localThumbnailUrlExists
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user