0.12.0-15 Fix DVD Review

This commit is contained in:
michael committed 2026-03-24 07:05:45 +00:00
1 parent 3e3f3231be
commit 76b020d5c0
30 files changed
+5148 -44

No files matched your search

@@ -0,0 +1,556 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { getDb } = require('../db/database');
const settingsService = require('./settingsService');
const wsService = require('./websocketService');
const logger = require('./logger').child('CONVERTER_SCAN');
const {
defaultConverterRawDir
} = require('../config');
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov', 'm4v', 'wmv', 'ts']);
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']);
const ISO_EXTENSIONS = new Set(['iso']);
let _pollingTimer = null;
let _pollingEnabled = false;
let _pollingInterval = 300;
function detectMediaType(fileName) {
const ext = path.extname(String(fileName || '')).slice(1).toLowerCase();
if (ISO_EXTENSIONS.has(ext)) return 'iso';
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
if (AUDIO_EXTENSIONS.has(ext)) return 'audio';
return null;
}
function detectFormat(fileName) {
return path.extname(String(fileName || '')).slice(1).toLowerCase() || null;
}
function getConfiguredExtensions(settings) {
const raw = String(settings?.converter_scan_extensions || '').trim();
if (!raw) {
return new Set([...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS, ...ISO_EXTENSIONS]);
}
return new Set(
raw.split(',')
.map((ext) => ext.trim().toLowerCase())
.filter(Boolean)
);
}
function getFileSize(fullPath) {
try {
return fs.statSync(fullPath).size;
} catch (_err) {
return null;
}
}
const SCAN_MAX_DEPTH = 4;
/**
* Rekursiv alle Dateien und direkten Unterordner im rawDir scannen.
* Gibt ein flaches Array von { relPath, entryType, fileSize, detectedMediaType, detectedFormat } zurück.
* Maximale Tiefe: SCAN_MAX_DEPTH (verhindert unendliche Rekursion bei geschachtelten Ordnern).
*/
function scanDirectory(rawDir, allowedExtensions, parentRelPath = '', depth = 0) {
const entries = [];
if (depth >= SCAN_MAX_DEPTH) {
return entries;
}
let dirEntries;
try {
dirEntries = fs.readdirSync(rawDir, { withFileTypes: true });
} catch (_err) {
return entries;
}
for (const dirent of dirEntries) {
const relPath = parentRelPath ? `${parentRelPath}/${dirent.name}` : dirent.name;
const fullPath = path.join(rawDir, relPath);
if (dirent.isDirectory()) {
entries.push({
relPath,
entryType: 'directory',
fileSize: null,
detectedMediaType: null,
detectedFormat: null
});
// Rekursiv in Unterordner (mit Tiefenbegrenzung)
const subEntries = scanDirectory(rawDir, allowedExtensions, relPath, depth + 1);
entries.push(...subEntries);
} else if (dirent.isFile()) {
const ext = path.extname(dirent.name).slice(1).toLowerCase();
if (!allowedExtensions.has(ext)) {
continue;
}
entries.push({
relPath,
entryType: 'file',
fileSize: getFileSize(fullPath),
detectedMediaType: detectMediaType(dirent.name),
detectedFormat: detectFormat(dirent.name)
});
}
}
return entries;
}
/**
* Scan-Ergebnisse in die DB schreiben (INSERT OR REPLACE) und
* nicht mehr vorhandene Einträge ohne Job entfernen.
*/
async function persistScanResults(rawDir, entries) {
const db = await getDb();
if (entries.length > 0) {
const stmt = await db.prepare(`
INSERT INTO converter_scan_entries (rel_path, entry_type, file_size, detected_media_type, detected_format, last_seen_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(rel_path) DO UPDATE SET
entry_type = excluded.entry_type,
file_size = excluded.file_size,
detected_media_type = excluded.detected_media_type,
detected_format = excluded.detected_format,
last_seen_at = excluded.last_seen_at
`);
for (const entry of entries) {
await stmt.run(
entry.relPath,
entry.entryType,
entry.fileSize,
entry.detectedMediaType,
entry.detectedFormat
);
}
await stmt.finalize();
}
// Einträge ohne zugewiesenen Job entfernen, wenn Datei nicht mehr vorhanden
const existingEntries = await db.all(
`SELECT rel_path FROM converter_scan_entries WHERE job_id IS NULL`
);
const currentRelPaths = new Set(entries.map((e) => e.relPath));
for (const row of existingEntries) {
if (!currentRelPaths.has(row.rel_path)) {
const fullPath = path.join(rawDir, row.rel_path);
if (!fs.existsSync(fullPath)) {
await db.run(
`DELETE FROM converter_scan_entries WHERE rel_path = ? AND job_id IS NULL`,
[row.rel_path]
);
}
}
}
}
/**
* Hauptmethode: rawDir scannen, DB aktualisieren, WebSocket-Event senden.
*/
async function scan() {
const settings = await settingsService.getSettingsMap();
const rawDir = String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
if (!rawDir) {
logger.warn('converter:scan:no-raw-dir');
return { rawDir: null, entryCount: 0 };
}
if (!fs.existsSync(rawDir)) {
logger.warn('converter:scan:dir-missing', { rawDir });
return { rawDir, entryCount: 0 };
}
const allowedExtensions = getConfiguredExtensions(settings);
logger.info('converter:scan:start', { rawDir, allowedExtensions: [...allowedExtensions] });
const entries = scanDirectory(rawDir, allowedExtensions);
await persistScanResults(rawDir, entries);
logger.info('converter:scan:done', { rawDir, entryCount: entries.length });
wsService.broadcast('CONVERTER_SCAN_UPDATE', { entryCount: entries.length });
return { rawDir, entryCount: entries.length };
}
/**
* Alle Einträge für den File-Explorer zurückgeben.
* Optionaler parentRelPath filtert auf Kinder eines bestimmten Verzeichnisses.
*/
async function getEntries(parentRelPath = null) {
const db = await getDb();
let rows;
if (!parentRelPath) {
// Root-Ebene: nur Einträge ohne '/' im rel_path
rows = await db.all(`
SELECT e.*, j.status AS job_status, j.title AS job_title
FROM converter_scan_entries e
LEFT JOIN jobs j ON j.id = e.job_id
ORDER BY e.entry_type DESC, e.rel_path ASC
`);
// Nur direkte Kinder (kein '/' im rel_path)
rows = rows.filter((r) => !String(r.rel_path).includes('/'));
} else {
const prefix = parentRelPath.endsWith('/') ? parentRelPath : `${parentRelPath}/`;
rows = await db.all(`
SELECT e.*, j.status AS job_status, j.title AS job_title
FROM converter_scan_entries e
LEFT JOIN jobs j ON j.id = e.job_id
ORDER BY e.entry_type DESC, e.rel_path ASC
`);
// Direkte Kinder des angegebenen Verzeichnisses
rows = rows.filter((r) => {
const rel = String(r.rel_path);
if (!rel.startsWith(prefix)) return false;
const remainder = rel.slice(prefix.length);
return !remainder.includes('/');
});
}
return rows.map((r) => ({
id: r.id,
relPath: r.rel_path,
entryType: r.entry_type,
fileSize: r.file_size,
detectedMediaType: r.detected_media_type,
detectedFormat: r.detected_format,
jobId: r.job_id,
jobStatus: r.job_status || null,
jobTitle: r.job_title || null,
lastSeenAt: r.last_seen_at
}));
}
async function getEntryById(id) {
const db = await getDb();
const row = await db.get(
`SELECT * FROM converter_scan_entries WHERE id = ?`,
[Number(id)]
);
return row || null;
}
async function getEntryByRelPath(relPath) {
const db = await getDb();
const row = await db.get(
`SELECT * FROM converter_scan_entries WHERE rel_path = ?`,
[relPath]
);
return row || null;
}
async function markEntryAsJob(relPath, jobId) {
const db = await getDb();
await db.run(
`UPDATE converter_scan_entries SET job_id = ? WHERE rel_path = ?`,
[jobId, relPath]
);
}
async function getRawDir() {
const settings = await settingsService.getSettingsMap();
return String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
}
/**
* Polling-Loop starten.
*/
async function startPolling() {
const settings = await settingsService.getSettingsMap();
_pollingEnabled = String(settings?.converter_polling_enabled || 'false').toLowerCase() === 'true';
_pollingInterval = Math.max(30, Number(settings?.converter_polling_interval || 300)) * 1000;
stopPolling();
if (!_pollingEnabled) {
logger.info('converter:polling:disabled');
return;
}
logger.info('converter:polling:start', { intervalMs: _pollingInterval });
const tick = async () => {
try {
await scan();
} catch (error) {
logger.error('converter:polling:error', { error: error?.message });
}
if (_pollingEnabled) {
_pollingTimer = setTimeout(tick, _pollingInterval);
}
};
_pollingTimer = setTimeout(tick, _pollingInterval);
}
function stopPolling() {
if (_pollingTimer) {
clearTimeout(_pollingTimer);
_pollingTimer = null;
}
}
async function restartPolling() {
await startPolling();
}
// ── Datei-Operationen (Löschen, Umbenennen, Verschieben, Ordner erstellen) ──
/**
* Relativen Pfad normalisieren und auf Path-Traversal prüfen.
* Gibt null zurück wenn der Pfad ungültig ist.
*/
function normalizeRelPath(input) {
if (input === null || input === undefined) return '';
const raw = String(input).replace(/\\/g, '/').trim();
if (!raw || raw === '.') return '';
if (raw.startsWith('/')) return null;
const normalized = path.posix.normalize(raw);
if (normalized.startsWith('..')) return null;
if (normalized === '.') return '';
return normalized;
}
function makeError(msg, code) {
const err = new Error(msg);
err.statusCode = code;
return err;
}
/**
* Datei oder Ordner löschen. Aktualisiert DB-Einträge.
*/
async function deleteEntry(relPath) {
const rawDir = await getRawDir();
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
const rel = normalizeRelPath(relPath);
if (rel === null || rel === '') throw makeError('Ungültiger oder leerer Pfad (Root kann nicht gelöscht werden).', 400);
const absPath = path.join(rawDir, rel);
// Traversal-Schutz
if (!absPath.startsWith(rawDir + path.sep)) throw makeError('Pfad außerhalb des RAW-Verzeichnisses.', 400);
const db = await getDb();
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht gelöscht werden.', 409);
if (!fs.existsSync(absPath)) throw makeError(`Pfad existiert nicht: ${rel}`, 404);
fs.rmSync(absPath, { recursive: true, force: true });
await db.run(
`DELETE FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
[rel, `${rel}/%`]
);
return { deleted: rel };
}
/**
* Datei oder Ordner umbenennen. Aktualisiert DB-Einträge.
*/
async function renameEntry(relPath, newName) {
const rawDir = await getRawDir();
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
const rel = normalizeRelPath(relPath);
if (rel === null || rel === '') throw makeError('Ungültiger Quellpfad.', 400);
const safeName = String(newName || '').trim();
if (!safeName || safeName.includes('/') || safeName.includes('\\') || safeName === '.' || safeName === '..') {
throw makeError('Ungültiger Name.', 400);
}
const parentRel = path.posix.dirname(rel);
const newRel = (parentRel === '.' || parentRel === '') ? safeName : `${parentRel}/${safeName}`;
const absOld = path.join(rawDir, rel);
const absNew = path.join(rawDir, newRel);
if (!absOld.startsWith(rawDir + path.sep)) throw makeError('Quellpfad außerhalb des RAW-Verzeichnisses.', 400);
if (!absNew.startsWith(rawDir + path.sep)) throw makeError('Zielpfad außerhalb des RAW-Verzeichnisses.', 400);
const db = await getDb();
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht umbenannt werden.', 409);
if (!fs.existsSync(absOld)) throw makeError('Quelle nicht gefunden.', 404);
if (fs.existsSync(absNew)) throw makeError('Ziel existiert bereits.', 409);
fs.renameSync(absOld, absNew);
const rows = await db.all(
`SELECT rel_path FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
[rel, `${rel}/%`]
);
for (const row of rows) {
const updatedRel = newRel + row.rel_path.slice(rel.length);
await db.run(`UPDATE converter_scan_entries SET rel_path = ? WHERE rel_path = ?`, [updatedRel, row.rel_path]);
}
return { oldRelPath: rel, newRelPath: newRel };
}
/**
* Datei oder Ordner in ein anderes Verzeichnis verschieben. Aktualisiert DB-Einträge.
*/
async function moveEntry(relPath, targetParentRelPath) {
const rawDir = await getRawDir();
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
const rel = normalizeRelPath(relPath);
if (rel === null || rel === '') throw makeError('Ungültiger Quellpfad.', 400);
const targetParentRel = normalizeRelPath(targetParentRelPath != null ? targetParentRelPath : '');
if (targetParentRel === null) throw makeError('Ungültiger Zielpfad.', 400);
const name = path.posix.basename(rel);
const newRel = targetParentRel === '' ? name : `${targetParentRel}/${name}`;
if (rel === newRel) throw makeError('Quelle und Ziel sind identisch.', 400);
if (newRel.startsWith(`${rel}/`)) throw makeError('Kann nicht in eigenen Unterordner verschoben werden.', 400);
const absOld = path.join(rawDir, rel);
const absNew = path.join(rawDir, newRel);
if (!absOld.startsWith(rawDir + path.sep)) throw makeError('Quellpfad außerhalb des RAW-Verzeichnisses.', 400);
if (!absNew.startsWith(rawDir + path.sep) && absNew !== rawDir) throw makeError('Zielpfad außerhalb des RAW-Verzeichnisses.', 400);
const db = await getDb();
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht verschoben werden.', 409);
if (!fs.existsSync(absOld)) throw makeError('Quelle nicht gefunden.', 404);
if (fs.existsSync(absNew)) throw makeError('Ziel existiert bereits.', 409);
fs.renameSync(absOld, absNew);
const rows = await db.all(
`SELECT rel_path FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
[rel, `${rel}/%`]
);
for (const row of rows) {
const updatedRel = newRel + row.rel_path.slice(rel.length);
await db.run(`UPDATE converter_scan_entries SET rel_path = ? WHERE rel_path = ?`, [updatedRel, row.rel_path]);
}
return { oldRelPath: rel, newRelPath: newRel, targetParentRelPath: targetParentRel };
}
/**
* Neuen Ordner erstellen (kein DB-Eintrag — erscheint beim nächsten Scan).
*/
async function createFolder(parentRelPath, name) {
const rawDir = await getRawDir();
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
const parentRel = normalizeRelPath(parentRelPath != null ? parentRelPath : '');
if (parentRel === null) throw makeError('Ungültiger übergeordneter Pfad.', 400);
const safeName = String(name || '').trim();
if (!safeName || safeName.includes('/') || safeName.includes('\\') || safeName === '.' || safeName === '..') {
throw makeError('Ungültiger Ordnername.', 400);
}
const newRel = parentRel === '' ? safeName : `${parentRel}/${safeName}`;
const absPath = path.join(rawDir, newRel);
if (!absPath.startsWith(rawDir + path.sep)) throw makeError('Pfad außerhalb des RAW-Verzeichnisses.', 400);
if (fs.existsSync(absPath)) throw makeError('Ordner existiert bereits.', 409);
fs.mkdirSync(absPath, { recursive: true });
return { relPath: newRel };
}
// ── Reines FS-Baum-Listing (keine DB) ─────────────────────────────────────
const TREE_MAX_DEPTH = 8;
function buildRawTree(rawDir, relPath, depth) {
if (depth >= TREE_MAX_DEPTH) return [];
const absDir = relPath ? path.join(rawDir, relPath) : rawDir;
let dirents;
try {
dirents = fs.readdirSync(absDir, { withFileTypes: true });
} catch (_) {
return [];
}
dirents.sort((a, b) => {
const ad = a.isDirectory() ? 0 : 1;
const bd = b.isDirectory() ? 0 : 1;
if (ad !== bd) return ad - bd;
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
});
const nodes = [];
for (const dirent of dirents) {
// Versteckte Einträge überspringen
if (dirent.name.startsWith('.')) continue;
const childRel = relPath ? `${relPath}/${dirent.name}` : dirent.name;
if (dirent.isDirectory()) {
const children = buildRawTree(rawDir, childRel, depth + 1);
const size = children.reduce((s, c) => s + (c.size || 0), 0);
nodes.push({ name: dirent.name, type: 'folder', path: childRel, size, children });
} else if (dirent.isFile()) {
let size = 0;
try { size = fs.statSync(path.join(rawDir, childRel)).size; } catch (_) {}
nodes.push({
name: dirent.name,
type: 'file',
path: childRel,
size,
detectedMediaType: detectMediaType(dirent.name),
detectedFormat: detectFormat(dirent.name)
});
}
}
return nodes;
}
async function getTree() {
const rawDir = await getRawDir();
if (!rawDir || !fs.existsSync(rawDir)) {
return { rawDir: rawDir || null, tree: null };
}
const children = buildRawTree(rawDir, '', 0);
const size = children.reduce((s, c) => s + (c.size || 0), 0);
return {
rawDir,
tree: { name: path.basename(rawDir) || 'raw', type: 'folder', path: '', size, children }
};
}
module.exports = {
scan,
getEntries,
getEntryById,
getEntryByRelPath,
markEntryAsJob,
getRawDir,
normalizeRelPath,
getTree,
startPolling,
stopPolling,
restartPolling,
detectMediaType,
detectFormat,
deleteEntry,
renameEntry,
moveEntry,
createFolder
};
+51 -4
View File
@@ -326,6 +326,13 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
return profileHint;
}
// Converter jobs: detected via media_type field (plan.mediaProfile = 'converter')
const rawMediaType = normalizeMediaTypeValue(job?.media_type);
const rawPlanProfile = String(plan?.mediaProfile || '').trim().toLowerCase();
if (rawPlanProfile === 'converter' || rawMediaType === 'converter') {
return 'converter';
}
const statusCandidates = [
job?.status,
job?.last_state,
@@ -530,6 +537,29 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
const handbrakeInfo = parsed?.handbrakeInfo || parseJsonSafe(job?.handbrake_info_json, null);
const mediaType = inferMediaType(job, mkInfo, miInfo, plan, handbrakeInfo);
const directoryLikeOutput = isDirectoryLikeOutput(mediaType, plan, handbrakeInfo);
// Converter jobs use their own storage dirs — do not remap output path
if (mediaType === 'converter') {
const s = settings || {};
const converterMediaType = String(plan?.converterMediaType || '').trim().toLowerCase();
const converterMovieDir = converterMediaType === 'audio'
? (String(s.converter_audio_dir || '').trim() || String(s.converter_movie_dir || '').trim())
: String(s.converter_movie_dir || '').trim();
const converterRawDir = String(s.converter_raw_dir || '').trim();
return {
mediaType: 'converter',
directoryLikeOutput: false,
rawDir: converterRawDir,
movieDir: converterMovieDir,
effectiveRawPath: job?.raw_path || null,
effectiveOutputPath: job?.output_path || null,
makemkvInfo: mkInfo,
mediainfoInfo: miInfo,
handbrakeInfo,
encodePlan: plan
};
}
const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType);
const rawDir = String(effectiveSettings?.raw_dir || '').trim();
const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim();
@@ -4325,8 +4355,20 @@ class HistoryService {
} else {
const rawPath = normalizeComparablePath(effectiveRawPath);
const rawRoot = normalizeComparablePath(effectiveRawDir);
const keepRoot = rawPath === rawRoot;
const result = deleteFilesRecursively(effectiveRawPath, keepRoot);
const stat = fs.lstatSync(rawPath);
const isFile = stat.isFile();
// Für Converter-Jobs: Datei liegt in einem Unterordner → Ordner löschen
let deletePath = rawPath;
if (isFile && resolvedPaths.mediaType === 'converter') {
const parentDir = normalizeComparablePath(path.dirname(rawPath));
if (parentDir && parentDir !== rawRoot && isPathInside(rawRoot, parentDir)) {
deletePath = parentDir;
}
}
const keepRoot = deletePath === rawRoot;
const result = deleteFilesRecursively(deletePath, keepRoot);
summary.raw.deleted = true;
summary.raw.filesDeleted = result.filesDeleted;
summary.raw.dirsRemoved = result.dirsRemoved;
@@ -4371,7 +4413,9 @@ class HistoryService {
summary.movie.reason = null;
} else {
const parentDir = normalizeComparablePath(path.dirname(trackedPath));
const canDeleteParentDir = parentDir
const isConverterJob = resolvedPaths.mediaType === 'converter';
const canDeleteParentDir = !isConverterJob
&& parentDir
&& parentDir !== movieRoot
&& isPathInside(movieRoot, parentDir)
&& fs.existsSync(parentDir)
@@ -4413,7 +4457,10 @@ class HistoryService {
movieCandidatePathForTracking = outputPath;
} else {
const parentDir = normalizeComparablePath(path.dirname(outputPath));
const canDeleteParentDir = parentDir
// Converter jobs output a single file — never delete the parent dir
const isConverterJob = resolvedPaths.mediaType === 'converter';
const canDeleteParentDir = !isConverterJob
&& parentDir
&& parentDir !== movieRoot
&& isPathInside(movieRoot, parentDir)
&& fs.existsSync(parentDir)
+742 -16
View File
@@ -19,7 +19,7 @@ const notificationService = require('./notificationService');
const logger = require('./logger').child('PIPELINE');
const { spawnTrackedProcess } = require('./processRunner');
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
const { ensureDir, sanitizeFileName, renderTemplate, findMediaFiles } = require('../utils/files');
const { ensureDir, sanitizeFileName, sanitizeFileNameWithExtension, renderTemplate, findMediaFiles } = require('../utils/files');
const { buildMediainfoReview } = require('../utils/encodePlan');
const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis');
const { errorToMeta } = require('../utils/errorMeta');
@@ -340,11 +340,14 @@ function normalizeMediaProfile(value) {
if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') {
return 'audiobook';
}
if (raw === 'converter') {
return 'converter';
}
return null;
}
function isSpecificMediaProfile(value) {
return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook';
return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook' || value === 'converter';
}
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
@@ -2653,7 +2656,7 @@ function resolveRawFolderStateFromOptions(options = {}) {
function buildRawDirName(metadataBase, jobId, options = {}) {
const state = resolveRawFolderStateFromOptions(options);
const baseName = sanitizeFileName(`${metadataBase} - RAW - job-${jobId}`);
return sanitizeFileName(applyRawFolderStateToName(baseName, state));
return applyRawFolderStateToName(baseName, state);
}
function buildRawPathForState(rawPath, state) {
@@ -3987,11 +3990,13 @@ class PipelineService extends EventEmitter {
const { DVDPlugin } = require('../plugins/DVDPlugin');
const { CdPlugin } = require('../plugins/CdPlugin');
const { AudiobookPlugin } = require('../plugins/AudiobookPlugin');
const { ConverterPlugin } = require('../plugins/ConverterPlugin');
const plugins = [
new BluRayPlugin(),
new DVDPlugin(),
new CdPlugin(),
new AudiobookPlugin()
new AudiobookPlugin(),
new ConverterPlugin()
];
for (const plugin of plugins) {
if (!registry.getPlugin(plugin.id)) {
@@ -6087,15 +6092,20 @@ class PipelineService extends EventEmitter {
try {
while (this.queueEntries.length > 0) {
// Get current running counts and limits
const [filmRunning, cdRunning, maxFilm, maxCd, maxTotal, cdBypass] = await Promise.all([
historyService.getRunningFilmEncodeJobs().then((r) => r.length),
historyService.getRunningCdEncodeJobs().then((r) => r.length),
const [allRunningJobs, maxFilm, maxCd, maxTotal, cdBypass] = await Promise.all([
historyService.getRunningJobs(),
this.getMaxParallelJobs(),
this.getMaxParallelCdEncodes(),
this.getMaxTotalEncodes(),
this.getCdBypassesQueue()
]);
const filmRunning = allRunningJobs.filter((j) => j.status === 'ENCODING').length;
const cdRunning = allRunningJobs.filter((j) => ['CD_RIPPING', 'CD_ENCODING'].includes(j.status)).length;
const totalRunning = filmRunning + cdRunning;
// Counts all actively processing jobs (excludes waiting states like READY_TO_ENCODE).
const anyActiveJobs = allRunningJobs.filter(
(j) => !['READY_TO_ENCODE', 'CD_READY_TO_RIP'].includes(j.status)
).length;
// Find next startable entry
let entryIndex = -1;
@@ -6104,10 +6114,10 @@ class PipelineService extends EventEmitter {
const isNonJob = candidate.type && candidate.type !== 'job';
if (isNonJob) {
// Non-job entries (script, chain, wait) only start when no jobs are running.
// Use both DB count and activeProcesses to cover all running states
// (ANALYZING, RIPPING, MEDIAINFO_CHECK etc. are not reflected in totalRunning).
if (totalRunning === 0 && this.activeProcesses.size === 0) {
// Non-job entries (script, chain, wait) only start when no jobs are actively running.
// anyActiveJobs covers all active states (ANALYZING, RIPPING, ENCODING, MEDIAINFO_CHECK etc.)
// activeProcesses provides a second safety net for race conditions during state transitions.
if (anyActiveJobs === 0 && this.activeProcesses.size === 0) {
entryIndex = i;
}
break; // FIFO: stop scanning regardless (non-job blocks everything behind it)
@@ -8654,6 +8664,9 @@ class PipelineService extends EventEmitter {
if (preloadedMediaProfile === 'audiobook') {
return this.startAudiobookEncode(jobId, { ...options, preloadedJob });
}
if (preloadedMediaProfile === 'converter') {
return this.startConverterEncode(jobId, { ...options, preloadedJob });
}
const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE';
if (isReadyToEncode) {
@@ -8711,6 +8724,9 @@ class PipelineService extends EventEmitter {
if (jobMediaProfile === 'audiobook') {
return this.startAudiobookEncode(jobId, { ...options, immediate: true, preloadedJob: job });
}
if (jobMediaProfile === 'converter') {
return this.startConverterEncode(jobId, { ...options, immediate: true, preloadedJob: job });
}
this.ensureNotBusy('startPreparedJob', jobId);
logger.info('startPreparedJob:requested', { jobId });
@@ -9878,6 +9894,7 @@ class PipelineService extends EventEmitter {
let dvdHandBrakeScanJson = null;
let dvdHandBrakeScanInputPath = null;
let dvdSelectedTitleInfo = null; // set when HandBrake scan succeeds; used to skip re-selection
let dvdScannedCandidates = null; // set when scan yields multiple MIN_LENGTH candidates needing user selection
if (isDvdSource && mediaFiles.length > 0) {
// For 'dvd': scan the folder that contains VIDEO_TS (parent of the VIDEO_TS dir).
@@ -9911,9 +9928,26 @@ class PipelineService extends EventEmitter {
dvdHandBrakeScanJson = parseMediainfoJsonOutput(dvdScanLines.join('\n'));
if (dvdHandBrakeScanJson) {
const titleInfo = parseHandBrakeSelectedTitleInfo(dvdHandBrakeScanJson);
const minLengthMinutes = Number(settings?.makemkv_min_length_minutes || 0);
const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60));
const allDvdTitles = parseHandBrakeTitleList(dvdHandBrakeScanJson);
const filteredDvdCandidates = minLengthSeconds > 0
? allDvdTitles.filter((t) => t.durationSeconds >= minLengthSeconds)
: allDvdTitles;
await historyService.appendLog(
jobId,
'SYSTEM',
`HandBrake DVD-Scan: ${allDvdTitles.length} Titel gesamt, ${filteredDvdCandidates.length} ≥ ${minLengthMinutes} min.`
);
// For synthetic mediaInfo: prefer the single MIN_LENGTH candidate; otherwise fall back to
// the best available title (longest / main feature) as a placeholder for the review.
const preferredTitleId = filteredDvdCandidates.length === 1
? filteredDvdCandidates[0].handBrakeTitleId
: null;
const titleInfo = parseHandBrakeSelectedTitleInfo(dvdHandBrakeScanJson, {
handBrakeTitleId: preferredTitleId || undefined
});
if (titleInfo) {
dvdSelectedTitleInfo = titleInfo;
const syntheticMediaInfo = buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo);
mediaInfoByPath[dvdHandBrakeScanInputPath] = syntheticMediaInfo;
effectiveMediaFiles = [{ path: dvdHandBrakeScanInputPath, size: 0 }];
@@ -9924,6 +9958,16 @@ class PipelineService extends EventEmitter {
'SYSTEM',
`HandBrake DVD-Scan: Audio=${audioCount}, Subtitle=${subtitleCount}, Dauer=${formatDurationClock(titleInfo.durationSeconds)}`
);
if (filteredDvdCandidates.length === 1) {
// Exactly one title passes MIN_LENGTH → auto-select it.
dvdSelectedTitleInfo = titleInfo;
} else if (filteredDvdCandidates.length > 1) {
// Multiple candidates → user must choose; store for downstream selection dialog.
dvdScannedCandidates = filteredDvdCandidates;
} else {
// No title passes MIN_LENGTH filter → auto-select best available (degraded mode).
dvdSelectedTitleInfo = titleInfo;
}
} else {
const error = new Error('HandBrake DVD-Scan: Kein Titel erkannt.');
error.runInfo = dvdScanRunInfo;
@@ -10092,9 +10136,8 @@ class PipelineService extends EventEmitter {
`HandBrake Titel-Auswahl (manuell) übernommen: -t ${preSelectedHandBrakeTitleId}`
);
} else if (dvdSelectedTitleInfo) {
// HandBrake already identified the valid title during the DVD analysis scan above.
// Use it directly — no need to re-parse all titles from TitleList, which would
// include short/invalid titles and incorrectly trigger manual title selection.
// Exactly one title passed the MIN_LENGTH filter (or fallback to best available) —
// auto-select it without requiring user input.
enrichedReview = {
...enrichedReview,
handBrakeTitleId: dvdSelectedTitleInfo.handBrakeTitleId,
@@ -10105,6 +10148,77 @@ class PipelineService extends EventEmitter {
'SYSTEM',
`HandBrake DVD Titel automatisch gewählt (aus Scan): -t ${dvdSelectedTitleInfo.handBrakeTitleId}`
);
} else if (dvdScannedCandidates && dvdScannedCandidates.length > 0) {
// Multiple titles passed the MIN_LENGTH filter during the initial DVD scan —
// present them to the user without running a second HandBrake scan.
const candidatesData = dvdScannedCandidates.map((t) => ({
handBrakeTitleId: t.handBrakeTitleId,
durationSeconds: t.durationSeconds,
durationMinutes: Number((t.durationSeconds / 60).toFixed(1)),
audioTrackCount: t.audioTrackCount,
subtitleTrackCount: t.subtitleTrackCount,
sizeBytes: t.sizeBytes || 0
}));
enrichedReview = {
...enrichedReview,
handBrakeTitleDecisionRequired: true,
handBrakeTitleCandidates: candidatesData,
handBrakeDvdInputPath: dvdScanInputPath,
encodeInputPath: null
};
const waitingMediainfoInfo = this.withPluginExecutionMeta({
generatedAt: nowIso(),
files: mediaInfoRuns
}, reviewPluginExecution);
await historyService.updateJob(jobId, {
status: 'WAITING_FOR_USER_DECISION',
last_state: 'WAITING_FOR_USER_DECISION',
error_message: null,
mediainfo_info_json: JSON.stringify(waitingMediainfoInfo),
encode_plan_json: JSON.stringify(enrichedReview),
encode_input_path: null,
encode_review_confirmed: 0
});
await historyService.appendLog(
jobId,
'SYSTEM',
`HandBrake DVD Titelauswahl erforderlich: ${candidatesData.length} Kandidaten. Nutzer muss Titel wählen.`
);
const dvdWaitingStatusText = `DVD Titelauswahl: ${candidatesData.length} Kandidaten gefunden`;
const dvdWaitingContextPatch = {
jobId,
rawPath,
handBrakeTitleDecisionRequired: true,
handBrakeTitleCandidates: candidatesData,
handBrakeDvdInputPath: dvdScanInputPath,
reviewConfirmed: false,
mode: options.mode || 'rip',
mediaProfile,
sourceJobId: options.sourceJobId || null,
mediaInfoReview: enrichedReview,
selectedMetadata
};
if (this.isPrimaryJob(jobId)) {
await this.setState('WAITING_FOR_USER_DECISION', {
activeJobId: jobId,
progress: 0,
eta: null,
statusText: dvdWaitingStatusText,
context: {
...dvdWaitingContextPatch,
...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {})
}
});
} else {
await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, {
contextPatch: dvdWaitingContextPatch
});
}
void this.notifyPushover('metadata_ready', {
title: 'Ripster - DVD Titelauswahl',
message: `Job #${jobId}: ${candidatesData.length} DVD-Titel gefunden, manuelle Auswahl erforderlich`
});
return enrichedReview;
} else {
// No pre-scanned result available — run a fresh HandBrake scan for title selection.
let dvdTitleScanJson = null;
@@ -14185,6 +14299,209 @@ class PipelineService extends EventEmitter {
return { started: true, stage: 'ENCODING', outputPath: incompleteOutputPath };
}
// ── Converter Pipeline ───────────────────────────────────────────────────────
async startConverterEncode(jobId, options = {}) {
const immediate = Boolean(options?.immediate);
if (!immediate) {
return this.enqueueOrStartAction(
QUEUE_ACTIONS.START_PREPARED,
jobId,
() => this.startConverterEncode(jobId, { ...options, immediate: true })
);
}
this.ensureNotBusy('startConverterEncode', jobId);
logger.info('converter:encode:start', { jobId });
this.cancelRequestedByJob.delete(Number(jobId));
const job = options?.preloadedJob || await historyService.getJobById(jobId);
if (!job) {
const error = new Error(`Job ${jobId} nicht gefunden.`);
error.statusCode = 404;
throw error;
}
const encodePlan = this.safeParseJson(job.encode_plan_json) || {};
const inputPath = encodePlan.inputPath || job.raw_path;
if (!inputPath || !fs.existsSync(inputPath)) {
const error = new Error(`Converter-Eingabedatei nicht gefunden: ${inputPath}`);
error.statusCode = 404;
throw error;
}
const outputPath = encodePlan.outputPath || null;
const outputDir = encodePlan.outputDir || null;
await historyService.resetProcessLog(jobId);
await this.setState('ANALYZING', {
activeJobId: jobId,
progress: 0,
eta: null,
statusText: 'Converter: Analyse läuft …',
context: {
jobId,
mode: 'converter',
mediaProfile: 'converter',
inputPath,
converterMediaType: encodePlan.converterMediaType || 'video'
}
});
await historyService.updateJob(jobId, {
status: 'ANALYZING',
last_state: 'ANALYZING',
start_time: nowIso(),
end_time: null,
error_message: null
});
void (async () => {
try {
const { ConverterPlugin } = require('../plugins/ConverterPlugin');
const converterPlugin = new ConverterPlugin();
const analyzeCtx = await this.buildPluginContext('converter', {
jobId,
filePath: inputPath,
encodePlan,
emitProgress: (pct, statusText) => {
void this.updateProgress('ANALYZING', pct, null, statusText, jobId);
}
});
await converterPlugin.analyze(inputPath, job, analyzeCtx);
if (this.cancelRequestedByJob.has(Number(jobId))) {
const cancelErr = new Error('Job wurde vom Benutzer abgebrochen.');
cancelErr.runInfo = { status: 'CANCELLED' };
throw cancelErr;
}
await this.setState('ENCODING', {
activeJobId: jobId,
progress: 0,
eta: null,
statusText: 'Converter: Encoding läuft …',
context: {
jobId,
mode: 'converter',
mediaProfile: 'converter',
inputPath,
outputPath: outputPath || outputDir,
converterMediaType: encodePlan.converterMediaType || 'video'
}
});
await historyService.updateJob(jobId, {
status: 'ENCODING',
last_state: 'ENCODING'
});
await historyService.appendLog(
jobId,
'SYSTEM',
`Converter-Encoding gestartet: ${path.basename(inputPath)} → ${encodePlan.converterMediaType || 'video'} / ${encodePlan.outputFormat || '?'}`
);
void this.notifyPushover('encoding_started', {
title: 'Ripster - Converter gestartet',
message: `${job.title || job.detected_title || `Job #${jobId}`} → ${outputPath || outputDir || '?'}`
});
const encodeCtx = await this.buildPluginContext('converter', {
jobId,
inputPath,
outputPath,
outputDir,
encodePlan,
isCancelled: () => this.cancelRequestedByJob.has(Number(jobId)),
onProcessHandle: (handle) => {
if (handle) {
this.activeProcesses.set(Number(jobId), handle);
}
},
emitProgress: (pct, statusText) => {
void this.updateProgress('ENCODING', pct, null, statusText, jobId);
}
});
await converterPlugin.encode(job, encodeCtx);
this.activeProcesses.delete(Number(jobId));
const finalOutputPath = outputPath || outputDir || null;
// Eigentümer der Ausgabedateien setzen
if (finalOutputPath) {
const converterSettings = await settingsService.getSettingsMap();
const converterMediaType = encodePlan.converterMediaType || 'video';
const outputOwner = converterMediaType === 'audio'
? String(converterSettings?.converter_audio_dir_owner || converterSettings?.converter_movie_dir_owner || '').trim()
: String(converterSettings?.converter_movie_dir_owner || '').trim();
if (outputOwner) {
chownRecursive(finalOutputPath, outputOwner);
}
}
await historyService.updateJob(jobId, {
status: 'FINISHED',
last_state: 'FINISHED',
end_time: nowIso(),
rip_successful: 1,
output_path: finalOutputPath,
error_message: null
});
await historyService.appendLog(
jobId,
'SYSTEM',
`Converter-Encoding abgeschlossen: ${finalOutputPath}`
);
if (Number(this.snapshot.activeJobId) === Number(jobId)) {
await this.setState('FINISHED', {
activeJobId: jobId,
progress: 100,
eta: null,
statusText: 'Converter abgeschlossen',
context: {
jobId,
mode: 'converter',
mediaProfile: 'converter',
outputPath: finalOutputPath
}
});
} else {
void this.pumpQueue();
}
void this.notifyPushover('job_finished', {
title: 'Ripster - Converter abgeschlossen',
message: `${job.title || job.detected_title || `Job #${jobId}`} → ${finalOutputPath}`
});
setTimeout(async () => {
if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) {
await this.setState('IDLE', {
finishingJobId: jobId,
activeJobId: null,
progress: 0,
eta: null,
statusText: 'Bereit',
context: {}
});
}
}, 3000);
} catch (error) {
this.activeProcesses.delete(Number(jobId));
logger.error('converter:encode:failed', { jobId, error: errorToMeta(error) });
await this.failJob(jobId, 'ENCODING', error);
}
})();
return { started: true, stage: 'ANALYZING', outputPath: outputPath || outputDir };
}
// ── CD Pipeline ─────────────────────────────────────────────────────────────
async analyzeCd(device, options = {}) {
@@ -15634,6 +15951,415 @@ class PipelineService extends EventEmitter {
}
}
// ═══════════════════════════════════════════════════════════════════════════
// CONVERTER
// ═══════════════════════════════════════════════════════════════════════════
/**
* Erstellt einen Converter-Job aus einem Scan-Eintrag (File-Explorer-Auswahl).
*
* @param {string} relPath - Relativer Pfad innerhalb von converter_raw_dir
* @param {object} options
* @param {string} options.converterMediaType - 'video'|'audio'|'iso'
* @param {boolean} options.isFolder - Ordner-Job?
* @returns {Promise<object>} Job-Objekt
*/
async createConverterJobFromEntry(relPath, options = {}) {
const converterScanService = require('./converterScanService');
const rawDir = await converterScanService.getRawDir();
if (!rawDir) {
const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
error.statusCode = 400;
throw error;
}
const fullPath = path.join(rawDir, relPath);
if (!fs.existsSync(fullPath)) {
const error = new Error(`Eingabedatei nicht gefunden: ${fullPath}`);
error.statusCode = 404;
throw error;
}
const stat = fs.statSync(fullPath);
const isDirectory = stat.isDirectory();
const baseName = path.basename(relPath, path.extname(relPath));
const detectedTitle = baseName || 'Converter Job';
const converterMediaType = options.converterMediaType
|| converterScanService.detectMediaType(path.basename(relPath));
const job = await historyService.createJob({
discDevice: null,
status: 'READY_TO_START',
detectedTitle
});
await historyService.updateJob(job.id, {
media_type: 'converter',
raw_path: fullPath,
encode_plan_json: JSON.stringify({
mediaProfile: 'converter',
converterMediaType,
inputPath: fullPath,
isFolder: isDirectory,
audioFiles: isDirectory && converterMediaType === 'audio'
? require('./converterScanService').detectFormat
? null // wird beim Start gefüllt
: null
: null
}),
last_state: 'READY_TO_START'
});
// Scan-Eintrag als Job markieren
await converterScanService.markEntryAsJob(relPath, job.id);
await historyService.appendLog(
job.id,
'SYSTEM',
`Converter-Job erstellt aus: ${relPath} | Typ: ${converterMediaType}`
);
logger.info('converter:job:created-from-entry', {
jobId: job.id, relPath, converterMediaType, fullPath
});
return historyService.getJobById(job.id);
}
/**
* Lädt Converter-Dateien hoch und legt Unterordner in converter_raw_dir an.
* Erstellt KEINE Jobs – das geschieht separat via createConverterJobsFromSelection.
*
* @param {Array<{path, originalname, size}>} files - Multer-Dateiobjekte
* @returns {Promise<{folders: Array<{folderRelPath, fileCount}>}>}
*/
async uploadConverterFiles(files, options = {}) {
const converterScanService = require('./converterScanService');
const rawDir = await converterScanService.getRawDir();
if (!rawDir) {
const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
error.statusCode = 400;
throw error;
}
ensureDir(rawDir);
const normalizedFiles = Array.isArray(files) ? files : [files];
if (normalizedFiles.length === 0) {
const error = new Error('Keine Dateien zum Upload.');
error.statusCode = 400;
throw error;
}
const folderName = options?.folderName ? String(options.folderName).trim() : null;
if (folderName) {
// ── Ordner-Upload (wie Klangkiste target_path): alle Dateien in EINEN Ordner ──
const safeFolderName = sanitizeFileName(folderName) || `upload_${Date.now()}`;
const uniqueFolderName = `${safeFolderName}_${Date.now()}`;
const targetDir = path.join(rawDir, uniqueFolderName);
ensureDir(targetDir);
for (const file of normalizedFiles) {
const tempPath = String(file?.path || '').trim();
if (!tempPath || !fs.existsSync(tempPath)) continue;
const rawName = String(file?.originalname || '').trim();
const safeFileName = sanitizeFileNameWithExtension(rawName) || rawName || path.basename(tempPath);
const targetFile = path.join(targetDir, safeFileName);
// Traversal-Schutz
if (!targetFile.startsWith(targetDir + path.sep)) {
logger.warn('converter:upload:traversal-blocked', { rawName });
continue;
}
moveFileWithFallback(tempPath, targetFile);
}
logger.info('converter:upload:folder-placed', {
folderName, targetDir, fileCount: normalizedFiles.length
});
return { folders: [{ folderRelPath: uniqueFolderName, fileCount: normalizedFiles.length }] };
}
// ── Einzeldatei-Upload ─────────────────────────────────────────────────
const createdFolders = [];
for (const file of normalizedFiles) {
const tempPath = String(file?.path || '').trim();
const originalName = String(file?.originalname || file?.originalName || '').trim()
|| path.basename(tempPath || 'upload');
if (!tempPath || !fs.existsSync(tempPath)) {
logger.warn('converter:upload:temp-missing', { originalName, tempPath });
continue;
}
const baseNameClean = sanitizeFileName(
path.basename(originalName, path.extname(originalName))
) || `upload_${Date.now()}`;
const subfolder = `${baseNameClean}_${Date.now()}`;
const targetDir = path.join(rawDir, subfolder);
ensureDir(targetDir);
const safeFileName = sanitizeFileNameWithExtension(originalName) || originalName;
const targetFile = path.join(targetDir, safeFileName);
moveFileWithFallback(tempPath, targetFile);
logger.info('converter:upload:file-placed', { originalName, targetFile });
createdFolders.push({ folderRelPath: subfolder, fileCount: 1 });
}
return { folders: createdFolders };
}
/**
* Erstellt Converter-Jobs aus ausgewählten Dateipfaden (relativ zu converter_raw_dir).
* Audio-Dateien können als einzelne Jobs oder als ein gemeinsamer Job angelegt werden.
*
* @param {string[]} relPaths - Dateipfade relativ zum rawDir
* @param {'individual'|'shared'} audioMode - Modus für Audio-Dateien
* @returns {Promise<object[]>} Erstellte Jobs
*/
async createConverterJobsFromSelection(relPaths, audioMode = 'individual') {
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']);
const converterScanService = require('./converterScanService');
const rawDir = await converterScanService.getRawDir();
if (!rawDir) {
const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
error.statusCode = 400;
throw error;
}
const audioRelPaths = [];
const nonAudioRelPaths = [];
for (const relPath of relPaths) {
const ext = path.extname(String(relPath || '')).toLowerCase();
if (AUDIO_EXTS.has(ext)) {
audioRelPaths.push(relPath);
} else {
nonAudioRelPaths.push(relPath);
}
}
const createdJobs = [];
// Nicht-Audio (Video, ISO, Ordner): immer ein Job pro Eintrag
for (const relPath of nonAudioRelPaths) {
const job = await this.createConverterJobFromEntry(relPath, {});
createdJobs.push(job);
}
if (audioMode === 'shared' && audioRelPaths.length > 0) {
// Ein gemeinsamer Job für alle Audio-Dateien
const fullPaths = audioRelPaths.map((p) => path.join(rawDir, p));
const rawPath = path.dirname(fullPaths[0]);
const detectedTitle = path.basename(rawPath) || 'Converter Audio Job';
const job = await historyService.createJob({
discDevice: null,
status: 'READY_TO_START',
detectedTitle
});
await historyService.updateJob(job.id, {
media_type: 'converter',
raw_path: rawPath,
encode_plan_json: JSON.stringify({
mediaProfile: 'converter',
converterMediaType: 'audio',
inputPath: rawPath,
inputPaths: fullPaths,
isFolder: false,
isSharedAudio: true
}),
last_state: 'READY_TO_START'
});
await historyService.appendLog(
job.id, 'SYSTEM',
`Converter-Job (gemeinsam) erstellt: ${audioRelPaths.length} Audio-Datei(en)`
);
logger.info('converter:job:created-shared-audio', {
jobId: job.id, fileCount: audioRelPaths.length
});
createdJobs.push(await historyService.getJobById(job.id));
} else {
// Einzelne Jobs für jede Audio-Datei
for (const relPath of audioRelPaths) {
const job = await this.createConverterJobFromEntry(relPath, { converterMediaType: 'audio' });
createdJobs.push(job);
}
}
return createdJobs;
}
/**
* Startet einen Converter-Job mit der finalen Konfiguration (Preset, Format etc.).
*
* @param {number} jobId
* @param {object} config
* @param {string} config.converterMediaType - 'video'|'audio'|'iso'
* @param {string} config.outputFormat - Ausgabeformat (mkv, mp4, flac, mp3, ...)
* @param {object} [config.userPreset] - HandBrake User-Preset (Video)
* @param {object} [config.trackSelection] - Audio/Subtitle-Selektion (Video)
* @param {number} [config.handBrakeTitleId] - HandBrake Titel-ID (Video)
* @param {object} [config.audioFormatOptions] - Format-Optionen (Audio)
* @returns {Promise<object>}
*/
async startConverterJob(jobId, config = {}) {
const normalizedJobId = Number(jobId);
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
const error = new Error('Ungültige Job-ID für Converter-Start.');
error.statusCode = 400;
throw error;
}
const job = await historyService.getJobById(normalizedJobId);
if (!job) {
const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
error.statusCode = 404;
throw error;
}
const existingPlan = this.safeParseJson(job.encode_plan_json) || {};
const converterMediaType = config.converterMediaType
|| existingPlan.converterMediaType
|| 'video';
const converterScanService = require('./converterScanService');
const settings = await settingsService.getSettingsMap();
const rawDir = String(
settings?.converter_raw_dir || require('../config').defaultConverterRawDir || ''
).trim();
const movieDir = String(
settings?.converter_movie_dir || require('../config').defaultConverterMovieDir || ''
).trim();
const audioDir = String(
settings?.converter_audio_dir || require('../config').defaultConverterAudioDir || ''
).trim();
const inputPath = existingPlan.inputPath || job.raw_path;
if (!inputPath || !fs.existsSync(inputPath)) {
const error = new Error(`Eingabedatei nicht gefunden: ${inputPath}`);
error.statusCode = 404;
throw error;
}
// Ausgabepfad bestimmen
let outputPath = null;
let outputDir = null;
const outputFormat = String(config.outputFormat || 'mkv').trim().toLowerCase();
const baseName = sanitizeFileName(
path.basename(inputPath, path.extname(inputPath))
) || 'output';
const title = job.title || job.detected_title || baseName;
const safeTitle = sanitizeFileName(title) || baseName;
if (converterMediaType === 'video' || converterMediaType === 'iso') {
const videoTemplate = String(
settings?.converter_output_template_video || '{title}'
).replace('{title}', safeTitle);
outputPath = path.join(movieDir, `${videoTemplate}.${outputFormat}`);
} else if (converterMediaType === 'audio') {
const isFolder = Boolean(existingPlan.isFolder || config.isFolder);
if (isFolder) {
const audioTemplate = String(
settings?.converter_output_template_audio || '{artist} - {title}'
).replace('{title}', safeTitle).replace('{artist}', safeTitle);
outputDir = path.join(audioDir, sanitizeFileName(audioTemplate) || safeTitle);
} else {
const audioTemplate = String(
settings?.converter_output_template_audio || '{artist} - {title}'
).replace('{title}', safeTitle).replace('{artist}', safeTitle);
outputPath = path.join(audioDir, `${sanitizeFileName(audioTemplate) || safeTitle}.${outputFormat}`);
}
}
// Audiodateien im Ordner laden
let audioFiles = existingPlan.audioFiles;
if (converterMediaType === 'audio' && existingPlan.isFolder && !audioFiles) {
const { readdirSync } = fs;
const AUDIO_EXTS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']);
try {
audioFiles = readdirSync(inputPath)
.filter((f) => AUDIO_EXTS.has(path.extname(f).slice(1).toLowerCase()))
.sort();
} catch (_err) {
audioFiles = [];
}
}
const nextEncodePlan = {
...existingPlan,
mediaProfile: 'converter',
converterMediaType,
inputPath,
outputPath: outputPath || null,
outputDir: outputDir || null,
outputFormat,
userPreset: config.userPreset || existingPlan.userPreset || null,
trackSelection: config.trackSelection || existingPlan.trackSelection || null,
handBrakeTitleId: config.handBrakeTitleId || existingPlan.handBrakeTitleId || null,
audioFormatOptions: config.audioFormatOptions || existingPlan.audioFormatOptions || {},
audioFiles: audioFiles || existingPlan.audioFiles || null,
reviewConfirmed: true
};
await historyService.updateJob(normalizedJobId, {
status: 'READY_TO_START',
last_state: 'READY_TO_START',
media_type: 'converter',
output_path: outputPath || outputDir || null,
encode_plan_json: JSON.stringify(nextEncodePlan),
encode_review_confirmed: 1,
error_message: null,
end_time: null
});
await historyService.appendLog(
normalizedJobId,
'USER_ACTION',
`Converter konfiguriert: Typ ${converterMediaType} | Format ${outputFormat}`
);
const startResult = await this.startPreparedJob(normalizedJobId);
return {
jobId: normalizedJobId,
...(startResult && typeof startResult === 'object' ? startResult : {})
};
}
/**
* Gibt alle Converter-Jobs zurück (für die Converter-Seite).
*/
async getConverterJobs() {
const db = await require('../db/database').getDb();
const rows = await db.all(`
SELECT id, title, detected_title, status, last_state, media_type,
encode_plan_json, output_path, raw_path, error_message,
start_time, end_time, created_at, updated_at
FROM jobs
WHERE media_type = 'converter'
ORDER BY created_at DESC
LIMIT 200
`);
return rows.map((r) => ({
...r,
encodePlan: this.safeParseJson(r.encode_plan_json)
}));
}
}
module.exports = new PipelineService();
+13 -2
View File
@@ -19,7 +19,10 @@ const {
defaultCdDir: DEFAULT_CD_DIR,
defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR,
defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR,
defaultDownloadDir: DEFAULT_DOWNLOAD_DIR
defaultDownloadDir: DEFAULT_DOWNLOAD_DIR,
defaultConverterRawDir: DEFAULT_CONVERTER_RAW_DIR,
defaultConverterMovieDir: DEFAULT_CONVERTER_MOVIE_DIR,
defaultConverterAudioDir: DEFAULT_CONVERTER_AUDIO_DIR
} = require('../config');
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
@@ -825,13 +828,21 @@ class SettingsService {
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
downloads: { path: bluray.download_dir },
converter: {
raw: String(map.converter_raw_dir || '').trim() || DEFAULT_CONVERTER_RAW_DIR,
movies: String(map.converter_movie_dir || '').trim() || DEFAULT_CONVERTER_MOVIE_DIR,
audio: String(map.converter_audio_dir || '').trim() || DEFAULT_CONVERTER_AUDIO_DIR
},
defaults: {
raw: DEFAULT_RAW_DIR,
movies: DEFAULT_MOVIE_DIR,
cd: DEFAULT_CD_DIR,
audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR,
audiobookMovies: DEFAULT_AUDIOBOOK_DIR,
downloads: DEFAULT_DOWNLOAD_DIR
downloads: DEFAULT_DOWNLOAD_DIR,
converterRaw: DEFAULT_CONVERTER_RAW_DIR,
converterMovies: DEFAULT_CONVERTER_MOVIE_DIR,
converterAudio: DEFAULT_CONVERTER_AUDIO_DIR
}
};
}