0.12.0-1 Plugins und diverses
This commit is contained in:
1 parent
0a9cf6969f
commit
24955a956d
33 files changed
+3978
-479
No files matched your search
@@ -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