0.12.0-19 Final Build
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled

This commit is contained in:
2026-04-07 19:12:40 +00:00
parent ccad7ec9f4
commit 43702b0138
39 changed files with 2829 additions and 460 deletions
+3 -3
View File
@@ -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) - Pre- und Post-Encode-Ausführungen (Skripte und/oder Skript-Ketten)
- Pipeline-Queue mit Job- und Nicht-Job-Einträgen (`script`, `chain`, `wait`) - Pipeline-Queue mit Job- und Nicht-Job-Einträgen (`script`, `chain`, `wait`)
- Cron-Jobs für Skripte/Ketten (inkl. Logs und manueller Auslösung) - 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 - Download-Queue: Ausgabedateien als ZIP herunterladen
- Historie mit Re-Encode, Review-Neustart, File-/Job-Löschung und Orphan-Import - 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 ## Tech-Stack
@@ -147,7 +147,7 @@ ripster/
utils/ utils/
frontend/ frontend/
src/ src/
pages/ # Dashboard, Settings, History, Converter, Downloads, Database pages/ # Ripper, Settings, History, Converter, Downloads, Database
components/ components/
api/ api/
db/schema.sql db/schema.sql
+3 -5
View File
@@ -7,11 +7,11 @@ const { spawnTrackedProcess } = require('../services/processRunner');
const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers'); const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers');
const { ensureDir, sanitizeFileName, renderTemplate } = require('../utils/files'); const { ensureDir, sanitizeFileName, renderTemplate } = require('../utils/files');
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov', 'm4v', 'wmv', 'ts']); const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']);
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']); const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
const ISO_EXTENSIONS = new Set(['iso']); 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']); const VIDEO_OUTPUT_FORMATS = new Set(['mkv', 'mp4', 'm4v']);
/** /**
@@ -62,8 +62,6 @@ function buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, format, opts = {
} else { } else {
args.push('-b:a', `${Number(opts.mp3Bitrate ?? 192)}k`); 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') { } else if (format === 'opus') {
args.push('-codec:a', 'libopus', '-b:a', `${Number(opts.opusBitrate ?? 160)}k`); args.push('-codec:a', 'libopus', '-b:a', `${Number(opts.opusBitrate ?? 160)}k`);
} else if (format === 'ogg') { } else if (format === 'ogg') {
+6 -2
View File
@@ -90,8 +90,12 @@ router.post(
for (const entry of entries) { for (const entry of entries) {
const relPath = String(entry?.relPath || '').trim(); const relPath = String(entry?.relPath || '').trim();
if (!relPath) continue; if (!relPath) continue;
const job = await pipelineService.createConverterJobFromEntry(relPath, { const job = await pipelineService.createFileJob({
converterMediaType: entry?.converterMediaType || null kind: 'converter_entry',
relPath,
options: {
converterMediaType: entry?.converterMediaType || null
}
}); });
jobs.push(job); jobs.push(job);
} }
+121 -3
View File
@@ -1,4 +1,5 @@
const express = require('express'); const express = require('express');
const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const multer = require('multer'); const multer = require('multer');
@@ -7,8 +8,10 @@ const pipelineService = require('../services/pipelineService');
const historyService = require('../services/historyService'); const historyService = require('../services/historyService');
const diskDetectionService = require('../services/diskDetectionService'); const diskDetectionService = require('../services/diskDetectionService');
const hardwareMonitorService = require('../services/hardwareMonitorService'); const hardwareMonitorService = require('../services/hardwareMonitorService');
const settingsService = require('../services/settingsService');
const logger = require('../services/logger').child('PIPELINE_ROUTE'); const logger = require('../services/logger').child('PIPELINE_ROUTE');
const activationBytesService = require('../services/activationBytesService'); const activationBytesService = require('../services/activationBytesService');
const { defaultAudiobookDir } = require('../config');
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const router = express.Router(); const router = express.Router();
@@ -16,6 +19,78 @@ const audiobookUpload = multer({
dest: path.join(os.tmpdir(), 'ripster-audiobook-uploads') 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( router.get(
'/state', '/state',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
@@ -175,9 +250,13 @@ router.post(
mimeType: String(req.file.mimetype || '').trim() || null, mimeType: String(req.file.mimetype || '').trim() || null,
tempPath: String(req.file.path || '').trim() || null tempPath: String(req.file.path || '').trim() || null
}); });
const result = await pipelineService.uploadAudiobookFile(req.file, { const result = await pipelineService.createFileJob({
format: req.body?.format, kind: 'audiobook_upload',
startImmediately: req.body?.startImmediately file: req.file,
options: {
format: req.body?.format,
startImmediately: req.body?.startImmediately
}
}); });
res.json({ result }); 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( router.post(
'/select-metadata', '/select-metadata',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
+12 -8
View File
@@ -10,9 +10,10 @@ const {
defaultConverterRawDir defaultConverterRawDir
} = require('../config'); } = require('../config');
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov', 'm4v', 'wmv', 'ts']); const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']);
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']); const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
const ISO_EXTENSIONS = new Set(['iso']); const ISO_EXTENSIONS = new Set(['iso']);
const SUPPORTED_SCAN_EXTENSIONS = new Set([...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS, ...ISO_EXTENSIONS]);
let _pollingTimer = null; let _pollingTimer = null;
let _pollingEnabled = false; let _pollingEnabled = false;
@@ -33,13 +34,16 @@ function detectFormat(fileName) {
function getConfiguredExtensions(settings) { function getConfiguredExtensions(settings) {
const raw = String(settings?.converter_scan_extensions || '').trim(); const raw = String(settings?.converter_scan_extensions || '').trim();
if (!raw) { if (!raw) {
return new Set([...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS, ...ISO_EXTENSIONS]); return new Set(SUPPORTED_SCAN_EXTENSIONS);
} }
return new Set( const configured = raw.split(',')
raw.split(',') .map((ext) => ext.trim().toLowerCase())
.map((ext) => ext.trim().toLowerCase()) .filter(Boolean);
.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) { function getFileSize(fullPath) {
+71 -5
View File
@@ -306,6 +306,35 @@ function normalizeMediaTypeValue(value) {
return null; 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) { function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeInfo = null) {
const mkInfo = parseInfoFromValue(makemkvInfo, null); const mkInfo = parseInfoFromValue(makemkvInfo, null);
const miInfo = parseInfoFromValue(mediainfoInfo, null); const miInfo = parseInfoFromValue(mediainfoInfo, null);
@@ -326,8 +355,21 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
return profileHint; 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') // Converter jobs: detected via media_type field (plan.mediaProfile = 'converter')
const rawMediaType = normalizeMediaTypeValue(job?.media_type); const rawMediaType = normalizeMediaTypeValue(job?.media_type);
const rawMediaTypeLegacy = String(job?.media_type || '').trim().toLowerCase();
const rawPlanProfile = String(plan?.mediaProfile || '').trim().toLowerCase(); const rawPlanProfile = String(plan?.mediaProfile || '').trim().toLowerCase();
const converterMediaTypeHint = String(plan?.converterMediaType || '').trim().toLowerCase(); const converterMediaTypeHint = String(plan?.converterMediaType || '').trim().toLowerCase();
const hasConverterPathHint = (value) => { const hasConverterPathHint = (value) => {
@@ -343,6 +385,7 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
if ( if (
rawPlanProfile === 'converter' rawPlanProfile === 'converter'
|| rawMediaType === 'converter' || rawMediaType === 'converter'
|| rawMediaTypeLegacy === 'converter'
|| converterMediaTypeHint === 'video' || converterMediaTypeHint === 'video'
|| converterMediaTypeHint === 'audio' || converterMediaTypeHint === 'audio'
|| converterMediaTypeHint === 'iso' || converterMediaTypeHint === 'iso'
@@ -1897,22 +1940,39 @@ function isFilesystemRootPath(inputPath) {
} }
class HistoryService { 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 db = await getDb();
const startTime = new Date().toISOString(); 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( const result = await db.run(
` `
INSERT INTO jobs (disc_device, status, start_time, detected_title, last_state, created_at, updated_at) 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) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`, `,
[discDevice, status, startTime, detectedTitle, status] [discDevice, status, startTime, detectedTitle, status, normalizedMediaType, normalizedJobKind]
); );
logger.info('job:created', { logger.info('job:created', {
jobId: result.lastID, jobId: result.lastID,
discDevice, discDevice,
status, status,
detectedTitle detectedTitle,
mediaType: normalizedMediaType,
jobKind: normalizedJobKind
}); });
return this.getJobById(result.lastID); return this.getJobById(result.lastID);
@@ -2635,6 +2695,7 @@ class HistoryService {
const rowMediaProfile = normalizeMediaTypeValue( const rowMediaProfile = normalizeMediaTypeValue(
makemkvInfo?.mediaProfile makemkvInfo?.mediaProfile
|| makemkvInfo?.analyzeContext?.mediaProfile || makemkvInfo?.analyzeContext?.mediaProfile
|| inferMediaTypeFromJobKind(row?.job_kind)
|| row?.media_type || row?.media_type
); );
const posterCandidate = String( const posterCandidate = String(
@@ -2764,6 +2825,7 @@ class HistoryService {
const mediaProfile = normalizeMediaTypeValue( const mediaProfile = normalizeMediaTypeValue(
makemkvInfo?.mediaProfile makemkvInfo?.mediaProfile
|| makemkvInfo?.analyzeContext?.mediaProfile || makemkvInfo?.analyzeContext?.mediaProfile
|| inferMediaTypeFromJobKind(job?.job_kind)
|| job?.media_type || job?.media_type
); );
if (String(makemkvInfo?.source || '').trim().toLowerCase() !== 'orphan_raw_import' || mediaProfile !== 'cd') { if (String(makemkvInfo?.source || '').trim().toLowerCase() !== 'orphan_raw_import' || mediaProfile !== 'cd') {
@@ -3616,6 +3678,10 @@ class HistoryService {
|| '' || ''
).trim() || null; ).trim() || null;
await this.updateJob(created.id, { 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', status: 'FINISHED',
last_state: 'FINISHED', last_state: 'FINISHED',
title: omdbById?.title title: omdbById?.title
+379 -28
View File
@@ -85,6 +85,11 @@ const FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD = 0.35;
const FORCED_SUBTITLE_MAX_EVENT_COUNT = 220; const FORCED_SUBTITLE_MAX_EVENT_COUNT = 220;
const FORCED_SUBTITLE_MIN_EVENT_GAP = 12; const FORCED_SUBTITLE_MIN_EVENT_GAP = 12;
const FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES = 64 * 1024; 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() { function nowIso() {
return new Date().toISOString(); return new Date().toISOString();
@@ -108,6 +113,50 @@ function normalizePositiveInteger(value) {
return Math.trunc(parsed); 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 = []) { function normalizeCdTrackPositionList(values = []) {
const source = Array.isArray(values) ? values : []; const source = Array.isArray(values) ? values : [];
const seen = new Set(); const seen = new Set();
@@ -356,6 +405,84 @@ function normalizeMediaProfile(value) {
return null; 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) { function isSpecificMediaProfile(value) {
return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook' || value === 'converter'; return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook' || value === 'converter';
} }
@@ -5580,6 +5707,7 @@ class PipelineService extends EventEmitter {
FROM jobs FROM jobs
WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> ''
AND (media_type IS NULL OR media_type <> 'converter') 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) { if (!Array.isArray(rows) || rows.length === 0) {
return; 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 const hasContextKeys = this.snapshot.context
&& typeof this.snapshot.context === 'object' && typeof this.snapshot.context === 'object'
&& Object.keys(this.snapshot.context).length > 0; && Object.keys(this.snapshot.context).length > 0;
@@ -5946,7 +6074,7 @@ class PipelineService extends EventEmitter {
this.cdDrives.set(devicePath, next); this.cdDrives.set(devicePath, next);
// If a drive is detached from a job (or reassigned), clear stale live // 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 previousJobId = Number(existing?.jobId || 0);
const nextJobId = Number(next?.jobId || 0); const nextJobId = Number(next?.jobId || 0);
if (Number.isFinite(previousJobId) && previousJobId > 0) { if (Number.isFinite(previousJobId) && previousJobId > 0) {
@@ -8008,6 +8136,14 @@ class PipelineService extends EventEmitter {
const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object' const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object'
? options.encodePlan ? options.encodePlan
: null; : null;
const profileFromJobKind = inferMediaProfileFromJobKind(
options?.jobKind
|| job?.job_kind
|| encodePlan?.jobKind
);
if (profileFromJobKind) {
return profileFromJobKind;
}
const profileFromPlan = pickSpecificProfile(encodePlan?.mediaProfile); const profileFromPlan = pickSpecificProfile(encodePlan?.mediaProfile);
if (profileFromPlan) { if (profileFromPlan) {
return profileFromPlan; return profileFromPlan;
@@ -8067,6 +8203,28 @@ class PipelineService extends EventEmitter {
return 'other'; 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 = {}) { async getEffectiveSettingsForJob(job, options = {}) {
const mediaProfile = this.resolveMediaProfileForJob(job, options); const mediaProfile = this.resolveMediaProfileForJob(job, options);
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
@@ -8401,7 +8559,8 @@ class PipelineService extends EventEmitter {
const job = await historyService.createJob({ const job = await historyService.createJob({
discDevice: device.path, discDevice: device.path,
status: 'METADATA_SELECTION', status: 'METADATA_SELECTION',
detectedTitle detectedTitle,
jobKind: resolveJobKindForMediaProfile(mediaProfile)
}); });
try { try {
@@ -10971,6 +11130,7 @@ class PipelineService extends EventEmitter {
const refreshedPlan = { const refreshedPlan = {
...(sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {}), ...(sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {}),
mediaProfile: 'audiobook', mediaProfile: 'audiobook',
jobKind: 'audiobook',
mode: 'audiobook', mode: 'audiobook',
encodeInputPath: rawInput.path encodeInputPath: rawInput.path
}; };
@@ -10996,6 +11156,8 @@ class PipelineService extends EventEmitter {
await historyService.resetProcessLog(sourceJobId); await historyService.resetProcessLog(sourceJobId);
await historyService.updateJob(sourceJobId, { await historyService.updateJob(sourceJobId, {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'READY_TO_START', status: 'READY_TO_START',
last_state: 'READY_TO_START', last_state: 'READY_TO_START',
start_time: null, start_time: null,
@@ -13832,7 +13994,10 @@ class PipelineService extends EventEmitter {
const retryJob = await historyService.createJob({ const retryJob = await historyService.createJob({
discDevice: sourceJob.disc_device || null, discDevice: sourceJob.disc_device || null,
status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'), 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); const retryJobId = Number(retryJob?.id || 0);
if (!Number.isFinite(retryJobId) || retryJobId <= 0) { if (!Number.isFinite(retryJobId) || retryJobId <= 0) {
@@ -14044,7 +14209,7 @@ class PipelineService extends EventEmitter {
await historyService.appendLog( await historyService.appendLog(
jobId, jobId,
'USER_ACTION', 'USER_ACTION',
'READY_TO_ENCODE Job nach Neustart ins Dashboard geladen.' 'READY_TO_ENCODE Job nach Neustart in den Ripper geladen.'
); );
if ( if (
@@ -14232,7 +14397,10 @@ class PipelineService extends EventEmitter {
const replacementJob = await historyService.createJob({ const replacementJob = await historyService.createJob({
discDevice: job.disc_device || null, discDevice: job.disc_device || null,
status: 'READY_TO_ENCODE', 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); const replacementJobId = Number(replacementJob?.id || 0);
if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) {
@@ -14562,7 +14730,10 @@ class PipelineService extends EventEmitter {
const replacementJob = await historyService.createJob({ const replacementJob = await historyService.createJob({
discDevice: sourceJob.disc_device || null, discDevice: sourceJob.disc_device || null,
status: 'MEDIAINFO_CHECK', 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); const replacementJobId = Number(replacementJob?.id || 0);
if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) {
@@ -14817,7 +14988,7 @@ class PipelineService extends EventEmitter {
const cancelMessage = 'Vom Benutzer abgebrochen.'; const cancelMessage = 'Vom Benutzer abgebrochen.';
await historyService.updateJob(normalizedJobId, { await historyService.updateJob(normalizedJobId, {
status: 'CANCELLED', 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, last_state: runningStatus,
end_time: nowIso(), end_time: nowIso(),
error_message: cancelMessage error_message: cancelMessage
@@ -15378,7 +15549,7 @@ class PipelineService extends EventEmitter {
} }
} else { } else {
// No drive mapping available (e.g. orphan/skipRip import path). // 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)); this.jobProgress.delete(Number(jobId));
} }
} else { } else {
@@ -15451,7 +15622,9 @@ class PipelineService extends EventEmitter {
const job = await historyService.createJob({ const job = await historyService.createJob({
discDevice: null, discDevice: null,
status: 'ANALYZING', status: 'ANALYZING',
detectedTitle detectedTitle,
mediaType: 'audiobook',
jobKind: 'audiobook'
}); });
let stagedRawDir = null; let stagedRawDir = null;
@@ -15641,6 +15814,7 @@ class PipelineService extends EventEmitter {
const encodePlan = { const encodePlan = {
mediaProfile: 'audiobook', mediaProfile: 'audiobook',
jobKind: 'audiobook',
mode: 'audiobook', mode: 'audiobook',
sourceType: 'upload', sourceType: 'upload',
uploadedAt: nowIso(), uploadedAt: nowIso(),
@@ -15654,6 +15828,8 @@ class PipelineService extends EventEmitter {
}; };
await historyService.updateJob(job.id, { await historyService.updateJob(job.id, {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'READY_TO_START', status: 'READY_TO_START',
last_state: 'READY_TO_START', last_state: 'READY_TO_START',
title: resolvedMetadata.title || detectedTitle, title: resolvedMetadata.title || detectedTitle,
@@ -15704,6 +15880,8 @@ class PipelineService extends EventEmitter {
error: errorToMeta(error) error: errorToMeta(error)
}); });
const updatePayload = { const updatePayload = {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'ERROR', status: 'ERROR',
last_state: 'ERROR', last_state: 'ERROR',
end_time: nowIso(), end_time: nowIso(),
@@ -15796,6 +15974,7 @@ class PipelineService extends EventEmitter {
const nextEncodePlan = { const nextEncodePlan = {
...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}), ...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}),
mediaProfile: 'audiobook', mediaProfile: 'audiobook',
jobKind: 'audiobook',
mode: 'audiobook', mode: 'audiobook',
format, format,
formatOptions, formatOptions,
@@ -15804,6 +15983,8 @@ class PipelineService extends EventEmitter {
}; };
await historyService.updateJob(normalizedJobId, { await historyService.updateJob(normalizedJobId, {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'READY_TO_START', status: 'READY_TO_START',
last_state: 'READY_TO_START', last_state: 'READY_TO_START',
title: resolvedMetadata.title || job.title || job.detected_title || 'Audiobook', title: resolvedMetadata.title || job.title || job.detected_title || 'Audiobook',
@@ -15957,6 +16138,8 @@ class PipelineService extends EventEmitter {
}); });
await historyService.updateJob(jobId, { await historyService.updateJob(jobId, {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'ENCODING', status: 'ENCODING',
last_state: 'ENCODING', last_state: 'ENCODING',
start_time: nowIso(), start_time: nowIso(),
@@ -16602,7 +16785,8 @@ class PipelineService extends EventEmitter {
const job = await historyService.createJob({ const job = await historyService.createJob({
discDevice: devicePath, discDevice: devicePath,
status: 'CD_METADATA_SELECTION', status: 'CD_METADATA_SELECTION',
detectedTitle detectedTitle,
jobKind: 'cd'
}); });
try { try {
@@ -17059,7 +17243,10 @@ class PipelineService extends EventEmitter {
const replacementJob = await historyService.createJob({ const replacementJob = await historyService.createJob({
discDevice: sourceJob.disc_device || null, discDevice: sourceJob.disc_device || null,
status: 'CD_READY_TO_RIP', 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); const replacementJobId = Number(replacementJob?.id || 0);
if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) { if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) {
@@ -18044,6 +18231,47 @@ class PipelineService extends EventEmitter {
// CONVERTER // 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<object>}
*/
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). * 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 isVideoLikeConverterJob = converterMediaType === 'video' || converterMediaType === 'iso';
const initialStatus = 'READY_TO_START'; 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({ const job = await historyService.createJob({
discDevice: null, discDevice: null,
status: initialStatus, status: initialStatus,
detectedTitle detectedTitle,
mediaType: 'converter',
jobKind: resolveConverterJobKind(normalizedConverterMediaType)
}); });
await historyService.updateJob(job.id, { await historyService.updateJob(job.id, {
media_type: 'converter', media_type: 'converter',
job_kind: resolveConverterJobKind(normalizedConverterMediaType),
raw_path: fullPath, raw_path: fullPath,
encode_plan_json: JSON.stringify({ encode_plan_json: JSON.stringify({
mediaProfile: 'converter', mediaProfile: 'converter',
converterMediaType, jobKind: resolveConverterJobKind(normalizedConverterMediaType),
converterMediaType: normalizedConverterMediaType,
inputPath: fullPath, inputPath: fullPath,
isFolder: isDirectory, isFolder: isDirectory,
audioFiles: isDirectory && converterMediaType === 'audio' audioFiles: isDirectory && normalizedConverterMediaType === 'audio'
? require('./converterScanService').detectFormat ? require('./converterScanService').detectFormat
? null // wird beim Start gefüllt ? null // wird beim Start gefüllt
: null : null
@@ -18129,7 +18366,7 @@ class PipelineService extends EventEmitter {
await historyService.appendLog( await historyService.appendLog(
job.id, job.id,
'SYSTEM', 'SYSTEM',
`Converter-Job erstellt aus: ${relPath} | Typ: ${converterMediaType || '-'}` `Converter-Job erstellt aus: ${relPath} | Typ: ${normalizedConverterMediaType || '-'}`
); );
if (isVideoLikeConverterJob) { if (isVideoLikeConverterJob) {
await historyService.appendLog( await historyService.appendLog(
@@ -18140,7 +18377,7 @@ class PipelineService extends EventEmitter {
} }
logger.info('converter:job:created-from-entry', { 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); return historyService.getJobById(job.id);
@@ -18172,6 +18409,30 @@ class PipelineService extends EventEmitter {
throw error; 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; const folderName = options?.folderName ? String(options.folderName).trim() : null;
if (folderName) { if (folderName) {
@@ -18246,7 +18507,7 @@ class PipelineService extends EventEmitter {
* @returns {Promise<object[]>} Erstellte Jobs * @returns {Promise<object[]>} Erstellte Jobs
*/ */
async createConverterJobsFromSelection(relPaths, audioMode = 'individual') { 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 converterScanService = require('./converterScanService');
const rawDir = await converterScanService.getRawDir(); const rawDir = await converterScanService.getRawDir();
@@ -18272,7 +18533,11 @@ class PipelineService extends EventEmitter {
// Nicht-Audio (Video, Ordner, sonstige): immer ein Job pro Eintrag // Nicht-Audio (Video, Ordner, sonstige): immer ein Job pro Eintrag
for (const relPath of nonAudioRelPaths) { for (const relPath of nonAudioRelPaths) {
const job = await this.createConverterJobFromEntry(relPath, {}); const job = await this.createFileJob({
kind: 'converter_entry',
relPath,
options: {}
});
createdJobs.push(job); createdJobs.push(job);
} }
@@ -18286,14 +18551,18 @@ class PipelineService extends EventEmitter {
const job = await historyService.createJob({ const job = await historyService.createJob({
discDevice: null, discDevice: null,
status: 'READY_TO_START', status: 'READY_TO_START',
detectedTitle detectedTitle,
mediaType: 'converter',
jobKind: 'converter_audio'
}); });
await historyService.updateJob(job.id, { await historyService.updateJob(job.id, {
media_type: 'converter', media_type: 'converter',
job_kind: 'converter_audio',
raw_path: rawPath, raw_path: rawPath,
encode_plan_json: JSON.stringify({ encode_plan_json: JSON.stringify({
mediaProfile: 'converter', mediaProfile: 'converter',
jobKind: 'converter_audio',
converterMediaType: 'audio', converterMediaType: 'audio',
inputPath: rawPath, inputPath: rawPath,
inputPaths: fullPaths, inputPaths: fullPaths,
@@ -18318,7 +18587,11 @@ class PipelineService extends EventEmitter {
} else { } else {
// Einzelne Jobs für jede Audio-Datei // Einzelne Jobs für jede Audio-Datei
for (const relPath of audioRelPaths) { 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); createdJobs.push(job);
} }
} }
@@ -18367,7 +18640,7 @@ class PipelineService extends EventEmitter {
error.statusCode = 404; error.statusCode = 404;
throw error; 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.`); const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
error.statusCode = 409; error.statusCode = 409;
throw error; throw error;
@@ -18481,6 +18754,7 @@ class PipelineService extends EventEmitter {
const nextPlan = { const nextPlan = {
...existingPlan, ...existingPlan,
mediaProfile: 'converter', mediaProfile: 'converter',
jobKind: 'converter_audio',
converterMediaType: 'audio', converterMediaType: 'audio',
isFolder: false, isFolder: false,
isSharedAudio, isSharedAudio,
@@ -18494,6 +18768,7 @@ class PipelineService extends EventEmitter {
status: 'READY_TO_START', status: 'READY_TO_START',
last_state: 'READY_TO_START', last_state: 'READY_TO_START',
media_type: 'converter', media_type: 'converter',
job_kind: 'converter_audio',
raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0], raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
encode_plan_json: JSON.stringify(nextPlan), encode_plan_json: JSON.stringify(nextPlan),
encode_review_confirmed: 0, encode_review_confirmed: 0,
@@ -18551,7 +18826,7 @@ class PipelineService extends EventEmitter {
error.statusCode = 404; error.statusCode = 404;
throw error; 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.`); const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
error.statusCode = 409; error.statusCode = 409;
throw error; throw error;
@@ -18621,6 +18896,7 @@ class PipelineService extends EventEmitter {
const nextPlan = { const nextPlan = {
...existingPlan, ...existingPlan,
mediaProfile: 'converter', mediaProfile: 'converter',
jobKind: 'converter_audio',
converterMediaType: 'audio', converterMediaType: 'audio',
isFolder: false, isFolder: false,
isSharedAudio, isSharedAudio,
@@ -18634,6 +18910,7 @@ class PipelineService extends EventEmitter {
status: 'READY_TO_START', status: 'READY_TO_START',
last_state: 'READY_TO_START', last_state: 'READY_TO_START',
media_type: 'converter', media_type: 'converter',
job_kind: 'converter_audio',
raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0], raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
encode_plan_json: JSON.stringify(nextPlan), encode_plan_json: JSON.stringify(nextPlan),
encode_review_confirmed: 0, encode_review_confirmed: 0,
@@ -18718,6 +18995,7 @@ class PipelineService extends EventEmitter {
const nextPlan = { const nextPlan = {
...existingPlan, ...existingPlan,
mediaProfile: 'converter', mediaProfile: 'converter',
jobKind: 'converter_audio',
converterMediaType: 'audio', converterMediaType: 'audio',
isFolder: false, isFolder: false,
isSharedAudio, isSharedAudio,
@@ -18731,6 +19009,7 @@ class PipelineService extends EventEmitter {
status: 'READY_TO_START', status: 'READY_TO_START',
last_state: 'READY_TO_START', last_state: 'READY_TO_START',
media_type: 'converter', media_type: 'converter',
job_kind: 'converter_audio',
raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0], raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
encode_plan_json: JSON.stringify(nextPlan), encode_plan_json: JSON.stringify(nextPlan),
encode_review_confirmed: 0, encode_review_confirmed: 0,
@@ -18772,7 +19051,7 @@ class PipelineService extends EventEmitter {
error.statusCode = 404; error.statusCode = 404;
throw error; 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.`); const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
error.statusCode = 409; error.statusCode = 409;
throw error; throw error;
@@ -18804,8 +19083,16 @@ class PipelineService extends EventEmitter {
: 'video'; : 'video';
const defaultFormat = converterMediaType === 'audio' ? 'flac' : 'mkv'; 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; ?.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; let userPreset = existingPlan.userPreset || null;
if (Object.prototype.hasOwnProperty.call(config, 'userPreset')) { if (Object.prototype.hasOwnProperty.call(config, 'userPreset')) {
@@ -18905,6 +19192,7 @@ class PipelineService extends EventEmitter {
const nextPlan = { const nextPlan = {
...existingPlan, ...existingPlan,
mediaProfile: 'converter', mediaProfile: 'converter',
jobKind: resolveConverterJobKind(converterMediaType),
converterMediaType, converterMediaType,
outputFormat, outputFormat,
userPreset, userPreset,
@@ -18919,6 +19207,8 @@ class PipelineService extends EventEmitter {
const newCoverUrl = nextMetadata?.coverUrl || null; const newCoverUrl = nextMetadata?.coverUrl || null;
const jobPatch = { const jobPatch = {
media_type: 'converter',
job_kind: resolveConverterJobKind(converterMediaType),
encode_plan_json: JSON.stringify(nextPlan), encode_plan_json: JSON.stringify(nextPlan),
encode_review_confirmed: 0, encode_review_confirmed: 0,
error_message: null error_message: null
@@ -19000,7 +19290,16 @@ class PipelineService extends EventEmitter {
let outputPath = null; let outputPath = null;
let outputDir = null; let outputDir = null;
let audioTrackTemplate = 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( const baseName = sanitizeFileName(
path.basename(inputPath, path.extname(inputPath)) path.basename(inputPath, path.extname(inputPath))
) || 'output'; ) || 'output';
@@ -19115,7 +19414,7 @@ class PipelineService extends EventEmitter {
let audioFiles = existingPlan.audioFiles; let audioFiles = existingPlan.audioFiles;
if (converterMediaType === 'audio' && existingPlan.isFolder && !audioFiles) { if (converterMediaType === 'audio' && existingPlan.isFolder && !audioFiles) {
const { readdirSync } = fs; 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 { try {
audioFiles = readdirSync(inputPath) audioFiles = readdirSync(inputPath)
.filter((f) => AUDIO_EXTS.has(path.extname(f).slice(1).toLowerCase())) .filter((f) => AUDIO_EXTS.has(path.extname(f).slice(1).toLowerCase()))
@@ -19147,6 +19446,7 @@ class PipelineService extends EventEmitter {
const nextEncodePlan = { const nextEncodePlan = {
...existingPlan, ...existingPlan,
mediaProfile: 'converter', mediaProfile: 'converter',
jobKind: resolveConverterJobKind(converterMediaType),
converterMediaType, converterMediaType,
inputPath, inputPath,
inputPaths: existingPlan.inputPaths || null, inputPaths: existingPlan.inputPaths || null,
@@ -19181,6 +19481,7 @@ class PipelineService extends EventEmitter {
status: 'READY_TO_START', status: 'READY_TO_START',
last_state: 'READY_TO_START', last_state: 'READY_TO_START',
media_type: 'converter', media_type: 'converter',
job_kind: resolveConverterJobKind(converterMediaType),
output_path: outputPath || outputDir || null, output_path: outputPath || outputDir || null,
encode_plan_json: JSON.stringify(nextEncodePlan), encode_plan_json: JSON.stringify(nextEncodePlan),
encode_review_confirmed: converterMediaType === 'audio' ? 1 : 0, 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). * 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 db = await require('../db/database').getDb();
const rows = await db.all(` const rows = await db.all(`
SELECT id, title, detected_title, year, imdb_id, poster_url, 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, encode_plan_json, output_path, raw_path, encode_input_path, error_message,
start_time, end_time, created_at, updated_at start_time, end_time, created_at, updated_at
FROM jobs FROM jobs
WHERE media_type = 'converter' WHERE media_type = 'converter'
OR job_kind LIKE 'converter_%'
OR ( OR (
(media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other') (media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other')
AND ( AND (
+25 -2
View File
@@ -50,6 +50,7 @@ CREATE TABLE jobs (
encode_review_confirmed INTEGER DEFAULT 0, encode_review_confirmed INTEGER DEFAULT 0,
aax_checksum TEXT, aax_checksum TEXT,
media_type TEXT, media_type TEXT,
job_kind TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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 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_status ON jobs(status);
CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC); 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_parent_job_id ON jobs(parent_job_id);
CREATE INDEX idx_jobs_job_kind ON jobs(job_kind);
CREATE TABLE job_lineage_artifacts ( CREATE TABLE job_lineage_artifacts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -588,8 +590,29 @@ UPDATE settings_schema SET category = 'Pfade' WHERE key IN ('converter_raw_dir',
-- Converter Scan -- Converter Scan
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) 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); 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,aac,wav,m4a,ogg,opus'); 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) 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); VALUES ('converter_polling_enabled', 'Converter', 'Auto-Scan (Polling)', 'boolean', 1, 'Converter Raw-Ordner automatisch in regelmäßigen Abständen scannen.', 'false', '[]', '{}', 830);
+1 -1
View File
@@ -6,7 +6,7 @@ Frontend: React + PrimeReact + Vite.
## Hauptseiten ## Hauptseiten
### `DashboardPage.jsx` ### `RipperPage.jsx`
Pipeline-Steuerung: Pipeline-Steuerung:
+1 -1
View File
@@ -9,7 +9,7 @@ Ripster ist eine Client-Server-Anwendung mit REST + WebSocket und externen CLI-T
```mermaid ```mermaid
graph TB graph TB
subgraph Browser["Browser (React)"] subgraph Browser["Browser (React)"]
Dashboard[Dashboard] Ripper[Ripper]
Settings[Einstellungen] Settings[Einstellungen]
History[Historie] History[Historie]
end end
+1 -1
View File
@@ -145,7 +145,7 @@ Nicht gesetzte Werte werden zu `unknown`.
| Feldname in der GUI | Typ | Default | Hinweis | | 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 | | `Auto-Scan (Polling)` | boolean | `false` | Converter-Ordner automatisch scannen |
| `Polling-Intervall (Sekunden)` | number | `300` | 30..86400 | | `Polling-Intervall (Sekunden)` | number | `300` | 30..86400 |
+2 -2
View File
@@ -37,7 +37,7 @@ Typische Beispiele:
### 3. Queue und Monitoring festlegen ### 3. Queue und Monitoring festlegen
- `Parallele Jobs` für den gleichzeitigen Durchsatz - `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 ### 4. Optional: Push-Benachrichtigungen
@@ -51,7 +51,7 @@ Dann über `PushOver Test` direkt prüfen.
## 2-Minuten-Funktionstest ## 2-Minuten-Funktionstest
1. `Dashboard` öffnen 1. `Ripper` öffnen
2. Disc einlegen 2. Disc einlegen
3. `Analyse starten` 3. `Analyse starten`
4. Metadaten übernehmen 4. Metadaten übernehmen
+3 -3
View File
@@ -2,7 +2,7 @@
Dieser Ablauf zeigt einen vollständigen Job aus Anwendersicht: von Disc-Erkennung bis fertiger Datei. 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: Erwartung:
@@ -13,7 +13,7 @@ Wenn nichts passiert: `Laufwerk neu lesen`.
## 2. Analyse starten ## 2. Analyse starten
Aktion im Dashboard: Aktion im Ripper:
- `Analyse starten` - `Analyse starten`
@@ -51,7 +51,7 @@ Dann `Encoding starten`.
Während `Encodieren`: Während `Encodieren`:
- Fortschritt + ETA im Dashboard - Fortschritt + ETA im Ripper
- Live-Log im `Pipeline-Status` - Live-Log im `Pipeline-Status`
- Queue- und Skript/Cron-Status parallel beobachtbar - Queue- und Skript/Cron-Status parallel beobachtbar
+2 -2
View File
@@ -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 - **Ein Job pro Datei** — jede Datei wird als eigenständiger Job angelegt
- **Gemeinsamer Job (Audio)** — mehrere Audio-Dateien werden zu einem gemeinsamen Job zusammengefasst - **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` | | Eingangsordner | `Settings > Pfade > Converter Raw-Ordner` |
| Videoausgabe | `Settings > Pfade > Converter Ausgabe (Video)` | | Videoausgabe | `Settings > Pfade > Converter Ausgabe (Video)` |
| Audioausgabe | `Settings > Pfade > Converter Ausgabe (Audio)` | | 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)` | | Auto-Scan | `Settings > Converter > Auto-Scan (Polling)` |
| Scan-Intervall | `Settings > Converter > Polling-Intervall (Sekunden)` | | Scan-Intervall | `Settings > Converter > Polling-Intervall (Sekunden)` |
| Output-Template Video | `Settings > Pfade > Output-Template (Video)` | | Output-Template Video | `Settings > Pfade > Output-Template (Video)` |
+3 -3
View File
@@ -6,7 +6,7 @@ Ripster hat fünf Hauptseiten in der Navigation plus eine Expert-Seite.
| Seite | Zweck | | 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 | | [Settings](settings.md) | Konfiguration, Skripte, Ketten, Presets, Cronjobs |
| [Historie](history.md) | abgeschlossene/laufende Jobs durchsuchen und nachbearbeiten | | [Historie](history.md) | abgeschlossene/laufende Jobs durchsuchen und nachbearbeiten |
| [Converter](converter.md) | Audio/Video-Dateien konvertieren, Datei-Explorer, Upload | | [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 ## 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` 2. **Dateien konvertieren oder Audiobooks verarbeiten:** `Converter`
3. **Regeln/Automatisierung anpassen:** `Settings` 3. **Regeln/Automatisierung anpassen:** `Settings`
4. **Ergebnisse prüfen oder Jobs nachbearbeiten:** `Historie` 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 ## 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`. - `Database` ist als Expert-Route verfügbar: `/database`.
+3 -3
View File
@@ -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 ## Aufbau der Seite
@@ -107,7 +107,7 @@ Aktionen:
--- ---
## Wichtige Dialoge im Dashboard ## Wichtige Dialoge im Ripper
### Metadaten auswählen ### Metadaten auswählen
+2 -2
View File
@@ -9,7 +9,7 @@ Die Seite `Settings` steuert Konfiguration und Automatisierung.
| `Konfiguration` | alle Kernsettings (Pfade, Tools, Monitoring, Metadaten, Queue, Benachrichtigungen) | | `Konfiguration` | alle Kernsettings (Pfade, Tools, Monitoring, Metadaten, Queue, Benachrichtigungen) |
| `Scripte` | einzelne Bash-Skripte verwalten und testen | | `Scripte` | einzelne Bash-Skripte verwalten und testen |
| `Skriptketten` | Sequenzen aus Skript- und Warte-Schritten bauen | | `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 | | `Cronjobs` | zeitgesteuerte Skript-/Kettenausführung |
--- ---
@@ -65,7 +65,7 @@ Ein Preset bündelt:
Verwendung: 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` ## Tab `Cronjobs`
+1 -1
View File
@@ -16,7 +16,7 @@ Dieses Dokumentationsset ist als **Benutzerhandbuch** aufgebaut: erst Bedienung
- **Benutzerhandbuch** - **Benutzerhandbuch**
- Installation - Installation
- GUI-Seiten im Detail (`Dashboard`, `Settings`, `Historie`, `Database`) - GUI-Seiten im Detail (`Ripper`, `Settings`, `Historie`, `Database`)
- typische Arbeitsabläufe aus Anwendersicht - typische Arbeitsabläufe aus Anwendersicht
- **Technischer Anhang** - **Technischer Anhang**
- vollständige Einstellungsreferenz - vollständige Einstellungsreferenz
+1 -1
View File
@@ -52,7 +52,7 @@ Default ist aktuell `60` Minuten.
## UI-Verhalten ## 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: Nach Bestätigung:
+6 -6
View File
@@ -4,7 +4,7 @@ Diese Seite beschreibt typische Abläufe mit den passenden UI-Aktionen.
## Workflow 1: Standardlauf (Disc -> fertige Datei) ## Workflow 1: Standardlauf (Disc -> fertige Datei)
1. `Dashboard`: Disc einlegen, `Analyse starten` 1. `Ripper`: Disc einlegen, `Analyse starten`
2. Metadaten im Dialog übernehmen 2. Metadaten im Dialog übernehmen
3. bei `Bereit zum Encodieren` Titel/Tracks prüfen 3. bei `Bereit zum Encodieren` Titel/Tracks prüfen
4. `Encoding starten` 4. `Encoding starten`
@@ -38,21 +38,21 @@ In `Historie` -> Detaildialog:
1. `Settings` -> `Scripte`: Skripte anlegen und testen 1. `Settings` -> `Scripte`: Skripte anlegen und testen
2. `Settings` -> `Skriptketten`: Ketten bauen 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 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 ## Workflow 6: Abbruch und Recovery
### Fall A: Job wurde abgebrochen ### 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` - 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 ### Fall B: Job steht in `Bereit zum Encodieren`, ist aber nicht aktive Session
- in `Historie`: `Im Dashboard öffnen` - in `Historie`: `Im Ripper öffnen`
- im Dashboard Review erneut prüfen und starten - im Ripper Review erneut prüfen und starten
### Fall C: RAW ohne Historieneintrag ### Fall C: RAW ohne Historieneintrag
+44 -28
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; 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 { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar'; import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag'; import { Tag } from 'primereact/tag';
@@ -7,12 +7,14 @@ import { Toast } from 'primereact/toast';
import { ConfirmDialog } from 'primereact/confirmdialog'; import { ConfirmDialog } from 'primereact/confirmdialog';
import { api } from './api/client'; import { api } from './api/client';
import { useWebSocket } from './hooks/useWebSocket'; import { useWebSocket } from './hooks/useWebSocket';
import DashboardPage from './pages/DashboardPage'; import RipperPage from './pages/RipperPage';
import SettingsPage from './pages/SettingsPage'; import SettingsPage from './pages/SettingsPage';
import HistoryPage from './pages/HistoryPage'; import HistoryPage from './pages/HistoryPage';
import DatabasePage from './pages/DatabasePage'; import DatabasePage from './pages/DatabasePage';
import DownloadsPage from './pages/DownloadsPage'; import DownloadsPage from './pages/DownloadsPage';
import ConverterPage from './pages/ConverterPage'; import ConverterPage from './pages/ConverterPage';
import JobsInboxPage from './pages/JobsInboxPage';
import AudiobooksPage from './pages/AudiobooksPage';
function normalizeJobId(value) { function normalizeJobId(value) {
const parsed = Number(value); const parsed = Number(value);
@@ -123,11 +125,11 @@ function App() {
const [lastDiscEvent, setLastDiscEvent] = useState(null); const [lastDiscEvent, setLastDiscEvent] = useState(null);
const [expertMode, setExpertMode] = useState(false); const [expertMode, setExpertMode] = useState(false);
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState()); const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0); const [ripperJobsRefreshToken, setRipperJobsRefreshToken] = useState(0);
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0); const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0); const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0);
const [downloadSummary, setDownloadSummary] = useState(null); const [downloadSummary, setDownloadSummary] = useState(null);
const [pendingDashboardJobId, setPendingDashboardJobId] = useState(null); const [pendingRipperJobId, setPendingRipperJobId] = useState(null);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const globalToastRef = useRef(null); const globalToastRef = useRef(null);
@@ -135,7 +137,7 @@ function App() {
// When a virtual CD drive is removed (CD encode/rip finished or failed), // 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 // 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(() => { useEffect(() => {
const current = pipeline?.cdDrives || {}; const current = pipeline?.cdDrives || {};
const prev = prevCdDrivesRef.current; const prev = prevCdDrivesRef.current;
@@ -167,7 +169,7 @@ function App() {
} }
if (shouldRefresh) { if (shouldRefresh) {
setDashboardJobsRefreshToken((t) => t + 1); setRipperJobsRefreshToken((t) => t + 1);
setHistoryJobsRefreshToken((t) => t + 1); setHistoryJobsRefreshToken((t) => t + 1);
} }
prevCdDrivesRef.current = current; prevCdDrivesRef.current = current;
@@ -235,10 +237,10 @@ function App() {
const uploadedJobId = normalizeJobId(response?.result?.jobId); const uploadedJobId = normalizeJobId(response?.result?.jobId);
await refreshPipeline().catch(() => null); await refreshPipeline().catch(() => null);
setDashboardJobsRefreshToken((prev) => prev + 1); setRipperJobsRefreshToken((prev) => prev + 1);
setHistoryJobsRefreshToken((prev) => prev + 1); setHistoryJobsRefreshToken((prev) => prev + 1);
if (uploadedJobId) { if (uploadedJobId) {
setPendingDashboardJobId(uploadedJobId); setPendingRipperJobId(uploadedJobId);
} }
setAudiobookUpload((prev) => ({ setAudiobookUpload((prev) => ({
@@ -268,12 +270,12 @@ function App() {
} }
}; };
const handleDashboardJobFocusConsumed = (jobId) => { const handleRipperJobFocusConsumed = (jobId) => {
const normalizedJobId = normalizeJobId(jobId); const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) { if (!normalizedJobId) {
return; return;
} }
setPendingDashboardJobId((prev) => ( setPendingRipperJobId((prev) => (
normalizeJobId(prev) === normalizedJobId ? null : prev normalizeJobId(prev) === normalizedJobId ? null : prev
)); ));
}; };
@@ -525,8 +527,10 @@ function App() {
}); });
const nav = [ const nav = [
{ label: 'Dashboard', path: '/' }, { label: 'Jobs', path: '/jobs' },
{ label: 'Ripper', path: '/ripper' },
{ label: 'Converter', path: '/converter' }, { label: 'Converter', path: '/converter' },
{ label: 'Audiobooks', path: '/audiobooks' },
{ label: 'Settings', path: '/settings' }, { label: 'Settings', path: '/settings' },
{ label: 'Historie', path: '/history' }, { label: 'Historie', path: '/history' },
{ label: 'Downloads', path: '/downloads' }, { label: 'Downloads', path: '/downloads' },
@@ -543,8 +547,14 @@ function App() {
: (uploadLoadedBytes > 0 ? `${formatBytes(uploadLoadedBytes)} hochgeladen` : null); : (uploadLoadedBytes > 0 ? `${formatBytes(uploadLoadedBytes)} hochgeladen` : null);
const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error'; const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error';
const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId)); const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId));
const isDashboardRoute = location.pathname === '/'; const isAudiobooksRoute = location.pathname === '/audiobooks';
const downloadIndicator = getDownloadIndicatorMeta(downloadSummary); const downloadIndicator = getDownloadIndicatorMeta(downloadSummary);
const isNavActive = (path) => {
if (path === '/ripper') {
return location.pathname === '/' || location.pathname === '/ripper';
}
return location.pathname === path;
};
return ( return (
<div className="app-shell"> <div className="app-shell">
@@ -570,8 +580,8 @@ function App() {
key={item.path} key={item.path}
label={item.label} label={item.label}
onClick={() => navigate(item.path)} onClick={() => navigate(item.path)}
className={location.pathname === item.path ? 'nav-btn nav-btn-active' : 'nav-btn'} className={isNavActive(item.path) ? 'nav-btn nav-btn-active' : 'nav-btn'}
outlined={location.pathname !== item.path} outlined={!isNavActive(item.path)}
/> />
))} ))}
</div> </div>
@@ -603,18 +613,14 @@ function App() {
</div> </div>
<div className="app-upload-banner-actions"> <div className="app-upload-banner-actions">
{hasUploadedJob && !isDashboardRoute ? ( {hasUploadedJob && !isAudiobooksRoute ? (
<Button <Button
label="Zum Dashboard" label="Zu Audiobooks"
icon="pi pi-arrow-right" icon="pi pi-arrow-right"
severity="secondary" severity="secondary"
outlined outlined
onClick={() => { onClick={() => {
const targetJobId = normalizeJobId(audiobookUpload?.jobId); navigate('/audiobooks');
if (targetJobId) {
setPendingDashboardJobId(targetJobId);
}
navigate('/');
}} }}
/> />
) : null} ) : null}
@@ -637,27 +643,37 @@ function App() {
<Route <Route
path="/" path="/"
element={ element={
<DashboardPage <RipperPage
pipeline={pipeline} pipeline={pipeline}
hardwareMonitoring={hardwareMonitoring} hardwareMonitoring={hardwareMonitoring}
lastDiscEvent={lastDiscEvent} lastDiscEvent={lastDiscEvent}
refreshPipeline={refreshPipeline} refreshPipeline={refreshPipeline}
audiobookUpload={audiobookUpload} jobsRefreshToken={ripperJobsRefreshToken}
onAudiobookUpload={handleAudiobookUpload} pendingExpandedJobId={pendingRipperJobId}
jobsRefreshToken={dashboardJobsRefreshToken} onPendingExpandedJobHandled={handleRipperJobFocusConsumed}
pendingExpandedJobId={pendingDashboardJobId}
onPendingExpandedJobHandled={handleDashboardJobFocusConsumed}
downloadSummary={downloadSummary} downloadSummary={downloadSummary}
> >
<Outlet /> <Outlet />
</DashboardPage> </RipperPage>
} }
> >
<Route index element={<Navigate to="jobs" replace />} />
<Route path="ripper" element={null} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} /> <Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} /> <Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
<Route path="database" element={<DatabasePage />} /> <Route path="database" element={<DatabasePage />} />
<Route path="jobs" element={<JobsInboxPage />} />
<Route path="converter" element={<ConverterPage />} /> <Route path="converter" element={<ConverterPage />} />
<Route
path="audiobooks"
element={
<AudiobooksPage
audiobookUpload={audiobookUpload}
onAudiobookUpload={handleAudiobookUpload}
/>
}
/>
</Route> </Route>
</Routes> </Routes>
</main> </main>
+6
View File
@@ -597,6 +597,12 @@ export const api = {
afterMutationInvalidate(['/history', '/pipeline/queue']); afterMutationInvalidate(['/history', '/pipeline/queue']);
return result; return result;
}, },
getAudiobookJobs() {
return request('/pipeline/audiobook/jobs');
},
getAudiobookOutputTree() {
return request('/pipeline/audiobook/output-tree');
},
async selectMetadata(payload) { async selectMetadata(payload) {
const result = await request('/pipeline/select-metadata', { const result = await request('/pipeline/select-metadata', {
method: 'POST', method: 'POST',
@@ -297,7 +297,7 @@ export default function AudiobookConfigPanel({
</div> </div>
{isRunning ? ( {isRunning ? (
<div className="dashboard-job-row-progress" aria-label={`Audiobook Fortschritt ${Math.round(progress)}%`}> <div className="ripper-job-row-progress" aria-label={`Audiobook Fortschritt ${Math.round(progress)}%`}>
<ProgressBar value={progress} showValue={false} /> <ProgressBar value={progress} showValue={false} />
<small>{Math.round(progress)}%{currentChapter ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}: ${currentChapter.title}` : ''}</small> <small>{Math.round(progress)}%{currentChapter ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}: ${currentChapter.title}` : ''}</small>
</div> </div>
@@ -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 (
<div key={key || '__root__'} className={`tree-node depth-${depth}`}>
<button
type="button"
className={`tree-row folder${isCurrent ? ' active' : ''}`}
onClick={() => navigateTo(key)}
>
{children.length > 0 ? (
<span
className="tree-caret"
onClick={(event) => {
event.stopPropagation();
toggleFolder(key);
}}
>
<i className={`pi ${isExpanded ? 'pi-chevron-down' : 'pi-chevron-right'}`} />
</span>
) : (
<span className="tree-caret disabled" aria-hidden="true" />
)}
<span className="tree-icon folder">
<i className="pi pi-folder" />
</span>
<span className="tree-label">{node.name || 'audiobooks'}</span>
</button>
{isExpanded && children.map((child) => renderTreeNode(child, depth + 1))}
</div>
);
};
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 (
<div className="explorer-loading">
<ProgressSpinner style={{ width: '2.2rem', height: '2.2rem' }} strokeWidth="5" />
</div>
);
}
if (!tree) {
return (
<div className="explorer-empty">
{errorMessage ? (
<small className="error-text">{errorMessage}</small>
) : (
<small>Kein Audiobook-Output vorhanden. Ausgabepfad: <code>{outputDir || 'nicht konfiguriert'}</code></small>
)}
<div style={{ marginTop: '0.6rem' }}>
<Button icon="pi pi-refresh" label="Neu laden" outlined size="small" onClick={() => void loadTree()} />
</div>
</div>
);
}
return (
<div className="explorer audiobook-output-explorer">
<div className="explorer-sidebar">
<div className="explorer-toolbar sidebar-toolbar">
<input
type="text"
placeholder="Ordner filtern..."
value={sidebarQuery}
onChange={(event) => setSidebarQuery(event.target.value)}
className="sidebar-search"
/>
</div>
<div className="sidebar-tree">
{filteredTree ? renderTreeNode(filteredTree, 0) : <small>Keine Ordner gefunden.</small>}
</div>
</div>
<div className="explorer-main">
<div className="explorer-toolbar">
<Button
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
label="Aktualisieren"
outlined
size="small"
onClick={() => void loadTree()}
disabled={loading}
/>
<div className="explorer-path">
<Button text label="/" onClick={() => navigateTo('')} />
{breadcrumb.map((crumb) => (
<Button key={crumb.path} text label={crumb.name} onClick={() => navigateTo(crumb.path)} />
))}
</div>
</div>
<div className="explorer-list">
<div className="explorer-row header audiobook-output-row">
<span>Name</span>
<span>Größe</span>
<span>Geändert</span>
</div>
{currentChildren.length === 0 ? (
<div className="explorer-row audiobook-output-row">
<span>Keine Einträge in diesem Ordner.</span>
<span />
<span />
</div>
) : (
currentChildren.map((entry) => (
<button
type="button"
key={entry.path || entry.name}
className="explorer-row audiobook-output-row"
onClick={() => {
if (entry.type === 'folder') {
navigateTo(entry.path);
}
}}
style={{ cursor: entry.type === 'folder' ? 'pointer' : 'default' }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
<i className={`pi ${entry.type === 'folder' ? 'pi-folder' : 'pi-file'}`} />
{entry.name}
</span>
<span>{entry.type === 'file' ? (formatBytes(entry.size) || '-') : '-'}</span>
<span>{formatDateTime(entry.mtime)}</span>
</button>
))
)}
</div>
<div className="explorer-footer">
<small>
Root: <code>{outputDir || '-'}</code>
</small>
</div>
</div>
</div>
);
}
@@ -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 (
<div className="audiobook-upload-panel">
<Toast ref={toastRef} position="top-right" />
<FileUpload
ref={fileUploadRef}
accept=".aax"
maxFileSize={10737418240}
customUpload
uploadHandler={() => 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) => (
<div className="aax-file-item">
<i className="pi pi-headphones aax-file-icon" />
<div className="aax-file-info">
<span className="aax-file-name" title={file.name}>{file.name}</span>
<small>{options.formatSize}</small>
</div>
<Button
icon="pi pi-times"
text
rounded
severity="danger"
size="small"
onClick={options.onRemove}
disabled={uploadBusy}
/>
</div>
)}
emptyTemplate={() => (
<div className="aax-drop-zone">
<i className="pi pi-headphones aax-drop-icon" />
<p>AAX-Datei hier ablegen</p>
<small>oder oben "Auswaehlen" klicken</small>
</div>
)}
/>
{statusVisible ? (
<div className={`audiobook-upload-status tone-${statusTone}`}>
<div className="audiobook-upload-status-head">
<strong>{statusLabel}</strong>
<Tag value={statusLabel} severity={statusTone} />
</div>
{audiobookUpload?.statusText ? <small>{audiobookUpload.statusText}</small> : null}
{fileName ? (
<small className="audiobook-upload-file" title={fileName}>
Datei: {fileName}
</small>
) : null}
<div
className="ripper-job-row-progress audiobook-upload-progress"
aria-label={`Audiobook Upload ${Math.round(progress)} Prozent`}
>
<ProgressBar value={progress} showValue={false} />
<small>
{phase === 'processing'
? '100% | Upload fertig, Job wird vorbereitet ...'
: totalBytes > 0
? `${Math.round(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}`
: `${Math.round(progress)}%`}
</small>
</div>
</div>
) : null}
</div>
);
}
+15 -15
View File
@@ -1056,9 +1056,9 @@ export default function ConverterJobCard({
})(); })();
const jobIdLabel = Number.isFinite(Number(job?.id)) ? `Job #${Math.trunc(Number(job.id))}` : 'Job'; 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 customTitle = String(job?.title || '').trim();
const title = customTitle ? `${dashboardTitleId} | ${customTitle}` : dashboardTitleId; const title = customTitle ? `${ripperTitleId} | ${customTitle}` : ripperTitleId;
const status = job.status || 'UNKNOWN'; const status = job.status || 'UNKNOWN';
const statusLabel = getStatusLabel(status); const statusLabel = getStatusLabel(status);
const statusSeverity = getStatusSeverity(status); const statusSeverity = getStatusSeverity(status);
@@ -1173,15 +1173,15 @@ export default function ConverterJobCard({
return ( return (
<button <button
type="button" type="button"
className="dashboard-job-row" className="ripper-job-row"
onClick={onExpand} onClick={onExpand}
> >
<div className="poster-thumb dashboard-job-poster-fallback"> <div className="poster-thumb ripper-job-poster-fallback">
{isAudio ? 'Audio' : 'Video'} {isAudio ? 'Audio' : 'Video'}
</div> </div>
<div className="dashboard-job-row-content"> <div className="ripper-job-row-content">
<div className="dashboard-job-row-main"> <div className="ripper-job-row-main">
<strong className="dashboard-job-title-line"> <strong className="ripper-job-title-line">
<i className="pi pi-circle-fill media-indicator-icon" style={{ color: 'var(--rip-muted)', fontSize: '0.65rem' }} /> <i className="pi pi-circle-fill media-indicator-icon" style={{ color: 'var(--rip-muted)', fontSize: '0.65rem' }} />
<span>{title}</span> <span>{title}</span>
</strong> </strong>
@@ -1189,12 +1189,12 @@ export default function ConverterJobCard({
{fileCount != null ? `${fileCount} Datei${fileCount !== 1 ? 'en' : ''}` : null} {fileCount != null ? `${fileCount} Datei${fileCount !== 1 ? 'en' : ''}` : null}
</small> </small>
</div> </div>
<div className="dashboard-job-badges"> <div className="ripper-job-badges">
{converterMediaType && mediaTypeBadge(converterMediaType)} {converterMediaType && mediaTypeBadge(converterMediaType)}
<Tag value={statusLabel} severity={statusSeverity} /> <Tag value={statusLabel} severity={statusSeverity} />
</div> </div>
{active && progressValue !== null && ( {active && progressValue !== null && (
<div className="dashboard-job-row-progress"> <div className="ripper-job-row-progress">
<ProgressBar value={progressValue} showValue={false} /> <ProgressBar value={progressValue} showValue={false} />
<small> <small>
{liveEta ? `${progressValue}% | ETA ${liveEta}` : `${progressValue}%`} {liveEta ? `${progressValue}% | ETA ${liveEta}` : `${progressValue}%`}
@@ -1209,20 +1209,20 @@ export default function ConverterJobCard({
// Ausgeklappt // Ausgeklappt
return ( return (
<div className={`dashboard-job-expanded converter-job-card status-${status.toLowerCase()}${isReady ? ' is-ready' : ''}`}> <div className={`ripper-job-expanded converter-job-card status-${status.toLowerCase()}${isReady ? ' is-ready' : ''}`}>
{/* Header */} {/* Header */}
<div className="dashboard-job-expanded-head"> <div className="ripper-job-expanded-head">
{job?.poster_url && job.poster_url !== 'N/A' ? ( {job?.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={title} className="poster-thumb" /> <img src={job.poster_url} alt={title} className="poster-thumb" />
) : ( ) : (
<div className="poster-thumb dashboard-job-poster-fallback">Kein Poster</div> <div className="poster-thumb ripper-job-poster-fallback">Kein Poster</div>
)} )}
<div className="dashboard-job-expanded-title"> <div className="ripper-job-expanded-title">
<strong className="dashboard-job-title-line"> <strong className="ripper-job-title-line">
<i className="pi pi-circle-fill media-indicator-icon" style={{ color: 'var(--rip-muted)', fontSize: '0.65rem' }} /> <i className="pi pi-circle-fill media-indicator-icon" style={{ color: 'var(--rip-muted)', fontSize: '0.65rem' }} />
<span>{title}</span> <span>{title}</span>
</strong> </strong>
<div className="dashboard-job-badges"> <div className="ripper-job-badges">
{converterMediaType && mediaTypeBadge(converterMediaType)} {converterMediaType && mediaTypeBadge(converterMediaType)}
<Tag value={statusLabel} severity={statusSeverity} /> <Tag value={statusLabel} severity={statusSeverity} />
</div> </div>
@@ -17,7 +17,6 @@ const VIDEO_OUTPUT_FORMATS = [
const AUDIO_OUTPUT_FORMATS = [ const AUDIO_OUTPUT_FORMATS = [
{ label: 'FLAC', value: 'flac' }, { label: 'FLAC', value: 'flac' },
{ label: 'MP3', value: 'mp3' }, { label: 'MP3', value: 'mp3' },
{ label: 'AAC', value: 'aac' },
{ label: 'Opus', value: 'opus' }, { label: 'Opus', value: 'opus' },
{ label: 'OGG', value: 'ogg' }, { label: 'OGG', value: 'ogg' },
{ label: 'WAV', value: 'wav' } { label: 'WAV', value: 'wav' }
@@ -49,7 +48,6 @@ export default function ConverterJobConfigDialog({
mp3Mode: 'cbr', mp3Mode: 'cbr',
mp3Bitrate: 192, mp3Bitrate: 192,
mp3Quality: 4, mp3Quality: 4,
aacBitrate: 256,
opusBitrate: 160, opusBitrate: 160,
oggQuality: 6 oggQuality: 6
}); });
@@ -268,17 +266,6 @@ export default function ConverterJobConfigDialog({
)} )}
</> </>
)} )}
{outputFormat === 'aac' && (
<div className="field">
<label>Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.aacBitrate}
options={[128, 160, 192, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, aacBitrate: e.value }))}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
)}
{outputFormat === 'opus' && ( {outputFormat === 'opus' && (
<div className="field"> <div className="field">
<label>Bitrate (kbps)</label> <label>Bitrate (kbps)</label>
@@ -1,19 +1,45 @@
import { useCallback, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { Button } from 'primereact/button'; import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar'; import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag'; import { Tag } from 'primereact/tag';
const ACCEPTED_EXTENSIONS = [ const DEFAULT_ALLOWED_EXTENSIONS = [
'.mkv', '.mp4', '.m2ts', '.avi', '.mov', '.m4v', '.wmv', '.ts', 'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov',
'.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape', 'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
'.iso' ];
].join(',');
function normalizeAllowedExtensions(values) {
const source = Array.isArray(values) ? values : [];
const seen = new Set();
const parsed = source
.map((item) => String(item || '').trim().toLowerCase())
.filter((item) => {
if (!item || !DEFAULT_ALLOWED_EXTENSIONS.includes(item) || seen.has(item)) {
return false;
}
seen.add(item);
return true;
});
if (parsed.length === 0) {
return [...DEFAULT_ALLOWED_EXTENSIONS];
}
return parsed;
}
function getFileExtensionWithoutDot(fileName) {
const raw = String(fileName || '').trim();
const dotIndex = raw.lastIndexOf('.');
if (dotIndex === -1 || dotIndex === raw.length - 1) {
return '';
}
return raw.slice(dotIndex + 1).toLowerCase();
}
/** /**
* Upload-Panel für den Converter. * Upload-Panel für den Converter.
* Unterstützt Mehrfach-Dateien und Ordner-Upload (webkitdirectory). * Unterstützt Mehrfach-Dateien und Ordner-Upload (webkitdirectory).
*/ */
export default function ConverterUploadPanel({ onUploaded }) { export default function ConverterUploadPanel({ onUploaded, allowedExtensions = null }) {
const [phase, setPhase] = useState('idle'); // idle | uploading | done | error const [phase, setPhase] = useState('idle'); // idle | uploading | done | error
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [statusText, setStatusText] = useState(''); const [statusText, setStatusText] = useState('');
@@ -21,6 +47,22 @@ export default function ConverterUploadPanel({ onUploaded }) {
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const dirInputRef = useRef(null); const dirInputRef = useRef(null);
const normalizedAllowedExtensions = useMemo(
() => normalizeAllowedExtensions(allowedExtensions),
[allowedExtensions]
);
const allowedExtensionSet = useMemo(
() => new Set(normalizedAllowedExtensions),
[normalizedAllowedExtensions]
);
const acceptValue = useMemo(
() => normalizedAllowedExtensions.map((ext) => `.${ext}`).join(','),
[normalizedAllowedExtensions]
);
const allowedListLabel = useMemo(
() => normalizedAllowedExtensions.map((ext) => ext.toUpperCase()).join(', '),
[normalizedAllowedExtensions]
);
const reset = () => { const reset = () => {
setPhase('idle'); setPhase('idle');
@@ -38,6 +80,24 @@ export default function ConverterUploadPanel({ onUploaded }) {
}); });
if (files.length === 0) return; if (files.length === 0) return;
const invalidFiles = files
.map((file) => ({
name: String(file?.name || '').trim(),
ext: getFileExtensionWithoutDot(file?.name)
}))
.filter((item) => !item.ext || !allowedExtensionSet.has(item.ext));
if (invalidFiles.length > 0) {
const preview = invalidFiles.slice(0, 6).map((item) => item.name || '<ohne Dateiname>').join(', ');
const suffix = invalidFiles.length > 6 ? ` (+${invalidFiles.length - 6} weitere)` : '';
setPhase('error');
setProgress(0);
setStatusText('Upload abgelehnt');
setErrorMsg(
`Nicht erlaubte Datei-Endung in ${invalidFiles.length} Datei(en): ${preview}${suffix}. Erlaubt: ${allowedListLabel}`
);
return;
}
const isFolderUpload = files.some((f) => f.webkitRelativePath && f.webkitRelativePath.includes('/')); const isFolderUpload = files.some((f) => f.webkitRelativePath && f.webkitRelativePath.includes('/'));
setPhase('uploading'); setPhase('uploading');
@@ -77,7 +137,7 @@ export default function ConverterUploadPanel({ onUploaded }) {
setPhase('error'); setPhase('error');
setErrorMsg(err.message || 'Upload fehlgeschlagen.'); setErrorMsg(err.message || 'Upload fehlgeschlagen.');
} }
}, [onUploaded]); }, [onUploaded, allowedExtensionSet, allowedListLabel]);
const handleFileChange = (e) => { const handleFileChange = (e) => {
if (e.target.files?.length > 0) { if (e.target.files?.length > 0) {
@@ -119,7 +179,7 @@ export default function ConverterUploadPanel({ onUploaded }) {
<div className="converter-upload-hint"> <div className="converter-upload-hint">
Dateien hierher ziehen oder auswählen Dateien hierher ziehen oder auswählen
<br /> <br />
<small>Unterstützt: MKV, MP4, ISO, AVI, FLAC, MP3, WAV, AAC, OGG, OPUS </small> <small>Unterstützt: {allowedListLabel}</small>
</div> </div>
<div className="converter-upload-buttons"> <div className="converter-upload-buttons">
<Button <Button
@@ -145,7 +205,7 @@ export default function ConverterUploadPanel({ onUploaded }) {
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
accept={ACCEPTED_EXTENSIONS} accept={acceptValue}
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleFileChange} onChange={handleFileChange}
/> />
@@ -154,6 +214,7 @@ export default function ConverterUploadPanel({ onUploaded }) {
type="file" type="file"
webkitdirectory="true" webkitdirectory="true"
multiple multiple
accept={acceptValue}
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleFileChange} onChange={handleFileChange}
/> />
@@ -5,6 +5,7 @@ import { InputText } from 'primereact/inputtext';
import { InputNumber } from 'primereact/inputnumber'; import { InputNumber } from 'primereact/inputnumber';
import { InputSwitch } from 'primereact/inputswitch'; import { InputSwitch } from 'primereact/inputswitch';
import { Dropdown } from 'primereact/dropdown'; import { Dropdown } from 'primereact/dropdown';
import { Checkbox } from 'primereact/checkbox';
import { Tag } from 'primereact/tag'; import { Tag } from 'primereact/tag';
import { Button } from 'primereact/button'; import { Button } from 'primereact/button';
import { api } from '../api/client'; import { api } from '../api/client';
@@ -444,6 +445,81 @@ const DOWNLOAD_PATH_KEYS = ['download_dir'];
const LOG_PATH_KEYS = ['log_dir']; const LOG_PATH_KEYS = ['log_dir'];
const EXTERNAL_STORAGE_PATH_KEYS = [EXTERNAL_STORAGE_PATHS_KEY]; const EXTERNAL_STORAGE_PATH_KEYS = [EXTERNAL_STORAGE_PATHS_KEY];
const CONVERTER_PATH_KEYS = ['converter_raw_dir', 'converter_movie_dir', 'converter_audio_dir', 'converter_output_template_video', 'converter_output_template_audio']; const CONVERTER_PATH_KEYS = ['converter_raw_dir', 'converter_movie_dir', 'converter_audio_dir', 'converter_output_template_video', 'converter_output_template_audio'];
const CONVERTER_SCAN_EXTENSION_KEY = 'converter_scan_extensions';
const CONVERTER_SCAN_EXTENSION_OPTIONS = [
'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov',
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
];
function parseConverterScanExtensions(value) {
const tokens = String(value || '')
.split(',')
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
const known = new Set(CONVERTER_SCAN_EXTENSION_OPTIONS);
const seen = new Set();
const selected = [];
for (const token of tokens) {
if (!known.has(token) || seen.has(token)) {
continue;
}
seen.add(token);
selected.push(token);
}
return selected;
}
function serializeConverterScanExtensions(values) {
const list = Array.isArray(values) ? values : [];
const known = new Set(CONVERTER_SCAN_EXTENSION_OPTIONS);
const seen = new Set();
const normalized = [];
for (const item of list) {
const ext = String(item || '').trim().toLowerCase();
if (!known.has(ext) || seen.has(ext)) {
continue;
}
seen.add(ext);
normalized.push(ext);
}
return normalized.join(',');
}
function ConverterScanExtensionsEditor({ value, onChange, settingKey }) {
const selected = parseConverterScanExtensions(value);
const selectedSet = new Set(selected);
const handleToggle = (extension) => {
const ext = String(extension || '').trim().toLowerCase();
if (!ext) {
return;
}
let next = selected.filter((item) => item !== ext);
if (!selectedSet.has(ext)) {
next = [...selected, ext];
}
// Setting ist required/minLength: mindestens eine Endung aktiv lassen.
if (next.length === 0) {
return;
}
onChange?.(settingKey, serializeConverterScanExtensions(next));
};
return (
<div className="converter-scan-extensions-grid">
{CONVERTER_SCAN_EXTENSION_OPTIONS.map((extension) => (
<label key={extension} htmlFor={`${settingKey}-${extension}`} className="converter-scan-extension-option">
<Checkbox
inputId={`${settingKey}-${extension}`}
checked={selectedSet.has(extension)}
onChange={() => handleToggle(extension)}
/>
<span>.{extension}</span>
</label>
))}
</div>
);
}
function buildSectionsForCategory(categoryName, settings) { function buildSectionsForCategory(categoryName, settings) {
const list = Array.isArray(settings) ? settings : []; const list = Array.isArray(settings) ? settings : [];
@@ -526,7 +602,8 @@ function SettingField({
{(setting.type === 'string' || setting.type === 'path') {(setting.type === 'string' || setting.type === 'path')
&& setting.key !== 'drive_devices' && setting.key !== 'drive_devices'
&& setting.key !== EXTERNAL_STORAGE_PATHS_KEY ? ( && setting.key !== EXTERNAL_STORAGE_PATHS_KEY
&& setting.key !== CONVERTER_SCAN_EXTENSION_KEY ? (
<InputText <InputText
id={setting.key} id={setting.key}
value={value ?? ''} value={value ?? ''}
@@ -550,6 +627,14 @@ function SettingField({
/> />
) : null} ) : null}
{setting.key === CONVERTER_SCAN_EXTENSION_KEY ? (
<ConverterScanExtensionsEditor
value={value}
onChange={onChange}
settingKey={setting.key}
/>
) : null}
{setting.type === 'number' ? ( {setting.type === 'number' ? (
<InputNumber <InputNumber
id={setting.key} id={setting.key}
+3 -6
View File
@@ -790,9 +790,6 @@ export default function JobDetailDialog({
} }
return `CBR ${Number(opts?.mp3Bitrate ?? 192)} kbps`; return `CBR ${Number(opts?.mp3Bitrate ?? 192)} kbps`;
} }
if (converterOutputFormat === 'aac') {
return `${Number(opts?.aacBitrate ?? 256)} kbps`;
}
if (converterOutputFormat === 'opus') { if (converterOutputFormat === 'opus') {
return `${Number(opts?.opusBitrate ?? 160)} kbps`; return `${Number(opts?.opusBitrate ?? 160)} kbps`;
} }
@@ -1470,7 +1467,7 @@ export default function JobDetailDialog({
)} )}
</> </>
) : ( ) : (
<p>Live-Log wird nur im Dashboard während laufender Analyse/Rip/Encode angezeigt.</p> <p>Live-Log wird nur im Ripper während laufender Analyse/Rip/Encode angezeigt.</p>
)} )}
<h4>Aktionen</h4> <h4>Aktionen</h4>
@@ -1563,7 +1560,7 @@ export default function JobDetailDialog({
{canResumeReady ? ( {canResumeReady ? (
<div className="action-item"> <div className="action-item">
<Button <Button
label="Im Dashboard öffnen" label="Im Ripper öffnen"
icon="pi pi-window-maximize" icon="pi pi-window-maximize"
severity="info" severity="info"
outlined outlined
@@ -1571,7 +1568,7 @@ export default function JobDetailDialog({
onClick={() => onResumeReady?.(job)} onClick={() => onResumeReady?.(job)}
loading={actionBusy} loading={actionBusy}
/> />
<span className="action-desc">Öffnet den wartenden Job im Dashboard zur Weiterverarbeitung.</span> <span className="action-desc">Öffnet den wartenden Job im Ripper zur Weiterverarbeitung.</span>
</div> </div>
) : null} ) : null}
{typeof onRestartEncode === 'function' ? ( {typeof onRestartEncode === 'function' ? (
+508
View File
@@ -0,0 +1,508 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button';
import { Card } from 'primereact/card';
import { Toast } from 'primereact/toast';
import { Badge } from 'primereact/badge';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import AudiobookUploadPanel from '../components/AudiobookUploadPanel';
import AudiobookConfigPanel from '../components/AudiobookConfigPanel';
import AudiobookOutputExplorer from '../components/AudiobookOutputExplorer';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
import { resolveMediaType } from '../utils/jobTaxonomy';
import { confirmModal } from '../utils/confirmModal';
const TERMINAL_STATES = new Set(['DONE', 'FINISHED']);
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function parseEncodePlan(job) {
if (job?.encodePlan && typeof job.encodePlan === 'object') {
return job.encodePlan;
}
try {
return JSON.parse(job?.encode_plan_json || '{}');
} catch (_error) {
return {};
}
}
function resolveAudiobookMetadata(job, encodePlan) {
const handbrakeMeta = job?.handbrakeInfo?.metadata && typeof job.handbrakeInfo.metadata === 'object'
? job.handbrakeInfo.metadata
: {};
const selectedMeta = job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
? job.makemkvInfo.selectedMetadata
: {};
const fallbackMeta = encodePlan?.metadata && typeof encodePlan.metadata === 'object'
? encodePlan.metadata
: {};
const merged = {
...fallbackMeta,
...selectedMeta,
...handbrakeMeta
};
const chapters = Array.isArray(merged?.chapters)
? merged.chapters
: (Array.isArray(encodePlan?.chapters) ? encodePlan.chapters : []);
return {
title: String(job?.title || job?.detected_title || merged?.title || '').trim() || null,
author: String(merged?.author || merged?.artist || '').trim() || null,
narrator: String(merged?.narrator || '').trim() || null,
description: String(merged?.description || '').trim() || null,
series: String(merged?.series || '').trim() || null,
part: Number.isFinite(Number(merged?.part)) ? Math.trunc(Number(merged.part)) : null,
year: Number.isFinite(Number(job?.year))
? Math.trunc(Number(job.year))
: (Number.isFinite(Number(merged?.year)) ? Math.trunc(Number(merged.year)) : null),
chapters,
durationMs: Number.isFinite(Number(merged?.durationMs)) ? Number(merged.durationMs) : 0,
poster: String(job?.poster_url || merged?.poster || '').trim() || null
};
}
function buildAudiobookPipeline(job, progress = null) {
const encodePlan = parseEncodePlan(job);
const selectedMetadata = resolveAudiobookMetadata(job, encodePlan);
const reviewData = job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
? job.makemkvInfo.selectedMetadata
: selectedMetadata;
const state = String(progress?.state || job?.status || '').trim().toUpperCase() || 'UNKNOWN';
return {
state,
activeJobId: Number(job?.id) || null,
progress: Number.isFinite(Number(progress?.progress)) ? Number(progress.progress) : 0,
eta: progress?.eta || null,
statusText: progress?.statusText || null,
context: {
jobId: Number(job?.id) || null,
mode: 'audiobook',
mediaProfile: 'audiobook',
selectedMetadata,
audiobookConfig: {
format: String(encodePlan?.format || job?.handbrakeInfo?.format || 'mp3').trim().toLowerCase() || 'mp3',
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
? encodePlan.formatOptions
: {}
},
mediaInfoReview: reviewData,
outputPath: String(job?.output_path || encodePlan?.outputPath || '').trim() || null,
currentChapter: progress?.currentChapter && typeof progress.currentChapter === 'object'
? progress.currentChapter
: null,
completedChapterCount: Number.isFinite(Number(progress?.completedChapterCount))
? Number(progress.completedChapterCount)
: null
}
};
}
export default function AudiobooksPage({
audiobookUpload,
onAudiobookUpload
}) {
const toastRef = useRef(null);
const navigate = useNavigate();
const [jobs, setJobs] = useState([]);
const [loadingJobs, setLoadingJobs] = useState(false);
const [expandedJobId, setExpandedJobId] = useState(undefined);
const [jobProgress, setJobProgress] = useState({});
const [actionBusyJobIds, setActionBusyJobIds] = useState(() => new Set());
const explorerRefreshToken = 0;
const setActionBusy = (jobId, busy) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setActionBusyJobIds((prev) => {
const next = new Set(prev);
if (busy) {
next.add(normalizedJobId);
} else {
next.delete(normalizedJobId);
}
return next;
});
};
const loadJobs = useCallback(async () => {
setLoadingJobs(true);
try {
const response = await api.getAudiobookJobs();
const rows = Array.isArray(response?.jobs) ? response.jobs : [];
const audiobookRows = rows
.filter((job) => resolveMediaType(job) === 'audiobook')
.sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0));
setJobs(audiobookRows);
setJobProgress((prev) => {
const next = { ...prev };
for (const job of audiobookRows) {
const jobId = normalizeJobId(job?.id);
if (!jobId) {
continue;
}
const status = String(job?.status || '').trim().toUpperCase();
if (!['ANALYZING', 'ENCODING'].includes(status)) {
delete next[jobId];
}
}
return next;
});
} catch (error) {
console.error('AudiobooksPage load jobs error:', error);
} finally {
setLoadingJobs(false);
}
}, []);
useEffect(() => {
void loadJobs();
const intervalId = setInterval(() => void loadJobs(), 5000);
return () => clearInterval(intervalId);
}, [loadJobs]);
useWebSocket({
onMessage: (message) => {
if (!message?.type || !message?.payload) {
return;
}
if (message.type === 'PIPELINE_PROGRESS') {
const payload = message.payload;
const jobId = normalizeJobId(payload?.activeJobId);
if (!jobId) {
return;
}
setJobProgress((prev) => ({
...prev,
[jobId]: {
progress: payload?.progress ?? null,
eta: payload?.eta ?? null,
statusText: payload?.statusText ?? null,
state: payload?.state ?? null,
currentChapter: payload?.contextPatch?.currentChapter ?? null,
completedChapterCount: payload?.contextPatch?.completedChapterCount ?? null
}
}));
}
if (
message.type === 'PIPELINE_UPDATE'
|| message.type === 'PIPELINE_STATE_CHANGED'
|| message.type === 'PIPELINE_QUEUE_CHANGED'
) {
void loadJobs();
}
}
});
const activeJobs = useMemo(
() => jobs.filter((job) => !TERMINAL_STATES.has(String(job?.status || '').trim().toUpperCase())),
[jobs]
);
useEffect(() => {
const normalizedExpanded = normalizeJobId(expandedJobId);
const hasExpanded = activeJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
if (hasExpanded) {
return;
}
if (expandedJobId === null) {
return;
}
if (activeJobs.length === 0) {
return;
}
setExpandedJobId(normalizeJobId(activeJobs[0]?.id));
}, [activeJobs, expandedJobId]);
const handleAudiobookStart = async (jobId, audiobookConfig) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setActionBusy(normalizedJobId, true);
try {
const response = await api.startAudiobook(normalizedJobId, audiobookConfig || {});
const queued = Boolean(response?.result?.queued);
toastRef.current?.show({
severity: queued ? 'info' : 'success',
summary: queued ? 'Job eingereiht' : 'Audiobook gestartet',
detail: queued
? `Job #${normalizedJobId} wurde in die Warteschlange eingereiht.`
: `Job #${normalizedJobId} wurde gestartet.`,
life: 3200
});
await loadJobs();
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Start fehlgeschlagen',
detail: error?.message || 'Bitte Logs pruefen.',
life: 4200
});
} finally {
setActionBusy(normalizedJobId, false);
}
};
const handleCancel = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setActionBusy(normalizedJobId, true);
try {
await api.cancelJob(normalizedJobId);
toastRef.current?.show({
severity: 'info',
summary: 'Abbruch angefordert',
detail: `Job #${normalizedJobId} wird abgebrochen.`,
life: 2600
});
await loadJobs();
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Abbruch fehlgeschlagen',
detail: error?.message || 'Bitte Logs pruefen.',
life: 4200
});
} finally {
setActionBusy(normalizedJobId, false);
}
};
const handleRetry = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setActionBusy(normalizedJobId, true);
try {
const response = await api.retryJob(normalizedJobId);
const retryJobId = normalizeJobId(response?.retryJob?.id);
toastRef.current?.show({
severity: 'success',
summary: 'Retry erstellt',
detail: retryJobId
? `Neuer Job #${retryJobId} wurde angelegt.`
: `Retry fuer Job #${normalizedJobId} wurde angelegt.`,
life: 3200
});
await loadJobs();
if (retryJobId) {
setExpandedJobId(retryJobId);
}
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Retry fehlgeschlagen',
detail: error?.message || 'Bitte Logs pruefen.',
life: 4200
});
} finally {
setActionBusy(normalizedJobId, false);
}
};
const handleDelete = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
const confirmed = await confirmModal({
message: `Job #${normalizedJobId} wirklich aus der Historie entfernen?`,
header: 'Job loeschen',
icon: 'pi pi-exclamation-triangle',
acceptClassName: 'p-button-danger',
acceptLabel: 'Loeschen',
rejectLabel: 'Abbrechen'
});
if (!confirmed) {
return;
}
setActionBusy(normalizedJobId, true);
try {
await api.deleteJobEntry(normalizedJobId, 'none', { includeRelated: false });
toastRef.current?.show({
severity: 'success',
summary: 'Job geloescht',
detail: `Job #${normalizedJobId} wurde entfernt.`,
life: 2800
});
if (normalizeJobId(expandedJobId) === normalizedJobId) {
setExpandedJobId(null);
}
await loadJobs();
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Loeschen fehlgeschlagen',
detail: error?.message || 'Bitte Logs pruefen.',
life: 4200
});
} finally {
setActionBusy(normalizedJobId, false);
}
};
const handleUploaded = async (uploadedJobId) => {
const normalizedJobId = normalizeJobId(uploadedJobId);
await loadJobs();
if (normalizedJobId) {
setExpandedJobId(normalizedJobId);
}
};
const jobsCardHeader = (
<div className="converter-card-header">
<div className="converter-card-title">
<span>Audiobook Jobs</span>
{activeJobs.length > 0 ? (
<Badge value={activeJobs.length} severity="info" />
) : null}
</div>
<Button
icon={loadingJobs ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
text
rounded
size="small"
onClick={() => void loadJobs()}
disabled={loadingJobs}
aria-label="Jobs neu laden"
/>
</div>
);
return (
<div className="ripper-subpage-content">
<Toast ref={toastRef} position="top-right" />
<Card title="Audiobooks Upload" subTitle="AAX-Dateien importieren und als Audiobook-Job vorbereiten">
<AudiobookUploadPanel
audiobookUpload={audiobookUpload}
onAudiobookUpload={onAudiobookUpload}
onUploaded={handleUploaded}
/>
</Card>
<Card header={jobsCardHeader}>
{activeJobs.length === 0 ? (
<p className="converter-jobs-empty-hint">
<small>Keine aktiven Audiobook-Jobs vorhanden. Fertige Jobs findest du in der Historie.</small>
</p>
) : (
<div className="ripper-job-list">
{activeJobs.map((job) => {
const jobId = normalizeJobId(job?.id);
if (!jobId) {
return null;
}
const pipelineForJob = buildAudiobookPipeline(job, jobProgress[jobId] || null);
const state = String(pipelineForJob?.state || job?.status || '').trim().toUpperCase();
const isExpanded = normalizeJobId(expandedJobId) === jobId;
const busy = actionBusyJobIds.has(jobId);
const title = String(job?.title || job?.detected_title || `Job #${jobId}`).trim();
const progressValue = Number.isFinite(Number(pipelineForJob?.progress))
? Math.max(0, Math.min(100, Number(pipelineForJob.progress)))
: 0;
const showProgress = ['ANALYZING', 'ENCODING'].includes(state) && progressValue > 0;
if (!isExpanded) {
return (
<button
key={jobId}
type="button"
className="ripper-job-row"
onClick={() => setExpandedJobId(jobId)}
>
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
<div className="ripper-job-row-content">
<div className="ripper-job-row-main">
<strong className="ripper-job-title-line">
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>{title}</span>
</strong>
<small>#{jobId}</small>
</div>
<div className="ripper-job-badges">
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
<Tag value="Audiobook" severity="info" />
</div>
{showProgress ? (
<div className="ripper-job-row-progress">
<ProgressBar value={progressValue} showValue={false} />
</div>
) : null}
</div>
</button>
);
}
return (
<div key={jobId} className="ripper-job-expanded">
<div className="ripper-job-expanded-head">
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
<div className="ripper-job-expanded-title">
<strong className="ripper-job-title-line">
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>#{jobId} | {title}</span>
</strong>
<div className="ripper-job-badges">
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
<Tag value="Audiobook" severity="info" />
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button
label="Im Ripper"
icon="pi pi-arrow-right"
severity="secondary"
outlined
onClick={() => navigate('/ripper')}
/>
<Button
label="Einklappen"
icon="pi pi-angle-up"
severity="secondary"
outlined
onClick={() => setExpandedJobId(null)}
disabled={busy}
/>
</div>
</div>
<AudiobookConfigPanel
pipeline={pipelineForJob}
onStart={(config) => void handleAudiobookStart(jobId, config)}
onCancel={() => void handleCancel(jobId)}
onRetry={() => void handleRetry(jobId)}
onDeleteJob={() => void handleDelete(jobId)}
busy={busy}
/>
</div>
);
})}
</div>
)}
</Card>
<Card
title="Audiobook Output Explorer"
subTitle="Read-only Explorer auf dem Audiobook-Ausgabeordner"
>
<AudiobookOutputExplorer refreshToken={explorerRefreshToken} />
</Card>
</div>
);
}
+74 -19
View File
@@ -21,15 +21,37 @@ import otherIndicatorIcon from '../assets/media-other.svg';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation'; import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
import { confirmModal } from '../utils/confirmModal'; import { confirmModal } from '../utils/confirmModal';
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 VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.wmv', '.ts', '.m2ts', '.iso', '.m4v', '.flv', '.webm']); const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.m2ts', '.iso', '.avi', '.mov']);
const TERMINAL_JOB_STATUSES = new Set(['DONE', 'FINISHED', 'ERROR', 'CANCELLED']); 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 = [ const VIDEO_OUTPUT_FORMATS = [
{ label: 'MKV', value: 'mkv' }, { label: 'MKV', value: 'mkv' },
{ label: 'MP4', value: 'mp4' }, { label: 'MP4', value: 'mp4' },
{ label: 'M4V', value: 'm4v' } { 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) { function isAudioEntry(e) {
if (e.detectedMediaType === 'audio') return true; if (e.detectedMediaType === 'audio') return true;
const p = (e.relPath || '').toLowerCase(); const p = (e.relPath || '').toLowerCase();
@@ -117,11 +139,24 @@ export default function ConverterPage() {
const [expandedJobId, setExpandedJobId] = useState(undefined); const [expandedJobId, setExpandedJobId] = useState(undefined);
const [videoUserPresets, setVideoUserPresets] = useState([]); const [videoUserPresets, setVideoUserPresets] = useState([]);
const [videoHbPresets, setVideoHbPresets] = 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 // Entries werden beim Öffnen des Modals gesichert, damit ein außerhalb-Click
// die Selection nicht löscht bevor die Jobs erstellt werden // die Selection nicht löscht bevor die Jobs erstellt werden
const jobEntriesRef = useRef([]); const jobEntriesRef = useRef([]);
const previousJobStatusesRef = useRef(new Map()); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const loadPresets = async () => { const loadPresets = async () => {
@@ -152,6 +187,10 @@ export default function ConverterPage() {
}; };
}, []); }, []);
useEffect(() => {
void loadUploadExtensions();
}, [loadUploadExtensions]);
const loadJobs = useCallback(async () => { const loadJobs = useCallback(async () => {
setLoadingJobs(true); setLoadingJobs(true);
try { try {
@@ -237,7 +276,7 @@ export default function ConverterPage() {
useWebSocket({ useWebSocket({
onMessage: (message) => { onMessage: (message) => {
if (!message?.type || !message?.payload) return; if (!message?.type) return;
if (message.type === 'PIPELINE_PROGRESS') { if (message.type === 'PIPELINE_PROGRESS') {
const payload = message.payload; const payload = message.payload;
@@ -266,6 +305,19 @@ export default function ConverterPage() {
setExplorerRefreshToken((t) => t + 1); 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 ( return (
<div className="dashboard-subpage-content"> <div className="ripper-subpage-content">
<Toast ref={toastRef} position="top-right" /> <Toast ref={toastRef} position="top-right" />
{/* Import-Ordner */} {/* Import-Ordner */}
@@ -890,7 +942,7 @@ export default function ConverterPage() {
<small>Keine aktiven Converter-Jobs vorhanden. Abgeschlossene Jobs findest du in der Historie.</small> <small>Keine aktiven Converter-Jobs vorhanden. Abgeschlossene Jobs findest du in der Historie.</small>
</p> </p>
) : ( ) : (
<div className="dashboard-job-list converter-jobs-list"> <div className="ripper-job-list converter-jobs-list">
{activeJobs.map((job) => { {activeJobs.map((job) => {
const plan = parseConverterPlan(job); const plan = parseConverterPlan(job);
const converterMediaType = String(plan?.converterMediaType || '').trim().toLowerCase(); const converterMediaType = String(plan?.converterMediaType || '').trim().toLowerCase();
@@ -945,7 +997,10 @@ export default function ConverterPage() {
{/* Upload */} {/* Upload */}
<Card title="Datei-Upload" subTitle="Einzelne Dateien oder ganze Ordner (Album) hochladen"> <Card title="Datei-Upload" subTitle="Einzelne Dateien oder ganze Ordner (Album) hochladen">
<ConverterUploadPanel onUploaded={handleUploaded} /> <ConverterUploadPanel
onUploaded={handleUploaded}
allowedExtensions={uploadExtensions}
/>
</Card> </Card>
<JobModeDialog <JobModeDialog
@@ -1164,27 +1219,27 @@ function ConverterVideoJobCard({
return ( return (
<button <button
type="button" type="button"
className="dashboard-job-row" className="ripper-job-row"
onClick={onExpand} onClick={onExpand}
> >
{job?.poster_url && job.poster_url !== 'N/A' ? ( {job?.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={title} className="poster-thumb" /> <img src={job.poster_url} alt={title} className="poster-thumb" />
) : ( ) : (
<div className="poster-thumb dashboard-job-poster-fallback">Kein Poster</div> <div className="poster-thumb ripper-job-poster-fallback">Kein Poster</div>
)} )}
<div className="dashboard-job-row-content"> <div className="ripper-job-row-content">
<div className="dashboard-job-row-main"> <div className="ripper-job-row-main">
<strong className="dashboard-job-title-line"> <strong className="ripper-job-title-line">
<i className="pi pi-video media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} /> <i className="pi pi-video media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>{title}</span> <span>{title}</span>
</strong> </strong>
<small>#{jobId}</small> <small>#{jobId}</small>
</div> </div>
<div className="dashboard-job-badges"> <div className="ripper-job-badges">
<Tag value={getStatusLabel(status, { queued: isQueued })} severity={getStatusSeverity(normalizeStatus(state), { queued: isQueued })} /> <Tag value={getStatusLabel(status, { queued: isQueued })} severity={getStatusSeverity(normalizeStatus(state), { queued: isQueued })} />
</div> </div>
{hasProgress && ( {hasProgress && (
<div className="dashboard-job-row-progress"> <div className="ripper-job-row-progress">
<ProgressBar value={Math.max(0, Math.min(100, progressValue))} showValue={false} /> <ProgressBar value={Math.max(0, Math.min(100, progressValue))} showValue={false} />
</div> </div>
)} )}
@@ -1195,19 +1250,19 @@ function ConverterVideoJobCard({
} }
return ( return (
<div className="dashboard-job-expanded"> <div className="ripper-job-expanded">
<div className="dashboard-job-expanded-head"> <div className="ripper-job-expanded-head">
{job?.poster_url && job.poster_url !== 'N/A' ? ( {job?.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={title} className="poster-thumb" /> <img src={job.poster_url} alt={title} className="poster-thumb" />
) : ( ) : (
<div className="poster-thumb dashboard-job-poster-fallback">Kein Poster</div> <div className="poster-thumb ripper-job-poster-fallback">Kein Poster</div>
)} )}
<div className="dashboard-job-expanded-title"> <div className="ripper-job-expanded-title">
<strong className="dashboard-job-title-line"> <strong className="ripper-job-title-line">
<i className="pi pi-video media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} /> <i className="pi pi-video media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>#{jobId} | {title}</span> <span>#{jobId} | {title}</span>
</strong> </strong>
<div className="dashboard-job-badges"> <div className="ripper-job-badges">
<Tag value={getStatusLabel(status, { queued: isQueued })} severity={getStatusSeverity(normalizeStatus(state), { queued: isQueued })} /> <Tag value={getStatusLabel(status, { queued: isQueued })} severity={getStatusSeverity(normalizeStatus(state), { queued: isQueued })} />
<Tag value="Converter" severity="info" /> <Tag value="Converter" severity="info" />
</div> </div>
+4 -4
View File
@@ -745,7 +745,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!skipConfirm) { if (!skipConfirm) {
const confirmed = await confirmModal({ const confirmed = await confirmModal({
header: 'Review neu starten', 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', acceptLabel: 'Review starten',
rejectLabel: 'Abbrechen' rejectLabel: 'Abbrechen'
}); });
@@ -761,7 +761,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
toastRef.current?.show({ toastRef.current?.show({
severity: 'success', severity: 'success',
summary: 'Review-Neustart', 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 life: 3500
}); });
await load(); await load();
@@ -776,7 +776,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!skipConfirm) { if (!skipConfirm) {
const confirmed = await confirmModal({ const confirmed = await confirmModal({
header: 'CD-Vorprüfung starten', 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', acceptLabel: 'Vorprüfung starten',
rejectLabel: 'Abbrechen' rejectLabel: 'Abbrechen'
}); });
@@ -790,7 +790,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
toastRef.current?.show({ toastRef.current?.show({
severity: 'success', severity: 'success',
summary: 'CD-Vorprüfung gestartet', 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 life: 4000
}); });
await load(); await load();
+357
View File
@@ -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 (
<Card
title="Job Inbox"
subTitle="Zentrale Jobliste mit denselben Datensätzen für Ripper-, Converter- und Audiobook-Sicht."
>
<div className="job-inbox-toolbar">
<div className="job-inbox-filter-row">
{VIEW_FILTERS.map((filter) => {
const isActive = filter.key === viewFilter;
const count = viewCounts[filter.key] ?? 0;
return (
<Button
key={filter.key}
label={`${filter.label} (${count})`}
className={isActive ? 'job-inbox-filter-active' : 'job-inbox-filter'}
outlined={!isActive}
onClick={() => setViewFilter(filter.key)}
size="small"
/>
);
})}
</div>
<div className="job-inbox-filter-row">
{STATUS_FILTERS.map((filter) => {
const isActive = filter.key === statusFilter;
return (
<Button
key={filter.key}
label={filter.label}
className={isActive ? 'job-inbox-filter-active' : 'job-inbox-filter'}
outlined={!isActive}
onClick={() => setStatusFilter(filter.key)}
size="small"
/>
);
})}
<Button
label="Aktualisieren"
icon="pi pi-refresh"
outlined
size="small"
loading={loading}
onClick={() => {
loadJobs().catch(() => null);
}}
/>
</div>
</div>
<div className="job-inbox-search-row">
<InputText
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Suche nach Titel, Status oder IMDB-ID"
className="job-inbox-search-input"
/>
<small>
Letztes Update: {formatUpdatedAt(lastUpdatedAt)} | Treffer: {filteredRows.length}
</small>
</div>
<div className="job-inbox-list">
{loading && filteredRows.length === 0 ? (
<p>Inbox wird geladen ...</p>
) : filteredRows.length === 0 ? (
<p>Keine Jobs für den aktuellen Filter gefunden.</p>
) : (
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 (
<article key={jobId} className="job-inbox-row">
<div className="job-inbox-row-main">
<strong>#{jobId} | {jobTitle}</strong>
<small>
Status: {getStatusLabel(normalizedStatus, { queued: isQueued })}
{' | '}
Aktualisiert: {formatUpdatedAt(job?.updated_at || job?.created_at || null)}
</small>
</div>
<div className="job-inbox-row-tags">
<Tag value={getKindLabel(meta)} severity={getKindSeverity(meta)} />
<Tag
value={getStatusLabel(normalizedStatus, { queued: isQueued })}
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
/>
</div>
<div className="job-inbox-row-actions">
<Button
label={meta.family === 'converter' ? 'Im Converter öffnen' : 'Im Ripper öffnen'}
icon={meta.family === 'converter' ? 'pi pi-external-link' : 'pi pi-arrow-right'}
outlined
size="small"
onClick={() => navigate(targetPath)}
/>
</div>
</article>
);
})
)}
</div>
{filteredRows.length > 0 ? (
<div className="job-inbox-pagination">
<Paginator
first={first}
rows={rows}
totalRecords={filteredRows.length}
rowsPerPageOptions={[25, 50, 100]}
onPageChange={(event) => {
setFirst(event.first);
setRows(event.rows);
}}
template="PrevPageLink PageLinks NextPageLink RowsPerPageDropdown CurrentPageReport"
currentPageReportTemplate="{first} - {last} von {totalRecords}"
/>
</div>
) : null}
</Card>
);
}
@@ -6,7 +6,6 @@ import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag'; import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar'; import { ProgressBar } from 'primereact/progressbar';
import { Dialog } from 'primereact/dialog'; import { Dialog } from 'primereact/dialog';
import { FileUpload } from 'primereact/fileupload';
import { InputNumber } from 'primereact/inputnumber'; import { InputNumber } from 'primereact/inputnumber';
import { InputText } from 'primereact/inputtext'; import { InputText } from 'primereact/inputtext';
import { api } from '../api/client'; import { api } from '../api/client';
@@ -22,11 +21,12 @@ import otherIndicatorIcon from '../assets/media-other.svg';
import audiobookIndicatorIcon from '../assets/media-audiobook.svg'; import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation'; import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
import { confirmModal } from '../utils/confirmModal'; 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 processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING']; 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 activeCdDriveStates = ['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING'];
const dashboardStatuses = new Set([ const ripperStatuses = new Set([
'ANALYZING', 'ANALYZING',
'METADATA_SELECTION', 'METADATA_SELECTION',
'WAITING_FOR_USER_DECISION', 'WAITING_FOR_USER_DECISION',
@@ -479,6 +479,11 @@ function getAnalyzeContext(job) {
} }
function resolveMediaType(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 encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const candidates = [ const candidates = [
job?.mediaType, job?.mediaType,
@@ -533,7 +538,7 @@ function resolveMediaType(job) {
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') { if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
return 'audiobook'; return 'audiobook';
} }
return 'other'; return centralMediaType || 'other';
} }
function mediaIndicatorMeta(job) { function mediaIndicatorMeta(job) {
@@ -906,13 +911,11 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
}; };
} }
export default function DashboardPage({ export default function RipperPage({
pipeline, pipeline,
hardwareMonitoring, hardwareMonitoring,
lastDiscEvent, lastDiscEvent,
refreshPipeline, refreshPipeline,
audiobookUpload,
onAudiobookUpload,
jobsRefreshToken, jobsRefreshToken,
pendingExpandedJobId, pendingExpandedJobId,
onPendingExpandedJobHandled, onPendingExpandedJobHandled,
@@ -957,9 +960,8 @@ export default function DashboardPage({
const [runtimeActionBusyKeys, setRuntimeActionBusyKeys] = useState(() => new Set()); const [runtimeActionBusyKeys, setRuntimeActionBusyKeys] = useState(() => new Set());
const [runtimeRecentClearing, setRuntimeRecentClearing] = useState(false); const [runtimeRecentClearing, setRuntimeRecentClearing] = useState(false);
const [jobsLoading, setJobsLoading] = useState(false); const [jobsLoading, setJobsLoading] = useState(false);
const [dashboardJobs, setDashboardJobs] = useState([]); const [ripperJobs, setRipperJobs] = useState([]);
const [expandedJobId, setExpandedJobId] = useState(undefined); const [expandedJobId, setExpandedJobId] = useState(undefined);
const [audiobookUploadFile, setAudiobookUploadFile] = useState(null);
const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null }); const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null });
const [activationBytesInput, setActivationBytesInput] = useState(''); const [activationBytesInput, setActivationBytesInput] = useState('');
const [activationBytesBusy, setActivationBytesBusy] = useState(false); const [activationBytesBusy, setActivationBytesBusy] = useState(false);
@@ -972,8 +974,6 @@ export default function DashboardPage({
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] }); const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30); const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
const toastRef = useRef(null); const toastRef = useRef(null);
const audiobookFileUploadRef = useRef(null);
const [audiobookUploadStatusVisible, setAudiobookUploadStatusVisible] = useState(false);
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase(); const state = String(pipeline?.state || 'IDLE').trim().toUpperCase();
const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId); const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId);
@@ -987,7 +987,7 @@ export default function DashboardPage({
[pipeline?.cdDrives] [pipeline?.cdDrives]
); );
// Detects when a background job reaches a terminal interactive state via PIPELINE_PROGRESS // 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 || {}) const jobProgressInteractiveKey = useMemo(() => Object.entries(pipeline?.jobProgress || {})
.filter(([, v]) => { .filter(([, v]) => {
const s = String(v?.state || '').toUpperCase(); const s = String(v?.state || '').toUpperCase();
@@ -1038,42 +1038,15 @@ export default function DashboardPage({
}, [storageMetrics]); }, [storageMetrics]);
const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : []; const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : [];
const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : []; const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : [];
const audiobookUploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase(); const isRipperMainRoute = location.pathname === '/' || location.pathname === '/ripper';
const audiobookUploadBusy = audiobookUploadPhase === 'uploading' || audiobookUploadPhase === 'processing'; const isSubpageRoute = !isRipperMainRoute;
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 loadDashboardJobs = async () => { const loadRipperJobs = async () => {
setJobsLoading(true); setJobsLoading(true);
try { try {
const [jobsResponse, queueResponse] = await Promise.allSettled([ const [jobsResponse, queueResponse] = await Promise.allSettled([
api.getJobs({ api.getJobs({
statuses: Array.from(dashboardStatuses), statuses: Array.from(ripperStatuses),
limit: 160, limit: 160,
lite: true lite: true
}), }),
@@ -1085,24 +1058,12 @@ export default function DashboardPage({
if (queueResponse.status === 'fulfilled') { if (queueResponse.status === 'fulfilled') {
setQueueState(normalizeQueue(queueResponse.value?.queue)); setQueueState(normalizeQueue(queueResponse.value?.queue));
} }
const shouldDisplayOnDashboard = (job) => { const shouldDisplayOnRipper = (job) => {
const rawMediaType = String(job?.media_type || '').trim().toLowerCase(); if (isConverterJob(job)) {
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) {
return false; return false;
} }
const normalizedStatus = String(job?.status || '').trim().toUpperCase(); const normalizedStatus = String(job?.status || '').trim().toUpperCase();
if (!dashboardStatuses.has(normalizedStatus)) { if (!ripperStatuses.has(normalizedStatus)) {
return false; return false;
} }
if (normalizedStatus !== 'CANCELLED') { if (normalizedStatus !== 'CANCELLED') {
@@ -1112,7 +1073,7 @@ export default function DashboardPage({
return !hiddenCancelledReviewOrigins.has(cancelledOrigin); return !hiddenCancelledReviewOrigins.has(cancelledOrigin);
}; };
const next = allJobs const next = allJobs
.filter((job) => shouldDisplayOnDashboard(job)) .filter((job) => shouldDisplayOnRipper(job))
.sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0)); .sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0));
const pinnedJobIds = new Set(); const pinnedJobIds = new Set();
@@ -1136,7 +1097,7 @@ export default function DashboardPage({
continue; continue;
} }
const job = result.value?.job; const job = result.value?.job;
if (job && shouldDisplayOnDashboard(job)) { if (job && shouldDisplayOnRipper(job)) {
next.unshift(job); next.unshift(job);
} }
} }
@@ -1153,7 +1114,7 @@ export default function DashboardPage({
deduped.push(job); deduped.push(job);
} }
setDashboardJobs(deduped); setRipperJobs(deduped);
// Prüfen ob Audiobook-Jobs auf Activation Bytes warten // Prüfen ob Audiobook-Jobs auf Activation Bytes warten
try { try {
@@ -1174,7 +1135,7 @@ export default function DashboardPage({
// ignorieren // ignorieren
} }
} catch (_error) { } catch (_error) {
setDashboardJobs([]); setRipperJobs([]);
} finally { } finally {
setJobsLoading(false); setJobsLoading(false);
} }
@@ -1245,7 +1206,7 @@ export default function DashboardPage({
}, [pipeline?.queue]); }, [pipeline?.queue]);
useEffect(() => { useEffect(() => {
void loadDashboardJobs(); void loadRipperJobs();
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, cdDriveLifecycleKey, jobsRefreshToken, jobProgressInteractiveKey]); }, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, cdDriveLifecycleKey, jobsRefreshToken, jobProgressInteractiveKey]);
useEffect(() => { useEffect(() => {
@@ -1259,13 +1220,13 @@ export default function DashboardPage({
if (!requestedJobId) { if (!requestedJobId) {
return; return;
} }
const hasRequestedJob = dashboardJobs.some((job) => normalizeJobId(job?.id) === requestedJobId); const hasRequestedJob = ripperJobs.some((job) => normalizeJobId(job?.id) === requestedJobId);
if (!hasRequestedJob) { if (!hasRequestedJob) {
return; return;
} }
setExpandedJobId(requestedJobId); setExpandedJobId(requestedJobId);
onPendingExpandedJobHandled?.(requestedJobId); onPendingExpandedJobHandled?.(requestedJobId);
}, [pendingExpandedJobId, dashboardJobs, onPendingExpandedJobHandled]); }, [pendingExpandedJobId, ripperJobs, onPendingExpandedJobHandled]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -1308,7 +1269,7 @@ export default function DashboardPage({
useEffect(() => { useEffect(() => {
const normalizedExpanded = normalizeJobId(expandedJobId); const normalizedExpanded = normalizeJobId(expandedJobId);
const hasExpanded = dashboardJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded); const hasExpanded = ripperJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
if (hasExpanded) { if (hasExpanded) {
return; return;
} }
@@ -1318,30 +1279,16 @@ export default function DashboardPage({
return; return;
} }
if (currentPipelineJobId && dashboardJobs.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) { if (currentPipelineJobId && ripperJobs.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
setExpandedJobId(currentPipelineJobId); setExpandedJobId(currentPipelineJobId);
return; return;
} }
setExpandedJobId(normalizeJobId(dashboardJobs[0]?.id)); setExpandedJobId(normalizeJobId(ripperJobs[0]?.id));
}, [dashboardJobs, expandedJobId, currentPipelineJobId]); }, [ripperJobs, 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]);
const pipelineByJobId = useMemo(() => { const pipelineByJobId = useMemo(() => {
const map = new Map(); const map = new Map();
for (const job of dashboardJobs) { for (const job of ripperJobs) {
const id = normalizeJobId(job?.id); const id = normalizeJobId(job?.id);
if (!id) { if (!id) {
continue; continue;
@@ -1349,14 +1296,14 @@ export default function DashboardPage({
map.set(id, buildPipelineFromJob(job, pipeline, currentPipelineJobId)); map.set(id, buildPipelineFromJob(job, pipeline, currentPipelineJobId));
} }
return map; return map;
}, [dashboardJobs, pipeline, currentPipelineJobId]); }, [ripperJobs, pipeline, currentPipelineJobId]);
const buildMetadataContextForJob = (jobId) => { const buildMetadataContextForJob = (jobId) => {
const normalizedJobId = normalizeJobId(jobId); const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) { if (!normalizedJobId) {
return null; 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 pipelineForJob = pipelineByJobId.get(normalizedJobId) || null;
const context = pipelineForJob?.context && typeof pipelineForJob.context === 'object' const context = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
? pipelineForJob.context ? 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); const normalized = normalizeStatus(job?.status);
return normalized === 'METADATA_SELECTION' || normalized === 'WAITING_FOR_USER_DECISION'; return normalized === 'METADATA_SELECTION' || normalized === 'WAITING_FOR_USER_DECISION';
}); });
@@ -1409,7 +1356,7 @@ export default function DashboardPage({
return null; return null;
} }
return buildMetadataContextForJob(pendingJob.id); return buildMetadataContextForJob(pendingJob.id);
}, [pipeline, dashboardJobs, pipelineByJobId]); }, [pipeline, ripperJobs, pipelineByJobId]);
const effectiveMetadataDialogContext = metadataDialogContext const effectiveMetadataDialogContext = metadataDialogContext
|| defaultMetadataDialogContext || defaultMetadataDialogContext
@@ -1452,7 +1399,7 @@ export default function DashboardPage({
try { try {
const response = await api.analyzeDisc(); const response = await api.analyzeDisc();
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
const analyzedJobId = normalizeJobId(response?.result?.jobId); const analyzedJobId = normalizeJobId(response?.result?.jobId);
if (analyzedJobId) { if (analyzedJobId) {
setMetadataDialogContext({ setMetadataDialogContext({
@@ -1486,7 +1433,7 @@ export default function DashboardPage({
try { try {
const response = await api.analyzeDisc(devicePath); const response = await api.analyzeDisc(devicePath);
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
const analyzedJobId = normalizeJobId(response?.result?.jobId); const analyzedJobId = normalizeJobId(response?.result?.jobId);
if (analyzedJobId) { if (analyzedJobId) {
setMetadataDialogContext({ setMetadataDialogContext({
@@ -1553,7 +1500,7 @@ export default function DashboardPage({
life: 2800 life: 2800
}); });
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
} catch (error) { } catch (error) {
showError(error); showError(error);
} finally { } finally {
@@ -1595,7 +1542,7 @@ export default function DashboardPage({
const handleCancel = async (jobId = null, jobState = null) => { const handleCancel = async (jobId = null, jobState = null) => {
const cancelledJobId = normalizeJobId(jobId) || currentPipelineJobId; 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( const cancelledState = String(
jobState jobState
|| cancelledJob?.status || cancelledJob?.status
@@ -1607,7 +1554,7 @@ export default function DashboardPage({
try { try {
await api.cancelPipeline(cancelledJobId); await api.cancelPipeline(cancelledJobId);
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
let latestCancelledJob = null; let latestCancelledJob = null;
const fetchLatestCancelledJob = async () => { const fetchLatestCancelledJob = async () => {
if (!cancelledJobId) { if (!cancelledJobId) {
@@ -1701,7 +1648,7 @@ export default function DashboardPage({
life: 4000 life: 4000
}); });
} }
await loadDashboardJobs(); await loadRipperJobs();
await refreshPipeline(); await refreshPipeline();
setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null }); setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null });
} catch (error) { } catch (error) {
@@ -1718,7 +1665,7 @@ export default function DashboardPage({
} }
const startOptions = options && typeof options === 'object' ? options : {}; 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); const mediaType = resolveMediaType(startJobRow);
setJobBusy(normalizedJobId, true); setJobBusy(normalizedJobId, true);
try { try {
@@ -1753,7 +1700,7 @@ export default function DashboardPage({
: await api.startJob(normalizedJobId); : await api.startJob(normalizedJobId);
const result = getQueueActionResult(response); const result = getQueueActionResult(response);
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
if (result.queued) { if (result.queued) {
showQueuedToast(toastRef, 'Start', result); showQueuedToast(toastRef, 'Start', result);
} else { } 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 handleSaveActivationBytes = async () => {
const { checksum, jobId } = activationBytesDialog; const { checksum, jobId } = activationBytesDialog;
const bytes = activationBytesInput.trim().toLowerCase(); 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.', detail: jobId ? `Job #${jobId} kann jetzt gestartet werden.` : 'Bytes wurden lokal gespeichert.',
life: 4000 life: 4000
}); });
await loadDashboardJobs(); await loadRipperJobs();
} catch (error) { } catch (error) {
showError(error); showError(error);
} finally { } finally {
@@ -1833,7 +1754,7 @@ export default function DashboardPage({
const response = await api.startAudiobook(normalizedJobId, audiobookConfig || {}); const response = await api.startAudiobook(normalizedJobId, audiobookConfig || {});
const result = getQueueActionResult(response); const result = getQueueActionResult(response);
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
if (result.queued) { if (result.queued) {
showQueuedToast(toastRef, 'Audiobook', result); showQueuedToast(toastRef, 'Audiobook', result);
} else { } else {
@@ -1870,7 +1791,7 @@ export default function DashboardPage({
} }
await api.confirmEncodeReview(jobId, payload); await api.confirmEncodeReview(jobId, payload);
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
setExpandedJobId(normalizedJobId); setExpandedJobId(normalizedJobId);
} catch (error) { } catch (error) {
showError(error); showError(error);
@@ -1888,7 +1809,7 @@ export default function DashboardPage({
selectedPlaylist: selectedPlaylist || null selectedPlaylist: selectedPlaylist || null
}); });
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
setExpandedJobId(normalizedJobId); setExpandedJobId(normalizedJobId);
} catch (error) { } catch (error) {
showError(error); showError(error);
@@ -1906,7 +1827,7 @@ export default function DashboardPage({
selectedHandBrakeTitleId: selectedHandBrakeTitleId || null selectedHandBrakeTitleId: selectedHandBrakeTitleId || null
}); });
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
setExpandedJobId(normalizedJobId); setExpandedJobId(normalizedJobId);
} catch (error) { } catch (error) {
showError(error); showError(error);
@@ -1923,7 +1844,7 @@ export default function DashboardPage({
const result = getQueueActionResult(response); const result = getQueueActionResult(response);
const retryJobId = normalizeJobId(result?.jobId) || normalizedJobId; const retryJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
if (result.queued) { if (result.queued) {
showQueuedToast(toastRef, 'Retry', result); showQueuedToast(toastRef, 'Retry', result);
} else { } else {
@@ -1936,12 +1857,12 @@ export default function DashboardPage({
} }
}; };
const handleDeleteDashboardJob = async (jobId) => { const handleDeleteRipperJob = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId); const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) { if (!normalizedJobId) {
return; 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 title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim();
const confirmed = await confirmModal({ const confirmed = await confirmModal({
header: 'Job löschen', header: 'Job löschen',
@@ -1978,7 +1899,7 @@ export default function DashboardPage({
keepDetectedDevice: false keepDetectedDevice: false
}); });
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
setExpandedJobId((prev) => (normalizeJobId(prev) === normalizedJobId ? null : prev)); setExpandedJobId((prev) => (normalizeJobId(prev) === normalizedJobId ? null : prev));
toastRef.current?.show({ toastRef.current?.show({
severity: 'success', severity: 'success',
@@ -1994,7 +1915,7 @@ export default function DashboardPage({
}; };
const handleRestartEncodeWithLastSettings = async (jobId) => { 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}`; const title = job?.title || job?.detected_title || `Job #${jobId}`;
if (job?.encodeSuccess) { if (job?.encodeSuccess) {
const confirmed = await confirmModal({ const confirmed = await confirmModal({
@@ -2017,7 +1938,7 @@ export default function DashboardPage({
const result = getQueueActionResult(response); const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId; const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
if (result.queued) { if (result.queued) {
showQueuedToast(toastRef, 'Encode-Neustart', result); showQueuedToast(toastRef, 'Encode-Neustart', result);
} else { } else {
@@ -2042,7 +1963,7 @@ export default function DashboardPage({
const result = getQueueActionResult(response); const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId; const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
setExpandedJobId(replacementJobId); setExpandedJobId(replacementJobId);
} catch (error) { } catch (error) {
showError(error); showError(error);
@@ -2063,7 +1984,7 @@ export default function DashboardPage({
const result = getQueueActionResult(response); const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId; const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
if (result.queued) { if (result.queued) {
showQueuedToast(toastRef, 'CD-Vorprüfung', result); showQueuedToast(toastRef, 'CD-Vorprüfung', result);
} else { } else {
@@ -2213,7 +2134,7 @@ export default function DashboardPage({
await api.selectMetadata(payload); await api.selectMetadata(payload);
} }
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
setMetadataDialogVisible(false); setMetadataDialogVisible(false);
setMetadataDialogContext(null); setMetadataDialogContext(null);
setMetadataDialogReassignMode(false); setMetadataDialogReassignMode(false);
@@ -2294,7 +2215,7 @@ export default function DashboardPage({
try { try {
await api.selectCdMetadata(payload); await api.selectCdMetadata(payload);
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
setCdMetadataDialogVisible(false); setCdMetadataDialogVisible(false);
setCdMetadataDialogContext(null); setCdMetadataDialogContext(null);
} catch (error) { } catch (error) {
@@ -2320,7 +2241,7 @@ export default function DashboardPage({
} }
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId; const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadRipperJobs();
if (replacementJobId) { if (replacementJobId) {
setExpandedJobId(replacementJobId); setExpandedJobId(replacementJobId);
} }
@@ -2485,7 +2406,7 @@ export default function DashboardPage({
try { try {
const response = normalizedAction === 'next-step' const response = normalizedAction === 'next-step'
? await api.requestRuntimeNextStep(activityId) ? await api.requestRuntimeNextStep(activityId)
: await api.cancelRuntimeActivity(activityId, { reason: 'Benutzerabbruch via Dashboard' }); : await api.cancelRuntimeActivity(activityId, { reason: 'Benutzerabbruch via Ripper' });
if (response?.snapshot) { if (response?.snapshot) {
setRuntimeActivities(normalizeRuntimeActivitiesPayload(response.snapshot)); setRuntimeActivities(normalizeRuntimeActivitiesPayload(response.snapshot));
} else { } else {
@@ -2564,77 +2485,8 @@ export default function DashboardPage({
<div className="page-grid"> <div className="page-grid">
<Toast ref={toastRef} /> <Toast ref={toastRef} />
<div className="dashboard-3col-grid"> <div className="ripper-3col-grid">
<div className="dashboard-col dashboard-col-left"> <div className="ripper-col ripper-col-left">
<Card title="Audiobook Upload">
<FileUpload
ref={audiobookFileUploadRef}
accept=".aax"
maxFileSize={10737418240}
customUpload
uploadHandler={() => 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) => (
<div className="aax-file-item">
<i className="pi pi-headphones aax-file-icon" />
<div className="aax-file-info">
<span className="aax-file-name" title={file.name}>{file.name}</span>
<small>{options.formatSize}</small>
</div>
<Button
icon="pi pi-times"
text
rounded
severity="danger"
size="small"
onClick={options.onRemove}
disabled={audiobookUploadBusy}
/>
</div>
)}
emptyTemplate={() => (
<div className="aax-drop-zone">
<i className="pi pi-headphones aax-drop-icon" />
<p>AAX-Datei hier ablegen</p>
<small>oder oben Auswählen" klicken</small>
</div>
)}
/>
{audiobookUploadStatusVisible ? (
<div className={`audiobook-upload-status tone-${audiobookUploadStatusTone}`}>
<div className="audiobook-upload-status-head">
<strong>{audiobookUploadStatusLabel}</strong>
<Tag value={audiobookUploadStatusLabel} severity={audiobookUploadStatusTone} />
</div>
{audiobookUpload?.statusText ? <small>{audiobookUpload.statusText}</small> : null}
{audiobookUploadFileName ? (
<small className="audiobook-upload-file" title={audiobookUploadFileName}>
Datei: {audiobookUploadFileName}
</small>
) : null}
<div
className="dashboard-job-row-progress audiobook-upload-progress"
aria-label={`Audiobook Upload ${Math.round(audiobookUploadProgress)} Prozent`}
>
<ProgressBar value={audiobookUploadProgress} showValue={false} />
<small>
{audiobookUploadPhase === 'processing'
? '100% | Upload fertig, Job wird vorbereitet ...'
: audiobookUploadTotalBytes > 0
? `${Math.round(audiobookUploadProgress)}% | ${formatBytes(audiobookUploadLoadedBytes)} / ${formatBytes(audiobookUploadTotalBytes)}`
: `${Math.round(audiobookUploadProgress)}%`}
</small>
</div>
</div>
) : null}
</Card>
<Card title="Disk-Information"> <Card title="Disk-Information">
{/* Per-drive list */} {/* Per-drive list */}
{allDrives.length > 0 ? ( {allDrives.length > 0 ? (
@@ -2888,20 +2740,20 @@ export default function DashboardPage({
</Card> </Card>
</div> </div>
<div className="dashboard-col dashboard-col-center"> <div className="ripper-col ripper-col-center">
{isSubpageRoute ? ( {isSubpageRoute ? (
<div className="dashboard-subpage-content"> <div className="ripper-subpage-content">
{children} {children}
</div> </div>
) : ( ) : (
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs"> <Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
{jobsLoading && dashboardJobs.length === 0 ? ( {jobsLoading && ripperJobs.length === 0 ? (
<p>Jobs werden geladen ...</p> <p>Jobs werden geladen ...</p>
) : dashboardJobs.length === 0 ? ( ) : ripperJobs.length === 0 ? (
<p>Keine relevanten Jobs im Dashboard (aktive/fortsetzbare Status).</p> <p>Keine relevanten Jobs im Ripper (aktive/fortsetzbare Status).</p>
) : ( ) : (
<div className="dashboard-job-list"> <div className="ripper-job-list">
{dashboardJobs.map((job) => { {ripperJobs.map((job) => {
const jobId = normalizeJobId(job?.id); const jobId = normalizeJobId(job?.id);
if (!jobId) { if (!jobId) {
return null; return null;
@@ -2945,15 +2797,15 @@ export default function DashboardPage({
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0; const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
if (isExpanded) { if (isExpanded) {
return ( return (
<div key={jobId} className="dashboard-job-expanded"> <div key={jobId} className="ripper-job-expanded">
<div className="dashboard-job-expanded-head"> <div className="ripper-job-expanded-head">
{(job?.poster_url && job.poster_url !== 'N/A') ? ( {(job?.poster_url && job.poster_url !== 'N/A') ? (
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" /> <img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
) : ( ) : (
<div className="poster-thumb dashboard-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div> <div className="poster-thumb ripper-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
)} )}
<div className="dashboard-job-expanded-title"> <div className="ripper-job-expanded-title">
<strong className="dashboard-job-title-line"> <strong className="ripper-job-title-line">
<img <img
src={mediaIndicator.src} src={mediaIndicator.src}
alt={mediaIndicator.alt} alt={mediaIndicator.alt}
@@ -2962,7 +2814,7 @@ export default function DashboardPage({
/> />
<span>#{jobId} | {jobTitle}</span> <span>#{jobId} | {jobTitle}</span>
</strong> </strong>
<div className="dashboard-job-badges"> <div className="ripper-job-badges">
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} /> <Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null} {isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null} {isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
@@ -2992,7 +2844,7 @@ export default function DashboardPage({
onCancel={() => handleCancel(jobId, jobState)} onCancel={() => handleCancel(jobId, jobState)}
onRetry={() => handleRetry(jobId)} onRetry={() => handleRetry(jobId)}
onRestartReview={() => handleRestartCdReviewFromRaw(jobId)} onRestartReview={() => handleRestartCdReviewFromRaw(jobId)}
onDeleteJob={() => handleDeleteDashboardJob(jobId)} onDeleteJob={() => handleDeleteRipperJob(jobId)}
onOpenMetadata={() => { onOpenMetadata={() => {
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object' const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
? pipelineForJob.context ? pipelineForJob.context
@@ -3024,7 +2876,7 @@ export default function DashboardPage({
onStart={(config) => handleAudiobookStart(jobId, config)} onStart={(config) => handleAudiobookStart(jobId, config)}
onCancel={() => handleCancel(jobId, jobState)} onCancel={() => handleCancel(jobId, jobState)}
onRetry={() => handleRetry(jobId)} onRetry={() => handleRetry(jobId)}
onDeleteJob={() => handleDeleteDashboardJob(jobId)} onDeleteJob={() => handleDeleteRipperJob(jobId)}
busy={busyJobIds.has(jobId) || needsBytes} busy={busyJobIds.has(jobId) || needsBytes}
/> />
</> </>
@@ -3048,7 +2900,7 @@ export default function DashboardPage({
onSelectHandBrakeTitle={handleSelectHandBrakeTitle} onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
onCancel={handleCancel} onCancel={handleCancel}
onRetry={handleRetry} onRetry={handleRetry}
onDeleteJob={handleDeleteDashboardJob} onDeleteJob={handleDeleteRipperJob}
onRemoveFromQueue={handleRemoveQueuedJob} onRemoveFromQueue={handleRemoveQueuedJob}
isQueued={isQueued} isQueued={isQueued}
busy={busyJobIds.has(jobId)} busy={busyJobIds.has(jobId)}
@@ -3062,17 +2914,17 @@ export default function DashboardPage({
<button <button
key={jobId} key={jobId}
type="button" type="button"
className="dashboard-job-row" className="ripper-job-row"
onClick={() => setExpandedJobId(jobId)} onClick={() => setExpandedJobId(jobId)}
> >
{job?.poster_url && job.poster_url !== 'N/A' ? ( {job?.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" /> <img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
) : ( ) : (
<div className="poster-thumb dashboard-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div> <div className="poster-thumb ripper-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
)} )}
<div className="dashboard-job-row-content"> <div className="ripper-job-row-content">
<div className="dashboard-job-row-main"> <div className="ripper-job-row-main">
<strong className="dashboard-job-title-line"> <strong className="ripper-job-title-line">
<img <img
src={mediaIndicator.src} src={mediaIndicator.src}
alt={mediaIndicator.alt} alt={mediaIndicator.alt}
@@ -3095,7 +2947,7 @@ export default function DashboardPage({
)} )}
</small> </small>
</div> </div>
<div className="dashboard-job-badges"> <div className="ripper-job-badges">
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} /> <Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null} {isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null} {isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
@@ -3104,7 +2956,7 @@ export default function DashboardPage({
: null} : null}
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} /> <JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
</div> </div>
<div className="dashboard-job-row-progress" aria-label={`Job Fortschritt ${progressLabel}`}> <div className="ripper-job-row-progress" aria-label={`Job Fortschritt ${progressLabel}`}>
<ProgressBar value={clampedProgress} showValue={false} /> <ProgressBar value={clampedProgress} showValue={false} />
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}</small> <small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}</small>
</div> </div>
@@ -3119,7 +2971,7 @@ export default function DashboardPage({
)} )}
</div> </div>
<div className="dashboard-col dashboard-col-right"> <div className="ripper-col ripper-col-right">
<Card title="Job Queue" subTitle="Elemente können per Drag-and-Drop umsortiert werden."> <Card title="Job Queue" subTitle="Elemente können per Drag-and-Drop umsortiert werden.">
<div className="pipeline-queue-meta"> <div className="pipeline-queue-meta">
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" /> <Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
@@ -3128,12 +2980,12 @@ export default function DashboardPage({
{queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio/CD-Jobs überspringen die Film-Queue-Reihenfolge" />} {queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio/CD-Jobs überspringen die Film-Queue-Reihenfolge" />}
<Tag value={`Film laufend: ${queueState?.runningCount || 0}`} severity={(queueState?.runningCount || 0) > 0 ? 'warning' : 'success'} /> <Tag value={`Film laufend: ${queueState?.runningCount || 0}`} severity={(queueState?.runningCount || 0) > 0 ? 'warning' : 'success'} />
<Tag value={`Audio/CD laufend: ${queueState?.runningCdCount || 0}`} severity={(queueState?.runningCdCount || 0) > 0 ? 'warning' : 'success'} /> <Tag value={`Audio/CD laufend: ${queueState?.runningCdCount || 0}`} severity={(queueState?.runningCdCount || 0) > 0 ? 'warning' : 'success'} />
<Tag value={`Idle: ${queueState?.idleCount || 0}`} severity={(queueState?.idleCount || 0) > 0 ? 'info' : 'success'} />
<Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} /> <Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} />
<Tag value={`Idle: ${queueState?.idleCount || 0}`} severity={(queueState?.idleCount || 0) > 0 ? 'info' : 'success'} />
</div> </div>
<div className="pipeline-queue-grid"> <div className="pipeline-queue-grid">
<div className="pipeline-queue-col"> <div className="pipeline-queue-col queue-col-running">
<h4>Laufende Jobs</h4> <h4>Laufende Jobs</h4>
{queueRunningJobs.length === 0 ? ( {queueRunningJobs.length === 0 ? (
<small>Keine laufenden Jobs.</small> <small>Keine laufenden Jobs.</small>
@@ -3177,7 +3029,7 @@ export default function DashboardPage({
}) })
)} )}
</div> </div>
<div className="pipeline-queue-col"> <div className="pipeline-queue-col queue-col-idle">
<h4>Idle</h4> <h4>Idle</h4>
{queueIdleJobs.length === 0 ? ( {queueIdleJobs.length === 0 ? (
<small>Keine Idle-Jobs.</small> <small>Keine Idle-Jobs.</small>
@@ -3215,7 +3067,7 @@ export default function DashboardPage({
}) })
)} )}
</div> </div>
<div className="pipeline-queue-col"> <div className="pipeline-queue-col queue-col-queued">
<div className="pipeline-queue-col-header"> <div className="pipeline-queue-col-header">
<h4>Warteschlange</h4> <h4>Warteschlange</h4>
<button <button
@@ -3489,9 +3341,9 @@ export default function DashboardPage({
</div> </div>
)} )}
<div className="dashboard-downloads-row"> <div className="ripper-downloads-row">
<h4>ZIP-Downloads</h4> <h4>ZIP-Downloads</h4>
<div className="dashboard-downloads-meta"> <div className="ripper-downloads-meta">
{downloadSummary?.activeCount > 0 ? ( {downloadSummary?.activeCount > 0 ? (
<Tag icon="pi pi-spinner" value={`${downloadSummary.activeCount} aktiv`} severity="warning" /> <Tag icon="pi pi-spinner" value={`${downloadSummary.activeCount} aktiv`} severity="warning" />
) : downloadSummary?.totalCount > 0 ? ( ) : downloadSummary?.totalCount > 0 ? (
+186 -40
View File
@@ -13,7 +13,7 @@
--rip-muted: #6a4d38; --rip-muted: #6a4d38;
--rip-panel: #fffaf1; --rip-panel: #fffaf1;
--rip-panel-soft: #fdf5e7; --rip-panel-soft: #fdf5e7;
--dashboard-side-width: 20rem; --ripper-side-width: 20rem;
/* PrimeReact theme tokens */ /* PrimeReact theme tokens */
--primary-color: var(--rip-brown-600); --primary-color: var(--rip-brown-600);
@@ -318,22 +318,22 @@ body {
min-width: 0; min-width: 0;
} }
.dashboard-3col-grid { .ripper-3col-grid {
display: grid; display: grid;
width: 100%; width: 100%;
grid-template-columns: var(--dashboard-side-width) minmax(0, 1fr) var(--dashboard-side-width); grid-template-columns: var(--ripper-side-width) minmax(0, 1fr) var(--ripper-side-width);
gap: 1rem; gap: 1rem;
align-items: start; align-items: start;
} }
.dashboard-col { .ripper-col {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
min-width: 0; min-width: 0;
} }
.dashboard-subpage-content { .ripper-subpage-content {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
display: flex; display: flex;
@@ -341,21 +341,121 @@ body {
gap: 1rem; gap: 1rem;
} }
.dashboard-subpage-content > * { .ripper-subpage-content > * {
min-width: 0; min-width: 0;
} }
.job-inbox-toolbar {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 0.85rem;
}
.job-inbox-filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.job-inbox-filter.p-button,
.job-inbox-filter-active.p-button {
border-radius: 999px;
}
.job-inbox-search-row {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.job-inbox-search-input {
width: min(40rem, 100%);
}
.job-inbox-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.job-inbox-row {
display: flex;
align-items: center;
gap: 0.9rem;
border: 1px solid var(--surface-border);
border-radius: 12px;
padding: 0.75rem 0.85rem;
background: var(--surface-card);
}
.job-inbox-row-main {
display: flex;
flex-direction: column;
gap: 0.22rem;
min-width: 0;
flex: 1 1 auto;
}
.job-inbox-row-main strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-inbox-row-main small {
color: var(--text-color-secondary);
}
.job-inbox-row-tags {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.job-inbox-row-actions {
display: flex;
justify-content: flex-end;
flex: 0 0 auto;
}
.job-inbox-pagination {
margin-top: 1rem;
}
.job-inbox-pagination .p-paginator {
justify-content: flex-end;
}
@media (max-width: 900px) {
.job-inbox-row {
flex-direction: column;
align-items: flex-start;
}
.job-inbox-row-tags,
.job-inbox-row-actions {
justify-content: flex-start;
}
.job-inbox-pagination .p-paginator {
justify-content: flex-start;
}
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.dashboard-3col-grid { .ripper-3col-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
} }
.dashboard-col-right { .ripper-col-right {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.dashboard-3col-grid { .ripper-3col-grid {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
} }
@@ -436,7 +536,7 @@ body {
text-align: right; text-align: right;
} }
.dashboard-drive-state { .ripper-drive-state {
margin: 0.5rem 0; margin: 0.5rem 0;
} }
@@ -812,6 +912,18 @@ body {
gap: 0.45rem; gap: 0.45rem;
} }
.pipeline-queue-col.queue-col-running {
order: 1;
}
.pipeline-queue-col.queue-col-queued {
order: 2;
}
.pipeline-queue-col.queue-col-idle {
order: 3;
}
.pipeline-queue-col h4 { .pipeline-queue-col h4 {
margin: 0; margin: 0;
} }
@@ -1164,12 +1276,12 @@ body {
height: 1.9rem; height: 1.9rem;
} }
.dashboard-job-list { .ripper-job-list {
display: grid; display: grid;
gap: 0.6rem; gap: 0.6rem;
} }
.dashboard-job-row { .ripper-job-row {
width: 100%; width: 100%;
border: 1px solid var(--rip-border); border: 1px solid var(--rip-border);
border-radius: 0.55rem; border-radius: 0.55rem;
@@ -1184,12 +1296,12 @@ body {
cursor: pointer; cursor: pointer;
} }
.dashboard-job-row:hover { .ripper-job-row:hover {
border-color: var(--rip-brown-600); border-color: var(--rip-brown-600);
background: #fbf0df; background: #fbf0df;
} }
.dashboard-job-row-content { .ripper-job-row-content {
min-width: 0; min-width: 0;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -1197,27 +1309,27 @@ body {
align-items: start; align-items: start;
} }
.dashboard-job-row-main { .ripper-job-row-main {
display: grid; display: grid;
gap: 0.3rem; gap: 0.3rem;
min-width: 0; min-width: 0;
} }
.dashboard-job-row-main strong, .ripper-job-row-main strong,
.dashboard-job-row-main small { .ripper-job-row-main small {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.dashboard-job-title-line { .ripper-job-title-line {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
min-width: 0; min-width: 0;
} }
.dashboard-job-title-line > span { .ripper-job-title-line > span {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -1231,17 +1343,17 @@ body {
display: inline-block; display: inline-block;
} }
.dashboard-job-row-progress { .ripper-job-row-progress {
grid-column: 1 / -1; grid-column: 1 / -1;
display: grid; display: grid;
gap: 0.2rem; gap: 0.2rem;
} }
.dashboard-job-row-progress .p-progressbar { .ripper-job-row-progress .p-progressbar {
height: 0.42rem; height: 0.42rem;
} }
.dashboard-job-row-progress small { .ripper-job-row-progress small {
color: var(--rip-muted); color: var(--rip-muted);
white-space: normal; white-space: normal;
} }
@@ -1367,7 +1479,7 @@ body {
margin-top: 0.15rem; margin-top: 0.15rem;
} }
.dashboard-job-badges { .ripper-job-badges {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
@@ -1422,7 +1534,7 @@ body {
display: block; display: block;
} }
.dashboard-job-poster-fallback { .ripper-job-poster-fallback {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1431,7 +1543,7 @@ body {
background: #f6ebd6; background: #f6ebd6;
} }
.dashboard-job-expanded { .ripper-job-expanded {
border: 1px solid var(--rip-border); border: 1px solid var(--rip-border);
border-radius: 0.6rem; border-radius: 0.6rem;
padding: 0.6rem; padding: 0.6rem;
@@ -1440,14 +1552,14 @@ body {
gap: 0.6rem; gap: 0.6rem;
} }
.dashboard-job-expanded-head { .ripper-job-expanded-head {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
} }
.dashboard-job-expanded-title { .ripper-job-expanded-title {
display: grid; display: grid;
gap: 0.35rem; gap: 0.35rem;
flex: 1; flex: 1;
@@ -1885,7 +1997,27 @@ body {
font-size: 0.9rem; font-size: 0.9rem;
} }
/* Per-drive CD sections in Dashboard */ .converter-scan-extensions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7.2rem, 1fr));
gap: 0.45rem 0.7rem;
margin-top: 0.1rem;
}
.converter-scan-extension-option {
display: inline-flex;
align-items: center;
gap: 0.45rem;
cursor: pointer;
user-select: none;
}
.converter-scan-extension-option span {
font-family: monospace;
font-size: 0.85rem;
}
/* Per-drive CD sections in Ripper */
/* Unified per-drive list (Disk-Information card) */ /* Unified per-drive list (Disk-Information card) */
.drive-list { .drive-list {
display: flex; display: flex;
@@ -3082,18 +3214,18 @@ body {
gap: 0.5rem; gap: 0.5rem;
} }
.dashboard-downloads-row { .ripper-downloads-row {
border-top: 1px solid var(--surface-border); border-top: 1px solid var(--surface-border);
padding-top: 0.75rem; padding-top: 0.75rem;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.dashboard-downloads-row h4 { .ripper-downloads-row h4 {
margin: 0 0 0.45rem; margin: 0 0 0.45rem;
font-size: 0.88rem; font-size: 0.88rem;
} }
.dashboard-downloads-meta { .ripper-downloads-meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -3802,15 +3934,15 @@ body {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.dashboard-job-row { .ripper-job-row {
grid-template-columns: 48px minmax(0, 1fr) auto; grid-template-columns: 48px minmax(0, 1fr) auto;
} }
.dashboard-job-badges { .ripper-job-badges {
justify-content: flex-start; justify-content: flex-start;
} }
.dashboard-job-expanded-head { .ripper-job-expanded-head {
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -3987,18 +4119,18 @@ body {
display: none; display: none;
} }
.dashboard-job-row { .ripper-job-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.dashboard-job-row .poster-thumb, .ripper-job-row .poster-thumb,
.dashboard-job-row .dashboard-job-poster-fallback { .ripper-job-row .ripper-job-poster-fallback {
width: 52px; width: 52px;
height: 76px; height: 76px;
} }
.dashboard-job-row-main strong, .ripper-job-row-main strong,
.dashboard-job-row-main small { .ripper-job-row-main small {
white-space: normal; white-space: normal;
} }
@@ -4007,7 +4139,7 @@ body {
white-space: normal; white-space: normal;
} }
.dashboard-job-title-line > span { .ripper-job-title-line > span {
white-space: normal; white-space: normal;
} }
@@ -5248,6 +5380,20 @@ body {
border-radius: 0 0 11px 11px; border-radius: 0 0 11px 11px;
} }
.explorer-loading,
.explorer-empty {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
min-height: 180px;
gap: 0.55rem;
}
.audiobook-output-explorer .audiobook-output-row {
grid-template-columns: minmax(180px, 2fr) minmax(96px, 0.8fr) minmax(160px, 1.2fr);
}
/* ── Icon-Button (wie Klangkiste) ────────────────────────────────────────── */ /* ── Icon-Button (wie Klangkiste) ────────────────────────────────────────── */
.icon-button { .icon-button {
+236
View File
@@ -0,0 +1,236 @@
function safeParseJson(value, fallback = null) {
if (!value) {
return fallback;
}
if (typeof value === 'object') {
return value;
}
try {
return JSON.parse(value);
} catch (_error) {
return fallback;
}
}
function getEncodePlan(job) {
if (!job || typeof job !== 'object') {
return null;
}
return safeParseJson(job.encodePlan || job.encode_plan_json, null);
}
function normalizeConverterMediaType(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'audio' || raw === 'video' || raw === 'iso') {
return raw;
}
return null;
}
function normalizeMediaType(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
return 'bluray';
}
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
return 'dvd';
}
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
return 'cd';
}
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
if (raw === 'converter') {
return 'converter';
}
return null;
}
function normalizeJobKind(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (['audiobook', 'cd', 'dvd', 'bluray', 'converter_audio', 'converter_video', 'converter_iso'].includes(raw)) {
return raw;
}
if (raw === 'converter') {
return 'converter_video';
}
if (raw === 'converter-audio' || raw === 'converter audio') {
return 'converter_audio';
}
if (raw === 'converter-video' || raw === 'converter video') {
return 'converter_video';
}
if (raw === 'converter-iso' || raw === 'converter iso') {
return 'converter_iso';
}
return null;
}
function converterJobKindFromMediaType(converterMediaType) {
const normalized = normalizeConverterMediaType(converterMediaType);
if (normalized === 'audio') {
return 'converter_audio';
}
if (normalized === 'iso') {
return 'converter_iso';
}
return 'converter_video';
}
function mediaTypeFromJobKind(jobKind) {
const normalized = normalizeJobKind(jobKind);
if (!normalized) {
return null;
}
if (normalized.startsWith('converter_')) {
return 'converter';
}
return normalized;
}
export function resolveJobKind(job) {
const encodePlan = getEncodePlan(job);
const converterMediaType = normalizeConverterMediaType(
encodePlan?.converterMediaType || job?.converterMediaType
);
const directCandidates = [
job?.job_kind,
job?.jobKind,
encodePlan?.jobKind,
job?.makemkvInfo?.jobKind,
job?.makemkvInfo?.analyzeContext?.jobKind,
job?.mediainfoInfo?.jobKind,
job?.handbrakeInfo?.jobKind
];
for (const candidate of directCandidates) {
const normalized = normalizeJobKind(candidate);
if (!normalized) {
continue;
}
if (normalized.startsWith('converter_')) {
return converterMediaType ? converterJobKindFromMediaType(converterMediaType) : normalized;
}
return normalized;
}
const mediaCandidates = [
job?.mediaType,
job?.media_type,
job?.mediaProfile,
job?.media_profile,
encodePlan?.mediaProfile,
job?.makemkvInfo?.analyzeContext?.mediaProfile,
job?.makemkvInfo?.mediaProfile,
job?.mediainfoInfo?.mediaProfile
];
for (const candidate of mediaCandidates) {
const normalized = normalizeMediaType(candidate);
if (!normalized) {
continue;
}
if (normalized === 'converter') {
return converterJobKindFromMediaType(converterMediaType);
}
return normalized;
}
if (converterMediaType) {
return converterJobKindFromMediaType(converterMediaType);
}
const statusCandidates = [job?.status, job?.last_state, job?.makemkvInfo?.lastState];
if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) {
return 'cd';
}
const planFormat = String(encodePlan?.format || '').trim().toLowerCase();
const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0;
if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
return 'cd';
}
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
return 'cd';
}
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
return 'cd';
}
if (['audiobook_encode', 'audiobook_encode_split'].includes(String(job?.handbrakeInfo?.mode || '').trim().toLowerCase())) {
return 'audiobook';
}
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
return 'audiobook';
}
return null;
}
export function resolveMediaType(job) {
const jobKind = resolveJobKind(job);
const mediaTypeFromKind = mediaTypeFromJobKind(jobKind);
if (mediaTypeFromKind) {
return mediaTypeFromKind;
}
const encodePlan = getEncodePlan(job);
const directCandidates = [
job?.mediaType,
job?.media_type,
job?.mediaProfile,
job?.media_profile,
encodePlan?.mediaProfile,
job?.makemkvInfo?.analyzeContext?.mediaProfile,
job?.makemkvInfo?.mediaProfile,
job?.mediainfoInfo?.mediaProfile
];
for (const candidate of directCandidates) {
const normalized = normalizeMediaType(candidate);
if (normalized) {
return normalized;
}
}
const converterMediaType = normalizeConverterMediaType(encodePlan?.converterMediaType || job?.converterMediaType);
if (converterMediaType) {
return 'converter';
}
return 'other';
}
export function isConverterJob(job) {
return resolveMediaType(job) === 'converter';
}
export function classifyJob(job) {
const jobKind = resolveJobKind(job);
const mediaType = resolveMediaType(job);
const converterMediaType = normalizeConverterMediaType(
(jobKind && jobKind.startsWith('converter_'))
? jobKind.replace('converter_', '')
: (getEncodePlan(job)?.converterMediaType || job?.converterMediaType)
);
let family = 'other';
if (mediaType === 'converter') {
family = 'converter';
} else if (mediaType === 'audiobook') {
family = 'audiobook';
} else if (mediaType === 'cd' || mediaType === 'dvd' || mediaType === 'bluray') {
family = 'rip';
}
return {
jobKind,
mediaType,
converterMediaType,
family
};
}
+1 -1
View File
@@ -53,7 +53,7 @@ nav:
- Erster Lauf: getting-started/quickstart.md - Erster Lauf: getting-started/quickstart.md
- GUI-Seiten: - GUI-Seiten:
- gui/index.md - gui/index.md
- Dashboard: gui/dashboard.md - Ripper: gui/ripper.md
- Settings: gui/settings.md - Settings: gui/settings.md
- Historie: gui/history.md - Historie: gui/history.md
- Converter: gui/converter.md - Converter: gui/converter.md
+2 -1
View File
@@ -14,7 +14,8 @@
"qa:audiobook:analysis": "node ./scripts/smoketest/qa-audiobook-analysis.js", "qa:audiobook:analysis": "node ./scripts/smoketest/qa-audiobook-analysis.js",
"qa:cd:check": "bash ./scripts/smoketest/qa-cd-check.sh", "qa:cd:check": "bash ./scripts/smoketest/qa-cd-check.sh",
"qa:audiobook:check": "bash ./scripts/smoketest/qa-audiobook-check.sh", "qa:audiobook:check": "bash ./scripts/smoketest/qa-audiobook-check.sh",
"qa:logs:dashboard": "node ./scripts/smoketest/qa-log-dashboard.js", "qa:logs:ripper": "node ./scripts/smoketest/qa-log-ripper.js",
"qa:jobs:kind-check": "node ./scripts/smoketest/qa-jobs-kind-check.js",
"release:interactive": "bash ./scripts/release.sh" "release:interactive": "bash ./scripts/release.sh"
}, },
"devDependencies": { "devDependencies": {