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)
- Pipeline-Queue mit Job- und Nicht-Job-Einträgen (`script`, `chain`, `wait`)
- Cron-Jobs für Skripte/Ketten (inkl. Logs und manueller Auslösung)
- **Aktivitäts-Tracking**: Laufende und abgeschlossene Aktionen in Echtzeit im Dashboard
- **Aktivitäts-Tracking**: Laufende und abgeschlossene Aktionen in Echtzeit im Ripper
- Download-Queue: Ausgabedateien als ZIP herunterladen
- Historie mit Re-Encode, Review-Neustart, File-/Job-Löschung und Orphan-Import
- Hardware-Monitoring (CPU/RAM/GPU/Storage) im Dashboard
- Hardware-Monitoring (CPU/RAM/GPU/Storage) im Ripper
## Tech-Stack
@@ -147,7 +147,7 @@ ripster/
utils/
frontend/
src/
pages/ # Dashboard, Settings, History, Converter, Downloads, Database
pages/ # Ripper, Settings, History, Converter, Downloads, Database
components/
api/
db/schema.sql
+3 -5
View File
@@ -7,11 +7,11 @@ const { spawnTrackedProcess } = require('../services/processRunner');
const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers');
const { ensureDir, sanitizeFileName, renderTemplate } = require('../utils/files');
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov', 'm4v', 'wmv', 'ts']);
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']);
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']);
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
const ISO_EXTENSIONS = new Set(['iso']);
const AUDIO_OUTPUT_FORMATS = new Set(['flac', 'mp3', 'aac', 'opus', 'wav', 'ogg']);
const AUDIO_OUTPUT_FORMATS = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']);
const VIDEO_OUTPUT_FORMATS = new Set(['mkv', 'mp4', 'm4v']);
/**
@@ -62,8 +62,6 @@ function buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, format, opts = {
} else {
args.push('-b:a', `${Number(opts.mp3Bitrate ?? 192)}k`);
}
} else if (format === 'aac') {
args.push('-codec:a', 'aac', '-b:a', `${Number(opts.aacBitrate ?? 256)}k`);
} else if (format === 'opus') {
args.push('-codec:a', 'libopus', '-b:a', `${Number(opts.opusBitrate ?? 160)}k`);
} else if (format === 'ogg') {
+6 -2
View File
@@ -90,8 +90,12 @@ router.post(
for (const entry of entries) {
const relPath = String(entry?.relPath || '').trim();
if (!relPath) continue;
const job = await pipelineService.createConverterJobFromEntry(relPath, {
converterMediaType: entry?.converterMediaType || null
const job = await pipelineService.createFileJob({
kind: 'converter_entry',
relPath,
options: {
converterMediaType: entry?.converterMediaType || null
}
});
jobs.push(job);
}
+121 -3
View File
@@ -1,4 +1,5 @@
const express = require('express');
const fs = require('fs');
const os = require('os');
const path = require('path');
const multer = require('multer');
@@ -7,8 +8,10 @@ const pipelineService = require('../services/pipelineService');
const historyService = require('../services/historyService');
const diskDetectionService = require('../services/diskDetectionService');
const hardwareMonitorService = require('../services/hardwareMonitorService');
const settingsService = require('../services/settingsService');
const logger = require('../services/logger').child('PIPELINE_ROUTE');
const activationBytesService = require('../services/activationBytesService');
const { defaultAudiobookDir } = require('../config');
const { getDb } = require('../db/database');
const router = express.Router();
@@ -16,6 +19,78 @@ const audiobookUpload = multer({
dest: path.join(os.tmpdir(), 'ripster-audiobook-uploads')
});
const AUDIOBOOK_TREE_MAX_DEPTH = 8;
function buildAudiobookOutputTree(rootDir, relPath = '', depth = 0) {
if (depth >= AUDIOBOOK_TREE_MAX_DEPTH) {
return [];
}
const absDir = relPath ? path.join(rootDir, relPath) : rootDir;
let dirents = [];
try {
dirents = fs.readdirSync(absDir, { withFileTypes: true });
} catch (_error) {
return [];
}
dirents.sort((left, right) => {
const leftOrder = left.isDirectory() ? 0 : 1;
const rightOrder = right.isDirectory() ? 0 : 1;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
});
const nodes = [];
for (const dirent of dirents) {
if (dirent.name.startsWith('.')) {
continue;
}
const childRelPath = relPath ? `${relPath}/${dirent.name}` : dirent.name;
const childAbsPath = path.join(rootDir, childRelPath);
if (dirent.isDirectory()) {
const children = buildAudiobookOutputTree(rootDir, childRelPath, depth + 1);
const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0);
let mtime = null;
try {
mtime = fs.statSync(childAbsPath).mtime.toISOString();
} catch (_error) {
mtime = null;
}
nodes.push({
name: dirent.name,
type: 'folder',
path: childRelPath,
size,
mtime,
children
});
continue;
}
if (dirent.isFile()) {
let size = 0;
let mtime = null;
try {
const stat = fs.statSync(childAbsPath);
size = Number(stat?.size || 0);
mtime = stat?.mtime ? stat.mtime.toISOString() : null;
} catch (_error) {
size = 0;
mtime = null;
}
nodes.push({
name: dirent.name,
type: 'file',
path: childRelPath,
size,
mtime
});
}
}
return nodes;
}
router.get(
'/state',
asyncHandler(async (req, res) => {
@@ -175,9 +250,13 @@ router.post(
mimeType: String(req.file.mimetype || '').trim() || null,
tempPath: String(req.file.path || '').trim() || null
});
const result = await pipelineService.uploadAudiobookFile(req.file, {
format: req.body?.format,
startImmediately: req.body?.startImmediately
const result = await pipelineService.createFileJob({
kind: 'audiobook_upload',
file: req.file,
options: {
format: req.body?.format,
startImmediately: req.body?.startImmediately
}
});
res.json({ result });
})
@@ -220,6 +299,45 @@ router.post(
})
);
router.get(
'/audiobook/jobs',
asyncHandler(async (_req, res) => {
const jobs = await pipelineService.getAudiobookJobs();
res.json({ jobs });
})
);
router.get(
'/audiobook/output-tree',
asyncHandler(async (_req, res) => {
const settings = await settingsService.getEffectiveSettingsMap('audiobook');
const configuredOutputDir = String(settings?.movie_dir || '').trim();
const rawOutputDir = configuredOutputDir || defaultAudiobookDir || null;
const outputDir = rawOutputDir
? (path.isAbsolute(rawOutputDir) ? rawOutputDir : path.resolve(process.cwd(), rawOutputDir))
: null;
if (!outputDir || !fs.existsSync(outputDir)) {
res.json({ outputDir, tree: null });
return;
}
const children = buildAudiobookOutputTree(outputDir, '', 0);
const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0);
res.json({
outputDir,
tree: {
name: path.basename(outputDir) || 'audiobooks',
type: 'folder',
path: '',
size,
mtime: null,
children
}
});
})
);
router.post(
'/select-metadata',
asyncHandler(async (req, res) => {
+12 -8
View File
@@ -10,9 +10,10 @@ const {
defaultConverterRawDir
} = require('../config');
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov', 'm4v', 'wmv', 'ts']);
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']);
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']);
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
const ISO_EXTENSIONS = new Set(['iso']);
const SUPPORTED_SCAN_EXTENSIONS = new Set([...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS, ...ISO_EXTENSIONS]);
let _pollingTimer = null;
let _pollingEnabled = false;
@@ -33,13 +34,16 @@ function detectFormat(fileName) {
function getConfiguredExtensions(settings) {
const raw = String(settings?.converter_scan_extensions || '').trim();
if (!raw) {
return new Set([...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS, ...ISO_EXTENSIONS]);
return new Set(SUPPORTED_SCAN_EXTENSIONS);
}
return new Set(
raw.split(',')
.map((ext) => ext.trim().toLowerCase())
.filter(Boolean)
);
const configured = raw.split(',')
.map((ext) => ext.trim().toLowerCase())
.filter(Boolean);
const filtered = configured.filter((ext) => SUPPORTED_SCAN_EXTENSIONS.has(ext));
if (filtered.length === 0) {
return new Set(SUPPORTED_SCAN_EXTENSIONS);
}
return new Set(filtered);
}
function getFileSize(fullPath) {
+71 -5
View File
@@ -306,6 +306,35 @@ function normalizeMediaTypeValue(value) {
return null;
}
function normalizeJobKindValue(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (raw === 'audiobook' || raw === 'cd' || raw === 'dvd' || raw === 'bluray') {
return raw;
}
if (raw === 'converter_audio' || raw === 'converter_video' || raw === 'converter_iso') {
return raw;
}
return null;
}
function inferMediaTypeFromJobKind(value) {
const normalized = normalizeJobKindValue(value);
if (normalized === 'converter_audio' || normalized === 'converter_video' || normalized === 'converter_iso') {
return 'converter';
}
if (normalized === 'audiobook' || normalized === 'cd' || normalized === 'dvd' || normalized === 'bluray') {
return normalized;
}
const legacy = String(value || '').trim().toLowerCase();
if (legacy === 'converter') {
return 'converter';
}
return null;
}
function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeInfo = null) {
const mkInfo = parseInfoFromValue(makemkvInfo, null);
const miInfo = parseInfoFromValue(mediainfoInfo, null);
@@ -326,8 +355,21 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
return profileHint;
}
const profileFromJobKind = inferMediaTypeFromJobKind(
job?.job_kind
|| plan?.jobKind
|| mkInfo?.jobKind
|| mkInfo?.analyzeContext?.jobKind
|| miInfo?.jobKind
|| hbInfo?.jobKind
);
if (profileFromJobKind) {
return profileFromJobKind;
}
// Converter jobs: detected via media_type field (plan.mediaProfile = 'converter')
const rawMediaType = normalizeMediaTypeValue(job?.media_type);
const rawMediaTypeLegacy = String(job?.media_type || '').trim().toLowerCase();
const rawPlanProfile = String(plan?.mediaProfile || '').trim().toLowerCase();
const converterMediaTypeHint = String(plan?.converterMediaType || '').trim().toLowerCase();
const hasConverterPathHint = (value) => {
@@ -343,6 +385,7 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
if (
rawPlanProfile === 'converter'
|| rawMediaType === 'converter'
|| rawMediaTypeLegacy === 'converter'
|| converterMediaTypeHint === 'video'
|| converterMediaTypeHint === 'audio'
|| converterMediaTypeHint === 'iso'
@@ -1897,22 +1940,39 @@ function isFilesystemRootPath(inputPath) {
}
class HistoryService {
async createJob({ discDevice = null, status = 'ANALYZING', detectedTitle = null }) {
async createJob({
discDevice = null,
status = 'ANALYZING',
detectedTitle = null,
mediaType = null,
jobKind = null
}) {
const db = await getDb();
const startTime = new Date().toISOString();
const normalizedMediaType = (() => {
const normalized = normalizeMediaTypeValue(mediaType);
if (normalized) {
return normalized;
}
const legacy = String(mediaType || '').trim().toLowerCase();
return legacy === 'converter' ? 'converter' : null;
})();
const normalizedJobKind = normalizeJobKindValue(jobKind);
const result = await db.run(
`
INSERT INTO jobs (disc_device, status, start_time, detected_title, last_state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
INSERT INTO jobs (disc_device, status, start_time, detected_title, last_state, media_type, job_kind, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`,
[discDevice, status, startTime, detectedTitle, status]
[discDevice, status, startTime, detectedTitle, status, normalizedMediaType, normalizedJobKind]
);
logger.info('job:created', {
jobId: result.lastID,
discDevice,
status,
detectedTitle
detectedTitle,
mediaType: normalizedMediaType,
jobKind: normalizedJobKind
});
return this.getJobById(result.lastID);
@@ -2635,6 +2695,7 @@ class HistoryService {
const rowMediaProfile = normalizeMediaTypeValue(
makemkvInfo?.mediaProfile
|| makemkvInfo?.analyzeContext?.mediaProfile
|| inferMediaTypeFromJobKind(row?.job_kind)
|| row?.media_type
);
const posterCandidate = String(
@@ -2764,6 +2825,7 @@ class HistoryService {
const mediaProfile = normalizeMediaTypeValue(
makemkvInfo?.mediaProfile
|| makemkvInfo?.analyzeContext?.mediaProfile
|| inferMediaTypeFromJobKind(job?.job_kind)
|| job?.media_type
);
if (String(makemkvInfo?.source || '').trim().toLowerCase() !== 'orphan_raw_import' || mediaProfile !== 'cd') {
@@ -3616,6 +3678,10 @@ class HistoryService {
|| ''
).trim() || null;
await this.updateJob(created.id, {
...(detectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}),
...(detectedMediaType === 'cd' ? { job_kind: 'cd' } : {}),
...(detectedMediaType === 'dvd' ? { job_kind: 'dvd' } : {}),
...(detectedMediaType === 'bluray' ? { job_kind: 'bluray' } : {}),
status: 'FINISHED',
last_state: 'FINISHED',
title: omdbById?.title
+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_MIN_EVENT_GAP = 12;
const FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES = 64 * 1024;
const CONVERTER_SUPPORTED_SCAN_EXTENSIONS = Object.freeze([
'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov',
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
]);
const CONVERTER_SUPPORTED_SCAN_EXTENSION_SET = new Set(CONVERTER_SUPPORTED_SCAN_EXTENSIONS);
function nowIso() {
return new Date().toISOString();
@@ -108,6 +113,50 @@ function normalizePositiveInteger(value) {
return Math.trunc(parsed);
}
function parseConverterScanExtensions(rawValue) {
const raw = String(rawValue || '').trim();
if (!raw) {
return [...CONVERTER_SUPPORTED_SCAN_EXTENSIONS];
}
const seen = new Set();
const parsed = raw
.split(',')
.map((item) => String(item || '').trim().toLowerCase())
.filter((item) => {
if (!item || !CONVERTER_SUPPORTED_SCAN_EXTENSION_SET.has(item) || seen.has(item)) {
return false;
}
seen.add(item);
return true;
});
if (parsed.length === 0) {
return [...CONVERTER_SUPPORTED_SCAN_EXTENSIONS];
}
return parsed;
}
function getFileExtensionWithoutDot(fileName) {
const ext = path.extname(String(fileName || '')).trim().toLowerCase();
if (!ext.startsWith('.')) {
return '';
}
return ext.slice(1);
}
function cleanupTempUploads(files = []) {
for (const file of Array.isArray(files) ? files : []) {
const tempPath = String(file?.path || '').trim();
if (!tempPath || !fs.existsSync(tempPath)) {
continue;
}
try {
fs.unlinkSync(tempPath);
} catch (_error) {
// Best-effort cleanup only.
}
}
}
function normalizeCdTrackPositionList(values = []) {
const source = Array.isArray(values) ? values : [];
const seen = new Set();
@@ -356,6 +405,84 @@ function normalizeMediaProfile(value) {
return null;
}
function normalizeJobKind(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (raw === 'audiobook' || raw === 'cd' || raw === 'dvd' || raw === 'bluray') {
return raw;
}
if (raw === 'converter_audio' || raw === 'converter_video' || raw === 'converter_iso') {
return raw;
}
return null;
}
function resolveConverterJobKind(converterMediaType = null) {
const normalized = String(converterMediaType || '').trim().toLowerCase();
if (normalized === 'audio') {
return 'converter_audio';
}
if (normalized === 'iso') {
return 'converter_iso';
}
return 'converter_video';
}
function resolveJobKindForMediaProfile(mediaProfile, options = {}) {
const explicit = normalizeJobKind(options?.jobKind);
if (explicit) {
return explicit;
}
const normalizedProfile = normalizeMediaProfile(mediaProfile);
if (!normalizedProfile) {
return null;
}
if (normalizedProfile === 'converter') {
return resolveConverterJobKind(options?.converterMediaType);
}
if (normalizedProfile === 'audiobook' || normalizedProfile === 'cd' || normalizedProfile === 'dvd' || normalizedProfile === 'bluray') {
return normalizedProfile;
}
return null;
}
function inferMediaProfileFromJobKind(rawJobKind) {
const normalized = normalizeJobKind(rawJobKind);
if (normalized === 'converter_audio' || normalized === 'converter_video' || normalized === 'converter_iso') {
return 'converter';
}
if (normalized === 'audiobook' || normalized === 'cd' || normalized === 'dvd' || normalized === 'bluray') {
return normalized;
}
const legacy = String(rawJobKind || '').trim().toLowerCase();
if (legacy === 'converter') {
return 'converter';
}
return null;
}
function isConverterJobRecord(job = null, encodePlan = null) {
const profileFromJobKind = inferMediaProfileFromJobKind(job?.job_kind);
if (profileFromJobKind === 'converter') {
return true;
}
const plan = encodePlan && typeof encodePlan === 'object'
? encodePlan
: null;
const profileFromPlanJobKind = inferMediaProfileFromJobKind(plan?.jobKind);
if (profileFromPlanJobKind === 'converter') {
return true;
}
const mediaType = String(job?.media_type || '').trim().toLowerCase();
if (mediaType === 'converter') {
return true;
}
const planProfile = String(plan?.mediaProfile || '').trim().toLowerCase();
return planProfile === 'converter';
}
function isSpecificMediaProfile(value) {
return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook' || value === 'converter';
}
@@ -5580,6 +5707,7 @@ class PipelineService extends EventEmitter {
FROM jobs
WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> ''
AND (media_type IS NULL OR media_type <> 'converter')
AND (job_kind IS NULL OR job_kind NOT LIKE 'converter_%')
`);
if (!Array.isArray(rows) || rows.length === 0) {
return;
@@ -5751,7 +5879,7 @@ class PipelineService extends EventEmitter {
});
}
// Always start with a clean dashboard/session snapshot after server restart.
// Always start with a clean ripper/session snapshot after server restart.
const hasContextKeys = this.snapshot.context
&& typeof this.snapshot.context === 'object'
&& Object.keys(this.snapshot.context).length > 0;
@@ -5946,7 +6074,7 @@ class PipelineService extends EventEmitter {
this.cdDrives.set(devicePath, next);
// If a drive is detached from a job (or reassigned), clear stale live
// progress for the previous job so dashboards do not stay in CD_ENCODING.
// progress for the previous job so ripper views do not stay in CD_ENCODING.
const previousJobId = Number(existing?.jobId || 0);
const nextJobId = Number(next?.jobId || 0);
if (Number.isFinite(previousJobId) && previousJobId > 0) {
@@ -8008,6 +8136,14 @@ class PipelineService extends EventEmitter {
const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object'
? options.encodePlan
: null;
const profileFromJobKind = inferMediaProfileFromJobKind(
options?.jobKind
|| job?.job_kind
|| encodePlan?.jobKind
);
if (profileFromJobKind) {
return profileFromJobKind;
}
const profileFromPlan = pickSpecificProfile(encodePlan?.mediaProfile);
if (profileFromPlan) {
return profileFromPlan;
@@ -8067,6 +8203,28 @@ class PipelineService extends EventEmitter {
return 'other';
}
resolveJobKindForJob(job, options = {}) {
const explicitKind = normalizeJobKind(options?.jobKind);
if (explicitKind) {
return explicitKind;
}
const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object'
? options.encodePlan
: this.safeParseJson(job?.encode_plan_json);
const kindFromPlan = normalizeJobKind(encodePlan?.jobKind);
if (kindFromPlan) {
return kindFromPlan;
}
const kindFromJob = normalizeJobKind(job?.job_kind);
if (kindFromJob) {
return kindFromJob;
}
const mediaProfile = this.resolveMediaProfileForJob(job, { ...options, encodePlan });
return resolveJobKindForMediaProfile(mediaProfile, {
converterMediaType: options?.converterMediaType || encodePlan?.converterMediaType || null
});
}
async getEffectiveSettingsForJob(job, options = {}) {
const mediaProfile = this.resolveMediaProfileForJob(job, options);
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
@@ -8401,7 +8559,8 @@ class PipelineService extends EventEmitter {
const job = await historyService.createJob({
discDevice: device.path,
status: 'METADATA_SELECTION',
detectedTitle
detectedTitle,
jobKind: resolveJobKindForMediaProfile(mediaProfile)
});
try {
@@ -10971,6 +11130,7 @@ class PipelineService extends EventEmitter {
const refreshedPlan = {
...(sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {}),
mediaProfile: 'audiobook',
jobKind: 'audiobook',
mode: 'audiobook',
encodeInputPath: rawInput.path
};
@@ -10996,6 +11156,8 @@ class PipelineService extends EventEmitter {
await historyService.resetProcessLog(sourceJobId);
await historyService.updateJob(sourceJobId, {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'READY_TO_START',
last_state: 'READY_TO_START',
start_time: null,
@@ -13832,7 +13994,10 @@ class PipelineService extends EventEmitter {
const retryJob = await historyService.createJob({
discDevice: sourceJob.disc_device || null,
status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'),
detectedTitle: sourceJob.detected_title || sourceJob.title || null
detectedTitle: sourceJob.detected_title || sourceJob.title || null,
jobKind: this.resolveJobKindForJob(sourceJob, {
encodePlan: sourceEncodePlan
})
});
const retryJobId = Number(retryJob?.id || 0);
if (!Number.isFinite(retryJobId) || retryJobId <= 0) {
@@ -14044,7 +14209,7 @@ class PipelineService extends EventEmitter {
await historyService.appendLog(
jobId,
'USER_ACTION',
'READY_TO_ENCODE Job nach Neustart ins Dashboard geladen.'
'READY_TO_ENCODE Job nach Neustart in den Ripper geladen.'
);
if (
@@ -14232,7 +14397,10 @@ class PipelineService extends EventEmitter {
const replacementJob = await historyService.createJob({
discDevice: job.disc_device || null,
status: 'READY_TO_ENCODE',
detectedTitle: job.detected_title || job.title || null
detectedTitle: job.detected_title || job.title || null,
jobKind: this.resolveJobKindForJob(job, {
encodePlan: restartPlan
})
});
const replacementJobId = Number(replacementJob?.id || 0);
if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) {
@@ -14562,7 +14730,10 @@ class PipelineService extends EventEmitter {
const replacementJob = await historyService.createJob({
discDevice: sourceJob.disc_device || null,
status: 'MEDIAINFO_CHECK',
detectedTitle: sourceJob.detected_title || sourceJob.title || null
detectedTitle: sourceJob.detected_title || sourceJob.title || null,
jobKind: this.resolveJobKindForJob(sourceJob, {
encodePlan: previousEncodePlan
})
});
const replacementJobId = Number(replacementJob?.id || 0);
if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) {
@@ -14817,7 +14988,7 @@ class PipelineService extends EventEmitter {
const cancelMessage = 'Vom Benutzer abgebrochen.';
await historyService.updateJob(normalizedJobId, {
status: 'CANCELLED',
// Preserve origin state so dashboard/history can distinguish review-cancelled rows.
// Preserve origin state so ripper/history can distinguish review-cancelled rows.
last_state: runningStatus,
end_time: nowIso(),
error_message: cancelMessage
@@ -15378,7 +15549,7 @@ class PipelineService extends EventEmitter {
}
} else {
// No drive mapping available (e.g. orphan/skipRip import path).
// Ensure stale live progress does not keep the job "running" in the dashboard.
// Ensure stale live progress does not keep the job "running" in the ripper.
this.jobProgress.delete(Number(jobId));
}
} else {
@@ -15451,7 +15622,9 @@ class PipelineService extends EventEmitter {
const job = await historyService.createJob({
discDevice: null,
status: 'ANALYZING',
detectedTitle
detectedTitle,
mediaType: 'audiobook',
jobKind: 'audiobook'
});
let stagedRawDir = null;
@@ -15641,6 +15814,7 @@ class PipelineService extends EventEmitter {
const encodePlan = {
mediaProfile: 'audiobook',
jobKind: 'audiobook',
mode: 'audiobook',
sourceType: 'upload',
uploadedAt: nowIso(),
@@ -15654,6 +15828,8 @@ class PipelineService extends EventEmitter {
};
await historyService.updateJob(job.id, {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'READY_TO_START',
last_state: 'READY_TO_START',
title: resolvedMetadata.title || detectedTitle,
@@ -15704,6 +15880,8 @@ class PipelineService extends EventEmitter {
error: errorToMeta(error)
});
const updatePayload = {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'ERROR',
last_state: 'ERROR',
end_time: nowIso(),
@@ -15796,6 +15974,7 @@ class PipelineService extends EventEmitter {
const nextEncodePlan = {
...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}),
mediaProfile: 'audiobook',
jobKind: 'audiobook',
mode: 'audiobook',
format,
formatOptions,
@@ -15804,6 +15983,8 @@ class PipelineService extends EventEmitter {
};
await historyService.updateJob(normalizedJobId, {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'READY_TO_START',
last_state: 'READY_TO_START',
title: resolvedMetadata.title || job.title || job.detected_title || 'Audiobook',
@@ -15957,6 +16138,8 @@ class PipelineService extends EventEmitter {
});
await historyService.updateJob(jobId, {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'ENCODING',
last_state: 'ENCODING',
start_time: nowIso(),
@@ -16602,7 +16785,8 @@ class PipelineService extends EventEmitter {
const job = await historyService.createJob({
discDevice: devicePath,
status: 'CD_METADATA_SELECTION',
detectedTitle
detectedTitle,
jobKind: 'cd'
});
try {
@@ -17059,7 +17243,10 @@ class PipelineService extends EventEmitter {
const replacementJob = await historyService.createJob({
discDevice: sourceJob.disc_device || null,
status: 'CD_READY_TO_RIP',
detectedTitle: sourceJob.detected_title || sourceJob.title || null
detectedTitle: sourceJob.detected_title || sourceJob.title || null,
jobKind: this.resolveJobKindForJob(sourceJob, {
encodePlan: this.safeParseJson(sourceJob.encode_plan_json)
}) || 'cd'
});
const replacementJobId = Number(replacementJob?.id || 0);
if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) {
@@ -18044,6 +18231,47 @@ class PipelineService extends EventEmitter {
// CONVERTER
// ═══════════════════════════════════════════════════════════════════════════
/**
* Zentraler Intake für dateibasierte Jobs.
* Bestehende Endpunkte bleiben erhalten und delegieren intern auf diese Methode.
*
* @param {object} payload
* @param {'audiobook_upload'|'audiobook'|'converter_entry'|'converter'} payload.kind
* @param {object} [payload.file]
* @param {string} [payload.relPath]
* @param {object} [payload.options]
* @returns {Promise<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).
*
@@ -18100,21 +18328,30 @@ class PipelineService extends EventEmitter {
const isVideoLikeConverterJob = converterMediaType === 'video' || converterMediaType === 'iso';
const initialStatus = 'READY_TO_START';
const normalizedConverterMediaType = ['audio', 'video', 'iso'].includes(
String(converterMediaType || '').trim().toLowerCase()
)
? String(converterMediaType || '').trim().toLowerCase()
: 'video';
const job = await historyService.createJob({
discDevice: null,
status: initialStatus,
detectedTitle
detectedTitle,
mediaType: 'converter',
jobKind: resolveConverterJobKind(normalizedConverterMediaType)
});
await historyService.updateJob(job.id, {
media_type: 'converter',
job_kind: resolveConverterJobKind(normalizedConverterMediaType),
raw_path: fullPath,
encode_plan_json: JSON.stringify({
mediaProfile: 'converter',
converterMediaType,
jobKind: resolveConverterJobKind(normalizedConverterMediaType),
converterMediaType: normalizedConverterMediaType,
inputPath: fullPath,
isFolder: isDirectory,
audioFiles: isDirectory && converterMediaType === 'audio'
audioFiles: isDirectory && normalizedConverterMediaType === 'audio'
? require('./converterScanService').detectFormat
? null // wird beim Start gefüllt
: null
@@ -18129,7 +18366,7 @@ class PipelineService extends EventEmitter {
await historyService.appendLog(
job.id,
'SYSTEM',
`Converter-Job erstellt aus: ${relPath} | Typ: ${converterMediaType || '-'}`
`Converter-Job erstellt aus: ${relPath} | Typ: ${normalizedConverterMediaType || '-'}`
);
if (isVideoLikeConverterJob) {
await historyService.appendLog(
@@ -18140,7 +18377,7 @@ class PipelineService extends EventEmitter {
}
logger.info('converter:job:created-from-entry', {
jobId: job.id, relPath, converterMediaType, fullPath
jobId: job.id, relPath, converterMediaType: normalizedConverterMediaType, fullPath
});
return historyService.getJobById(job.id);
@@ -18172,6 +18409,30 @@ class PipelineService extends EventEmitter {
throw error;
}
const settings = await settingsService.getSettingsMap();
const allowedExtensions = new Set(parseConverterScanExtensions(settings?.converter_scan_extensions));
const invalidFiles = [];
for (const file of normalizedFiles) {
const tempPath = String(file?.path || '').trim();
const originalName = String(file?.originalname || file?.originalName || '').trim()
|| path.basename(tempPath || 'upload');
const extension = getFileExtensionWithoutDot(originalName);
if (!extension || !allowedExtensions.has(extension)) {
invalidFiles.push(originalName);
}
}
if (invalidFiles.length > 0) {
cleanupTempUploads(normalizedFiles);
const allowedList = [...allowedExtensions].join(', ');
const preview = invalidFiles.slice(0, 6).join(', ');
const suffix = invalidFiles.length > 6 ? ` (+${invalidFiles.length - 6} weitere)` : '';
const error = new Error(
`Upload abgelehnt: Nicht erlaubte Dateiendung in ${invalidFiles.length} Datei(en): ${preview}${suffix}. Erlaubt: ${allowedList}`
);
error.statusCode = 400;
throw error;
}
const folderName = options?.folderName ? String(options.folderName).trim() : null;
if (folderName) {
@@ -18246,7 +18507,7 @@ class PipelineService extends EventEmitter {
* @returns {Promise<object[]>} Erstellte Jobs
*/
async createConverterJobsFromSelection(relPaths, audioMode = 'individual') {
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']);
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.wav', '.m4a', '.ogg', '.opus']);
const converterScanService = require('./converterScanService');
const rawDir = await converterScanService.getRawDir();
@@ -18272,7 +18533,11 @@ class PipelineService extends EventEmitter {
// Nicht-Audio (Video, Ordner, sonstige): immer ein Job pro Eintrag
for (const relPath of nonAudioRelPaths) {
const job = await this.createConverterJobFromEntry(relPath, {});
const job = await this.createFileJob({
kind: 'converter_entry',
relPath,
options: {}
});
createdJobs.push(job);
}
@@ -18286,14 +18551,18 @@ class PipelineService extends EventEmitter {
const job = await historyService.createJob({
discDevice: null,
status: 'READY_TO_START',
detectedTitle
detectedTitle,
mediaType: 'converter',
jobKind: 'converter_audio'
});
await historyService.updateJob(job.id, {
media_type: 'converter',
job_kind: 'converter_audio',
raw_path: rawPath,
encode_plan_json: JSON.stringify({
mediaProfile: 'converter',
jobKind: 'converter_audio',
converterMediaType: 'audio',
inputPath: rawPath,
inputPaths: fullPaths,
@@ -18318,7 +18587,11 @@ class PipelineService extends EventEmitter {
} else {
// Einzelne Jobs für jede Audio-Datei
for (const relPath of audioRelPaths) {
const job = await this.createConverterJobFromEntry(relPath, { converterMediaType: 'audio' });
const job = await this.createFileJob({
kind: 'converter_entry',
relPath,
options: { converterMediaType: 'audio' }
});
createdJobs.push(job);
}
}
@@ -18367,7 +18640,7 @@ class PipelineService extends EventEmitter {
error.statusCode = 404;
throw error;
}
if (String(job.media_type || '').trim().toLowerCase() !== 'converter') {
if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) {
const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
error.statusCode = 409;
throw error;
@@ -18481,6 +18754,7 @@ class PipelineService extends EventEmitter {
const nextPlan = {
...existingPlan,
mediaProfile: 'converter',
jobKind: 'converter_audio',
converterMediaType: 'audio',
isFolder: false,
isSharedAudio,
@@ -18494,6 +18768,7 @@ class PipelineService extends EventEmitter {
status: 'READY_TO_START',
last_state: 'READY_TO_START',
media_type: 'converter',
job_kind: 'converter_audio',
raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
encode_plan_json: JSON.stringify(nextPlan),
encode_review_confirmed: 0,
@@ -18551,7 +18826,7 @@ class PipelineService extends EventEmitter {
error.statusCode = 404;
throw error;
}
if (String(job.media_type || '').trim().toLowerCase() !== 'converter') {
if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) {
const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
error.statusCode = 409;
throw error;
@@ -18621,6 +18896,7 @@ class PipelineService extends EventEmitter {
const nextPlan = {
...existingPlan,
mediaProfile: 'converter',
jobKind: 'converter_audio',
converterMediaType: 'audio',
isFolder: false,
isSharedAudio,
@@ -18634,6 +18910,7 @@ class PipelineService extends EventEmitter {
status: 'READY_TO_START',
last_state: 'READY_TO_START',
media_type: 'converter',
job_kind: 'converter_audio',
raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
encode_plan_json: JSON.stringify(nextPlan),
encode_review_confirmed: 0,
@@ -18718,6 +18995,7 @@ class PipelineService extends EventEmitter {
const nextPlan = {
...existingPlan,
mediaProfile: 'converter',
jobKind: 'converter_audio',
converterMediaType: 'audio',
isFolder: false,
isSharedAudio,
@@ -18731,6 +19009,7 @@ class PipelineService extends EventEmitter {
status: 'READY_TO_START',
last_state: 'READY_TO_START',
media_type: 'converter',
job_kind: 'converter_audio',
raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
encode_plan_json: JSON.stringify(nextPlan),
encode_review_confirmed: 0,
@@ -18772,7 +19051,7 @@ class PipelineService extends EventEmitter {
error.statusCode = 404;
throw error;
}
if (String(job.media_type || '').trim().toLowerCase() !== 'converter') {
if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) {
const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
error.statusCode = 409;
throw error;
@@ -18804,8 +19083,16 @@ class PipelineService extends EventEmitter {
: 'video';
const defaultFormat = converterMediaType === 'audio' ? 'flac' : 'mkv';
const outputFormat = normalizeText(config.outputFormat || existingPlan.outputFormat || defaultFormat, 20)
const allowedAudioFormats = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']);
const allowedVideoFormats = new Set(['mkv', 'mp4', 'm4v']);
let outputFormat = normalizeText(config.outputFormat || existingPlan.outputFormat || defaultFormat, 20)
?.toLowerCase() || defaultFormat;
if (converterMediaType === 'audio' && !allowedAudioFormats.has(outputFormat)) {
outputFormat = 'flac';
}
if ((converterMediaType === 'video' || converterMediaType === 'iso') && !allowedVideoFormats.has(outputFormat)) {
outputFormat = 'mkv';
}
let userPreset = existingPlan.userPreset || null;
if (Object.prototype.hasOwnProperty.call(config, 'userPreset')) {
@@ -18905,6 +19192,7 @@ class PipelineService extends EventEmitter {
const nextPlan = {
...existingPlan,
mediaProfile: 'converter',
jobKind: resolveConverterJobKind(converterMediaType),
converterMediaType,
outputFormat,
userPreset,
@@ -18919,6 +19207,8 @@ class PipelineService extends EventEmitter {
const newCoverUrl = nextMetadata?.coverUrl || null;
const jobPatch = {
media_type: 'converter',
job_kind: resolveConverterJobKind(converterMediaType),
encode_plan_json: JSON.stringify(nextPlan),
encode_review_confirmed: 0,
error_message: null
@@ -19000,7 +19290,16 @@ class PipelineService extends EventEmitter {
let outputPath = null;
let outputDir = null;
let audioTrackTemplate = null;
const outputFormat = String(config.outputFormat || 'mkv').trim().toLowerCase();
const allowedAudioFormats = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']);
const allowedVideoFormats = new Set(['mkv', 'mp4', 'm4v']);
const defaultOutputFormat = converterMediaType === 'audio' ? 'flac' : 'mkv';
let outputFormat = String(config.outputFormat || existingPlan.outputFormat || defaultOutputFormat).trim().toLowerCase();
if (converterMediaType === 'audio' && !allowedAudioFormats.has(outputFormat)) {
outputFormat = 'flac';
}
if ((converterMediaType === 'video' || converterMediaType === 'iso') && !allowedVideoFormats.has(outputFormat)) {
outputFormat = 'mkv';
}
const baseName = sanitizeFileName(
path.basename(inputPath, path.extname(inputPath))
) || 'output';
@@ -19115,7 +19414,7 @@ class PipelineService extends EventEmitter {
let audioFiles = existingPlan.audioFiles;
if (converterMediaType === 'audio' && existingPlan.isFolder && !audioFiles) {
const { readdirSync } = fs;
const AUDIO_EXTS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']);
const AUDIO_EXTS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
try {
audioFiles = readdirSync(inputPath)
.filter((f) => AUDIO_EXTS.has(path.extname(f).slice(1).toLowerCase()))
@@ -19147,6 +19446,7 @@ class PipelineService extends EventEmitter {
const nextEncodePlan = {
...existingPlan,
mediaProfile: 'converter',
jobKind: resolveConverterJobKind(converterMediaType),
converterMediaType,
inputPath,
inputPaths: existingPlan.inputPaths || null,
@@ -19181,6 +19481,7 @@ class PipelineService extends EventEmitter {
status: 'READY_TO_START',
last_state: 'READY_TO_START',
media_type: 'converter',
job_kind: resolveConverterJobKind(converterMediaType),
output_path: outputPath || outputDir || null,
encode_plan_json: JSON.stringify(nextEncodePlan),
encode_review_confirmed: converterMediaType === 'audio' ? 1 : 0,
@@ -19224,6 +19525,55 @@ class PipelineService extends EventEmitter {
};
}
/**
* Gibt alle Audiobook-Jobs zurück (für die Audiobooks-Seite).
*/
async getAudiobookJobs() {
const db = await require('../db/database').getDb();
const rows = await db.all(`
SELECT id, title, detected_title, year, poster_url,
status, last_state, media_type, job_kind,
encode_plan_json, handbrake_info_json, makemkv_info_json,
output_path, raw_path, error_message,
start_time, end_time, created_at, updated_at
FROM jobs
WHERE media_type = 'audiobook'
OR job_kind = 'audiobook'
OR (
(media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other')
AND (
encode_plan_json LIKE '%"mediaProfile":"audiobook"%'
OR encode_plan_json LIKE '%"mode":"audiobook"%'
)
)
ORDER BY created_at DESC
LIMIT 200
`);
return rows.map((row) => {
const encodePlan = this.safeParseJson(row.encode_plan_json);
const handbrakeInfo = this.safeParseJson(row.handbrake_info_json);
const makemkvInfo = this.safeParseJson(row.makemkv_info_json);
const rawMediaType = String(row?.media_type || '').trim().toLowerCase();
const planMode = String(encodePlan?.mode || '').trim().toLowerCase();
const planProfile = String(encodePlan?.mediaProfile || '').trim().toLowerCase();
const effectiveMediaType = rawMediaType === 'audiobook'
? 'audiobook'
: (
planProfile === 'audiobook' || planMode === 'audiobook'
? 'audiobook'
: (rawMediaType || null)
);
return {
...row,
media_type: effectiveMediaType,
encodePlan,
handbrakeInfo,
makemkvInfo
};
});
}
/**
* Gibt alle Converter-Jobs zurück (für die Converter-Seite).
*/
@@ -19231,11 +19581,12 @@ class PipelineService extends EventEmitter {
const db = await require('../db/database').getDb();
const rows = await db.all(`
SELECT id, title, detected_title, year, imdb_id, poster_url,
status, last_state, media_type, encode_review_confirmed,
status, last_state, media_type, job_kind, encode_review_confirmed,
encode_plan_json, output_path, raw_path, encode_input_path, error_message,
start_time, end_time, created_at, updated_at
FROM jobs
WHERE media_type = 'converter'
OR job_kind LIKE 'converter_%'
OR (
(media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other')
AND (
+25 -2
View File
@@ -50,6 +50,7 @@ CREATE TABLE jobs (
encode_review_confirmed INTEGER DEFAULT 0,
aax_checksum TEXT,
media_type TEXT,
job_kind TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE SET NULL
@@ -58,6 +59,7 @@ CREATE TABLE jobs (
CREATE INDEX idx_jobs_status ON jobs(status);
CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC);
CREATE INDEX idx_jobs_parent_job_id ON jobs(parent_job_id);
CREATE INDEX idx_jobs_job_kind ON jobs(job_kind);
CREATE TABLE job_lineage_artifacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -588,8 +590,29 @@ UPDATE settings_schema SET category = 'Pfade' WHERE key IN ('converter_raw_dir',
-- Converter Scan
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_scan_extensions', 'Converter', 'Erlaubte Datei-Endungen', 'string', 1, 'Komma-getrennte Liste erlaubter Dateiendungen für den Scan (ohne Punkt).', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,aac,wav,m4a,ogg,opus', '[]', '{"minLength":1}', 820);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_scan_extensions', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,aac,wav,m4a,ogg,opus');
VALUES ('converter_scan_extensions', 'Converter', 'Erlaubte Datei-Endungen*', 'string', 1, 'Erlaubte Datei-Endungen für den Converter-Scan (ohne Punkt). Wird in der UI als Checkbox-Liste gepflegt.', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus', '[]', '{"minLength":1}', 820);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_scan_extensions', 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus');
UPDATE settings_schema
SET
label = 'Erlaubte Datei-Endungen*',
description = 'Erlaubte Datei-Endungen für den Converter-Scan (ohne Punkt). Wird in der UI als Checkbox-Liste gepflegt.',
default_value = 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus'
WHERE key = 'converter_scan_extensions';
UPDATE settings_values
SET value = TRIM(
REPLACE(
REPLACE(
REPLACE(',' || LOWER(COALESCE(value, '')) || ',', ',aac,', ','),
',,', ','
),
',,', ','
),
','
)
WHERE key = 'converter_scan_extensions';
UPDATE settings_values
SET value = 'mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus'
WHERE key = 'converter_scan_extensions' AND (value IS NULL OR TRIM(value) = '');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('converter_polling_enabled', 'Converter', 'Auto-Scan (Polling)', 'boolean', 1, 'Converter Raw-Ordner automatisch in regelmäßigen Abständen scannen.', 'false', '[]', '{}', 830);
+1 -1
View File
@@ -6,7 +6,7 @@ Frontend: React + PrimeReact + Vite.
## Hauptseiten
### `DashboardPage.jsx`
### `RipperPage.jsx`
Pipeline-Steuerung:
+1 -1
View File
@@ -9,7 +9,7 @@ Ripster ist eine Client-Server-Anwendung mit REST + WebSocket und externen CLI-T
```mermaid
graph TB
subgraph Browser["Browser (React)"]
Dashboard[Dashboard]
Ripper[Ripper]
Settings[Einstellungen]
History[Historie]
end
+1 -1
View File
@@ -145,7 +145,7 @@ Nicht gesetzte Werte werden zu `unknown`.
| Feldname in der GUI | Typ | Default | Hinweis |
|---|---|---|---|
| `Erlaubte Datei-Endungen` | string | `mkv,mp4,m2ts,iso,avi,mov,flac,mp3,aac,wav,m4a,ogg,opus` | Komma-getrennt, ohne Punkt |
| `Erlaubte Datei-Endungen*` | string | `mkv,mp4,m2ts,iso,avi,mov,flac,mp3,wav,m4a,ogg,opus` | In der UI als Checkbox-Liste, ohne Punkt |
| `Auto-Scan (Polling)` | boolean | `false` | Converter-Ordner automatisch scannen |
| `Polling-Intervall (Sekunden)` | number | `300` | 30..86400 |
+2 -2
View File
@@ -37,7 +37,7 @@ Typische Beispiele:
### 3. Queue und Monitoring festlegen
- `Parallele Jobs` für den gleichzeitigen Durchsatz
- `Hardware Monitoring aktiviert` + `Hardware Monitoring Intervall (ms)` für Live-Metriken im Dashboard
- `Hardware Monitoring aktiviert` + `Hardware Monitoring Intervall (ms)` für Live-Metriken im Ripper
### 4. Optional: Push-Benachrichtigungen
@@ -51,7 +51,7 @@ Dann über `PushOver Test` direkt prüfen.
## 2-Minuten-Funktionstest
1. `Dashboard` öffnen
1. `Ripper` öffnen
2. Disc einlegen
3. `Analyse starten`
4. Metadaten übernehmen
+3 -3
View File
@@ -2,7 +2,7 @@
Dieser Ablauf zeigt einen vollständigen Job aus Anwendersicht: von Disc-Erkennung bis fertiger Datei.
## 1. Dashboard öffnen und Disc einlegen
## 1. Ripper öffnen und Disc einlegen
Erwartung:
@@ -13,7 +13,7 @@ Wenn nichts passiert: `Laufwerk neu lesen`.
## 2. Analyse starten
Aktion im Dashboard:
Aktion im Ripper:
- `Analyse starten`
@@ -51,7 +51,7 @@ Dann `Encoding starten`.
Während `Encodieren`:
- Fortschritt + ETA im Dashboard
- Fortschritt + ETA im Ripper
- Live-Log im `Pipeline-Status`
- Queue- und Skript/Cron-Status parallel beobachtbar
+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
- **Gemeinsamer Job (Audio)** — mehrere Audio-Dateien werden zu einem gemeinsamen Job zusammengefasst
Erlaubte Dateiendungen werden in den Settings unter `Converter > Erlaubte Datei-Endungen` konfiguriert.
Erlaubte Dateiendungen werden in den Settings unter `Converter > Erlaubte Datei-Endungen*` als Checkboxen konfiguriert.
---
@@ -84,7 +84,7 @@ Für AAX-Dateien (Audible) ist folgendes erforderlich:
| Eingangsordner | `Settings > Pfade > Converter Raw-Ordner` |
| Videoausgabe | `Settings > Pfade > Converter Ausgabe (Video)` |
| Audioausgabe | `Settings > Pfade > Converter Ausgabe (Audio)` |
| Erlaubte Endungen | `Settings > Converter > Erlaubte Datei-Endungen` |
| Erlaubte Endungen | `Settings > Converter > Erlaubte Datei-Endungen*` |
| Auto-Scan | `Settings > Converter > Auto-Scan (Polling)` |
| Scan-Intervall | `Settings > Converter > Polling-Intervall (Sekunden)` |
| Output-Template Video | `Settings > Pfade > Output-Template (Video)` |
+3 -3
View File
@@ -6,7 +6,7 @@ Ripster hat fünf Hauptseiten in der Navigation plus eine Expert-Seite.
| Seite | Zweck |
|---|---|
| [Dashboard](dashboard.md) | Live-Betrieb: Pipeline, Queue, Aktivitäten, Disc-Infos |
| [Ripper](ripper.md) | Live-Betrieb: Pipeline, Queue, Aktivitäten, Disc-Infos |
| [Settings](settings.md) | Konfiguration, Skripte, Ketten, Presets, Cronjobs |
| [Historie](history.md) | abgeschlossene/laufende Jobs durchsuchen und nachbearbeiten |
| [Converter](converter.md) | Audio/Video-Dateien konvertieren, Datei-Explorer, Upload |
@@ -15,7 +15,7 @@ Ripster hat fünf Hauptseiten in der Navigation plus eine Expert-Seite.
## Empfohlene Nutzung im Alltag
1. **Start eines neuen Disc-Jobs:** `Dashboard`
1. **Start eines neuen Disc-Jobs:** `Ripper`
2. **Dateien konvertieren oder Audiobooks verarbeiten:** `Converter`
3. **Regeln/Automatisierung anpassen:** `Settings`
4. **Ergebnisse prüfen oder Jobs nachbearbeiten:** `Historie`
@@ -24,5 +24,5 @@ Ripster hat fünf Hauptseiten in der Navigation plus eine Expert-Seite.
## Hinweise zur Navigation
- `Dashboard`, `Settings`, `Historie`, `Converter` und `Downloads` sind direkt in der Kopfnavigation.
- `Ripper`, `Settings`, `Historie`, `Converter` und `Downloads` sind direkt in der Kopfnavigation.
- `Database` ist als Expert-Route verfügbar: `/database`.
+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
@@ -107,7 +107,7 @@ Aktionen:
---
## Wichtige Dialoge im Dashboard
## Wichtige Dialoge im Ripper
### 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) |
| `Scripte` | einzelne Bash-Skripte verwalten und testen |
| `Skriptketten` | Sequenzen aus Skript- und Warte-Schritten bauen |
| `Encode-Presets` | benutzerdefinierte Presets für das Review im Dashboard |
| `Encode-Presets` | benutzerdefinierte Presets für das Review im Ripper |
| `Cronjobs` | zeitgesteuerte Skript-/Kettenausführung |
---
@@ -65,7 +65,7 @@ Ein Preset bündelt:
Verwendung:
- Diese Presets erscheinen später im Dashboard im Review (`Bereit zum Encodieren`).
- Diese Presets erscheinen später im Ripper im Review (`Bereit zum Encodieren`).
## Tab `Cronjobs`
+1 -1
View File
@@ -16,7 +16,7 @@ Dieses Dokumentationsset ist als **Benutzerhandbuch** aufgebaut: erst Bedienung
- **Benutzerhandbuch**
- Installation
- GUI-Seiten im Detail (`Dashboard`, `Settings`, `Historie`, `Database`)
- GUI-Seiten im Detail (`Ripper`, `Settings`, `Historie`, `Database`)
- typische Arbeitsabläufe aus Anwendersicht
- **Technischer Anhang**
- vollständige Einstellungsreferenz
+1 -1
View File
@@ -52,7 +52,7 @@ Default ist aktuell `60` Minuten.
## UI-Verhalten
Bei manueller Entscheidung zeigt das Dashboard Kandidaten inkl. Score/Bewertung und markiert eine Empfehlung.
Bei manueller Entscheidung zeigt das Ripper Kandidaten inkl. Score/Bewertung und markiert eine Empfehlung.
Nach Bestätigung:
+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)
1. `Dashboard`: Disc einlegen, `Analyse starten`
1. `Ripper`: Disc einlegen, `Analyse starten`
2. Metadaten im Dialog übernehmen
3. bei `Bereit zum Encodieren` Titel/Tracks prüfen
4. `Encoding starten`
@@ -38,21 +38,21 @@ In `Historie` -> Detaildialog:
1. `Settings` -> `Scripte`: Skripte anlegen und testen
2. `Settings` -> `Skriptketten`: Ketten bauen und testen
3. im Dashboard-Review Pre-/Post-Ausführungen pro Job auswählen
3. im Ripper-Review Pre-/Post-Ausführungen pro Job auswählen
4. `Settings` -> `Cronjobs`: zeitgesteuerte Ausführung konfigurieren
5. Status im Dashboard (`Skript- / Cron-Status`) überwachen
5. Status im Ripper (`Skript- / Cron-Status`) überwachen
## Workflow 6: Abbruch und Recovery
### Fall A: Job wurde abgebrochen
- im Dashboard optional erzeugte RAW/Movie-Datei bereinigen
- im Ripper optional erzeugte RAW/Movie-Datei bereinigen
- anschließend je nach Ziel: `Retry Rippen` oder `Disk-Analyse neu starten`
### Fall B: Job steht in `Bereit zum Encodieren`, ist aber nicht aktive Session
- in `Historie`: `Im Dashboard öffnen`
- im Dashboard Review erneut prüfen und starten
- in `Historie`: `Im Ripper öffnen`
- im Ripper Review erneut prüfen und starten
### Fall C: RAW ohne Historieneintrag
+44 -28
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import { Navigate, Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
@@ -7,12 +7,14 @@ import { Toast } from 'primereact/toast';
import { ConfirmDialog } from 'primereact/confirmdialog';
import { api } from './api/client';
import { useWebSocket } from './hooks/useWebSocket';
import DashboardPage from './pages/DashboardPage';
import RipperPage from './pages/RipperPage';
import SettingsPage from './pages/SettingsPage';
import HistoryPage from './pages/HistoryPage';
import DatabasePage from './pages/DatabasePage';
import DownloadsPage from './pages/DownloadsPage';
import ConverterPage from './pages/ConverterPage';
import JobsInboxPage from './pages/JobsInboxPage';
import AudiobooksPage from './pages/AudiobooksPage';
function normalizeJobId(value) {
const parsed = Number(value);
@@ -123,11 +125,11 @@ function App() {
const [lastDiscEvent, setLastDiscEvent] = useState(null);
const [expertMode, setExpertMode] = useState(false);
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0);
const [ripperJobsRefreshToken, setRipperJobsRefreshToken] = useState(0);
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0);
const [downloadSummary, setDownloadSummary] = useState(null);
const [pendingDashboardJobId, setPendingDashboardJobId] = useState(null);
const [pendingRipperJobId, setPendingRipperJobId] = useState(null);
const location = useLocation();
const navigate = useNavigate();
const globalToastRef = useRef(null);
@@ -135,7 +137,7 @@ function App() {
// When a virtual CD drive is removed (CD encode/rip finished or failed),
// or when a CD drive transitions away from an active job, force both
// Dashboard and History to re-fetch so jobs leave the live list reliably.
// Ripper and History to re-fetch so jobs leave the live list reliably.
useEffect(() => {
const current = pipeline?.cdDrives || {};
const prev = prevCdDrivesRef.current;
@@ -167,7 +169,7 @@ function App() {
}
if (shouldRefresh) {
setDashboardJobsRefreshToken((t) => t + 1);
setRipperJobsRefreshToken((t) => t + 1);
setHistoryJobsRefreshToken((t) => t + 1);
}
prevCdDrivesRef.current = current;
@@ -235,10 +237,10 @@ function App() {
const uploadedJobId = normalizeJobId(response?.result?.jobId);
await refreshPipeline().catch(() => null);
setDashboardJobsRefreshToken((prev) => prev + 1);
setRipperJobsRefreshToken((prev) => prev + 1);
setHistoryJobsRefreshToken((prev) => prev + 1);
if (uploadedJobId) {
setPendingDashboardJobId(uploadedJobId);
setPendingRipperJobId(uploadedJobId);
}
setAudiobookUpload((prev) => ({
@@ -268,12 +270,12 @@ function App() {
}
};
const handleDashboardJobFocusConsumed = (jobId) => {
const handleRipperJobFocusConsumed = (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setPendingDashboardJobId((prev) => (
setPendingRipperJobId((prev) => (
normalizeJobId(prev) === normalizedJobId ? null : prev
));
};
@@ -525,8 +527,10 @@ function App() {
});
const nav = [
{ label: 'Dashboard', path: '/' },
{ label: 'Jobs', path: '/jobs' },
{ label: 'Ripper', path: '/ripper' },
{ label: 'Converter', path: '/converter' },
{ label: 'Audiobooks', path: '/audiobooks' },
{ label: 'Settings', path: '/settings' },
{ label: 'Historie', path: '/history' },
{ label: 'Downloads', path: '/downloads' },
@@ -543,8 +547,14 @@ function App() {
: (uploadLoadedBytes > 0 ? `${formatBytes(uploadLoadedBytes)} hochgeladen` : null);
const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error';
const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId));
const isDashboardRoute = location.pathname === '/';
const isAudiobooksRoute = location.pathname === '/audiobooks';
const downloadIndicator = getDownloadIndicatorMeta(downloadSummary);
const isNavActive = (path) => {
if (path === '/ripper') {
return location.pathname === '/' || location.pathname === '/ripper';
}
return location.pathname === path;
};
return (
<div className="app-shell">
@@ -570,8 +580,8 @@ function App() {
key={item.path}
label={item.label}
onClick={() => navigate(item.path)}
className={location.pathname === item.path ? 'nav-btn nav-btn-active' : 'nav-btn'}
outlined={location.pathname !== item.path}
className={isNavActive(item.path) ? 'nav-btn nav-btn-active' : 'nav-btn'}
outlined={!isNavActive(item.path)}
/>
))}
</div>
@@ -603,18 +613,14 @@ function App() {
</div>
<div className="app-upload-banner-actions">
{hasUploadedJob && !isDashboardRoute ? (
{hasUploadedJob && !isAudiobooksRoute ? (
<Button
label="Zum Dashboard"
label="Zu Audiobooks"
icon="pi pi-arrow-right"
severity="secondary"
outlined
onClick={() => {
const targetJobId = normalizeJobId(audiobookUpload?.jobId);
if (targetJobId) {
setPendingDashboardJobId(targetJobId);
}
navigate('/');
navigate('/audiobooks');
}}
/>
) : null}
@@ -637,27 +643,37 @@ function App() {
<Route
path="/"
element={
<DashboardPage
<RipperPage
pipeline={pipeline}
hardwareMonitoring={hardwareMonitoring}
lastDiscEvent={lastDiscEvent}
refreshPipeline={refreshPipeline}
audiobookUpload={audiobookUpload}
onAudiobookUpload={handleAudiobookUpload}
jobsRefreshToken={dashboardJobsRefreshToken}
pendingExpandedJobId={pendingDashboardJobId}
onPendingExpandedJobHandled={handleDashboardJobFocusConsumed}
jobsRefreshToken={ripperJobsRefreshToken}
pendingExpandedJobId={pendingRipperJobId}
onPendingExpandedJobHandled={handleRipperJobFocusConsumed}
downloadSummary={downloadSummary}
>
<Outlet />
</DashboardPage>
</RipperPage>
}
>
<Route index element={<Navigate to="jobs" replace />} />
<Route path="ripper" element={null} />
<Route path="settings" element={<SettingsPage />} />
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
<Route path="database" element={<DatabasePage />} />
<Route path="jobs" element={<JobsInboxPage />} />
<Route path="converter" element={<ConverterPage />} />
<Route
path="audiobooks"
element={
<AudiobooksPage
audiobookUpload={audiobookUpload}
onAudiobookUpload={handleAudiobookUpload}
/>
}
/>
</Route>
</Routes>
</main>
+6
View File
@@ -597,6 +597,12 @@ export const api = {
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
getAudiobookJobs() {
return request('/pipeline/audiobook/jobs');
},
getAudiobookOutputTree() {
return request('/pipeline/audiobook/output-tree');
},
async selectMetadata(payload) {
const result = await request('/pipeline/select-metadata', {
method: 'POST',
@@ -297,7 +297,7 @@ export default function AudiobookConfigPanel({
</div>
{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} />
<small>{Math.round(progress)}%{currentChapter ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}: ${currentChapter.title}` : ''}</small>
</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 dashboardTitleId = Number.isFinite(Number(job?.id)) ? `#${Math.trunc(Number(job.id))}` : '#-';
const ripperTitleId = Number.isFinite(Number(job?.id)) ? `#${Math.trunc(Number(job.id))}` : '#-';
const customTitle = String(job?.title || '').trim();
const title = customTitle ? `${dashboardTitleId} | ${customTitle}` : dashboardTitleId;
const title = customTitle ? `${ripperTitleId} | ${customTitle}` : ripperTitleId;
const status = job.status || 'UNKNOWN';
const statusLabel = getStatusLabel(status);
const statusSeverity = getStatusSeverity(status);
@@ -1173,15 +1173,15 @@ export default function ConverterJobCard({
return (
<button
type="button"
className="dashboard-job-row"
className="ripper-job-row"
onClick={onExpand}
>
<div className="poster-thumb dashboard-job-poster-fallback">
<div className="poster-thumb ripper-job-poster-fallback">
{isAudio ? 'Audio' : 'Video'}
</div>
<div className="dashboard-job-row-content">
<div className="dashboard-job-row-main">
<strong className="dashboard-job-title-line">
<div className="ripper-job-row-content">
<div className="ripper-job-row-main">
<strong className="ripper-job-title-line">
<i className="pi pi-circle-fill media-indicator-icon" style={{ color: 'var(--rip-muted)', fontSize: '0.65rem' }} />
<span>{title}</span>
</strong>
@@ -1189,12 +1189,12 @@ export default function ConverterJobCard({
{fileCount != null ? `${fileCount} Datei${fileCount !== 1 ? 'en' : ''}` : null}
</small>
</div>
<div className="dashboard-job-badges">
<div className="ripper-job-badges">
{converterMediaType && mediaTypeBadge(converterMediaType)}
<Tag value={statusLabel} severity={statusSeverity} />
</div>
{active && progressValue !== null && (
<div className="dashboard-job-row-progress">
<div className="ripper-job-row-progress">
<ProgressBar value={progressValue} showValue={false} />
<small>
{liveEta ? `${progressValue}% | ETA ${liveEta}` : `${progressValue}%`}
@@ -1209,20 +1209,20 @@ export default function ConverterJobCard({
// Ausgeklappt
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 */}
<div className="dashboard-job-expanded-head">
<div className="ripper-job-expanded-head">
{job?.poster_url && job.poster_url !== 'N/A' ? (
<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">
<strong className="dashboard-job-title-line">
<div className="ripper-job-expanded-title">
<strong className="ripper-job-title-line">
<i className="pi pi-circle-fill media-indicator-icon" style={{ color: 'var(--rip-muted)', fontSize: '0.65rem' }} />
<span>{title}</span>
</strong>
<div className="dashboard-job-badges">
<div className="ripper-job-badges">
{converterMediaType && mediaTypeBadge(converterMediaType)}
<Tag value={statusLabel} severity={statusSeverity} />
</div>
@@ -17,7 +17,6 @@ const VIDEO_OUTPUT_FORMATS = [
const AUDIO_OUTPUT_FORMATS = [
{ label: 'FLAC', value: 'flac' },
{ label: 'MP3', value: 'mp3' },
{ label: 'AAC', value: 'aac' },
{ label: 'Opus', value: 'opus' },
{ label: 'OGG', value: 'ogg' },
{ label: 'WAV', value: 'wav' }
@@ -49,7 +48,6 @@ export default function ConverterJobConfigDialog({
mp3Mode: 'cbr',
mp3Bitrate: 192,
mp3Quality: 4,
aacBitrate: 256,
opusBitrate: 160,
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' && (
<div className="field">
<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 { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
const ACCEPTED_EXTENSIONS = [
'.mkv', '.mp4', '.m2ts', '.avi', '.mov', '.m4v', '.wmv', '.ts',
'.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape',
'.iso'
].join(',');
const DEFAULT_ALLOWED_EXTENSIONS = [
'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov',
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
];
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.
* 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 [progress, setProgress] = useState(0);
const [statusText, setStatusText] = useState('');
@@ -21,6 +47,22 @@ export default function ConverterUploadPanel({ onUploaded }) {
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = 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 = () => {
setPhase('idle');
@@ -38,6 +80,24 @@ export default function ConverterUploadPanel({ onUploaded }) {
});
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('/'));
setPhase('uploading');
@@ -77,7 +137,7 @@ export default function ConverterUploadPanel({ onUploaded }) {
setPhase('error');
setErrorMsg(err.message || 'Upload fehlgeschlagen.');
}
}, [onUploaded]);
}, [onUploaded, allowedExtensionSet, allowedListLabel]);
const handleFileChange = (e) => {
if (e.target.files?.length > 0) {
@@ -119,7 +179,7 @@ export default function ConverterUploadPanel({ onUploaded }) {
<div className="converter-upload-hint">
Dateien hierher ziehen oder auswählen
<br />
<small>Unterstützt: MKV, MP4, ISO, AVI, FLAC, MP3, WAV, AAC, OGG, OPUS </small>
<small>Unterstützt: {allowedListLabel}</small>
</div>
<div className="converter-upload-buttons">
<Button
@@ -145,7 +205,7 @@ export default function ConverterUploadPanel({ onUploaded }) {
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_EXTENSIONS}
accept={acceptValue}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
@@ -154,6 +214,7 @@ export default function ConverterUploadPanel({ onUploaded }) {
type="file"
webkitdirectory="true"
multiple
accept={acceptValue}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
@@ -5,6 +5,7 @@ import { InputText } from 'primereact/inputtext';
import { InputNumber } from 'primereact/inputnumber';
import { InputSwitch } from 'primereact/inputswitch';
import { Dropdown } from 'primereact/dropdown';
import { Checkbox } from 'primereact/checkbox';
import { Tag } from 'primereact/tag';
import { Button } from 'primereact/button';
import { api } from '../api/client';
@@ -444,6 +445,81 @@ const DOWNLOAD_PATH_KEYS = ['download_dir'];
const LOG_PATH_KEYS = ['log_dir'];
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_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) {
const list = Array.isArray(settings) ? settings : [];
@@ -526,7 +602,8 @@ function SettingField({
{(setting.type === 'string' || setting.type === 'path')
&& setting.key !== 'drive_devices'
&& setting.key !== EXTERNAL_STORAGE_PATHS_KEY ? (
&& setting.key !== EXTERNAL_STORAGE_PATHS_KEY
&& setting.key !== CONVERTER_SCAN_EXTENSION_KEY ? (
<InputText
id={setting.key}
value={value ?? ''}
@@ -550,6 +627,14 @@ function SettingField({
/>
) : null}
{setting.key === CONVERTER_SCAN_EXTENSION_KEY ? (
<ConverterScanExtensionsEditor
value={value}
onChange={onChange}
settingKey={setting.key}
/>
) : null}
{setting.type === 'number' ? (
<InputNumber
id={setting.key}
+3 -6
View File
@@ -790,9 +790,6 @@ export default function JobDetailDialog({
}
return `CBR ${Number(opts?.mp3Bitrate ?? 192)} kbps`;
}
if (converterOutputFormat === 'aac') {
return `${Number(opts?.aacBitrate ?? 256)} kbps`;
}
if (converterOutputFormat === 'opus') {
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>
@@ -1563,7 +1560,7 @@ export default function JobDetailDialog({
{canResumeReady ? (
<div className="action-item">
<Button
label="Im Dashboard öffnen"
label="Im Ripper öffnen"
icon="pi pi-window-maximize"
severity="info"
outlined
@@ -1571,7 +1568,7 @@ export default function JobDetailDialog({
onClick={() => onResumeReady?.(job)}
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>
) : null}
{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 { confirmModal } from '../utils/confirmModal';
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']);
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.wmv', '.ts', '.m2ts', '.iso', '.m4v', '.flv', '.webm']);
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.wav', '.m4a', '.ogg', '.opus']);
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.m2ts', '.iso', '.avi', '.mov']);
const TERMINAL_JOB_STATUSES = new Set(['DONE', 'FINISHED', 'ERROR', 'CANCELLED']);
const DEFAULT_CONVERTER_SCAN_EXTENSIONS = [
'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov',
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
];
const VIDEO_OUTPUT_FORMATS = [
{ label: 'MKV', value: 'mkv' },
{ label: 'MP4', value: 'mp4' },
{ label: 'M4V', value: 'm4v' }
];
function parseConverterScanExtensions(value) {
const seen = new Set();
const parsed = String(value || '')
.split(',')
.map((item) => String(item || '').trim().toLowerCase())
.filter((item) => {
if (!item || !DEFAULT_CONVERTER_SCAN_EXTENSIONS.includes(item) || seen.has(item)) {
return false;
}
seen.add(item);
return true;
});
if (parsed.length === 0) {
return [...DEFAULT_CONVERTER_SCAN_EXTENSIONS];
}
return parsed;
}
function isAudioEntry(e) {
if (e.detectedMediaType === 'audio') return true;
const p = (e.relPath || '').toLowerCase();
@@ -117,11 +139,24 @@ export default function ConverterPage() {
const [expandedJobId, setExpandedJobId] = useState(undefined);
const [videoUserPresets, setVideoUserPresets] = useState([]);
const [videoHbPresets, setVideoHbPresets] = useState([]);
const [uploadExtensions, setUploadExtensions] = useState(() => [...DEFAULT_CONVERTER_SCAN_EXTENSIONS]);
// Entries werden beim Öffnen des Modals gesichert, damit ein außerhalb-Click
// die Selection nicht löscht bevor die Jobs erstellt werden
const jobEntriesRef = useRef([]);
const previousJobStatusesRef = useRef(new Map());
const loadUploadExtensions = useCallback(async () => {
try {
const response = await api.getSettings({ forceRefresh: true });
const allSettings = (response?.categories || []).flatMap((category) => category?.settings || []);
const setting = allSettings.find((item) => String(item?.key || '').trim() === 'converter_scan_extensions');
const value = setting?.value ?? setting?.default_value ?? '';
setUploadExtensions(parseConverterScanExtensions(value));
} catch (_error) {
setUploadExtensions([...DEFAULT_CONVERTER_SCAN_EXTENSIONS]);
}
}, []);
useEffect(() => {
let cancelled = false;
const loadPresets = async () => {
@@ -152,6 +187,10 @@ export default function ConverterPage() {
};
}, []);
useEffect(() => {
void loadUploadExtensions();
}, [loadUploadExtensions]);
const loadJobs = useCallback(async () => {
setLoadingJobs(true);
try {
@@ -237,7 +276,7 @@ export default function ConverterPage() {
useWebSocket({
onMessage: (message) => {
if (!message?.type || !message?.payload) return;
if (!message?.type) return;
if (message.type === 'PIPELINE_PROGRESS') {
const payload = message.payload;
@@ -266,6 +305,19 @@ export default function ConverterPage() {
setExplorerRefreshToken((t) => t + 1);
}
}
if (message.type === 'SETTINGS_UPDATED') {
if (String(message?.payload?.key || '').trim() === 'converter_scan_extensions') {
void loadUploadExtensions();
}
}
if (message.type === 'SETTINGS_BULK_UPDATED') {
const keys = Array.isArray(message?.payload?.keys) ? message.payload.keys : [];
if (keys.includes('converter_scan_extensions')) {
void loadUploadExtensions();
}
}
}
});
@@ -856,7 +908,7 @@ export default function ConverterPage() {
);
return (
<div className="dashboard-subpage-content">
<div className="ripper-subpage-content">
<Toast ref={toastRef} position="top-right" />
{/* Import-Ordner */}
@@ -890,7 +942,7 @@ export default function ConverterPage() {
<small>Keine aktiven Converter-Jobs vorhanden. Abgeschlossene Jobs findest du in der Historie.</small>
</p>
) : (
<div className="dashboard-job-list converter-jobs-list">
<div className="ripper-job-list converter-jobs-list">
{activeJobs.map((job) => {
const plan = parseConverterPlan(job);
const converterMediaType = String(plan?.converterMediaType || '').trim().toLowerCase();
@@ -945,7 +997,10 @@ export default function ConverterPage() {
{/* Upload */}
<Card title="Datei-Upload" subTitle="Einzelne Dateien oder ganze Ordner (Album) hochladen">
<ConverterUploadPanel onUploaded={handleUploaded} />
<ConverterUploadPanel
onUploaded={handleUploaded}
allowedExtensions={uploadExtensions}
/>
</Card>
<JobModeDialog
@@ -1164,27 +1219,27 @@ function ConverterVideoJobCard({
return (
<button
type="button"
className="dashboard-job-row"
className="ripper-job-row"
onClick={onExpand}
>
{job?.poster_url && job.poster_url !== 'N/A' ? (
<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="dashboard-job-row-main">
<strong className="dashboard-job-title-line">
<div className="ripper-job-row-content">
<div className="ripper-job-row-main">
<strong className="ripper-job-title-line">
<i className="pi pi-video media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>{title}</span>
</strong>
<small>#{jobId}</small>
</div>
<div className="dashboard-job-badges">
<div className="ripper-job-badges">
<Tag value={getStatusLabel(status, { queued: isQueued })} severity={getStatusSeverity(normalizeStatus(state), { queued: isQueued })} />
</div>
{hasProgress && (
<div className="dashboard-job-row-progress">
<div className="ripper-job-row-progress">
<ProgressBar value={Math.max(0, Math.min(100, progressValue))} showValue={false} />
</div>
)}
@@ -1195,19 +1250,19 @@ function ConverterVideoJobCard({
}
return (
<div className="dashboard-job-expanded">
<div className="dashboard-job-expanded-head">
<div className="ripper-job-expanded">
<div className="ripper-job-expanded-head">
{job?.poster_url && job.poster_url !== 'N/A' ? (
<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">
<strong className="dashboard-job-title-line">
<div className="ripper-job-expanded-title">
<strong className="ripper-job-title-line">
<i className="pi pi-video media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>#{jobId} | {title}</span>
</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="Converter" severity="info" />
</div>
+4 -4
View File
@@ -745,7 +745,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!skipConfirm) {
const confirmed = await confirmModal({
header: 'Review neu starten',
message: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Dashboard neu getroffen werden.`,
message: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Ripper neu getroffen werden.`,
acceptLabel: 'Review starten',
rejectLabel: 'Abbrechen'
});
@@ -761,7 +761,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
toastRef.current?.show({
severity: 'success',
summary: 'Review-Neustart',
detail: 'Analyse gestartet. Job ist jetzt im Dashboard verfügbar.',
detail: 'Analyse gestartet. Job ist jetzt im Ripper verfügbar.',
life: 3500
});
await load();
@@ -776,7 +776,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!skipConfirm) {
const confirmed = await confirmModal({
header: 'CD-Vorprüfung starten',
message: `CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Dashboard geöffnet.`,
message: `CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Ripper geöffnet.`,
acceptLabel: 'Vorprüfung starten',
rejectLabel: 'Abbrechen'
});
@@ -790,7 +790,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
toastRef.current?.show({
severity: 'success',
summary: 'CD-Vorprüfung gestartet',
detail: 'Job ist jetzt im Dashboard verfügbar — bitte Metadaten und Einstellungen wählen.',
detail: 'Job ist jetzt im Ripper verfügbar — bitte Metadaten und Einstellungen wählen.',
life: 4000
});
await load();
+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 { ProgressBar } from 'primereact/progressbar';
import { Dialog } from 'primereact/dialog';
import { FileUpload } from 'primereact/fileupload';
import { InputNumber } from 'primereact/inputnumber';
import { InputText } from 'primereact/inputtext';
import { api } from '../api/client';
@@ -22,11 +21,12 @@ import otherIndicatorIcon from '../assets/media-other.svg';
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
import { confirmModal } from '../utils/confirmModal';
import { isConverterJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy';
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
const activeCdDriveStates = ['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING'];
const dashboardStatuses = new Set([
const ripperStatuses = new Set([
'ANALYZING',
'METADATA_SELECTION',
'WAITING_FOR_USER_DECISION',
@@ -479,6 +479,11 @@ function getAnalyzeContext(job) {
}
function resolveMediaType(job) {
const centralMediaType = resolveCentralMediaType(job);
if (centralMediaType && centralMediaType !== 'other') {
return centralMediaType;
}
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const candidates = [
job?.mediaType,
@@ -533,7 +538,7 @@ function resolveMediaType(job) {
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
return 'audiobook';
}
return 'other';
return centralMediaType || 'other';
}
function mediaIndicatorMeta(job) {
@@ -906,13 +911,11 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
};
}
export default function DashboardPage({
export default function RipperPage({
pipeline,
hardwareMonitoring,
lastDiscEvent,
refreshPipeline,
audiobookUpload,
onAudiobookUpload,
jobsRefreshToken,
pendingExpandedJobId,
onPendingExpandedJobHandled,
@@ -957,9 +960,8 @@ export default function DashboardPage({
const [runtimeActionBusyKeys, setRuntimeActionBusyKeys] = useState(() => new Set());
const [runtimeRecentClearing, setRuntimeRecentClearing] = useState(false);
const [jobsLoading, setJobsLoading] = useState(false);
const [dashboardJobs, setDashboardJobs] = useState([]);
const [ripperJobs, setRipperJobs] = useState([]);
const [expandedJobId, setExpandedJobId] = useState(undefined);
const [audiobookUploadFile, setAudiobookUploadFile] = useState(null);
const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null });
const [activationBytesInput, setActivationBytesInput] = useState('');
const [activationBytesBusy, setActivationBytesBusy] = useState(false);
@@ -972,8 +974,6 @@ export default function DashboardPage({
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
const toastRef = useRef(null);
const audiobookFileUploadRef = useRef(null);
const [audiobookUploadStatusVisible, setAudiobookUploadStatusVisible] = useState(false);
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase();
const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId);
@@ -987,7 +987,7 @@ export default function DashboardPage({
[pipeline?.cdDrives]
);
// Detects when a background job reaches a terminal interactive state via PIPELINE_PROGRESS
// (e.g. READY_TO_ENCODE or WAITING_FOR_USER_DECISION), triggering a dashboard jobs reload.
// (e.g. READY_TO_ENCODE or WAITING_FOR_USER_DECISION), triggering a ripper jobs reload.
const jobProgressInteractiveKey = useMemo(() => Object.entries(pipeline?.jobProgress || {})
.filter(([, v]) => {
const s = String(v?.state || '').toUpperCase();
@@ -1038,42 +1038,15 @@ export default function DashboardPage({
}, [storageMetrics]);
const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : [];
const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : [];
const audiobookUploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
const audiobookUploadBusy = audiobookUploadPhase === 'uploading' || audiobookUploadPhase === 'processing';
const audiobookUploadProgress = Number.isFinite(Number(audiobookUpload?.progressPercent))
? Math.max(0, Math.min(100, Number(audiobookUpload.progressPercent)))
: 0;
const audiobookUploadLoadedBytes = Number(audiobookUpload?.loadedBytes || 0);
const audiobookUploadTotalBytes = Number(audiobookUpload?.totalBytes || 0);
const audiobookUploadFileName = String(audiobookUpload?.fileName || '').trim()
|| String(audiobookUploadFile?.name || '').trim()
|| null;
const audiobookUploadStatusTone = audiobookUploadPhase === 'error'
? 'danger'
: audiobookUploadPhase === 'completed'
? 'success'
: audiobookUploadPhase === 'processing'
? 'info'
: audiobookUploadPhase === 'uploading'
? 'warning'
: 'secondary';
const audiobookUploadStatusLabel = audiobookUploadPhase === 'uploading'
? 'Upload läuft'
: audiobookUploadPhase === 'processing'
? 'Server verarbeitet'
: audiobookUploadPhase === 'completed'
? 'Bereit'
: audiobookUploadPhase === 'error'
? 'Fehler'
: 'Inaktiv';
const isSubpageRoute = location.pathname !== '/';
const isRipperMainRoute = location.pathname === '/' || location.pathname === '/ripper';
const isSubpageRoute = !isRipperMainRoute;
const loadDashboardJobs = async () => {
const loadRipperJobs = async () => {
setJobsLoading(true);
try {
const [jobsResponse, queueResponse] = await Promise.allSettled([
api.getJobs({
statuses: Array.from(dashboardStatuses),
statuses: Array.from(ripperStatuses),
limit: 160,
lite: true
}),
@@ -1085,24 +1058,12 @@ export default function DashboardPage({
if (queueResponse.status === 'fulfilled') {
setQueueState(normalizeQueue(queueResponse.value?.queue));
}
const shouldDisplayOnDashboard = (job) => {
const rawMediaType = String(job?.media_type || '').trim().toLowerCase();
const resolvedMediaType = String(job?.mediaType || '').trim().toLowerCase();
const planMediaProfile = String(job?.encodePlan?.mediaProfile || '').trim().toLowerCase();
const converterPlanType = String(job?.encodePlan?.converterMediaType || '').trim().toLowerCase();
const isConverterJob = (
rawMediaType === 'converter'
|| resolvedMediaType === 'converter'
|| planMediaProfile === 'converter'
|| converterPlanType === 'audio'
|| converterPlanType === 'video'
|| converterPlanType === 'iso'
);
if (isConverterJob) {
const shouldDisplayOnRipper = (job) => {
if (isConverterJob(job)) {
return false;
}
const normalizedStatus = String(job?.status || '').trim().toUpperCase();
if (!dashboardStatuses.has(normalizedStatus)) {
if (!ripperStatuses.has(normalizedStatus)) {
return false;
}
if (normalizedStatus !== 'CANCELLED') {
@@ -1112,7 +1073,7 @@ export default function DashboardPage({
return !hiddenCancelledReviewOrigins.has(cancelledOrigin);
};
const next = allJobs
.filter((job) => shouldDisplayOnDashboard(job))
.filter((job) => shouldDisplayOnRipper(job))
.sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0));
const pinnedJobIds = new Set();
@@ -1136,7 +1097,7 @@ export default function DashboardPage({
continue;
}
const job = result.value?.job;
if (job && shouldDisplayOnDashboard(job)) {
if (job && shouldDisplayOnRipper(job)) {
next.unshift(job);
}
}
@@ -1153,7 +1114,7 @@ export default function DashboardPage({
deduped.push(job);
}
setDashboardJobs(deduped);
setRipperJobs(deduped);
// Prüfen ob Audiobook-Jobs auf Activation Bytes warten
try {
@@ -1174,7 +1135,7 @@ export default function DashboardPage({
// ignorieren
}
} catch (_error) {
setDashboardJobs([]);
setRipperJobs([]);
} finally {
setJobsLoading(false);
}
@@ -1245,7 +1206,7 @@ export default function DashboardPage({
}, [pipeline?.queue]);
useEffect(() => {
void loadDashboardJobs();
void loadRipperJobs();
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, cdDriveLifecycleKey, jobsRefreshToken, jobProgressInteractiveKey]);
useEffect(() => {
@@ -1259,13 +1220,13 @@ export default function DashboardPage({
if (!requestedJobId) {
return;
}
const hasRequestedJob = dashboardJobs.some((job) => normalizeJobId(job?.id) === requestedJobId);
const hasRequestedJob = ripperJobs.some((job) => normalizeJobId(job?.id) === requestedJobId);
if (!hasRequestedJob) {
return;
}
setExpandedJobId(requestedJobId);
onPendingExpandedJobHandled?.(requestedJobId);
}, [pendingExpandedJobId, dashboardJobs, onPendingExpandedJobHandled]);
}, [pendingExpandedJobId, ripperJobs, onPendingExpandedJobHandled]);
useEffect(() => {
let cancelled = false;
@@ -1308,7 +1269,7 @@ export default function DashboardPage({
useEffect(() => {
const normalizedExpanded = normalizeJobId(expandedJobId);
const hasExpanded = dashboardJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
const hasExpanded = ripperJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
if (hasExpanded) {
return;
}
@@ -1318,30 +1279,16 @@ export default function DashboardPage({
return;
}
if (currentPipelineJobId && dashboardJobs.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
if (currentPipelineJobId && ripperJobs.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
setExpandedJobId(currentPipelineJobId);
return;
}
setExpandedJobId(normalizeJobId(dashboardJobs[0]?.id));
}, [dashboardJobs, expandedJobId, currentPipelineJobId]);
useEffect(() => {
if (audiobookUploadPhase === 'idle') {
setAudiobookUploadStatusVisible(false);
return undefined;
}
setAudiobookUploadStatusVisible(true);
if (audiobookUploadPhase === 'completed') {
const timer = setTimeout(() => setAudiobookUploadStatusVisible(false), 5000);
return () => clearTimeout(timer);
}
return undefined;
}, [audiobookUploadPhase]);
setExpandedJobId(normalizeJobId(ripperJobs[0]?.id));
}, [ripperJobs, expandedJobId, currentPipelineJobId]);
const pipelineByJobId = useMemo(() => {
const map = new Map();
for (const job of dashboardJobs) {
for (const job of ripperJobs) {
const id = normalizeJobId(job?.id);
if (!id) {
continue;
@@ -1349,14 +1296,14 @@ export default function DashboardPage({
map.set(id, buildPipelineFromJob(job, pipeline, currentPipelineJobId));
}
return map;
}, [dashboardJobs, pipeline, currentPipelineJobId]);
}, [ripperJobs, pipeline, currentPipelineJobId]);
const buildMetadataContextForJob = (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return null;
}
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
const pipelineForJob = pipelineByJobId.get(normalizedJobId) || null;
const context = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
? pipelineForJob.context
@@ -1401,7 +1348,7 @@ export default function DashboardPage({
};
}
const pendingJob = dashboardJobs.find((job) => {
const pendingJob = ripperJobs.find((job) => {
const normalized = normalizeStatus(job?.status);
return normalized === 'METADATA_SELECTION' || normalized === 'WAITING_FOR_USER_DECISION';
});
@@ -1409,7 +1356,7 @@ export default function DashboardPage({
return null;
}
return buildMetadataContextForJob(pendingJob.id);
}, [pipeline, dashboardJobs, pipelineByJobId]);
}, [pipeline, ripperJobs, pipelineByJobId]);
const effectiveMetadataDialogContext = metadataDialogContext
|| defaultMetadataDialogContext
@@ -1452,7 +1399,7 @@ export default function DashboardPage({
try {
const response = await api.analyzeDisc();
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
const analyzedJobId = normalizeJobId(response?.result?.jobId);
if (analyzedJobId) {
setMetadataDialogContext({
@@ -1486,7 +1433,7 @@ export default function DashboardPage({
try {
const response = await api.analyzeDisc(devicePath);
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
const analyzedJobId = normalizeJobId(response?.result?.jobId);
if (analyzedJobId) {
setMetadataDialogContext({
@@ -1553,7 +1500,7 @@ export default function DashboardPage({
life: 2800
});
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
} catch (error) {
showError(error);
} finally {
@@ -1595,7 +1542,7 @@ export default function DashboardPage({
const handleCancel = async (jobId = null, jobState = null) => {
const cancelledJobId = normalizeJobId(jobId) || currentPipelineJobId;
const cancelledJob = dashboardJobs.find((item) => normalizeJobId(item?.id) === cancelledJobId) || null;
const cancelledJob = ripperJobs.find((item) => normalizeJobId(item?.id) === cancelledJobId) || null;
const cancelledState = String(
jobState
|| cancelledJob?.status
@@ -1607,7 +1554,7 @@ export default function DashboardPage({
try {
await api.cancelPipeline(cancelledJobId);
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
let latestCancelledJob = null;
const fetchLatestCancelledJob = async () => {
if (!cancelledJobId) {
@@ -1701,7 +1648,7 @@ export default function DashboardPage({
life: 4000
});
}
await loadDashboardJobs();
await loadRipperJobs();
await refreshPipeline();
setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null });
} catch (error) {
@@ -1718,7 +1665,7 @@ export default function DashboardPage({
}
const startOptions = options && typeof options === 'object' ? options : {};
const startJobRow = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
const startJobRow = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
const mediaType = resolveMediaType(startJobRow);
setJobBusy(normalizedJobId, true);
try {
@@ -1753,7 +1700,7 @@ export default function DashboardPage({
: await api.startJob(normalizedJobId);
const result = getQueueActionResult(response);
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
if (result.queued) {
showQueuedToast(toastRef, 'Start', result);
} else {
@@ -1766,32 +1713,6 @@ export default function DashboardPage({
}
};
const handleAudiobookUpload = async () => {
if (!audiobookUploadFile) {
showError(new Error('Bitte zuerst eine AAX-Datei auswählen.'));
return;
}
try {
const response = await onAudiobookUpload?.(audiobookUploadFile, { startImmediately: false });
const result = response?.result || {};
const uploadedJobId = normalizeJobId(result.jobId);
if (result.needsActivationBytes && result.checksum) {
setActivationBytesInput('');
setActivationBytesDialog({ visible: true, checksum: result.checksum, jobId: uploadedJobId });
} else if (uploadedJobId) {
toastRef.current?.show({
severity: 'success',
summary: 'Audiobook importiert',
detail: `Job #${uploadedJobId} wurde angelegt und wird geoeffnet.`,
life: 3200
});
}
setAudiobookUploadFile(null);
} catch (error) {
showError(error);
}
};
const handleSaveActivationBytes = async () => {
const { checksum, jobId } = activationBytesDialog;
const bytes = activationBytesInput.trim().toLowerCase();
@@ -1806,7 +1727,7 @@ export default function DashboardPage({
detail: jobId ? `Job #${jobId} kann jetzt gestartet werden.` : 'Bytes wurden lokal gespeichert.',
life: 4000
});
await loadDashboardJobs();
await loadRipperJobs();
} catch (error) {
showError(error);
} finally {
@@ -1833,7 +1754,7 @@ export default function DashboardPage({
const response = await api.startAudiobook(normalizedJobId, audiobookConfig || {});
const result = getQueueActionResult(response);
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
if (result.queued) {
showQueuedToast(toastRef, 'Audiobook', result);
} else {
@@ -1870,7 +1791,7 @@ export default function DashboardPage({
}
await api.confirmEncodeReview(jobId, payload);
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
setExpandedJobId(normalizedJobId);
} catch (error) {
showError(error);
@@ -1888,7 +1809,7 @@ export default function DashboardPage({
selectedPlaylist: selectedPlaylist || null
});
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
setExpandedJobId(normalizedJobId);
} catch (error) {
showError(error);
@@ -1906,7 +1827,7 @@ export default function DashboardPage({
selectedHandBrakeTitleId: selectedHandBrakeTitleId || null
});
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
setExpandedJobId(normalizedJobId);
} catch (error) {
showError(error);
@@ -1923,7 +1844,7 @@ export default function DashboardPage({
const result = getQueueActionResult(response);
const retryJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
if (result.queued) {
showQueuedToast(toastRef, 'Retry', result);
} else {
@@ -1936,12 +1857,12 @@ export default function DashboardPage({
}
};
const handleDeleteDashboardJob = async (jobId) => {
const handleDeleteRipperJob = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim();
const confirmed = await confirmModal({
header: 'Job löschen',
@@ -1978,7 +1899,7 @@ export default function DashboardPage({
keepDetectedDevice: false
});
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
setExpandedJobId((prev) => (normalizeJobId(prev) === normalizedJobId ? null : prev));
toastRef.current?.show({
severity: 'success',
@@ -1994,7 +1915,7 @@ export default function DashboardPage({
};
const handleRestartEncodeWithLastSettings = async (jobId) => {
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
const title = job?.title || job?.detected_title || `Job #${jobId}`;
if (job?.encodeSuccess) {
const confirmed = await confirmModal({
@@ -2017,7 +1938,7 @@ export default function DashboardPage({
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
if (result.queued) {
showQueuedToast(toastRef, 'Encode-Neustart', result);
} else {
@@ -2042,7 +1963,7 @@ export default function DashboardPage({
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
setExpandedJobId(replacementJobId);
} catch (error) {
showError(error);
@@ -2063,7 +1984,7 @@ export default function DashboardPage({
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
if (result.queued) {
showQueuedToast(toastRef, 'CD-Vorprüfung', result);
} else {
@@ -2213,7 +2134,7 @@ export default function DashboardPage({
await api.selectMetadata(payload);
}
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
setMetadataDialogReassignMode(false);
@@ -2294,7 +2215,7 @@ export default function DashboardPage({
try {
await api.selectCdMetadata(payload);
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
setCdMetadataDialogVisible(false);
setCdMetadataDialogContext(null);
} catch (error) {
@@ -2320,7 +2241,7 @@ export default function DashboardPage({
}
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
await loadDashboardJobs();
await loadRipperJobs();
if (replacementJobId) {
setExpandedJobId(replacementJobId);
}
@@ -2485,7 +2406,7 @@ export default function DashboardPage({
try {
const response = normalizedAction === 'next-step'
? await api.requestRuntimeNextStep(activityId)
: await api.cancelRuntimeActivity(activityId, { reason: 'Benutzerabbruch via Dashboard' });
: await api.cancelRuntimeActivity(activityId, { reason: 'Benutzerabbruch via Ripper' });
if (response?.snapshot) {
setRuntimeActivities(normalizeRuntimeActivitiesPayload(response.snapshot));
} else {
@@ -2564,77 +2485,8 @@ export default function DashboardPage({
<div className="page-grid">
<Toast ref={toastRef} />
<div className="dashboard-3col-grid">
<div className="dashboard-col dashboard-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>
<div className="ripper-3col-grid">
<div className="ripper-col ripper-col-left">
<Card title="Disk-Information">
{/* Per-drive list */}
{allDrives.length > 0 ? (
@@ -2888,20 +2740,20 @@ export default function DashboardPage({
</Card>
</div>
<div className="dashboard-col dashboard-col-center">
<div className="ripper-col ripper-col-center">
{isSubpageRoute ? (
<div className="dashboard-subpage-content">
<div className="ripper-subpage-content">
{children}
</div>
) : (
<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>
) : dashboardJobs.length === 0 ? (
<p>Keine relevanten Jobs im Dashboard (aktive/fortsetzbare Status).</p>
) : ripperJobs.length === 0 ? (
<p>Keine relevanten Jobs im Ripper (aktive/fortsetzbare Status).</p>
) : (
<div className="dashboard-job-list">
{dashboardJobs.map((job) => {
<div className="ripper-job-list">
{ripperJobs.map((job) => {
const jobId = normalizeJobId(job?.id);
if (!jobId) {
return null;
@@ -2945,15 +2797,15 @@ export default function DashboardPage({
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
if (isExpanded) {
return (
<div key={jobId} className="dashboard-job-expanded">
<div className="dashboard-job-expanded-head">
<div key={jobId} className="ripper-job-expanded">
<div className="ripper-job-expanded-head">
{(job?.poster_url && job.poster_url !== 'N/A') ? (
<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">
<strong className="dashboard-job-title-line">
<div className="ripper-job-expanded-title">
<strong className="ripper-job-title-line">
<img
src={mediaIndicator.src}
alt={mediaIndicator.alt}
@@ -2962,7 +2814,7 @@ export default function DashboardPage({
/>
<span>#{jobId} | {jobTitle}</span>
</strong>
<div className="dashboard-job-badges">
<div className="ripper-job-badges">
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
@@ -2992,7 +2844,7 @@ export default function DashboardPage({
onCancel={() => handleCancel(jobId, jobState)}
onRetry={() => handleRetry(jobId)}
onRestartReview={() => handleRestartCdReviewFromRaw(jobId)}
onDeleteJob={() => handleDeleteDashboardJob(jobId)}
onDeleteJob={() => handleDeleteRipperJob(jobId)}
onOpenMetadata={() => {
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
? pipelineForJob.context
@@ -3024,7 +2876,7 @@ export default function DashboardPage({
onStart={(config) => handleAudiobookStart(jobId, config)}
onCancel={() => handleCancel(jobId, jobState)}
onRetry={() => handleRetry(jobId)}
onDeleteJob={() => handleDeleteDashboardJob(jobId)}
onDeleteJob={() => handleDeleteRipperJob(jobId)}
busy={busyJobIds.has(jobId) || needsBytes}
/>
</>
@@ -3048,7 +2900,7 @@ export default function DashboardPage({
onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
onCancel={handleCancel}
onRetry={handleRetry}
onDeleteJob={handleDeleteDashboardJob}
onDeleteJob={handleDeleteRipperJob}
onRemoveFromQueue={handleRemoveQueuedJob}
isQueued={isQueued}
busy={busyJobIds.has(jobId)}
@@ -3062,17 +2914,17 @@ export default function DashboardPage({
<button
key={jobId}
type="button"
className="dashboard-job-row"
className="ripper-job-row"
onClick={() => setExpandedJobId(jobId)}
>
{job?.poster_url && job.poster_url !== 'N/A' ? (
<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="dashboard-job-row-main">
<strong className="dashboard-job-title-line">
<div className="ripper-job-row-content">
<div className="ripper-job-row-main">
<strong className="ripper-job-title-line">
<img
src={mediaIndicator.src}
alt={mediaIndicator.alt}
@@ -3095,7 +2947,7 @@ export default function DashboardPage({
)}
</small>
</div>
<div className="dashboard-job-badges">
<div className="ripper-job-badges">
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
@@ -3104,7 +2956,7 @@ export default function DashboardPage({
: null}
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
</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} />
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}</small>
</div>
@@ -3119,7 +2971,7 @@ export default function DashboardPage({
)}
</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.">
<div className="pipeline-queue-meta">
<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" />}
<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={`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={`Idle: ${queueState?.idleCount || 0}`} severity={(queueState?.idleCount || 0) > 0 ? 'info' : 'success'} />
</div>
<div className="pipeline-queue-grid">
<div className="pipeline-queue-col">
<div className="pipeline-queue-col queue-col-running">
<h4>Laufende Jobs</h4>
{queueRunningJobs.length === 0 ? (
<small>Keine laufenden Jobs.</small>
@@ -3177,7 +3029,7 @@ export default function DashboardPage({
})
)}
</div>
<div className="pipeline-queue-col">
<div className="pipeline-queue-col queue-col-idle">
<h4>Idle</h4>
{queueIdleJobs.length === 0 ? (
<small>Keine Idle-Jobs.</small>
@@ -3215,7 +3067,7 @@ export default function DashboardPage({
})
)}
</div>
<div className="pipeline-queue-col">
<div className="pipeline-queue-col queue-col-queued">
<div className="pipeline-queue-col-header">
<h4>Warteschlange</h4>
<button
@@ -3489,9 +3341,9 @@ export default function DashboardPage({
</div>
)}
<div className="dashboard-downloads-row">
<div className="ripper-downloads-row">
<h4>ZIP-Downloads</h4>
<div className="dashboard-downloads-meta">
<div className="ripper-downloads-meta">
{downloadSummary?.activeCount > 0 ? (
<Tag icon="pi pi-spinner" value={`${downloadSummary.activeCount} aktiv`} severity="warning" />
) : downloadSummary?.totalCount > 0 ? (
+186 -40
View File
@@ -13,7 +13,7 @@
--rip-muted: #6a4d38;
--rip-panel: #fffaf1;
--rip-panel-soft: #fdf5e7;
--dashboard-side-width: 20rem;
--ripper-side-width: 20rem;
/* PrimeReact theme tokens */
--primary-color: var(--rip-brown-600);
@@ -318,22 +318,22 @@ body {
min-width: 0;
}
.dashboard-3col-grid {
.ripper-3col-grid {
display: grid;
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;
align-items: start;
}
.dashboard-col {
.ripper-col {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
.dashboard-subpage-content {
.ripper-subpage-content {
width: 100%;
min-width: 0;
display: flex;
@@ -341,21 +341,121 @@ body {
gap: 1rem;
}
.dashboard-subpage-content > * {
.ripper-subpage-content > * {
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) {
.dashboard-3col-grid {
.ripper-3col-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
}
.dashboard-col-right {
.ripper-col-right {
grid-column: 1 / -1;
}
}
@media (max-width: 700px) {
.dashboard-3col-grid {
.ripper-3col-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -436,7 +536,7 @@ body {
text-align: right;
}
.dashboard-drive-state {
.ripper-drive-state {
margin: 0.5rem 0;
}
@@ -812,6 +912,18 @@ body {
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 {
margin: 0;
}
@@ -1164,12 +1276,12 @@ body {
height: 1.9rem;
}
.dashboard-job-list {
.ripper-job-list {
display: grid;
gap: 0.6rem;
}
.dashboard-job-row {
.ripper-job-row {
width: 100%;
border: 1px solid var(--rip-border);
border-radius: 0.55rem;
@@ -1184,12 +1296,12 @@ body {
cursor: pointer;
}
.dashboard-job-row:hover {
.ripper-job-row:hover {
border-color: var(--rip-brown-600);
background: #fbf0df;
}
.dashboard-job-row-content {
.ripper-job-row-content {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -1197,27 +1309,27 @@ body {
align-items: start;
}
.dashboard-job-row-main {
.ripper-job-row-main {
display: grid;
gap: 0.3rem;
min-width: 0;
}
.dashboard-job-row-main strong,
.dashboard-job-row-main small {
.ripper-job-row-main strong,
.ripper-job-row-main small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-job-title-line {
.ripper-job-title-line {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.dashboard-job-title-line > span {
.ripper-job-title-line > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -1231,17 +1343,17 @@ body {
display: inline-block;
}
.dashboard-job-row-progress {
.ripper-job-row-progress {
grid-column: 1 / -1;
display: grid;
gap: 0.2rem;
}
.dashboard-job-row-progress .p-progressbar {
.ripper-job-row-progress .p-progressbar {
height: 0.42rem;
}
.dashboard-job-row-progress small {
.ripper-job-row-progress small {
color: var(--rip-muted);
white-space: normal;
}
@@ -1367,7 +1479,7 @@ body {
margin-top: 0.15rem;
}
.dashboard-job-badges {
.ripper-job-badges {
display: flex;
align-items: center;
gap: 0.35rem;
@@ -1422,7 +1534,7 @@ body {
display: block;
}
.dashboard-job-poster-fallback {
.ripper-job-poster-fallback {
display: flex;
align-items: center;
justify-content: center;
@@ -1431,7 +1543,7 @@ body {
background: #f6ebd6;
}
.dashboard-job-expanded {
.ripper-job-expanded {
border: 1px solid var(--rip-border);
border-radius: 0.6rem;
padding: 0.6rem;
@@ -1440,14 +1552,14 @@ body {
gap: 0.6rem;
}
.dashboard-job-expanded-head {
.ripper-job-expanded-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.dashboard-job-expanded-title {
.ripper-job-expanded-title {
display: grid;
gap: 0.35rem;
flex: 1;
@@ -1885,7 +1997,27 @@ body {
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) */
.drive-list {
display: flex;
@@ -3082,18 +3214,18 @@ body {
gap: 0.5rem;
}
.dashboard-downloads-row {
.ripper-downloads-row {
border-top: 1px solid var(--surface-border);
padding-top: 0.75rem;
margin-top: 0.5rem;
}
.dashboard-downloads-row h4 {
.ripper-downloads-row h4 {
margin: 0 0 0.45rem;
font-size: 0.88rem;
}
.dashboard-downloads-meta {
.ripper-downloads-meta {
display: flex;
align-items: center;
gap: 0.5rem;
@@ -3802,15 +3934,15 @@ body {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dashboard-job-row {
.ripper-job-row {
grid-template-columns: 48px minmax(0, 1fr) auto;
}
.dashboard-job-badges {
.ripper-job-badges {
justify-content: flex-start;
}
.dashboard-job-expanded-head {
.ripper-job-expanded-head {
flex-wrap: wrap;
}
@@ -3987,18 +4119,18 @@ body {
display: none;
}
.dashboard-job-row {
.ripper-job-row {
grid-template-columns: 1fr;
}
.dashboard-job-row .poster-thumb,
.dashboard-job-row .dashboard-job-poster-fallback {
.ripper-job-row .poster-thumb,
.ripper-job-row .ripper-job-poster-fallback {
width: 52px;
height: 76px;
}
.dashboard-job-row-main strong,
.dashboard-job-row-main small {
.ripper-job-row-main strong,
.ripper-job-row-main small {
white-space: normal;
}
@@ -4007,7 +4139,7 @@ body {
white-space: normal;
}
.dashboard-job-title-line > span {
.ripper-job-title-line > span {
white-space: normal;
}
@@ -5248,6 +5380,20 @@ body {
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 {
+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
- GUI-Seiten:
- gui/index.md
- Dashboard: gui/dashboard.md
- Ripper: gui/ripper.md
- Settings: gui/settings.md
- Historie: gui/history.md
- Converter: gui/converter.md
+2 -1
View File
@@ -14,7 +14,8 @@
"qa:audiobook:analysis": "node ./scripts/smoketest/qa-audiobook-analysis.js",
"qa:cd:check": "bash ./scripts/smoketest/qa-cd-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"
},
"devDependencies": {