0.12.0-15 Fix DVD Review
This commit is contained in:
+16
-1
@@ -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;\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.12.0-14",
|
||||
"version": "0.12.0-15",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@@ -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')
|
||||
};
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -326,6 +326,13 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
|
||||
return profileHint;
|
||||
}
|
||||
|
||||
// Converter jobs: detected via media_type field (plan.mediaProfile = 'converter')
|
||||
const rawMediaType = normalizeMediaTypeValue(job?.media_type);
|
||||
const rawPlanProfile = String(plan?.mediaProfile || '').trim().toLowerCase();
|
||||
if (rawPlanProfile === 'converter' || rawMediaType === 'converter') {
|
||||
return 'converter';
|
||||
}
|
||||
|
||||
const statusCandidates = [
|
||||
job?.status,
|
||||
job?.last_state,
|
||||
@@ -530,6 +537,29 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
|
||||
const handbrakeInfo = parsed?.handbrakeInfo || parseJsonSafe(job?.handbrake_info_json, null);
|
||||
const mediaType = inferMediaType(job, mkInfo, miInfo, plan, handbrakeInfo);
|
||||
const directoryLikeOutput = isDirectoryLikeOutput(mediaType, plan, handbrakeInfo);
|
||||
|
||||
// Converter jobs use their own storage dirs — do not remap output path
|
||||
if (mediaType === 'converter') {
|
||||
const s = settings || {};
|
||||
const converterMediaType = String(plan?.converterMediaType || '').trim().toLowerCase();
|
||||
const converterMovieDir = converterMediaType === 'audio'
|
||||
? (String(s.converter_audio_dir || '').trim() || String(s.converter_movie_dir || '').trim())
|
||||
: String(s.converter_movie_dir || '').trim();
|
||||
const converterRawDir = String(s.converter_raw_dir || '').trim();
|
||||
return {
|
||||
mediaType: 'converter',
|
||||
directoryLikeOutput: false,
|
||||
rawDir: converterRawDir,
|
||||
movieDir: converterMovieDir,
|
||||
effectiveRawPath: job?.raw_path || null,
|
||||
effectiveOutputPath: job?.output_path || null,
|
||||
makemkvInfo: mkInfo,
|
||||
mediainfoInfo: miInfo,
|
||||
handbrakeInfo,
|
||||
encodePlan: plan
|
||||
};
|
||||
}
|
||||
|
||||
const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType);
|
||||
const rawDir = String(effectiveSettings?.raw_dir || '').trim();
|
||||
const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim();
|
||||
@@ -4325,8 +4355,20 @@ class HistoryService {
|
||||
} else {
|
||||
const rawPath = normalizeComparablePath(effectiveRawPath);
|
||||
const rawRoot = normalizeComparablePath(effectiveRawDir);
|
||||
const keepRoot = rawPath === rawRoot;
|
||||
const result = deleteFilesRecursively(effectiveRawPath, keepRoot);
|
||||
const stat = fs.lstatSync(rawPath);
|
||||
const isFile = stat.isFile();
|
||||
|
||||
// Für Converter-Jobs: Datei liegt in einem Unterordner → Ordner löschen
|
||||
let deletePath = rawPath;
|
||||
if (isFile && resolvedPaths.mediaType === 'converter') {
|
||||
const parentDir = normalizeComparablePath(path.dirname(rawPath));
|
||||
if (parentDir && parentDir !== rawRoot && isPathInside(rawRoot, parentDir)) {
|
||||
deletePath = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
const keepRoot = deletePath === rawRoot;
|
||||
const result = deleteFilesRecursively(deletePath, keepRoot);
|
||||
summary.raw.deleted = true;
|
||||
summary.raw.filesDeleted = result.filesDeleted;
|
||||
summary.raw.dirsRemoved = result.dirsRemoved;
|
||||
@@ -4371,7 +4413,9 @@ class HistoryService {
|
||||
summary.movie.reason = null;
|
||||
} else {
|
||||
const parentDir = normalizeComparablePath(path.dirname(trackedPath));
|
||||
const canDeleteParentDir = parentDir
|
||||
const isConverterJob = resolvedPaths.mediaType === 'converter';
|
||||
const canDeleteParentDir = !isConverterJob
|
||||
&& parentDir
|
||||
&& parentDir !== movieRoot
|
||||
&& isPathInside(movieRoot, parentDir)
|
||||
&& fs.existsSync(parentDir)
|
||||
@@ -4413,7 +4457,10 @@ class HistoryService {
|
||||
movieCandidatePathForTracking = outputPath;
|
||||
} else {
|
||||
const parentDir = normalizeComparablePath(path.dirname(outputPath));
|
||||
const canDeleteParentDir = parentDir
|
||||
// Converter jobs output a single file — never delete the parent dir
|
||||
const isConverterJob = resolvedPaths.mediaType === 'converter';
|
||||
const canDeleteParentDir = !isConverterJob
|
||||
&& parentDir
|
||||
&& parentDir !== movieRoot
|
||||
&& isPathInside(movieRoot, parentDir)
|
||||
&& fs.existsSync(parentDir)
|
||||
|
||||
@@ -19,7 +19,7 @@ const notificationService = require('./notificationService');
|
||||
const logger = require('./logger').child('PIPELINE');
|
||||
const { spawnTrackedProcess } = require('./processRunner');
|
||||
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
|
||||
const { ensureDir, sanitizeFileName, renderTemplate, findMediaFiles } = require('../utils/files');
|
||||
const { ensureDir, sanitizeFileName, sanitizeFileNameWithExtension, renderTemplate, findMediaFiles } = require('../utils/files');
|
||||
const { buildMediainfoReview } = require('../utils/encodePlan');
|
||||
const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
@@ -340,11 +340,14 @@ function normalizeMediaProfile(value) {
|
||||
if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') {
|
||||
return 'audiobook';
|
||||
}
|
||||
if (raw === 'converter') {
|
||||
return 'converter';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSpecificMediaProfile(value) {
|
||||
return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook';
|
||||
return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook' || value === 'converter';
|
||||
}
|
||||
|
||||
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
||||
@@ -2653,7 +2656,7 @@ function resolveRawFolderStateFromOptions(options = {}) {
|
||||
function buildRawDirName(metadataBase, jobId, options = {}) {
|
||||
const state = resolveRawFolderStateFromOptions(options);
|
||||
const baseName = sanitizeFileName(`${metadataBase} - RAW - job-${jobId}`);
|
||||
return sanitizeFileName(applyRawFolderStateToName(baseName, state));
|
||||
return applyRawFolderStateToName(baseName, state);
|
||||
}
|
||||
|
||||
function buildRawPathForState(rawPath, state) {
|
||||
@@ -3987,11 +3990,13 @@ class PipelineService extends EventEmitter {
|
||||
const { DVDPlugin } = require('../plugins/DVDPlugin');
|
||||
const { CdPlugin } = require('../plugins/CdPlugin');
|
||||
const { AudiobookPlugin } = require('../plugins/AudiobookPlugin');
|
||||
const { ConverterPlugin } = require('../plugins/ConverterPlugin');
|
||||
const plugins = [
|
||||
new BluRayPlugin(),
|
||||
new DVDPlugin(),
|
||||
new CdPlugin(),
|
||||
new AudiobookPlugin()
|
||||
new AudiobookPlugin(),
|
||||
new ConverterPlugin()
|
||||
];
|
||||
for (const plugin of plugins) {
|
||||
if (!registry.getPlugin(plugin.id)) {
|
||||
@@ -6087,15 +6092,20 @@ class PipelineService extends EventEmitter {
|
||||
try {
|
||||
while (this.queueEntries.length > 0) {
|
||||
// Get current running counts and limits
|
||||
const [filmRunning, cdRunning, maxFilm, maxCd, maxTotal, cdBypass] = await Promise.all([
|
||||
historyService.getRunningFilmEncodeJobs().then((r) => r.length),
|
||||
historyService.getRunningCdEncodeJobs().then((r) => r.length),
|
||||
const [allRunningJobs, maxFilm, maxCd, maxTotal, cdBypass] = await Promise.all([
|
||||
historyService.getRunningJobs(),
|
||||
this.getMaxParallelJobs(),
|
||||
this.getMaxParallelCdEncodes(),
|
||||
this.getMaxTotalEncodes(),
|
||||
this.getCdBypassesQueue()
|
||||
]);
|
||||
const filmRunning = allRunningJobs.filter((j) => j.status === 'ENCODING').length;
|
||||
const cdRunning = allRunningJobs.filter((j) => ['CD_RIPPING', 'CD_ENCODING'].includes(j.status)).length;
|
||||
const totalRunning = filmRunning + cdRunning;
|
||||
// Counts all actively processing jobs (excludes waiting states like READY_TO_ENCODE).
|
||||
const anyActiveJobs = allRunningJobs.filter(
|
||||
(j) => !['READY_TO_ENCODE', 'CD_READY_TO_RIP'].includes(j.status)
|
||||
).length;
|
||||
|
||||
// Find next startable entry
|
||||
let entryIndex = -1;
|
||||
@@ -6104,10 +6114,10 @@ class PipelineService extends EventEmitter {
|
||||
const isNonJob = candidate.type && candidate.type !== 'job';
|
||||
|
||||
if (isNonJob) {
|
||||
// Non-job entries (script, chain, wait) only start when no jobs are running.
|
||||
// Use both DB count and activeProcesses to cover all running states
|
||||
// (ANALYZING, RIPPING, MEDIAINFO_CHECK etc. are not reflected in totalRunning).
|
||||
if (totalRunning === 0 && this.activeProcesses.size === 0) {
|
||||
// Non-job entries (script, chain, wait) only start when no jobs are actively running.
|
||||
// anyActiveJobs covers all active states (ANALYZING, RIPPING, ENCODING, MEDIAINFO_CHECK etc.)
|
||||
// activeProcesses provides a second safety net for race conditions during state transitions.
|
||||
if (anyActiveJobs === 0 && this.activeProcesses.size === 0) {
|
||||
entryIndex = i;
|
||||
}
|
||||
break; // FIFO: stop scanning regardless (non-job blocks everything behind it)
|
||||
@@ -8654,6 +8664,9 @@ class PipelineService extends EventEmitter {
|
||||
if (preloadedMediaProfile === 'audiobook') {
|
||||
return this.startAudiobookEncode(jobId, { ...options, preloadedJob });
|
||||
}
|
||||
if (preloadedMediaProfile === 'converter') {
|
||||
return this.startConverterEncode(jobId, { ...options, preloadedJob });
|
||||
}
|
||||
|
||||
const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE';
|
||||
if (isReadyToEncode) {
|
||||
@@ -8711,6 +8724,9 @@ class PipelineService extends EventEmitter {
|
||||
if (jobMediaProfile === 'audiobook') {
|
||||
return this.startAudiobookEncode(jobId, { ...options, immediate: true, preloadedJob: job });
|
||||
}
|
||||
if (jobMediaProfile === 'converter') {
|
||||
return this.startConverterEncode(jobId, { ...options, immediate: true, preloadedJob: job });
|
||||
}
|
||||
|
||||
this.ensureNotBusy('startPreparedJob', jobId);
|
||||
logger.info('startPreparedJob:requested', { jobId });
|
||||
@@ -9878,6 +9894,7 @@ class PipelineService extends EventEmitter {
|
||||
let dvdHandBrakeScanJson = null;
|
||||
let dvdHandBrakeScanInputPath = null;
|
||||
let dvdSelectedTitleInfo = null; // set when HandBrake scan succeeds; used to skip re-selection
|
||||
let dvdScannedCandidates = null; // set when scan yields multiple MIN_LENGTH candidates needing user selection
|
||||
|
||||
if (isDvdSource && mediaFiles.length > 0) {
|
||||
// For 'dvd': scan the folder that contains VIDEO_TS (parent of the VIDEO_TS dir).
|
||||
@@ -9911,9 +9928,26 @@ class PipelineService extends EventEmitter {
|
||||
|
||||
dvdHandBrakeScanJson = parseMediainfoJsonOutput(dvdScanLines.join('\n'));
|
||||
if (dvdHandBrakeScanJson) {
|
||||
const titleInfo = parseHandBrakeSelectedTitleInfo(dvdHandBrakeScanJson);
|
||||
const minLengthMinutes = Number(settings?.makemkv_min_length_minutes || 0);
|
||||
const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60));
|
||||
const allDvdTitles = parseHandBrakeTitleList(dvdHandBrakeScanJson);
|
||||
const filteredDvdCandidates = minLengthSeconds > 0
|
||||
? allDvdTitles.filter((t) => t.durationSeconds >= minLengthSeconds)
|
||||
: allDvdTitles;
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`HandBrake DVD-Scan: ${allDvdTitles.length} Titel gesamt, ${filteredDvdCandidates.length} ≥ ${minLengthMinutes} min.`
|
||||
);
|
||||
// For synthetic mediaInfo: prefer the single MIN_LENGTH candidate; otherwise fall back to
|
||||
// the best available title (longest / main feature) as a placeholder for the review.
|
||||
const preferredTitleId = filteredDvdCandidates.length === 1
|
||||
? filteredDvdCandidates[0].handBrakeTitleId
|
||||
: null;
|
||||
const titleInfo = parseHandBrakeSelectedTitleInfo(dvdHandBrakeScanJson, {
|
||||
handBrakeTitleId: preferredTitleId || undefined
|
||||
});
|
||||
if (titleInfo) {
|
||||
dvdSelectedTitleInfo = titleInfo;
|
||||
const syntheticMediaInfo = buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo);
|
||||
mediaInfoByPath[dvdHandBrakeScanInputPath] = syntheticMediaInfo;
|
||||
effectiveMediaFiles = [{ path: dvdHandBrakeScanInputPath, size: 0 }];
|
||||
@@ -9924,6 +9958,16 @@ class PipelineService extends EventEmitter {
|
||||
'SYSTEM',
|
||||
`HandBrake DVD-Scan: Audio=${audioCount}, Subtitle=${subtitleCount}, Dauer=${formatDurationClock(titleInfo.durationSeconds)}`
|
||||
);
|
||||
if (filteredDvdCandidates.length === 1) {
|
||||
// Exactly one title passes MIN_LENGTH → auto-select it.
|
||||
dvdSelectedTitleInfo = titleInfo;
|
||||
} else if (filteredDvdCandidates.length > 1) {
|
||||
// Multiple candidates → user must choose; store for downstream selection dialog.
|
||||
dvdScannedCandidates = filteredDvdCandidates;
|
||||
} else {
|
||||
// No title passes MIN_LENGTH filter → auto-select best available (degraded mode).
|
||||
dvdSelectedTitleInfo = titleInfo;
|
||||
}
|
||||
} else {
|
||||
const error = new Error('HandBrake DVD-Scan: Kein Titel erkannt.');
|
||||
error.runInfo = dvdScanRunInfo;
|
||||
@@ -10092,9 +10136,8 @@ class PipelineService extends EventEmitter {
|
||||
`HandBrake Titel-Auswahl (manuell) übernommen: -t ${preSelectedHandBrakeTitleId}`
|
||||
);
|
||||
} else if (dvdSelectedTitleInfo) {
|
||||
// HandBrake already identified the valid title during the DVD analysis scan above.
|
||||
// Use it directly — no need to re-parse all titles from TitleList, which would
|
||||
// include short/invalid titles and incorrectly trigger manual title selection.
|
||||
// Exactly one title passed the MIN_LENGTH filter (or fallback to best available) —
|
||||
// auto-select it without requiring user input.
|
||||
enrichedReview = {
|
||||
...enrichedReview,
|
||||
handBrakeTitleId: dvdSelectedTitleInfo.handBrakeTitleId,
|
||||
@@ -10105,6 +10148,77 @@ class PipelineService extends EventEmitter {
|
||||
'SYSTEM',
|
||||
`HandBrake DVD Titel automatisch gewählt (aus Scan): -t ${dvdSelectedTitleInfo.handBrakeTitleId}`
|
||||
);
|
||||
} else if (dvdScannedCandidates && dvdScannedCandidates.length > 0) {
|
||||
// Multiple titles passed the MIN_LENGTH filter during the initial DVD scan —
|
||||
// present them to the user without running a second HandBrake scan.
|
||||
const candidatesData = dvdScannedCandidates.map((t) => ({
|
||||
handBrakeTitleId: t.handBrakeTitleId,
|
||||
durationSeconds: t.durationSeconds,
|
||||
durationMinutes: Number((t.durationSeconds / 60).toFixed(1)),
|
||||
audioTrackCount: t.audioTrackCount,
|
||||
subtitleTrackCount: t.subtitleTrackCount,
|
||||
sizeBytes: t.sizeBytes || 0
|
||||
}));
|
||||
enrichedReview = {
|
||||
...enrichedReview,
|
||||
handBrakeTitleDecisionRequired: true,
|
||||
handBrakeTitleCandidates: candidatesData,
|
||||
handBrakeDvdInputPath: dvdScanInputPath,
|
||||
encodeInputPath: null
|
||||
};
|
||||
const waitingMediainfoInfo = this.withPluginExecutionMeta({
|
||||
generatedAt: nowIso(),
|
||||
files: mediaInfoRuns
|
||||
}, reviewPluginExecution);
|
||||
await historyService.updateJob(jobId, {
|
||||
status: 'WAITING_FOR_USER_DECISION',
|
||||
last_state: 'WAITING_FOR_USER_DECISION',
|
||||
error_message: null,
|
||||
mediainfo_info_json: JSON.stringify(waitingMediainfoInfo),
|
||||
encode_plan_json: JSON.stringify(enrichedReview),
|
||||
encode_input_path: null,
|
||||
encode_review_confirmed: 0
|
||||
});
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`HandBrake DVD Titelauswahl erforderlich: ${candidatesData.length} Kandidaten. Nutzer muss Titel wählen.`
|
||||
);
|
||||
const dvdWaitingStatusText = `DVD Titelauswahl: ${candidatesData.length} Kandidaten gefunden`;
|
||||
const dvdWaitingContextPatch = {
|
||||
jobId,
|
||||
rawPath,
|
||||
handBrakeTitleDecisionRequired: true,
|
||||
handBrakeTitleCandidates: candidatesData,
|
||||
handBrakeDvdInputPath: dvdScanInputPath,
|
||||
reviewConfirmed: false,
|
||||
mode: options.mode || 'rip',
|
||||
mediaProfile,
|
||||
sourceJobId: options.sourceJobId || null,
|
||||
mediaInfoReview: enrichedReview,
|
||||
selectedMetadata
|
||||
};
|
||||
if (this.isPrimaryJob(jobId)) {
|
||||
await this.setState('WAITING_FOR_USER_DECISION', {
|
||||
activeJobId: jobId,
|
||||
progress: 0,
|
||||
eta: null,
|
||||
statusText: dvdWaitingStatusText,
|
||||
context: {
|
||||
...dvdWaitingContextPatch,
|
||||
...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {})
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, {
|
||||
contextPatch: dvdWaitingContextPatch
|
||||
});
|
||||
}
|
||||
void this.notifyPushover('metadata_ready', {
|
||||
title: 'Ripster - DVD Titelauswahl',
|
||||
message: `Job #${jobId}: ${candidatesData.length} DVD-Titel gefunden, manuelle Auswahl erforderlich`
|
||||
});
|
||||
return enrichedReview;
|
||||
} else {
|
||||
// No pre-scanned result available — run a fresh HandBrake scan for title selection.
|
||||
let dvdTitleScanJson = null;
|
||||
@@ -14185,6 +14299,209 @@ class PipelineService extends EventEmitter {
|
||||
return { started: true, stage: 'ENCODING', outputPath: incompleteOutputPath };
|
||||
}
|
||||
|
||||
// ── Converter Pipeline ───────────────────────────────────────────────────────
|
||||
|
||||
async startConverterEncode(jobId, options = {}) {
|
||||
const immediate = Boolean(options?.immediate);
|
||||
if (!immediate) {
|
||||
return this.enqueueOrStartAction(
|
||||
QUEUE_ACTIONS.START_PREPARED,
|
||||
jobId,
|
||||
() => this.startConverterEncode(jobId, { ...options, immediate: true })
|
||||
);
|
||||
}
|
||||
|
||||
this.ensureNotBusy('startConverterEncode', jobId);
|
||||
logger.info('converter:encode:start', { jobId });
|
||||
this.cancelRequestedByJob.delete(Number(jobId));
|
||||
|
||||
const job = options?.preloadedJob || await historyService.getJobById(jobId);
|
||||
if (!job) {
|
||||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const encodePlan = this.safeParseJson(job.encode_plan_json) || {};
|
||||
const inputPath = encodePlan.inputPath || job.raw_path;
|
||||
|
||||
if (!inputPath || !fs.existsSync(inputPath)) {
|
||||
const error = new Error(`Converter-Eingabedatei nicht gefunden: ${inputPath}`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const outputPath = encodePlan.outputPath || null;
|
||||
const outputDir = encodePlan.outputDir || null;
|
||||
|
||||
await historyService.resetProcessLog(jobId);
|
||||
await this.setState('ANALYZING', {
|
||||
activeJobId: jobId,
|
||||
progress: 0,
|
||||
eta: null,
|
||||
statusText: 'Converter: Analyse läuft …',
|
||||
context: {
|
||||
jobId,
|
||||
mode: 'converter',
|
||||
mediaProfile: 'converter',
|
||||
inputPath,
|
||||
converterMediaType: encodePlan.converterMediaType || 'video'
|
||||
}
|
||||
});
|
||||
|
||||
await historyService.updateJob(jobId, {
|
||||
status: 'ANALYZING',
|
||||
last_state: 'ANALYZING',
|
||||
start_time: nowIso(),
|
||||
end_time: null,
|
||||
error_message: null
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { ConverterPlugin } = require('../plugins/ConverterPlugin');
|
||||
const converterPlugin = new ConverterPlugin();
|
||||
|
||||
const analyzeCtx = await this.buildPluginContext('converter', {
|
||||
jobId,
|
||||
filePath: inputPath,
|
||||
encodePlan,
|
||||
emitProgress: (pct, statusText) => {
|
||||
void this.updateProgress('ANALYZING', pct, null, statusText, jobId);
|
||||
}
|
||||
});
|
||||
await converterPlugin.analyze(inputPath, job, analyzeCtx);
|
||||
|
||||
if (this.cancelRequestedByJob.has(Number(jobId))) {
|
||||
const cancelErr = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||
cancelErr.runInfo = { status: 'CANCELLED' };
|
||||
throw cancelErr;
|
||||
}
|
||||
|
||||
await this.setState('ENCODING', {
|
||||
activeJobId: jobId,
|
||||
progress: 0,
|
||||
eta: null,
|
||||
statusText: 'Converter: Encoding läuft …',
|
||||
context: {
|
||||
jobId,
|
||||
mode: 'converter',
|
||||
mediaProfile: 'converter',
|
||||
inputPath,
|
||||
outputPath: outputPath || outputDir,
|
||||
converterMediaType: encodePlan.converterMediaType || 'video'
|
||||
}
|
||||
});
|
||||
|
||||
await historyService.updateJob(jobId, {
|
||||
status: 'ENCODING',
|
||||
last_state: 'ENCODING'
|
||||
});
|
||||
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Converter-Encoding gestartet: ${path.basename(inputPath)} → ${encodePlan.converterMediaType || 'video'} / ${encodePlan.outputFormat || '?'}`
|
||||
);
|
||||
|
||||
void this.notifyPushover('encoding_started', {
|
||||
title: 'Ripster - Converter gestartet',
|
||||
message: `${job.title || job.detected_title || `Job #${jobId}`} → ${outputPath || outputDir || '?'}`
|
||||
});
|
||||
|
||||
const encodeCtx = await this.buildPluginContext('converter', {
|
||||
jobId,
|
||||
inputPath,
|
||||
outputPath,
|
||||
outputDir,
|
||||
encodePlan,
|
||||
isCancelled: () => this.cancelRequestedByJob.has(Number(jobId)),
|
||||
onProcessHandle: (handle) => {
|
||||
if (handle) {
|
||||
this.activeProcesses.set(Number(jobId), handle);
|
||||
}
|
||||
},
|
||||
emitProgress: (pct, statusText) => {
|
||||
void this.updateProgress('ENCODING', pct, null, statusText, jobId);
|
||||
}
|
||||
});
|
||||
|
||||
await converterPlugin.encode(job, encodeCtx);
|
||||
this.activeProcesses.delete(Number(jobId));
|
||||
|
||||
const finalOutputPath = outputPath || outputDir || null;
|
||||
|
||||
// Eigentümer der Ausgabedateien setzen
|
||||
if (finalOutputPath) {
|
||||
const converterSettings = await settingsService.getSettingsMap();
|
||||
const converterMediaType = encodePlan.converterMediaType || 'video';
|
||||
const outputOwner = converterMediaType === 'audio'
|
||||
? String(converterSettings?.converter_audio_dir_owner || converterSettings?.converter_movie_dir_owner || '').trim()
|
||||
: String(converterSettings?.converter_movie_dir_owner || '').trim();
|
||||
if (outputOwner) {
|
||||
chownRecursive(finalOutputPath, outputOwner);
|
||||
}
|
||||
}
|
||||
|
||||
await historyService.updateJob(jobId, {
|
||||
status: 'FINISHED',
|
||||
last_state: 'FINISHED',
|
||||
end_time: nowIso(),
|
||||
rip_successful: 1,
|
||||
output_path: finalOutputPath,
|
||||
error_message: null
|
||||
});
|
||||
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Converter-Encoding abgeschlossen: ${finalOutputPath}`
|
||||
);
|
||||
|
||||
if (Number(this.snapshot.activeJobId) === Number(jobId)) {
|
||||
await this.setState('FINISHED', {
|
||||
activeJobId: jobId,
|
||||
progress: 100,
|
||||
eta: null,
|
||||
statusText: 'Converter abgeschlossen',
|
||||
context: {
|
||||
jobId,
|
||||
mode: 'converter',
|
||||
mediaProfile: 'converter',
|
||||
outputPath: finalOutputPath
|
||||
}
|
||||
});
|
||||
} else {
|
||||
void this.pumpQueue();
|
||||
}
|
||||
|
||||
void this.notifyPushover('job_finished', {
|
||||
title: 'Ripster - Converter abgeschlossen',
|
||||
message: `${job.title || job.detected_title || `Job #${jobId}`} → ${finalOutputPath}`
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) {
|
||||
await this.setState('IDLE', {
|
||||
finishingJobId: jobId,
|
||||
activeJobId: null,
|
||||
progress: 0,
|
||||
eta: null,
|
||||
statusText: 'Bereit',
|
||||
context: {}
|
||||
});
|
||||
}
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
this.activeProcesses.delete(Number(jobId));
|
||||
logger.error('converter:encode:failed', { jobId, error: errorToMeta(error) });
|
||||
await this.failJob(jobId, 'ENCODING', error);
|
||||
}
|
||||
})();
|
||||
|
||||
return { started: true, stage: 'ANALYZING', outputPath: outputPath || outputDir };
|
||||
}
|
||||
|
||||
// ── CD Pipeline ─────────────────────────────────────────────────────────────
|
||||
|
||||
async analyzeCd(device, options = {}) {
|
||||
@@ -15634,6 +15951,415 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CONVERTER
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Erstellt einen Converter-Job aus einem Scan-Eintrag (File-Explorer-Auswahl).
|
||||
*
|
||||
* @param {string} relPath - Relativer Pfad innerhalb von converter_raw_dir
|
||||
* @param {object} options
|
||||
* @param {string} options.converterMediaType - 'video'|'audio'|'iso'
|
||||
* @param {boolean} options.isFolder - Ordner-Job?
|
||||
* @returns {Promise<object>} Job-Objekt
|
||||
*/
|
||||
async createConverterJobFromEntry(relPath, options = {}) {
|
||||
const converterScanService = require('./converterScanService');
|
||||
const rawDir = await converterScanService.getRawDir();
|
||||
|
||||
if (!rawDir) {
|
||||
const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const fullPath = path.join(rawDir, relPath);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
const error = new Error(`Eingabedatei nicht gefunden: ${fullPath}`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const stat = fs.statSync(fullPath);
|
||||
const isDirectory = stat.isDirectory();
|
||||
const baseName = path.basename(relPath, path.extname(relPath));
|
||||
const detectedTitle = baseName || 'Converter Job';
|
||||
|
||||
const converterMediaType = options.converterMediaType
|
||||
|| converterScanService.detectMediaType(path.basename(relPath));
|
||||
|
||||
const job = await historyService.createJob({
|
||||
discDevice: null,
|
||||
status: 'READY_TO_START',
|
||||
detectedTitle
|
||||
});
|
||||
|
||||
await historyService.updateJob(job.id, {
|
||||
media_type: 'converter',
|
||||
raw_path: fullPath,
|
||||
encode_plan_json: JSON.stringify({
|
||||
mediaProfile: 'converter',
|
||||
converterMediaType,
|
||||
inputPath: fullPath,
|
||||
isFolder: isDirectory,
|
||||
audioFiles: isDirectory && converterMediaType === 'audio'
|
||||
? require('./converterScanService').detectFormat
|
||||
? null // wird beim Start gefüllt
|
||||
: null
|
||||
: null
|
||||
}),
|
||||
last_state: 'READY_TO_START'
|
||||
});
|
||||
|
||||
// Scan-Eintrag als Job markieren
|
||||
await converterScanService.markEntryAsJob(relPath, job.id);
|
||||
|
||||
await historyService.appendLog(
|
||||
job.id,
|
||||
'SYSTEM',
|
||||
`Converter-Job erstellt aus: ${relPath} | Typ: ${converterMediaType}`
|
||||
);
|
||||
|
||||
logger.info('converter:job:created-from-entry', {
|
||||
jobId: job.id, relPath, converterMediaType, fullPath
|
||||
});
|
||||
|
||||
return historyService.getJobById(job.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt Converter-Dateien hoch und legt Unterordner in converter_raw_dir an.
|
||||
* Erstellt KEINE Jobs – das geschieht separat via createConverterJobsFromSelection.
|
||||
*
|
||||
* @param {Array<{path, originalname, size}>} files - Multer-Dateiobjekte
|
||||
* @returns {Promise<{folders: Array<{folderRelPath, fileCount}>}>}
|
||||
*/
|
||||
async uploadConverterFiles(files, options = {}) {
|
||||
const converterScanService = require('./converterScanService');
|
||||
const rawDir = await converterScanService.getRawDir();
|
||||
|
||||
if (!rawDir) {
|
||||
const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
ensureDir(rawDir);
|
||||
|
||||
const normalizedFiles = Array.isArray(files) ? files : [files];
|
||||
if (normalizedFiles.length === 0) {
|
||||
const error = new Error('Keine Dateien zum Upload.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const folderName = options?.folderName ? String(options.folderName).trim() : null;
|
||||
|
||||
if (folderName) {
|
||||
// ── Ordner-Upload (wie Klangkiste target_path): alle Dateien in EINEN Ordner ──
|
||||
const safeFolderName = sanitizeFileName(folderName) || `upload_${Date.now()}`;
|
||||
const uniqueFolderName = `${safeFolderName}_${Date.now()}`;
|
||||
const targetDir = path.join(rawDir, uniqueFolderName);
|
||||
ensureDir(targetDir);
|
||||
|
||||
for (const file of normalizedFiles) {
|
||||
const tempPath = String(file?.path || '').trim();
|
||||
if (!tempPath || !fs.existsSync(tempPath)) continue;
|
||||
|
||||
const rawName = String(file?.originalname || '').trim();
|
||||
const safeFileName = sanitizeFileNameWithExtension(rawName) || rawName || path.basename(tempPath);
|
||||
const targetFile = path.join(targetDir, safeFileName);
|
||||
|
||||
// Traversal-Schutz
|
||||
if (!targetFile.startsWith(targetDir + path.sep)) {
|
||||
logger.warn('converter:upload:traversal-blocked', { rawName });
|
||||
continue;
|
||||
}
|
||||
|
||||
moveFileWithFallback(tempPath, targetFile);
|
||||
}
|
||||
|
||||
logger.info('converter:upload:folder-placed', {
|
||||
folderName, targetDir, fileCount: normalizedFiles.length
|
||||
});
|
||||
|
||||
return { folders: [{ folderRelPath: uniqueFolderName, fileCount: normalizedFiles.length }] };
|
||||
}
|
||||
|
||||
// ── Einzeldatei-Upload ─────────────────────────────────────────────────
|
||||
const createdFolders = [];
|
||||
for (const file of normalizedFiles) {
|
||||
const tempPath = String(file?.path || '').trim();
|
||||
const originalName = String(file?.originalname || file?.originalName || '').trim()
|
||||
|| path.basename(tempPath || 'upload');
|
||||
|
||||
if (!tempPath || !fs.existsSync(tempPath)) {
|
||||
logger.warn('converter:upload:temp-missing', { originalName, tempPath });
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseNameClean = sanitizeFileName(
|
||||
path.basename(originalName, path.extname(originalName))
|
||||
) || `upload_${Date.now()}`;
|
||||
const subfolder = `${baseNameClean}_${Date.now()}`;
|
||||
const targetDir = path.join(rawDir, subfolder);
|
||||
ensureDir(targetDir);
|
||||
|
||||
const safeFileName = sanitizeFileNameWithExtension(originalName) || originalName;
|
||||
const targetFile = path.join(targetDir, safeFileName);
|
||||
moveFileWithFallback(tempPath, targetFile);
|
||||
|
||||
logger.info('converter:upload:file-placed', { originalName, targetFile });
|
||||
|
||||
createdFolders.push({ folderRelPath: subfolder, fileCount: 1 });
|
||||
}
|
||||
|
||||
return { folders: createdFolders };
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt Converter-Jobs aus ausgewählten Dateipfaden (relativ zu converter_raw_dir).
|
||||
* Audio-Dateien können als einzelne Jobs oder als ein gemeinsamer Job angelegt werden.
|
||||
*
|
||||
* @param {string[]} relPaths - Dateipfade relativ zum rawDir
|
||||
* @param {'individual'|'shared'} audioMode - Modus für Audio-Dateien
|
||||
* @returns {Promise<object[]>} Erstellte Jobs
|
||||
*/
|
||||
async createConverterJobsFromSelection(relPaths, audioMode = 'individual') {
|
||||
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']);
|
||||
const converterScanService = require('./converterScanService');
|
||||
const rawDir = await converterScanService.getRawDir();
|
||||
|
||||
if (!rawDir) {
|
||||
const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const audioRelPaths = [];
|
||||
const nonAudioRelPaths = [];
|
||||
|
||||
for (const relPath of relPaths) {
|
||||
const ext = path.extname(String(relPath || '')).toLowerCase();
|
||||
if (AUDIO_EXTS.has(ext)) {
|
||||
audioRelPaths.push(relPath);
|
||||
} else {
|
||||
nonAudioRelPaths.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
const createdJobs = [];
|
||||
|
||||
// Nicht-Audio (Video, ISO, Ordner): immer ein Job pro Eintrag
|
||||
for (const relPath of nonAudioRelPaths) {
|
||||
const job = await this.createConverterJobFromEntry(relPath, {});
|
||||
createdJobs.push(job);
|
||||
}
|
||||
|
||||
if (audioMode === 'shared' && audioRelPaths.length > 0) {
|
||||
// Ein gemeinsamer Job für alle Audio-Dateien
|
||||
const fullPaths = audioRelPaths.map((p) => path.join(rawDir, p));
|
||||
const rawPath = path.dirname(fullPaths[0]);
|
||||
const detectedTitle = path.basename(rawPath) || 'Converter Audio Job';
|
||||
|
||||
const job = await historyService.createJob({
|
||||
discDevice: null,
|
||||
status: 'READY_TO_START',
|
||||
detectedTitle
|
||||
});
|
||||
|
||||
await historyService.updateJob(job.id, {
|
||||
media_type: 'converter',
|
||||
raw_path: rawPath,
|
||||
encode_plan_json: JSON.stringify({
|
||||
mediaProfile: 'converter',
|
||||
converterMediaType: 'audio',
|
||||
inputPath: rawPath,
|
||||
inputPaths: fullPaths,
|
||||
isFolder: false,
|
||||
isSharedAudio: true
|
||||
}),
|
||||
last_state: 'READY_TO_START'
|
||||
});
|
||||
|
||||
await historyService.appendLog(
|
||||
job.id, 'SYSTEM',
|
||||
`Converter-Job (gemeinsam) erstellt: ${audioRelPaths.length} Audio-Datei(en)`
|
||||
);
|
||||
|
||||
logger.info('converter:job:created-shared-audio', {
|
||||
jobId: job.id, fileCount: audioRelPaths.length
|
||||
});
|
||||
|
||||
createdJobs.push(await historyService.getJobById(job.id));
|
||||
} else {
|
||||
// Einzelne Jobs für jede Audio-Datei
|
||||
for (const relPath of audioRelPaths) {
|
||||
const job = await this.createConverterJobFromEntry(relPath, { converterMediaType: 'audio' });
|
||||
createdJobs.push(job);
|
||||
}
|
||||
}
|
||||
|
||||
return createdJobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet einen Converter-Job mit der finalen Konfiguration (Preset, Format etc.).
|
||||
*
|
||||
* @param {number} jobId
|
||||
* @param {object} config
|
||||
* @param {string} config.converterMediaType - 'video'|'audio'|'iso'
|
||||
* @param {string} config.outputFormat - Ausgabeformat (mkv, mp4, flac, mp3, ...)
|
||||
* @param {object} [config.userPreset] - HandBrake User-Preset (Video)
|
||||
* @param {object} [config.trackSelection] - Audio/Subtitle-Selektion (Video)
|
||||
* @param {number} [config.handBrakeTitleId] - HandBrake Titel-ID (Video)
|
||||
* @param {object} [config.audioFormatOptions] - Format-Optionen (Audio)
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async startConverterJob(jobId, config = {}) {
|
||||
const normalizedJobId = Number(jobId);
|
||||
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
|
||||
const error = new Error('Ungültige Job-ID für Converter-Start.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const job = await historyService.getJobById(normalizedJobId);
|
||||
if (!job) {
|
||||
const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingPlan = this.safeParseJson(job.encode_plan_json) || {};
|
||||
const converterMediaType = config.converterMediaType
|
||||
|| existingPlan.converterMediaType
|
||||
|| 'video';
|
||||
|
||||
const converterScanService = require('./converterScanService');
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const rawDir = String(
|
||||
settings?.converter_raw_dir || require('../config').defaultConverterRawDir || ''
|
||||
).trim();
|
||||
const movieDir = String(
|
||||
settings?.converter_movie_dir || require('../config').defaultConverterMovieDir || ''
|
||||
).trim();
|
||||
const audioDir = String(
|
||||
settings?.converter_audio_dir || require('../config').defaultConverterAudioDir || ''
|
||||
).trim();
|
||||
|
||||
const inputPath = existingPlan.inputPath || job.raw_path;
|
||||
if (!inputPath || !fs.existsSync(inputPath)) {
|
||||
const error = new Error(`Eingabedatei nicht gefunden: ${inputPath}`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Ausgabepfad bestimmen
|
||||
let outputPath = null;
|
||||
let outputDir = null;
|
||||
const outputFormat = String(config.outputFormat || 'mkv').trim().toLowerCase();
|
||||
const baseName = sanitizeFileName(
|
||||
path.basename(inputPath, path.extname(inputPath))
|
||||
) || 'output';
|
||||
const title = job.title || job.detected_title || baseName;
|
||||
const safeTitle = sanitizeFileName(title) || baseName;
|
||||
|
||||
if (converterMediaType === 'video' || converterMediaType === 'iso') {
|
||||
const videoTemplate = String(
|
||||
settings?.converter_output_template_video || '{title}'
|
||||
).replace('{title}', safeTitle);
|
||||
outputPath = path.join(movieDir, `${videoTemplate}.${outputFormat}`);
|
||||
} else if (converterMediaType === 'audio') {
|
||||
const isFolder = Boolean(existingPlan.isFolder || config.isFolder);
|
||||
if (isFolder) {
|
||||
const audioTemplate = String(
|
||||
settings?.converter_output_template_audio || '{artist} - {title}'
|
||||
).replace('{title}', safeTitle).replace('{artist}', safeTitle);
|
||||
outputDir = path.join(audioDir, sanitizeFileName(audioTemplate) || safeTitle);
|
||||
} else {
|
||||
const audioTemplate = String(
|
||||
settings?.converter_output_template_audio || '{artist} - {title}'
|
||||
).replace('{title}', safeTitle).replace('{artist}', safeTitle);
|
||||
outputPath = path.join(audioDir, `${sanitizeFileName(audioTemplate) || safeTitle}.${outputFormat}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Audiodateien im Ordner laden
|
||||
let audioFiles = existingPlan.audioFiles;
|
||||
if (converterMediaType === 'audio' && existingPlan.isFolder && !audioFiles) {
|
||||
const { readdirSync } = fs;
|
||||
const AUDIO_EXTS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']);
|
||||
try {
|
||||
audioFiles = readdirSync(inputPath)
|
||||
.filter((f) => AUDIO_EXTS.has(path.extname(f).slice(1).toLowerCase()))
|
||||
.sort();
|
||||
} catch (_err) {
|
||||
audioFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
const nextEncodePlan = {
|
||||
...existingPlan,
|
||||
mediaProfile: 'converter',
|
||||
converterMediaType,
|
||||
inputPath,
|
||||
outputPath: outputPath || null,
|
||||
outputDir: outputDir || null,
|
||||
outputFormat,
|
||||
userPreset: config.userPreset || existingPlan.userPreset || null,
|
||||
trackSelection: config.trackSelection || existingPlan.trackSelection || null,
|
||||
handBrakeTitleId: config.handBrakeTitleId || existingPlan.handBrakeTitleId || null,
|
||||
audioFormatOptions: config.audioFormatOptions || existingPlan.audioFormatOptions || {},
|
||||
audioFiles: audioFiles || existingPlan.audioFiles || null,
|
||||
reviewConfirmed: true
|
||||
};
|
||||
|
||||
await historyService.updateJob(normalizedJobId, {
|
||||
status: 'READY_TO_START',
|
||||
last_state: 'READY_TO_START',
|
||||
media_type: 'converter',
|
||||
output_path: outputPath || outputDir || null,
|
||||
encode_plan_json: JSON.stringify(nextEncodePlan),
|
||||
encode_review_confirmed: 1,
|
||||
error_message: null,
|
||||
end_time: null
|
||||
});
|
||||
|
||||
await historyService.appendLog(
|
||||
normalizedJobId,
|
||||
'USER_ACTION',
|
||||
`Converter konfiguriert: Typ ${converterMediaType} | Format ${outputFormat}`
|
||||
);
|
||||
|
||||
const startResult = await this.startPreparedJob(normalizedJobId);
|
||||
return {
|
||||
jobId: normalizedJobId,
|
||||
...(startResult && typeof startResult === 'object' ? startResult : {})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Converter-Jobs zurück (für die Converter-Seite).
|
||||
*/
|
||||
async getConverterJobs() {
|
||||
const db = await require('../db/database').getDb();
|
||||
const rows = await db.all(`
|
||||
SELECT id, title, detected_title, status, last_state, media_type,
|
||||
encode_plan_json, output_path, raw_path, error_message,
|
||||
start_time, end_time, created_at, updated_at
|
||||
FROM jobs
|
||||
WHERE media_type = 'converter'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
`);
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
encodePlan: this.safeParseJson(r.encode_plan_json)
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = new PipelineService();
|
||||
|
||||
@@ -19,7 +19,10 @@ const {
|
||||
defaultCdDir: DEFAULT_CD_DIR,
|
||||
defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR,
|
||||
defaultDownloadDir: DEFAULT_DOWNLOAD_DIR
|
||||
defaultDownloadDir: DEFAULT_DOWNLOAD_DIR,
|
||||
defaultConverterRawDir: DEFAULT_CONVERTER_RAW_DIR,
|
||||
defaultConverterMovieDir: DEFAULT_CONVERTER_MOVIE_DIR,
|
||||
defaultConverterAudioDir: DEFAULT_CONVERTER_AUDIO_DIR
|
||||
} = require('../config');
|
||||
|
||||
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
|
||||
@@ -825,13 +828,21 @@ class SettingsService {
|
||||
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
|
||||
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
|
||||
downloads: { path: bluray.download_dir },
|
||||
converter: {
|
||||
raw: String(map.converter_raw_dir || '').trim() || DEFAULT_CONVERTER_RAW_DIR,
|
||||
movies: String(map.converter_movie_dir || '').trim() || DEFAULT_CONVERTER_MOVIE_DIR,
|
||||
audio: String(map.converter_audio_dir || '').trim() || DEFAULT_CONVERTER_AUDIO_DIR
|
||||
},
|
||||
defaults: {
|
||||
raw: DEFAULT_RAW_DIR,
|
||||
movies: DEFAULT_MOVIE_DIR,
|
||||
cd: DEFAULT_CD_DIR,
|
||||
audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
audiobookMovies: DEFAULT_AUDIOBOOK_DIR,
|
||||
downloads: DEFAULT_DOWNLOAD_DIR
|
||||
downloads: DEFAULT_DOWNLOAD_DIR,
|
||||
converterRaw: DEFAULT_CONVERTER_RAW_DIR,
|
||||
converterMovies: DEFAULT_CONVERTER_MOVIE_DIR,
|
||||
converterAudio: DEFAULT_CONVERTER_AUDIO_DIR
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.12.0-14",
|
||||
"version": "0.12.0-15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (0–12)</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 (0–12)</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 }] : [])
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user