0.12.0-15 Fix DVD Review
This commit is contained in:
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
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user