0.12.0-1 Plugins und diverses

This commit is contained in:
michael committed 2026-03-19 18:57:05 +00:00
1 parent 0a9cf6969f
commit 24955a956d
33 files changed
+3978 -479

No files matched your search

+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
};