From 43702b0138e99a6b4fccfe934d0e325c7f41e104 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Tue, 7 Apr 2026 19:12:40 +0000 Subject: [PATCH] 0.12.0-19 Final Build --- README.md | 6 +- backend/src/plugins/ConverterPlugin.js | 8 +- backend/src/routes/converterRoutes.js | 8 +- backend/src/routes/pipelineRoutes.js | 124 ++++- backend/src/services/converterScanService.js | 20 +- backend/src/services/historyService.js | 76 ++- backend/src/services/pipelineService.js | 407 +++++++++++++- db/schema.sql | 27 +- docs/architecture/frontend.md | 2 +- docs/architecture/index.md | 2 +- docs/configuration/settings-reference.md | 2 +- docs/getting-started/configuration.md | 4 +- docs/getting-started/quickstart.md | 6 +- docs/gui/converter.md | 4 +- docs/gui/index.md | 6 +- docs/gui/{dashboard.md => ripper.md} | 6 +- docs/gui/settings.md | 4 +- docs/index.md | 2 +- docs/pipeline/playlist-analysis.md | 2 +- docs/workflows/index.md | 12 +- frontend/src/App.jsx | 72 ++- frontend/src/api/client.js | 6 + .../src/components/AudiobookConfigPanel.jsx | 2 +- .../components/AudiobookOutputExplorer.jsx | 298 ++++++++++ .../src/components/AudiobookUploadPanel.jsx | 200 +++++++ frontend/src/components/ConverterJobCard.jsx | 30 +- .../components/ConverterJobConfigDialog.jsx | 13 - .../src/components/ConverterUploadPanel.jsx | 81 ++- .../src/components/DynamicSettingsForm.jsx | 87 ++- frontend/src/components/JobDetailDialog.jsx | 9 +- frontend/src/pages/AudiobooksPage.jsx | 508 ++++++++++++++++++ frontend/src/pages/ConverterPage.jsx | 93 +++- frontend/src/pages/HistoryPage.jsx | 8 +- frontend/src/pages/JobsInboxPage.jsx | 357 ++++++++++++ .../{DashboardPage.jsx => RipperPage.jsx} | 330 ++++-------- frontend/src/styles/app.css | 226 ++++++-- frontend/src/utils/jobTaxonomy.js | 236 ++++++++ mkdocs.yml | 2 +- package.json | 3 +- 39 files changed, 2829 insertions(+), 460 deletions(-) rename docs/gui/{dashboard.md => ripper.md} (96%) create mode 100644 frontend/src/components/AudiobookOutputExplorer.jsx create mode 100644 frontend/src/components/AudiobookUploadPanel.jsx create mode 100644 frontend/src/pages/AudiobooksPage.jsx create mode 100644 frontend/src/pages/JobsInboxPage.jsx rename frontend/src/pages/{DashboardPage.jsx => RipperPage.jsx} (92%) create mode 100644 frontend/src/utils/jobTaxonomy.js diff --git a/README.md b/README.md index bacee0e..3607cc8 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,10 @@ Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping, Audio - Pre- und Post-Encode-Ausführungen (Skripte und/oder Skript-Ketten) - Pipeline-Queue mit Job- und Nicht-Job-Einträgen (`script`, `chain`, `wait`) - Cron-Jobs für Skripte/Ketten (inkl. Logs und manueller Auslösung) -- **Aktivitäts-Tracking**: Laufende und abgeschlossene Aktionen in Echtzeit im Dashboard +- **Aktivitäts-Tracking**: Laufende und abgeschlossene Aktionen in Echtzeit im Ripper - Download-Queue: Ausgabedateien als ZIP herunterladen - Historie mit Re-Encode, Review-Neustart, File-/Job-Löschung und Orphan-Import -- Hardware-Monitoring (CPU/RAM/GPU/Storage) im Dashboard +- Hardware-Monitoring (CPU/RAM/GPU/Storage) im Ripper ## Tech-Stack @@ -147,7 +147,7 @@ ripster/ utils/ frontend/ src/ - pages/ # Dashboard, Settings, History, Converter, Downloads, Database + pages/ # Ripper, Settings, History, Converter, Downloads, Database components/ api/ db/schema.sql diff --git a/backend/src/plugins/ConverterPlugin.js b/backend/src/plugins/ConverterPlugin.js index 687e9ac..c5fe9ec 100644 --- a/backend/src/plugins/ConverterPlugin.js +++ b/backend/src/plugins/ConverterPlugin.js @@ -7,11 +7,11 @@ const { spawnTrackedProcess } = require('../services/processRunner'); const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers'); const { ensureDir, sanitizeFileName, renderTemplate } = 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 VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']); +const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']); const ISO_EXTENSIONS = new Set(['iso']); -const AUDIO_OUTPUT_FORMATS = new Set(['flac', 'mp3', 'aac', 'opus', 'wav', 'ogg']); +const AUDIO_OUTPUT_FORMATS = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']); const VIDEO_OUTPUT_FORMATS = new Set(['mkv', 'mp4', 'm4v']); /** @@ -62,8 +62,6 @@ function buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, format, opts = { } 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') { diff --git a/backend/src/routes/converterRoutes.js b/backend/src/routes/converterRoutes.js index f8f219b..d8913e3 100644 --- a/backend/src/routes/converterRoutes.js +++ b/backend/src/routes/converterRoutes.js @@ -90,8 +90,12 @@ router.post( for (const entry of entries) { const relPath = String(entry?.relPath || '').trim(); if (!relPath) continue; - const job = await pipelineService.createConverterJobFromEntry(relPath, { - converterMediaType: entry?.converterMediaType || null + const job = await pipelineService.createFileJob({ + kind: 'converter_entry', + relPath, + options: { + converterMediaType: entry?.converterMediaType || null + } }); jobs.push(job); } diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 6cb660a..16339d5 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -1,4 +1,5 @@ const express = require('express'); +const fs = require('fs'); const os = require('os'); const path = require('path'); const multer = require('multer'); @@ -7,8 +8,10 @@ const pipelineService = require('../services/pipelineService'); const historyService = require('../services/historyService'); const diskDetectionService = require('../services/diskDetectionService'); const hardwareMonitorService = require('../services/hardwareMonitorService'); +const settingsService = require('../services/settingsService'); const logger = require('../services/logger').child('PIPELINE_ROUTE'); const activationBytesService = require('../services/activationBytesService'); +const { defaultAudiobookDir } = require('../config'); const { getDb } = require('../db/database'); const router = express.Router(); @@ -16,6 +19,78 @@ const audiobookUpload = multer({ dest: path.join(os.tmpdir(), 'ripster-audiobook-uploads') }); +const AUDIOBOOK_TREE_MAX_DEPTH = 8; + +function buildAudiobookOutputTree(rootDir, relPath = '', depth = 0) { + if (depth >= AUDIOBOOK_TREE_MAX_DEPTH) { + return []; + } + const absDir = relPath ? path.join(rootDir, relPath) : rootDir; + let dirents = []; + try { + dirents = fs.readdirSync(absDir, { withFileTypes: true }); + } catch (_error) { + return []; + } + + dirents.sort((left, right) => { + const leftOrder = left.isDirectory() ? 0 : 1; + const rightOrder = right.isDirectory() ? 0 : 1; + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }); + }); + + const nodes = []; + for (const dirent of dirents) { + if (dirent.name.startsWith('.')) { + continue; + } + const childRelPath = relPath ? `${relPath}/${dirent.name}` : dirent.name; + const childAbsPath = path.join(rootDir, childRelPath); + if (dirent.isDirectory()) { + const children = buildAudiobookOutputTree(rootDir, childRelPath, depth + 1); + const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0); + let mtime = null; + try { + mtime = fs.statSync(childAbsPath).mtime.toISOString(); + } catch (_error) { + mtime = null; + } + nodes.push({ + name: dirent.name, + type: 'folder', + path: childRelPath, + size, + mtime, + children + }); + continue; + } + if (dirent.isFile()) { + let size = 0; + let mtime = null; + try { + const stat = fs.statSync(childAbsPath); + size = Number(stat?.size || 0); + mtime = stat?.mtime ? stat.mtime.toISOString() : null; + } catch (_error) { + size = 0; + mtime = null; + } + nodes.push({ + name: dirent.name, + type: 'file', + path: childRelPath, + size, + mtime + }); + } + } + return nodes; +} + router.get( '/state', asyncHandler(async (req, res) => { @@ -175,9 +250,13 @@ router.post( mimeType: String(req.file.mimetype || '').trim() || null, tempPath: String(req.file.path || '').trim() || null }); - const result = await pipelineService.uploadAudiobookFile(req.file, { - format: req.body?.format, - startImmediately: req.body?.startImmediately + const result = await pipelineService.createFileJob({ + kind: 'audiobook_upload', + file: req.file, + options: { + format: req.body?.format, + startImmediately: req.body?.startImmediately + } }); res.json({ result }); }) @@ -220,6 +299,45 @@ router.post( }) ); +router.get( + '/audiobook/jobs', + asyncHandler(async (_req, res) => { + const jobs = await pipelineService.getAudiobookJobs(); + res.json({ jobs }); + }) +); + +router.get( + '/audiobook/output-tree', + asyncHandler(async (_req, res) => { + const settings = await settingsService.getEffectiveSettingsMap('audiobook'); + const configuredOutputDir = String(settings?.movie_dir || '').trim(); + const rawOutputDir = configuredOutputDir || defaultAudiobookDir || null; + const outputDir = rawOutputDir + ? (path.isAbsolute(rawOutputDir) ? rawOutputDir : path.resolve(process.cwd(), rawOutputDir)) + : null; + + if (!outputDir || !fs.existsSync(outputDir)) { + res.json({ outputDir, tree: null }); + return; + } + + const children = buildAudiobookOutputTree(outputDir, '', 0); + const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0); + res.json({ + outputDir, + tree: { + name: path.basename(outputDir) || 'audiobooks', + type: 'folder', + path: '', + size, + mtime: null, + children + } + }); + }) +); + router.post( '/select-metadata', asyncHandler(async (req, res) => { diff --git a/backend/src/services/converterScanService.js b/backend/src/services/converterScanService.js index 0aff036..b215d36 100644 --- a/backend/src/services/converterScanService.js +++ b/backend/src/services/converterScanService.js @@ -10,9 +10,10 @@ 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 VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']); +const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']); const ISO_EXTENSIONS = new Set(['iso']); +const SUPPORTED_SCAN_EXTENSIONS = new Set([...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS, ...ISO_EXTENSIONS]); let _pollingTimer = null; let _pollingEnabled = false; @@ -33,13 +34,16 @@ function detectFormat(fileName) { 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(SUPPORTED_SCAN_EXTENSIONS); } - return new Set( - raw.split(',') - .map((ext) => ext.trim().toLowerCase()) - .filter(Boolean) - ); + const configured = raw.split(',') + .map((ext) => ext.trim().toLowerCase()) + .filter(Boolean); + const filtered = configured.filter((ext) => SUPPORTED_SCAN_EXTENSIONS.has(ext)); + if (filtered.length === 0) { + return new Set(SUPPORTED_SCAN_EXTENSIONS); + } + return new Set(filtered); } function getFileSize(fullPath) { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 95a59ef..9245708 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -306,6 +306,35 @@ function normalizeMediaTypeValue(value) { return null; } +function normalizeJobKindValue(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (raw === 'audiobook' || raw === 'cd' || raw === 'dvd' || raw === 'bluray') { + return raw; + } + if (raw === 'converter_audio' || raw === 'converter_video' || raw === 'converter_iso') { + return raw; + } + return null; +} + +function inferMediaTypeFromJobKind(value) { + const normalized = normalizeJobKindValue(value); + if (normalized === 'converter_audio' || normalized === 'converter_video' || normalized === 'converter_iso') { + return 'converter'; + } + if (normalized === 'audiobook' || normalized === 'cd' || normalized === 'dvd' || normalized === 'bluray') { + return normalized; + } + const legacy = String(value || '').trim().toLowerCase(); + if (legacy === 'converter') { + return 'converter'; + } + return null; +} + function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeInfo = null) { const mkInfo = parseInfoFromValue(makemkvInfo, null); const miInfo = parseInfoFromValue(mediainfoInfo, null); @@ -326,8 +355,21 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn return profileHint; } + const profileFromJobKind = inferMediaTypeFromJobKind( + job?.job_kind + || plan?.jobKind + || mkInfo?.jobKind + || mkInfo?.analyzeContext?.jobKind + || miInfo?.jobKind + || hbInfo?.jobKind + ); + if (profileFromJobKind) { + return profileFromJobKind; + } + // Converter jobs: detected via media_type field (plan.mediaProfile = 'converter') const rawMediaType = normalizeMediaTypeValue(job?.media_type); + const rawMediaTypeLegacy = String(job?.media_type || '').trim().toLowerCase(); const rawPlanProfile = String(plan?.mediaProfile || '').trim().toLowerCase(); const converterMediaTypeHint = String(plan?.converterMediaType || '').trim().toLowerCase(); const hasConverterPathHint = (value) => { @@ -343,6 +385,7 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn if ( rawPlanProfile === 'converter' || rawMediaType === 'converter' + || rawMediaTypeLegacy === 'converter' || converterMediaTypeHint === 'video' || converterMediaTypeHint === 'audio' || converterMediaTypeHint === 'iso' @@ -1897,22 +1940,39 @@ function isFilesystemRootPath(inputPath) { } class HistoryService { - async createJob({ discDevice = null, status = 'ANALYZING', detectedTitle = null }) { + async createJob({ + discDevice = null, + status = 'ANALYZING', + detectedTitle = null, + mediaType = null, + jobKind = null + }) { const db = await getDb(); const startTime = new Date().toISOString(); + const normalizedMediaType = (() => { + const normalized = normalizeMediaTypeValue(mediaType); + if (normalized) { + return normalized; + } + const legacy = String(mediaType || '').trim().toLowerCase(); + return legacy === 'converter' ? 'converter' : null; + })(); + const normalizedJobKind = normalizeJobKindValue(jobKind); const result = await db.run( ` - INSERT INTO jobs (disc_device, status, start_time, detected_title, last_state, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + INSERT INTO jobs (disc_device, status, start_time, detected_title, last_state, media_type, job_kind, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) `, - [discDevice, status, startTime, detectedTitle, status] + [discDevice, status, startTime, detectedTitle, status, normalizedMediaType, normalizedJobKind] ); logger.info('job:created', { jobId: result.lastID, discDevice, status, - detectedTitle + detectedTitle, + mediaType: normalizedMediaType, + jobKind: normalizedJobKind }); return this.getJobById(result.lastID); @@ -2635,6 +2695,7 @@ class HistoryService { const rowMediaProfile = normalizeMediaTypeValue( makemkvInfo?.mediaProfile || makemkvInfo?.analyzeContext?.mediaProfile + || inferMediaTypeFromJobKind(row?.job_kind) || row?.media_type ); const posterCandidate = String( @@ -2764,6 +2825,7 @@ class HistoryService { const mediaProfile = normalizeMediaTypeValue( makemkvInfo?.mediaProfile || makemkvInfo?.analyzeContext?.mediaProfile + || inferMediaTypeFromJobKind(job?.job_kind) || job?.media_type ); if (String(makemkvInfo?.source || '').trim().toLowerCase() !== 'orphan_raw_import' || mediaProfile !== 'cd') { @@ -3616,6 +3678,10 @@ class HistoryService { || '' ).trim() || null; await this.updateJob(created.id, { + ...(detectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}), + ...(detectedMediaType === 'cd' ? { job_kind: 'cd' } : {}), + ...(detectedMediaType === 'dvd' ? { job_kind: 'dvd' } : {}), + ...(detectedMediaType === 'bluray' ? { job_kind: 'bluray' } : {}), status: 'FINISHED', last_state: 'FINISHED', title: omdbById?.title diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 2ac160b..8cbb5ae 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -85,6 +85,11 @@ const FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD = 0.35; const FORCED_SUBTITLE_MAX_EVENT_COUNT = 220; const FORCED_SUBTITLE_MIN_EVENT_GAP = 12; const FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES = 64 * 1024; +const CONVERTER_SUPPORTED_SCAN_EXTENSIONS = Object.freeze([ + 'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov', + 'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus' +]); +const CONVERTER_SUPPORTED_SCAN_EXTENSION_SET = new Set(CONVERTER_SUPPORTED_SCAN_EXTENSIONS); function nowIso() { return new Date().toISOString(); @@ -108,6 +113,50 @@ function normalizePositiveInteger(value) { return Math.trunc(parsed); } +function parseConverterScanExtensions(rawValue) { + const raw = String(rawValue || '').trim(); + if (!raw) { + return [...CONVERTER_SUPPORTED_SCAN_EXTENSIONS]; + } + const seen = new Set(); + const parsed = raw + .split(',') + .map((item) => String(item || '').trim().toLowerCase()) + .filter((item) => { + if (!item || !CONVERTER_SUPPORTED_SCAN_EXTENSION_SET.has(item) || seen.has(item)) { + return false; + } + seen.add(item); + return true; + }); + if (parsed.length === 0) { + return [...CONVERTER_SUPPORTED_SCAN_EXTENSIONS]; + } + return parsed; +} + +function getFileExtensionWithoutDot(fileName) { + const ext = path.extname(String(fileName || '')).trim().toLowerCase(); + if (!ext.startsWith('.')) { + return ''; + } + return ext.slice(1); +} + +function cleanupTempUploads(files = []) { + for (const file of Array.isArray(files) ? files : []) { + const tempPath = String(file?.path || '').trim(); + if (!tempPath || !fs.existsSync(tempPath)) { + continue; + } + try { + fs.unlinkSync(tempPath); + } catch (_error) { + // Best-effort cleanup only. + } + } +} + function normalizeCdTrackPositionList(values = []) { const source = Array.isArray(values) ? values : []; const seen = new Set(); @@ -356,6 +405,84 @@ function normalizeMediaProfile(value) { return null; } +function normalizeJobKind(value) { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) { + return null; + } + if (raw === 'audiobook' || raw === 'cd' || raw === 'dvd' || raw === 'bluray') { + return raw; + } + if (raw === 'converter_audio' || raw === 'converter_video' || raw === 'converter_iso') { + return raw; + } + return null; +} + +function resolveConverterJobKind(converterMediaType = null) { + const normalized = String(converterMediaType || '').trim().toLowerCase(); + if (normalized === 'audio') { + return 'converter_audio'; + } + if (normalized === 'iso') { + return 'converter_iso'; + } + return 'converter_video'; +} + +function resolveJobKindForMediaProfile(mediaProfile, options = {}) { + const explicit = normalizeJobKind(options?.jobKind); + if (explicit) { + return explicit; + } + const normalizedProfile = normalizeMediaProfile(mediaProfile); + if (!normalizedProfile) { + return null; + } + if (normalizedProfile === 'converter') { + return resolveConverterJobKind(options?.converterMediaType); + } + if (normalizedProfile === 'audiobook' || normalizedProfile === 'cd' || normalizedProfile === 'dvd' || normalizedProfile === 'bluray') { + return normalizedProfile; + } + return null; +} + +function inferMediaProfileFromJobKind(rawJobKind) { + const normalized = normalizeJobKind(rawJobKind); + if (normalized === 'converter_audio' || normalized === 'converter_video' || normalized === 'converter_iso') { + return 'converter'; + } + if (normalized === 'audiobook' || normalized === 'cd' || normalized === 'dvd' || normalized === 'bluray') { + return normalized; + } + const legacy = String(rawJobKind || '').trim().toLowerCase(); + if (legacy === 'converter') { + return 'converter'; + } + return null; +} + +function isConverterJobRecord(job = null, encodePlan = null) { + const profileFromJobKind = inferMediaProfileFromJobKind(job?.job_kind); + if (profileFromJobKind === 'converter') { + return true; + } + const plan = encodePlan && typeof encodePlan === 'object' + ? encodePlan + : null; + const profileFromPlanJobKind = inferMediaProfileFromJobKind(plan?.jobKind); + if (profileFromPlanJobKind === 'converter') { + return true; + } + const mediaType = String(job?.media_type || '').trim().toLowerCase(); + if (mediaType === 'converter') { + return true; + } + const planProfile = String(plan?.mediaProfile || '').trim().toLowerCase(); + return planProfile === 'converter'; +} + function isSpecificMediaProfile(value) { return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook' || value === 'converter'; } @@ -5580,6 +5707,7 @@ class PipelineService extends EventEmitter { FROM jobs WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' AND (media_type IS NULL OR media_type <> 'converter') + AND (job_kind IS NULL OR job_kind NOT LIKE 'converter_%') `); if (!Array.isArray(rows) || rows.length === 0) { return; @@ -5751,7 +5879,7 @@ class PipelineService extends EventEmitter { }); } - // Always start with a clean dashboard/session snapshot after server restart. + // Always start with a clean ripper/session snapshot after server restart. const hasContextKeys = this.snapshot.context && typeof this.snapshot.context === 'object' && Object.keys(this.snapshot.context).length > 0; @@ -5946,7 +6074,7 @@ class PipelineService extends EventEmitter { this.cdDrives.set(devicePath, next); // If a drive is detached from a job (or reassigned), clear stale live - // progress for the previous job so dashboards do not stay in CD_ENCODING. + // progress for the previous job so ripper views do not stay in CD_ENCODING. const previousJobId = Number(existing?.jobId || 0); const nextJobId = Number(next?.jobId || 0); if (Number.isFinite(previousJobId) && previousJobId > 0) { @@ -8008,6 +8136,14 @@ class PipelineService extends EventEmitter { const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object' ? options.encodePlan : null; + const profileFromJobKind = inferMediaProfileFromJobKind( + options?.jobKind + || job?.job_kind + || encodePlan?.jobKind + ); + if (profileFromJobKind) { + return profileFromJobKind; + } const profileFromPlan = pickSpecificProfile(encodePlan?.mediaProfile); if (profileFromPlan) { return profileFromPlan; @@ -8067,6 +8203,28 @@ class PipelineService extends EventEmitter { return 'other'; } + resolveJobKindForJob(job, options = {}) { + const explicitKind = normalizeJobKind(options?.jobKind); + if (explicitKind) { + return explicitKind; + } + const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object' + ? options.encodePlan + : this.safeParseJson(job?.encode_plan_json); + const kindFromPlan = normalizeJobKind(encodePlan?.jobKind); + if (kindFromPlan) { + return kindFromPlan; + } + const kindFromJob = normalizeJobKind(job?.job_kind); + if (kindFromJob) { + return kindFromJob; + } + const mediaProfile = this.resolveMediaProfileForJob(job, { ...options, encodePlan }); + return resolveJobKindForMediaProfile(mediaProfile, { + converterMediaType: options?.converterMediaType || encodePlan?.converterMediaType || null + }); + } + async getEffectiveSettingsForJob(job, options = {}) { const mediaProfile = this.resolveMediaProfileForJob(job, options); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); @@ -8401,7 +8559,8 @@ class PipelineService extends EventEmitter { const job = await historyService.createJob({ discDevice: device.path, status: 'METADATA_SELECTION', - detectedTitle + detectedTitle, + jobKind: resolveJobKindForMediaProfile(mediaProfile) }); try { @@ -10971,6 +11130,7 @@ class PipelineService extends EventEmitter { const refreshedPlan = { ...(sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {}), mediaProfile: 'audiobook', + jobKind: 'audiobook', mode: 'audiobook', encodeInputPath: rawInput.path }; @@ -10996,6 +11156,8 @@ class PipelineService extends EventEmitter { await historyService.resetProcessLog(sourceJobId); await historyService.updateJob(sourceJobId, { + media_type: 'audiobook', + job_kind: 'audiobook', status: 'READY_TO_START', last_state: 'READY_TO_START', start_time: null, @@ -13832,7 +13994,10 @@ class PipelineService extends EventEmitter { const retryJob = await historyService.createJob({ discDevice: sourceJob.disc_device || null, status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'), - detectedTitle: sourceJob.detected_title || sourceJob.title || null + detectedTitle: sourceJob.detected_title || sourceJob.title || null, + jobKind: this.resolveJobKindForJob(sourceJob, { + encodePlan: sourceEncodePlan + }) }); const retryJobId = Number(retryJob?.id || 0); if (!Number.isFinite(retryJobId) || retryJobId <= 0) { @@ -14044,7 +14209,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( jobId, 'USER_ACTION', - 'READY_TO_ENCODE Job nach Neustart ins Dashboard geladen.' + 'READY_TO_ENCODE Job nach Neustart in den Ripper geladen.' ); if ( @@ -14232,7 +14397,10 @@ class PipelineService extends EventEmitter { const replacementJob = await historyService.createJob({ discDevice: job.disc_device || null, status: 'READY_TO_ENCODE', - detectedTitle: job.detected_title || job.title || null + detectedTitle: job.detected_title || job.title || null, + jobKind: this.resolveJobKindForJob(job, { + encodePlan: restartPlan + }) }); const replacementJobId = Number(replacementJob?.id || 0); if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { @@ -14562,7 +14730,10 @@ class PipelineService extends EventEmitter { const replacementJob = await historyService.createJob({ discDevice: sourceJob.disc_device || null, status: 'MEDIAINFO_CHECK', - detectedTitle: sourceJob.detected_title || sourceJob.title || null + detectedTitle: sourceJob.detected_title || sourceJob.title || null, + jobKind: this.resolveJobKindForJob(sourceJob, { + encodePlan: previousEncodePlan + }) }); const replacementJobId = Number(replacementJob?.id || 0); if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { @@ -14817,7 +14988,7 @@ class PipelineService extends EventEmitter { const cancelMessage = 'Vom Benutzer abgebrochen.'; await historyService.updateJob(normalizedJobId, { status: 'CANCELLED', - // Preserve origin state so dashboard/history can distinguish review-cancelled rows. + // Preserve origin state so ripper/history can distinguish review-cancelled rows. last_state: runningStatus, end_time: nowIso(), error_message: cancelMessage @@ -15378,7 +15549,7 @@ class PipelineService extends EventEmitter { } } else { // No drive mapping available (e.g. orphan/skipRip import path). - // Ensure stale live progress does not keep the job "running" in the dashboard. + // Ensure stale live progress does not keep the job "running" in the ripper. this.jobProgress.delete(Number(jobId)); } } else { @@ -15451,7 +15622,9 @@ class PipelineService extends EventEmitter { const job = await historyService.createJob({ discDevice: null, status: 'ANALYZING', - detectedTitle + detectedTitle, + mediaType: 'audiobook', + jobKind: 'audiobook' }); let stagedRawDir = null; @@ -15641,6 +15814,7 @@ class PipelineService extends EventEmitter { const encodePlan = { mediaProfile: 'audiobook', + jobKind: 'audiobook', mode: 'audiobook', sourceType: 'upload', uploadedAt: nowIso(), @@ -15654,6 +15828,8 @@ class PipelineService extends EventEmitter { }; await historyService.updateJob(job.id, { + media_type: 'audiobook', + job_kind: 'audiobook', status: 'READY_TO_START', last_state: 'READY_TO_START', title: resolvedMetadata.title || detectedTitle, @@ -15704,6 +15880,8 @@ class PipelineService extends EventEmitter { error: errorToMeta(error) }); const updatePayload = { + media_type: 'audiobook', + job_kind: 'audiobook', status: 'ERROR', last_state: 'ERROR', end_time: nowIso(), @@ -15796,6 +15974,7 @@ class PipelineService extends EventEmitter { const nextEncodePlan = { ...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}), mediaProfile: 'audiobook', + jobKind: 'audiobook', mode: 'audiobook', format, formatOptions, @@ -15804,6 +15983,8 @@ class PipelineService extends EventEmitter { }; await historyService.updateJob(normalizedJobId, { + media_type: 'audiobook', + job_kind: 'audiobook', status: 'READY_TO_START', last_state: 'READY_TO_START', title: resolvedMetadata.title || job.title || job.detected_title || 'Audiobook', @@ -15957,6 +16138,8 @@ class PipelineService extends EventEmitter { }); await historyService.updateJob(jobId, { + media_type: 'audiobook', + job_kind: 'audiobook', status: 'ENCODING', last_state: 'ENCODING', start_time: nowIso(), @@ -16602,7 +16785,8 @@ class PipelineService extends EventEmitter { const job = await historyService.createJob({ discDevice: devicePath, status: 'CD_METADATA_SELECTION', - detectedTitle + detectedTitle, + jobKind: 'cd' }); try { @@ -17059,7 +17243,10 @@ class PipelineService extends EventEmitter { const replacementJob = await historyService.createJob({ discDevice: sourceJob.disc_device || null, status: 'CD_READY_TO_RIP', - detectedTitle: sourceJob.detected_title || sourceJob.title || null + detectedTitle: sourceJob.detected_title || sourceJob.title || null, + jobKind: this.resolveJobKindForJob(sourceJob, { + encodePlan: this.safeParseJson(sourceJob.encode_plan_json) + }) || 'cd' }); const replacementJobId = Number(replacementJob?.id || 0); if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { @@ -18044,6 +18231,47 @@ class PipelineService extends EventEmitter { // CONVERTER // ═══════════════════════════════════════════════════════════════════════════ + /** + * Zentraler Intake für dateibasierte Jobs. + * Bestehende Endpunkte bleiben erhalten und delegieren intern auf diese Methode. + * + * @param {object} payload + * @param {'audiobook_upload'|'audiobook'|'converter_entry'|'converter'} payload.kind + * @param {object} [payload.file] + * @param {string} [payload.relPath] + * @param {object} [payload.options] + * @returns {Promise} + */ + async createFileJob(payload = {}) { + const inferredKind = String(payload?.kind || '').trim().toLowerCase() + || (payload?.file ? 'audiobook_upload' : '') + || (payload?.relPath ? 'converter_entry' : ''); + const kind = inferredKind; + + if (kind === 'audiobook_upload' || kind === 'audiobook') { + if (!payload?.file) { + const error = new Error('Upload-Datei fehlt.'); + error.statusCode = 400; + throw error; + } + return this.uploadAudiobookFile(payload.file, payload?.options || {}); + } + + if (kind === 'converter_entry' || kind === 'converter') { + const relPath = String(payload?.relPath || '').trim(); + if (!relPath) { + const error = new Error('relPath fehlt.'); + error.statusCode = 400; + throw error; + } + return this.createConverterJobFromEntry(relPath, payload?.options || {}); + } + + const error = new Error(`Unbekannter File-Job-Typ: ${kind || 'n/a'}`); + error.statusCode = 400; + throw error; + } + /** * Erstellt einen Converter-Job aus einem Scan-Eintrag (File-Explorer-Auswahl). * @@ -18100,21 +18328,30 @@ class PipelineService extends EventEmitter { const isVideoLikeConverterJob = converterMediaType === 'video' || converterMediaType === 'iso'; const initialStatus = 'READY_TO_START'; + const normalizedConverterMediaType = ['audio', 'video', 'iso'].includes( + String(converterMediaType || '').trim().toLowerCase() + ) + ? String(converterMediaType || '').trim().toLowerCase() + : 'video'; const job = await historyService.createJob({ discDevice: null, status: initialStatus, - detectedTitle + detectedTitle, + mediaType: 'converter', + jobKind: resolveConverterJobKind(normalizedConverterMediaType) }); await historyService.updateJob(job.id, { media_type: 'converter', + job_kind: resolveConverterJobKind(normalizedConverterMediaType), raw_path: fullPath, encode_plan_json: JSON.stringify({ mediaProfile: 'converter', - converterMediaType, + jobKind: resolveConverterJobKind(normalizedConverterMediaType), + converterMediaType: normalizedConverterMediaType, inputPath: fullPath, isFolder: isDirectory, - audioFiles: isDirectory && converterMediaType === 'audio' + audioFiles: isDirectory && normalizedConverterMediaType === 'audio' ? require('./converterScanService').detectFormat ? null // wird beim Start gefüllt : null @@ -18129,7 +18366,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( job.id, 'SYSTEM', - `Converter-Job erstellt aus: ${relPath} | Typ: ${converterMediaType || '-'}` + `Converter-Job erstellt aus: ${relPath} | Typ: ${normalizedConverterMediaType || '-'}` ); if (isVideoLikeConverterJob) { await historyService.appendLog( @@ -18140,7 +18377,7 @@ class PipelineService extends EventEmitter { } logger.info('converter:job:created-from-entry', { - jobId: job.id, relPath, converterMediaType, fullPath + jobId: job.id, relPath, converterMediaType: normalizedConverterMediaType, fullPath }); return historyService.getJobById(job.id); @@ -18172,6 +18409,30 @@ class PipelineService extends EventEmitter { throw error; } + const settings = await settingsService.getSettingsMap(); + const allowedExtensions = new Set(parseConverterScanExtensions(settings?.converter_scan_extensions)); + const invalidFiles = []; + for (const file of normalizedFiles) { + const tempPath = String(file?.path || '').trim(); + const originalName = String(file?.originalname || file?.originalName || '').trim() + || path.basename(tempPath || 'upload'); + const extension = getFileExtensionWithoutDot(originalName); + if (!extension || !allowedExtensions.has(extension)) { + invalidFiles.push(originalName); + } + } + if (invalidFiles.length > 0) { + cleanupTempUploads(normalizedFiles); + const allowedList = [...allowedExtensions].join(', '); + const preview = invalidFiles.slice(0, 6).join(', '); + const suffix = invalidFiles.length > 6 ? ` (+${invalidFiles.length - 6} weitere)` : ''; + const error = new Error( + `Upload abgelehnt: Nicht erlaubte Dateiendung in ${invalidFiles.length} Datei(en): ${preview}${suffix}. Erlaubt: ${allowedList}` + ); + error.statusCode = 400; + throw error; + } + const folderName = options?.folderName ? String(options.folderName).trim() : null; if (folderName) { @@ -18246,7 +18507,7 @@ class PipelineService extends EventEmitter { * @returns {Promise} Erstellte Jobs */ async createConverterJobsFromSelection(relPaths, audioMode = 'individual') { - const AUDIO_EXTS = new Set(['.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']); + const AUDIO_EXTS = new Set(['.flac', '.mp3', '.wav', '.m4a', '.ogg', '.opus']); const converterScanService = require('./converterScanService'); const rawDir = await converterScanService.getRawDir(); @@ -18272,7 +18533,11 @@ class PipelineService extends EventEmitter { // Nicht-Audio (Video, Ordner, sonstige): immer ein Job pro Eintrag for (const relPath of nonAudioRelPaths) { - const job = await this.createConverterJobFromEntry(relPath, {}); + const job = await this.createFileJob({ + kind: 'converter_entry', + relPath, + options: {} + }); createdJobs.push(job); } @@ -18286,14 +18551,18 @@ class PipelineService extends EventEmitter { const job = await historyService.createJob({ discDevice: null, status: 'READY_TO_START', - detectedTitle + detectedTitle, + mediaType: 'converter', + jobKind: 'converter_audio' }); await historyService.updateJob(job.id, { media_type: 'converter', + job_kind: 'converter_audio', raw_path: rawPath, encode_plan_json: JSON.stringify({ mediaProfile: 'converter', + jobKind: 'converter_audio', converterMediaType: 'audio', inputPath: rawPath, inputPaths: fullPaths, @@ -18318,7 +18587,11 @@ class PipelineService extends EventEmitter { } else { // Einzelne Jobs für jede Audio-Datei for (const relPath of audioRelPaths) { - const job = await this.createConverterJobFromEntry(relPath, { converterMediaType: 'audio' }); + const job = await this.createFileJob({ + kind: 'converter_entry', + relPath, + options: { converterMediaType: 'audio' } + }); createdJobs.push(job); } } @@ -18367,7 +18640,7 @@ class PipelineService extends EventEmitter { error.statusCode = 404; throw error; } - if (String(job.media_type || '').trim().toLowerCase() !== 'converter') { + if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) { const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`); error.statusCode = 409; throw error; @@ -18481,6 +18754,7 @@ class PipelineService extends EventEmitter { const nextPlan = { ...existingPlan, mediaProfile: 'converter', + jobKind: 'converter_audio', converterMediaType: 'audio', isFolder: false, isSharedAudio, @@ -18494,6 +18768,7 @@ class PipelineService extends EventEmitter { status: 'READY_TO_START', last_state: 'READY_TO_START', media_type: 'converter', + job_kind: 'converter_audio', raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0], encode_plan_json: JSON.stringify(nextPlan), encode_review_confirmed: 0, @@ -18551,7 +18826,7 @@ class PipelineService extends EventEmitter { error.statusCode = 404; throw error; } - if (String(job.media_type || '').trim().toLowerCase() !== 'converter') { + if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) { const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`); error.statusCode = 409; throw error; @@ -18621,6 +18896,7 @@ class PipelineService extends EventEmitter { const nextPlan = { ...existingPlan, mediaProfile: 'converter', + jobKind: 'converter_audio', converterMediaType: 'audio', isFolder: false, isSharedAudio, @@ -18634,6 +18910,7 @@ class PipelineService extends EventEmitter { status: 'READY_TO_START', last_state: 'READY_TO_START', media_type: 'converter', + job_kind: 'converter_audio', raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0], encode_plan_json: JSON.stringify(nextPlan), encode_review_confirmed: 0, @@ -18718,6 +18995,7 @@ class PipelineService extends EventEmitter { const nextPlan = { ...existingPlan, mediaProfile: 'converter', + jobKind: 'converter_audio', converterMediaType: 'audio', isFolder: false, isSharedAudio, @@ -18731,6 +19009,7 @@ class PipelineService extends EventEmitter { status: 'READY_TO_START', last_state: 'READY_TO_START', media_type: 'converter', + job_kind: 'converter_audio', raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0], encode_plan_json: JSON.stringify(nextPlan), encode_review_confirmed: 0, @@ -18772,7 +19051,7 @@ class PipelineService extends EventEmitter { error.statusCode = 404; throw error; } - if (String(job.media_type || '').trim().toLowerCase() !== 'converter') { + if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) { const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`); error.statusCode = 409; throw error; @@ -18804,8 +19083,16 @@ class PipelineService extends EventEmitter { : 'video'; const defaultFormat = converterMediaType === 'audio' ? 'flac' : 'mkv'; - const outputFormat = normalizeText(config.outputFormat || existingPlan.outputFormat || defaultFormat, 20) + const allowedAudioFormats = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']); + const allowedVideoFormats = new Set(['mkv', 'mp4', 'm4v']); + let outputFormat = normalizeText(config.outputFormat || existingPlan.outputFormat || defaultFormat, 20) ?.toLowerCase() || defaultFormat; + if (converterMediaType === 'audio' && !allowedAudioFormats.has(outputFormat)) { + outputFormat = 'flac'; + } + if ((converterMediaType === 'video' || converterMediaType === 'iso') && !allowedVideoFormats.has(outputFormat)) { + outputFormat = 'mkv'; + } let userPreset = existingPlan.userPreset || null; if (Object.prototype.hasOwnProperty.call(config, 'userPreset')) { @@ -18905,6 +19192,7 @@ class PipelineService extends EventEmitter { const nextPlan = { ...existingPlan, mediaProfile: 'converter', + jobKind: resolveConverterJobKind(converterMediaType), converterMediaType, outputFormat, userPreset, @@ -18919,6 +19207,8 @@ class PipelineService extends EventEmitter { const newCoverUrl = nextMetadata?.coverUrl || null; const jobPatch = { + media_type: 'converter', + job_kind: resolveConverterJobKind(converterMediaType), encode_plan_json: JSON.stringify(nextPlan), encode_review_confirmed: 0, error_message: null @@ -19000,7 +19290,16 @@ class PipelineService extends EventEmitter { let outputPath = null; let outputDir = null; let audioTrackTemplate = null; - const outputFormat = String(config.outputFormat || 'mkv').trim().toLowerCase(); + const allowedAudioFormats = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']); + const allowedVideoFormats = new Set(['mkv', 'mp4', 'm4v']); + const defaultOutputFormat = converterMediaType === 'audio' ? 'flac' : 'mkv'; + let outputFormat = String(config.outputFormat || existingPlan.outputFormat || defaultOutputFormat).trim().toLowerCase(); + if (converterMediaType === 'audio' && !allowedAudioFormats.has(outputFormat)) { + outputFormat = 'flac'; + } + if ((converterMediaType === 'video' || converterMediaType === 'iso') && !allowedVideoFormats.has(outputFormat)) { + outputFormat = 'mkv'; + } const baseName = sanitizeFileName( path.basename(inputPath, path.extname(inputPath)) ) || 'output'; @@ -19115,7 +19414,7 @@ class PipelineService extends EventEmitter { 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']); + const AUDIO_EXTS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']); try { audioFiles = readdirSync(inputPath) .filter((f) => AUDIO_EXTS.has(path.extname(f).slice(1).toLowerCase())) @@ -19147,6 +19446,7 @@ class PipelineService extends EventEmitter { const nextEncodePlan = { ...existingPlan, mediaProfile: 'converter', + jobKind: resolveConverterJobKind(converterMediaType), converterMediaType, inputPath, inputPaths: existingPlan.inputPaths || null, @@ -19181,6 +19481,7 @@ class PipelineService extends EventEmitter { status: 'READY_TO_START', last_state: 'READY_TO_START', media_type: 'converter', + job_kind: resolveConverterJobKind(converterMediaType), output_path: outputPath || outputDir || null, encode_plan_json: JSON.stringify(nextEncodePlan), encode_review_confirmed: converterMediaType === 'audio' ? 1 : 0, @@ -19224,6 +19525,55 @@ class PipelineService extends EventEmitter { }; } + /** + * Gibt alle Audiobook-Jobs zurück (für die Audiobooks-Seite). + */ + async getAudiobookJobs() { + const db = await require('../db/database').getDb(); + const rows = await db.all(` + SELECT id, title, detected_title, year, poster_url, + status, last_state, media_type, job_kind, + encode_plan_json, handbrake_info_json, makemkv_info_json, + output_path, raw_path, error_message, + start_time, end_time, created_at, updated_at + FROM jobs + WHERE media_type = 'audiobook' + OR job_kind = 'audiobook' + OR ( + (media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other') + AND ( + encode_plan_json LIKE '%"mediaProfile":"audiobook"%' + OR encode_plan_json LIKE '%"mode":"audiobook"%' + ) + ) + ORDER BY created_at DESC + LIMIT 200 + `); + return rows.map((row) => { + const encodePlan = this.safeParseJson(row.encode_plan_json); + const handbrakeInfo = this.safeParseJson(row.handbrake_info_json); + const makemkvInfo = this.safeParseJson(row.makemkv_info_json); + const rawMediaType = String(row?.media_type || '').trim().toLowerCase(); + const planMode = String(encodePlan?.mode || '').trim().toLowerCase(); + const planProfile = String(encodePlan?.mediaProfile || '').trim().toLowerCase(); + const effectiveMediaType = rawMediaType === 'audiobook' + ? 'audiobook' + : ( + planProfile === 'audiobook' || planMode === 'audiobook' + ? 'audiobook' + : (rawMediaType || null) + ); + + return { + ...row, + media_type: effectiveMediaType, + encodePlan, + handbrakeInfo, + makemkvInfo + }; + }); + } + /** * Gibt alle Converter-Jobs zurück (für die Converter-Seite). */ @@ -19231,11 +19581,12 @@ class PipelineService extends EventEmitter { const db = await require('../db/database').getDb(); const rows = await db.all(` SELECT id, title, detected_title, year, imdb_id, poster_url, - status, last_state, media_type, encode_review_confirmed, + status, last_state, media_type, job_kind, encode_review_confirmed, encode_plan_json, output_path, raw_path, encode_input_path, error_message, start_time, end_time, created_at, updated_at FROM jobs WHERE media_type = 'converter' + OR job_kind LIKE 'converter_%' OR ( (media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other') AND ( diff --git a/db/schema.sql b/db/schema.sql index 3c90272..80351bb 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -50,6 +50,7 @@ CREATE TABLE jobs ( encode_review_confirmed INTEGER DEFAULT 0, aax_checksum TEXT, media_type TEXT, + job_kind 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 @@ -58,6 +59,7 @@ CREATE TABLE jobs ( CREATE INDEX idx_jobs_status ON jobs(status); CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC); CREATE INDEX idx_jobs_parent_job_id ON jobs(parent_job_id); +CREATE INDEX idx_jobs_job_kind ON jobs(job_kind); CREATE TABLE job_lineage_artifacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -588,8 +590,29 @@ UPDATE settings_schema SET category = 'Pfade' WHERE key IN ('converter_raw_dir', -- 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'); +VALUES ('converter_scan_extensions', 'Converter', 'Erlaubte Datei-Endungen*', 'string', 1, 'Erlaubte Datei-Endungen für den Converter-Scan (ohne Punkt). Wird in der UI als Checkbox-Liste gepflegt.', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,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,wav,m4a,ogg,opus'); +UPDATE settings_schema +SET + label = 'Erlaubte Datei-Endungen*', + description = 'Erlaubte Datei-Endungen für den Converter-Scan (ohne Punkt). Wird in der UI als Checkbox-Liste gepflegt.', + default_value = 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus' +WHERE key = 'converter_scan_extensions'; +UPDATE settings_values +SET value = TRIM( + REPLACE( + REPLACE( + REPLACE(',' || LOWER(COALESCE(value, '')) || ',', ',aac,', ','), + ',,', ',' + ), + ',,', ',' + ), + ',' +) +WHERE key = 'converter_scan_extensions'; +UPDATE settings_values +SET value = 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus' +WHERE key = 'converter_scan_extensions' AND (value IS NULL OR TRIM(value) = ''); 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); diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 8cbb75a..5820558 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -6,7 +6,7 @@ Frontend: React + PrimeReact + Vite. ## Hauptseiten -### `DashboardPage.jsx` +### `RipperPage.jsx` Pipeline-Steuerung: diff --git a/docs/architecture/index.md b/docs/architecture/index.md index 8a44a70..7ae42e4 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -9,7 +9,7 @@ Ripster ist eine Client-Server-Anwendung mit REST + WebSocket und externen CLI-T ```mermaid graph TB subgraph Browser["Browser (React)"] - Dashboard[Dashboard] + Ripper[Ripper] Settings[Einstellungen] History[Historie] end diff --git a/docs/configuration/settings-reference.md b/docs/configuration/settings-reference.md index f0949b6..d92784b 100644 --- a/docs/configuration/settings-reference.md +++ b/docs/configuration/settings-reference.md @@ -145,7 +145,7 @@ Nicht gesetzte Werte werden zu `unknown`. | Feldname in der GUI | Typ | Default | Hinweis | |---|---|---|---| -| `Erlaubte Datei-Endungen` | string | `mkv,mp4,m2ts,iso,avi,mov,flac,mp3,aac,wav,m4a,ogg,opus` | Komma-getrennt, ohne Punkt | +| `Erlaubte Datei-Endungen*` | string | `mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus` | In der UI als Checkbox-Liste, ohne Punkt | | `Auto-Scan (Polling)` | boolean | `false` | Converter-Ordner automatisch scannen | | `Polling-Intervall (Sekunden)` | number | `300` | 30..86400 | diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index aa1b45f..c190141 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -37,7 +37,7 @@ Typische Beispiele: ### 3. Queue und Monitoring festlegen - `Parallele Jobs` für den gleichzeitigen Durchsatz -- `Hardware Monitoring aktiviert` + `Hardware Monitoring Intervall (ms)` für Live-Metriken im Dashboard +- `Hardware Monitoring aktiviert` + `Hardware Monitoring Intervall (ms)` für Live-Metriken im Ripper ### 4. Optional: Push-Benachrichtigungen @@ -51,7 +51,7 @@ Dann über `PushOver Test` direkt prüfen. ## 2-Minuten-Funktionstest -1. `Dashboard` öffnen +1. `Ripper` öffnen 2. Disc einlegen 3. `Analyse starten` 4. Metadaten übernehmen diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index b94a262..03560bb 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -2,7 +2,7 @@ Dieser Ablauf zeigt einen vollständigen Job aus Anwendersicht: von Disc-Erkennung bis fertiger Datei. -## 1. Dashboard öffnen und Disc einlegen +## 1. Ripper öffnen und Disc einlegen Erwartung: @@ -13,7 +13,7 @@ Wenn nichts passiert: `Laufwerk neu lesen`. ## 2. Analyse starten -Aktion im Dashboard: +Aktion im Ripper: - `Analyse starten` @@ -51,7 +51,7 @@ Dann `Encoding starten`. Während `Encodieren`: -- Fortschritt + ETA im Dashboard +- Fortschritt + ETA im Ripper - Live-Log im `Pipeline-Status` - Queue- und Skript/Cron-Status parallel beobachtbar diff --git a/docs/gui/converter.md b/docs/gui/converter.md index 2d96c25..b71f328 100644 --- a/docs/gui/converter.md +++ b/docs/gui/converter.md @@ -32,7 +32,7 @@ Dateien können per Checkbox ausgewählt werden. Aus der Auswahl lassen sich Job - **Ein Job pro Datei** — jede Datei wird als eigenständiger Job angelegt - **Gemeinsamer Job (Audio)** — mehrere Audio-Dateien werden zu einem gemeinsamen Job zusammengefasst -Erlaubte Dateiendungen werden in den Settings unter `Converter > Erlaubte Datei-Endungen` konfiguriert. +Erlaubte Dateiendungen werden in den Settings unter `Converter > Erlaubte Datei-Endungen*` als Checkboxen konfiguriert. --- @@ -84,7 +84,7 @@ Für AAX-Dateien (Audible) ist folgendes erforderlich: | Eingangsordner | `Settings > Pfade > Converter Raw-Ordner` | | Videoausgabe | `Settings > Pfade > Converter Ausgabe (Video)` | | Audioausgabe | `Settings > Pfade > Converter Ausgabe (Audio)` | -| Erlaubte Endungen | `Settings > Converter > Erlaubte Datei-Endungen` | +| Erlaubte Endungen | `Settings > Converter > Erlaubte Datei-Endungen*` | | Auto-Scan | `Settings > Converter > Auto-Scan (Polling)` | | Scan-Intervall | `Settings > Converter > Polling-Intervall (Sekunden)` | | Output-Template Video | `Settings > Pfade > Output-Template (Video)` | diff --git a/docs/gui/index.md b/docs/gui/index.md index ecb6013..53f613c 100644 --- a/docs/gui/index.md +++ b/docs/gui/index.md @@ -6,7 +6,7 @@ Ripster hat fünf Hauptseiten in der Navigation plus eine Expert-Seite. | Seite | Zweck | |---|---| -| [Dashboard](dashboard.md) | Live-Betrieb: Pipeline, Queue, Aktivitäten, Disc-Infos | +| [Ripper](ripper.md) | Live-Betrieb: Pipeline, Queue, Aktivitäten, Disc-Infos | | [Settings](settings.md) | Konfiguration, Skripte, Ketten, Presets, Cronjobs | | [Historie](history.md) | abgeschlossene/laufende Jobs durchsuchen und nachbearbeiten | | [Converter](converter.md) | Audio/Video-Dateien konvertieren, Datei-Explorer, Upload | @@ -15,7 +15,7 @@ Ripster hat fünf Hauptseiten in der Navigation plus eine Expert-Seite. ## Empfohlene Nutzung im Alltag -1. **Start eines neuen Disc-Jobs:** `Dashboard` +1. **Start eines neuen Disc-Jobs:** `Ripper` 2. **Dateien konvertieren oder Audiobooks verarbeiten:** `Converter` 3. **Regeln/Automatisierung anpassen:** `Settings` 4. **Ergebnisse prüfen oder Jobs nachbearbeiten:** `Historie` @@ -24,5 +24,5 @@ Ripster hat fünf Hauptseiten in der Navigation plus eine Expert-Seite. ## Hinweise zur Navigation -- `Dashboard`, `Settings`, `Historie`, `Converter` und `Downloads` sind direkt in der Kopfnavigation. +- `Ripper`, `Settings`, `Historie`, `Converter` und `Downloads` sind direkt in der Kopfnavigation. - `Database` ist als Expert-Route verfügbar: `/database`. diff --git a/docs/gui/dashboard.md b/docs/gui/ripper.md similarity index 96% rename from docs/gui/dashboard.md rename to docs/gui/ripper.md index abb9a2f..2782720 100644 --- a/docs/gui/dashboard.md +++ b/docs/gui/ripper.md @@ -1,6 +1,6 @@ -# Dashboard +# Ripper -Das Dashboard ist die **Betriebszentrale** für laufende Jobs. +Das Ripper ist die **Betriebszentrale** für laufende Jobs. ## Aufbau der Seite @@ -107,7 +107,7 @@ Aktionen: --- -## Wichtige Dialoge im Dashboard +## Wichtige Dialoge im Ripper ### Metadaten auswählen diff --git a/docs/gui/settings.md b/docs/gui/settings.md index b308a53..c8f8d1c 100644 --- a/docs/gui/settings.md +++ b/docs/gui/settings.md @@ -9,7 +9,7 @@ Die Seite `Settings` steuert Konfiguration und Automatisierung. | `Konfiguration` | alle Kernsettings (Pfade, Tools, Monitoring, Metadaten, Queue, Benachrichtigungen) | | `Scripte` | einzelne Bash-Skripte verwalten und testen | | `Skriptketten` | Sequenzen aus Skript- und Warte-Schritten bauen | -| `Encode-Presets` | benutzerdefinierte Presets für das Review im Dashboard | +| `Encode-Presets` | benutzerdefinierte Presets für das Review im Ripper | | `Cronjobs` | zeitgesteuerte Skript-/Kettenausführung | --- @@ -65,7 +65,7 @@ Ein Preset bündelt: Verwendung: -- Diese Presets erscheinen später im Dashboard im Review (`Bereit zum Encodieren`). +- Diese Presets erscheinen später im Ripper im Review (`Bereit zum Encodieren`). ## Tab `Cronjobs` diff --git a/docs/index.md b/docs/index.md index 4ca2142..0307b8d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,7 +16,7 @@ Dieses Dokumentationsset ist als **Benutzerhandbuch** aufgebaut: erst Bedienung - **Benutzerhandbuch** - Installation - - GUI-Seiten im Detail (`Dashboard`, `Settings`, `Historie`, `Database`) + - GUI-Seiten im Detail (`Ripper`, `Settings`, `Historie`, `Database`) - typische Arbeitsabläufe aus Anwendersicht - **Technischer Anhang** - vollständige Einstellungsreferenz diff --git a/docs/pipeline/playlist-analysis.md b/docs/pipeline/playlist-analysis.md index 4ce38f2..b96d683 100644 --- a/docs/pipeline/playlist-analysis.md +++ b/docs/pipeline/playlist-analysis.md @@ -52,7 +52,7 @@ Default ist aktuell `60` Minuten. ## UI-Verhalten -Bei manueller Entscheidung zeigt das Dashboard Kandidaten inkl. Score/Bewertung und markiert eine Empfehlung. +Bei manueller Entscheidung zeigt das Ripper Kandidaten inkl. Score/Bewertung und markiert eine Empfehlung. Nach Bestätigung: diff --git a/docs/workflows/index.md b/docs/workflows/index.md index 17755d2..f72df9b 100644 --- a/docs/workflows/index.md +++ b/docs/workflows/index.md @@ -4,7 +4,7 @@ Diese Seite beschreibt typische Abläufe mit den passenden UI-Aktionen. ## Workflow 1: Standardlauf (Disc -> fertige Datei) -1. `Dashboard`: Disc einlegen, `Analyse starten` +1. `Ripper`: Disc einlegen, `Analyse starten` 2. Metadaten im Dialog übernehmen 3. bei `Bereit zum Encodieren` Titel/Tracks prüfen 4. `Encoding starten` @@ -38,21 +38,21 @@ In `Historie` -> Detaildialog: 1. `Settings` -> `Scripte`: Skripte anlegen und testen 2. `Settings` -> `Skriptketten`: Ketten bauen und testen -3. im Dashboard-Review Pre-/Post-Ausführungen pro Job auswählen +3. im Ripper-Review Pre-/Post-Ausführungen pro Job auswählen 4. `Settings` -> `Cronjobs`: zeitgesteuerte Ausführung konfigurieren -5. Status im Dashboard (`Skript- / Cron-Status`) überwachen +5. Status im Ripper (`Skript- / Cron-Status`) überwachen ## Workflow 6: Abbruch und Recovery ### Fall A: Job wurde abgebrochen -- im Dashboard optional erzeugte RAW/Movie-Datei bereinigen +- im Ripper optional erzeugte RAW/Movie-Datei bereinigen - anschließend je nach Ziel: `Retry Rippen` oder `Disk-Analyse neu starten` ### Fall B: Job steht in `Bereit zum Encodieren`, ist aber nicht aktive Session -- in `Historie`: `Im Dashboard öffnen` -- im Dashboard Review erneut prüfen und starten +- in `Historie`: `Im Ripper öffnen` +- im Ripper Review erneut prüfen und starten ### Fall C: RAW ohne Historieneintrag diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2d5f1b5..d968f23 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom'; +import { Navigate, Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom'; import { Button } from 'primereact/button'; import { ProgressBar } from 'primereact/progressbar'; import { Tag } from 'primereact/tag'; @@ -7,12 +7,14 @@ import { Toast } from 'primereact/toast'; import { ConfirmDialog } from 'primereact/confirmdialog'; import { api } from './api/client'; import { useWebSocket } from './hooks/useWebSocket'; -import DashboardPage from './pages/DashboardPage'; +import RipperPage from './pages/RipperPage'; 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'; +import JobsInboxPage from './pages/JobsInboxPage'; +import AudiobooksPage from './pages/AudiobooksPage'; function normalizeJobId(value) { const parsed = Number(value); @@ -123,11 +125,11 @@ function App() { const [lastDiscEvent, setLastDiscEvent] = useState(null); const [expertMode, setExpertMode] = useState(false); const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState()); - const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0); + const [ripperJobsRefreshToken, setRipperJobsRefreshToken] = useState(0); const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0); const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0); const [downloadSummary, setDownloadSummary] = useState(null); - const [pendingDashboardJobId, setPendingDashboardJobId] = useState(null); + const [pendingRipperJobId, setPendingRipperJobId] = useState(null); const location = useLocation(); const navigate = useNavigate(); const globalToastRef = useRef(null); @@ -135,7 +137,7 @@ function App() { // When a virtual CD drive is removed (CD encode/rip finished or failed), // or when a CD drive transitions away from an active job, force both - // Dashboard and History to re-fetch so jobs leave the live list reliably. + // Ripper and History to re-fetch so jobs leave the live list reliably. useEffect(() => { const current = pipeline?.cdDrives || {}; const prev = prevCdDrivesRef.current; @@ -167,7 +169,7 @@ function App() { } if (shouldRefresh) { - setDashboardJobsRefreshToken((t) => t + 1); + setRipperJobsRefreshToken((t) => t + 1); setHistoryJobsRefreshToken((t) => t + 1); } prevCdDrivesRef.current = current; @@ -235,10 +237,10 @@ function App() { const uploadedJobId = normalizeJobId(response?.result?.jobId); await refreshPipeline().catch(() => null); - setDashboardJobsRefreshToken((prev) => prev + 1); + setRipperJobsRefreshToken((prev) => prev + 1); setHistoryJobsRefreshToken((prev) => prev + 1); if (uploadedJobId) { - setPendingDashboardJobId(uploadedJobId); + setPendingRipperJobId(uploadedJobId); } setAudiobookUpload((prev) => ({ @@ -268,12 +270,12 @@ function App() { } }; - const handleDashboardJobFocusConsumed = (jobId) => { + const handleRipperJobFocusConsumed = (jobId) => { const normalizedJobId = normalizeJobId(jobId); if (!normalizedJobId) { return; } - setPendingDashboardJobId((prev) => ( + setPendingRipperJobId((prev) => ( normalizeJobId(prev) === normalizedJobId ? null : prev )); }; @@ -525,8 +527,10 @@ function App() { }); const nav = [ - { label: 'Dashboard', path: '/' }, + { label: 'Jobs', path: '/jobs' }, + { label: 'Ripper', path: '/ripper' }, { label: 'Converter', path: '/converter' }, + { label: 'Audiobooks', path: '/audiobooks' }, { label: 'Settings', path: '/settings' }, { label: 'Historie', path: '/history' }, { label: 'Downloads', path: '/downloads' }, @@ -543,8 +547,14 @@ function App() { : (uploadLoadedBytes > 0 ? `${formatBytes(uploadLoadedBytes)} hochgeladen` : null); const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error'; const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId)); - const isDashboardRoute = location.pathname === '/'; + const isAudiobooksRoute = location.pathname === '/audiobooks'; const downloadIndicator = getDownloadIndicatorMeta(downloadSummary); + const isNavActive = (path) => { + if (path === '/ripper') { + return location.pathname === '/' || location.pathname === '/ripper'; + } + return location.pathname === path; + }; return (
@@ -570,8 +580,8 @@ function App() { key={item.path} label={item.label} onClick={() => navigate(item.path)} - className={location.pathname === item.path ? 'nav-btn nav-btn-active' : 'nav-btn'} - outlined={location.pathname !== item.path} + className={isNavActive(item.path) ? 'nav-btn nav-btn-active' : 'nav-btn'} + outlined={!isNavActive(item.path)} /> ))}
@@ -603,18 +613,14 @@ function App() {
- {hasUploadedJob && !isDashboardRoute ? ( + {hasUploadedJob && !isAudiobooksRoute ? (
{isRunning ? ( -
+
{Math.round(progress)}%{currentChapter ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}: ${currentChapter.title}` : ''}
diff --git a/frontend/src/components/AudiobookOutputExplorer.jsx b/frontend/src/components/AudiobookOutputExplorer.jsx new file mode 100644 index 0000000..c4d5efc --- /dev/null +++ b/frontend/src/components/AudiobookOutputExplorer.jsx @@ -0,0 +1,298 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button } from 'primereact/button'; +import { ProgressSpinner } from 'primereact/progressspinner'; +import { api } from '../api/client'; + +function formatBytes(value) { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) { + return ''; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let index = 0; + let current = n; + while (current >= 1024 && index < units.length - 1) { + current /= 1024; + index += 1; + } + return `${current.toFixed(index <= 1 ? 0 : 1)} ${units[index]}`; +} + +function formatDateTime(value) { + if (!value) { + return '-'; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return '-'; + } + return parsed.toLocaleString('de-DE'); +} + +function getNodeByPath(root, targetPath) { + if (!root) { + return null; + } + if ((root.path || '') === (targetPath || '')) { + return root; + } + for (const child of (root.children || [])) { + if (child.type !== 'folder') { + continue; + } + const found = getNodeByPath(child, targetPath); + if (found) { + return found; + } + } + return null; +} + +function listChildren(node) { + if (!node || !Array.isArray(node.children)) { + return []; + } + return node.children; +} + +function buildBreadcrumb(pathValue) { + if (!pathValue) { + return []; + } + const parts = String(pathValue).split('/').filter(Boolean); + return parts.map((part, index) => ({ + name: part, + path: parts.slice(0, index + 1).join('/') + })); +} + +function filterFolderTree(node, query) { + if (!node || node.type !== 'folder') { + return null; + } + if (!query || !query.trim()) { + return node; + } + const normalized = query.toLowerCase(); + const children = (node.children || []) + .filter((child) => child.type === 'folder') + .map((child) => filterFolderTree(child, query)) + .filter(Boolean); + const nameMatches = String(node.name || '').toLowerCase().includes(normalized); + if (nameMatches || children.length > 0) { + return { ...node, children }; + } + return null; +} + +function defaultExpandedSet(tree) { + const next = new Set(['']); + const firstLevel = Array.isArray(tree?.children) ? tree.children : []; + for (const entry of firstLevel) { + if (entry?.type === 'folder' && entry?.path) { + next.add(entry.path); + } + } + return next; +} + +export default function AudiobookOutputExplorer({ refreshToken = 0 }) { + const [tree, setTree] = useState(null); + const [outputDir, setOutputDir] = useState(null); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [currentPath, setCurrentPath] = useState(''); + const [expandedFolders, setExpandedFolders] = useState(() => new Set([''])); + const [sidebarQuery, setSidebarQuery] = useState(''); + + const loadTree = useCallback(async () => { + setLoading(true); + setErrorMessage(''); + try { + const response = await api.getAudiobookOutputTree(); + const nextTree = response?.tree || null; + setTree(nextTree); + setOutputDir(response?.outputDir || null); + setExpandedFolders(defaultExpandedSet(nextTree)); + setCurrentPath(''); + } catch (error) { + setTree(null); + setErrorMessage(error?.message || 'Explorer konnte nicht geladen werden.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadTree(); + }, [loadTree, refreshToken]); + + const navigateTo = (pathValue) => { + const normalized = String(pathValue || '').trim(); + const node = getNodeByPath(tree, normalized); + if (node && node.type === 'folder') { + setCurrentPath(normalized); + } + }; + + const toggleFolder = (pathValue) => { + const normalized = String(pathValue || '').trim(); + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(normalized)) { + next.delete(normalized); + } else { + next.add(normalized); + } + return next; + }); + }; + + const renderTreeNode = (node, depth = 0) => { + if (!node || node.type !== 'folder') { + return null; + } + const key = node.path || ''; + const isExpanded = expandedFolders.has(key); + const isCurrent = currentPath === key; + const children = Array.isArray(node.children) ? node.children.filter((entry) => entry.type === 'folder') : []; + + return ( +
+ + {isExpanded && children.map((child) => renderTreeNode(child, depth + 1))} +
+ ); + }; + + const filteredTree = useMemo(() => filterFolderTree(tree, sidebarQuery), [tree, sidebarQuery]); + const currentNode = getNodeByPath(tree, currentPath); + const currentChildren = listChildren(currentNode); + const breadcrumb = buildBreadcrumb(currentPath); + + if (loading && !tree) { + return ( +
+ +
+ ); + } + + if (!tree) { + return ( +
+ {errorMessage ? ( + {errorMessage} + ) : ( + Kein Audiobook-Output vorhanden. Ausgabepfad: {outputDir || 'nicht konfiguriert'} + )} +
+
+
+ ); + } + + return ( +
+
+
+ setSidebarQuery(event.target.value)} + className="sidebar-search" + /> +
+
+ {filteredTree ? renderTreeNode(filteredTree, 0) : Keine Ordner gefunden.} +
+
+ +
+
+
+
+ +
+
+ Name + Größe + Geändert +
+ + {currentChildren.length === 0 ? ( +
+ Keine Einträge in diesem Ordner. + + +
+ ) : ( + currentChildren.map((entry) => ( + + )) + )} +
+ +
+ + Root: {outputDir || '-'} + +
+
+
+ ); +} diff --git a/frontend/src/components/AudiobookUploadPanel.jsx b/frontend/src/components/AudiobookUploadPanel.jsx new file mode 100644 index 0000000..c437f95 --- /dev/null +++ b/frontend/src/components/AudiobookUploadPanel.jsx @@ -0,0 +1,200 @@ +import { useEffect, useRef, useState } from 'react'; +import { FileUpload } from 'primereact/fileupload'; +import { Button } from 'primereact/button'; +import { Tag } from 'primereact/tag'; +import { ProgressBar } from 'primereact/progressbar'; +import { Toast } from 'primereact/toast'; + +function formatBytes(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return 'n/a'; + } + if (parsed === 0) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let unitIndex = 0; + let current = parsed; + while (current >= 1024 && unitIndex < units.length - 1) { + current /= 1024; + unitIndex += 1; + } + const digits = unitIndex <= 1 ? 0 : 2; + return `${current.toFixed(digits)} ${units[unitIndex]}`; +} + +function normalizeJobId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +export default function AudiobookUploadPanel({ + audiobookUpload, + onAudiobookUpload, + onUploaded = null +}) { + const toastRef = useRef(null); + const fileUploadRef = useRef(null); + const [uploadFile, setUploadFile] = useState(null); + const [statusVisible, setStatusVisible] = useState(false); + + const phase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase(); + const uploadBusy = phase === 'uploading' || phase === 'processing'; + const progress = Number.isFinite(Number(audiobookUpload?.progressPercent)) + ? Math.max(0, Math.min(100, Number(audiobookUpload.progressPercent))) + : 0; + const loadedBytes = Number(audiobookUpload?.loadedBytes || 0); + const totalBytes = Number(audiobookUpload?.totalBytes || 0); + const fileName = String(audiobookUpload?.fileName || '').trim() + || String(uploadFile?.name || '').trim() + || null; + const statusTone = phase === 'error' + ? 'danger' + : phase === 'completed' + ? 'success' + : phase === 'processing' + ? 'info' + : phase === 'uploading' + ? 'warning' + : 'secondary'; + const statusLabel = phase === 'uploading' + ? 'Upload laeuft' + : phase === 'processing' + ? 'Server verarbeitet' + : phase === 'completed' + ? 'Bereit' + : phase === 'error' + ? 'Fehler' + : 'Inaktiv'; + + useEffect(() => { + if (phase === 'idle') { + setStatusVisible(false); + return; + } + setStatusVisible(true); + if (phase === 'completed') { + const timer = setTimeout(() => setStatusVisible(false), 5000); + return () => clearTimeout(timer); + } + return undefined; + }, [phase]); + + const handleUpload = async () => { + if (!uploadFile) { + toastRef.current?.show({ + severity: 'warn', + summary: 'Keine Datei', + detail: 'Bitte zuerst eine AAX-Datei auswaehlen.', + life: 2600 + }); + return; + } + try { + const response = await onAudiobookUpload?.(uploadFile, { startImmediately: false }); + const uploadedJobId = normalizeJobId(response?.result?.jobId); + if (uploadedJobId) { + toastRef.current?.show({ + severity: 'success', + summary: 'Audiobook importiert', + detail: `Job #${uploadedJobId} ist bereit.`, + life: 3200 + }); + } else { + toastRef.current?.show({ + severity: 'success', + summary: 'Audiobook importiert', + detail: 'Upload abgeschlossen.', + life: 2600 + }); + } + setUploadFile(null); + fileUploadRef.current?.clear?.(); + onUploaded?.(uploadedJobId, response); + } catch (error) { + toastRef.current?.show({ + severity: 'error', + summary: 'Upload fehlgeschlagen', + detail: error?.message || 'Bitte Logs pruefen.', + life: 4200 + }); + } + }; + + return ( +
+ + void handleUpload()} + disabled={uploadBusy} + onSelect={(event) => setUploadFile(event.files[0] || null)} + onClear={() => setUploadFile(null)} + onRemove={() => setUploadFile(null)} + chooseOptions={{ icon: 'pi pi-images', iconOnly: true, className: 'p-button-rounded p-button-outlined' }} + uploadOptions={{ icon: 'pi pi-cloud-upload', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-success' }} + cancelOptions={{ icon: 'pi pi-times', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-danger' }} + itemTemplate={(file, options) => ( +
+ +
+ {file.name} + {options.formatSize} +
+
+ )} + emptyTemplate={() => ( +
+ +

AAX-Datei hier ablegen

+ oder oben "Auswaehlen" klicken +
+ )} + /> + + {statusVisible ? ( +
+
+ {statusLabel} + +
+ {audiobookUpload?.statusText ? {audiobookUpload.statusText} : null} + {fileName ? ( + + Datei: {fileName} + + ) : null} +
+ + + {phase === 'processing' + ? '100% | Upload fertig, Job wird vorbereitet ...' + : totalBytes > 0 + ? `${Math.round(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}` + : `${Math.round(progress)}%`} + +
+
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/ConverterJobCard.jsx b/frontend/src/components/ConverterJobCard.jsx index f3769d0..f298ffc 100644 --- a/frontend/src/components/ConverterJobCard.jsx +++ b/frontend/src/components/ConverterJobCard.jsx @@ -1056,9 +1056,9 @@ export default function ConverterJobCard({ })(); const jobIdLabel = Number.isFinite(Number(job?.id)) ? `Job #${Math.trunc(Number(job.id))}` : 'Job'; - const dashboardTitleId = Number.isFinite(Number(job?.id)) ? `#${Math.trunc(Number(job.id))}` : '#-'; + const ripperTitleId = Number.isFinite(Number(job?.id)) ? `#${Math.trunc(Number(job.id))}` : '#-'; const customTitle = String(job?.title || '').trim(); - const title = customTitle ? `${dashboardTitleId} | ${customTitle}` : dashboardTitleId; + const title = customTitle ? `${ripperTitleId} | ${customTitle}` : ripperTitleId; const status = job.status || 'UNKNOWN'; const statusLabel = getStatusLabel(status); const statusSeverity = getStatusSeverity(status); @@ -1173,15 +1173,15 @@ export default function ConverterJobCard({ return ( + ); + } + + return ( +
+
+
Kein Cover
+
+ + + #{jobId} | {title} + +
+ + +
+
+
+
+
+ void handleAudiobookStart(jobId, config)} + onCancel={() => void handleCancel(jobId)} + onRetry={() => void handleRetry(jobId)} + onDeleteJob={() => void handleDelete(jobId)} + busy={busy} + /> +
+ ); + })} + + )} + + + + + + + ); +} diff --git a/frontend/src/pages/ConverterPage.jsx b/frontend/src/pages/ConverterPage.jsx index 24351c0..b8fc385 100644 --- a/frontend/src/pages/ConverterPage.jsx +++ b/frontend/src/pages/ConverterPage.jsx @@ -21,15 +21,37 @@ import otherIndicatorIcon from '../assets/media-other.svg'; import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation'; import { confirmModal } from '../utils/confirmModal'; -const AUDIO_EXTS = new Set(['.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']); -const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.wmv', '.ts', '.m2ts', '.iso', '.m4v', '.flv', '.webm']); +const AUDIO_EXTS = new Set(['.flac', '.mp3', '.wav', '.m4a', '.ogg', '.opus']); +const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.m2ts', '.iso', '.avi', '.mov']); const TERMINAL_JOB_STATUSES = new Set(['DONE', 'FINISHED', 'ERROR', 'CANCELLED']); +const DEFAULT_CONVERTER_SCAN_EXTENSIONS = [ + 'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov', + 'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus' +]; const VIDEO_OUTPUT_FORMATS = [ { label: 'MKV', value: 'mkv' }, { label: 'MP4', value: 'mp4' }, { label: 'M4V', value: 'm4v' } ]; +function parseConverterScanExtensions(value) { + const seen = new Set(); + const parsed = String(value || '') + .split(',') + .map((item) => String(item || '').trim().toLowerCase()) + .filter((item) => { + if (!item || !DEFAULT_CONVERTER_SCAN_EXTENSIONS.includes(item) || seen.has(item)) { + return false; + } + seen.add(item); + return true; + }); + if (parsed.length === 0) { + return [...DEFAULT_CONVERTER_SCAN_EXTENSIONS]; + } + return parsed; +} + function isAudioEntry(e) { if (e.detectedMediaType === 'audio') return true; const p = (e.relPath || '').toLowerCase(); @@ -117,11 +139,24 @@ export default function ConverterPage() { const [expandedJobId, setExpandedJobId] = useState(undefined); const [videoUserPresets, setVideoUserPresets] = useState([]); const [videoHbPresets, setVideoHbPresets] = useState([]); + const [uploadExtensions, setUploadExtensions] = useState(() => [...DEFAULT_CONVERTER_SCAN_EXTENSIONS]); // Entries werden beim Öffnen des Modals gesichert, damit ein außerhalb-Click // die Selection nicht löscht bevor die Jobs erstellt werden const jobEntriesRef = useRef([]); const previousJobStatusesRef = useRef(new Map()); + const loadUploadExtensions = useCallback(async () => { + try { + const response = await api.getSettings({ forceRefresh: true }); + const allSettings = (response?.categories || []).flatMap((category) => category?.settings || []); + const setting = allSettings.find((item) => String(item?.key || '').trim() === 'converter_scan_extensions'); + const value = setting?.value ?? setting?.default_value ?? ''; + setUploadExtensions(parseConverterScanExtensions(value)); + } catch (_error) { + setUploadExtensions([...DEFAULT_CONVERTER_SCAN_EXTENSIONS]); + } + }, []); + useEffect(() => { let cancelled = false; const loadPresets = async () => { @@ -152,6 +187,10 @@ export default function ConverterPage() { }; }, []); + useEffect(() => { + void loadUploadExtensions(); + }, [loadUploadExtensions]); + const loadJobs = useCallback(async () => { setLoadingJobs(true); try { @@ -237,7 +276,7 @@ export default function ConverterPage() { useWebSocket({ onMessage: (message) => { - if (!message?.type || !message?.payload) return; + if (!message?.type) return; if (message.type === 'PIPELINE_PROGRESS') { const payload = message.payload; @@ -266,6 +305,19 @@ export default function ConverterPage() { setExplorerRefreshToken((t) => t + 1); } } + + if (message.type === 'SETTINGS_UPDATED') { + if (String(message?.payload?.key || '').trim() === 'converter_scan_extensions') { + void loadUploadExtensions(); + } + } + + if (message.type === 'SETTINGS_BULK_UPDATED') { + const keys = Array.isArray(message?.payload?.keys) ? message.payload.keys : []; + if (keys.includes('converter_scan_extensions')) { + void loadUploadExtensions(); + } + } } }); @@ -856,7 +908,7 @@ export default function ConverterPage() { ); return ( -
+
{/* Import-Ordner */} @@ -890,7 +942,7 @@ export default function ConverterPage() { Keine aktiven Converter-Jobs vorhanden. Abgeschlossene Jobs findest du in der Historie.

) : ( -
+
{activeJobs.map((job) => { const plan = parseConverterPlan(job); const converterMediaType = String(plan?.converterMediaType || '').trim().toLowerCase(); @@ -945,7 +997,10 @@ export default function ConverterPage() { {/* Upload */} - + {job?.poster_url && job.poster_url !== 'N/A' ? ( {title} ) : ( -
Kein Poster
+
Kein Poster
)} -
-
- +
+
+ {title} #{jobId}
-
+
{hasProgress && ( -
+
)} @@ -1195,19 +1250,19 @@ function ConverterVideoJobCard({ } return ( -
-
+
+
{job?.poster_url && job.poster_url !== 'N/A' ? ( {title} ) : ( -
Kein Poster
+
Kein Poster
)} -
- +
+ #{jobId} | {title} -
+
diff --git a/frontend/src/pages/HistoryPage.jsx b/frontend/src/pages/HistoryPage.jsx index d188728..da3c74f 100644 --- a/frontend/src/pages/HistoryPage.jsx +++ b/frontend/src/pages/HistoryPage.jsx @@ -745,7 +745,7 @@ export default function HistoryPage({ refreshToken = 0 }) { if (!skipConfirm) { const confirmed = await confirmModal({ header: 'Review neu starten', - message: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Dashboard neu getroffen werden.`, + message: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Ripper neu getroffen werden.`, acceptLabel: 'Review starten', rejectLabel: 'Abbrechen' }); @@ -761,7 +761,7 @@ export default function HistoryPage({ refreshToken = 0 }) { toastRef.current?.show({ severity: 'success', summary: 'Review-Neustart', - detail: 'Analyse gestartet. Job ist jetzt im Dashboard verfügbar.', + detail: 'Analyse gestartet. Job ist jetzt im Ripper verfügbar.', life: 3500 }); await load(); @@ -776,7 +776,7 @@ export default function HistoryPage({ refreshToken = 0 }) { if (!skipConfirm) { const confirmed = await confirmModal({ header: 'CD-Vorprüfung starten', - message: `CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Dashboard geöffnet.`, + message: `CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Ripper geöffnet.`, acceptLabel: 'Vorprüfung starten', rejectLabel: 'Abbrechen' }); @@ -790,7 +790,7 @@ export default function HistoryPage({ refreshToken = 0 }) { toastRef.current?.show({ severity: 'success', summary: 'CD-Vorprüfung gestartet', - detail: 'Job ist jetzt im Dashboard verfügbar — bitte Metadaten und Einstellungen wählen.', + detail: 'Job ist jetzt im Ripper verfügbar — bitte Metadaten und Einstellungen wählen.', life: 4000 }); await load(); diff --git a/frontend/src/pages/JobsInboxPage.jsx b/frontend/src/pages/JobsInboxPage.jsx new file mode 100644 index 0000000..1eb33c2 --- /dev/null +++ b/frontend/src/pages/JobsInboxPage.jsx @@ -0,0 +1,357 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Card } from 'primereact/card'; +import { Button } from 'primereact/button'; +import { InputText } from 'primereact/inputtext'; +import { Tag } from 'primereact/tag'; +import { Paginator } from 'primereact/paginator'; +import { api } from '../api/client'; +import { classifyJob } from '../utils/jobTaxonomy'; +import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation'; + +const ACTIVE_STATUSES = [ + 'ANALYZING', + 'METADATA_SELECTION', + 'WAITING_FOR_USER_DECISION', + 'READY_TO_START', + 'MEDIAINFO_CHECK', + 'READY_TO_ENCODE', + 'RIPPING', + 'ENCODING', + 'CD_METADATA_SELECTION', + 'CD_READY_TO_RIP', + 'CD_ANALYZING', + 'CD_RIPPING', + 'CD_ENCODING' +]; + +const STATUS_FILTERS = [ + { key: 'all', label: 'Alle' }, + { key: 'active', label: 'Aktiv' }, + { key: 'errors', label: 'Fehler/Abbruch' } +]; + +const VIEW_FILTERS = [ + { key: 'all', label: 'Alle Jobs' }, + { key: 'ripper', label: 'Ripper-View' }, + { key: 'converter', label: 'Converter-View' }, + { key: 'audiobook', label: 'Audiobooks' } +]; + +function normalizeJobId(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function formatUpdatedAt(value) { + if (!value) { + return '-'; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return '-'; + } + return parsed.toLocaleString('de-DE'); +} + +function getKindLabel(meta) { + if (meta.family === 'converter') { + if (meta.converterMediaType === 'audio') { + return 'Converter Audio'; + } + if (meta.converterMediaType === 'iso') { + return 'Converter ISO'; + } + return 'Converter Video'; + } + if (meta.family === 'audiobook') { + return 'Audiobook'; + } + if (meta.mediaType === 'bluray') { + return 'Blu-ray'; + } + if (meta.mediaType === 'dvd') { + return 'DVD'; + } + if (meta.mediaType === 'cd') { + return 'CD'; + } + return 'Sonstiges'; +} + +function getKindSeverity(meta) { + if (meta.family === 'converter') { + return 'info'; + } + if (meta.family === 'audiobook') { + return 'warning'; + } + if (meta.mediaType === 'bluray' || meta.mediaType === 'dvd') { + return 'success'; + } + return 'secondary'; +} + +export default function JobsInboxPage() { + const navigate = useNavigate(); + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(false); + const [viewFilter, setViewFilter] = useState('all'); + const [statusFilter, setStatusFilter] = useState('all'); + const [search, setSearch] = useState(''); + const [queuedJobIds, setQueuedJobIds] = useState(new Set()); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + const [first, setFirst] = useState(0); + const [rows, setRows] = useState(25); + + const loadJobs = useCallback(async () => { + setLoading(true); + try { + const query = { limit: 500, lite: true }; + if (statusFilter === 'active') { + query.statuses = ACTIVE_STATUSES; + } else if (statusFilter === 'errors') { + query.statuses = ['ERROR', 'CANCELLED']; + } + + const [jobsResponse, queueResponse] = await Promise.allSettled([ + api.getJobs(query), + api.getPipelineQueue() + ]); + + const nextJobs = jobsResponse.status === 'fulfilled' && Array.isArray(jobsResponse.value?.jobs) + ? jobsResponse.value.jobs + : []; + setJobs(nextJobs); + + if (queueResponse.status === 'fulfilled') { + const rows = Array.isArray(queueResponse.value?.queue?.queuedJobs) + ? queueResponse.value.queue.queuedJobs + : []; + setQueuedJobIds(new Set(rows.map((item) => normalizeJobId(item?.jobId)).filter(Boolean))); + } else { + setQueuedJobIds(new Set()); + } + setLastUpdatedAt(new Date().toISOString()); + } finally { + setLoading(false); + } + }, [statusFilter]); + + useEffect(() => { + loadJobs(); + const interval = setInterval(() => { + loadJobs().catch(() => null); + }, 7000); + return () => clearInterval(interval); + }, [loadJobs]); + + const jobsWithMeta = useMemo(() => ( + jobs.map((job) => ({ + job, + meta: classifyJob(job) + })) + ), [jobs]); + + const viewCounts = useMemo(() => { + let all = 0; + let ripper = 0; + let converter = 0; + let audiobook = 0; + for (const item of jobsWithMeta) { + all += 1; + if (item.meta.family === 'converter') { + converter += 1; + } else { + ripper += 1; + } + if (item.meta.family === 'audiobook') { + audiobook += 1; + } + } + return { all, ripper, converter, audiobook }; + }, [jobsWithMeta]); + + const filteredRows = useMemo(() => { + const normalizedSearch = String(search || '').trim().toLowerCase(); + return jobsWithMeta.filter(({ job, meta }) => { + if (viewFilter === 'ripper' && meta.family === 'converter') { + return false; + } + if (viewFilter === 'converter' && meta.family !== 'converter') { + return false; + } + if (viewFilter === 'audiobook' && meta.family !== 'audiobook') { + return false; + } + + if (!normalizedSearch) { + return true; + } + const haystack = [ + job?.title, + job?.detected_title, + job?.imdb_id, + job?.status, + job?.job_kind, + job?.media_type + ] + .map((value) => String(value || '').trim().toLowerCase()) + .filter(Boolean) + .join(' '); + return haystack.includes(normalizedSearch); + }); + }, [jobsWithMeta, search, viewFilter]); + + useEffect(() => { + setFirst(0); + }, [viewFilter, statusFilter, search]); + + useEffect(() => { + if (filteredRows.length === 0) { + if (first !== 0) { + setFirst(0); + } + return; + } + if (first >= filteredRows.length) { + const pageStart = Math.max(0, Math.floor((filteredRows.length - 1) / rows) * rows); + setFirst(pageStart); + } + }, [filteredRows.length, first, rows]); + + const pagedRows = useMemo( + () => filteredRows.slice(first, first + rows), + [filteredRows, first, rows] + ); + + return ( + +
+
+ {VIEW_FILTERS.map((filter) => { + const isActive = filter.key === viewFilter; + const count = viewCounts[filter.key] ?? 0; + return ( +
+
+ {STATUS_FILTERS.map((filter) => { + const isActive = filter.key === statusFilter; + return ( +
+
+ +
+ setSearch(event.target.value)} + placeholder="Suche nach Titel, Status oder IMDB-ID" + className="job-inbox-search-input" + /> + + Letztes Update: {formatUpdatedAt(lastUpdatedAt)} | Treffer: {filteredRows.length} + +
+ +
+ {loading && filteredRows.length === 0 ? ( +

Inbox wird geladen ...

+ ) : filteredRows.length === 0 ? ( +

Keine Jobs für den aktuellen Filter gefunden.

+ ) : ( + pagedRows.map(({ job, meta }) => { + const jobId = normalizeJobId(job?.id); + if (!jobId) { + return null; + } + const isQueued = queuedJobIds.has(jobId); + const normalizedStatus = normalizeStatus(job?.status); + const targetPath = meta.family === 'converter' ? '/converter' : '/ripper'; + const jobTitle = String(job?.title || job?.detected_title || '').trim() || `Job #${jobId}`; + return ( +
+
+ #{jobId} | {jobTitle} + + Status: {getStatusLabel(normalizedStatus, { queued: isQueued })} + {' | '} + Aktualisiert: {formatUpdatedAt(job?.updated_at || job?.created_at || null)} + +
+
+ + +
+
+
+
+ ); + }) + )} +
+ + {filteredRows.length > 0 ? ( +
+ { + setFirst(event.first); + setRows(event.rows); + }} + template="PrevPageLink PageLinks NextPageLink RowsPerPageDropdown CurrentPageReport" + currentPageReportTemplate="{first} - {last} von {totalRecords}" + /> +
+ ) : null} +
+ ); +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/RipperPage.jsx similarity index 92% rename from frontend/src/pages/DashboardPage.jsx rename to frontend/src/pages/RipperPage.jsx index 95be4a0..49cd01c 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/RipperPage.jsx @@ -6,7 +6,6 @@ import { Button } from 'primereact/button'; import { Tag } from 'primereact/tag'; import { ProgressBar } from 'primereact/progressbar'; import { Dialog } from 'primereact/dialog'; -import { FileUpload } from 'primereact/fileupload'; import { InputNumber } from 'primereact/inputnumber'; import { InputText } from 'primereact/inputtext'; import { api } from '../api/client'; @@ -22,11 +21,12 @@ import otherIndicatorIcon from '../assets/media-other.svg'; import audiobookIndicatorIcon from '../assets/media-audiobook.svg'; import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation'; import { confirmModal } from '../utils/confirmModal'; +import { isConverterJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy'; const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']; const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING']; const activeCdDriveStates = ['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING']; -const dashboardStatuses = new Set([ +const ripperStatuses = new Set([ 'ANALYZING', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', @@ -479,6 +479,11 @@ function getAnalyzeContext(job) { } function resolveMediaType(job) { + const centralMediaType = resolveCentralMediaType(job); + if (centralMediaType && centralMediaType !== 'other') { + return centralMediaType; + } + const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null; const candidates = [ job?.mediaType, @@ -533,7 +538,7 @@ function resolveMediaType(job) { if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') { return 'audiobook'; } - return 'other'; + return centralMediaType || 'other'; } function mediaIndicatorMeta(job) { @@ -906,13 +911,11 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) { }; } -export default function DashboardPage({ +export default function RipperPage({ pipeline, hardwareMonitoring, lastDiscEvent, refreshPipeline, - audiobookUpload, - onAudiobookUpload, jobsRefreshToken, pendingExpandedJobId, onPendingExpandedJobHandled, @@ -957,9 +960,8 @@ export default function DashboardPage({ const [runtimeActionBusyKeys, setRuntimeActionBusyKeys] = useState(() => new Set()); const [runtimeRecentClearing, setRuntimeRecentClearing] = useState(false); const [jobsLoading, setJobsLoading] = useState(false); - const [dashboardJobs, setDashboardJobs] = useState([]); + const [ripperJobs, setRipperJobs] = useState([]); const [expandedJobId, setExpandedJobId] = useState(undefined); - const [audiobookUploadFile, setAudiobookUploadFile] = useState(null); const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null }); const [activationBytesInput, setActivationBytesInput] = useState(''); const [activationBytesBusy, setActivationBytesBusy] = useState(false); @@ -972,8 +974,6 @@ export default function DashboardPage({ const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] }); const [insertWaitSeconds, setInsertWaitSeconds] = useState(30); const toastRef = useRef(null); - const audiobookFileUploadRef = useRef(null); - const [audiobookUploadStatusVisible, setAudiobookUploadStatusVisible] = useState(false); const state = String(pipeline?.state || 'IDLE').trim().toUpperCase(); const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId); @@ -987,7 +987,7 @@ export default function DashboardPage({ [pipeline?.cdDrives] ); // Detects when a background job reaches a terminal interactive state via PIPELINE_PROGRESS - // (e.g. READY_TO_ENCODE or WAITING_FOR_USER_DECISION), triggering a dashboard jobs reload. + // (e.g. READY_TO_ENCODE or WAITING_FOR_USER_DECISION), triggering a ripper jobs reload. const jobProgressInteractiveKey = useMemo(() => Object.entries(pipeline?.jobProgress || {}) .filter(([, v]) => { const s = String(v?.state || '').toUpperCase(); @@ -1038,42 +1038,15 @@ export default function DashboardPage({ }, [storageMetrics]); const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : []; const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : []; - const audiobookUploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase(); - const audiobookUploadBusy = audiobookUploadPhase === 'uploading' || audiobookUploadPhase === 'processing'; - const audiobookUploadProgress = Number.isFinite(Number(audiobookUpload?.progressPercent)) - ? Math.max(0, Math.min(100, Number(audiobookUpload.progressPercent))) - : 0; - const audiobookUploadLoadedBytes = Number(audiobookUpload?.loadedBytes || 0); - const audiobookUploadTotalBytes = Number(audiobookUpload?.totalBytes || 0); - const audiobookUploadFileName = String(audiobookUpload?.fileName || '').trim() - || String(audiobookUploadFile?.name || '').trim() - || null; - const audiobookUploadStatusTone = audiobookUploadPhase === 'error' - ? 'danger' - : audiobookUploadPhase === 'completed' - ? 'success' - : audiobookUploadPhase === 'processing' - ? 'info' - : audiobookUploadPhase === 'uploading' - ? 'warning' - : 'secondary'; - const audiobookUploadStatusLabel = audiobookUploadPhase === 'uploading' - ? 'Upload läuft' - : audiobookUploadPhase === 'processing' - ? 'Server verarbeitet' - : audiobookUploadPhase === 'completed' - ? 'Bereit' - : audiobookUploadPhase === 'error' - ? 'Fehler' - : 'Inaktiv'; - const isSubpageRoute = location.pathname !== '/'; + const isRipperMainRoute = location.pathname === '/' || location.pathname === '/ripper'; + const isSubpageRoute = !isRipperMainRoute; - const loadDashboardJobs = async () => { + const loadRipperJobs = async () => { setJobsLoading(true); try { const [jobsResponse, queueResponse] = await Promise.allSettled([ api.getJobs({ - statuses: Array.from(dashboardStatuses), + statuses: Array.from(ripperStatuses), limit: 160, lite: true }), @@ -1085,24 +1058,12 @@ export default function DashboardPage({ if (queueResponse.status === 'fulfilled') { setQueueState(normalizeQueue(queueResponse.value?.queue)); } - const shouldDisplayOnDashboard = (job) => { - const rawMediaType = String(job?.media_type || '').trim().toLowerCase(); - const resolvedMediaType = String(job?.mediaType || '').trim().toLowerCase(); - const planMediaProfile = String(job?.encodePlan?.mediaProfile || '').trim().toLowerCase(); - const converterPlanType = String(job?.encodePlan?.converterMediaType || '').trim().toLowerCase(); - const isConverterJob = ( - rawMediaType === 'converter' - || resolvedMediaType === 'converter' - || planMediaProfile === 'converter' - || converterPlanType === 'audio' - || converterPlanType === 'video' - || converterPlanType === 'iso' - ); - if (isConverterJob) { + const shouldDisplayOnRipper = (job) => { + if (isConverterJob(job)) { return false; } const normalizedStatus = String(job?.status || '').trim().toUpperCase(); - if (!dashboardStatuses.has(normalizedStatus)) { + if (!ripperStatuses.has(normalizedStatus)) { return false; } if (normalizedStatus !== 'CANCELLED') { @@ -1112,7 +1073,7 @@ export default function DashboardPage({ return !hiddenCancelledReviewOrigins.has(cancelledOrigin); }; const next = allJobs - .filter((job) => shouldDisplayOnDashboard(job)) + .filter((job) => shouldDisplayOnRipper(job)) .sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0)); const pinnedJobIds = new Set(); @@ -1136,7 +1097,7 @@ export default function DashboardPage({ continue; } const job = result.value?.job; - if (job && shouldDisplayOnDashboard(job)) { + if (job && shouldDisplayOnRipper(job)) { next.unshift(job); } } @@ -1153,7 +1114,7 @@ export default function DashboardPage({ deduped.push(job); } - setDashboardJobs(deduped); + setRipperJobs(deduped); // Prüfen ob Audiobook-Jobs auf Activation Bytes warten try { @@ -1174,7 +1135,7 @@ export default function DashboardPage({ // ignorieren } } catch (_error) { - setDashboardJobs([]); + setRipperJobs([]); } finally { setJobsLoading(false); } @@ -1245,7 +1206,7 @@ export default function DashboardPage({ }, [pipeline?.queue]); useEffect(() => { - void loadDashboardJobs(); + void loadRipperJobs(); }, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, cdDriveLifecycleKey, jobsRefreshToken, jobProgressInteractiveKey]); useEffect(() => { @@ -1259,13 +1220,13 @@ export default function DashboardPage({ if (!requestedJobId) { return; } - const hasRequestedJob = dashboardJobs.some((job) => normalizeJobId(job?.id) === requestedJobId); + const hasRequestedJob = ripperJobs.some((job) => normalizeJobId(job?.id) === requestedJobId); if (!hasRequestedJob) { return; } setExpandedJobId(requestedJobId); onPendingExpandedJobHandled?.(requestedJobId); - }, [pendingExpandedJobId, dashboardJobs, onPendingExpandedJobHandled]); + }, [pendingExpandedJobId, ripperJobs, onPendingExpandedJobHandled]); useEffect(() => { let cancelled = false; @@ -1308,7 +1269,7 @@ export default function DashboardPage({ useEffect(() => { const normalizedExpanded = normalizeJobId(expandedJobId); - const hasExpanded = dashboardJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded); + const hasExpanded = ripperJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded); if (hasExpanded) { return; } @@ -1318,30 +1279,16 @@ export default function DashboardPage({ return; } - if (currentPipelineJobId && dashboardJobs.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) { + if (currentPipelineJobId && ripperJobs.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) { setExpandedJobId(currentPipelineJobId); return; } - setExpandedJobId(normalizeJobId(dashboardJobs[0]?.id)); - }, [dashboardJobs, expandedJobId, currentPipelineJobId]); - - useEffect(() => { - if (audiobookUploadPhase === 'idle') { - setAudiobookUploadStatusVisible(false); - return undefined; - } - setAudiobookUploadStatusVisible(true); - if (audiobookUploadPhase === 'completed') { - const timer = setTimeout(() => setAudiobookUploadStatusVisible(false), 5000); - return () => clearTimeout(timer); - } - return undefined; - }, [audiobookUploadPhase]); - + setExpandedJobId(normalizeJobId(ripperJobs[0]?.id)); + }, [ripperJobs, expandedJobId, currentPipelineJobId]); const pipelineByJobId = useMemo(() => { const map = new Map(); - for (const job of dashboardJobs) { + for (const job of ripperJobs) { const id = normalizeJobId(job?.id); if (!id) { continue; @@ -1349,14 +1296,14 @@ export default function DashboardPage({ map.set(id, buildPipelineFromJob(job, pipeline, currentPipelineJobId)); } return map; - }, [dashboardJobs, pipeline, currentPipelineJobId]); + }, [ripperJobs, pipeline, currentPipelineJobId]); const buildMetadataContextForJob = (jobId) => { const normalizedJobId = normalizeJobId(jobId); if (!normalizedJobId) { return null; } - const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null; + const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null; const pipelineForJob = pipelineByJobId.get(normalizedJobId) || null; const context = pipelineForJob?.context && typeof pipelineForJob.context === 'object' ? pipelineForJob.context @@ -1401,7 +1348,7 @@ export default function DashboardPage({ }; } - const pendingJob = dashboardJobs.find((job) => { + const pendingJob = ripperJobs.find((job) => { const normalized = normalizeStatus(job?.status); return normalized === 'METADATA_SELECTION' || normalized === 'WAITING_FOR_USER_DECISION'; }); @@ -1409,7 +1356,7 @@ export default function DashboardPage({ return null; } return buildMetadataContextForJob(pendingJob.id); - }, [pipeline, dashboardJobs, pipelineByJobId]); + }, [pipeline, ripperJobs, pipelineByJobId]); const effectiveMetadataDialogContext = metadataDialogContext || defaultMetadataDialogContext @@ -1452,7 +1399,7 @@ export default function DashboardPage({ try { const response = await api.analyzeDisc(); await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); const analyzedJobId = normalizeJobId(response?.result?.jobId); if (analyzedJobId) { setMetadataDialogContext({ @@ -1486,7 +1433,7 @@ export default function DashboardPage({ try { const response = await api.analyzeDisc(devicePath); await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); const analyzedJobId = normalizeJobId(response?.result?.jobId); if (analyzedJobId) { setMetadataDialogContext({ @@ -1553,7 +1500,7 @@ export default function DashboardPage({ life: 2800 }); await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); } catch (error) { showError(error); } finally { @@ -1595,7 +1542,7 @@ export default function DashboardPage({ const handleCancel = async (jobId = null, jobState = null) => { const cancelledJobId = normalizeJobId(jobId) || currentPipelineJobId; - const cancelledJob = dashboardJobs.find((item) => normalizeJobId(item?.id) === cancelledJobId) || null; + const cancelledJob = ripperJobs.find((item) => normalizeJobId(item?.id) === cancelledJobId) || null; const cancelledState = String( jobState || cancelledJob?.status @@ -1607,7 +1554,7 @@ export default function DashboardPage({ try { await api.cancelPipeline(cancelledJobId); await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); let latestCancelledJob = null; const fetchLatestCancelledJob = async () => { if (!cancelledJobId) { @@ -1701,7 +1648,7 @@ export default function DashboardPage({ life: 4000 }); } - await loadDashboardJobs(); + await loadRipperJobs(); await refreshPipeline(); setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null }); } catch (error) { @@ -1718,7 +1665,7 @@ export default function DashboardPage({ } const startOptions = options && typeof options === 'object' ? options : {}; - const startJobRow = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null; + const startJobRow = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null; const mediaType = resolveMediaType(startJobRow); setJobBusy(normalizedJobId, true); try { @@ -1753,7 +1700,7 @@ export default function DashboardPage({ : await api.startJob(normalizedJobId); const result = getQueueActionResult(response); await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); if (result.queued) { showQueuedToast(toastRef, 'Start', result); } else { @@ -1766,32 +1713,6 @@ export default function DashboardPage({ } }; - const handleAudiobookUpload = async () => { - if (!audiobookUploadFile) { - showError(new Error('Bitte zuerst eine AAX-Datei auswählen.')); - return; - } - try { - const response = await onAudiobookUpload?.(audiobookUploadFile, { startImmediately: false }); - const result = response?.result || {}; - const uploadedJobId = normalizeJobId(result.jobId); - if (result.needsActivationBytes && result.checksum) { - setActivationBytesInput(''); - setActivationBytesDialog({ visible: true, checksum: result.checksum, jobId: uploadedJobId }); - } else if (uploadedJobId) { - toastRef.current?.show({ - severity: 'success', - summary: 'Audiobook importiert', - detail: `Job #${uploadedJobId} wurde angelegt und wird geoeffnet.`, - life: 3200 - }); - } - setAudiobookUploadFile(null); - } catch (error) { - showError(error); - } - }; - const handleSaveActivationBytes = async () => { const { checksum, jobId } = activationBytesDialog; const bytes = activationBytesInput.trim().toLowerCase(); @@ -1806,7 +1727,7 @@ export default function DashboardPage({ detail: jobId ? `Job #${jobId} kann jetzt gestartet werden.` : 'Bytes wurden lokal gespeichert.', life: 4000 }); - await loadDashboardJobs(); + await loadRipperJobs(); } catch (error) { showError(error); } finally { @@ -1833,7 +1754,7 @@ export default function DashboardPage({ const response = await api.startAudiobook(normalizedJobId, audiobookConfig || {}); const result = getQueueActionResult(response); await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); if (result.queued) { showQueuedToast(toastRef, 'Audiobook', result); } else { @@ -1870,7 +1791,7 @@ export default function DashboardPage({ } await api.confirmEncodeReview(jobId, payload); await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); setExpandedJobId(normalizedJobId); } catch (error) { showError(error); @@ -1888,7 +1809,7 @@ export default function DashboardPage({ selectedPlaylist: selectedPlaylist || null }); await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); setExpandedJobId(normalizedJobId); } catch (error) { showError(error); @@ -1906,7 +1827,7 @@ export default function DashboardPage({ selectedHandBrakeTitleId: selectedHandBrakeTitleId || null }); await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); setExpandedJobId(normalizedJobId); } catch (error) { showError(error); @@ -1923,7 +1844,7 @@ export default function DashboardPage({ const result = getQueueActionResult(response); const retryJobId = normalizeJobId(result?.jobId) || normalizedJobId; await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); if (result.queued) { showQueuedToast(toastRef, 'Retry', result); } else { @@ -1936,12 +1857,12 @@ export default function DashboardPage({ } }; - const handleDeleteDashboardJob = async (jobId) => { + const handleDeleteRipperJob = async (jobId) => { const normalizedJobId = normalizeJobId(jobId); if (!normalizedJobId) { return; } - const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null; + const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null; const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim(); const confirmed = await confirmModal({ header: 'Job löschen', @@ -1978,7 +1899,7 @@ export default function DashboardPage({ keepDetectedDevice: false }); await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); setExpandedJobId((prev) => (normalizeJobId(prev) === normalizedJobId ? null : prev)); toastRef.current?.show({ severity: 'success', @@ -1994,7 +1915,7 @@ export default function DashboardPage({ }; const handleRestartEncodeWithLastSettings = async (jobId) => { - const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null; + const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null; const title = job?.title || job?.detected_title || `Job #${jobId}`; if (job?.encodeSuccess) { const confirmed = await confirmModal({ @@ -2017,7 +1938,7 @@ export default function DashboardPage({ const result = getQueueActionResult(response); const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId; await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); if (result.queued) { showQueuedToast(toastRef, 'Encode-Neustart', result); } else { @@ -2042,7 +1963,7 @@ export default function DashboardPage({ const result = getQueueActionResult(response); const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId; await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); setExpandedJobId(replacementJobId); } catch (error) { showError(error); @@ -2063,7 +1984,7 @@ export default function DashboardPage({ const result = getQueueActionResult(response); const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId; await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); if (result.queued) { showQueuedToast(toastRef, 'CD-Vorprüfung', result); } else { @@ -2213,7 +2134,7 @@ export default function DashboardPage({ await api.selectMetadata(payload); } await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); setMetadataDialogVisible(false); setMetadataDialogContext(null); setMetadataDialogReassignMode(false); @@ -2294,7 +2215,7 @@ export default function DashboardPage({ try { await api.selectCdMetadata(payload); await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); setCdMetadataDialogVisible(false); setCdMetadataDialogContext(null); } catch (error) { @@ -2320,7 +2241,7 @@ export default function DashboardPage({ } const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId; await refreshPipeline(); - await loadDashboardJobs(); + await loadRipperJobs(); if (replacementJobId) { setExpandedJobId(replacementJobId); } @@ -2485,7 +2406,7 @@ export default function DashboardPage({ try { const response = normalizedAction === 'next-step' ? await api.requestRuntimeNextStep(activityId) - : await api.cancelRuntimeActivity(activityId, { reason: 'Benutzerabbruch via Dashboard' }); + : await api.cancelRuntimeActivity(activityId, { reason: 'Benutzerabbruch via Ripper' }); if (response?.snapshot) { setRuntimeActivities(normalizeRuntimeActivitiesPayload(response.snapshot)); } else { @@ -2564,77 +2485,8 @@ export default function DashboardPage({
-
-
- - void handleAudiobookUpload()} - disabled={audiobookUploadBusy} - onSelect={(e) => setAudiobookUploadFile(e.files[0] || null)} - onClear={() => setAudiobookUploadFile(null)} - onRemove={() => setAudiobookUploadFile(null)} - chooseOptions={{ icon: 'pi pi-images', iconOnly: true, className: 'p-button-rounded p-button-outlined' }} - uploadOptions={{ icon: 'pi pi-cloud-upload', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-success' }} - cancelOptions={{ icon: 'pi pi-times', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-danger' }} - itemTemplate={(file, options) => ( -
- -
- {file.name} - {options.formatSize} -
-
- )} - emptyTemplate={() => ( -
- -

AAX-Datei hier ablegen

- oder oben „Auswählen" klicken -
- )} - /> - {audiobookUploadStatusVisible ? ( -
-
- {audiobookUploadStatusLabel} - -
- {audiobookUpload?.statusText ? {audiobookUpload.statusText} : null} - {audiobookUploadFileName ? ( - - Datei: {audiobookUploadFileName} - - ) : null} -
- - - {audiobookUploadPhase === 'processing' - ? '100% | Upload fertig, Job wird vorbereitet ...' - : audiobookUploadTotalBytes > 0 - ? `${Math.round(audiobookUploadProgress)}% | ${formatBytes(audiobookUploadLoadedBytes)} / ${formatBytes(audiobookUploadTotalBytes)}` - : `${Math.round(audiobookUploadProgress)}%`} - -
-
- ) : null} -
- +
+
{/* Per-drive list */} {allDrives.length > 0 ? ( @@ -2888,20 +2740,20 @@ export default function DashboardPage({
-
+
{isSubpageRoute ? ( -
+
{children}
) : ( - {jobsLoading && dashboardJobs.length === 0 ? ( + {jobsLoading && ripperJobs.length === 0 ? (

Jobs werden geladen ...

- ) : dashboardJobs.length === 0 ? ( -

Keine relevanten Jobs im Dashboard (aktive/fortsetzbare Status).

+ ) : ripperJobs.length === 0 ? ( +

Keine relevanten Jobs im Ripper (aktive/fortsetzbare Status).

) : ( -
- {dashboardJobs.map((job) => { +
+ {ripperJobs.map((job) => { const jobId = normalizeJobId(job?.id); if (!jobId) { return null; @@ -2945,15 +2797,15 @@ export default function DashboardPage({ const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0; if (isExpanded) { return ( -
-
+
+
{(job?.poster_url && job.poster_url !== 'N/A') ? ( {jobTitle} ) : ( -
{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}
+
{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}
)} -
- +
+ {mediaIndicator.alt} #{jobId} | {jobTitle} -
+
{isCurrentSession ? : null} {isResumable ? : null} @@ -2992,7 +2844,7 @@ export default function DashboardPage({ onCancel={() => handleCancel(jobId, jobState)} onRetry={() => handleRetry(jobId)} onRestartReview={() => handleRestartCdReviewFromRaw(jobId)} - onDeleteJob={() => handleDeleteDashboardJob(jobId)} + onDeleteJob={() => handleDeleteRipperJob(jobId)} onOpenMetadata={() => { const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object' ? pipelineForJob.context @@ -3024,7 +2876,7 @@ export default function DashboardPage({ onStart={(config) => handleAudiobookStart(jobId, config)} onCancel={() => handleCancel(jobId, jobState)} onRetry={() => handleRetry(jobId)} - onDeleteJob={() => handleDeleteDashboardJob(jobId)} + onDeleteJob={() => handleDeleteRipperJob(jobId)} busy={busyJobIds.has(jobId) || needsBytes} /> @@ -3048,7 +2900,7 @@ export default function DashboardPage({ onSelectHandBrakeTitle={handleSelectHandBrakeTitle} onCancel={handleCancel} onRetry={handleRetry} - onDeleteJob={handleDeleteDashboardJob} + onDeleteJob={handleDeleteRipperJob} onRemoveFromQueue={handleRemoveQueuedJob} isQueued={isQueued} busy={busyJobIds.has(jobId)} @@ -3062,17 +2914,17 @@ export default function DashboardPage({