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
+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) => {