DVD Integration
This commit is contained in:
@@ -20,7 +20,42 @@ function flattenDevices(nodes, acc = []) {
|
||||
}
|
||||
|
||||
function buildSignature(info) {
|
||||
return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}`;
|
||||
return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}|${info.mediaProfile || ''}`;
|
||||
}
|
||||
|
||||
function normalizeMediaProfile(rawValue) {
|
||||
const value = String(rawValue || '').trim().toLowerCase();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (value === 'bluray' || value === 'blu-ray' || value === 'bd' || value === 'bdmv') {
|
||||
return 'bluray';
|
||||
}
|
||||
if (value === 'dvd') {
|
||||
return 'dvd';
|
||||
}
|
||||
if (value === 'disc' || value === 'other' || value === 'sonstiges' || value === 'cd') {
|
||||
return 'other';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferMediaProfileFromTextParts(parts) {
|
||||
const markerText = (parts || [])
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
if (!markerText) {
|
||||
return null;
|
||||
}
|
||||
if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd-rom|bd-r|bd-re/.test(markerText)) {
|
||||
return 'bluray';
|
||||
}
|
||||
if (/(^|[\s_-])video_ts($|[\s_-])|dvd/.test(markerText)) {
|
||||
return 'dvd';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class DiskDetectionService extends EventEmitter {
|
||||
@@ -265,6 +300,14 @@ class DiskDetectionService extends EventEmitter {
|
||||
const details = await this.getBlockDeviceInfo();
|
||||
const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {};
|
||||
|
||||
const mediaProfile = await this.inferMediaProfile(devicePath, {
|
||||
discLabel,
|
||||
label: match.label,
|
||||
model: match.model,
|
||||
fstype: match.fstype,
|
||||
mountpoint: match.mountpoint
|
||||
});
|
||||
|
||||
const detected = {
|
||||
mode: 'explicit',
|
||||
path: devicePath,
|
||||
@@ -274,6 +317,7 @@ class DiskDetectionService extends EventEmitter {
|
||||
discLabel: discLabel || null,
|
||||
mountpoint: match.mountpoint || null,
|
||||
fstype: match.fstype || null,
|
||||
mediaProfile: mediaProfile || null,
|
||||
index: this.guessDiscIndex(match.name || devicePath)
|
||||
};
|
||||
logger.debug('detect:explicit:success', { detected });
|
||||
@@ -304,6 +348,14 @@ class DiskDetectionService extends EventEmitter {
|
||||
}
|
||||
const discLabel = await this.getDiscLabel(path);
|
||||
|
||||
const mediaProfile = await this.inferMediaProfile(path, {
|
||||
discLabel,
|
||||
label: item.label,
|
||||
model: item.model,
|
||||
fstype: item.fstype,
|
||||
mountpoint: item.mountpoint
|
||||
});
|
||||
|
||||
const detected = {
|
||||
mode: 'auto',
|
||||
path,
|
||||
@@ -313,6 +365,7 @@ class DiskDetectionService extends EventEmitter {
|
||||
discLabel: discLabel || null,
|
||||
mountpoint: item.mountpoint || null,
|
||||
fstype: item.fstype || null,
|
||||
mediaProfile: mediaProfile || null,
|
||||
index: this.guessDiscIndex(item.name)
|
||||
};
|
||||
logger.debug('detect:auto:success', { detected });
|
||||
@@ -372,6 +425,82 @@ class DiskDetectionService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
async inferMediaProfile(devicePath, hints = {}) {
|
||||
const explicit = normalizeMediaProfile(hints?.mediaProfile);
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const hinted = inferMediaProfileFromTextParts([
|
||||
hints?.discLabel,
|
||||
hints?.label,
|
||||
hints?.fstype
|
||||
]);
|
||||
if (hinted) {
|
||||
return hinted;
|
||||
}
|
||||
|
||||
const mountpoint = String(hints?.mountpoint || '').trim();
|
||||
if (mountpoint) {
|
||||
try {
|
||||
if (fs.existsSync(`${mountpoint}/BDMV`)) {
|
||||
return 'bluray';
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore fs errors
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(`${mountpoint}/VIDEO_TS`)) {
|
||||
return 'dvd';
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore fs errors
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('blkid', ['-o', 'export', devicePath]);
|
||||
const payload = {};
|
||||
for (const line of String(stdout || '').split(/\r?\n/)) {
|
||||
const idx = line.indexOf('=');
|
||||
if (idx <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key = String(line.slice(0, idx)).trim().toUpperCase();
|
||||
const value = String(line.slice(idx + 1)).trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
payload[key] = value;
|
||||
}
|
||||
|
||||
const byBlkidMarker = inferMediaProfileFromTextParts([
|
||||
payload.LABEL,
|
||||
payload.TYPE,
|
||||
payload.VERSION
|
||||
]);
|
||||
if (byBlkidMarker) {
|
||||
return byBlkidMarker;
|
||||
}
|
||||
|
||||
const type = String(payload.TYPE || '').trim().toLowerCase();
|
||||
if (type === 'udf') {
|
||||
const version = Number.parseFloat(String(payload.VERSION || '').replace(',', '.'));
|
||||
if (Number.isFinite(version)) {
|
||||
return version >= 2 ? 'bluray' : 'dvd';
|
||||
}
|
||||
return 'dvd';
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('infer-media-profile:blkid-failed', {
|
||||
devicePath,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
guessDiscIndex(name) {
|
||||
if (!name) {
|
||||
return 0;
|
||||
|
||||
@@ -135,27 +135,103 @@ function hasBlurayStructure(rawPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDvdStructure(rawPath) {
|
||||
const basePath = String(rawPath || '').trim();
|
||||
if (!basePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const videoTsPath = path.join(basePath, 'VIDEO_TS');
|
||||
try {
|
||||
if (fs.existsSync(videoTsPath)) {
|
||||
const stat = fs.statSync(videoTsPath);
|
||||
if (stat.isDirectory()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore fs errors
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(basePath)) {
|
||||
const stat = fs.statSync(basePath);
|
||||
if (stat.isDirectory()) {
|
||||
const entries = fs.readdirSync(basePath);
|
||||
if (entries.some((entry) => /^vts_\d{2}_\d\.(ifo|vob|bup)$/i.test(entry) || /^video_ts\.(ifo|vob|bup)$/i.test(entry))) {
|
||||
return true;
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
return /(^|\/)video_ts\/.+\.(ifo|vob|bup)$/i.test(basePath) || /\.(ifo|vob|bup)$/i.test(basePath);
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore fs errors and fallback to path checks
|
||||
}
|
||||
|
||||
if (/(^|\/)video_ts(\/|$)/i.test(basePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeMediaTypeValue(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (raw === 'bluray' || raw === 'blu-ray' || raw === 'bd' || raw === 'bdmv') {
|
||||
return 'bluray';
|
||||
}
|
||||
if (raw === 'dvd') {
|
||||
return 'dvd';
|
||||
}
|
||||
if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') {
|
||||
return 'other';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan) {
|
||||
const mkInfo = parseInfoFromValue(makemkvInfo, null);
|
||||
const miInfo = parseInfoFromValue(mediainfoInfo, null);
|
||||
const plan = parseInfoFromValue(encodePlan, null);
|
||||
const rawPath = String(job?.raw_path || '').trim();
|
||||
const encodeInputPath = String(job?.encode_input_path || plan?.encodeInputPath || '').trim();
|
||||
const profileHint = normalizeMediaTypeValue(
|
||||
plan?.mediaProfile
|
||||
|| mkInfo?.analyzeContext?.mediaProfile
|
||||
|| mkInfo?.mediaProfile
|
||||
|| miInfo?.mediaProfile
|
||||
|| job?.media_type
|
||||
|| job?.mediaType
|
||||
);
|
||||
|
||||
if (profileHint === 'bluray' || profileHint === 'dvd') {
|
||||
return profileHint;
|
||||
}
|
||||
|
||||
if (hasBlurayStructure(rawPath)) {
|
||||
return 'bluray';
|
||||
}
|
||||
if (hasDvdStructure(rawPath)) {
|
||||
return 'dvd';
|
||||
}
|
||||
|
||||
const mkSource = String(mkInfo?.source || '').trim().toLowerCase();
|
||||
const mkRipMode = String(mkInfo?.ripMode || mkInfo?.rip_mode || '').trim().toLowerCase();
|
||||
if (
|
||||
mkRipMode === 'backup'
|
||||
|| mkSource.includes('backup')
|
||||
|| mkSource.includes('raw_backup')
|
||||
|| Boolean(mkInfo?.analyzeContext?.playlistAnalysis)
|
||||
) {
|
||||
if (Boolean(mkInfo?.analyzeContext?.playlistAnalysis)) {
|
||||
return 'bluray';
|
||||
}
|
||||
if (mkRipMode === 'backup' || mkSource.includes('backup') || mkSource.includes('raw_backup')) {
|
||||
if (hasDvdStructure(rawPath) || hasDvdStructure(encodeInputPath)) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (hasBlurayStructure(rawPath) || hasBlurayStructure(encodeInputPath)) {
|
||||
return 'bluray';
|
||||
}
|
||||
}
|
||||
|
||||
const planMode = String(plan?.mode || '').trim().toLowerCase();
|
||||
if (planMode === 'pre_rip' || Boolean(plan?.preRip)) {
|
||||
@@ -163,9 +239,17 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan) {
|
||||
}
|
||||
|
||||
const mediainfoSource = String(miInfo?.source || '').trim().toLowerCase();
|
||||
if (mediainfoSource.includes('raw_backup') || Number(miInfo?.handbrakeTitleId) > 0) {
|
||||
if (Number(miInfo?.handbrakeTitleId) > 0) {
|
||||
return 'bluray';
|
||||
}
|
||||
if (mediainfoSource.includes('raw_backup')) {
|
||||
if (hasDvdStructure(rawPath) || hasDvdStructure(encodeInputPath)) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (hasBlurayStructure(rawPath) || hasBlurayStructure(encodeInputPath)) {
|
||||
return 'bluray';
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
/(^|\/)bdmv(\/|$)/i.test(rawPath)
|
||||
@@ -174,8 +258,15 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan) {
|
||||
) {
|
||||
return 'bluray';
|
||||
}
|
||||
if (
|
||||
/(^|\/)video_ts(\/|$)/i.test(rawPath)
|
||||
|| /(^|\/)video_ts(\/|$)/i.test(encodeInputPath)
|
||||
|| /\.(ifo|vob|bup)(\.|$)/i.test(encodeInputPath)
|
||||
) {
|
||||
return 'dvd';
|
||||
}
|
||||
|
||||
return 'disc';
|
||||
return profileHint || 'other';
|
||||
}
|
||||
|
||||
function toProcessLogPath(jobId) {
|
||||
@@ -199,11 +290,39 @@ function toProcessLogStreamKey(jobId) {
|
||||
return String(Math.trunc(normalizedId));
|
||||
}
|
||||
|
||||
function enrichJobRow(job) {
|
||||
const rawStatus = inspectDirectory(job.raw_path);
|
||||
const outputStatus = inspectOutputFile(job.output_path);
|
||||
const movieDir = job.output_path ? path.dirname(job.output_path) : null;
|
||||
const movieDirStatus = inspectDirectory(movieDir);
|
||||
function resolveEffectiveRawPath(storedPath, rawDir) {
|
||||
const stored = String(storedPath || '').trim();
|
||||
if (!stored || !rawDir) return stored;
|
||||
const folderName = path.basename(stored);
|
||||
if (!folderName) return stored;
|
||||
return path.join(String(rawDir).trim(), folderName);
|
||||
}
|
||||
|
||||
function resolveEffectiveOutputPath(storedPath, movieDir) {
|
||||
const stored = String(storedPath || '').trim();
|
||||
if (!stored || !movieDir) return stored;
|
||||
// output_path structure: {movie_dir}/{folderName}/{fileName}
|
||||
const fileName = path.basename(stored);
|
||||
const folderName = path.basename(path.dirname(stored));
|
||||
if (!fileName || !folderName || folderName === '.') return stored;
|
||||
return path.join(String(movieDir).trim(), folderName, fileName);
|
||||
}
|
||||
|
||||
function enrichJobRow(job, settings = null) {
|
||||
const rawDir = String(settings?.raw_dir || '').trim();
|
||||
const movieDir = String(settings?.movie_dir || '').trim();
|
||||
|
||||
const effectiveRawPath = rawDir && job.raw_path
|
||||
? resolveEffectiveRawPath(job.raw_path, rawDir)
|
||||
: (job.raw_path || null);
|
||||
const effectiveOutputPath = movieDir && job.output_path
|
||||
? resolveEffectiveOutputPath(job.output_path, movieDir)
|
||||
: (job.output_path || null);
|
||||
|
||||
const rawStatus = inspectDirectory(effectiveRawPath);
|
||||
const outputStatus = inspectOutputFile(effectiveOutputPath);
|
||||
const movieDirPath = effectiveOutputPath ? path.dirname(effectiveOutputPath) : null;
|
||||
const movieDirStatus = inspectDirectory(movieDirPath);
|
||||
const makemkvInfo = parseJsonSafe(job.makemkv_info_json, null);
|
||||
const handbrakeInfo = parseJsonSafe(job.handbrake_info_json, null);
|
||||
const mediainfoInfo = parseJsonSafe(job.mediainfo_info_json, null);
|
||||
@@ -215,6 +334,8 @@ function enrichJobRow(job) {
|
||||
|
||||
return {
|
||||
...job,
|
||||
raw_path: effectiveRawPath,
|
||||
output_path: effectiveOutputPath,
|
||||
makemkvInfo,
|
||||
handbrakeInfo,
|
||||
mediainfoInfo,
|
||||
@@ -547,19 +668,22 @@ class HistoryService {
|
||||
|
||||
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
||||
|
||||
const jobs = await db.all(
|
||||
`
|
||||
const [jobs, settings] = await Promise.all([
|
||||
db.all(
|
||||
`
|
||||
SELECT j.*
|
||||
FROM jobs j
|
||||
${whereClause}
|
||||
ORDER BY j.created_at DESC
|
||||
LIMIT 500
|
||||
`,
|
||||
values
|
||||
);
|
||||
values
|
||||
),
|
||||
settingsService.getSettingsMap()
|
||||
]);
|
||||
|
||||
return jobs.map((job) => ({
|
||||
...enrichJobRow(job),
|
||||
...enrichJobRow(job, settings),
|
||||
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
||||
}));
|
||||
}
|
||||
@@ -575,57 +699,68 @@ class HistoryService {
|
||||
return [];
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
const rows = await db.all(
|
||||
`SELECT * FROM jobs WHERE id IN (${placeholders})`,
|
||||
ids
|
||||
);
|
||||
const [rows, settings] = await Promise.all([
|
||||
(async () => {
|
||||
const db = await getDb();
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
return db.all(`SELECT * FROM jobs WHERE id IN (${placeholders})`, ids);
|
||||
})(),
|
||||
settingsService.getSettingsMap()
|
||||
]);
|
||||
const byId = new Map(rows.map((row) => [Number(row.id), row]));
|
||||
return ids
|
||||
.map((id) => byId.get(id))
|
||||
.filter(Boolean)
|
||||
.map((job) => ({
|
||||
...enrichJobRow(job),
|
||||
...enrichJobRow(job, settings),
|
||||
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
||||
}));
|
||||
}
|
||||
|
||||
async getRunningJobs() {
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
const [rows, settings] = await Promise.all([
|
||||
db.all(
|
||||
`
|
||||
SELECT *
|
||||
FROM jobs
|
||||
WHERE status IN ('RIPPING', 'ENCODING')
|
||||
ORDER BY updated_at ASC, id ASC
|
||||
`
|
||||
);
|
||||
),
|
||||
settingsService.getSettingsMap()
|
||||
]);
|
||||
return rows.map((job) => ({
|
||||
...enrichJobRow(job),
|
||||
...enrichJobRow(job, settings),
|
||||
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
||||
}));
|
||||
}
|
||||
|
||||
async getRunningEncodeJobs() {
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
const [rows, settings] = await Promise.all([
|
||||
db.all(
|
||||
`
|
||||
SELECT *
|
||||
FROM jobs
|
||||
WHERE status = 'ENCODING'
|
||||
ORDER BY updated_at ASC, id ASC
|
||||
`
|
||||
);
|
||||
),
|
||||
settingsService.getSettingsMap()
|
||||
]);
|
||||
return rows.map((job) => ({
|
||||
...enrichJobRow(job),
|
||||
...enrichJobRow(job, settings),
|
||||
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
||||
}));
|
||||
}
|
||||
|
||||
async getJobWithLogs(jobId, options = {}) {
|
||||
const db = await getDb();
|
||||
const job = await db.get('SELECT * FROM jobs WHERE id = ?', [jobId]);
|
||||
const [job, settings] = await Promise.all([
|
||||
db.get('SELECT * FROM jobs WHERE id = ?', [jobId]),
|
||||
settingsService.getSettingsMap()
|
||||
]);
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
@@ -643,7 +778,7 @@ class HistoryService {
|
||||
|
||||
if (!shouldLoadLogs) {
|
||||
return {
|
||||
...enrichJobRow(job),
|
||||
...enrichJobRow(job, settings),
|
||||
log_count: baseLogCount,
|
||||
logs: [],
|
||||
log: '',
|
||||
@@ -662,7 +797,7 @@ class HistoryService {
|
||||
});
|
||||
|
||||
return {
|
||||
...enrichJobRow(job),
|
||||
...enrichJobRow(job, settings),
|
||||
log_count: processLog.exists ? processLog.total : 0,
|
||||
logs: [],
|
||||
log: processLog.lines.join('\n'),
|
||||
@@ -909,7 +1044,7 @@ class HistoryService {
|
||||
});
|
||||
|
||||
const imported = await this.getJobById(created.id);
|
||||
return enrichJobRow(imported);
|
||||
return enrichJobRow(imported, settings);
|
||||
}
|
||||
|
||||
async assignOmdbMetadata(jobId, payload = {}) {
|
||||
@@ -967,8 +1102,11 @@ class HistoryService {
|
||||
: `Metadaten manuell aktualisiert: title="${title || '-'}", year="${year || '-'}", imdb="${imdbId || '-'}"`
|
||||
);
|
||||
|
||||
const updated = await this.getJobById(jobId);
|
||||
return enrichJobRow(updated);
|
||||
const [updated, settings] = await Promise.all([
|
||||
this.getJobById(jobId),
|
||||
settingsService.getSettingsMap()
|
||||
]);
|
||||
return enrichJobRow(updated, settings);
|
||||
}
|
||||
|
||||
async deleteJobFiles(jobId, target = 'both') {
|
||||
@@ -987,6 +1125,12 @@ class HistoryService {
|
||||
}
|
||||
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const effectiveRawPath = settings.raw_dir && job.raw_path
|
||||
? resolveEffectiveRawPath(job.raw_path, settings.raw_dir)
|
||||
: job.raw_path;
|
||||
const effectiveOutputPath = settings.movie_dir && job.output_path
|
||||
? resolveEffectiveOutputPath(job.output_path, settings.movie_dir)
|
||||
: job.output_path;
|
||||
const summary = {
|
||||
target,
|
||||
raw: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null },
|
||||
@@ -995,16 +1139,16 @@ class HistoryService {
|
||||
|
||||
if (target === 'raw' || target === 'both') {
|
||||
summary.raw.attempted = true;
|
||||
if (!job.raw_path) {
|
||||
if (!effectiveRawPath) {
|
||||
summary.raw.reason = 'Kein raw_path im Job gesetzt.';
|
||||
} else if (!isPathInside(settings.raw_dir, job.raw_path)) {
|
||||
const error = new Error(`RAW-Pfad liegt außerhalb von raw_dir: ${job.raw_path}`);
|
||||
} else if (!isPathInside(settings.raw_dir, effectiveRawPath)) {
|
||||
const error = new Error(`RAW-Pfad liegt außerhalb von raw_dir: ${effectiveRawPath}`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
} else if (!fs.existsSync(job.raw_path)) {
|
||||
} else if (!fs.existsSync(effectiveRawPath)) {
|
||||
summary.raw.reason = 'RAW-Pfad existiert nicht.';
|
||||
} else {
|
||||
const result = deleteFilesRecursively(job.raw_path, true);
|
||||
const result = deleteFilesRecursively(effectiveRawPath, true);
|
||||
summary.raw.deleted = true;
|
||||
summary.raw.filesDeleted = result.filesDeleted;
|
||||
summary.raw.dirsRemoved = result.dirsRemoved;
|
||||
@@ -1013,16 +1157,16 @@ class HistoryService {
|
||||
|
||||
if (target === 'movie' || target === 'both') {
|
||||
summary.movie.attempted = true;
|
||||
if (!job.output_path) {
|
||||
if (!effectiveOutputPath) {
|
||||
summary.movie.reason = 'Kein output_path im Job gesetzt.';
|
||||
} else if (!isPathInside(settings.movie_dir, job.output_path)) {
|
||||
const error = new Error(`Movie-Pfad liegt außerhalb von movie_dir: ${job.output_path}`);
|
||||
} else if (!isPathInside(settings.movie_dir, effectiveOutputPath)) {
|
||||
const error = new Error(`Movie-Pfad liegt außerhalb von movie_dir: ${effectiveOutputPath}`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
} else if (!fs.existsSync(job.output_path)) {
|
||||
} else if (!fs.existsSync(effectiveOutputPath)) {
|
||||
summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.';
|
||||
} else {
|
||||
const outputPath = normalizeComparablePath(job.output_path);
|
||||
const outputPath = normalizeComparablePath(effectiveOutputPath);
|
||||
const movieRoot = normalizeComparablePath(settings.movie_dir);
|
||||
const stat = fs.lstatSync(outputPath);
|
||||
if (stat.isDirectory()) {
|
||||
@@ -1061,10 +1205,13 @@ class HistoryService {
|
||||
);
|
||||
logger.info('job:delete-files', { jobId, summary });
|
||||
|
||||
const updated = await this.getJobById(jobId);
|
||||
const [updated, enrichSettings] = await Promise.all([
|
||||
this.getJobById(jobId),
|
||||
settingsService.getSettingsMap()
|
||||
]);
|
||||
return {
|
||||
summary,
|
||||
job: enrichJobRow(updated)
|
||||
job: enrichJobRow(updated, enrichSettings)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,45 @@ const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-s
|
||||
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
||||
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
||||
const LOG_DIR_SETTING_KEY = 'log_dir';
|
||||
const MEDIA_PROFILES = ['bluray', 'dvd', 'other'];
|
||||
const PROFILED_SETTINGS = {
|
||||
mediainfo_extra_args: {
|
||||
bluray: 'mediainfo_extra_args_bluray',
|
||||
dvd: 'mediainfo_extra_args_dvd'
|
||||
},
|
||||
makemkv_rip_mode: {
|
||||
bluray: 'makemkv_rip_mode_bluray',
|
||||
dvd: 'makemkv_rip_mode_dvd'
|
||||
},
|
||||
makemkv_analyze_extra_args: {
|
||||
bluray: 'makemkv_analyze_extra_args_bluray',
|
||||
dvd: 'makemkv_analyze_extra_args_dvd'
|
||||
},
|
||||
makemkv_rip_extra_args: {
|
||||
bluray: 'makemkv_rip_extra_args_bluray',
|
||||
dvd: 'makemkv_rip_extra_args_dvd'
|
||||
},
|
||||
handbrake_preset: {
|
||||
bluray: 'handbrake_preset_bluray',
|
||||
dvd: 'handbrake_preset_dvd'
|
||||
},
|
||||
handbrake_extra_args: {
|
||||
bluray: 'handbrake_extra_args_bluray',
|
||||
dvd: 'handbrake_extra_args_dvd'
|
||||
},
|
||||
output_extension: {
|
||||
bluray: 'output_extension_bluray',
|
||||
dvd: 'output_extension_dvd'
|
||||
},
|
||||
filename_template: {
|
||||
bluray: 'filename_template_bluray',
|
||||
dvd: 'filename_template_dvd'
|
||||
},
|
||||
output_folder_template: {
|
||||
bluray: 'output_folder_template_bluray',
|
||||
dvd: 'output_folder_template_dvd'
|
||||
}
|
||||
};
|
||||
|
||||
function applyRuntimeLogDirSetting(rawValue) {
|
||||
const resolved = setLogRootDir(rawValue);
|
||||
@@ -183,6 +222,37 @@ function uniquePresetEntries(entries) {
|
||||
return unique;
|
||||
}
|
||||
|
||||
function normalizeMediaProfileValue(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (raw === 'bluray' || raw === 'blu-ray' || raw === 'bd' || raw === 'bdmv') {
|
||||
return 'bluray';
|
||||
}
|
||||
if (raw === 'dvd') {
|
||||
return 'dvd';
|
||||
}
|
||||
if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') {
|
||||
return 'other';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveProfileFallbackOrder(profile) {
|
||||
const normalized = normalizeMediaProfileValue(profile);
|
||||
if (normalized === 'bluray') {
|
||||
return ['bluray', 'dvd'];
|
||||
}
|
||||
if (normalized === 'dvd') {
|
||||
return ['dvd', 'bluray'];
|
||||
}
|
||||
if (normalized === 'other') {
|
||||
return ['dvd', 'bluray'];
|
||||
}
|
||||
return ['dvd', 'bluray'];
|
||||
}
|
||||
|
||||
function normalizePresetListLines(rawOutput) {
|
||||
const lines = String(rawOutput || '').split(/\r?\n/);
|
||||
const normalized = [];
|
||||
@@ -358,6 +428,42 @@ class SettingsService {
|
||||
return map;
|
||||
}
|
||||
|
||||
normalizeMediaProfile(value) {
|
||||
return normalizeMediaProfileValue(value);
|
||||
}
|
||||
|
||||
resolveEffectiveToolSettings(settingsMap = {}, mediaProfile = null) {
|
||||
const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
|
||||
const fallbackOrder = resolveProfileFallbackOrder(mediaProfile);
|
||||
const resolvedMediaProfile = normalizeMediaProfileValue(mediaProfile) || fallbackOrder[0] || 'dvd';
|
||||
const effective = {
|
||||
...sourceMap,
|
||||
media_profile: resolvedMediaProfile
|
||||
};
|
||||
|
||||
for (const [legacyKey, profileKeys] of Object.entries(PROFILED_SETTINGS)) {
|
||||
let resolvedValue = sourceMap[legacyKey];
|
||||
for (const profile of fallbackOrder) {
|
||||
const profileKey = profileKeys?.[profile];
|
||||
if (!profileKey) {
|
||||
continue;
|
||||
}
|
||||
if (sourceMap[profileKey] !== undefined) {
|
||||
resolvedValue = sourceMap[profileKey];
|
||||
break;
|
||||
}
|
||||
}
|
||||
effective[legacyKey] = resolvedValue;
|
||||
}
|
||||
|
||||
return effective;
|
||||
}
|
||||
|
||||
async getEffectiveSettingsMap(mediaProfile = null) {
|
||||
const map = await this.getSettingsMap();
|
||||
return this.resolveEffectiveToolSettings(map, mediaProfile);
|
||||
}
|
||||
|
||||
async getFlatSettings() {
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
@@ -537,19 +643,24 @@ class SettingsService {
|
||||
}));
|
||||
}
|
||||
|
||||
async buildMakeMKVAnalyzeConfig(deviceInfo = null) {
|
||||
const map = await this.getSettingsMap();
|
||||
async buildMakeMKVAnalyzeConfig(deviceInfo = null, options = {}) {
|
||||
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||
const map = this.resolveEffectiveToolSettings(
|
||||
rawMap,
|
||||
options?.mediaProfile || deviceInfo?.mediaProfile || null
|
||||
);
|
||||
const cmd = map.makemkv_command;
|
||||
const args = ['-r', 'info', this.resolveSourceArg(map, deviceInfo)];
|
||||
const args = ['-r', 'info', this.resolveSourceArg(map, deviceInfo), ...splitArgs(map.makemkv_analyze_extra_args)];
|
||||
logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo });
|
||||
return { cmd, args };
|
||||
}
|
||||
|
||||
async buildMakeMKVAnalyzePathConfig(sourcePath, options = {}) {
|
||||
const map = await this.getSettingsMap();
|
||||
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||
const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null);
|
||||
const cmd = map.makemkv_command;
|
||||
const sourceArg = `file:${sourcePath}`;
|
||||
const args = ['-r', 'info', sourceArg];
|
||||
const args = ['-r', 'info', sourceArg, ...splitArgs(map.makemkv_analyze_extra_args)];
|
||||
const titleIdRaw = Number(options?.titleId);
|
||||
// "makemkvcon info" supports only <source>; title filtering is done in app parser.
|
||||
logger.debug('cli:makemkv:analyze:path', {
|
||||
@@ -562,7 +673,11 @@ class SettingsService {
|
||||
}
|
||||
|
||||
async buildMakeMKVRipConfig(rawJobDir, deviceInfo = null, options = {}) {
|
||||
const map = await this.getSettingsMap();
|
||||
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||
const map = this.resolveEffectiveToolSettings(
|
||||
rawMap,
|
||||
options?.mediaProfile || deviceInfo?.mediaProfile || null
|
||||
);
|
||||
const cmd = map.makemkv_command;
|
||||
const ripMode = String(map.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup'
|
||||
? 'backup'
|
||||
@@ -579,12 +694,16 @@ class SettingsService {
|
||||
ignored: parsedExtra
|
||||
});
|
||||
}
|
||||
baseArgs = [
|
||||
'backup',
|
||||
'--decrypt',
|
||||
sourceArg,
|
||||
rawJobDir
|
||||
];
|
||||
const normalizedProfile = normalizeMediaProfileValue(options?.mediaProfile || deviceInfo?.mediaProfile || null);
|
||||
const isDvd = normalizedProfile === 'dvd';
|
||||
if (isDvd) {
|
||||
const isoBase = options?.isoOutputBase
|
||||
? path.join(rawJobDir, options.isoOutputBase)
|
||||
: rawJobDir;
|
||||
baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', '--noscan', sourceArg, isoBase];
|
||||
} else {
|
||||
baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', sourceArg, rawJobDir];
|
||||
}
|
||||
} else {
|
||||
extra = parsedExtra;
|
||||
const minLength = Number(map.makemkv_min_length_minutes || 60);
|
||||
@@ -592,6 +711,7 @@ class SettingsService {
|
||||
const targetTitle = hasExplicitTitle ? String(Math.trunc(rawSelectedTitleId)) : 'all';
|
||||
if (hasExplicitTitle) {
|
||||
baseArgs = [
|
||||
'-r', '--progress=-same',
|
||||
'mkv',
|
||||
sourceArg,
|
||||
targetTitle,
|
||||
@@ -599,6 +719,7 @@ class SettingsService {
|
||||
];
|
||||
} else {
|
||||
baseArgs = [
|
||||
'-r', '--progress=-same',
|
||||
'--minlength=' + Math.round(minLength * 60),
|
||||
'mkv',
|
||||
sourceArg,
|
||||
@@ -637,8 +758,9 @@ class SettingsService {
|
||||
};
|
||||
}
|
||||
|
||||
async buildMediaInfoConfig(inputPath) {
|
||||
const map = await this.getSettingsMap();
|
||||
async buildMediaInfoConfig(inputPath, options = {}) {
|
||||
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||
const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null);
|
||||
const cmd = map.mediainfo_command || 'mediainfo';
|
||||
const baseArgs = ['--Output=JSON'];
|
||||
const extra = splitArgs(map.mediainfo_extra_args);
|
||||
@@ -648,7 +770,8 @@ class SettingsService {
|
||||
}
|
||||
|
||||
async buildHandBrakeConfig(inputFile, outputFile, options = {}) {
|
||||
const map = await this.getSettingsMap();
|
||||
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||
const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null);
|
||||
const cmd = map.handbrake_command;
|
||||
const rawTitleId = Number(options?.titleId);
|
||||
const selectedTitleId = Number.isFinite(rawTitleId) && rawTitleId > 0
|
||||
@@ -752,8 +875,12 @@ class SettingsService {
|
||||
return '/dev/sr0';
|
||||
}
|
||||
|
||||
async buildHandBrakeScanConfig(deviceInfo = null) {
|
||||
const map = await this.getSettingsMap();
|
||||
async buildHandBrakeScanConfig(deviceInfo = null, options = {}) {
|
||||
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||
const map = this.resolveEffectiveToolSettings(
|
||||
rawMap,
|
||||
options?.mediaProfile || deviceInfo?.mediaProfile || null
|
||||
);
|
||||
const cmd = map.handbrake_command || 'HandBrakeCLI';
|
||||
const sourceArg = this.resolveHandBrakeSourceArg(map, deviceInfo);
|
||||
// Match legacy rip.sh behavior: scan all titles, then decide in app logic.
|
||||
@@ -767,7 +894,8 @@ class SettingsService {
|
||||
}
|
||||
|
||||
async buildHandBrakeScanConfigForInput(inputPath, options = {}) {
|
||||
const map = await this.getSettingsMap();
|
||||
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||
const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null);
|
||||
const cmd = map.handbrake_command || 'HandBrakeCLI';
|
||||
// RAW backup folders must be scanned as full BD source to get usable title list.
|
||||
const rawTitleId = Number(options?.titleId);
|
||||
@@ -785,7 +913,8 @@ class SettingsService {
|
||||
}
|
||||
|
||||
async buildHandBrakePresetProfile(sampleInputPath = null, options = {}) {
|
||||
const map = await this.getSettingsMap();
|
||||
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||
const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null);
|
||||
const cmd = map.handbrake_command || 'HandBrakeCLI';
|
||||
const presetName = map.handbrake_preset || null;
|
||||
const rawTitleId = Number(options?.titleId);
|
||||
@@ -917,10 +1046,12 @@ class SettingsService {
|
||||
|
||||
async getHandBrakePresetOptions() {
|
||||
const map = await this.getSettingsMap();
|
||||
const configuredPreset = String(map.handbrake_preset || '').trim();
|
||||
const fallbackOptions = configuredPreset
|
||||
? [{ label: configuredPreset, value: configuredPreset }]
|
||||
: [];
|
||||
const configuredPresets = uniqueOrderedValues([
|
||||
map.handbrake_preset_bluray,
|
||||
map.handbrake_preset_dvd,
|
||||
map.handbrake_preset
|
||||
]);
|
||||
const fallbackOptions = configuredPresets.map((preset) => ({ label: preset, value: preset }));
|
||||
const rawCommand = String(map.handbrake_command || 'HandBrakeCLI').trim();
|
||||
const commandTokens = splitArgs(rawCommand);
|
||||
const cmd = commandTokens[0] || 'HandBrakeCLI';
|
||||
@@ -963,7 +1094,7 @@ class SettingsService {
|
||||
options: fallbackOptions
|
||||
};
|
||||
}
|
||||
if (!configuredPreset) {
|
||||
if (configuredPresets.length === 0) {
|
||||
return {
|
||||
source: 'handbrake-cli',
|
||||
message: null,
|
||||
@@ -971,8 +1102,10 @@ class SettingsService {
|
||||
};
|
||||
}
|
||||
|
||||
const hasConfiguredPreset = options.some((option) => option.value === configuredPreset);
|
||||
if (hasConfiguredPreset) {
|
||||
const missingConfiguredPresets = configuredPresets.filter(
|
||||
(preset) => !options.some((option) => option.value === preset)
|
||||
);
|
||||
if (missingConfiguredPresets.length === 0) {
|
||||
return {
|
||||
source: 'handbrake-cli',
|
||||
message: null,
|
||||
@@ -982,8 +1115,11 @@ class SettingsService {
|
||||
|
||||
return {
|
||||
source: 'handbrake-cli',
|
||||
message: `Aktuell gesetztes Preset "${configuredPreset}" wurde in HandBrakeCLI -z nicht gefunden.`,
|
||||
options: [{ label: configuredPreset, value: configuredPreset }, ...options]
|
||||
message: `Konfigurierte Presets wurden in HandBrakeCLI -z nicht gefunden: ${missingConfiguredPresets.join(', ')}`,
|
||||
options: [
|
||||
...missingConfiguredPresets.map((preset) => ({ label: preset, value: preset })),
|
||||
...options
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user