0.12.0-15 Fix DVD Review

This commit is contained in:
2026-03-24 07:05:45 +00:00
parent 3e3f3231be
commit 76b020d5c0
30 changed files with 5148 additions and 44 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "0.12.0-14",
"version": "0.12.0-15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "0.12.0-14",
"version": "0.12.0-15",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.12.0-14",
"version": "0.12.0-15",
"private": true,
"type": "commonjs",
"scripts": {
+4 -1
View File
@@ -26,5 +26,8 @@ module.exports = {
defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd'),
defaultAudiobookRawDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_RAW_DIR, 'output', 'audiobook-raw'),
defaultAudiobookDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_DIR, 'output', 'audiobooks'),
defaultDownloadDir: resolveOutputPath(process.env.DEFAULT_DOWNLOAD_DIR, 'downloads')
defaultDownloadDir: resolveOutputPath(process.env.DEFAULT_DOWNLOAD_DIR, 'downloads'),
defaultConverterRawDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_RAW_DIR, 'output', 'converter-raw'),
defaultConverterMovieDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_MOVIE_DIR, 'output', 'converted-movies'),
defaultConverterAudioDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_AUDIO_DIR, 'output', 'converted-audio')
};
+55 -1
View File
@@ -848,7 +848,12 @@ const SETTINGS_CATEGORY_MOVES = [
{ key: 'output_template_dvd', category: 'Pfade' },
{ key: 'output_template_audiobook', category: 'Pfade' },
{ key: 'output_chapter_template_audiobook', category: 'Pfade' },
{ key: 'audiobook_raw_template', category: 'Pfade' }
{ key: 'audiobook_raw_template', category: 'Pfade' },
{ key: 'converter_raw_dir', category: 'Pfade' },
{ key: 'converter_movie_dir', category: 'Pfade' },
{ key: 'converter_audio_dir', category: 'Pfade' },
{ key: 'converter_output_template_video', category: 'Pfade' },
{ key: 'converter_output_template_audio', category: 'Pfade' }
];
async function migrateSettingsSchemaMetadata(db) {
@@ -968,6 +973,55 @@ async function migrateSettingsSchemaMetadata(db) {
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL)`);
// Converter-Pfade und Eigentümer
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_raw_dir', 'Pfade', 'Converter RAW-Ordner', 'path', 0, 'Quellordner für Converter-Eingabedateien. Leer = Standardpfad.', NULL, '[]', '{}', 106)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_movie_dir', 'Pfade', 'Converter Video Output-Ordner', 'path', 0, 'Zielordner für konvertierte Video-Dateien. Leer = Standardpfad.', NULL, '[]', '{}', 116)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_audio_dir', 'Pfade', 'Converter Audio Output-Ordner', 'path', 0, 'Zielordner für konvertierte Audio-Dateien. Leer = Standardpfad.', NULL, '[]', '{}', 117)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_output_template_video', 'Pfade', 'Converter Video Output-Template', 'string', 0, 'Template für Video-Ausgabedateinamen, z.B. {title}. Leer = Titel des Jobs.', NULL, '[]', '{}', 738)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_video', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_output_template_audio', 'Pfade', 'Converter Audio Output-Template', 'string', 0, 'Template für Audio-Ausgabedateinamen, z.B. {artist} - {title}. Leer = Titel des Jobs.', NULL, '[]', '{}', 739)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_audio', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_raw_dir_owner', 'Pfade', 'Eigentümer Converter RAW-Ordner', 'string', 0, 'Eigentümer der Converter-Eingabedateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1065)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_movie_dir_owner', 'Pfade', 'Eigentümer Converter Video Output-Ordner', 'string', 0, 'Eigentümer der konvertierten Video-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1165)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_audio_dir_owner', 'Pfade', 'Eigentümer Converter Audio Output-Ordner', 'string', 0, 'Eigentümer der konvertierten Audio-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1166)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('download_dir', 'Pfade', 'Download ZIP-Ordner', 'path', 0, 'Zielordner für vorbereitete ZIP-Downloads. Leer = Standardpfad (data/downloads).', NULL, '[]', '{}', 118)`
+5
View File
@@ -13,8 +13,10 @@ const historyRoutes = require('./routes/historyRoutes');
const downloadRoutes = require('./routes/downloadRoutes');
const cronRoutes = require('./routes/cronRoutes');
const runtimeRoutes = require('./routes/runtimeRoutes');
const converterRoutes = require('./routes/converterRoutes');
const wsService = require('./services/websocketService');
const pipelineService = require('./services/pipelineService');
const converterScanService = require('./services/converterScanService');
const cronService = require('./services/cronService');
const downloadService = require('./services/downloadService');
const diskDetectionService = require('./services/diskDetectionService');
@@ -28,6 +30,7 @@ async function start() {
logger.info('backend:start:init');
await initDatabase();
await pipelineService.init();
await converterScanService.startPolling();
await cronService.init();
await downloadService.init();
await coverArtRecoveryService.init();
@@ -47,6 +50,7 @@ async function start() {
app.use('/api/downloads', downloadRoutes);
app.use('/api/crons', cronRoutes);
app.use('/api/runtime', runtimeRoutes);
app.use('/api/converter', converterRoutes);
app.use('/api/thumbnails', express.static(getThumbnailsDir(), { maxAge: '30d', immutable: true }));
app.use(errorHandler);
@@ -86,6 +90,7 @@ async function start() {
const shutdown = () => {
logger.warn('backend:shutdown:received');
converterScanService.stopPolling();
diskDetectionService.stop();
coverArtRecoveryService.stop();
hardwareMonitorService.stop();
+515
View File
@@ -0,0 +1,515 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { SourcePlugin } = require('./PluginBase');
const { spawnTrackedProcess } = require('../services/processRunner');
const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers');
const { ensureDir, sanitizeFileName } = require('../utils/files');
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']);
const AUDIO_OUTPUT_FORMATS = new Set(['flac', 'mp3', 'aac', 'opus', 'wav', 'ogg']);
const VIDEO_OUTPUT_FORMATS = new Set(['mkv', 'mp4', 'm4v']);
/**
* Erkennt den Medientyp anhand der Dateiendung.
* @returns {'video'|'audio'|'iso'|null}
*/
function detectMediaTypeFromPath(filePath) {
const ext = path.extname(String(filePath || '')).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 isAudioExt(ext) {
return AUDIO_EXTENSIONS.has(String(ext).toLowerCase());
}
/**
* Alle Audio-Dateien in einem Verzeichnis auflisten (nicht rekursiv).
*/
function listAudioFilesInDir(dirPath) {
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
return entries
.filter((e) => e.isFile() && isAudioExt(path.extname(e.name).slice(1)))
.map((e) => e.name)
.sort();
} catch (_err) {
return [];
}
}
/**
* ffmpeg-Args zum Konvertieren einer einzelnen Audio-Datei.
*/
function buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, format, opts = {}) {
const args = ['-y', '-i', inputFile];
if (format === 'flac') {
const level = Math.max(0, Math.min(12, Number(opts.flacCompression ?? 5)));
args.push('-codec:a', 'flac', '-compression_level', String(level));
} else if (format === 'mp3') {
const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase();
args.push('-codec:a', 'libmp3lame');
if (mode === 'vbr') {
args.push('-q:a', String(Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4)))));
} else {
args.push('-b:a', `${Number(opts.mp3Bitrate ?? 192)}k`);
}
} else if (format === 'aac') {
args.push('-codec:a', 'aac', '-b:a', `${Number(opts.aacBitrate ?? 256)}k`);
} else if (format === 'opus') {
args.push('-codec:a', 'libopus', '-b:a', `${Number(opts.opusBitrate ?? 160)}k`);
} else if (format === 'ogg') {
args.push('-codec:a', 'libvorbis', '-q:a', String(Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6)))));
} else if (format === 'wav') {
args.push('-codec:a', 'pcm_s16le');
} else {
args.push('-codec:a', 'copy');
}
args.push(outputFile);
return { cmd: ffmpegCmd, args };
}
/**
* Source-Plugin für den Converter.
*
* Unterstützte Eingaben:
* - Video-Dateien (mkv, mp4, avi, mov, ...): HandBrake-Encode
* - Audio-Dateien (flac, mp3, wav, ...): ffmpeg-Encode
* - Audio-Ordner: alle enthaltenen Audio-Dateien mit ffmpeg
* - ISO: MakeMKV-Rip → HandBrake-Encode
*
* Erkennung via mediaProfile === 'converter'.
*
* Erforderliche Felder in ctx.extra für encode():
* inputPath - Absoluter Pfad zur Eingabedatei/-ordner (nach Rip: raw_path)
* outputDir - Ausgabeverzeichnis (für Audio/ISO-Ordner) oder
* outputPath - Ausgabedatei (für einzelne Video/Audio-Dateien)
* encodePlan - aus encode_plan_json
* isCancelled - () => boolean
* onProcessHandle - (handle) => void [optional]
*/
class ConverterPlugin extends SourcePlugin {
get id() { return 'converter'; }
get name() { return 'Converter'; }
get priority() { return 3; }
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
return profile === 'converter';
}
/**
* Analysiert die Eingabedatei/-ordner.
* - Video/ISO: HandBrake --scan für Track-Info
* - Audio: ffprobe für Metadaten
* - Audio-Ordner: Dateiliste + ffprobe auf erste Datei
*/
async analyze(inputPath, job, ctx) {
ctx.markExecution('analyze', {
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
});
const actualInput = ctx.extra?.filePath || inputPath;
const encodePlan = ctx.extra?.encodePlan || {};
const converterMediaType = encodePlan.converterMediaType
|| detectMediaTypeFromPath(actualInput);
ctx.logger.info('converter:analyze:start', {
jobId: job?.id, inputPath: actualInput, converterMediaType
});
const settings = await ctx.settings.getSettingsMap();
const ffprobeCmd = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
const handbrakeCmd = String(settings?.handbrake_command || 'HandBrakeCLI').trim() || 'HandBrakeCLI';
let analysisResult = { converterMediaType, inputPath: actualInput };
if (converterMediaType === 'video' || converterMediaType === 'iso') {
// HandBrake --scan für Track-Informationen
try {
const scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(actualInput);
const captured = await this._runCaptured(scanConfig.cmd, scanConfig.args, { jobId: job?.id });
const hbInfo = this._parseHandBrakeScan(captured.stdout);
analysisResult.handbrakeInfo = hbInfo;
ctx.logger.info('converter:analyze:handbrake-scan', {
jobId: job?.id,
titleCount: hbInfo?.TitleList?.length || 0
});
} catch (scanError) {
ctx.logger.warn('converter:analyze:handbrake-scan-failed', {
jobId: job?.id, error: scanError?.message
});
}
}
if (converterMediaType === 'audio') {
const stat = fs.statSync(actualInput);
if (stat.isDirectory()) {
// Ordner mit Audio-Dateien
const audioFiles = listAudioFilesInDir(actualInput);
analysisResult.isFolder = true;
analysisResult.audioFiles = audioFiles;
// Erster Track: ffprobe für Metadaten
if (audioFiles.length > 0) {
const firstFile = path.join(actualInput, audioFiles[0]);
try {
const meta = await this._ffprobeMetadata(ffprobeCmd, firstFile, { jobId: job?.id });
analysisResult.folderMeta = meta;
} catch (_err) {
// Metadaten optional
}
}
ctx.logger.info('converter:analyze:audio-folder', {
jobId: job?.id, fileCount: audioFiles.length
});
} else {
// Einzelne Audio-Datei
analysisResult.isFolder = false;
try {
const meta = await this._ffprobeMetadata(ffprobeCmd, actualInput, { jobId: job?.id });
analysisResult.fileMeta = meta;
} catch (_err) {
// Metadaten optional
}
ctx.logger.info('converter:analyze:audio-file', { jobId: job?.id });
}
}
return analysisResult;
}
/**
* Rip-Schritt:
* - Video/Audio-Dateien: No-Op (Datei liegt bereits vor)
* - ISO: MakeMKV backup → raw_path
*/
async rip(job, ctx) {
ctx.markExecution('rip', {
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
});
const encodePlan = ctx.extra?.encodePlan || {};
const converterMediaType = encodePlan.converterMediaType;
if (converterMediaType !== 'iso') {
ctx.logger.info('converter:rip:no-op', { jobId: job?.id, converterMediaType });
return;
}
// ISO: MakeMKV backup
const {
inputPath,
rawJobDir,
isCancelled,
onProcessHandle
} = ctx.extra || {};
if (!inputPath) {
throw new Error('ConverterPlugin.rip(): ctx.extra.inputPath fehlt');
}
if (!rawJobDir) {
throw new Error('ConverterPlugin.rip(): ctx.extra.rawJobDir fehlt');
}
ensureDir(rawJobDir);
const settings = await ctx.settings.getSettingsMap();
const makemkvCmd = String(settings?.makemkv_command || 'makemkvcon').trim() || 'makemkvcon';
const registrationKey = String(settings?.makemkv_registration_key || '').trim() || null;
if (registrationKey) {
try {
await this._runCaptured(makemkvCmd, ['reg', registrationKey], { jobId: job?.id });
} catch (_err) {
// Registrierung ist optional
}
}
const args = ['--noscan', '--decrypt', '-r', 'backup', `iso:${inputPath}`, rawJobDir];
ctx.logger.info('converter:rip:iso:start', {
jobId: job?.id, inputPath, rawJobDir, cmd: makemkvCmd
});
ctx.emitProgress(0, 'ISO: MakeMKV Backup läuft …');
const runInfo = await _spawnAndWait({
cmd: makemkvCmd, args,
jobId: job?.id,
progressParser: parseMakeMkvProgress,
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.rip', stage: 'RIPPING' }
});
ctx.logger.info('converter:rip:iso:done', {
jobId: job?.id, exitCode: runInfo?.exitCode ?? null
});
ctx.extra.ripRunInfo = runInfo;
}
/**
* Encode-Schritt:
* - Video: HandBrakeCLI
* - Audio Einzeldatei: ffmpeg
* - Audio Ordner: ffmpeg je Datei
*/
async encode(job, ctx) {
ctx.markExecution('encode', {
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
});
const {
inputPath,
outputPath,
outputDir,
encodePlan = {},
isCancelled,
onProcessHandle
} = ctx.extra || {};
const converterMediaType = encodePlan.converterMediaType;
if (!inputPath) {
throw new Error('ConverterPlugin.encode(): ctx.extra.inputPath fehlt');
}
const settings = await ctx.settings.getSettingsMap();
if (converterMediaType === 'video' || converterMediaType === 'iso') {
await this._encodeVideo({
job, ctx, settings, inputPath, outputPath, encodePlan, isCancelled, onProcessHandle
});
} else if (converterMediaType === 'audio') {
await this._encodeAudio({
job, ctx, settings, inputPath, outputPath, outputDir, encodePlan, isCancelled, onProcessHandle
});
} else {
throw new Error(`ConverterPlugin.encode(): Unbekannter converterMediaType: ${converterMediaType}`);
}
}
// ── Video-Encode (HandBrake) ─────────────────────────────────────────────
async _encodeVideo({ job, ctx, settings, inputPath, outputPath, encodePlan, isCancelled, onProcessHandle }) {
if (!outputPath) {
throw new Error('ConverterPlugin._encodeVideo(): outputPath fehlt');
}
ensureDir(path.dirname(outputPath));
const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, {
trackSelection: encodePlan.trackSelection || null,
titleId: encodePlan.handBrakeTitleId || null,
mediaProfile: null,
settingsMap: settings,
userPreset: encodePlan.userPreset || null
});
ctx.logger.info('converter:encode:video:start', {
jobId: job?.id, cmd: handBrakeConfig.cmd, titleId: encodePlan.handBrakeTitleId || null
});
ctx.emitProgress(0, 'Converter: Video Encoding läuft …');
const runInfo = await _spawnAndWait({
cmd: handBrakeConfig.cmd, args: handBrakeConfig.args,
jobId: job?.id,
progressParser: parseHandBrakeProgress,
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText || `Encoding ${pct}%`),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
});
ctx.logger.info('converter:encode:video:done', {
jobId: job?.id, outputPath, exitCode: runInfo.exitCode
});
ctx.extra.encodeRunInfo = runInfo;
}
// ── Audio-Encode (ffmpeg) ────────────────────────────────────────────────
async _encodeAudio({ job, ctx, settings, inputPath, outputPath, outputDir, encodePlan, isCancelled, onProcessHandle }) {
const ffmpegCmd = String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg';
const outputFormat = String(encodePlan.outputFormat || encodePlan.audioFormat || 'flac').trim().toLowerCase();
const formatOptions = encodePlan.audioFormatOptions || {};
const isFolder = Boolean(encodePlan.isFolder);
if (!AUDIO_OUTPUT_FORMATS.has(outputFormat)) {
throw new Error(`Unbekanntes Audio-Ausgabeformat: ${outputFormat}`);
}
if (isFolder) {
// Ordner-Modus: alle Audio-Dateien im Ordner konvertieren
const audioFiles = encodePlan.audioFiles || listAudioFilesInDir(inputPath);
if (!outputDir) {
throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für Ordner-Job');
}
ensureDir(outputDir);
ctx.logger.info('converter:encode:audio:folder:start', {
jobId: job?.id, inputPath, outputDir, format: outputFormat, fileCount: audioFiles.length
});
for (let i = 0; i < audioFiles.length; i++) {
if (typeof isCancelled === 'function' && isCancelled()) {
const err = new Error('Job wurde vom Benutzer abgebrochen.');
err.statusCode = 409;
throw err;
}
const fileName = audioFiles[i];
const inputFile = path.join(inputPath, fileName);
const baseName = path.basename(fileName, path.extname(fileName));
const outputFile = path.join(outputDir, `${sanitizeFileName(baseName)}.${outputFormat}`);
ctx.logger.info(`converter:encode:audio:track:${i + 1}`, { inputFile, outputFile });
ctx.emitProgress(
Math.round((i / audioFiles.length) * 100),
`Audio: ${i + 1}/${audioFiles.length}${fileName}`
);
const encodeConfig = buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, outputFormat, formatOptions);
await _spawnAndWait({
cmd: encodeConfig.cmd, args: encodeConfig.args,
jobId: job?.id,
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
});
}
ctx.logger.info('converter:encode:audio:folder:done', {
jobId: job?.id, fileCount: audioFiles.length, outputDir
});
ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: audioFiles.length };
} else {
// Einzelne Datei
if (!outputPath) {
throw new Error('ConverterPlugin._encodeAudio(): outputPath fehlt');
}
ensureDir(path.dirname(outputPath));
ctx.logger.info('converter:encode:audio:file:start', {
jobId: job?.id, inputPath, outputPath, format: outputFormat
});
ctx.emitProgress(0, `Audio: Encoding läuft …`);
const encodeConfig = buildAudioFfmpegArgs(ffmpegCmd, inputPath, outputPath, outputFormat, formatOptions);
await _spawnAndWait({
cmd: encodeConfig.cmd, args: encodeConfig.args,
jobId: job?.id,
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
});
ctx.logger.info('converter:encode:audio:file:done', {
jobId: job?.id, outputPath
});
ctx.extra.encodeResult = { outputPath, format: outputFormat };
}
}
// ── Hilfsmethoden ───────────────────────────────────────────────────────
async _runCaptured(cmd, args, options = {}) {
return new Promise((resolve, reject) => {
const { spawn } = require('child_process');
let stdout = '';
let stderr = '';
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
child.on('error', reject);
child.on('close', (code) => resolve({ exitCode: code, stdout, stderr }));
});
}
_parseHandBrakeScan(rawOutput) {
try {
const jsonStart = rawOutput.indexOf('{');
if (jsonStart < 0) return null;
return JSON.parse(rawOutput.slice(jsonStart));
} catch (_err) {
return null;
}
}
async _ffprobeMetadata(ffprobeCmd, filePath, options = {}) {
const args = [
'-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath
];
const result = await this._runCaptured(ffprobeCmd, args, options);
try {
return JSON.parse(result.stdout);
} catch (_err) {
return null;
}
}
getSettingsSchema() {
return [];
}
}
async function _spawnAndWait({ cmd, args, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
if (typeof isCancelled === 'function' && isCancelled()) {
const err = new Error('Job wurde vom Benutzer abgebrochen.');
err.statusCode = 409;
throw err;
}
const handle = spawnTrackedProcess({
cmd,
args,
onStdoutLine: () => {},
onStderrLine: (line) => {
if (progressParser && typeof onProgress === 'function') {
const progress = progressParser(line);
if (progress?.percent != null) {
onProgress(progress.percent, progress.detail || null);
}
}
},
context: context || { jobId }
});
if (typeof onProcessHandle === 'function') {
onProcessHandle(handle);
}
if (typeof isCancelled === 'function' && isCancelled()) {
handle.cancel();
}
try {
const result = await handle.promise;
return result;
} catch (error) {
if (typeof isCancelled === 'function' && isCancelled()) {
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
cancelError.statusCode = 409;
throw cancelError;
}
throw error;
}
}
module.exports = { ConverterPlugin };
+321
View File
@@ -0,0 +1,321 @@
'use strict';
const express = require('express');
const fs = require('fs');
const os = require('os');
const path = require('path');
const multer = require('multer');
const asyncHandler = require('../middleware/asyncHandler');
const pipelineService = require('../services/pipelineService');
const converterScanService = require('../services/converterScanService');
const historyService = require('../services/historyService');
const logger = require('../services/logger').child('CONVERTER_ROUTE');
/** Pfadauflösung mit Traversal-Schutz (wie Klangkiste resolveMediaTarget) */
function resolveTarget(rawDir, input) {
const rel = converterScanService.normalizeRelPath(input != null ? String(input) : '');
if (rel === null) return { error: 'Ungültiger Pfad' };
const absolute = path.join(rawDir, rel || '.');
return { rel: rel || '', absolute };
}
const router = express.Router();
const converterUpload = multer({
dest: path.join(os.tmpdir(), 'ripster-converter-uploads')
});
// ── Scan ──────────────────────────────────────────────────────────────────
/**
* GET /api/converter/tree
* Vollständiger FS-Verzeichnisbaum des converter_raw_dir (keine DB).
*/
router.get(
'/tree',
asyncHandler(async (req, res) => {
logger.debug('get:tree');
const result = await converterScanService.getTree();
res.json(result);
})
);
/**
* GET /api/converter/browse?parent=relPath
* File-Explorer (DB-basiert, wird weiterhin für Job-Zuweisung gebraucht).
*/
router.get(
'/browse',
asyncHandler(async (req, res) => {
const parent = req.query.parent ? String(req.query.parent).trim() : null;
logger.debug('get:browse', { parent });
const entries = await converterScanService.getEntries(parent);
const rawDir = await converterScanService.getRawDir();
res.json({ entries, rawDir });
})
);
/**
* POST /api/converter/scan
* Manuellen Scan des converter_raw_dir auslösen.
*/
router.post(
'/scan',
asyncHandler(async (req, res) => {
logger.info('post:scan');
const result = await converterScanService.scan();
res.json({ result });
})
);
// ── Jobs erstellen ────────────────────────────────────────────────────────
/**
* POST /api/converter/create-jobs
* Body: { entries: [{ relPath, converterMediaType }] }
* Erstellt Jobs aus Scan-Einträgen (File-Explorer-Auswahl).
*/
router.post(
'/create-jobs',
asyncHandler(async (req, res) => {
const entries = Array.isArray(req.body?.entries) ? req.body.entries : [];
if (entries.length === 0) {
const error = new Error('Keine Einträge ausgewählt.');
error.statusCode = 400;
throw error;
}
logger.info('post:create-jobs', { count: entries.length });
const jobs = [];
for (const entry of entries) {
const relPath = String(entry?.relPath || '').trim();
if (!relPath) continue;
const job = await pipelineService.createConverterJobFromEntry(relPath, {
converterMediaType: entry?.converterMediaType || null
});
jobs.push(job);
}
res.json({ jobs });
})
);
/**
* POST /api/converter/upload
* Datei(en) hochladen → Unterordner anlegen (kein Job-Anlegen).
* Gibt { folders: [{folderRelPath, fileCount}] } zurück.
*/
router.post(
'/upload',
converterUpload.array('files', 50),
asyncHandler(async (req, res) => {
const files = Array.isArray(req.files) ? req.files : [];
if (files.length === 0) {
const error = new Error('Keine Dateien hochgeladen.');
error.statusCode = 400;
throw error;
}
const folderName = req.body?.folderName ? String(req.body.folderName).trim() : null;
logger.info('post:upload', {
fileCount: files.length,
folderName,
files: files.map((f) => ({ name: f.originalname, size: f.size }))
});
const result = await pipelineService.uploadConverterFiles(files, { folderName });
res.json(result);
})
);
/**
* POST /api/converter/jobs/from-selection
* Body: { relPaths: string[], audioMode: 'individual'|'shared' }
* Erstellt Jobs aus im File-Explorer ausgewählten Dateien.
*/
router.post(
'/jobs/from-selection',
asyncHandler(async (req, res) => {
const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : [];
const audioMode = String(req.body?.audioMode || 'individual');
if (relPaths.length === 0) {
const error = new Error('Keine Dateien ausgewählt.');
error.statusCode = 400;
throw error;
}
logger.info('post:jobs:from-selection', { count: relPaths.length, audioMode });
const jobs = await pipelineService.createConverterJobsFromSelection(relPaths, audioMode);
res.json({ jobs });
})
);
// ── Datei-Operationen (reines FS, keine DB) ───────────────────────────────
/**
* DELETE /api/converter/files
* Body: { relPath }
* Datei oder Ordner löschen (fs.rmSync, ohne DB).
*/
router.delete(
'/files',
asyncHandler(async (req, res) => {
const rawDir = await converterScanService.getRawDir();
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
const relPath = String(req.body?.relPath || '').trim();
if (!relPath) return res.status(400).json({ detail: 'relPath fehlt.' });
const target = resolveTarget(rawDir, relPath);
if (target.error || !target.rel) return res.status(400).json({ detail: target.error || 'Ungültiger Pfad.' });
logger.info('delete:files', { relPath });
fs.rmSync(target.absolute, { recursive: true, force: true });
res.json({ ok: true });
})
);
/**
* POST /api/converter/files/rename
* Body: { relPath, newName }
* Umbenennen (fs.renameSync, ohne DB).
*/
router.post(
'/files/rename',
asyncHandler(async (req, res) => {
const rawDir = await converterScanService.getRawDir();
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
const relPath = String(req.body?.relPath || '').trim();
const newName = String(req.body?.newName || '').trim();
if (!relPath || !newName) return res.status(400).json({ detail: 'relPath und newName erforderlich.' });
if (newName.includes('/') || newName.includes('\\')) return res.status(400).json({ detail: 'Ungültiger Name.' });
const source = resolveTarget(rawDir, relPath);
if (source.error || !source.rel) return res.status(400).json({ detail: source.error || 'Ungültiger Quellpfad.' });
const parentAbs = path.dirname(source.absolute);
const destAbs = path.join(parentAbs, newName);
logger.info('post:files:rename', { relPath, newName });
fs.renameSync(source.absolute, destAbs);
res.json({ ok: true });
})
);
/**
* POST /api/converter/files/move
* Body: { relPath, targetParentRelPath }
* Verschieben (fs.renameSync, ohne DB). targetParentRelPath = '' → Root.
*/
router.post(
'/files/move',
asyncHandler(async (req, res) => {
const rawDir = await converterScanService.getRawDir();
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
const relPath = String(req.body?.relPath || '').trim();
if (!relPath) return res.status(400).json({ detail: 'relPath erforderlich.' });
const targetParentRelPath = req.body?.targetParentRelPath != null ? String(req.body.targetParentRelPath) : '';
const source = resolveTarget(rawDir, relPath);
if (source.error || !source.rel) return res.status(400).json({ detail: source.error || 'Ungültiger Quellpfad.' });
const targetParent = resolveTarget(rawDir, targetParentRelPath);
if (targetParent.error) return res.status(400).json({ detail: targetParent.error });
const name = path.basename(source.absolute);
const destAbs = path.join(targetParent.absolute, name);
logger.info('post:files:move', { relPath, targetParentRelPath });
fs.renameSync(source.absolute, destAbs);
res.json({ ok: true });
})
);
/**
* POST /api/converter/files/folder
* Body: { parentRelPath, name }
* Neuen Ordner anlegen (fs.mkdirSync, ohne DB).
*/
router.post(
'/files/folder',
asyncHandler(async (req, res) => {
const rawDir = await converterScanService.getRawDir();
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
const parentRelPath = req.body?.parentRelPath != null ? String(req.body.parentRelPath) : '';
const name = String(req.body?.name || '').trim();
if (!name || name.includes('/') || name.includes('\\')) return res.status(400).json({ detail: 'Ungültiger Name.' });
const parent = resolveTarget(rawDir, parentRelPath);
if (parent.error) return res.status(400).json({ detail: parent.error });
logger.info('post:files:folder', { parentRelPath, name });
fs.mkdirSync(path.join(parent.absolute, name), { recursive: true });
res.json({ ok: true });
})
);
// ── Job-Status ────────────────────────────────────────────────────────────
/**
* GET /api/converter/jobs
* Alle Converter-Jobs zurückgeben.
*/
router.get(
'/jobs',
asyncHandler(async (req, res) => {
const jobs = await pipelineService.getConverterJobs();
res.json({ jobs });
})
);
/**
* GET /api/converter/jobs/:jobId
* Einzelnen Converter-Job abrufen.
*/
router.get(
'/jobs/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const job = await historyService.getJobById(jobId);
if (!job) {
const error = new Error(`Job ${jobId} nicht gefunden.`);
error.statusCode = 404;
throw error;
}
res.json({ job });
})
);
/**
* POST /api/converter/jobs/:jobId/start
* Job mit finaler Konfiguration starten.
* Body: { converterMediaType, outputFormat, userPreset, trackSelection, handBrakeTitleId, audioFormatOptions }
*/
router.post(
'/jobs/:jobId/start',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const config = req.body || {};
logger.info('post:jobs:start', {
jobId,
converterMediaType: config.converterMediaType,
outputFormat: config.outputFormat
});
const result = await pipelineService.startConverterJob(jobId, config);
res.json({ result });
})
);
/**
* POST /api/converter/jobs/:jobId/cancel
* Job abbrechen.
*/
router.post(
'/jobs/:jobId/cancel',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
logger.info('post:jobs:cancel', { jobId });
const result = await pipelineService.cancelJob(jobId);
res.json({ result });
})
);
/**
* DELETE /api/converter/jobs/:jobId
* Job aus der DB löschen.
*/
router.delete(
'/jobs/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
logger.info('delete:jobs', { jobId });
await historyService.deleteJob(jobId);
res.json({ ok: true });
})
);
module.exports = router;
@@ -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
}
};
}
+1 -1
View File
@@ -28,7 +28,7 @@ function transliterateForFilename(input) {
function sanitizeFileName(input) {
return transliterateForFilename(String(input || 'untitled'))
.replace(/[^a-zA-Z0-9 ()[\]-]/g, '')
.replace(/[^a-zA-Z0-9 ()[\]_-]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180);