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
+16 -1
View File
@@ -26,7 +26,22 @@
"Bash(grep -n \"inferMediaProfile\" /home/michael/ripster/backend/src/services/*.js)",
"Bash(grep -rn \"PROCESS_LOG\\\\|LOG_UPDATE\\\\|processLog\" /home/michael/ripster/backend/src --include=*.js)",
"Bash(grep -n \"buildHandBrakeConfig\" /home/michael/ripster/backend/src/services/*.js)",
"Bash(grep -n \"class.*Plugin\\\\|get id\\(\\)\\\\|get name\\(\\)\" /home/michael/ripster/backend/src/plugins/*.js)"
"Bash(grep -n \"class.*Plugin\\\\|get id\\(\\)\\\\|get name\\(\\)\" /home/michael/ripster/backend/src/plugins/*.js)",
"Bash(/home/michael/ripster/db/schema.sql:*)",
"Read(//home/michael/**)",
"Bash(find /home/michael/ripster/AddOns/klangkiste -name *.jsx -o -name *.js -o -name *.vue)",
"Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, label, order_index FROM settings_schema WHERE key LIKE ''converter%'' ORDER BY order_index\")",
"Bash(find /home/michael/ripster/backend -name *.db)",
"Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT count\\(*\\) FROM settings_schema\")",
"Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key FROM settings_schema WHERE key LIKE ''%converter%''\")",
"Bash(grep -n \"renderMediaTree\\\\|renderMediaContent\\\\|activeSection === ''media''\" /home/michael/ripster/AddOns/klangkiste/gui/frontend/src/App.jsx)",
"Bash(grep -n \"activeModal === ''new-folder''\" /home/michael/ripster/AddOns/klangkiste/gui/frontend/src/App.jsx)",
"Bash(sqlite3 /home/michael/ripster_data/ripster.db \"SELECT id, status, last_state, encode_input_path FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")",
"Bash(sqlite3 /opt/ripster_data/ripster.db \"SELECT id, status, last_state FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")",
"Read(//opt/**)",
"Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state, encode_input_path, substr\\(encode_plan_json,1,500\\) as plan FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")",
"Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state FROM jobs ORDER BY id DESC LIMIT 10;\")",
"Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state, encode_input_path FROM jobs WHERE title LIKE ''%Kill%'' OR id IN \\(69, 212, 213\\) ORDER BY id DESC;\")"
]
}
}
+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);
+57
View File
@@ -49,6 +49,7 @@ CREATE TABLE jobs (
encode_input_path TEXT,
encode_review_confirmed INTEGER DEFAULT 0,
aax_checksum TEXT,
media_type TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE SET NULL
@@ -190,6 +191,21 @@ CREATE TABLE IF NOT EXISTS user_prefs (
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE converter_scan_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rel_path TEXT NOT NULL UNIQUE,
entry_type TEXT NOT NULL,
file_size INTEGER,
detected_media_type TEXT,
detected_format TEXT,
job_id INTEGER,
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE SET NULL
);
CREATE INDEX idx_converter_scan_entries_rel_path ON converter_scan_entries(rel_path);
CREATE INDEX idx_converter_scan_entries_job_id ON converter_scan_entries(job_id);
-- =============================================================================
-- Default Settings Seed
-- =============================================================================
@@ -537,3 +553,44 @@ INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_reen
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('pushover_notify_reencode_finished', 'Benachrichtigungen', 'Bei Re-Encode Erfolg senden', 'boolean', 1, 'Sendet bei erfolgreichem RAW Re-Encode.', 'true', '[]', '{}', 640);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_reencode_finished', 'true');
-- =============================================================================
-- Converter Settings
-- =============================================================================
-- Converter Pfade und Templates (Kategorie: Pfade)
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, 'Eingangsordner für den Converter (Import-Scan und Datei-Uploads). Jeder Upload erhält einen Unterordner. Leer = Standardpfad (data/output/converter-raw).', NULL, '[]', '{}', 800);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir', NULL);
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 Ausgabe (Video)', 'path', 0, 'Ausgabepfad für konvertierte Video-Dateien. Leer = Standardpfad (data/output/converted-movies).', NULL, '[]', '{}', 801);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir', NULL);
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 Ausgabe (Audio)', 'path', 0, 'Ausgabepfad für konvertierte Audio-Dateien. Leer = Standardpfad (data/output/converted-audio).', NULL, '[]', '{}', 802);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir', NULL);
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', 'Output-Template (Video)', 'string', 1, 'Dateiname-Template für konvertierte Video-Dateien (ohne Endung). Platzhalter: {title}, {year}.', '{title}', '[]', '{"minLength":1}', 810);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_video', '{title}');
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', 'Output-Template (Audio)', 'string', 1, 'Dateiname-Template für konvertierte Audio-Dateien (ohne Endung). Platzhalter: {artist}, {title}, {album}, {year}, {trackNr}.', '{artist} - {title}', '[]', '{"minLength":1}', 811);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_audio', '{artist} - {title}');
-- Migration: Converter-Pfad-Settings in Kategorie 'Pfade' verschieben (falls bereits in DB vorhanden)
UPDATE settings_schema SET category = 'Pfade' WHERE key IN ('converter_raw_dir', 'converter_movie_dir', 'converter_audio_dir', 'converter_output_template_video', 'converter_output_template_audio') AND category = 'Converter';
-- Converter Scan
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_scan_extensions', 'Converter', 'Erlaubte Datei-Endungen', 'string', 1, 'Komma-getrennte Liste erlaubter Dateiendungen für den Scan (ohne Punkt).', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,aac,wav,m4a,ogg,opus', '[]', '{"minLength":1}', 820);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_scan_extensions', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,aac,wav,m4a,ogg,opus');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_polling_enabled', 'Converter', 'Auto-Scan (Polling)', 'boolean', 1, 'Converter Raw-Ordner automatisch in regelmäßigen Abständen scannen.', 'false', '[]', '{}', 830);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_polling_enabled', 'false');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_polling_interval', 'Converter', 'Polling-Intervall (Sekunden)', 'number', 1, 'Intervall für automatischen Scan des Converter-Ordners.', '300', '[]', '{"min":30,"max":86400}', 840);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_polling_interval', '300');
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.12.0-14",
"version": "0.12.0-15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.12.0-14",
"version": "0.12.0-15",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.12.0-14",
"version": "0.12.0-15",
"private": true,
"type": "module",
"scripts": {
+3
View File
@@ -11,6 +11,7 @@ import SettingsPage from './pages/SettingsPage';
import HistoryPage from './pages/HistoryPage';
import DatabasePage from './pages/DatabasePage';
import DownloadsPage from './pages/DownloadsPage';
import ConverterPage from './pages/ConverterPage';
function normalizeJobId(value) {
const parsed = Number(value);
@@ -524,6 +525,7 @@ function App() {
const nav = [
{ label: 'Dashboard', path: '/' },
{ label: 'Converter', path: '/converter' },
{ label: 'Settings', path: '/settings' },
{ label: 'Historie', path: '/history' },
{ label: 'Downloads', path: '/downloads' },
@@ -653,6 +655,7 @@ function App() {
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
<Route path="database" element={<DatabasePage />} />
<Route path="converter" element={<ConverterPage />} />
</Route>
</Routes>
</main>
+63
View File
@@ -895,6 +895,69 @@ export const api = {
method: 'POST',
body: JSON.stringify({ cronExpression })
});
},
// ── Converter ──────────────────────────────────────────────────────────────
converterGetTree() {
return request('/converter/tree');
},
converterBrowse(parent = null) {
const q = parent ? `?parent=${encodeURIComponent(parent)}` : '';
return request(`/converter/browse${q}`);
},
converterScan() {
return request('/converter/scan', { method: 'POST' });
},
converterCreateJobs(entries) {
return request('/converter/create-jobs', {
method: 'POST',
body: JSON.stringify({ entries })
});
},
converterCreateJobsFromSelection(relPaths, audioMode = 'individual') {
return request('/converter/jobs/from-selection', {
method: 'POST',
body: JSON.stringify({ relPaths, audioMode })
});
},
converterUploadFiles(formData) {
return request('/converter/upload', { method: 'POST', body: formData });
},
getConverterJobs() {
return request('/converter/jobs');
},
startConverterJob(jobId, config = {}) {
afterMutationInvalidate(['/converter/jobs']);
return request(`/converter/jobs/${encodeURIComponent(jobId)}/start`, {
method: 'POST',
body: JSON.stringify(config)
});
},
cancelConverterJob(jobId) {
afterMutationInvalidate(['/converter/jobs']);
return request(`/converter/jobs/${encodeURIComponent(jobId)}/cancel`, {
method: 'POST'
});
},
deleteConverterJob(jobId) {
afterMutationInvalidate(['/converter/jobs']);
return request(`/converter/jobs/${encodeURIComponent(jobId)}`, {
method: 'DELETE'
});
},
// ── Converter Datei-Operationen ──────────────────────────────────────────
converterDeleteFile(relPath) {
return request('/converter/files', { method: 'DELETE', body: JSON.stringify({ relPath }) });
},
converterRenameFile(relPath, newName) {
return request('/converter/files/rename', { method: 'POST', body: JSON.stringify({ relPath, newName }) });
},
converterMoveFile(relPath, targetParentRelPath) {
return request('/converter/files/move', { method: 'POST', body: JSON.stringify({ relPath, targetParentRelPath }) });
},
converterCreateFolder(parentRelPath, name) {
return request('/converter/files/folder', { method: 'POST', body: JSON.stringify({ parentRelPath, name }) });
}
};
@@ -0,0 +1,725 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { ProgressSpinner } from 'primereact/progressspinner';
import { Dialog } from 'primereact/dialog';
import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
import { Toast } from 'primereact/toast';
import { api } from '../api/client';
// Hilfsfunktionen
function formatBytes(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0; let v = n;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(i <= 1 ? 0 : 1)} ${units[i]}`;
}
function mediaTypeBadge(type) {
if (!type) return null;
const map = { video: { label: 'Video', severity: 'info' }, audio: { label: 'Audio', severity: 'success' }, iso: { label: 'ISO', severity: 'warning' } };
const m = map[type] || { label: type, severity: 'secondary' };
return <Tag value={m.label} severity={m.severity} />;
}
/** Navigiert den Baum per Pfad-String */
function getNodeByPath(root, targetPath) {
if (!root) return null;
if ((root.path || '') === (targetPath || '')) return root;
for (const child of (root.children || [])) {
if (child.type === 'folder') {
const found = getNodeByPath(child, targetPath);
if (found) return found;
}
}
return null;
}
/** Kinder des aktuellen Knotens (Ordner zuerst, alphabetisch) */
function listChildren(node) {
if (!node || !Array.isArray(node.children)) return [];
return node.children; // buildRawTree liefert bereits sortiert
}
/** Breadcrumb aus Pfad-String */
function buildBreadcrumb(pathStr) {
if (!pathStr) return [];
const parts = String(pathStr).split('/').filter(Boolean);
return parts.map((part, i) => ({ name: part, path: parts.slice(0, i + 1).join('/') }));
}
/** Baum nach Ordnername filtern (nur Ordner in der Seitenleiste) */
function filterTree(node, query) {
if (!node || node.type !== 'folder') return null;
if (!query || !query.trim()) return node;
const q = query.toLowerCase();
const filteredChildren = (node.children || [])
.filter((c) => c.type === 'folder')
.map((c) => filterTree(c, query))
.filter(Boolean);
const nameMatches = node.name.toLowerCase().includes(q);
if (nameMatches || filteredChildren.length > 0) {
return { ...node, children: filteredChildren };
}
return null;
}
/** Alle Datei-Pfade innerhalb eines Knotens rekursiv sammeln */
function collectDescendantFilePaths(node) {
const result = [];
for (const child of (node?.children || [])) {
if (child.type === 'file') result.push(child.path || '');
else if (child.type === 'folder') result.push(...collectDescendantFilePaths(child));
}
return result;
}
/** Nur "Wurzel"-Selektionen: Pfade, die keinen ausgewählten Elternpfad haben */
function getRootSelections(paths) {
const set = new Set(paths);
return paths.filter((p) =>
!paths.some((other) => other !== p && p.startsWith(other + '/') && set.has(other))
);
}
/** Alle Ordner-Pfade für das Verschieben-Dropdown sammeln */
function collectFolderPaths(node, result = []) {
if (!node || node.type !== 'folder') return result;
result.push({ label: node.path ? node.path : '/ (Root)', value: node.path || '' });
for (const child of (node.children || [])) {
if (child.type === 'folder') collectFolderPaths(child, result);
}
return result;
}
// Hauptkomponente
export default function ConverterFileExplorer({ onSelectionChange, refreshToken, navigateToPath }) {
const toastRef = useRef(null);
const explorerRef = useRef(null);
// Daten
const [tree, setTree] = useState(null);
const [rawDir, setRawDir] = useState(null);
const [loading, setLoading] = useState(false);
// Navigation
const [currentPath, setCurrentPath] = useState('');
const [expandedFolders, setExpandedFolders] = useState(() => new Set(['']));
// Auswahl
const [selectedPaths, setSelectedPaths] = useState([]);
// Seitenleiste
const [sidebarQuery, setSidebarQuery] = useState('');
// Modals
const [activeModal, setActiveModal] = useState('');
const [newFolderName, setNewFolderName] = useState('');
const [renameName, setRenameName] = useState('');
const [moveTarget, setMoveTarget] = useState('');
const [busy, setBusy] = useState(false);
// Stabile Ref für onSelectionChange
const onSelectionChangeRef = useRef(onSelectionChange);
useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]);
// Baum laden
const loadTree = useCallback(async () => {
setLoading(true);
try {
const data = await api.converterGetTree();
setTree(data.tree || null);
setRawDir(data.rawDir || null);
} catch (err) {
console.error('ConverterFileExplorer: tree load error', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadTree(); }, [loadTree, refreshToken]);
// Auto-Navigation nach Upload: Baum neu laden, dann in Zielordner navigieren
// navigateToPath ist { path: string, ts: number } damit jeder Upload einen neuen Trigger auslöst
useEffect(() => {
if (!navigateToPath?.path) return;
loadTree().then(() => {
navigateTo(navigateToPath.path);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigateToPath]);
// Auswahl an Eltern melden: nur Wurzel-Selektionen (keine redundanten Kind-Pfade)
useEffect(() => {
if (!tree) return;
const rootPaths = getRootSelections(selectedPaths);
const report = rootPaths.map((p) => {
const node = getNodeByPath(tree, p);
if (!node) return null;
return {
relPath: node.path,
entryType: node.type === 'folder' ? 'directory' : 'file',
detectedMediaType: node.detectedMediaType || null,
detectedFormat: node.detectedFormat || null
};
}).filter(Boolean);
onSelectionChangeRef.current?.(report);
}, [selectedPaths, tree]);
// Outside-Click Auswahl aufheben
useEffect(() => {
function handleOutside(event) {
if (activeModal) return;
if (!explorerRef.current) return;
if (!explorerRef.current.contains(event.target)) {
setSelectedPaths([]);
}
}
window.addEventListener('mousedown', handleOutside);
return () => window.removeEventListener('mousedown', handleOutside);
}, [activeModal]);
// Navigation
const currentNode = getNodeByPath(tree, currentPath);
const currentItems = listChildren(currentNode);
const filteredTree = filterTree(tree, sidebarQuery);
const topLevelFolders = tree ? (tree.children || []).filter((c) => c.type === 'folder') : [];
function navigateTo(pathStr) {
setCurrentPath(pathStr || '');
setSelectedPaths([]);
setExpandedFolders((prev) => new Set(prev).add(pathStr || ''));
}
function handleGoUp() {
if (!currentPath) return;
const parts = currentPath.split('/').filter(Boolean);
parts.pop();
navigateTo(parts.join('/'));
}
function toggleFolder(pathValue) {
const key = pathValue || '';
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
// Auswahl
/** Gibt true wenn item.path direkt oder als Kind eines ausgewählten Ordners selektiert ist */
function isSelected(pathValue) {
const p = pathValue || '';
if (selectedPaths.includes(p)) return true;
return selectedPaths.some((sel) => p.startsWith(sel + '/'));
}
/** Unbestimmter Zustand: Ordner, von dem nur Teile selektiert sind */
function isIndeterminate(item) {
if (item.type !== 'folder') return false;
const p = item.path || '';
if (selectedPaths.includes(p)) return false;
const folderNode = getNodeByPath(tree, p);
const allFiles = collectDescendantFilePaths(folderNode);
return allFiles.length > 0 && allFiles.some((fp) => selectedPaths.includes(fp));
}
function handleCheckboxChange(item, checked) {
const p = item.path || '';
if (item.type === 'folder') {
const folderNode = getNodeByPath(tree, p);
const descendantFiles = collectDescendantFilePaths(folderNode);
if (checked) {
setSelectedPaths((prev) => Array.from(new Set([...prev, p, ...descendantFiles])));
} else {
const toRemove = new Set([p, ...descendantFiles]);
setSelectedPaths((prev) => prev.filter((x) => !toRemove.has(x)));
}
} else {
const allFilesInCurrentView = currentItems.filter((i) => i.type === 'file').map((i) => i.path || '');
if (checked) {
setSelectedPaths((prev) => {
const next = Array.from(new Set([...prev, p]));
// Wenn alle Dateien im aktuellen Ordner gewählt Ordner auch auswählen
if (currentPath && allFilesInCurrentView.length > 0 &&
allFilesInCurrentView.every((fp) => next.includes(fp)) &&
!next.includes(currentPath)) {
return [...next, currentPath];
}
return next;
});
} else {
setSelectedPaths((prev) => {
const next = prev.filter((x) => x !== p);
// Ordner abwählen wenn eine seiner Dateien abgewählt wird
if (currentPath && next.includes(currentPath)) {
return next.filter((x) => x !== currentPath);
}
return next;
});
}
}
}
function handleOpen(item) {
if (item.type !== 'folder') return;
navigateTo(item.path || '');
}
// Datei-Operationen
async function handleCreateFolder() {
const name = newFolderName.trim();
if (!name) return;
setBusy(true);
try {
await api.converterCreateFolder(currentPath, name);
toastRef.current?.show({ severity: 'success', summary: 'Ordner erstellt', detail: name, life: 2500 });
setNewFolderName('');
setActiveModal('');
await loadTree();
} catch (err) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
} finally { setBusy(false); }
}
async function handleRename() {
if (!selectedPaths.length) return;
const name = renameName.trim();
if (!name) return;
setBusy(true);
try {
await api.converterRenameFile(rootSelected[0], name);
toastRef.current?.show({ severity: 'success', summary: 'Umbenannt', detail: `${name}`, life: 2500 });
setRenameName('');
setActiveModal('');
setSelectedPaths([]);
await loadTree();
} catch (err) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
} finally { setBusy(false); }
}
async function handleDeleteSelected() {
if (!rootSelected.length) return;
setBusy(true);
try {
for (const p of rootSelected) {
await api.converterDeleteFile(p);
}
toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', detail: `${rootSelected.length} Eintrag/Einträge`, life: 2500 });
setSelectedPaths([]);
setActiveModal('');
await loadTree();
} catch (err) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
} finally { setBusy(false); }
}
async function handleMoveSelected() {
if (!selectedPaths.length) return;
setBusy(true);
try {
const isRoot = moveTarget === '' || moveTarget === '__root__';
await api.converterMoveFile(rootSelected[0], isRoot ? '' : moveTarget);
toastRef.current?.show({ severity: 'success', summary: 'Verschoben', life: 2500 });
setMoveTarget('');
setSelectedPaths([]);
setActiveModal('');
await loadTree();
} catch (err) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: err.message, life: 4000 });
} finally { setBusy(false); }
}
// Ordnerbaum (Seitenleiste)
function renderFolderTree(node, depth) {
if (!node || node.type !== 'folder') return null;
const isActive = (node.path || '') === currentPath;
const key = node.path || '';
const isExpanded = expandedFolders.has(key) || depth === 0;
const childFolders = (node.children || []).filter((c) => c.type === 'folder');
const hasChildren = childFolders.length > 0;
return (
<div key={key || '__root__'} className={`tree-node depth-${depth}`}>
<div
className={`tree-row folder${isActive ? ' active' : ''}`}
onClick={() => handleOpen(node)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') handleOpen(node); }}
>
{hasChildren ? (
<div
className="tree-caret"
role="button"
tabIndex={0}
onClick={(e) => { e.stopPropagation(); toggleFolder(node.path || ''); }}
onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); toggleFolder(node.path || ''); } }}
>
<i className={`pi ${isExpanded ? 'pi-chevron-down' : 'pi-chevron-right'}`} style={{ fontSize: '0.65rem' }} />
</div>
) : (
<span className="tree-caret disabled" aria-hidden="true" />
)}
<span className="tree-icon folder">
<i className="pi pi-folder" />
</span>
<span className="tree-label">{node.name || 'raw'}</span>
</div>
{isExpanded && hasChildren && childFolders.map((child) => renderFolderTree(child, depth + 1))}
</div>
);
}
// Abgeleitete Werte
const allFolders = tree ? collectFolderPaths(tree) : [{ label: '/ (Root)', value: '' }];
const breadcrumb = buildBreadcrumb(currentPath);
const rootSelected = getRootSelections(selectedPaths);
const canRename = rootSelected.length === 1;
const canDelete = selectedPaths.length > 0;
const canMove = rootSelected.length === 1;
// Render
return (
<div className="cfx-wrap">
<Toast ref={toastRef} position="top-right" />
{/* Obere Leiste: rawdir-Pfad + Aktualisieren */}
<div className="cfx-top-bar">
<span className="cfx-rawdir" title={rawDir || ''}>
{rawDir
? <><i className="pi pi-server" style={{ marginRight: 4 }} />{rawDir}</>
: <em>Kein Ordner konfiguriert</em>}
</span>
<Button
label={loading ? 'Laden …' : 'Aktualisieren'}
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
size="small"
outlined
disabled={loading}
onClick={loadTree}
/>
</div>
{loading && !tree ? (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<ProgressSpinner style={{ width: 32, height: 32 }} />
</div>
) : !rawDir ? (
<div style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--rip-muted)', fontSize: '0.85rem' }}>
Bitte Converter Raw-Ordner in den Settings konfigurieren.
</div>
) : (
<div className="explorer" ref={explorerRef}>
{/* Linke Seitenleiste */}
<div className="explorer-sidebar">
<div className="explorer-toolbar sidebar-toolbar">
<InputText
className="sidebar-search p-inputtext-sm"
type="search"
placeholder="Suchen..."
value={sidebarQuery}
onChange={(e) => setSidebarQuery(e.target.value)}
aria-label="Ordner suchen"
/>
</div>
<div className="sidebar-tree">
{topLevelFolders.length === 0 && !sidebarQuery && (
<div style={{ padding: '0.5rem', fontSize: '0.8rem', color: 'var(--rip-muted)', fontStyle: 'italic' }}>
Keine Ordner gefunden.
</div>
)}
{filteredTree && renderFolderTree(filteredTree, 0)}
</div>
</div>
{/* Rechter Hauptbereich */}
<div className="explorer-main">
<div className="explorer-toolbar">
{/* ArrowUp */}
<button
type="button"
className="icon-button"
onClick={handleGoUp}
disabled={!currentPath}
title="Eine Ebene hoch"
aria-label="Hoch"
>
<i className="pi pi-arrow-up" />
</button>
{/* Breadcrumb */}
<div className="explorer-path">
<span
className="breadcrumb-root path-link"
onClick={() => navigateTo('')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') navigateTo(''); }}
>
raw
</span>
{breadcrumb.map((crumb) => (
<span
key={crumb.path}
className="path-link"
onClick={() => navigateTo(crumb.path)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') navigateTo(crumb.path); }}
>
/ {crumb.name}
</span>
))}
</div>
{/* Toolbar-Aktionen rechts */}
<div className="toolbar-actions">
<button
type="button"
className="icon-button"
onClick={() => { setNewFolderName(''); setActiveModal('new-folder'); }}
title="Neuer Ordner"
aria-label="Neuer Ordner"
>
<i className="pi pi-folder-plus" />
</button>
<button
type="button"
className="icon-button"
onClick={() => {
const node = getNodeByPath(tree, rootSelected[0]);
setRenameName(node?.name || '');
setActiveModal('rename');
}}
disabled={!canRename}
title="Umbenennen"
aria-label="Umbenennen"
>
<i className="pi pi-pencil" />
</button>
<button
type="button"
className="icon-button danger"
onClick={() => setActiveModal('delete')}
disabled={!canDelete}
title="Löschen"
aria-label="Löschen"
>
<i className="pi pi-trash" />
</button>
<button
type="button"
className="icon-button"
onClick={() => { setMoveTarget(''); setActiveModal('move'); }}
disabled={!canMove}
title="Verschieben"
aria-label="Verschieben"
>
<i className="pi pi-arrow-right" />
</button>
</div>
</div>
{/* Dateiliste */}
<div className="explorer-list">
{/* Header */}
<div className="explorer-row header">
<span />
<span>Name</span>
<span>Typ</span>
<span>Größe</span>
</div>
{/* Zeilen */}
{currentItems.length === 0 ? (
<div style={{ padding: '1.5rem', textAlign: 'center', fontSize: '0.82rem', color: 'var(--rip-muted)', fontStyle: 'italic' }}>
Leer.
</div>
) : currentItems.map((item) => {
const sel = isSelected(item.path);
const indet = isIndeterminate(item);
return (
<div
key={item.path}
className={`explorer-row${sel ? ' selected' : ''}`}
onClick={() => {
if (item.type === 'folder') {
handleOpen(item);
} else {
handleCheckboxChange(item, !sel);
}
}}
role="row"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') item.type === 'folder' ? handleOpen(item) : handleCheckboxChange(item, !sel); }}
>
<span className="row-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={sel}
ref={(el) => { if (el) el.indeterminate = indet; }}
onChange={(e) => handleCheckboxChange(item, e.target.checked)}
aria-label={`${item.name} auswählen`}
/>
</span>
<span className="row-name">
<span className="row-icon">
<i className={`pi ${item.type === 'folder' ? 'pi-folder' : 'pi-file'}`} />
</span>
{item.name}
</span>
<span>
{item.type === 'folder'
? <Tag value="Ordner" severity="secondary" />
: (item.detectedMediaType ? mediaTypeBadge(item.detectedMediaType) : <span style={{ color: 'var(--rip-muted)', fontSize: '0.78rem' }}>Datei</span>)}
</span>
<span style={{ textAlign: 'right', color: 'var(--rip-muted)', fontSize: '0.78rem' }}>
{formatBytes(item.size)}
</span>
</div>
);
})}
{/* Footer: Auswahl-Anzahl */}
{selectedPaths.length > 0 && (
<div className="explorer-row footer">
<span />
<span />
<span />
<span className="footer-count">{getRootSelections(selectedPaths).length} ausgewählt</span>
</div>
)}
</div>
</div>
{/* Untere Leiste (ganzeBreite) */}
<div className="explorer-footer">
<span>{currentItems.length} Einträge{rawDir ? ` · ${rawDir}` : ''}</span>
</div>
</div>
)}
{/* ── Dialoge ─────────────────────────────────────────────────────────── */}
{/* Neuer Ordner */}
<Dialog
header="Neuen Ordner erstellen"
visible={activeModal === 'new-folder'}
onHide={() => { setActiveModal(''); setNewFolderName(''); }}
style={{ width: '380px' }}
footer={
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={() => { setActiveModal(''); setNewFolderName(''); }} disabled={busy} />
<Button label="Erstellen" icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-check'} disabled={busy || !newFolderName.trim()} onClick={handleCreateFolder} />
</div>
}
modal
>
<div className="field">
<label>Ordnername</label>
<InputText
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateFolder(); }}
autoFocus
style={{ width: '100%', marginTop: 6 }}
placeholder={`In: ${currentPath || '/'}`}
/>
</div>
</Dialog>
{/* Umbenennen */}
<Dialog
header="Umbenennen"
visible={activeModal === 'rename'}
onHide={() => { setActiveModal(''); setRenameName(''); }}
style={{ width: '380px' }}
footer={
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={() => { setActiveModal(''); setRenameName(''); }} disabled={busy} />
<Button label="Umbenennen" icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-check'} disabled={busy || !renameName.trim()} onClick={handleRename} />
</div>
}
modal
>
<div className="field">
<label>Neuer Name</label>
<InputText
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); }}
autoFocus
style={{ width: '100%', marginTop: 6 }}
/>
</div>
</Dialog>
{/* Löschen */}
<Dialog
header="Löschen bestätigen"
visible={activeModal === 'delete'}
onHide={() => setActiveModal('')}
style={{ width: '380px' }}
footer={
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={() => setActiveModal('')} disabled={busy} />
<Button label="Löschen" severity="danger" icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-trash'} disabled={busy} onClick={handleDeleteSelected} />
</div>
}
modal
>
<p style={{ margin: 0 }}>
{rootSelected.length === 1
? <><strong>{getNodeByPath(tree, rootSelected[0])?.name}</strong> wirklich löschen?</>
: <>{rootSelected.length} Einträge wirklich löschen?</>}
</p>
</Dialog>
{/* Verschieben */}
<Dialog
header="Verschieben"
visible={activeModal === 'move'}
onHide={() => setActiveModal('')}
style={{ width: '420px' }}
footer={
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={() => setActiveModal('')} disabled={busy} />
<Button label="Verschieben" icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-check'} disabled={busy} onClick={handleMoveSelected} />
</div>
}
modal
>
<div className="field">
<label>Zielordner</label>
<Dropdown
value={moveTarget}
options={allFolders.filter((f) => {
const src = rootSelected[0] || '';
return f.value !== src && !f.value.startsWith(src + '/');
})}
onChange={(e) => setMoveTarget(e.value)}
style={{ width: '100%', marginTop: 6 }}
placeholder="Zielordner auswählen …"
/>
</div>
</Dialog>
</div>
);
}
@@ -0,0 +1,440 @@
import { useEffect, useState } from 'react';
import { Button } from 'primereact/button';
import { Dropdown } from 'primereact/dropdown';
import { InputNumber } from 'primereact/inputnumber';
import { SelectButton } from 'primereact/selectbutton';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
import { Message } from 'primereact/message';
import { api } from '../api/client';
const VIDEO_OUTPUT_FORMATS = [
{ label: 'MKV', value: 'mkv' },
{ label: 'MP4', value: 'mp4' },
{ label: 'M4V', value: 'm4v' }
];
const AUDIO_OUTPUT_FORMATS = [
{ label: 'FLAC', value: 'flac' },
{ label: 'MP3', value: 'mp3' },
{ label: 'AAC', value: 'aac' },
{ label: 'Opus', value: 'opus' },
{ label: 'OGG', value: 'ogg' },
{ label: 'WAV', value: 'wav' }
];
const MP3_MODES = [
{ label: 'CBR', value: 'cbr' },
{ label: 'VBR', value: 'vbr' }
];
function getStatusMeta(status) {
const s = String(status || '').toUpperCase();
const map = {
READY_TO_START: { label: 'Bereit', severity: 'warning', icon: 'pi-clock' },
ANALYZING: { label: 'Analysiere', severity: 'info', icon: 'pi-spin pi-spinner' },
RIPPING: { label: 'Rippen', severity: 'info', icon: 'pi-spin pi-spinner' },
ENCODING: { label: 'Encoding', severity: 'info', icon: 'pi-spin pi-spinner' },
MEDIAINFO_CHECK:{ label: 'MediaInfo', severity: 'info', icon: 'pi-spin pi-spinner' },
DONE: { label: 'Fertig', severity: 'success', icon: 'pi-check' },
ERROR: { label: 'Fehler', severity: 'danger', icon: 'pi-exclamation-triangle' },
CANCELLED: { label: 'Abgebrochen', severity: 'secondary', icon: 'pi-times' }
};
return map[s] || { label: s, severity: 'secondary', icon: 'pi-question' };
}
function mediaTypeBadge(type) {
const map = {
video: { label: 'Video', severity: 'info' },
audio: { label: 'Audio', severity: 'success' },
iso: { label: 'ISO', severity: 'warning' }
};
const m = map[type] || { label: type || '?', severity: 'secondary' };
return <Tag value={m.label} severity={m.severity} style={{ fontSize: 11 }} />;
}
function isActiveStatus(status) {
return ['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK'].includes(String(status || '').toUpperCase());
}
function isTerminal(status) {
return ['DONE', 'ERROR', 'CANCELLED'].includes(String(status || '').toUpperCase());
}
/**
* Inline-Konfigurationsbereich für READY_TO_START Jobs.
* Zeigt alle nötigen Optionen direkt in der Karte an.
*/
function InlineConfig({ job, plan, onStarted }) {
const converterMediaType = plan?.converterMediaType || 'video';
const isVideo = converterMediaType === 'video' || converterMediaType === 'iso';
const isAudio = converterMediaType === 'audio';
const [outputFormat, setOutputFormat] = useState(isAudio ? 'flac' : 'mkv');
const [userPresets, setUserPresets] = useState([]);
const [hbPresets, setHbPresets] = useState([]);
const [selectedUserPreset, setSelectedUserPreset] = useState(null);
const [selectedHbPreset, setSelectedHbPreset] = useState('');
const [audioFormatOptions, setAudioFormatOptions] = useState({
flacCompression: 5,
mp3Mode: 'cbr',
mp3Bitrate: 192,
mp3Quality: 4,
aacBitrate: 256,
opusBitrate: 160,
oggQuality: 6
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
// Presets laden
useEffect(() => {
if (!isVideo) return;
api.getUserPresets?.('video')
.then((d) => setUserPresets(Array.isArray(d?.presets) ? d.presets : []))
.catch(() => setUserPresets([]));
api.getHandBrakePresets?.()
.then((d) => setHbPresets(Array.isArray(d?.presets) ? d.presets : []))
.catch(() => setHbPresets([]));
}, [isVideo]);
const handleStart = async () => {
setBusy(true);
setError(null);
try {
let userPreset = null;
if (selectedUserPreset) {
const found = userPresets.find((p) => p.id === selectedUserPreset);
if (found) userPreset = { id: found.id, handbrakePreset: found.handbrake_preset || null, extraArgs: found.extra_args || '' };
} else if (selectedHbPreset) {
userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' };
}
await api.startConverterJob(job.id, {
converterMediaType,
outputFormat,
userPreset,
audioFormatOptions: isAudio ? audioFormatOptions : undefined
});
onStarted?.(job.id);
} catch (err) {
setError(err.message || 'Fehler beim Starten.');
setBusy(false);
}
};
const outputFormats = isVideo ? VIDEO_OUTPUT_FORMATS : AUDIO_OUTPUT_FORMATS;
return (
<div className="cjc-config">
{error && (
<Message severity="error" text={error} style={{ marginBottom: 10, width: '100%' }} />
)}
<div className="cjc-config-row">
{/* Medientyp (read-only) */}
<div className="cjc-config-field">
<label className="cjc-config-label">Medientyp</label>
<div className="cjc-config-value">
{mediaTypeBadge(converterMediaType)}
{converterMediaType === 'iso' && (
<small style={{ marginLeft: 6, color: 'var(--text-color-secondary)' }}>MakeMKV HandBrake</small>
)}
</div>
</div>
{/* Ausgabeformat */}
<div className="cjc-config-field">
<label className="cjc-config-label">Ausgabeformat</label>
<Dropdown
value={outputFormat}
options={outputFormats}
onChange={(e) => setOutputFormat(e.value)}
style={{ width: '100%' }}
/>
</div>
</div>
{/* Video: Presets */}
{isVideo && (
<div className="cjc-config-row">
<div className="cjc-config-field">
<label className="cjc-config-label">User-Preset</label>
<Dropdown
value={selectedUserPreset}
options={[
{ label: '— Kein User-Preset —', value: null },
...userPresets.map((p) => ({ label: p.name, value: p.id }))
]}
onChange={(e) => { setSelectedUserPreset(e.value); if (e.value) setSelectedHbPreset(''); }}
style={{ width: '100%' }}
/>
</div>
{!selectedUserPreset && (
<div className="cjc-config-field">
<label className="cjc-config-label">HandBrake-Preset</label>
<Dropdown
value={selectedHbPreset}
options={[
{ label: '— Kein Preset (Standard) —', value: '' },
...hbPresets.map((p) => ({ label: p.category ? `[${p.category}] ${p.name}` : p.name, value: p.name }))
]}
onChange={(e) => setSelectedHbPreset(e.value)}
filter
placeholder="Preset auswählen …"
style={{ width: '100%' }}
/>
</div>
)}
</div>
)}
{/* Audio: Format-Optionen */}
{isAudio && (
<div className="cjc-config-row">
{outputFormat === 'flac' && (
<div className="cjc-config-field">
<label className="cjc-config-label">Komprimierung (012)</label>
<InputNumber
value={audioFormatOptions.flacCompression}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, flacCompression: e.value ?? 5 }))}
min={0} max={12} step={1}
style={{ width: '100%' }}
/>
</div>
)}
{outputFormat === 'mp3' && (
<>
<div className="cjc-config-field">
<label className="cjc-config-label">Modus</label>
<SelectButton
value={audioFormatOptions.mp3Mode}
options={MP3_MODES}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Mode: e.value || 'cbr' }))}
/>
</div>
<div className="cjc-config-field">
{audioFormatOptions.mp3Mode === 'cbr' ? (
<>
<label className="cjc-config-label">Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.mp3Bitrate}
options={[128, 160, 192, 224, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Bitrate: e.value }))}
style={{ width: '100%' }}
/>
</>
) : (
<>
<label className="cjc-config-label">VBR Qualität (0=beste, 9=schlechteste)</label>
<InputNumber
value={audioFormatOptions.mp3Quality}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Quality: e.value ?? 4 }))}
min={0} max={9} step={1}
style={{ width: '100%' }}
/>
</>
)}
</div>
</>
)}
{outputFormat === 'aac' && (
<div className="cjc-config-field">
<label className="cjc-config-label">Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.aacBitrate}
options={[128, 160, 192, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, aacBitrate: e.value }))}
style={{ width: '100%' }}
/>
</div>
)}
{outputFormat === 'opus' && (
<div className="cjc-config-field">
<label className="cjc-config-label">Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.opusBitrate}
options={[64, 96, 128, 160, 192, 256].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, opusBitrate: e.value }))}
style={{ width: '100%' }}
/>
</div>
)}
{outputFormat === 'ogg' && (
<div className="cjc-config-field">
<label className="cjc-config-label">Qualität (-1 bis 10)</label>
<InputNumber
value={audioFormatOptions.oggQuality}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, oggQuality: e.value ?? 6 }))}
min={-1} max={10} step={1}
style={{ width: '100%' }}
/>
</div>
)}
</div>
)}
<div className="cjc-config-actions">
<Button
label="Encoding starten"
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-play'}
disabled={busy}
onClick={handleStart}
/>
</div>
</div>
);
}
/**
* Status-Karte für einen einzelnen Converter-Job.
* Zeigt Konfiguration inline an wenn der Job READY_TO_START ist.
*/
export default function ConverterJobCard({
job,
jobProgress,
onStarted,
onDeleted,
onCancelled
}) {
const [cancelBusy, setCancelBusy] = useState(false);
const [deleteBusy, setDeleteBusy] = useState(false);
const plan = (() => {
try { return JSON.parse(job.encode_plan_json || '{}'); } catch (_e) { return {}; }
})();
const title = job.title || job.detected_title || 'Converter Job';
const status = job.status || 'UNKNOWN';
const statusMeta = getStatusMeta(status);
const converterMediaType = plan?.converterMediaType;
const active = isActiveStatus(status);
const terminal = isTerminal(status);
const isReady = String(status).toUpperCase() === 'READY_TO_START';
const liveProgress = jobProgress?.progress ?? null;
const liveEta = jobProgress?.eta || null;
const liveStatusText = jobProgress?.statusText || null;
const progressValue = liveProgress !== null ? Math.round(liveProgress) : null;
const handleCancel = async () => {
setCancelBusy(true);
try {
await api.cancelConverterJob(job.id);
onCancelled?.(job.id);
} catch (err) {
console.error('Cancel error:', err);
} finally {
setCancelBusy(false);
}
};
const handleDelete = async () => {
if (!window.confirm(`Job "${title}" wirklich löschen?`)) return;
setDeleteBusy(true);
try {
await api.deleteConverterJob(job.id);
onDeleted?.(job.id);
} catch (err) {
console.error('Delete error:', err);
} finally {
setDeleteBusy(false);
}
};
return (
<div className={`converter-job-card status-${status.toLowerCase()}${isReady ? ' is-ready' : ''}`}>
{/* Header */}
<div className="converter-job-card-header">
<div className="converter-job-card-title-row">
<span className="converter-job-card-title" title={title}>{title}</span>
{converterMediaType && mediaTypeBadge(converterMediaType)}
<Tag
value={statusMeta.label}
severity={statusMeta.severity}
icon={`pi ${statusMeta.icon}`}
/>
</div>
{!isReady && plan?.outputFormat && (
<div className="converter-job-card-meta">
<small>Format: <strong>{String(plan.outputFormat).toUpperCase()}</strong></small>
{plan?.inputPath && (
<small title={plan.inputPath} style={{ marginLeft: 8 }}>
{plan.inputPath.split('/').pop()}
</small>
)}
</div>
)}
{isReady && plan?.inputPath && (
<div className="converter-job-card-meta">
<small title={plan.inputPath}> {plan.inputPath.split('/').pop()}</small>
</div>
)}
</div>
{/* Inline-Konfiguration für READY_TO_START */}
{isReady && (
<InlineConfig job={job} plan={plan} onStarted={onStarted} />
)}
{/* Fortschrittsbalken */}
{active && (
<div className="converter-job-card-progress">
{progressValue !== null ? (
<ProgressBar value={progressValue} style={{ height: 6 }} displayValueTemplate={() => `${progressValue}%`} />
) : (
<ProgressBar mode="indeterminate" style={{ height: 6 }} />
)}
{(liveStatusText || liveEta) && (
<div className="converter-job-card-progress-meta">
{liveStatusText && <small>{liveStatusText}</small>}
{liveEta && <small>ETA: {liveEta}</small>}
</div>
)}
</div>
)}
{/* Fehler */}
{status === 'ERROR' && job.error_message && (
<div className="converter-job-card-error">
<small>{job.error_message}</small>
</div>
)}
{/* Ausgabe-Pfad */}
{terminal && job.output_path && (
<div className="converter-job-card-output">
<small title={job.output_path}> {job.output_path.split('/').slice(-2).join('/')}</small>
</div>
)}
{/* Aktions-Buttons */}
<div className="converter-job-card-actions">
{active && (
<Button
label="Abbrechen"
icon={cancelBusy ? 'pi pi-spin pi-spinner' : 'pi pi-times'}
size="small"
severity="warning"
outlined
disabled={cancelBusy}
onClick={handleCancel}
/>
)}
{terminal && (
<Button
label="Löschen"
icon={deleteBusy ? 'pi pi-spin pi-spinner' : 'pi pi-trash'}
size="small"
severity="danger"
outlined
disabled={deleteBusy}
onClick={handleDelete}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,308 @@
import { useEffect, useState } from 'react';
import { Button } from 'primereact/button';
import { Dialog } from 'primereact/dialog';
import { Dropdown } from 'primereact/dropdown';
import { InputNumber } from 'primereact/inputnumber';
import { SelectButton } from 'primereact/selectbutton';
import { Divider } from 'primereact/divider';
import { Message } from 'primereact/message';
import { api } from '../api/client';
const VIDEO_OUTPUT_FORMATS = [
{ label: 'MKV', value: 'mkv' },
{ label: 'MP4', value: 'mp4' },
{ label: 'M4V', value: 'm4v' }
];
const AUDIO_OUTPUT_FORMATS = [
{ label: 'FLAC', value: 'flac' },
{ label: 'MP3', value: 'mp3' },
{ label: 'AAC', value: 'aac' },
{ label: 'Opus', value: 'opus' },
{ label: 'OGG', value: 'ogg' },
{ label: 'WAV', value: 'wav' }
];
const MP3_MODES = [
{ label: 'CBR', value: 'cbr' },
{ label: 'VBR', value: 'vbr' }
];
/**
* Konfigurationsmodal für einen Converter-Job.
* Erscheint nachdem ein Job aus dem File-Explorer oder Upload erstellt wurde.
*/
export default function ConverterJobConfigDialog({
visible,
job,
onHide,
onStarted
}) {
const [converterMediaType, setConverterMediaType] = useState('video');
const [outputFormat, setOutputFormat] = useState('mkv');
const [userPresets, setUserPresets] = useState([]);
const [hbPresets, setHbPresets] = useState([]);
const [selectedUserPreset, setSelectedUserPreset] = useState(null);
const [selectedHbPreset, setSelectedHbPreset] = useState('');
const [audioFormatOptions, setAudioFormatOptions] = useState({
flacCompression: 5,
mp3Mode: 'cbr',
mp3Bitrate: 192,
mp3Quality: 4,
aacBitrate: 256,
opusBitrate: 160,
oggQuality: 6
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
// Beim Öffnen: Medientyp und Format aus dem Job-Plan lesen
useEffect(() => {
if (!job || !visible) return;
let plan = null;
try {
plan = job.encode_plan_json ? JSON.parse(job.encode_plan_json) : null;
} catch (_err) { /* ignore */ }
const mediaType = plan?.converterMediaType || 'video';
setConverterMediaType(mediaType);
setOutputFormat(mediaType === 'audio' ? 'flac' : 'mkv');
setSelectedUserPreset(null);
setSelectedHbPreset('');
setError(null);
}, [job, visible]);
// User-Presets laden
useEffect(() => {
if (!visible) return;
api.getUserPresets?.('video')
.then((data) => setUserPresets(Array.isArray(data?.presets) ? data.presets : []))
.catch(() => setUserPresets([]));
}, [visible]);
// HandBrake Built-in Presets laden
useEffect(() => {
if (!visible || converterMediaType !== 'video') return;
api.getHandBrakePresets?.()
.then((data) => {
const list = Array.isArray(data?.presets) ? data.presets : [];
setHbPresets(list);
})
.catch(() => setHbPresets([]));
}, [visible, converterMediaType]);
const handleStart = async () => {
if (!job) return;
setBusy(true);
setError(null);
try {
let userPreset = null;
if (selectedUserPreset) {
const found = userPresets.find((p) => p.id === selectedUserPreset);
if (found) {
userPreset = {
id: found.id,
handbrakePreset: found.handbrake_preset || null,
extraArgs: found.extra_args || ''
};
}
} else if (selectedHbPreset) {
userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' };
}
await api.startConverterJob(job.id, {
converterMediaType,
outputFormat,
userPreset,
audioFormatOptions: converterMediaType === 'audio' ? audioFormatOptions : undefined
});
onStarted?.(job.id);
onHide?.();
} catch (err) {
setError(err.message || 'Fehler beim Starten.');
} finally {
setBusy(false);
}
};
const isVideo = converterMediaType === 'video' || converterMediaType === 'iso';
const isAudio = converterMediaType === 'audio';
const outputFormats = isVideo ? VIDEO_OUTPUT_FORMATS : AUDIO_OUTPUT_FORMATS;
const footer = (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={onHide} disabled={busy} />
<Button
label="Encoding starten"
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-play'}
disabled={busy}
onClick={handleStart}
/>
</div>
);
return (
<Dialog
header={`Converter konfigurieren — ${job?.title || job?.detected_title || 'Job'}`}
visible={visible}
onHide={onHide}
footer={footer}
style={{ width: '520px' }}
modal
>
{error && (
<Message severity="error" text={error} style={{ marginBottom: 16, width: '100%' }} />
)}
{/* Medientyp anzeigen (read-only) */}
<div className="field">
<label>Medientyp</label>
<div style={{ marginTop: 4 }}>
<strong>{converterMediaType === 'iso' ? 'ISO (Video)' : converterMediaType === 'video' ? 'Video' : 'Audio'}</strong>
{converterMediaType === 'iso' && (
<small style={{ marginLeft: 8, color: '#888' }}> MakeMKV Rip, dann HandBrake</small>
)}
</div>
</div>
{/* Ausgabeformat */}
<div className="field" style={{ marginTop: 12 }}>
<label>Ausgabeformat</label>
<Dropdown
value={outputFormat}
options={outputFormats}
onChange={(e) => setOutputFormat(e.value)}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
{/* Video: Preset-Auswahl */}
{isVideo && (
<>
<Divider />
<div className="field">
<label>User-Preset (optional)</label>
<Dropdown
value={selectedUserPreset}
options={[
{ label: '— Kein User-Preset —', value: null },
...userPresets.map((p) => ({ label: p.name, value: p.id }))
]}
onChange={(e) => {
setSelectedUserPreset(e.value);
if (e.value) setSelectedHbPreset('');
}}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
{!selectedUserPreset && (
<div className="field" style={{ marginTop: 12 }}>
<label>HandBrake-Preset (optional)</label>
<Dropdown
value={selectedHbPreset}
options={[
{ label: '— Kein Preset (HB-Standard) —', value: '' },
...hbPresets.map((p) => ({
label: p.category ? `[${p.category}] ${p.name}` : p.name,
value: p.name
}))
]}
onChange={(e) => setSelectedHbPreset(e.value)}
filter
style={{ width: '100%', marginTop: 4 }}
placeholder="Preset auswählen …"
/>
</div>
)}
</>
)}
{/* Audio: Format-Optionen */}
{isAudio && (
<>
<Divider />
{outputFormat === 'flac' && (
<div className="field">
<label>Komprimierung (012)</label>
<InputNumber
value={audioFormatOptions.flacCompression}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, flacCompression: e.value ?? 5 }))}
min={0} max={12} step={1}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
)}
{outputFormat === 'mp3' && (
<>
<div className="field">
<label>Modus</label>
<SelectButton
value={audioFormatOptions.mp3Mode}
options={MP3_MODES}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Mode: e.value || 'cbr' }))}
style={{ marginTop: 4 }}
/>
</div>
{audioFormatOptions.mp3Mode === 'cbr' ? (
<div className="field" style={{ marginTop: 12 }}>
<label>Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.mp3Bitrate}
options={[128, 160, 192, 224, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Bitrate: e.value }))}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
) : (
<div className="field" style={{ marginTop: 12 }}>
<label>VBR Qualität (0=beste, 9=schlechteste)</label>
<InputNumber
value={audioFormatOptions.mp3Quality}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Quality: e.value ?? 4 }))}
min={0} max={9} step={1}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
)}
</>
)}
{outputFormat === 'aac' && (
<div className="field">
<label>Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.aacBitrate}
options={[128, 160, 192, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, aacBitrate: e.value }))}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
)}
{outputFormat === 'opus' && (
<div className="field">
<label>Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.opusBitrate}
options={[64, 96, 128, 160, 192, 256].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, opusBitrate: e.value }))}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
)}
{outputFormat === 'ogg' && (
<div className="field">
<label>Qualität (-1 bis 10)</label>
<InputNumber
value={audioFormatOptions.oggQuality}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, oggQuality: e.value ?? 6 }))}
min={-1} max={10} step={1}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
)}
</>
)}
</Dialog>
);
}
@@ -0,0 +1,224 @@
import { useCallback, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
const ACCEPTED_EXTENSIONS = [
'.mkv', '.mp4', '.m2ts', '.avi', '.mov', '.m4v', '.wmv', '.ts',
'.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape',
'.iso'
].join(',');
/**
* Upload-Panel für den Converter.
* Unterstützt Mehrfach-Dateien und Ordner-Upload (webkitdirectory).
*/
export default function ConverterUploadPanel({ onUploaded }) {
const [phase, setPhase] = useState('idle'); // idle | uploading | done | error
const [progress, setProgress] = useState(0);
const [statusText, setStatusText] = useState('');
const [errorMsg, setErrorMsg] = useState(null);
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef(null);
const dirInputRef = useRef(null);
const reset = () => {
setPhase('idle');
setProgress(0);
setStatusText('');
setErrorMsg(null);
};
const uploadFiles = useCallback(async (fileList) => {
const files = Array.from(fileList).filter((f) => {
if (f.size === 0) return false;
// Dot-Dateien (.DS_Store, .gitkeep, __MACOSX etc.) ignorieren
const relPath = f.webkitRelativePath || f.name;
return !relPath.split('/').some((seg) => seg.startsWith('.') || seg === '__MACOSX');
});
if (files.length === 0) return;
const isFolderUpload = files.some((f) => f.webkitRelativePath && f.webkitRelativePath.includes('/'));
setPhase('uploading');
setProgress(0);
setStatusText(isFolderUpload
? `Lade Ordner hoch (${files.length} Datei${files.length !== 1 ? 'en' : ''}) …`
: `Lade ${files.length} Datei${files.length !== 1 ? 'en' : ''} hoch …`);
setErrorMsg(null);
// Wie Klangkiste: folderName als eigenes Feld, Dateien mit file.name (kein Pfad im Dateinamen)
const formData = new FormData();
if (isFolderUpload) {
const folderName = files.find((f) => f.webkitRelativePath)?.webkitRelativePath?.split('/')[0] || 'upload';
formData.append('folderName', folderName);
for (const file of files) {
formData.append('files', file, file.name);
}
} else {
for (const file of files) {
formData.append('files', file, file.name);
}
}
try {
// XHR für Upload-Fortschritt
const result = await uploadWithProgress(formData, (pct) => {
setProgress(pct);
setStatusText(`Upload: ${Math.round(pct)}%`);
});
setPhase('done');
setProgress(100);
const folders = result.folders || [];
setStatusText(`${folders.length} Ordner hochgeladen.`);
onUploaded?.(folders);
} catch (err) {
setPhase('error');
setErrorMsg(err.message || 'Upload fehlgeschlagen.');
}
}, [onUploaded]);
const handleFileChange = (e) => {
if (e.target.files?.length > 0) {
uploadFiles(e.target.files);
}
e.target.value = '';
};
const handleDrop = (e) => {
e.preventDefault();
setIsDragOver(false);
const dt = e.dataTransfer;
if (dt?.files?.length > 0) {
uploadFiles(dt.files);
}
};
const handleDragOver = (e) => { e.preventDefault(); setIsDragOver(true); };
const handleDragLeave = () => setIsDragOver(false);
const phaseLabel = {
idle: null,
uploading: { label: 'Wird hochgeladen', severity: 'warning' },
done: { label: 'Fertig', severity: 'success' },
error: { label: 'Fehler', severity: 'danger' }
}[phase];
return (
<div className="converter-upload-panel">
<div
className={`converter-upload-dropzone${isDragOver ? ' drag-over' : ''}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<div className="converter-upload-icon">
<i className="pi pi-upload" style={{ fontSize: '2rem', color: '#888' }} />
</div>
<div className="converter-upload-hint">
Dateien hierher ziehen oder auswählen
<br />
<small>Unterstützt: MKV, MP4, ISO, AVI, FLAC, MP3, WAV, AAC, OGG, OPUS </small>
</div>
<div className="converter-upload-buttons">
<Button
label="Dateien auswählen"
icon="pi pi-file"
size="small"
outlined
disabled={phase === 'uploading'}
onClick={() => fileInputRef.current?.click()}
/>
<Button
label="Ordner auswählen"
icon="pi pi-folder-open"
size="small"
outlined
disabled={phase === 'uploading'}
onClick={() => dirInputRef.current?.click()}
/>
</div>
{/* Versteckte File-Inputs */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_EXTENSIONS}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<input
ref={dirInputRef}
type="file"
webkitdirectory="true"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
{phase !== 'idle' && (
<div className="converter-upload-status">
<div className="converter-upload-status-head">
{phaseLabel && <Tag value={phaseLabel.label} severity={phaseLabel.severity} />}
<span>{statusText}</span>
{(phase === 'done' || phase === 'error') && (
<Button
icon="pi pi-times"
text
rounded
size="small"
onClick={reset}
aria-label="Schließen"
/>
)}
</div>
{phase === 'uploading' && (
<ProgressBar value={progress} style={{ height: 6 }} />
)}
{phase === 'error' && errorMsg && (
<small style={{ color: 'var(--red-500)' }}>{errorMsg}</small>
)}
</div>
)}
</div>
);
}
function uploadWithProgress(formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const apiBase = import.meta.env.VITE_API_BASE || '/api';
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText));
} catch (_err) {
resolve({});
}
} else {
let msg = `HTTP ${xhr.status}`;
try {
const parsed = JSON.parse(xhr.responseText);
msg = parsed?.error?.message || msg;
} catch (_err) { /* ignore */ }
reject(new Error(msg));
}
});
xhr.addEventListener('error', () => reject(new Error('Netzwerkfehler beim Upload.')));
xhr.addEventListener('abort', () => reject(new Error('Upload abgebrochen.')));
xhr.open('POST', `${apiBase}/converter/upload`);
xhr.send(formData);
});
}
@@ -351,6 +351,7 @@ const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template'];
const AUDIOBOOK_PATH_KEYS = ['raw_dir_audiobook', 'movie_dir_audiobook', 'output_template_audiobook', 'output_chapter_template_audiobook', 'audiobook_raw_template'];
const DOWNLOAD_PATH_KEYS = ['download_dir'];
const LOG_PATH_KEYS = ['log_dir'];
const CONVERTER_PATH_KEYS = ['converter_raw_dir', 'converter_movie_dir', 'converter_audio_dir', 'converter_output_template_video', 'converter_output_template_audio'];
function buildSectionsForCategory(categoryName, settings) {
const list = Array.isArray(settings) ? settings : [];
@@ -539,6 +540,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
const audiobookSettings = list.filter((s) => AUDIOBOOK_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && AUDIOBOOK_PATH_KEYS.includes(s.key.replace('_owner', ''))));
const downloadSettings = list.filter((s) => DOWNLOAD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DOWNLOAD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
const logSettings = list.filter((s) => LOG_PATH_KEYS.includes(s.key));
const converterSettings = list.filter((s) => CONVERTER_PATH_KEYS.includes(s.key));
const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
const defaultMovies = effectivePaths?.defaults?.movies || 'data/output/movies';
@@ -547,6 +549,10 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
const defaultAudiobookMovies = effectivePaths?.defaults?.audiobookMovies || 'data/output/audiobooks';
const defaultDownloads = effectivePaths?.defaults?.downloads || 'data/downloads';
const defaultConverterRaw = effectivePaths?.defaults?.converterRaw || 'data/output/converter-raw';
const defaultConverterMovies = effectivePaths?.defaults?.converterMovies || 'data/output/converted-movies';
const defaultConverterAudio = effectivePaths?.defaults?.converterAudio || 'data/output/converted-audio';
const ep = effectivePaths || {};
const blurayRaw = ep.bluray?.raw || defaultRaw;
const blurayMovies = ep.bluray?.movies || defaultMovies;
@@ -557,6 +563,9 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
const audiobookRaw = ep.audiobook?.raw || defaultAudiobookRaw;
const audiobookMovies = ep.audiobook?.movies || defaultAudiobookMovies;
const downloadPath = ep.downloads?.path || defaultDownloads;
const converterRaw = ep.converter?.raw || defaultConverterRaw;
const converterMovies = ep.converter?.movies || defaultConverterMovies;
const converterAudio = ep.converter?.audio || defaultConverterAudio;
const isDefault = (path, def) => path === def;
@@ -572,8 +581,8 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
<thead>
<tr>
<th>Medium</th>
<th>RAW-Ordner</th>
<th>Encode-Ordner</th>
<th>RAW / Eingang</th>
<th>Encode / Ausgang</th>
</tr>
</thead>
<tbody>
@@ -627,8 +636,30 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
<code>{downloadPath}</code>
{isDefault(downloadPath, defaultDownloads) && <span className="path-default-badge">Standard</span>}
</td>
<td>---</td>
</tr>
<tr>
<td><strong>Converter Raw</strong></td>
<td>
---
<code>{converterRaw}</code>
{isDefault(converterRaw, defaultConverterRaw) && <span className="path-default-badge">Standard</span>}
</td>
<td>---</td>
</tr>
<tr>
<td><strong>Converter Video</strong></td>
<td>---</td>
<td>
<code>{converterMovies}</code>
{isDefault(converterMovies, defaultConverterMovies) && <span className="path-default-badge">Standard</span>}
</td>
</tr>
<tr>
<td><strong>Converter Audio</strong></td>
<td>---</td>
<td>
<code>{converterAudio}</code>
{isDefault(converterAudio, defaultConverterAudio) && <span className="path-default-badge">Standard</span>}
</td>
</tr>
</tbody>
@@ -642,6 +673,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
{ title: 'DVD', pathSettings: dvdSettings },
{ title: 'CD / Audio', pathSettings: cdSettings },
{ title: 'Audiobook', pathSettings: audiobookSettings },
{ title: 'Converter', pathSettings: converterSettings },
{ title: 'Downloads', pathSettings: downloadSettings },
...(logSettings.length > 0 ? [{ title: 'Logs', pathSettings: logSettings }] : [])
]
+377
View File
@@ -0,0 +1,377 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { Card } from 'primereact/card';
import { Toast } from 'primereact/toast';
import { Divider } from 'primereact/divider';
import { Badge } from 'primereact/badge';
import { Dialog } from 'primereact/dialog';
import { RadioButton } from 'primereact/radiobutton';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import ConverterFileExplorer from '../components/ConverterFileExplorer';
import ConverterUploadPanel from '../components/ConverterUploadPanel';
import ConverterJobCard from '../components/ConverterJobCard';
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']);
function isAudioEntry(e) {
const p = (e.relPath || '').toLowerCase();
const dot = p.lastIndexOf('.');
if (dot === -1) return false;
return AUDIO_EXTS.has(p.slice(dot));
}
function normalizeJobId(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
}
export default function ConverterPage() {
const toastRef = useRef(null);
const [jobs, setJobs] = useState([]);
const [jobProgress, setJobProgress] = useState({});
const [loadingJobs, setLoadingJobs] = useState(false);
const [explorerRefreshToken, setExplorerRefreshToken] = useState(0);
const [explorerNavigateTo, setExplorerNavigateTo] = useState(null);
const [selectedEntries, setSelectedEntries] = useState([]);
const [jobModeVisible, setJobModeVisible] = useState(false);
const [audioMode, setAudioMode] = useState('individual');
const [creatingJobs, setCreatingJobs] = useState(false);
const loadJobs = useCallback(async () => {
setLoadingJobs(true);
try {
const data = await api.getConverterJobs();
setJobs(Array.isArray(data?.jobs) ? data.jobs : []);
} catch (err) {
console.error('Load converter jobs error:', err);
} finally {
setLoadingJobs(false);
}
}, []);
useEffect(() => {
loadJobs();
const interval = setInterval(loadJobs, 5000);
return () => clearInterval(interval);
}, [loadJobs]);
useWebSocket((message) => {
if (!message?.type || !message?.payload) return;
if (message.type === 'PIPELINE_PROGRESS') {
const payload = message.payload;
const jobId = normalizeJobId(payload?.activeJobId);
if (jobId) {
setJobProgress((prev) => ({
...prev,
[jobId]: {
progress: payload?.progress ?? null,
eta: payload?.eta ?? null,
statusText: payload?.statusText ?? null,
state: payload?.state ?? null
}
}));
}
}
if (message.type === 'PIPELINE_UPDATE' || message.type === 'CONVERTER_SCAN_UPDATE') {
loadJobs();
if (message.type === 'CONVERTER_SCAN_UPDATE') {
setExplorerRefreshToken((t) => t + 1);
}
}
});
const handleSelectionChange = (entries) => setSelectedEntries(entries || []);
const handleOpenJobModal = () => {
const hasAudio = selectedEntries.some(isAudioEntry);
if (!hasAudio) {
// Keine Audio-Dateien (nur Videos/Ordner) kein Modal, direkt Einzeljobs
handleCreateJobsFromSelection('individual');
return;
}
setAudioMode('individual');
setJobModeVisible(true);
};
const handleCreateJobsFromSelection = async (explicitMode) => {
if (selectedEntries.length === 0) return;
setCreatingJobs(true);
setJobModeVisible(false);
try {
const relPaths = selectedEntries.map((e) => e.relPath).filter(Boolean);
const result = await api.converterCreateJobsFromSelection(relPaths, explicitMode ?? audioMode);
const newJobs = result?.jobs || [];
toastRef.current?.show({
severity: 'success',
summary: 'Jobs erstellt',
detail: `${newJobs.length} Converter-Job${newJobs.length !== 1 ? 's' : ''} erstellt.`,
life: 3500
});
setSelectedEntries([]);
setExplorerRefreshToken((t) => t + 1);
await loadJobs();
} catch (err) {
toastRef.current?.show({
severity: 'error',
summary: 'Fehler',
detail: err.message || 'Jobs konnten nicht erstellt werden.',
life: 4500
});
} finally {
setCreatingJobs(false);
}
};
const handleUploaded = (folders) => {
setExplorerRefreshToken((t) => t + 1);
if (folders?.length > 0) {
// Explorer in den ersten hochgeladenen Ordner navigieren
setExplorerNavigateTo({ path: folders[0].folderRelPath, ts: Date.now() });
toastRef.current?.show({
severity: 'success',
summary: 'Upload abgeschlossen',
detail: `${folders.length} Ordner hochgeladen. Dateien auswählen und Jobs anlegen.`,
life: 4000
});
}
};
const handleJobStarted = async (jobId) => {
await loadJobs();
toastRef.current?.show({
severity: 'success',
summary: 'Gestartet',
detail: `Converter-Job ${jobId} läuft.`,
life: 3000
});
};
const handleJobDeleted = (jobId) => {
setJobs((prev) => prev.filter((j) => j.id !== jobId));
setJobProgress((prev) => {
const next = { ...prev };
delete next[jobId];
return next;
});
};
const handleJobCancelled = () => setTimeout(loadJobs, 1000);
const activeJobs = jobs.filter((j) => !['DONE', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
const finishedJobs = jobs.filter((j) => ['DONE', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
const jobsCardHeader = (
<div className="converter-card-header">
<div className="converter-card-title">
<span>Converter Jobs</span>
{activeJobs.length > 0 && (
<Badge value={activeJobs.length} severity="info" />
)}
</div>
<Button
icon={loadingJobs ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
text
rounded
size="small"
onClick={loadJobs}
disabled={loadingJobs}
aria-label="Jobs neu laden"
/>
</div>
);
return (
<div className="dashboard-subpage-content">
<Toast ref={toastRef} position="top-right" />
{/* Import-Ordner */}
<Card title="Import-Ordner" subTitle="Dateien aus dem Raw-Ordner auswählen und als Job anlegen">
<ConverterFileExplorer
onSelectionChange={handleSelectionChange}
refreshToken={explorerRefreshToken}
navigateToPath={explorerNavigateTo}
/>
{selectedEntries.length > 0 && (
<div className="converter-selection-bar">
<span>
<Badge value={selectedEntries.length} severity="info" /> ausgewählt
</span>
<Button
label="Job(s) anlegen"
icon={creatingJobs ? 'pi pi-spin pi-spinner' : 'pi pi-plus'}
disabled={creatingJobs}
onClick={handleOpenJobModal}
/>
</div>
)}
</Card>
{/* Jobs */}
<Card header={jobsCardHeader}>
{jobs.length === 0 ? (
<p className="converter-jobs-empty-hint">
<small>Keine Jobs vorhanden. Dateien im Import-Ordner auswählen oder per Upload hochladen.</small>
</p>
) : (
<>
{activeJobs.length > 0 && (
<>
<p className="converter-jobs-group-label">Aktiv</p>
<div className="converter-jobs-list">
{activeJobs.map((job) => (
<ConverterJobCard
key={job.id}
job={job}
jobProgress={jobProgress[job.id] || null}
onStarted={handleJobStarted}
onDeleted={handleJobDeleted}
onCancelled={handleJobCancelled}
/>
))}
</div>
</>
)}
{activeJobs.length > 0 && finishedJobs.length > 0 && <Divider />}
{finishedJobs.length > 0 && (
<>
<p className="converter-jobs-group-label">Abgeschlossen</p>
<div className="converter-jobs-list">
{finishedJobs.slice(0, 20).map((job) => (
<ConverterJobCard
key={job.id}
job={job}
jobProgress={null}
onDeleted={handleJobDeleted}
/>
))}
</div>
</>
)}
</>
)}
</Card>
{/* Upload */}
<Card title="Datei-Upload" subTitle="Einzelne Dateien oder ganze Ordner (Album) hochladen">
<ConverterUploadPanel onUploaded={handleUploaded} />
</Card>
<JobModeDialog
visible={jobModeVisible}
entries={selectedEntries}
audioMode={audioMode}
onAudioModeChange={setAudioMode}
busy={creatingJobs}
onConfirm={handleCreateJobsFromSelection}
onHide={() => setJobModeVisible(false)}
/>
</div>
);
}
// Job-Modus-Dialog
function JobModeDialog({ visible, entries, audioMode, onAudioModeChange, busy, onConfirm, onHide }) {
const audioEntries = entries.filter(isAudioEntry);
const nonAudioEntries = entries.filter((e) => !isAudioEntry(e));
const hasAudio = audioEntries.length > 0;
const hasNonAudio = nonAudioEntries.length > 0;
const onlyAudio = hasAudio && !hasNonAudio;
const mixed = hasAudio && hasNonAudio;
// Anzahl der Jobs die entstehen werden
const audioJobCount = audioMode === 'shared' ? 1 : audioEntries.length;
const totalJobs = nonAudioEntries.length + audioJobCount;
const footer = (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={onHide} disabled={busy} />
<Button
label={`${totalJobs} Job${totalJobs !== 1 ? 's' : ''} anlegen`}
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-plus'}
disabled={busy}
onClick={onConfirm}
/>
</div>
);
return (
<Dialog
header="Jobs anlegen"
visible={visible}
onHide={onHide}
footer={footer}
style={{ width: '440px' }}
modal
>
{/* Nur Videos / Ordner gewählt */}
{!hasAudio && (
<p style={{ margin: 0, lineHeight: 1.6 }}>
Für jede ausgewählte Datei / jeden Ordner wird ein eigener Job angelegt.
<br />
<strong>{nonAudioEntries.length} Job{nonAudioEntries.length !== 1 ? 's' : ''}</strong> werden erstellt.
</p>
)}
{/* Nur Audio gewählt */}
{onlyAudio && (
<div>
<p style={{ marginTop: 0, marginBottom: 12 }}>
Wie sollen die <strong>{audioEntries.length} Audiodatei{audioEntries.length !== 1 ? 'en' : ''}</strong> verarbeitet werden?
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<RadioButton
value="individual"
checked={audioMode === 'individual'}
onChange={() => onAudioModeChange('individual')}
/>
<span>Für jede Datei ein eigener Job <small style={{ color: 'var(--rip-muted)' }}>({audioEntries.length} Jobs)</small></span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<RadioButton
value="shared"
checked={audioMode === 'shared'}
onChange={() => onAudioModeChange('shared')}
/>
<span>Ein gemeinsamer Job für alle Dateien <small style={{ color: 'var(--rip-muted)' }}>(1 Job)</small></span>
</label>
</div>
</div>
)}
{/* Gemischte Auswahl */}
{mixed && (
<div>
<p style={{ marginTop: 0, marginBottom: 8 }}>
<strong>Videos/Ordner ({nonAudioEntries.length}):</strong> Für jede Datei ein eigener Job.
</p>
<Divider style={{ margin: '8px 0' }} />
<p style={{ marginBottom: 10 }}>
<strong>Audiodateien ({audioEntries.length}):</strong> Wie verarbeiten?
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<RadioButton
value="individual"
checked={audioMode === 'individual'}
onChange={() => onAudioModeChange('individual')}
/>
<span>Für jede Audio-Datei ein eigener Job <small style={{ color: 'var(--rip-muted)' }}>({audioEntries.length} Jobs)</small></span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<RadioButton
value="shared"
checked={audioMode === 'shared'}
onChange={() => onAudioModeChange('shared')}
/>
<span>Ein gemeinsamer Job für alle Audio-Dateien <small style={{ color: 'var(--rip-muted)' }}>(1 Job)</small></span>
</label>
</div>
</div>
)}
</Dialog>
);
}
+6
View File
@@ -865,6 +865,12 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
? {
...computedContext,
...liveContext,
// Prefer the DB plan when it has been confirmed but the live (jobProgress) plan hasn't
// this happens when confirmEncodeReview is called with skipPipelineStateUpdate=true
// (always the case for queued jobs), leaving jobProgress with the pre-confirmation plan.
mediaInfoReview: (computedContext.mediaInfoReview?.reviewConfirmedAt && !liveContext.mediaInfoReview?.reviewConfirmedAt)
? computedContext.mediaInfoReview
: (liveContext.mediaInfoReview || computedContext.mediaInfoReview),
tracks: (Array.isArray(liveContext.tracks) && liveContext.tracks.length > 0)
? liveContext.tracks
: computedContext.tracks,
+16 -5
View File
@@ -88,6 +88,9 @@ function resolveMediaType(row) {
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
if (raw === 'converter') {
return 'converter';
}
}
const statusCandidates = [
row?.status,
@@ -151,6 +154,14 @@ function resolveMediaTypeMeta(row) {
alt: 'Audiobook'
};
}
if (mediaType === 'converter') {
return {
mediaType,
icon: otherIndicatorIcon,
label: 'Converter',
alt: 'Converter'
};
}
return {
mediaType,
icon: otherIndicatorIcon,
@@ -1582,11 +1593,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
detailLoading={detailLoading}
onLoadLog={handleLoadLog}
logLoadingMode={logLoadingMode}
onRestartEncode={handleRestartEncode}
onRestartReview={handleRestartReview}
onRestartCdReview={handleRestartCdReview}
onReencode={handleReencode}
onRetry={handleRetry}
onRestartEncode={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartEncode}
onRestartReview={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartReview}
onRestartCdReview={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartCdReview}
onReencode={resolveMediaType(selectedJob) === 'converter' ? null : handleReencode}
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
onAssignOmdb={handleAssignOmdb}
onAssignCdMetadata={handleAssignCdMetadata}
onDeleteFiles={handleDeleteFiles}
+32 -1
View File
@@ -14,6 +14,26 @@ import CronJobsTab from '../components/CronJobsTab';
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
// Validates template/string fields: only letters, digits, space, _, -, and {placeholders} allowed
function validateSettingValue(key, value) {
if (!key.includes('template')) return null;
const str = String(value ?? '');
if (!str) return null;
// Strip valid placeholder patterns ${...} and {...}
const stripped = str.replace(/\$\{[^}]*\}/g, '').replace(/\{[^}]*\}/g, '');
// Standalone { or } after stripping = unclosed placeholder
if (/[{}]/.test(stripped)) {
return 'Geschweifte Klammern sind nur als vollständige Platzhalter erlaubt, z.B. {title}.';
}
// Find first invalid special char (anything beyond letters, digits, space, _, -, (), [], /)
// . is not allowed (extensions are set separately); / is only for folder separators
const invalid = stripped.match(/[^a-zA-Z0-9 ()[\]/_-]/);
if (invalid) {
return `Ungültiges Zeichen "${invalid[0]}". Erlaubt sind nur Buchstaben, Ziffern, Leerzeichen, _, -, und {Platzhalter}.`;
}
return null;
}
function buildValuesMap(categories) {
const next = {};
for (const category of categories || []) {
@@ -427,7 +447,8 @@ export default function SettingsPage() {
const handleFieldChange = (key, value) => {
setDraftValues((prev) => ({ ...prev, [key]: value }));
setErrors((prev) => ({ ...prev, [key]: null }));
const validationError = validateSettingValue(key, value);
setErrors((prev) => ({ ...prev, [key]: validationError || null }));
};
const handleExpertModeToggle = async (checked) => {
@@ -468,6 +489,16 @@ export default function SettingsPage() {
return;
}
const hasValidationErrors = Object.values(errors).some(Boolean);
if (hasValidationErrors) {
toastRef.current?.show({
severity: 'error',
summary: 'Validierungsfehler',
detail: 'Bitte korrigiere die fehlerhaften Felder vor dem Speichern.'
});
return;
}
const patch = {};
for (const key of dirtyKeys) {
patch[key] = draftValues[key];
+574
View File
@@ -4864,3 +4864,577 @@ body {
justify-content: flex-end;
flex-wrap: wrap;
}
/* ===== Converter Page ===== */
.converter-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 1rem 1.25rem 0;
}
.converter-card-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 700;
color: var(--text-color, #333);
}
.converter-jobs-group-label {
margin: 0 0 0.6rem;
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--rip-muted, #6a4d38);
}
.converter-jobs-empty-hint {
margin: 0;
color: var(--rip-muted, #6a4d38);
}
/* ===== File Explorer (Klangkiste 1:1 mit Ripster-Farben) ===== */
/* Äußere Hülle */
.cfx-wrap {
display: flex;
flex-direction: column;
gap: 0;
}
/* Obere Infozeile: rawdir + Aktualisieren */
.cfx-top-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
font-size: 0.8rem;
}
.cfx-rawdir {
flex: 1;
font-size: 0.75rem;
color: var(--rip-muted, #6a4d38);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Klangkiste Explorer-Layout ───────────────────────────────────────────── */
.explorer {
display: grid;
grid-template-columns: 260px 1fr;
grid-template-rows: 1fr auto;
gap: 0;
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 12px;
overflow-x: auto;
overflow-y: hidden;
background: var(--rip-panel, #fffaf1);
min-height: 260px;
align-items: stretch;
font-size: 13px;
}
.explorer-sidebar {
grid-row: 1;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--rip-panel-soft, #fdf5e7);
border-right: 1px solid var(--rip-border, #d9bc8d);
height: 100%;
}
.explorer-toolbar,
.sidebar-toolbar {
height: 40px;
min-height: 40px;
display: flex;
align-items: center;
gap: 10px;
}
.sidebar-toolbar {
border: 1px solid var(--rip-border, #d9bc8d);
background: var(--rip-cream-50, #fffaf0);
padding: 0 10px;
border-radius: 8px;
flex-shrink: 0;
}
.sidebar-tree {
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 10px;
background: var(--rip-cream-50, #fffaf0);
padding: 8px;
overflow: auto;
flex: 1;
}
.sidebar-search {
flex: 1;
width: 100%;
border: 1px solid var(--rip-border, #d9bc8d);
background: var(--rip-cream-50, #fffaf0);
border-radius: 8px;
padding: 0 10px;
font-size: 13px;
color: var(--rip-ink, #2f180f);
min-width: 0;
height: 28px;
line-height: 28px;
outline: none;
}
.sidebar-search:focus {
border-color: var(--rip-gold-400, #d49c56);
box-shadow: 0 0 0 2px rgba(212, 156, 86, 0.2);
}
.explorer-main {
grid-row: 1;
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px 14px;
background: var(--rip-panel, #fffaf1);
height: 100%;
overflow: hidden;
}
.explorer-toolbar {
flex-wrap: wrap;
padding: 0 10px;
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 10px;
background: var(--rip-cream-50, #fffaf0);
flex-shrink: 0;
}
.explorer-toolbar .icon-button {
width: 30px;
height: 30px;
}
.explorer-path {
display: flex;
flex-wrap: wrap;
gap: 0;
font-size: 13px;
color: var(--rip-muted, #6a4d38);
align-items: center;
line-height: 1;
flex: 1;
min-width: 0;
}
.breadcrumb-root {
font-weight: 600;
color: var(--rip-ink, #2f180f);
}
.path-link {
background: transparent;
border: none;
color: var(--rip-ink, #2f180f);
cursor: pointer;
padding: 0;
font-size: 13px;
font-weight: 600;
}
.path-link:hover,
.path-link:focus { background: transparent; color: var(--primary-color, #8e4d2d); outline: none; text-decoration: underline; }
.toolbar-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
}
.explorer-list {
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 10px;
background: var(--rip-cream-50, #fffaf0);
overflow: auto;
flex: 1;
min-height: 0;
}
.explorer-row {
display: grid;
grid-template-columns: 28px 2fr repeat(2, minmax(80px, 1fr));
gap: 10px;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--rip-border, #d9bc8d);
font-size: 13px;
cursor: pointer;
user-select: none;
transition: background 0.08s, border-left 0.08s;
}
.explorer-row:hover:not(.header):not(.footer) { background: var(--rip-cream-100, #fbf2df); }
.explorer-row.selected {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
border-left: 3px solid var(--primary-color);
padding-left: 9px;
}
.explorer-row.header {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--rip-muted, #6a4d38);
background: var(--rip-panel-soft, #fdf5e7);
cursor: default;
border-left: 3px solid transparent;
position: sticky;
top: 0;
z-index: 1;
}
.explorer-row.footer {
background: var(--rip-panel-soft, #fdf5e7);
cursor: default;
border-left: 3px solid transparent;
}
.explorer-row:last-child { border-bottom: none; }
.row-name {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-icon {
display: inline-flex;
align-items: center;
color: var(--rip-brown-700, #6f3922);
flex-shrink: 0;
}
.row-checkbox {
display: flex;
align-items: center;
justify-content: center;
}
.row-checkbox input[type="checkbox"] {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--primary-color);
border-radius: 3px;
}
.explorer-row.header .row-checkbox { pointer-events: none; }
.footer-count {
font-weight: 600;
color: var(--rip-ink, #2f180f);
text-align: right;
}
.explorer-footer {
grid-column: 1 / -1;
padding: 8px 14px;
font-size: 12px;
color: var(--rip-muted, #6a4d38);
border-top: 1px solid var(--rip-border, #d9bc8d);
background: var(--rip-panel-soft, #fdf5e7);
border-radius: 0 0 11px 11px;
}
/* ── Icon-Button (wie Klangkiste) ────────────────────────────────────────── */
.icon-button {
border: 1px solid var(--rip-border, #d9bc8d);
background: var(--rip-panel, #fffaf1);
color: var(--rip-ink, #2f180f);
border-radius: 8px;
padding: 0;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
line-height: 1;
font-size: 14px;
transition: background 0.1s;
flex-shrink: 0;
}
.icon-button:hover:not(:disabled) { background: var(--rip-cream-100, #fbf2df); }
.icon-button.danger {
border-color: rgba(239, 68, 68, 0.55);
color: rgb(220, 38, 38);
background: rgba(239, 68, 68, 0.08);
}
.icon-button.danger:hover:not(:disabled) { background: rgba(239, 68, 68, 0.15); }
.icon-button:disabled { cursor: not-allowed; opacity: 0.4; }
/* ── Ordnerbaum ──────────────────────────────────────────────────────────── */
.tree-node { display: flex; flex-direction: column; gap: 2px; }
.depth-1 .tree-row { margin-left: 16px; }
.depth-2 .tree-row { margin-left: 32px; }
.depth-3 .tree-row { margin-left: 48px; }
.depth-4 .tree-row { margin-left: 64px; }
.depth-5 .tree-row { margin-left: 80px; }
.tree-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 8px;
cursor: pointer;
color: var(--rip-muted, #6a4d38);
user-select: none;
}
.tree-row:hover { background: var(--rip-cream-100, #fbf2df); color: var(--rip-ink, #2f180f); }
.tree-row.active { background: var(--primary-50, #fef6ec); color: var(--rip-ink, #2f180f); font-weight: 600; }
.tree-caret {
border: none;
background: transparent;
color: var(--rip-muted, #6a4d38);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
flex: 0 0 18px;
cursor: pointer;
}
.tree-caret.disabled { width: 18px; flex: 0 0 18px; }
.tree-icon.folder { color: var(--rip-brown-700, #6f3922); }
.tree-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Selection bar */
.converter-selection-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--primary-50, #fef6ec);
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 6px;
font-size: 0.85rem;
}
/* ===== Upload Panel ===== */
.converter-upload-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.converter-upload-dropzone {
border: 2px dashed var(--rip-border, #d9bc8d);
border-radius: 8px;
padding: 1.25rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
background: var(--rip-panel, #fffaf1);
transition: border-color 0.15s, background 0.15s;
}
.converter-upload-dropzone.drag-over {
border-color: var(--primary-color, #8e4d2d);
background: var(--primary-50, #fef6ec);
}
.converter-upload-hint {
text-align: center;
font-size: 0.85rem;
color: var(--rip-muted, #6a4d38);
line-height: 1.5;
}
.converter-upload-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
}
.converter-upload-status {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
background: var(--rip-panel-soft, #fdf5e7);
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 6px;
}
.converter-upload-status-head {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
/* ===== Job Card ===== */
.converter-jobs-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.converter-jobs-empty {
padding: 1rem 0;
color: var(--rip-muted, #6a4d38);
}
.converter-job-card {
border: 1px solid var(--rip-border, #d9bc8d);
border-radius: 8px;
padding: 0.75rem 1rem;
background: var(--rip-panel, #fffaf1);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.converter-job-card.status-done {
border-color: var(--green-300, #74c69d);
background: #f6fff9;
}
.converter-job-card.status-error {
border-color: var(--red-300, #ff8080);
background: #fff5f5;
}
.converter-job-card.status-cancelled {
opacity: 0.7;
}
.converter-job-card-header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.converter-job-card-title-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.converter-job-card-title {
font-weight: 600;
font-size: 0.9rem;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.converter-job-card-meta {
font-size: 0.78rem;
color: var(--rip-muted, #6a4d38);
}
.converter-job-card-progress {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.converter-job-card-progress-meta {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--rip-muted, #6a4d38);
}
.converter-job-card-error {
color: var(--red-600, #c62828);
font-size: 0.8rem;
background: #fff0f0;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.converter-job-card-output {
font-size: 0.78rem;
color: var(--rip-muted, #6a4d38);
}
.converter-job-card-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
/* Inline-Konfiguration für READY_TO_START Jobs */
.converter-job-card.is-ready {
border-color: var(--amber-400, #f59e0b);
background: var(--surface-50, #fafaf9);
}
.cjc-config {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--surface-200, #e5e7eb);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cjc-config-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.cjc-config-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
flex: 1;
min-width: 160px;
}
.cjc-config-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.cjc-config-value {
display: flex;
align-items: center;
gap: 0.4rem;
padding-top: 0.1rem;
}
.cjc-config-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
padding-top: 0.25rem;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster",
"version": "0.12.0-14",
"version": "0.12.0-15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
"version": "0.12.0-14",
"version": "0.12.0-15",
"devDependencies": {
"concurrently": "^9.1.2"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
"version": "0.12.0-14",
"version": "0.12.0-15",
"scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend",