0.12.0-1 Plugins und diverses

This commit is contained in:
2026-03-19 18:57:05 +00:00
parent 0a9cf6969f
commit 24955a956d
33 changed files with 3978 additions and 479 deletions
+221 -19
View File
@@ -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();
}
+3
View File
@@ -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(() => {
+4 -2
View File
@@ -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()
+8 -2
View File
@@ -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 });
})
+45 -6
View File
@@ -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 });
})
);
+40
View File
@@ -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 });
+13 -1
View File
@@ -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();
+151 -41
View File
@@ -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';
}
+2 -2
View File
@@ -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
+3 -1
View File
@@ -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
}));
}
+63 -8
View File
@@ -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
};