From 76b020d5c0a1befbd81bc5f833add20226a79089 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Tue, 24 Mar 2026 07:05:45 +0000 Subject: [PATCH] 0.12.0-15 Fix DVD Review --- .claude/settings.json | 17 +- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/config.js | 5 +- backend/src/db/database.js | 56 +- backend/src/index.js | 5 + backend/src/plugins/ConverterPlugin.js | 515 ++++++++++++ backend/src/routes/converterRoutes.js | 321 ++++++++ backend/src/services/converterScanService.js | 556 +++++++++++++ backend/src/services/historyService.js | 55 +- backend/src/services/pipelineService.js | 758 +++++++++++++++++- backend/src/services/settingsService.js | 15 +- backend/src/utils/files.js | 2 +- db/schema.sql | 57 ++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/App.jsx | 3 + frontend/src/api/client.js | 63 ++ .../src/components/ConverterFileExplorer.jsx | 725 +++++++++++++++++ frontend/src/components/ConverterJobCard.jsx | 440 ++++++++++ .../components/ConverterJobConfigDialog.jsx | 308 +++++++ .../src/components/ConverterUploadPanel.jsx | 224 ++++++ .../src/components/DynamicSettingsForm.jsx | 38 +- frontend/src/pages/ConverterPage.jsx | 377 +++++++++ frontend/src/pages/DashboardPage.jsx | 6 + frontend/src/pages/HistoryPage.jsx | 21 +- frontend/src/pages/SettingsPage.jsx | 33 +- frontend/src/styles/app.css | 574 +++++++++++++ package-lock.json | 4 +- package.json | 2 +- 30 files changed, 5148 insertions(+), 44 deletions(-) create mode 100644 backend/src/plugins/ConverterPlugin.js create mode 100644 backend/src/routes/converterRoutes.js create mode 100644 backend/src/services/converterScanService.js create mode 100644 frontend/src/components/ConverterFileExplorer.jsx create mode 100644 frontend/src/components/ConverterJobCard.jsx create mode 100644 frontend/src/components/ConverterJobConfigDialog.jsx create mode 100644 frontend/src/components/ConverterUploadPanel.jsx create mode 100644 frontend/src/pages/ConverterPage.jsx diff --git a/.claude/settings.json b/.claude/settings.json index a622031..1844cfd 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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;\")" ] } } diff --git a/backend/package-lock.json b/backend/package-lock.json index 888ef77..6b1ee67 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index ff66423..64bd849 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.12.0-14", + "version": "0.12.0-15", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/config.js b/backend/src/config.js index 6402427..250618f 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -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') }; diff --git a/backend/src/db/database.js b/backend/src/db/database.js index d9c1784..4158108 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -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)` diff --git a/backend/src/index.js b/backend/src/index.js index cb5accb..9f66233 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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(); diff --git a/backend/src/plugins/ConverterPlugin.js b/backend/src/plugins/ConverterPlugin.js new file mode 100644 index 0000000..7222575 --- /dev/null +++ b/backend/src/plugins/ConverterPlugin.js @@ -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 }; diff --git a/backend/src/routes/converterRoutes.js b/backend/src/routes/converterRoutes.js new file mode 100644 index 0000000..29f197f --- /dev/null +++ b/backend/src/routes/converterRoutes.js @@ -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; diff --git a/backend/src/services/converterScanService.js b/backend/src/services/converterScanService.js new file mode 100644 index 0000000..ec0a6b7 --- /dev/null +++ b/backend/src/services/converterScanService.js @@ -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 +}; diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 8f8c638..fa90587 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -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) diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 2585437..d2761f9 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -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} 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} 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} + */ + 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(); diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index e68e998..4ebdccc 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -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 } }; } diff --git a/backend/src/utils/files.js b/backend/src/utils/files.js index 9dd610d..98fb6ea 100644 --- a/backend/src/utils/files.js +++ b/backend/src/utils/files.js @@ -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); diff --git a/db/schema.sql b/db/schema.sql index 7f0b9c1..fd2a7bd 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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'); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index aa6d543..d632aad 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 7bc80a5..3afde2d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.12.0-14", + "version": "0.12.0-15", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0f7c95c..abd40bb 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> } /> } /> + } /> diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 313ea6a..9d37d1e 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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 }) }); } }; diff --git a/frontend/src/components/ConverterFileExplorer.jsx b/frontend/src/components/ConverterFileExplorer.jsx new file mode 100644 index 0000000..092c8dc --- /dev/null +++ b/frontend/src/components/ConverterFileExplorer.jsx @@ -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 ; +} + +/** 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 ( +
+
handleOpen(node)} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter') handleOpen(node); }} + > + {hasChildren ? ( +
{ e.stopPropagation(); toggleFolder(node.path || ''); }} + onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); toggleFolder(node.path || ''); } }} + > + +
+ ) : ( +
+ {isExpanded && hasChildren && childFolders.map((child) => renderFolderTree(child, depth + 1))} +
+ ); + } + + // ── 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 ( +
+ + + {/* Obere Leiste: rawdir-Pfad + Aktualisieren */} +
+ + {rawDir + ? <>{rawDir} + : Kein Ordner konfiguriert} + +
+ + {loading && !tree ? ( +
+ +
+ ) : !rawDir ? ( +
+ Bitte Converter Raw-Ordner in den Settings konfigurieren. +
+ ) : ( +
+ + {/* Linke Seitenleiste */} +
+
+ setSidebarQuery(e.target.value)} + aria-label="Ordner suchen" + /> +
+
+ {topLevelFolders.length === 0 && !sidebarQuery && ( +
+ Keine Ordner gefunden. +
+ )} + {filteredTree && renderFolderTree(filteredTree, 0)} +
+
+ + {/* Rechter Hauptbereich */} +
+
+ {/* ArrowUp */} + + + {/* Breadcrumb */} +
+ navigateTo('')} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter') navigateTo(''); }} + > + raw + + {breadcrumb.map((crumb) => ( + navigateTo(crumb.path)} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter') navigateTo(crumb.path); }} + > + / {crumb.name} + + ))} +
+ + {/* Toolbar-Aktionen rechts */} +
+ + + + +
+
+ + {/* Dateiliste */} +
+ {/* Header */} +
+ + Name + Typ + Größe +
+ + {/* Zeilen */} + {currentItems.length === 0 ? ( +
+ Leer. +
+ ) : currentItems.map((item) => { + const sel = isSelected(item.path); + const indet = isIndeterminate(item); + return ( +
{ + 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); }} + > + e.stopPropagation()}> + { if (el) el.indeterminate = indet; }} + onChange={(e) => handleCheckboxChange(item, e.target.checked)} + aria-label={`${item.name} auswählen`} + /> + + + + + + {item.name} + + + {item.type === 'folder' + ? + : (item.detectedMediaType ? mediaTypeBadge(item.detectedMediaType) : Datei)} + + + {formatBytes(item.size)} + +
+ ); + })} + + {/* Footer: Auswahl-Anzahl */} + {selectedPaths.length > 0 && ( +
+ + + + {getRootSelections(selectedPaths).length} ausgewählt +
+ )} +
+
+ + {/* Untere Leiste (ganzeBreite) */} +
+ {currentItems.length} Einträge{rawDir ? ` · ${rawDir}` : ''} +
+
+ )} + + {/* ── Dialoge ─────────────────────────────────────────────────────────── */} + + {/* Neuer Ordner */} + { setActiveModal(''); setNewFolderName(''); }} + style={{ width: '380px' }} + footer={ +
+
+ } + modal + > +
+ + setNewFolderName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleCreateFolder(); }} + autoFocus + style={{ width: '100%', marginTop: 6 }} + placeholder={`In: ${currentPath || '/'}`} + /> +
+
+ + {/* Umbenennen */} + { setActiveModal(''); setRenameName(''); }} + style={{ width: '380px' }} + footer={ +
+
+ } + modal + > +
+ + setRenameName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); }} + autoFocus + style={{ width: '100%', marginTop: 6 }} + /> +
+
+ + {/* Löschen */} + setActiveModal('')} + style={{ width: '380px' }} + footer={ +
+
+ } + modal + > +

+ {rootSelected.length === 1 + ? <>{getNodeByPath(tree, rootSelected[0])?.name} wirklich löschen? + : <>{rootSelected.length} Einträge wirklich löschen?} +

+
+ + {/* Verschieben */} + setActiveModal('')} + style={{ width: '420px' }} + footer={ +
+
+ } + modal + > +
+ + { + 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 …" + /> +
+
+
+ ); +} diff --git a/frontend/src/components/ConverterJobCard.jsx b/frontend/src/components/ConverterJobCard.jsx new file mode 100644 index 0000000..cd26fa6 --- /dev/null +++ b/frontend/src/components/ConverterJobCard.jsx @@ -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 ; +} + +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 ( +
+ {error && ( + + )} + +
+ {/* Medientyp (read-only) */} +
+ +
+ {mediaTypeBadge(converterMediaType)} + {converterMediaType === 'iso' && ( + MakeMKV → HandBrake + )} +
+
+ + {/* Ausgabeformat */} +
+ + setOutputFormat(e.value)} + style={{ width: '100%' }} + /> +
+
+ + {/* Video: Presets */} + {isVideo && ( +
+
+ + ({ label: p.name, value: p.id })) + ]} + onChange={(e) => { setSelectedUserPreset(e.value); if (e.value) setSelectedHbPreset(''); }} + style={{ width: '100%' }} + /> +
+ {!selectedUserPreset && ( +
+ + ({ 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%' }} + /> +
+ )} +
+ )} + + {/* Audio: Format-Optionen */} + {isAudio && ( +
+ {outputFormat === 'flac' && ( +
+ + setAudioFormatOptions((p) => ({ ...p, flacCompression: e.value ?? 5 }))} + min={0} max={12} step={1} + style={{ width: '100%' }} + /> +
+ )} + {outputFormat === 'mp3' && ( + <> +
+ + setAudioFormatOptions((p) => ({ ...p, mp3Mode: e.value || 'cbr' }))} + /> +
+
+ {audioFormatOptions.mp3Mode === 'cbr' ? ( + <> + + ({ label: `${v} kbps`, value: v }))} + onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Bitrate: e.value }))} + style={{ width: '100%' }} + /> + + ) : ( + <> + + setAudioFormatOptions((p) => ({ ...p, mp3Quality: e.value ?? 4 }))} + min={0} max={9} step={1} + style={{ width: '100%' }} + /> + + )} +
+ + )} + {outputFormat === 'aac' && ( +
+ + ({ label: `${v} kbps`, value: v }))} + onChange={(e) => setAudioFormatOptions((p) => ({ ...p, aacBitrate: e.value }))} + style={{ width: '100%' }} + /> +
+ )} + {outputFormat === 'opus' && ( +
+ + ({ label: `${v} kbps`, value: v }))} + onChange={(e) => setAudioFormatOptions((p) => ({ ...p, opusBitrate: e.value }))} + style={{ width: '100%' }} + /> +
+ )} + {outputFormat === 'ogg' && ( +
+ + setAudioFormatOptions((p) => ({ ...p, oggQuality: e.value ?? 6 }))} + min={-1} max={10} step={1} + style={{ width: '100%' }} + /> +
+ )} +
+ )} + +
+
+
+ ); +} + +/** + * 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 ( +
+ {/* Header */} +
+
+ {title} + {converterMediaType && mediaTypeBadge(converterMediaType)} + +
+ + {!isReady && plan?.outputFormat && ( +
+ Format: {String(plan.outputFormat).toUpperCase()} + {plan?.inputPath && ( + + ← {plan.inputPath.split('/').pop()} + + )} +
+ )} + {isReady && plan?.inputPath && ( +
+ ← {plan.inputPath.split('/').pop()} +
+ )} +
+ + {/* Inline-Konfiguration für READY_TO_START */} + {isReady && ( + + )} + + {/* Fortschrittsbalken */} + {active && ( +
+ {progressValue !== null ? ( + `${progressValue}%`} /> + ) : ( + + )} + {(liveStatusText || liveEta) && ( +
+ {liveStatusText && {liveStatusText}} + {liveEta && ETA: {liveEta}} +
+ )} +
+ )} + + {/* Fehler */} + {status === 'ERROR' && job.error_message && ( +
+ {job.error_message} +
+ )} + + {/* Ausgabe-Pfad */} + {terminal && job.output_path && ( +
+ → {job.output_path.split('/').slice(-2).join('/')} +
+ )} + + {/* Aktions-Buttons */} +
+ {active && ( +
+
+ ); +} diff --git a/frontend/src/components/ConverterJobConfigDialog.jsx b/frontend/src/components/ConverterJobConfigDialog.jsx new file mode 100644 index 0000000..3dedad8 --- /dev/null +++ b/frontend/src/components/ConverterJobConfigDialog.jsx @@ -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 = ( +
+
+ ); + + return ( + + {error && ( + + )} + + {/* Medientyp anzeigen (read-only) */} +
+ +
+ {converterMediaType === 'iso' ? 'ISO (Video)' : converterMediaType === 'video' ? 'Video' : 'Audio'} + {converterMediaType === 'iso' && ( + → MakeMKV Rip, dann HandBrake + )} +
+
+ + {/* Ausgabeformat */} +
+ + setOutputFormat(e.value)} + style={{ width: '100%', marginTop: 4 }} + /> +
+ + {/* Video: Preset-Auswahl */} + {isVideo && ( + <> + +
+ + ({ label: p.name, value: p.id })) + ]} + onChange={(e) => { + setSelectedUserPreset(e.value); + if (e.value) setSelectedHbPreset(''); + }} + style={{ width: '100%', marginTop: 4 }} + /> +
+ {!selectedUserPreset && ( +
+ + ({ + 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 …" + /> +
+ )} + + )} + + {/* Audio: Format-Optionen */} + {isAudio && ( + <> + + {outputFormat === 'flac' && ( +
+ + setAudioFormatOptions((p) => ({ ...p, flacCompression: e.value ?? 5 }))} + min={0} max={12} step={1} + style={{ width: '100%', marginTop: 4 }} + /> +
+ )} + {outputFormat === 'mp3' && ( + <> +
+ + setAudioFormatOptions((p) => ({ ...p, mp3Mode: e.value || 'cbr' }))} + style={{ marginTop: 4 }} + /> +
+ {audioFormatOptions.mp3Mode === 'cbr' ? ( +
+ + ({ label: `${v} kbps`, value: v }))} + onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Bitrate: e.value }))} + style={{ width: '100%', marginTop: 4 }} + /> +
+ ) : ( +
+ + setAudioFormatOptions((p) => ({ ...p, mp3Quality: e.value ?? 4 }))} + min={0} max={9} step={1} + style={{ width: '100%', marginTop: 4 }} + /> +
+ )} + + )} + {outputFormat === 'aac' && ( +
+ + ({ label: `${v} kbps`, value: v }))} + onChange={(e) => setAudioFormatOptions((p) => ({ ...p, aacBitrate: e.value }))} + style={{ width: '100%', marginTop: 4 }} + /> +
+ )} + {outputFormat === 'opus' && ( +
+ + ({ label: `${v} kbps`, value: v }))} + onChange={(e) => setAudioFormatOptions((p) => ({ ...p, opusBitrate: e.value }))} + style={{ width: '100%', marginTop: 4 }} + /> +
+ )} + {outputFormat === 'ogg' && ( +
+ + setAudioFormatOptions((p) => ({ ...p, oggQuality: e.value ?? 6 }))} + min={-1} max={10} step={1} + style={{ width: '100%', marginTop: 4 }} + /> +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/ConverterUploadPanel.jsx b/frontend/src/components/ConverterUploadPanel.jsx new file mode 100644 index 0000000..73383f9 --- /dev/null +++ b/frontend/src/components/ConverterUploadPanel.jsx @@ -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 ( +
+
+
+ +
+
+ Dateien hierher ziehen oder auswählen +
+ Unterstützt: MKV, MP4, ISO, AVI, FLAC, MP3, WAV, AAC, OGG, OPUS … +
+
+
+ + {/* Versteckte File-Inputs */} + + +
+ + {phase !== 'idle' && ( +
+
+ {phaseLabel && } + {statusText} + {(phase === 'done' || phase === 'error') && ( +
+ {phase === 'uploading' && ( + + )} + {phase === 'error' && errorMsg && ( + {errorMsg} + )} +
+ )} +
+ ); +} + +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); + }); +} diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index 4d9057f..081b29a 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -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 Medium - RAW-Ordner - Encode-Ordner + RAW / Eingang + Encode / Ausgang @@ -627,8 +636,30 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect {downloadPath} {isDefault(downloadPath, defaultDownloads) && Standard} + --- + + + Converter Raw - --- + {converterRaw} + {isDefault(converterRaw, defaultConverterRaw) && Standard} + + --- + + + Converter Video + --- + + {converterMovies} + {isDefault(converterMovies, defaultConverterMovies) && Standard} + + + + Converter Audio + --- + + {converterAudio} + {isDefault(converterAudio, defaultConverterAudio) && Standard} @@ -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 }] : []) ] diff --git a/frontend/src/pages/ConverterPage.jsx b/frontend/src/pages/ConverterPage.jsx new file mode 100644 index 0000000..436fc48 --- /dev/null +++ b/frontend/src/pages/ConverterPage.jsx @@ -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 = ( +
+
+ Converter Jobs + {activeJobs.length > 0 && ( + + )} +
+
+ ); + + return ( +
+ + + {/* Import-Ordner */} + + + {selectedEntries.length > 0 && ( +
+ + ausgewählt + +
+ )} +
+ + {/* Jobs */} + + {jobs.length === 0 ? ( +

+ Keine Jobs vorhanden. Dateien im Import-Ordner auswählen oder per Upload hochladen. +

+ ) : ( + <> + {activeJobs.length > 0 && ( + <> +

Aktiv

+
+ {activeJobs.map((job) => ( + + ))} +
+ + )} + {activeJobs.length > 0 && finishedJobs.length > 0 && } + {finishedJobs.length > 0 && ( + <> +

Abgeschlossen

+
+ {finishedJobs.slice(0, 20).map((job) => ( + + ))} +
+ + )} + + )} +
+ + {/* Upload */} + + + + + setJobModeVisible(false)} + /> +
+ ); +} + +// ── 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 = ( +
+
+ ); + + return ( + + {/* Nur Videos / Ordner gewählt */} + {!hasAudio && ( +

+ Für jede ausgewählte Datei / jeden Ordner wird ein eigener Job angelegt. +
+ {nonAudioEntries.length} Job{nonAudioEntries.length !== 1 ? 's' : ''} werden erstellt. +

+ )} + + {/* Nur Audio gewählt */} + {onlyAudio && ( +
+

+ Wie sollen die {audioEntries.length} Audiodatei{audioEntries.length !== 1 ? 'en' : ''} verarbeitet werden? +

+
+ + +
+
+ )} + + {/* Gemischte Auswahl */} + {mixed && ( +
+

+ Videos/Ordner ({nonAudioEntries.length}): Für jede Datei ein eigener Job. +

+ +

+ Audiodateien ({audioEntries.length}): Wie verarbeiten? +

+
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 9ae2925..463f254 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -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, diff --git a/frontend/src/pages/HistoryPage.jsx b/frontend/src/pages/HistoryPage.jsx index 8bcd1bb..bc1e623 100644 --- a/frontend/src/pages/HistoryPage.jsx +++ b/frontend/src/pages/HistoryPage.jsx @@ -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} diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index dab5bc1..dcf893f 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -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]; diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index f413638..7606b41 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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; +} diff --git a/package-lock.json b/package-lock.json index c91eca7..8684ca6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/package.json b/package.json index 7ed230e..c3919ac 100644 --- a/package.json +++ b/package.json @@ -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",