0.12.0-18 Tests and Converter Fixes
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user