0.12.0-18 Tests and Converter Fixes

This commit is contained in:
2026-03-31 12:51:32 +00:00
parent aea8fa6772
commit 5aa65ad4c1
24 changed files with 2281 additions and 249 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "0.12.0-17",
"version": "0.12.0-18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "0.12.0-17",
"version": "0.12.0-18",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.12.0-17",
"version": "0.12.0-18",
"private": true,
"type": "commonjs",
"scripts": {
+43 -11
View File
@@ -339,7 +339,13 @@ class ConverterPlugin extends SourcePlugin {
cmd: handBrakeConfig.cmd, args: handBrakeConfig.args,
jobId: job?.id,
progressParser: parseHandBrakeProgress,
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText || `Encoding ${pct}%`),
onProgress: (pct, statusText, eta) => ctx.emitProgress(
Math.round(pct),
statusText || `Encoding ${pct}%`,
eta ?? null
),
appendLogLine: ctx?.extra?.appendLogLine,
logSource: 'HANDBRAKE',
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
@@ -451,6 +457,8 @@ class ConverterPlugin extends SourcePlugin {
await _spawnAndWait({
cmd: encodeConfig.cmd, args: encodeConfig.args,
jobId: job?.id,
appendLogLine: ctx?.extra?.appendLogLine,
logSource: 'FFMPEG',
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
@@ -504,6 +512,8 @@ class ConverterPlugin extends SourcePlugin {
await _spawnAndWait({
cmd: encodeConfig.cmd, args: encodeConfig.args,
jobId: job?.id,
appendLogLine: ctx?.extra?.appendLogLine,
logSource: 'FFMPEG',
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
@@ -536,6 +546,8 @@ class ConverterPlugin extends SourcePlugin {
await _spawnAndWait({
cmd: encodeConfig.cmd, args: encodeConfig.args,
jobId: job?.id,
appendLogLine: ctx?.extra?.appendLogLine,
logSource: 'FFMPEG',
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
@@ -591,25 +603,45 @@ class ConverterPlugin extends SourcePlugin {
}
}
async function _spawnAndWait({ cmd, args, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
async function _spawnAndWait({
cmd,
args,
jobId,
progressParser,
onProgress,
appendLogLine,
logSource = 'PROCESS',
isCancelled,
onProcessHandle,
context
}) {
if (typeof isCancelled === 'function' && isCancelled()) {
const err = new Error('Job wurde vom Benutzer abgebrochen.');
err.statusCode = 409;
throw err;
}
const handleLine = (stream, line) => {
if (progressParser && typeof onProgress === 'function') {
const progress = progressParser(line);
if (progress?.percent != null) {
onProgress(progress.percent, progress.detail || null, progress.eta ?? null);
}
}
if (typeof appendLogLine === 'function') {
try {
appendLogLine(logSource, line, stream);
} catch (_error) {
// ignore log append errors in process stream
}
}
};
const handle = spawnTrackedProcess({
cmd,
args,
onStdoutLine: () => {},
onStderrLine: (line) => {
if (progressParser && typeof onProgress === 'function') {
const progress = progressParser(line);
if (progress?.percent != null) {
onProgress(progress.percent, progress.detail || null);
}
}
},
onStdoutLine: (line) => handleLine('stdout', line),
onStderrLine: (line) => handleLine('stderr', line),
context: context || { jobId }
});
+4 -1
View File
@@ -280,6 +280,7 @@ router.post(
const selectedPreEncodeChainIds = req.body?.selectedPreEncodeChainIds;
const skipPipelineStateUpdate = Boolean(req.body?.skipPipelineStateUpdate);
const selectedUserPresetId = req.body?.selectedUserPresetId ?? null;
const selectedHandBrakePreset = req.body?.selectedHandBrakePreset ?? null;
logger.info('post:confirm-encode', {
reqId: req.reqId,
jobId,
@@ -287,6 +288,7 @@ router.post(
selectedTrackSelectionProvided: Boolean(selectedTrackSelection),
skipPipelineStateUpdate,
selectedUserPresetId,
selectedHandBrakePreset,
selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds)
? selectedPostEncodeScriptIds.length
: 0,
@@ -308,7 +310,8 @@ router.post(
selectedPostEncodeChainIds,
selectedPreEncodeChainIds,
skipPipelineStateUpdate,
selectedUserPresetId
selectedUserPresetId,
selectedHandBrakePreset
});
res.json({ job });
})
+58 -6
View File
@@ -22,7 +22,7 @@ function parseJsonSafe(raw, fallback = null) {
const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024;
const processLogStreams = new Map();
const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'cd', 'audiobook', 'other'];
const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'cd', 'audiobook', 'converter', 'other'];
const RAW_INCOMPLETE_PREFIX = 'Incomplete_';
const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_';
const PROCESS_LOG_FILE_PATTERN = /^job-(\d+)\.process\.log$/i;
@@ -329,7 +329,27 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
// Converter jobs: detected via media_type field (plan.mediaProfile = 'converter')
const rawMediaType = normalizeMediaTypeValue(job?.media_type);
const rawPlanProfile = String(plan?.mediaProfile || '').trim().toLowerCase();
if (rawPlanProfile === 'converter' || rawMediaType === 'converter') {
const converterMediaTypeHint = String(plan?.converterMediaType || '').trim().toLowerCase();
const hasConverterPathHint = (value) => {
const normalized = String(value || '')
.trim()
.replace(/\\/g, '/')
.toLowerCase();
if (!normalized) {
return false;
}
return /(^|\/)converter(\/|$)/.test(normalized);
};
if (
rawPlanProfile === 'converter'
|| rawMediaType === 'converter'
|| converterMediaTypeHint === 'video'
|| converterMediaTypeHint === 'audio'
|| converterMediaTypeHint === 'iso'
|| hasConverterPathHint(rawPath)
|| hasConverterPathHint(encodeInputPath)
|| hasConverterPathHint(plan?.inputPath)
) {
return 'converter';
}
@@ -630,12 +650,17 @@ function enrichJobRow(job, settings = null, options = {}) {
const mediainfoInfo = resolvedPaths.mediainfoInfo;
const encodePlan = resolvedPaths.encodePlan;
const mediaType = resolvedPaths.mediaType;
const normalizedJobStatus = String(job?.status || '').trim().toUpperCase();
const ripSuccessful = Number(job?.rip_successful || 0) === 1
|| String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
const backupSuccess = ripSuccessful;
const finishedWithOutput = normalizedJobStatus === 'FINISHED'
&& (includeFsChecks ? Boolean(outputStatus?.exists) : true);
const encodeSuccess = directoryLikeOutput
? (String(job?.status || '').trim().toUpperCase() === 'FINISHED' && Boolean(outputStatus?.exists))
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
? finishedWithOutput
: (mediaType === 'converter'
? finishedWithOutput
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS');
return {
...job,
@@ -3067,7 +3092,33 @@ class HistoryService {
`
SELECT *
FROM jobs
WHERE status IN ('ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'READY_TO_ENCODE', 'CD_ANALYZING', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING')
WHERE status IN ('ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING')
ORDER BY updated_at ASC, id ASC
`
),
settingsService.getSettingsMap()
]);
return rows.map((job) => ({
...enrichJobRow(job, settings),
log_count: hasProcessLogFile(job.id) ? 1 : 0
}));
}
async getQueueIdleJobs() {
const db = await getDb();
const [rows, settings] = await Promise.all([
db.all(
`
SELECT *
FROM jobs
WHERE status IN (
'METADATA_SELECTION',
'WAITING_FOR_USER_DECISION',
'READY_TO_START',
'READY_TO_ENCODE',
'CD_METADATA_SELECTION',
'CD_READY_TO_RIP'
)
ORDER BY updated_at ASC, id ASC
`
),
@@ -4041,6 +4092,7 @@ class HistoryService {
}
const artifactMoviePathSet = new Set(artifactMoviePaths);
const includeMovieParentCandidate = resolvedPaths?.mediaType !== 'converter';
for (const outputPath of explicitMoviePaths) {
addCandidate(
movieCandidates,
@@ -4050,7 +4102,7 @@ class HistoryService {
movieAllowedPaths
);
const parentDir = toNormalizedPath(path.dirname(outputPath));
if (parentDir && !movieRoots.includes(parentDir)) {
if (includeMovieParentCandidate && parentDir && !movieRoots.includes(parentDir)) {
addCandidate(
movieCandidates,
'movie',
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.12.0-17",
"version": "0.12.0-18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.12.0-17",
"version": "0.12.0-18",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.12.0-17",
"version": "0.12.0-18",
"private": true,
"type": "module",
"scripts": {
+2
View File
@@ -4,6 +4,7 @@ import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
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';
@@ -548,6 +549,7 @@ function App() {
return (
<div className="app-shell">
<Toast ref={globalToastRef} position="top-right" />
<ConfirmDialog />
<header className="app-header">
<div className="brand-block">
+42 -2
View File
@@ -77,6 +77,45 @@ function afterMutationInvalidate(prefixes = []) {
invalidateCachedGet(prefixes);
}
function buildHandBrakePresetList(payload) {
const existing = Array.isArray(payload?.presets) ? payload.presets : [];
if (existing.length > 0) {
return existing;
}
const options = Array.isArray(payload?.options) ? payload.options : [];
const list = [];
for (const option of options) {
if (!option || option.disabled) {
continue;
}
const rawValue = option.value ?? option.name ?? option.label ?? '';
const name = String(rawValue || '').trim();
if (!name || name.startsWith('__group__')) {
continue;
}
const category = option.category ? String(option.category).trim() : '';
list.push({
name,
category: category || null
});
}
return list;
}
function normalizeHandBrakePresetPayload(payload) {
if (!payload || typeof payload !== 'object') {
return payload;
}
const existing = Array.isArray(payload.presets) ? payload.presets : [];
if (existing.length > 0) {
return payload;
}
return {
...payload,
presets: buildHandBrakePresetList(payload)
};
}
async function request(path, options = {}) {
const isFormDataBody = typeof FormData !== 'undefined' && options?.body instanceof FormData;
const mergedHeaders = {
@@ -324,11 +363,12 @@ export const api = {
getDetectedDrives() {
return request('/settings/drives');
},
getHandBrakePresets(options = {}) {
return requestCachedGet('/settings/handbrake-presets', {
async getHandBrakePresets(options = {}) {
const result = await requestCachedGet('/settings/handbrake-presets', {
ttlMs: 10 * 60 * 1000,
forceRefresh: options.forceRefresh
});
return normalizeHandBrakePresetPayload(result);
},
getScripts(options = {}) {
return requestCachedGet('/settings/scripts', {
@@ -149,6 +149,7 @@ function collectFolderPaths(node, result = []) {
export default function ConverterFileExplorer({
onSelectionChange,
refreshToken,
selectionResetToken,
navigateToPath,
onAssignmentChanged
}) {
@@ -204,6 +205,12 @@ export default function ConverterFileExplorer({
useEffect(() => { loadTree(); }, [loadTree, refreshToken]);
useEffect(() => {
setSelectedPaths([]);
setActivePaths([]);
setActiveAnchor(null);
}, [selectionResetToken]);
// Auto-Navigation nach Upload: Baum neu laden, dann in Zielordner navigieren
// navigateToPath ist { path: string, ts: number } damit jeder Upload einen neuen Trigger auslöst
useEffect(() => {
@@ -217,6 +224,17 @@ export default function ConverterFileExplorer({
// Auswahl an Eltern melden: Ordner werden zu ihren Datei-Kindern expandiert
useEffect(() => {
if (!tree) return;
setSelectedPaths((prev) => {
const next = prev.filter((entryPath) => {
const node = getNodeByPath(tree, entryPath);
if (!node) return false;
if (node.type !== 'file') return true;
const assignedJobId = Number(node.jobId);
return !Number.isFinite(assignedJobId) || assignedJobId <= 0;
});
return next.length === prev.length ? prev : next;
});
const rootPaths = getRootSelections(selectedPaths);
const report = [];
for (const p of rootPaths) {
+9 -1
View File
@@ -13,6 +13,7 @@ import MetadataSelectionDialog from './MetadataSelectionDialog';
import CdMetadataDialog from './CdMetadataDialog';
import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas';
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
import { confirmModal } from '../utils/confirmModal';
const VIDEO_OUTPUT_FORMATS = [
{ label: 'MKV', value: 'mkv' },
@@ -1129,7 +1130,14 @@ export default function ConverterJobCard({
const confirmText = statusUpper === 'READY_TO_START'
? `${jobIdLabel} wirklich abbrechen?\nDer Job wird auf "Abgebrochen" gesetzt.`
: `${jobIdLabel} wirklich abbrechen?`;
if (!window.confirm(confirmText)) return;
const confirmed = await confirmModal({
header: 'Job abbrechen',
message: confirmText,
acceptLabel: 'Abbrechen',
rejectLabel: 'Zurück',
danger: true
});
if (!confirmed) return;
setCancelBusy(true);
try {
await api.cancelConverterJob(job.id);
+9 -1
View File
@@ -6,6 +6,7 @@ import { Dropdown } from 'primereact/dropdown';
import { InputSwitch } from 'primereact/inputswitch';
import { Toast } from 'primereact/toast';
import { api } from '../api/client';
import { confirmModal } from '../utils/confirmModal';
// Hilfsfunktionen
@@ -251,7 +252,14 @@ export default function CronJobsTab({ onWsMessage }) {
// Löschen
async function handleDelete(job) {
if (!window.confirm(`Cronjob "${job.name}" wirklich löschen?`)) return;
const confirmed = await confirmModal({
header: 'Cronjob löschen',
message: `Cronjob "${job.name}" wirklich löschen?`,
acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) return;
setBusyId(job.id);
try {
await api.deleteCronJob(job.id);
@@ -1217,7 +1217,8 @@ export default function MediaInfoReviewPanel({
onReorderPostEncodeItem = null,
userPresets = [],
selectedUserPresetId = null,
onUserPresetChange = null
onUserPresetChange = null,
selectedHandBrakePreset = ''
}) {
if (!review) {
return <p>Keine Mediainfo-Daten vorhanden.</p>;
@@ -1237,14 +1238,20 @@ export default function MediaInfoReviewPanel({
const selectedUserPreset = selectedUserPresetId
? normalizedUserPresets.find((p) => Number(p.id) === Number(selectedUserPresetId)) || null
: null;
const effectivePresetOverride = selectedUserPreset
const hasUserPresets = normalizedUserPresets.length > 0;
const allowUserPresetSelection = hasUserPresets && typeof onUserPresetChange === 'function' && allowEncodeItemSelection;
const isUserPresetActive = Boolean(selectedUserPreset);
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
const isHandBrakePresetActive = !isUserPresetActive && Boolean(trimmedHandBrakePreset);
const effectivePresetOverride = isUserPresetActive
? { handbrakePreset: selectedUserPreset.handbrakePreset || '', extraArgs: selectedUserPreset.extraArgs || '' }
: null;
: (isHandBrakePresetActive ? { handbrakePreset: trimmedHandBrakePreset, extraArgs: '' } : null);
const effectiveAudioSelector = effectivePresetOverride?.extraArgs
? parseAudioSelectorFromArgs(effectivePresetOverride.extraArgs, review?.selectors?.audio)
: (review?.selectors?.audio || null);
const hasUserPresets = normalizedUserPresets.length > 0;
const allowUserPresetSelection = hasUserPresets && typeof onUserPresetChange === 'function' && allowEncodeItemSelection;
const presetSourceLabel = isUserPresetActive
? `User-Preset: ${selectedUserPreset?.name || 'Preset'}`
: (isHandBrakePresetActive ? 'HandBrake-Preset' : '');
const scriptCatalog = (Array.isArray(availableScripts) ? availableScripts : [])
.map((item) => ({
@@ -1308,23 +1315,28 @@ export default function MediaInfoReviewPanel({
)}
</div>
)}
<div className="media-review-meta">
<div>
<strong>Preset:</strong>{' '}
{effectivePresetOverride
? (effectivePresetOverride.handbrakePreset || '(kein Preset)')
: presetLabel}
{effectivePresetOverride && <span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>(User-Preset: {selectedUserPreset?.name})</span>}
{effectivePresetOverride && presetSourceLabel && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>
({presetSourceLabel})
</span>
)}
</div>
<div>
<strong>Extra Args:</strong>{' '}
{effectivePresetOverride
? (effectivePresetOverride.extraArgs || '(keine)')
: (review.selectors?.extraArgs || '(keine)')}
{effectivePresetOverride && !selectedUserPreset?.extraArgs && <span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>(aus User-Preset)</span>}
{effectivePresetOverride && isUserPresetActive && !selectedUserPreset?.extraArgs && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>(aus User-Preset)</span>
)}
</div>
<div><strong>Preset-Profil:</strong> {effectivePresetOverride ? 'user-preset' : (review.selectors?.presetProfileSource || '-')}</div>
<div><strong>Preset-Profil:</strong> {effectivePresetOverride ? (isUserPresetActive ? 'user-preset' : 'handbrake-preset') : (review.selectors?.presetProfileSource || '-')}</div>
<div><strong>MIN_LENGTH_MINUTES:</strong> {review.minLengthMinutes}</div>
<div><strong>Encode Input:</strong> {encodeInputTitle?.fileName || '-'}</div>
<div><strong>Audio Auswahl:</strong> {effectiveAudioSelector?.mode || '-'}</div>
+335 -20
View File
@@ -1,12 +1,19 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Card } from 'primereact/card';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Button } from 'primereact/button';
import { Dropdown } from 'primereact/dropdown';
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
import { api } from '../api/client';
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
const CONVERTER_VIDEO_OUTPUT_FORMATS = [
{ label: 'MKV', value: 'mkv' },
{ label: 'MP4', value: 'mp4' },
{ label: 'M4V', value: 'm4v' }
];
function normalizeTitleId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -120,6 +127,9 @@ function normalizeMediaProfile(value) {
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
return 'audiobook';
}
if (raw === 'converter') {
return 'converter';
}
if (['other', 'sonstiges', 'unknown'].includes(raw)) {
return 'other';
}
@@ -453,6 +463,37 @@ function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId
return `${root}/${baseName}.${ext}`;
}
function buildConverterOutputPathPreview(settings, review, metadata, fallbackJobId = null) {
const explicitPath = String(review?.outputPath || review?.outputDir || '').trim();
if (explicitPath) {
return explicitPath;
}
const movieDir = String(settings?.converter_movie_dir || '').trim();
if (!movieDir) {
return null;
}
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
const year = metadata?.year || new Date().getFullYear();
const template = String(settings?.converter_output_template_video || '{title}').trim() || '{title}';
const rendered = renderTemplate(template, { title, year });
const segments = rendered
.replace(/\\/g, '/')
.replace(/\/+/g, '/')
.replace(/^\/+|\/+$/g, '')
.split('/')
.map((seg) => sanitizeFileName(seg))
.filter(Boolean);
const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled';
const folderParts = segments.slice(0, -1);
const ext = String(review?.outputFormat || 'mkv').trim() || 'mkv';
const root = movieDir.replace(/\/+$/g, '');
if (folderParts.length > 0) {
return `${root}/${folderParts.join('/')}/${baseName}.${ext}`;
}
return `${root}/${baseName}.${ext}`;
}
export default function PipelineStatusCard({
jobId = null,
pipeline,
@@ -470,6 +511,7 @@ export default function PipelineStatusCard({
onCancel,
onRetry,
onDeleteJob,
onInputsChanged,
isQueued = false,
busy
}) {
@@ -502,6 +544,13 @@ export default function PipelineStatusCard({
const [postEncodeItems, setPostEncodeItems] = useState([]);
const [userPresets, setUserPresets] = useState([]);
const [selectedUserPresetId, setSelectedUserPresetId] = useState(null);
const [handBrakePresetOptions, setHandBrakePresetOptions] = useState([]);
const [selectedHandBrakePreset, setSelectedHandBrakePreset] = useState('');
const [selectedOutputFormat, setSelectedOutputFormat] = useState('mkv');
const [converterConfigSaving, setConverterConfigSaving] = useState(false);
const [converterConfigError, setConverterConfigError] = useState(null);
const converterConfigLatestRef = useRef(null);
const converterConfigSaveTimeoutRef = useRef(null);
useEffect(() => {
let cancelled = false;
@@ -512,7 +561,7 @@ export default function PipelineStatusCard({
api.getHandBrakePresets(),
api.getScripts(),
api.getScriptChains(),
api.getUserPresets()
api.getUserPresets(null, { forceRefresh: true })
]);
if (!cancelled) {
const categories = settingsResponse.status === 'fulfilled'
@@ -523,6 +572,7 @@ export default function PipelineStatusCard({
? (presetsResponse.value?.options || [])
: [];
setPresetDisplayMap(buildPresetDisplayMap(presetOptions));
setHandBrakePresetOptions(Array.isArray(presetOptions) ? presetOptions : []);
const scripts = scriptsResponse.status === 'fulfilled'
? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : [])
: [];
@@ -548,6 +598,7 @@ export default function PipelineStatusCard({
setScriptCatalog([]);
setChainCatalog([]);
setUserPresets([]);
setHandBrakePresetOptions([]);
}
}
};
@@ -570,14 +621,25 @@ export default function PipelineStatusCard({
...normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
...normChain(mediaInfoReview?.postEncodeChainIds).map((id) => ({ type: 'chain', id }))
]);
const nextOutputFormat = String(mediaInfoReview?.outputFormat || 'mkv').trim().toLowerCase() || 'mkv';
setSelectedOutputFormat(nextOutputFormat);
const userPresetId = Number(mediaInfoReview?.userPreset?.id);
setSelectedUserPresetId(Number.isFinite(userPresetId) && userPresetId > 0 ? Math.trunc(userPresetId) : null);
if (Number.isFinite(userPresetId) && userPresetId > 0) {
setSelectedUserPresetId(Math.trunc(userPresetId));
setSelectedHandBrakePreset('');
} else {
setSelectedUserPresetId(null);
const hbPresetName = String(mediaInfoReview?.userPreset?.handbrakePreset || '').trim();
setSelectedHandBrakePreset(hbPresetName);
}
}, [
mediaInfoReview?.encodeInputTitleId,
mediaInfoReview?.generatedAt,
mediaInfoReview?.reviewConfirmedAt,
mediaInfoReview?.prefilledFromPreviousRunAt,
mediaInfoReview?.outputFormat,
mediaInfoReview?.userPreset?.id,
mediaInfoReview?.userPreset?.handbrakePreset,
retryJobId
]);
@@ -609,6 +671,11 @@ export default function PipelineStatusCard({
const reviewPlaylistDecisionRequired = Boolean(mediaInfoReview?.playlistDecisionRequired);
const hasSelectedEncodeTitle = Boolean(normalizeTitleId(selectedEncodeTitleId));
const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle;
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
const hasSelectedPreset = Boolean(selectedUserPresetId) || Boolean(trimmedHandBrakePreset);
const converterMediaType = String(mediaInfoReview?.converterMediaType || 'video').trim().toLowerCase() || 'video';
const showConverterConfig = jobMediaProfile === 'converter' && converterMediaType !== 'audio';
const requiresPresetSelection = showConverterConfig;
// Filter user presets by job media profile ('all' presets always shown)
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
@@ -616,11 +683,17 @@ export default function PipelineStatusCard({
if (!jobMediaProfile) {
return true;
}
// Converter jobs may reuse any user preset; do not restrict by preset media type here.
if (jobMediaProfile === 'converter') {
return true;
}
return presetMediaType === 'all' || presetMediaType === jobMediaProfile;
});
const canStartReadyJob = isPreRipReview
const effectiveUserPresets = filteredUserPresets;
const canStartReadyJob = (isPreRipReview
? Boolean(retryJobId)
: Boolean(retryJobId && encodeInputPath);
: Boolean(retryJobId && encodeInputPath))
&& (!requiresPresetSelection || hasSelectedPreset);
const canRestartEncodeFromLastSettings = Boolean(
(state === 'ERROR' || state === 'CANCELLED')
&& retryJobId
@@ -631,6 +704,87 @@ export default function PipelineStatusCard({
&& !running
&& (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath)
);
const allowConverterConfigEdit = showConverterConfig && !queueLocked && state === 'READY_TO_ENCODE';
const converterConfigPayload = useMemo(() => {
if (!showConverterConfig) {
return null;
}
let userPreset = null;
if (selectedUserPresetId) {
const preset = (Array.isArray(userPresets) ? userPresets : [])
.find((p) => Number(p?.id) === Number(selectedUserPresetId));
userPreset = preset
? { id: preset.id, handbrakePreset: preset.handbrake_preset || null, extraArgs: preset.extra_args || '' }
: { id: selectedUserPresetId };
} else if (trimmedHandBrakePreset) {
userPreset = { handbrakePreset: trimmedHandBrakePreset, extraArgs: '' };
}
return {
converterMediaType,
outputFormat: selectedOutputFormat,
userPreset
};
}, [
showConverterConfig,
converterMediaType,
selectedOutputFormat,
selectedUserPresetId,
trimmedHandBrakePreset,
userPresets
]);
const converterConfigSerialized = useMemo(
() => (converterConfigPayload ? JSON.stringify(converterConfigPayload) : null),
[converterConfigPayload]
);
useEffect(() => {
converterConfigLatestRef.current = null;
setConverterConfigError(null);
setConverterConfigSaving(false);
}, [retryJobId]);
useEffect(() => {
if (!allowConverterConfigEdit || !converterConfigPayload || !retryJobId) {
return;
}
if (converterConfigLatestRef.current === null) {
converterConfigLatestRef.current = converterConfigSerialized;
return;
}
if (converterConfigSerialized === converterConfigLatestRef.current) {
return;
}
if (converterConfigSaveTimeoutRef.current) {
clearTimeout(converterConfigSaveTimeoutRef.current);
}
converterConfigSaveTimeoutRef.current = setTimeout(async () => {
setConverterConfigSaving(true);
setConverterConfigError(null);
try {
await api.converterUpdateJobConfig(retryJobId, converterConfigPayload);
converterConfigLatestRef.current = converterConfigSerialized;
onInputsChanged?.(retryJobId, converterConfigPayload);
} catch (error) {
setConverterConfigError(error?.message || 'Konfiguration konnte nicht gespeichert werden.');
} finally {
setConverterConfigSaving(false);
}
}, 650);
return () => {
if (converterConfigSaveTimeoutRef.current) {
clearTimeout(converterConfigSaveTimeoutRef.current);
converterConfigSaveTimeoutRef.current = null;
}
};
}, [
allowConverterConfigEdit,
converterConfigPayload,
converterConfigSerialized,
retryJobId,
onInputsChanged
]);
const waitingPlaylistRows = useMemo(() => {
const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates)
@@ -780,10 +934,15 @@ export default function PipelineStatusCard({
}, [running, retryJobId]);
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && !handBrakeTitleDecisionRequired;
const commandOutputPath = useMemo(
() => buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId),
[settingsMap, jobMediaProfile, selectedMetadata, retryJobId]
);
const commandOutputPath = useMemo(() => {
if (jobMediaProfile === 'converter') {
const reviewWithOutput = mediaInfoReview
? { ...mediaInfoReview, outputFormat: selectedOutputFormat }
: { outputFormat: selectedOutputFormat };
return buildConverterOutputPathPreview(settingsMap, reviewWithOutput, selectedMetadata, retryJobId);
}
return buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId);
}, [settingsMap, jobMediaProfile, mediaInfoReview, selectedMetadata, retryJobId, selectedOutputFormat]);
const presetDisplayValue = useMemo(() => {
const preset = String(mediaInfoReview?.selectors?.preset || '').trim();
if (!preset) {
@@ -791,6 +950,52 @@ export default function PipelineStatusCard({
}
return presetDisplayMap[preset] || preset;
}, [mediaInfoReview?.selectors?.preset, presetDisplayMap]);
const converterUserPresetOptions = useMemo(() => {
if (!showConverterConfig) {
return [];
}
const userOptions = (Array.isArray(effectiveUserPresets) ? effectiveUserPresets : [])
.map((preset) => ({
label: String(preset?.name || '').trim(),
value: Number(preset?.id)
}))
.filter((option) => option.label && Number.isFinite(option.value));
return [{ label: 'Kein User-Preset', value: null }, ...userOptions];
}, [showConverterConfig, effectiveUserPresets]);
const converterHandBrakePresetOptions = useMemo(() => {
if (!showConverterConfig) {
return [];
}
const list = Array.isArray(handBrakePresetOptions) ? handBrakePresetOptions : [];
const hbOptions = list
.map((option) => {
const label = String(option?.label || '').trim();
if (!label) {
return null;
}
const disabled = Boolean(option?.disabled);
const lowerLabel = label.toLowerCase();
if (lowerLabel.includes('handbrake cli') || lowerLabel.includes('cli default')) {
return null;
}
if (!disabled && (lowerLabel.includes('kein preset') || lowerLabel.includes('cli-parameter'))) {
return null;
}
const value = disabled
? String(option?.value || label)
: String(option?.value || '').trim();
if (!disabled && !value) {
return null;
}
return {
label,
value,
disabled
};
})
.filter(Boolean);
return [{ label: 'Kein HandBrake-Preset', value: '' }, ...hbOptions];
}, [showConverterConfig, handBrakePresetOptions]);
const buildSelectedTrackSelectionForCurrentTitle = () => {
const encodeTitleId = normalizeTitleId(selectedEncodeTitleId)
|| normalizeTitleId(mediaInfoReview?.encodeInputTitleId)
@@ -862,7 +1067,8 @@ export default function PipelineStatusCard({
selectedPostScriptIds,
selectedPreScriptIds,
selectedPostChainIds,
selectedPreChainIds
selectedPreChainIds,
selectedHandBrakePreset: trimmedHandBrakePreset
};
};
@@ -908,7 +1114,10 @@ export default function PipelineStatusCard({
/>
)}
{(state === 'METADATA_SELECTION' || state === 'WAITING_FOR_USER_DECISION') && retryJobId && typeof onOpenMetadata === 'function' ? (
{(state === 'METADATA_SELECTION' || state === 'WAITING_FOR_USER_DECISION')
&& retryJobId
&& typeof onOpenMetadata === 'function'
&& !(jobMediaProfile === 'converter' && !selectedMetadata?.imdbId) ? (
<Button
label="Metadaten öffnen"
icon="pi pi-list"
@@ -918,7 +1127,24 @@ export default function PipelineStatusCard({
/>
) : null}
{!running && state !== 'METADATA_SELECTION' && state !== 'WAITING_FOR_USER_DECISION' && state !== 'IDLE' && state !== 'DISC_DETECTED' && retryJobId && typeof onReassignOmdb === 'function' ? (
{state === 'METADATA_SELECTION' && jobMediaProfile === 'converter' && retryJobId ? (
<Button
label="Review starten"
icon="pi pi-play"
severity="info"
onClick={() => onStart(retryJobId)}
loading={busy}
/>
) : null}
{!running
&& state !== 'METADATA_SELECTION'
&& state !== 'WAITING_FOR_USER_DECISION'
&& state !== 'IDLE'
&& state !== 'DISC_DETECTED'
&& retryJobId
&& typeof onReassignOmdb === 'function'
&& !(jobMediaProfile === 'converter' && state === 'READY_TO_START' && !selectedMetadata?.imdbId) ? (
<Button
label="OMDb neu zuordnen"
icon="pi pi-search"
@@ -931,9 +1157,9 @@ export default function PipelineStatusCard({
{state === 'READY_TO_START' && retryJobId ? (
<Button
label="Job starten"
label={jobMediaProfile === 'converter' ? 'Review starten' : 'Job starten'}
icon="pi pi-play"
severity="success"
severity={jobMediaProfile === 'converter' ? 'info' : 'success'}
onClick={() => onStart(retryJobId)}
loading={busy}
/>
@@ -975,9 +1201,10 @@ export default function PipelineStatusCard({
selectedPostScriptIds,
selectedPreScriptIds,
selectedPostChainIds,
selectedPreChainIds
selectedPreChainIds,
selectedHandBrakePreset
} = buildSelectedTrackSelectionForCurrentTitle();
await onStart(retryJobId, {
const startPayload = {
ensureConfirmed: true,
selectedEncodeTitleId: encodeTitleId,
selectedTrackSelection,
@@ -985,8 +1212,10 @@ export default function PipelineStatusCard({
selectedPreEncodeScriptIds: selectedPreScriptIds,
selectedPostEncodeChainIds: selectedPostChainIds,
selectedPreEncodeChainIds: selectedPreChainIds,
selectedUserPresetId: selectedUserPresetId || null
});
selectedHandBrakePreset
};
startPayload.selectedUserPresetId = selectedUserPresetId || null;
await onStart(retryJobId, startPayload);
}}
loading={busy}
disabled={!canStartReadyJob || !canConfirmReview}
@@ -1203,6 +1432,91 @@ export default function PipelineStatusCard({
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
</small>
) : null}
{showConverterConfig ? (
<div style={{ marginTop: '0.75rem', marginBottom: '0.75rem', padding: '0.75rem', border: '1px solid var(--surface-border, #e0e0e0)', borderRadius: '6px', background: 'var(--surface-ground, #f8f8f8)' }}>
<div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
<div>
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 600 }}>Ausgabeformat</label>
<Dropdown
value={selectedOutputFormat}
options={CONVERTER_VIDEO_OUTPUT_FORMATS}
onChange={(e) => setSelectedOutputFormat(e.value)}
style={{ width: '100%' }}
disabled={!allowConverterConfigEdit}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 600 }}>User-Preset</label>
<Dropdown
value={selectedUserPresetId}
options={converterUserPresetOptions}
optionLabel="label"
optionValue="value"
onChange={(e) => {
const rawValue = e?.value && typeof e.value === 'object' ? e.value.value : e?.value;
const nextValue = rawValue === null || rawValue === undefined || String(rawValue).trim() === ''
? null
: Number(rawValue);
const normalized = Number.isFinite(nextValue) && nextValue > 0 ? Math.trunc(nextValue) : null;
setSelectedUserPresetId(normalized);
if (normalized) {
setSelectedHandBrakePreset('');
}
}}
style={{ width: '100%' }}
placeholder="User-Preset auswählen …"
showClear
disabled={!allowConverterConfigEdit || Boolean(trimmedHandBrakePreset) || converterUserPresetOptions.length <= 1}
/>
{converterUserPresetOptions.length <= 1 ? (
<small style={{ color: 'var(--text-color-secondary)' }}>Keine User-Presets vorhanden.</small>
) : null}
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 600 }}>HandBrake-Preset</label>
<Dropdown
value={trimmedHandBrakePreset}
options={converterHandBrakePresetOptions}
optionLabel="label"
optionValue="value"
onChange={(e) => {
const rawValue = e?.value && typeof e.value === 'object' ? e.value.value : e?.value;
const next = String(rawValue || '').trim();
setSelectedHandBrakePreset(next);
if (next) {
setSelectedUserPresetId(null);
}
}}
style={{ width: '100%' }}
placeholder="HandBrake-Preset auswählen …"
filter
optionDisabled="disabled"
showClear
disabled={!allowConverterConfigEdit || Boolean(selectedUserPresetId) || converterHandBrakePresetOptions.length <= 1}
/>
{converterHandBrakePresetOptions.length <= 1 ? (
<small style={{ color: 'var(--text-color-secondary)' }}>Keine HandBrake-Presets geladen.</small>
) : null}
</div>
</div>
{allowConverterConfigEdit && !hasSelectedPreset ? (
<small style={{ display: 'block', marginTop: '0.5rem', color: 'var(--red-600)' }}>
Bitte wähle ein User- oder HandBrake-Preset aus, bevor das Encoding starten kann.
</small>
) : null}
{allowConverterConfigEdit ? (
converterConfigError ? (
<small style={{ display: 'block', marginTop: '0.5rem', color: 'var(--red-600)' }}>
{converterConfigError}
</small>
) : (
<small style={{ display: 'block', marginTop: '0.5rem', color: 'var(--text-color-secondary)' }}>
{converterConfigSaving ? 'Speichere Konfiguration …' : 'Konfiguration gespeichert'}
</small>
)
) : null}
</div>
) : null}
<MediaInfoReviewPanel
review={mediaInfoReview}
presetDisplayValue={presetDisplayValue}
@@ -1292,9 +1606,10 @@ export default function PipelineStatusCard({
availableChains={chainCatalog}
preEncodeItems={preEncodeItems}
postEncodeItems={postEncodeItems}
userPresets={filteredUserPresets}
userPresets={effectiveUserPresets}
selectedUserPresetId={selectedUserPresetId}
onUserPresetChange={(presetId) => setSelectedUserPresetId(presetId)}
onUserPresetChange={showConverterConfig ? null : (presetId) => setSelectedUserPresetId(presetId)}
selectedHandBrakePreset={selectedHandBrakePreset}
allowEncodeItemSelection={state === 'READY_TO_ENCODE' && !queueLocked}
onAddPreEncodeItem={(itemType) => {
setPreEncodeItems((prev) => {
+733 -20
View File
@@ -3,6 +3,8 @@ 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 { Divider } from 'primereact/divider';
import { Dialog } from 'primereact/dialog';
import { Dropdown } from 'primereact/dropdown';
@@ -13,9 +15,20 @@ import ConverterFileExplorer from '../components/ConverterFileExplorer';
import ConverterUploadPanel from '../components/ConverterUploadPanel';
import ConverterJobCard from '../components/ConverterJobCard';
import JobDetailDialog from '../components/JobDetailDialog';
import PipelineStatusCard from '../components/PipelineStatusCard';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
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 TERMINAL_JOB_STATUSES = new Set(['DONE', 'FINISHED', 'ERROR', 'CANCELLED']);
const VIDEO_OUTPUT_FORMATS = [
{ label: 'MKV', value: 'mkv' },
{ label: 'MP4', value: 'mp4' },
{ label: 'M4V', value: 'm4v' }
];
function isAudioEntry(e) {
if (e.detectedMediaType === 'audio') return true;
@@ -46,14 +59,45 @@ function parseConverterPlan(job) {
}
}
function buildConverterPipeline(job, plan, progress = null) {
const metadata = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : {};
const review = plan && typeof plan === 'object' ? plan : {};
const selectedMetadata = {
title: String(job?.title || metadata?.title || job?.detected_title || '').trim() || null,
year: Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : (Number.isFinite(Number(metadata?.year)) ? Math.trunc(Number(metadata.year)) : null),
imdbId: String(job?.imdb_id || metadata?.imdbId || '').trim() || null,
poster: String(job?.poster_url || metadata?.poster || '').trim() || null
};
return {
state: String(progress?.state || job?.status || '').trim().toUpperCase() || 'UNKNOWN',
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: 'converter',
mediaProfile: 'converter',
selectedMetadata,
mediaInfoReview: review,
playlistAnalysis: review?.playlistAnalysis || null,
playlistCandidates: Array.isArray(review?.playlistCandidates) ? review.playlistCandidates : [],
handBrakeTitleDecisionRequired: Boolean(review?.handBrakeTitleDecisionRequired),
handBrakeTitleCandidates: Array.isArray(review?.handBrakeTitleCandidates) ? review.handBrakeTitleCandidates : []
}
};
}
export default function ConverterPage() {
const toastRef = useRef(null);
const [jobs, setJobs] = useState([]);
const [queuedJobIds, setQueuedJobIds] = useState([]);
const [jobProgress, setJobProgress] = useState({});
const [loadingJobs, setLoadingJobs] = useState(false);
const [explorerRefreshToken, setExplorerRefreshToken] = useState(0);
const [explorerNavigateTo, setExplorerNavigateTo] = useState(null);
const [selectedEntries, setSelectedEntries] = useState([]);
const [selectionResetToken, setSelectionResetToken] = useState(0);
const [jobModeVisible, setJobModeVisible] = useState(false);
const [audioMode, setAudioMode] = useState('individual');
const [jobModalAction, setJobModalAction] = useState('create');
@@ -63,19 +107,121 @@ export default function ConverterPage() {
const [detailVisible, setDetailVisible] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [selectedJob, setSelectedJob] = useState(null);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [metadataDialogBusy, setMetadataDialogBusy] = useState(false);
const [actionBusyJobIds, setActionBusyJobIds] = useState(new Set());
const [logLoadingMode, setLogLoadingMode] = useState(null);
const [deleteEntryBusy, setDeleteEntryBusy] = useState(false);
const [cancelDetailBusy, setCancelDetailBusy] = useState(false);
const [expandedJobId, setExpandedJobId] = useState(undefined);
const [videoUserPresets, setVideoUserPresets] = useState([]);
const [videoHbPresets, setVideoHbPresets] = useState([]);
// 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());
useEffect(() => {
let cancelled = false;
const loadPresets = async () => {
try {
const [userResponse, hbResponse] = await Promise.allSettled([
api.getUserPresets('video'),
api.getHandBrakePresets()
]);
if (cancelled) return;
const userPresets = userResponse.status === 'fulfilled'
? (Array.isArray(userResponse.value?.presets) ? userResponse.value.presets : [])
: [];
const hbPresets = hbResponse.status === 'fulfilled'
? (Array.isArray(hbResponse.value?.presets) ? hbResponse.value.presets : [])
: [];
setVideoUserPresets(userPresets);
setVideoHbPresets(hbPresets);
} catch (_error) {
if (!cancelled) {
setVideoUserPresets([]);
setVideoHbPresets([]);
}
}
};
loadPresets();
return () => {
cancelled = true;
};
}, []);
const loadJobs = useCallback(async () => {
setLoadingJobs(true);
try {
const data = await api.getConverterJobs();
setJobs(Array.isArray(data?.jobs) ? data.jobs : []);
const [jobsResponse, queueResponse] = await Promise.allSettled([
api.getConverterJobs(),
api.getPipelineQueue()
]);
const nextJobs = jobsResponse.status === 'fulfilled' && Array.isArray(jobsResponse.value?.jobs)
? jobsResponse.value.jobs
: [];
if (queueResponse.status === 'fulfilled') {
const queuedRows = Array.isArray(queueResponse.value?.queue?.queuedJobs)
? queueResponse.value.queue.queuedJobs
: [];
setQueuedJobIds(
queuedRows
.map((item) => normalizeJobId(item?.jobId))
.filter(Boolean)
);
} else {
setQueuedJobIds([]);
}
setJobs(nextJobs);
const runningStates = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK']);
setJobProgress((prev) => {
const next = { ...prev };
for (const job of nextJobs) {
const jobId = normalizeJobId(job?.id);
if (!jobId) continue;
const status = String(job?.status || '').trim().toUpperCase();
if (!runningStates.has(status) && next[jobId]) {
delete next[jobId];
}
}
return next;
});
const previousStatuses = previousJobStatusesRef.current;
const nextStatuses = new Map();
let hasStatusChanges = false;
let hasCompletedJobTransitions = false;
for (const job of nextJobs) {
const jobId = normalizeJobId(job?.id);
if (!jobId) continue;
const nextStatus = String(job?.status || '').trim().toUpperCase();
const prevStatus = previousStatuses.get(jobId) || null;
nextStatuses.set(jobId, nextStatus);
if (prevStatus !== nextStatus) {
hasStatusChanges = true;
if (prevStatus && !TERMINAL_JOB_STATUSES.has(prevStatus) && TERMINAL_JOB_STATUSES.has(nextStatus)) {
hasCompletedJobTransitions = true;
}
}
}
if (previousStatuses.size !== nextStatuses.size) {
hasStatusChanges = true;
}
const isFirstSnapshot = previousStatuses.size === 0;
previousJobStatusesRef.current = nextStatuses;
if (!isFirstSnapshot && hasStatusChanges) {
setExplorerRefreshToken((t) => t + 1);
}
if (!isFirstSnapshot && hasCompletedJobTransitions) {
setSelectedEntries([]);
setSelectionResetToken((t) => t + 1);
}
} catch (err) {
console.error('Load converter jobs error:', err);
} finally {
@@ -112,6 +258,7 @@ export default function ConverterPage() {
if (
message.type === 'PIPELINE_UPDATE' ||
message.type === 'PIPELINE_STATE_CHANGED' ||
message.type === 'PIPELINE_QUEUE_CHANGED' ||
message.type === 'CONVERTER_SCAN_UPDATE'
) {
loadJobs();
@@ -122,6 +269,17 @@ export default function ConverterPage() {
}
});
const queuedJobIdSet = useMemo(() => {
const next = new Set();
for (const value of queuedJobIds) {
const jobId = normalizeJobId(value);
if (jobId) {
next.add(jobId);
}
}
return next;
}, [queuedJobIds]);
const assignableJobs = useMemo(() => (
jobs
.map((job) => ({ job, plan: parseConverterPlan(job) }))
@@ -184,6 +342,7 @@ export default function ConverterPage() {
jobEntriesRef.current = [];
setJobEntries([]);
setSelectedEntries([]);
setSelectionResetToken((t) => t + 1);
setExplorerRefreshToken((t) => t + 1);
await loadJobs();
} catch (err) {
@@ -221,6 +380,7 @@ export default function ConverterPage() {
jobEntriesRef.current = [];
setJobEntries([]);
setSelectedEntries([]);
setSelectionResetToken((t) => t + 1);
setExplorerRefreshToken((t) => t + 1);
await loadJobs();
} catch (err) {
@@ -256,6 +416,219 @@ export default function ConverterPage() {
}
};
const runJobAction = useCallback(async (jobId, action) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId || typeof action !== 'function') return;
setActionBusyJobIds((prev) => new Set([...prev, normalizedJobId]));
try {
await action(normalizedJobId);
await loadJobs();
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Aktion fehlgeschlagen',
detail: error?.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setActionBusyJobIds((prev) => {
const next = new Set(prev);
next.delete(normalizedJobId);
return next;
});
}
}, [loadJobs]);
const handleOpenMetadataDialog = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) return;
const row = jobs.find((entry) => normalizeJobId(entry?.id) === normalizedJobId) || null;
if (!row) return;
const plan = parseConverterPlan(row);
const metadata = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : {};
setMetadataDialogContext({
jobId: normalizedJobId,
detectedTitle: row?.detected_title || row?.title || metadata?.title || '',
selectedMetadata: {
title: row?.title || metadata?.title || row?.detected_title || '',
year: row?.year || metadata?.year || null,
imdbId: row?.imdb_id || metadata?.imdbId || null,
poster: row?.poster_url || metadata?.poster || null
},
omdbCandidates: []
});
setMetadataDialogVisible(true);
};
const handleMetadataSubmit = async (payload) => {
setMetadataDialogBusy(true);
try {
await api.selectMetadata(payload || {});
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
await loadJobs();
toastRef.current?.show({
severity: 'success',
summary: 'Metadaten übernommen',
detail: `Job #${payload?.jobId} wurde aktualisiert.`,
life: 3200
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Metadaten fehlgeschlagen',
detail: error.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setMetadataDialogBusy(false);
}
};
const handleOmdbSearch = async (query) => {
try {
const response = await api.searchOmdb(query);
return Array.isArray(response?.results) ? response.results : [];
} catch (_error) {
return [];
}
};
const handleStartPipelineJob = async (jobId, options = null) => {
await runJobAction(jobId, async (resolvedJobId) => {
const startOptions = options && typeof options === 'object' ? options : {};
if (startOptions.ensureConfirmed) {
const confirmPayload = {
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
skipPipelineStateUpdate: true
};
if (startOptions.selectedPostEncodeScriptIds !== undefined) {
confirmPayload.selectedPostEncodeScriptIds = startOptions.selectedPostEncodeScriptIds;
}
if (startOptions.selectedPreEncodeScriptIds !== undefined) {
confirmPayload.selectedPreEncodeScriptIds = startOptions.selectedPreEncodeScriptIds;
}
if (startOptions.selectedPostEncodeChainIds !== undefined) {
confirmPayload.selectedPostEncodeChainIds = startOptions.selectedPostEncodeChainIds;
}
if (startOptions.selectedPreEncodeChainIds !== undefined) {
confirmPayload.selectedPreEncodeChainIds = startOptions.selectedPreEncodeChainIds;
}
if (startOptions.selectedUserPresetId !== undefined) {
confirmPayload.selectedUserPresetId = startOptions.selectedUserPresetId;
}
if (startOptions.selectedHandBrakePreset !== undefined) {
confirmPayload.selectedHandBrakePreset = startOptions.selectedHandBrakePreset;
}
await api.confirmEncodeReview(resolvedJobId, confirmPayload);
}
await api.startJob(resolvedJobId);
toastRef.current?.show({
severity: 'success',
summary: 'Job gestartet',
detail: `Job #${resolvedJobId} wurde gestartet.`,
life: 2800
});
});
};
const handleConfirmReview = async (jobId, payload = {}) => {
await runJobAction(jobId, async (resolvedJobId) => {
await api.confirmEncodeReview(resolvedJobId, payload || {});
toastRef.current?.show({
severity: 'success',
summary: 'Review bestätigt',
detail: `Encode für Job #${resolvedJobId} ist freigegeben.`,
life: 3000
});
});
};
const handleSelectPlaylist = async (jobId, selectedPlaylist) => {
await runJobAction(jobId, async (resolvedJobId) => {
await api.selectMetadata({ jobId: resolvedJobId, selectedPlaylist });
});
};
const handleSelectHandBrakeTitle = async (jobId, selectedHandBrakeTitleId) => {
await runJobAction(jobId, async (resolvedJobId) => {
await api.selectMetadata({ jobId: resolvedJobId, selectedHandBrakeTitleId });
});
};
const handleCancelPipelineJob = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) return;
const confirmed = await confirmModal({
header: 'Job abbrechen',
message: `Job #${normalizedJobId} abbrechen?`,
acceptLabel: 'Abbrechen',
rejectLabel: 'Zurück',
danger: true
});
if (!confirmed) return;
await runJobAction(normalizedJobId, async (resolvedJobId) => {
await api.cancelPipeline(resolvedJobId);
handleJobCancelledImmediate(resolvedJobId);
toastRef.current?.show({
severity: 'success',
summary: 'Abbruch gesendet',
detail: `Job #${resolvedJobId} wird abgebrochen.`,
life: 2800
});
});
};
const handleRetryJob = async (jobId) => {
await runJobAction(jobId, async (resolvedJobId) => {
await api.retryJob(resolvedJobId);
toastRef.current?.show({
severity: 'success',
summary: 'Retry gestartet',
detail: `Job #${resolvedJobId} wurde neu eingeplant.`,
life: 3000
});
});
};
const handleDeleteConverterJob = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) return;
const confirmed = await confirmModal({
header: 'Job löschen',
message: `Job #${normalizedJobId} wirklich löschen?`,
acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) return;
await runJobAction(normalizedJobId, async (resolvedJobId) => {
await api.deleteConverterJob(resolvedJobId);
handleJobDeleted(resolvedJobId);
toastRef.current?.show({
severity: 'success',
summary: 'Job gelöscht',
detail: `Job #${resolvedJobId} wurde entfernt.`,
life: 3000
});
});
};
const handleRemoveFromQueue = async (jobId) => {
await runJobAction(jobId, async (resolvedJobId) => {
await api.cancelPipeline(resolvedJobId);
handleJobCancelledImmediate(resolvedJobId);
toastRef.current?.show({
severity: 'success',
summary: 'Aus Queue entfernt',
detail: `Job #${resolvedJobId} wurde aus der Queue entfernt.`,
life: 3000
});
});
};
const handleJobStarted = async (jobId) => {
await loadJobs();
toastRef.current?.show({
@@ -331,7 +704,13 @@ export default function ConverterPage() {
if (!jobId) {
return;
}
const confirmed = window.confirm(`Historieneintrag Job #${jobId} löschen?`);
const confirmed = await confirmModal({
header: 'Historieneintrag löschen',
message: `Historieneintrag Job #${jobId} löschen?`,
acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) {
return;
}
@@ -365,13 +744,20 @@ export default function ConverterPage() {
if (!jobId) {
return;
}
const confirmed = window.confirm(`Job #${jobId} abbrechen?`);
const confirmed = await confirmModal({
header: 'Job abbrechen',
message: `Job #${jobId} abbrechen?`,
acceptLabel: 'Abbrechen',
rejectLabel: 'Zurück',
danger: true
});
if (!confirmed) {
return;
}
setCancelDetailBusy(true);
try {
await api.cancelConverterJob(jobId);
handleJobCancelledImmediate(jobId);
toastRef.current?.show({
severity: 'success',
summary: 'Abbruch gesendet',
@@ -411,13 +797,33 @@ export default function ConverterPage() {
}
};
const handleJobCancelledImmediate = (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setJobs((prev) => prev.map((job) => (
Number(job?.id) === normalizedJobId
? { ...job, status: 'CANCELLED' }
: job
)));
setJobProgress((prev) => {
const next = { ...prev };
delete next[normalizedJobId];
return next;
});
if (Number(selectedJob?.id || 0) === normalizedJobId) {
setSelectedJob((prev) => (prev ? { ...prev, status: 'CANCELLED' } : prev));
}
};
const handleJobCancelled = () => setTimeout(loadJobs, 1000);
const handleJobInputsChanged = async (_jobId, _payload) => {
setExplorerRefreshToken((t) => t + 1);
await loadJobs();
};
const activeJobs = jobs.filter((j) => !['DONE', 'FINISHED', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
const activeJobs = jobs.filter((j) => !['DONE', 'FINISHED', 'ERROR'].includes(String(j.status || '').toUpperCase()));
// Auto-expand: ersten Job aufklappen, wenn keiner expanded ist (außer user hat explizit zugeklappt)
useEffect(() => {
@@ -458,6 +864,7 @@ export default function ConverterPage() {
<ConverterFileExplorer
onSelectionChange={handleSelectionChange}
refreshToken={explorerRefreshToken}
selectionResetToken={selectionResetToken}
navigateToPath={explorerNavigateTo}
onAssignmentChanged={loadJobs}
/>
@@ -484,21 +891,54 @@ export default function ConverterPage() {
</p>
) : (
<div className="dashboard-job-list converter-jobs-list">
{activeJobs.map((job) => (
<ConverterJobCard
key={job.id}
job={job}
jobProgress={jobProgress[job.id] || null}
isExpanded={Number(job.id) === Number(expandedJobId)}
onExpand={() => setExpandedJobId(Number(job.id))}
onCollapse={() => setExpandedJobId(null)}
onStarted={handleJobStarted}
onDeleted={handleJobDeleted}
onCancelled={handleJobCancelled}
onOpenDetails={handleOpenJobDetails}
onInputsChanged={handleJobInputsChanged}
/>
))}
{activeJobs.map((job) => {
const plan = parseConverterPlan(job);
const converterMediaType = String(plan?.converterMediaType || '').trim().toLowerCase();
const isAudioJob = converterMediaType === 'audio';
if (isAudioJob) {
return (
<ConverterJobCard
key={job.id}
job={job}
jobProgress={jobProgress[job.id] || null}
isExpanded={Number(job.id) === Number(expandedJobId)}
onExpand={() => setExpandedJobId(Number(job.id))}
onCollapse={() => setExpandedJobId(null)}
onStarted={handleJobStarted}
onDeleted={handleJobDeleted}
onCancelled={handleJobCancelled}
onOpenDetails={handleOpenJobDetails}
onInputsChanged={handleJobInputsChanged}
/>
);
}
return (
<ConverterVideoJobCard
key={job.id}
job={job}
plan={plan}
jobProgress={jobProgress[job.id] || null}
isQueued={queuedJobIdSet.has(Number(job.id))}
busy={actionBusyJobIds.has(Number(job.id))}
userPresets={videoUserPresets}
hbPresets={videoHbPresets}
isExpanded={Number(job.id) === Number(expandedJobId)}
onExpand={() => setExpandedJobId(Number(job.id))}
onCollapse={() => setExpandedJobId(null)}
onOpenMetadata={handleOpenMetadataDialog}
onReassignOmdb={handleOpenMetadataDialog}
onStart={handleStartPipelineJob}
onConfirmReview={handleConfirmReview}
onSelectPlaylist={handleSelectPlaylist}
onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
onCancel={handleCancelPipelineJob}
onRetry={handleRetryJob}
onDeleteJob={handleDeleteConverterJob}
onRemoveFromQueue={handleRemoveFromQueue}
onInputsChanged={handleJobInputsChanged}
/>
);
})}
</div>
)}
</Card>
@@ -523,6 +963,19 @@ export default function ConverterPage() {
onHide={() => setJobModeVisible(false)}
/>
<MetadataSelectionDialog
visible={metadataDialogVisible}
context={metadataDialogContext}
onHide={() => {
if (metadataDialogBusy) return;
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
}}
onSubmit={handleMetadataSubmit}
onSearch={handleOmdbSearch}
busy={metadataDialogBusy}
/>
<JobDetailDialog
visible={detailVisible}
job={selectedJob}
@@ -675,3 +1128,263 @@ function JobModeDialog({
</Dialog>
);
}
function ConverterVideoJobCard({
job,
plan,
jobProgress,
isQueued,
busy,
userPresets,
hbPresets,
isExpanded,
onExpand,
onCollapse,
onOpenMetadata,
onReassignOmdb,
onStart,
onConfirmReview,
onSelectPlaylist,
onSelectHandBrakeTitle,
onCancel,
onRetry,
onDeleteJob,
onRemoveFromQueue,
onInputsChanged
}) {
const jobId = Number(job?.id || 0);
const status = String(job?.status || '').trim().toUpperCase() || 'UNKNOWN';
const title = String(job?.title || job?.detected_title || `Job #${jobId}`).trim() || `Job #${jobId}`;
const state = String(jobProgress?.state || status).trim().toUpperCase() || status;
const pipeline = buildConverterPipeline(job, plan, jobProgress);
const progressValue = Number(jobProgress?.progress);
const hasProgress = Number.isFinite(progressValue);
if (!isExpanded) {
return (
<button
type="button"
className="dashboard-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="dashboard-job-row-content">
<div className="dashboard-job-row-main">
<strong className="dashboard-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">
<Tag value={getStatusLabel(status, { queued: isQueued })} severity={getStatusSeverity(normalizeStatus(state), { queued: isQueued })} />
</div>
{hasProgress && (
<div className="dashboard-job-row-progress">
<ProgressBar value={Math.max(0, Math.min(100, progressValue))} showValue={false} />
</div>
)}
</div>
<i className="pi pi-angle-down" aria-hidden="true" />
</button>
);
}
return (
<div className="dashboard-job-expanded">
<div className="dashboard-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="dashboard-job-expanded-title">
<strong className="dashboard-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">
<Tag value={getStatusLabel(status, { queued: isQueued })} severity={getStatusSeverity(normalizeStatus(state), { queued: isQueued })} />
<Tag value="Converter" severity="info" />
</div>
</div>
<Button
label="Einklappen"
icon="pi pi-angle-up"
severity="secondary"
outlined
onClick={onCollapse}
disabled={busy}
/>
</div>
<PipelineStatusCard
jobId={jobId}
pipeline={pipeline}
onOpenMetadata={onOpenMetadata}
onReassignOmdb={onReassignOmdb}
onStart={onStart}
onConfirmReview={onConfirmReview}
onSelectPlaylist={onSelectPlaylist}
onSelectHandBrakeTitle={onSelectHandBrakeTitle}
onCancel={onCancel}
onRetry={onRetry}
onDeleteJob={onDeleteJob}
onRemoveFromQueue={onRemoveFromQueue}
onInputsChanged={onInputsChanged}
isQueued={isQueued}
busy={busy}
/>
</div>
);
}
function ConverterVideoConfigPanel({
job,
plan,
userPresets,
hbPresets,
disabled,
onConfigSaved
}) {
const [outputFormat, setOutputFormat] = useState(
String(plan?.outputFormat || 'mkv').trim().toLowerCase() || 'mkv'
);
const [selectedUserPreset, setSelectedUserPreset] = useState(
Number.isFinite(Number(plan?.userPreset?.id)) ? Math.trunc(Number(plan.userPreset.id)) : null
);
const [selectedHbPreset, setSelectedHbPreset] = useState(
Number.isFinite(Number(plan?.userPreset?.id))
? ''
: String(plan?.userPreset?.handbrakePreset || '').trim()
);
const [draftSaving, setDraftSaving] = useState(false);
const [draftError, setDraftError] = useState(null);
const latestSavedRef = useRef(null);
const saveTimeoutRef = useRef(null);
useEffect(() => {
setOutputFormat(String(plan?.outputFormat || 'mkv').trim().toLowerCase() || 'mkv');
if (Number.isFinite(Number(plan?.userPreset?.id))) {
setSelectedUserPreset(Math.trunc(Number(plan.userPreset.id)));
setSelectedHbPreset('');
} else {
setSelectedUserPreset(null);
setSelectedHbPreset(String(plan?.userPreset?.handbrakePreset || '').trim());
}
}, [job?.id, plan?.outputFormat, plan?.userPreset?.id, plan?.userPreset?.handbrakePreset]);
const draftPayload = useMemo(() => {
let userPreset = null;
if (selectedUserPreset) {
const preset = (Array.isArray(userPresets) ? userPresets : [])
.find((p) => Number(p?.id) === Number(selectedUserPreset));
userPreset = preset
? { id: preset.id, handbrakePreset: preset.handbrake_preset || null, extraArgs: preset.extra_args || '' }
: { id: selectedUserPreset };
} else if (selectedHbPreset) {
userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' };
}
return {
converterMediaType: String(plan?.converterMediaType || 'video').trim().toLowerCase() || 'video',
outputFormat,
userPreset
};
}, [outputFormat, plan?.converterMediaType, selectedUserPreset, selectedHbPreset, userPresets]);
const draftSerialized = useMemo(() => JSON.stringify(draftPayload), [draftPayload]);
useEffect(() => {
if (disabled) return;
if (latestSavedRef.current === null) {
latestSavedRef.current = draftSerialized;
return;
}
if (draftSerialized === latestSavedRef.current) return;
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(async () => {
setDraftSaving(true);
setDraftError(null);
try {
await api.converterUpdateJobConfig(job.id, draftPayload);
latestSavedRef.current = draftSerialized;
onConfigSaved?.(job.id, draftPayload);
} catch (err) {
setDraftError(err?.message || 'Konfiguration konnte nicht gespeichert werden.');
} finally {
setDraftSaving(false);
}
}, 650);
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
};
}, [disabled, draftPayload, draftSerialized, job.id]);
const userPresetOptions = [
{ label: '— Kein User-Preset —', value: null },
...(Array.isArray(userPresets) ? userPresets : []).map((p) => ({ label: p.name, value: p.id }))
];
const hbPresetOptions = [
{ label: '— Kein Preset (Standard) —', value: '' },
...(Array.isArray(hbPresets) ? hbPresets : []).map((p) => ({
label: p.category ? `[${p.category}] ${p.name}` : p.name,
value: p.name
}))
];
return (
<div className="cjc-config" style={{ marginBottom: '0.75rem' }}>
<div className="cjc-config-row">
<div className="cjc-config-field">
<label className="cjc-config-label">Ausgabeformat</label>
<Dropdown
value={outputFormat}
options={VIDEO_OUTPUT_FORMATS}
onChange={(e) => setOutputFormat(e.value)}
style={{ width: '100%' }}
disabled={disabled}
/>
</div>
<div className="cjc-config-field">
<label className="cjc-config-label">User-Preset</label>
<Dropdown
value={selectedUserPreset}
options={userPresetOptions}
onChange={(e) => { setSelectedUserPreset(e.value); if (e.value) setSelectedHbPreset(''); }}
style={{ width: '100%' }}
disabled={disabled}
/>
</div>
<div className="cjc-config-field">
<label className="cjc-config-label">HandBrake-Preset</label>
<Dropdown
value={selectedHbPreset}
options={hbPresetOptions}
onChange={(e) => setSelectedHbPreset(e.value)}
filter
placeholder="Preset auswählen …"
style={{ width: '100%' }}
disabled={disabled || Boolean(selectedUserPreset)}
/>
{selectedUserPreset ? (
<small style={{ color: 'var(--text-color-secondary)' }}>Deaktiviert, solange ein User-Preset aktiv ist.</small>
) : null}
</div>
</div>
{draftError ? (
<small style={{ color: 'var(--red-600)' }}>{draftError}</small>
) : (
<small style={{ color: 'var(--text-color-secondary)' }}>
{draftSaving ? 'Speichere Konfiguration …' : 'Konfiguration gespeichert'}
</small>
)}
</div>
);
}
+86 -14
View File
@@ -21,6 +21,7 @@ import discIndicatorIcon from '../assets/media-disc.svg';
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';
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
@@ -326,6 +327,7 @@ function getStorageUsageTone(usagePercent) {
function normalizeQueue(queue) {
const payload = queue && typeof queue === 'object' ? queue : {};
const runningJobs = Array.isArray(payload.runningJobs) ? payload.runningJobs : [];
const idleJobs = Array.isArray(payload.idleJobs) ? payload.idleJobs : [];
const queuedJobs = Array.isArray(payload.queuedJobs) ? payload.queuedJobs : [];
return {
maxParallelJobs: Number(payload.maxParallelJobs || 1),
@@ -335,6 +337,8 @@ function normalizeQueue(queue) {
runningCount: Number(payload.runningCount || runningJobs.length || 0),
runningCdCount: Number(payload.runningCdCount || 0),
runningJobs,
idleCount: Number(payload.idleCount || idleJobs.length || 0),
idleJobs,
queuedJobs,
queuedCount: Number(payload.queuedCount || queuedJobs.length || 0),
updatedAt: payload.updatedAt || null
@@ -1739,6 +1743,9 @@ export default function DashboardPage({
if (startOptions.selectedUserPresetId !== undefined) {
confirmPayload.selectedUserPresetId = startOptions.selectedUserPresetId;
}
if (startOptions.selectedHandBrakePreset !== undefined) {
confirmPayload.selectedHandBrakePreset = startOptions.selectedHandBrakePreset;
}
await api.confirmEncodeReview(normalizedJobId, confirmPayload);
}
const response = mediaType === 'audiobook'
@@ -1843,16 +1850,25 @@ export default function DashboardPage({
jobId,
selectedEncodeTitleId = null,
selectedTrackSelection = null,
selectedPostEncodeScriptIds = undefined
selectedPostEncodeScriptIds = undefined,
selectedUserPresetId = undefined,
selectedHandBrakePreset = undefined
) => {
const normalizedJobId = normalizeJobId(jobId);
if (normalizedJobId) setJobBusy(normalizedJobId, true);
try {
await api.confirmEncodeReview(jobId, {
const payload = {
selectedEncodeTitleId,
selectedTrackSelection,
selectedPostEncodeScriptIds
});
};
if (selectedUserPresetId !== undefined) {
payload.selectedUserPresetId = selectedUserPresetId;
}
if (selectedHandBrakePreset !== undefined) {
payload.selectedHandBrakePreset = selectedHandBrakePreset;
}
await api.confirmEncodeReview(jobId, payload);
await refreshPipeline();
await loadDashboardJobs();
setExpandedJobId(normalizedJobId);
@@ -1927,11 +1943,16 @@ export default function DashboardPage({
}
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim();
const confirmed = window.confirm(
const confirmed = await confirmModal({
header: 'Job löschen',
message:
`Job #${normalizedJobId} wirklich löschen?\n` +
`${title}\n\n` +
'Hinweis: Dateien bleiben erhalten. Das zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.'
);
'Hinweis: Dateien bleiben erhalten. Das zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.',
acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) {
return;
}
@@ -1976,10 +1997,14 @@ export default function DashboardPage({
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
const title = job?.title || job?.detected_title || `Job #${jobId}`;
if (job?.encodeSuccess) {
const confirmed = window.confirm(
const confirmed = await confirmModal({
header: 'Encode neu starten',
message:
`Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n` +
'Es wird eine neue Datei mit Kollisionsprüfung angelegt.'
);
'Es wird eine neue Datei mit Kollisionsprüfung angelegt.',
acceptLabel: 'Neu encodieren',
rejectLabel: 'Abbrechen'
});
if (!confirmed) {
return;
}
@@ -2366,11 +2391,13 @@ export default function DashboardPage({
const canRescan = !isDriveActive;
const canOpenMetadataModal = Boolean(defaultMetadataDialogContext?.jobId);
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
const queueIdleJobs = Array.isArray(queueState?.idleJobs) ? queueState.idleJobs : [];
const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
const canReorderQueue = queuedJobs.length > 1 && !queueReorderBusy;
const queueHasJobEntry = queueRunningJobs.some((i) => i.jobId != null)
|| queuedJobs.some((i) => i.jobId != null);
const buildRunningQueueScriptKey = (jobId) => `running-${normalizeJobId(jobId) || '-'}`;
const buildIdleQueueScriptKey = (jobId) => `idle-${normalizeJobId(jobId) || '-'}`;
const buildQueuedQueueScriptKey = (entryId) => `queued-${Number(entryId) || '-'}`;
const toggleQueueScriptDetails = (key) => {
if (!key) {
@@ -2394,6 +2421,12 @@ export default function DashboardPage({
}
validKeys.add(buildRunningQueueScriptKey(item?.jobId));
}
for (const item of queueIdleJobs) {
if (!hasQueueScriptSummary(item)) {
continue;
}
validKeys.add(buildIdleQueueScriptKey(item?.jobId));
}
for (const item of queuedJobs) {
if (String(item?.type || 'job') !== 'job' || !hasQueueScriptSummary(item)) {
continue;
@@ -2411,8 +2444,8 @@ export default function DashboardPage({
}
}
return changed ? next : prev;
});
}, [queueRunningJobs, queuedJobs]);
});
}, [queueRunningJobs, queueIdleJobs, queuedJobs]);
const queuedJobIdSet = useMemo(() => {
const set = new Set();
for (const item of queuedJobs) {
@@ -3090,11 +3123,12 @@ export default function DashboardPage({
<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" />
<Tag value={`CD max.: ${queueState?.maxParallelCdEncodes || 2}`} severity="info" />
<Tag value={`Audio/CD max.: ${queueState?.maxParallelCdEncodes || 2}`} severity="info" />
<Tag value={`Gesamt max.: ${queueState?.maxTotalEncodes || 3}`} severity="info" />
{queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio CDs überspringen die Film-Queue-Reihenfolge" />}
{queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio/CD-Jobs überspringen die Film-Queue-Reihenfolge" />}
<Tag value={`Film laufend: ${queueState?.runningCount || 0}`} severity={(queueState?.runningCount || 0) > 0 ? 'warning' : 'success'} />
<Tag value={`CD laufend: ${queueState?.runningCdCount || 0}`} severity={(queueState?.runningCdCount || 0) > 0 ? 'warning' : 'success'} />
<Tag value={`Audio/CD laufend: ${queueState?.runningCdCount || 0}`} severity={(queueState?.runningCdCount || 0) > 0 ? 'warning' : 'success'} />
<Tag value={`Idle: ${queueState?.idleCount || 0}`} severity={(queueState?.idleCount || 0) > 0 ? 'info' : 'success'} />
<Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} />
</div>
@@ -3143,6 +3177,44 @@ export default function DashboardPage({
})
)}
</div>
<div className="pipeline-queue-col">
<h4>Idle</h4>
{queueIdleJobs.length === 0 ? (
<small>Keine Idle-Jobs.</small>
) : (
queueIdleJobs.map((item) => {
const hasScriptSummary = hasQueueScriptSummary(item);
const detailKey = buildIdleQueueScriptKey(item?.jobId);
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
return (
<div key={`idle-${item.jobId}`} className="pipeline-queue-entry-wrap">
<div className="pipeline-queue-item idle queue-job-item">
<div className="pipeline-queue-item-main">
<strong>
#{item.jobId} | {item.title || `Job #${item.jobId}`}
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
</strong>
<small>{getStatusLabel(item.status)}</small>
</div>
{hasScriptSummary ? (
<button
type="button"
className="queue-job-expand-btn"
aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
aria-expanded={detailsExpanded}
onClick={() => toggleQueueScriptDetails(detailKey)}
>
<i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
</button>
) : null}
</div>
{detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
</div>
);
})
)}
</div>
<div className="pipeline-queue-col">
<div className="pipeline-queue-col-header">
<h4>Warteschlange</h4>
+7 -1
View File
@@ -6,6 +6,7 @@ import { Tag } from 'primereact/tag';
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { api } from '../api/client';
import { confirmModal } from '../utils/confirmModal';
function formatDetectedMediaType(value) {
const mediaType = String(value || '').trim().toLowerCase();
@@ -48,7 +49,12 @@ export default function DatabasePage() {
const handleImportOrphanRaw = async (row) => {
const target = row?.rawPath || row?.folderName || '-';
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`);
const confirmed = await confirmModal({
header: 'Historienjob anlegen',
message: `Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`,
acceptLabel: 'Job anlegen',
rejectLabel: 'Abbrechen'
});
if (!confirmed) {
return;
}
+8 -1
View File
@@ -8,6 +8,7 @@ import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
import { api } from '../api/client';
import { confirmModal } from '../utils/confirmModal';
const STATUS_OPTIONS = [
{ label: 'Alle Stati', value: '' },
@@ -164,7 +165,13 @@ export default function DownloadsPage({ refreshToken = 0 }) {
return;
}
const label = row?.archiveName || `ZIP ${id}`;
const confirmed = window.confirm(`"${label}" wirklich loeschen?`);
const confirmed = await confirmModal({
header: 'ZIP löschen',
message: `"${label}" wirklich loeschen?`,
acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) {
return;
}
+67 -18
View File
@@ -22,6 +22,7 @@ import {
normalizeStatus,
STATUS_FILTER_OPTIONS
} from '../utils/statusPresentation';
import { confirmModal } from '../utils/confirmModal';
const MEDIA_FILTER_OPTIONS = [
{ label: 'Alle Medien', value: '' },
@@ -274,25 +275,50 @@ function resolveAudiobookDetails(row) {
};
}
function getOutputLabelForRow(row) {
const mediaType = resolveMediaType(row);
if (mediaType === 'cd') {
return 'Audio-Dateien';
function resolveConverterMediaType(row) {
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
const value = String(
encodePlan?.converterMediaType
|| row?.converterMediaType
|| ''
).trim().toLowerCase();
if (value === 'audio') {
return 'audio';
}
if (mediaType === 'audiobook') {
if (value === 'video' || value === 'iso') {
return 'video';
}
return null;
}
function hasAudioOutput(row) {
const mediaType = resolveMediaType(row);
if (mediaType === 'cd' || mediaType === 'audiobook') {
return true;
}
if (mediaType === 'converter') {
return resolveConverterMediaType(row) === 'audio';
}
return false;
}
function getOutputLabelForRow(row) {
if (resolveMediaType(row) === 'audiobook') {
return 'Audiobook-Datei(en)';
}
if (hasAudioOutput(row)) {
return 'Audio-Dateien';
}
return 'Movie-Datei(en)';
}
function getOutputShortLabelForRow(row) {
const mediaType = resolveMediaType(row);
if (mediaType === 'cd') {
return 'Audio';
}
if (mediaType === 'audiobook') {
if (resolveMediaType(row) === 'audiobook') {
return 'Audiobook';
}
if (hasAudioOutput(row)) {
return 'Audio';
}
return 'Movie';
}
@@ -717,7 +743,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
} else if (action === 'restart_review') {
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
if (!skipConfirm) {
const confirmed = window.confirm(`Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Dashboard neu getroffen werden.`);
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.`,
acceptLabel: 'Review starten',
rejectLabel: 'Abbrechen'
});
if (!confirmed) {
return;
}
@@ -743,7 +774,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
} else if (action === 'restart_cd_review') {
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
if (!skipConfirm) {
const confirmed = window.confirm(`CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Dashboard geöffnet.`);
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.`,
acceptLabel: 'Vorprüfung starten',
rejectLabel: 'Abbrechen'
});
if (!confirmed) {
return;
}
@@ -848,7 +884,13 @@ export default function HistoryPage({ refreshToken = 0 }) {
const outputShortLabel = getOutputShortLabelForRow(row);
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
const title = row.title || row.detected_title || `Job #${row.id}`;
const confirmed = window.confirm(`${label} für "${title}" wirklich löschen?`);
const confirmed = await confirmModal({
header: 'Dateien löschen',
message: `${label} für "${title}" wirklich löschen?`,
acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) {
return;
}
@@ -998,7 +1040,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
const mediaType = resolveMediaType(row);
const actionLabel = mediaType === 'cd' ? 'CD-Rip' : 'Retry';
const confirmed = window.confirm(`${actionLabel} für "${title}" neu starten?`);
const confirmed = await confirmModal({
header: `${actionLabel} neu starten`,
message: `${actionLabel} für "${title}" neu starten?`,
acceptLabel: 'Neu starten',
rejectLabel: 'Abbrechen'
});
if (!confirmed) {
return;
}
@@ -1415,6 +1462,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
const listItem = (row) => {
const mediaMeta = resolveMediaTypeMeta(row);
const isCdJob = mediaMeta.mediaType === 'cd';
const outputIsAudio = hasAudioOutput(row);
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
const subtitle = isCdJob
? [
@@ -1460,8 +1508,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
<div className="history-dv-flags-row">
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{renderPresenceChip(isCdJob || mediaMeta.mediaType === 'audiobook' ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', isCdJob || mediaMeta.mediaType === 'audiobook' ? Boolean(row?.ripSuccessful) : Boolean(row?.encodeSuccess))}
{renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
</div>
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
@@ -1476,6 +1524,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
const gridItem = (row) => {
const mediaMeta = resolveMediaTypeMeta(row);
const isCdJob = mediaMeta.mediaType === 'cd';
const outputIsAudio = hasAudioOutput(row);
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
const subtitle = isCdJob
? [
@@ -1523,8 +1572,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
<div className="history-dv-flags-row">
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{renderPresenceChip(isCdJob || mediaMeta.mediaType === 'audiobook' ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', isCdJob || mediaMeta.mediaType === 'audiobook' ? Boolean(row?.ripSuccessful) : Boolean(row?.encodeSuccess))}
{renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
</div>
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
+16 -2
View File
@@ -11,6 +11,7 @@ import { InputSwitch } from 'primereact/inputswitch';
import { api } from '../api/client';
import DynamicSettingsForm from '../components/DynamicSettingsForm';
import CronJobsTab from '../components/CronJobsTab';
import { confirmModal } from '../utils/confirmModal';
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
@@ -689,7 +690,13 @@ export default function SettingsPage() {
if (!Number.isFinite(scriptId) || scriptId <= 0) {
return;
}
const confirmed = window.confirm(`Script "${script?.name || scriptId}" wirklich löschen?`);
const confirmed = await confirmModal({
header: 'Script löschen',
message: `Script "${script?.name || scriptId}" wirklich löschen?`,
acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) {
return;
}
@@ -933,7 +940,14 @@ export default function SettingsPage() {
if (!Number.isFinite(chainId) || chainId <= 0) {
return;
}
if (!window.confirm(`Skriptkette "${chain?.name || chainId}" wirklich löschen?`)) {
const confirmed = await confirmModal({
header: 'Skriptkette löschen',
message: `Skriptkette "${chain?.name || chainId}" wirklich löschen?`,
acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) {
return;
}
try {
+41
View File
@@ -0,0 +1,41 @@
import { confirmDialog } from 'primereact/confirmdialog';
export function confirmModal(options = {}) {
const message = String(options?.message || '').trim();
if (!message) {
return Promise.resolve(false);
}
const header = String(options?.header || 'Bitte bestätigen').trim() || 'Bitte bestätigen';
const acceptLabel = String(options?.acceptLabel || 'Ja').trim() || 'Ja';
const rejectLabel = String(options?.rejectLabel || 'Abbrechen').trim() || 'Abbrechen';
const icon = String(options?.icon || 'pi pi-exclamation-triangle').trim() || 'pi pi-exclamation-triangle';
const danger = Boolean(options?.danger);
return new Promise((resolve) => {
let finished = false;
const settle = (value) => {
if (finished) {
return;
}
finished = true;
resolve(Boolean(value));
};
confirmDialog({
header,
message,
icon,
closable: true,
closeOnEscape: true,
draggable: false,
dismissableMask: true,
acceptLabel,
rejectLabel,
...(danger ? { acceptClassName: 'p-button-danger' } : {}),
accept: () => settle(true),
reject: () => settle(false),
onHide: () => settle(false)
});
});
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster",
"version": "0.12.0-17",
"version": "0.12.0-18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
"version": "0.12.0-17",
"version": "0.12.0-18",
"devDependencies": {
"concurrently": "^9.1.2"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
"version": "0.12.0-17",
"version": "0.12.0-18",
"scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend",