Queue and UI fixes

This commit is contained in:
2026-03-05 11:04:20 +00:00
parent 23acea4773
commit e3d890c071
103 changed files with 11400 additions and 2010 deletions

View File

@@ -10,6 +10,7 @@ import PipelineStatusCard from '../components/PipelineStatusCard';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'];
const dashboardStatuses = new Set([
@@ -21,23 +22,9 @@ const dashboardStatuses = new Set([
'READY_TO_ENCODE',
'RIPPING',
'ENCODING',
'CANCELLED',
'ERROR'
]);
const statusSeverityMap = {
IDLE: 'secondary',
DISC_DETECTED: 'info',
ANALYZING: 'warning',
METADATA_SELECTION: 'warning',
WAITING_FOR_USER_DECISION: 'warning',
READY_TO_START: 'info',
MEDIAINFO_CHECK: 'warning',
READY_TO_ENCODE: 'info',
RIPPING: 'warning',
ENCODING: 'warning',
FINISHED: 'success',
ERROR: 'danger'
};
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -46,6 +33,54 @@ function normalizeJobId(value) {
return Math.trunc(parsed);
}
function normalizeQueue(queue) {
const payload = queue && typeof queue === 'object' ? queue : {};
const runningJobs = Array.isArray(payload.runningJobs) ? payload.runningJobs : [];
const queuedJobs = Array.isArray(payload.queuedJobs) ? payload.queuedJobs : [];
return {
maxParallelJobs: Number(payload.maxParallelJobs || 1),
runningCount: Number(payload.runningCount || runningJobs.length || 0),
runningJobs,
queuedJobs,
queuedCount: Number(payload.queuedCount || queuedJobs.length || 0),
updatedAt: payload.updatedAt || null
};
}
function getQueueActionResult(response) {
return response?.result && typeof response.result === 'object' ? response.result : {};
}
function showQueuedToast(toastRef, actionLabel, result) {
if (!toastRef?.current) {
return;
}
const queuePosition = Number(result?.queuePosition || 0);
const positionText = queuePosition > 0 ? `Position ${queuePosition}` : 'in der Warteschlange';
toastRef.current.show({
severity: 'info',
summary: `${actionLabel} in Queue`,
detail: `${actionLabel} wurde ${positionText} eingeplant.`,
life: 3200
});
}
function reorderQueuedItems(items, draggedJobId, targetJobId) {
const list = Array.isArray(items) ? items : [];
const from = list.findIndex((item) => Number(item?.jobId) === Number(draggedJobId));
const to = list.findIndex((item) => Number(item?.jobId) === Number(targetJobId));
if (from < 0 || to < 0 || from === to) {
return list;
}
const next = [...list];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next.map((item, index) => ({
...item,
position: index + 1
}));
}
function getAnalyzeContext(job) {
return job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object'
? job.makemkvInfo.analyzeContext
@@ -99,14 +134,12 @@ function JobStepChecks({ backupSuccess, encodeSuccess }) {
function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const jobId = normalizeJobId(job?.id);
if (
const isCurrentSessionJob = Boolean(
jobId
&& currentPipelineJobId
&& jobId === currentPipelineJobId
&& String(currentPipeline?.state || '').trim().toUpperCase() !== 'IDLE'
) {
return currentPipeline;
}
);
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const analyzeContext = getAnalyzeContext(job);
@@ -124,11 +157,11 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const errorText = String(job?.error_message || '').trim().toUpperCase();
const hasOutputPath = Boolean(String(job?.output_path || '').trim());
const hasEncodePlan = Boolean(encodePlan && Array.isArray(encodePlan?.titles) && encodePlan.titles.length > 0);
const looksLikeCancelledEncode = jobStatus === 'ERROR' && (
const looksLikeCancelledEncode = (jobStatus === 'ERROR' || jobStatus === 'CANCELLED') && (
(errorText.includes('ABGEBROCHEN') || errorText.includes('CANCELLED'))
&& (hasOutputPath || Boolean(job?.encode_input_path) || Boolean(job?.handbrakeInfo))
);
const looksLikeEncodingError = jobStatus === 'ERROR' && (
const looksLikeEncodingError = (jobStatus === 'ERROR' || jobStatus === 'CANCELLED') && (
errorText.includes('ENCODING')
|| errorText.includes('HANDBRAKE')
|| lastState === 'ENCODING'
@@ -142,9 +175,60 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
&& (
jobStatus === 'READY_TO_ENCODE'
|| jobStatus === 'ENCODING'
|| jobStatus === 'CANCELLED'
|| looksLikeEncodingError
)
);
const canRestartReviewFromRaw = Boolean(
job?.raw_path
&& !processingStates.includes(jobStatus)
);
const computedContext = {
jobId,
rawPath: job?.raw_path || null,
detectedTitle: job?.detected_title || null,
inputPath,
hasEncodableTitle,
reviewConfirmed,
mode,
sourceJobId: encodePlan?.sourceJobId || null,
selectedMetadata: {
title: job?.title || job?.detected_title || null,
year: job?.year || null,
imdbId: job?.imdb_id || null,
poster: job?.poster_url || null
},
mediaInfoReview: encodePlan,
playlistAnalysis: analyzeContext.playlistAnalysis || null,
playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
playlistCandidates: Array.isArray(analyzeContext?.playlistAnalysis?.evaluatedCandidates)
? analyzeContext.playlistAnalysis.evaluatedCandidates
: [],
selectedPlaylist: analyzeContext.selectedPlaylist || null,
selectedTitleId: analyzeContext.selectedTitleId ?? null,
omdbCandidates: [],
canRestartEncodeFromLastSettings,
canRestartReviewFromRaw
};
if (isCurrentSessionJob) {
const existingContext = currentPipeline?.context && typeof currentPipeline.context === 'object'
? currentPipeline.context
: {};
return {
...currentPipeline,
context: {
...computedContext,
...existingContext,
rawPath: existingContext.rawPath || computedContext.rawPath,
selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata,
canRestartEncodeFromLastSettings:
existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
canRestartReviewFromRaw:
existingContext.canRestartReviewFromRaw ?? computedContext.canRestartReviewFromRaw
}
};
}
return {
state: jobStatus,
@@ -152,41 +236,24 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
progress: Number.isFinite(Number(job?.progress)) ? Number(job.progress) : 0,
eta: job?.eta || null,
statusText: job?.status_text || job?.error_message || null,
context: {
jobId,
inputPath,
hasEncodableTitle,
reviewConfirmed,
mode,
sourceJobId: encodePlan?.sourceJobId || null,
selectedMetadata: {
title: job?.title || job?.detected_title || null,
year: job?.year || null,
imdbId: job?.imdb_id || null,
poster: job?.poster_url || null
},
mediaInfoReview: encodePlan,
playlistAnalysis: analyzeContext.playlistAnalysis || null,
playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
playlistCandidates: Array.isArray(analyzeContext?.playlistAnalysis?.evaluatedCandidates)
? analyzeContext.playlistAnalysis.evaluatedCandidates
: [],
selectedPlaylist: analyzeContext.selectedPlaylist || null,
selectedTitleId: analyzeContext.selectedTitleId ?? null,
canRestartEncodeFromLastSettings
}
context: computedContext
};
}
export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline }) {
const [busy, setBusy] = useState(false);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [cancelCleanupDialog, setCancelCleanupDialog] = useState({
visible: false,
jobId: null,
outputPath: null
target: null,
path: null
});
const [cancelCleanupBusy, setCancelCleanupBusy] = useState(false);
const [queueState, setQueueState] = useState(() => normalizeQueue(pipeline?.queue));
const [queueReorderBusy, setQueueReorderBusy] = useState(false);
const [draggingQueueJobId, setDraggingQueueJobId] = useState(null);
const [liveJobLog, setLiveJobLog] = useState('');
const [jobsLoading, setJobsLoading] = useState(false);
const [dashboardJobs, setDashboardJobs] = useState([]);
@@ -200,8 +267,16 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
const loadDashboardJobs = async () => {
setJobsLoading(true);
try {
const response = await api.getJobs();
const allJobs = Array.isArray(response?.jobs) ? response.jobs : [];
const [jobsResponse, queueResponse] = await Promise.allSettled([
api.getJobs(),
api.getPipelineQueue()
]);
const allJobs = jobsResponse.status === 'fulfilled'
? (Array.isArray(jobsResponse.value?.jobs) ? jobsResponse.value.jobs : [])
: [];
if (queueResponse.status === 'fulfilled') {
setQueueState(normalizeQueue(queueResponse.value?.queue));
}
const next = allJobs
.filter((job) => dashboardStatuses.has(String(job?.status || '').trim().toUpperCase()))
.sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0));
@@ -237,10 +312,20 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
};
useEffect(() => {
if (!metadataDialogVisible) {
return;
}
if (metadataDialogContext?.jobId) {
return;
}
if (pipeline?.state !== 'METADATA_SELECTION' && pipeline?.state !== 'WAITING_FOR_USER_DECISION') {
setMetadataDialogVisible(false);
}
}, [pipeline?.state]);
}, [pipeline?.state, metadataDialogVisible, metadataDialogContext?.jobId]);
useEffect(() => {
setQueueState(normalizeQueue(pipeline?.queue));
}, [pipeline?.queue]);
useEffect(() => {
void loadDashboardJobs();
@@ -303,6 +388,71 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
return map;
}, [dashboardJobs, pipeline, currentPipelineJobId]);
const buildMetadataContextForJob = (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return null;
}
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
const pipelineForJob = pipelineByJobId.get(normalizedJobId) || null;
const context = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
? pipelineForJob.context
: {};
const selectedMetadata = context.selectedMetadata && typeof context.selectedMetadata === 'object'
? context.selectedMetadata
: {
title: job?.title || job?.detected_title || context?.detectedTitle || '',
year: job?.year || null,
imdbId: job?.imdb_id || null,
poster: job?.poster_url || null
};
return {
...context,
jobId: normalizedJobId,
detectedTitle: context?.detectedTitle || job?.detected_title || selectedMetadata?.title || '',
selectedMetadata,
omdbCandidates: Array.isArray(context?.omdbCandidates) ? context.omdbCandidates : []
};
};
const defaultMetadataDialogContext = useMemo(() => {
const currentState = String(pipeline?.state || '').trim().toUpperCase();
const currentContext = pipeline?.context && typeof pipeline.context === 'object'
? pipeline.context
: null;
const currentContextJobId = normalizeJobId(currentContext?.jobId);
if (
(currentState === 'METADATA_SELECTION' || currentState === 'WAITING_FOR_USER_DECISION')
&& currentContextJobId
) {
return {
...currentContext,
jobId: currentContextJobId,
selectedMetadata: currentContext?.selectedMetadata || {
title: currentContext?.detectedTitle || '',
year: null,
imdbId: null,
poster: null
},
omdbCandidates: Array.isArray(currentContext?.omdbCandidates) ? currentContext.omdbCandidates : []
};
}
const pendingJob = dashboardJobs.find((job) => {
const normalized = normalizeStatus(job?.status);
return normalized === 'METADATA_SELECTION' || normalized === 'WAITING_FOR_USER_DECISION';
});
if (!pendingJob) {
return null;
}
return buildMetadataContextForJob(pendingJob.id);
}, [pipeline, dashboardJobs, pipelineByJobId]);
const effectiveMetadataDialogContext = metadataDialogContext
|| defaultMetadataDialogContext
|| pipeline?.context
|| {};
const showError = (error) => {
toastRef.current?.show({
severity: 'error',
@@ -312,12 +462,39 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
});
};
const handleOpenMetadataDialog = (jobId = null) => {
const context = jobId ? buildMetadataContextForJob(jobId) : defaultMetadataDialogContext;
if (!context?.jobId) {
showError(new Error('Kein Job mit offener Metadaten-Auswahl gefunden.'));
return;
}
setMetadataDialogContext(context);
setMetadataDialogVisible(true);
};
const handleAnalyze = async () => {
setBusy(true);
try {
await api.analyzeDisc();
const response = await api.analyzeDisc();
await refreshPipeline();
await loadDashboardJobs();
const analyzedJobId = normalizeJobId(response?.result?.jobId);
if (analyzedJobId && state === 'ENCODING') {
setMetadataDialogContext({
jobId: analyzedJobId,
detectedTitle: response?.result?.detectedTitle || '',
selectedMetadata: {
title: response?.result?.detectedTitle || '',
year: null,
imdbId: null,
poster: null
},
omdbCandidates: Array.isArray(response?.result?.omdbCandidates)
? response.result.omdbCandidates
: []
});
setMetadataDialogVisible(true);
}
} catch (error) {
showError(error);
} finally {
@@ -327,7 +504,14 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
const handleReanalyze = async () => {
const hasActiveJob = Boolean(pipeline?.context?.jobId || pipeline?.activeJobId);
if (hasActiveJob && !['IDLE', 'DISC_DETECTED', 'FINISHED'].includes(state)) {
if (state === 'ENCODING') {
const confirmed = window.confirm(
'Laufendes Encoding bleibt aktiv. Neue Disk jetzt als separaten Job analysieren?'
);
if (!confirmed) {
return;
}
} else if (hasActiveJob && !['IDLE', 'DISC_DETECTED', 'FINISHED'].includes(state)) {
const confirmed = window.confirm(
'Aktuellen Ablauf verwerfen und die Disk ab der ersten MakeMKV-Analyse neu starten?'
);
@@ -361,21 +545,34 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
}
};
const handleCancel = async () => {
const cancelledState = state;
const cancelledJobId = currentPipelineJobId;
const handleCancel = async (jobId = null, jobState = null) => {
const cancelledJobId = normalizeJobId(jobId) || currentPipelineJobId;
const cancelledJob = dashboardJobs.find((item) => normalizeJobId(item?.id) === cancelledJobId) || null;
const cancelledState = String(
jobState
|| cancelledJob?.status
|| state
|| 'IDLE'
).trim().toUpperCase();
setBusy(true);
try {
await api.cancelPipeline();
await api.cancelPipeline(cancelledJobId);
await refreshPipeline();
await loadDashboardJobs();
if (cancelledState === 'ENCODING' && cancelledJobId) {
setCancelCleanupDialog({
visible: true,
jobId: cancelledJobId,
outputPath: cancelledJob?.output_path || null
target: 'movie',
path: cancelledJob?.output_path || null
});
} else if (cancelledState === 'RIPPING' && cancelledJobId) {
setCancelCleanupDialog({
visible: true,
jobId: cancelledJobId,
target: 'raw',
path: cancelledJob?.raw_path || null
});
}
} catch (error) {
@@ -387,24 +584,32 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
const handleDeleteCancelledOutput = async () => {
const jobId = normalizeJobId(cancelCleanupDialog?.jobId);
const target = String(cancelCleanupDialog?.target || '').trim().toLowerCase();
const effectiveTarget = target === 'raw' ? 'raw' : 'movie';
if (!jobId) {
setCancelCleanupDialog({ visible: false, jobId: null, outputPath: null });
setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null });
return;
}
setCancelCleanupBusy(true);
try {
const response = await api.deleteJobFiles(jobId, 'movie');
const response = await api.deleteJobFiles(jobId, effectiveTarget);
const summary = response?.summary || {};
const deletedFiles = effectiveTarget === 'raw'
? (summary.raw?.filesDeleted ?? 0)
: (summary.movie?.filesDeleted ?? 0);
const removedDirs = effectiveTarget === 'raw'
? (summary.raw?.dirsRemoved ?? 0)
: (summary.movie?.dirsRemoved ?? 0);
toastRef.current?.show({
severity: 'success',
summary: 'Movie gelöscht',
detail: `Entfernt: ${summary.movie?.filesDeleted ?? 0} Datei(en), ${summary.movie?.dirsRemoved ?? 0} Ordner.`,
summary: effectiveTarget === 'raw' ? 'RAW gelöscht' : 'Movie gelöscht',
detail: `Entfernt: ${deletedFiles} Datei(en), ${removedDirs} Ordner.`,
life: 4000
});
await loadDashboardJobs();
await refreshPipeline();
setCancelCleanupDialog({ visible: false, jobId: null, outputPath: null });
setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null });
} catch (error) {
showError(error);
} finally {
@@ -425,13 +630,19 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
await api.confirmEncodeReview(normalizedJobId, {
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
selectedPostEncodeScriptIds: startOptions.selectedPostEncodeScriptIds ?? []
selectedPostEncodeScriptIds: startOptions.selectedPostEncodeScriptIds ?? [],
skipPipelineStateUpdate: true
});
}
await api.startJob(normalizedJobId);
const response = await api.startJob(normalizedJobId);
const result = getQueueActionResult(response);
await refreshPipeline();
await loadDashboardJobs();
setExpandedJobId(normalizedJobId);
if (result.queued) {
showQueuedToast(toastRef, 'Start', result);
} else {
setExpandedJobId(normalizedJobId);
}
} catch (error) {
showError(error);
} finally {
@@ -482,10 +693,15 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
const handleRetry = async (jobId) => {
setBusy(true);
try {
await api.retryJob(jobId);
const response = await api.retryJob(jobId);
const result = getQueueActionResult(response);
await refreshPipeline();
await loadDashboardJobs();
setExpandedJobId(normalizeJobId(jobId));
if (result.queued) {
showQueuedToast(toastRef, 'Retry', result);
} else {
setExpandedJobId(normalizeJobId(jobId));
}
} catch (error) {
showError(error);
} finally {
@@ -508,10 +724,15 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
setBusy(true);
try {
await api.restartEncodeWithLastSettings(jobId);
const response = await api.restartEncodeWithLastSettings(jobId);
const result = getQueueActionResult(response);
await refreshPipeline();
await loadDashboardJobs();
setExpandedJobId(normalizeJobId(jobId));
if (result.queued) {
showQueuedToast(toastRef, 'Encode-Neustart', result);
} else {
setExpandedJobId(normalizeJobId(jobId));
}
} catch (error) {
showError(error);
} finally {
@@ -519,6 +740,99 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
}
};
const handleRestartReviewFromRaw = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setBusy(true);
try {
await api.restartReviewFromRaw(normalizedJobId);
await refreshPipeline();
await loadDashboardJobs();
setExpandedJobId(normalizedJobId);
} catch (error) {
showError(error);
} finally {
setBusy(false);
}
};
const handleQueueDragEnter = (targetJobId) => {
const targetId = normalizeJobId(targetJobId);
const draggedId = normalizeJobId(draggingQueueJobId);
if (!targetId || !draggedId || targetId === draggedId || queueReorderBusy) {
return;
}
setQueueState((prev) => {
const queuedJobs = reorderQueuedItems(prev?.queuedJobs || [], draggedId, targetId);
return {
...normalizeQueue(prev),
queuedJobs,
queuedCount: queuedJobs.length
};
});
};
const handleQueueDrop = async () => {
const draggedId = normalizeJobId(draggingQueueJobId);
setDraggingQueueJobId(null);
if (!draggedId || queueReorderBusy) {
return;
}
const orderedJobIds = (Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [])
.map((item) => normalizeJobId(item?.jobId))
.filter(Boolean);
if (orderedJobIds.length <= 1) {
return;
}
setQueueReorderBusy(true);
try {
const response = await api.reorderPipelineQueue(orderedJobIds);
setQueueState(normalizeQueue(response?.queue));
} catch (error) {
showError(error);
try {
const latest = await api.getPipelineQueue();
setQueueState(normalizeQueue(latest?.queue));
} catch (_reloadError) {
// ignore reload failures after reorder error
}
} finally {
setQueueReorderBusy(false);
}
};
const handleRemoveQueuedJob = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId || queueReorderBusy) {
return;
}
setQueueReorderBusy(true);
try {
await api.cancelPipeline(normalizedJobId);
const latest = await api.getPipelineQueue();
setQueueState(normalizeQueue(latest?.queue));
} catch (error) {
showError(error);
} finally {
setQueueReorderBusy(false);
}
};
const syncQueueFromServer = async () => {
try {
const latest = await api.getPipelineQueue();
setQueueState(normalizeQueue(latest?.queue));
} catch (_error) {
// ignore sync failures
}
};
const handleOmdbSearch = async (query) => {
try {
const response = await api.searchOmdb(query);
@@ -536,6 +850,7 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
await refreshPipeline();
await loadDashboardJobs();
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
} catch (error) {
showError(error);
} finally {
@@ -544,13 +859,103 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
};
const device = lastDiscEvent || pipeline?.context?.device;
const canReanalyze = !processingStates.includes(state);
const canOpenMetadataModal = pipeline?.state === 'METADATA_SELECTION' || pipeline?.state === 'WAITING_FOR_USER_DECISION';
const canReanalyze = state === 'ENCODING'
? Boolean(device)
: !processingStates.includes(state);
const canOpenMetadataModal = Boolean(defaultMetadataDialogContext?.jobId);
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
const canReorderQueue = queuedJobs.length > 1 && !queueReorderBusy;
const queuedJobIdSet = useMemo(() => {
const set = new Set();
for (const item of queuedJobs) {
const id = normalizeJobId(item?.jobId);
if (id) {
set.add(id);
}
}
return set;
}, [queuedJobs]);
return (
<div className="page-grid">
<Toast ref={toastRef} />
<Card title="Job Queue" subTitle="Starts werden nach Parallel-Limit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
<div className="pipeline-queue-meta">
<Tag value={`Parallel: ${queueState?.maxParallelJobs || 1}`} severity="info" />
<Tag value={`Laufend: ${queueState?.runningCount || 0}`} severity={queueRunningJobs.length > 0 ? 'warning' : 'success'} />
<Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} />
</div>
<div className="pipeline-queue-grid">
<div className="pipeline-queue-col">
<h4>Laufende Jobs</h4>
{queueRunningJobs.length === 0 ? (
<small>Keine laufenden Jobs.</small>
) : (
queueRunningJobs.map((item) => (
<div key={`running-${item.jobId}`} className="pipeline-queue-item running">
<strong>#{item.jobId} | {item.title || `Job #${item.jobId}`}</strong>
<small>{getStatusLabel(item.status)}</small>
</div>
))
)}
</div>
<div className="pipeline-queue-col">
<h4>Warteschlange</h4>
{queuedJobs.length === 0 ? (
<small>Queue ist leer.</small>
) : (
queuedJobs.map((item) => {
const queuedJobId = normalizeJobId(item?.jobId);
const isDragging = normalizeJobId(draggingQueueJobId) === queuedJobId;
return (
<div
key={`queued-${item.jobId}`}
className={`pipeline-queue-item queued${isDragging ? ' dragging' : ''}`}
draggable={canReorderQueue}
onDragStart={() => setDraggingQueueJobId(queuedJobId)}
onDragEnter={() => handleQueueDragEnter(queuedJobId)}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
void handleQueueDrop();
}}
onDragEnd={() => {
setDraggingQueueJobId(null);
void syncQueueFromServer();
}}
>
<span className={`pipeline-queue-drag-handle${canReorderQueue ? '' : ' disabled'}`} title="Reihenfolge ändern">
<i className="pi pi-bars" />
</span>
<div className="pipeline-queue-item-main">
<strong>{item.position || '-'} | #{item.jobId} | {item.title || `Job #${item.jobId}`}</strong>
<small>{item.actionLabel || item.action || '-'} | Status {getStatusLabel(item.status)}</small>
</div>
<Button
icon="pi pi-times"
severity="danger"
text
rounded
size="small"
className="pipeline-queue-remove-btn"
disabled={queueReorderBusy}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleRemoveQueuedJob(queuedJobId);
}}
/>
</div>
);
})
)}
</div>
</div>
</Card>
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
{jobsLoading ? (
<p>Jobs werden geladen ...</p>
@@ -563,9 +968,13 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
if (!jobId) {
return null;
}
const normalizedStatus = normalizeStatus(job?.status);
const isQueued = queuedJobIdSet.has(jobId);
const statusBadgeValue = getStatusLabel(job?.status, { queued: isQueued });
const statusBadgeSeverity = getStatusSeverity(normalizedStatus, { queued: isQueued });
const isExpanded = normalizeJobId(expandedJobId) === jobId;
const isCurrentSession = currentPipelineJobId === jobId && state !== 'IDLE';
const isResumable = String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' && !isCurrentSession;
const isResumable = normalizedStatus === 'READY_TO_ENCODE' && !isCurrentSession;
const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0));
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
@@ -592,10 +1001,10 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
<span>#{jobId} | {jobTitle}</span>
</strong>
<div className="dashboard-job-badges">
<Tag value={String(job?.status || '-')} severity={statusSeverityMap[String(job?.status || '').trim().toUpperCase()] || 'secondary'} />
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
{String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE'
{normalizedStatus === 'READY_TO_ENCODE'
? <Tag value={reviewConfirmed ? 'Review bestätigt' : 'Review offen'} severity={reviewConfirmed ? 'success' : 'warning'} />
: null}
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
@@ -614,12 +1023,16 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
pipeline={pipelineForJob}
onAnalyze={handleAnalyze}
onReanalyze={handleReanalyze}
onOpenMetadata={handleOpenMetadataDialog}
onStart={handleStartJob}
onRestartEncode={handleRestartEncodeWithLastSettings}
onRestartReview={handleRestartReviewFromRaw}
onConfirmReview={handleConfirmReview}
onSelectPlaylist={handleSelectPlaylist}
onCancel={handleCancel}
onRetry={handleRetry}
onRemoveFromQueue={handleRemoveQueuedJob}
isQueued={isQueued}
busy={busy}
liveJobLog={isCurrentSession ? liveJobLog : ''}
/>
@@ -660,10 +1073,10 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
</div>
</div>
<div className="dashboard-job-badges">
<Tag value={String(job?.status || '-')} severity={statusSeverityMap[String(job?.status || '').trim().toUpperCase()] || 'secondary'} />
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
{String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE'
{normalizedStatus === 'READY_TO_ENCODE'
? <Tag value={reviewConfirmed ? 'Bestätigt' : 'Unbestätigt'} severity={reviewConfirmed ? 'success' : 'warning'} />
: null}
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
@@ -696,7 +1109,7 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
<Button
label="Metadaten-Modal öffnen"
icon="pi pi-list"
onClick={() => setMetadataDialogVisible(true)}
onClick={() => handleOpenMetadataDialog()}
disabled={!canOpenMetadataModal}
/>
</div>
@@ -725,36 +1138,43 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
<MetadataSelectionDialog
visible={metadataDialogVisible}
context={pipeline?.context || {}}
onHide={() => setMetadataDialogVisible(false)}
context={effectiveMetadataDialogContext}
onHide={() => {
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
}}
onSubmit={handleMetadataSubmit}
onSearch={handleOmdbSearch}
busy={busy}
/>
<Dialog
header="Encode abgebrochen"
header={cancelCleanupDialog?.target === 'raw' ? 'Rip abgebrochen' : 'Encode abgebrochen'}
visible={Boolean(cancelCleanupDialog.visible)}
onHide={() => setCancelCleanupDialog({ visible: false, jobId: null, outputPath: null })}
onHide={() => setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null })}
style={{ width: '32rem', maxWidth: '96vw' }}
modal
>
<p>
Soll die bisher erzeugte Movie-Datei inklusive Job-Ordner im Ausgabeverzeichnis gelöscht werden?
{cancelCleanupDialog?.target === 'raw'
? 'Soll der bisher erzeugte RAW-Ordner gelöscht werden?'
: 'Soll die bisher erzeugte Movie-Datei inklusive Job-Ordner im Ausgabeverzeichnis gelöscht werden?'}
</p>
{cancelCleanupDialog?.outputPath ? (
<small className="muted-inline">Output-Pfad: {cancelCleanupDialog.outputPath}</small>
{cancelCleanupDialog?.path ? (
<small className="muted-inline">
{cancelCleanupDialog?.target === 'raw' ? 'RAW-Pfad' : 'Output-Pfad'}: {cancelCleanupDialog.path}
</small>
) : null}
<div className="dialog-actions">
<Button
label="Behalten"
severity="secondary"
outlined
onClick={() => setCancelCleanupDialog({ visible: false, jobId: null, outputPath: null })}
onClick={() => setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null })}
disabled={cancelCleanupBusy}
/>
<Button
label="Movie löschen"
label={cancelCleanupDialog?.target === 'raw' ? 'RAW löschen' : 'Movie löschen'}
icon="pi pi-trash"
severity="danger"
onClick={handleDeleteCancelledOutput}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Card } from 'primereact/card';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
@@ -12,35 +12,30 @@ import JobDetailDialog from '../components/JobDetailDialog';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
const statusOptions = [
{ label: 'Alle', value: '' },
{ label: 'FINISHED', value: 'FINISHED' },
{ label: 'ERROR', value: 'ERROR' },
{ label: 'WAITING_FOR_USER_DECISION', value: 'WAITING_FOR_USER_DECISION' },
{ label: 'READY_TO_START', value: 'READY_TO_START' },
{ label: 'READY_TO_ENCODE', value: 'READY_TO_ENCODE' },
{ label: 'MEDIAINFO_CHECK', value: 'MEDIAINFO_CHECK' },
{ label: 'RIPPING', value: 'RIPPING' },
{ label: 'ENCODING', value: 'ENCODING' },
{ label: 'ANALYZING', value: 'ANALYZING' },
{ label: 'METADATA_SELECTION', value: 'METADATA_SELECTION' }
];
function statusSeverity(status) {
if (status === 'FINISHED') return 'success';
if (status === 'ERROR') return 'danger';
if (status === 'READY_TO_START' || status === 'READY_TO_ENCODE') return 'info';
if (status === 'WAITING_FOR_USER_DECISION') return 'warning';
if (status === 'RIPPING' || status === 'ENCODING' || status === 'ANALYZING' || status === 'MEDIAINFO_CHECK') return 'warning';
return 'secondary';
}
import {
getStatusLabel,
getStatusSeverity,
normalizeStatus,
STATUS_FILTER_OPTIONS
} from '../utils/statusPresentation';
function resolveMediaType(row) {
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
return raw === 'bluray' ? 'bluray' : 'disc';
}
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function getQueueActionResult(response) {
return response?.result && typeof response.result === 'object' ? response.result : {};
}
export default function DatabasePage() {
const [rows, setRows] = useState([]);
const [orphanRows, setOrphanRows] = useState([]);
@@ -59,7 +54,18 @@ export default function DatabasePage() {
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
const [deleteEntryBusyJobId, setDeleteEntryBusyJobId] = useState(null);
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
const [queuedJobIds, setQueuedJobIds] = useState([]);
const toastRef = useRef(null);
const queuedJobIdSet = useMemo(() => {
const next = new Set();
for (const value of Array.isArray(queuedJobIds) ? queuedJobIds : []) {
const id = normalizeJobId(value);
if (id) {
next.add(id);
}
}
return next;
}, [queuedJobIds]);
const loadRows = async () => {
setLoading(true);
@@ -85,8 +91,21 @@ export default function DatabasePage() {
}
};
const loadQueue = async () => {
try {
const response = await api.getPipelineQueue();
const queuedRows = Array.isArray(response?.queue?.queuedJobs) ? response.queue.queuedJobs : [];
const queuedIds = queuedRows
.map((item) => normalizeJobId(item?.jobId))
.filter(Boolean);
setQueuedJobIds(queuedIds);
} catch (_error) {
setQueuedJobIds([]);
}
};
const load = async () => {
await Promise.all([loadRows(), loadOrphans()]);
await Promise.all([loadRows(), loadOrphans(), loadQueue()]);
};
useEffect(() => {
@@ -241,6 +260,116 @@ export default function DatabasePage() {
}
};
const handleRestartEncode = async (row) => {
const title = row.title || row.detected_title || `Job #${row.id}`;
if (row?.encodeSuccess) {
const confirmed = window.confirm(
`Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n` +
'Es wird eine neue Datei mit Kollisionsprüfung angelegt.'
);
if (!confirmed) {
return;
}
}
setActionBusy(true);
try {
const response = await api.restartEncodeWithLastSettings(row.id);
const result = getQueueActionResult(response);
if (result.queued) {
const queuePosition = Number(result?.queuePosition || 0);
toastRef.current?.show({
severity: 'info',
summary: 'Encode-Neustart in Queue',
detail: queuePosition > 0
? `Job wurde auf Position ${queuePosition} eingeplant.`
: 'Job wurde in die Warteschlange eingeplant.',
life: 3500
});
} else {
toastRef.current?.show({
severity: 'success',
summary: 'Encode-Neustart gestartet',
detail: 'Letzte bestätigte Einstellungen werden verwendet.',
life: 3500
});
}
await load();
await refreshDetailIfOpen(row.id);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Encode-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
setActionBusy(false);
}
};
const handleRestartReview = async (row) => {
setActionBusy(true);
try {
await api.restartReviewFromRaw(row.id);
toastRef.current?.show({
severity: 'success',
summary: 'Review-Neustart gestartet',
detail: 'Die Titel-/Spurprüfung wird aus dem RAW neu berechnet.',
life: 3500
});
await load();
await refreshDetailIfOpen(row.id);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
setActionBusy(false);
}
};
const handleRemoveFromQueue = async (row) => {
const jobId = normalizeJobId(row?.id || row);
if (!jobId) {
return;
}
setActionBusy(true);
try {
await api.cancelPipeline(jobId);
toastRef.current?.show({
severity: 'success',
summary: 'Aus Queue entfernt',
detail: `Job #${jobId} wurde aus der Warteschlange entfernt.`,
life: 3200
});
await load();
await refreshDetailIfOpen(jobId);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Queue-Entfernung fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setActionBusy(false);
}
};
const handleResumeReady = async (row) => {
setActionBusy(true);
try {
await api.resumeReadyJob(row.id);
toastRef.current?.show({
severity: 'success',
summary: 'Job ins Dashboard geladen',
detail: 'Job ist wieder im Dashboard aktiv.',
life: 3200
});
await load();
await refreshDetailIfOpen(row.id);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Laden fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
setActionBusy(false);
}
};
const mapDeleteChoice = (value) => {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'raw') return 'raw';
@@ -431,7 +560,17 @@ export default function DatabasePage() {
</div>
);
const stateBody = (row) => <Tag value={row.status} severity={statusSeverity(row.status)} />;
const stateBody = (row) => {
const normalizedStatus = normalizeStatus(row?.status);
const rowId = normalizeJobId(row?.id);
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
return (
<Tag
value={getStatusLabel(row?.status, { queued: isQueued })}
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
/>
);
};
const mediaBody = (row) => {
const mediaType = resolveMediaType(row);
const src = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
@@ -480,7 +619,7 @@ export default function DatabasePage() {
/>
<Dropdown
value={status}
options={statusOptions}
options={STATUS_FILTER_OPTIONS}
optionLabel="label"
optionValue="value"
onChange={(event) => setStatus(event.value)}
@@ -556,9 +695,14 @@ export default function DatabasePage() {
setLogLoadingMode(null);
}}
onAssignOmdb={openMetadataAssignDialog}
onResumeReady={handleResumeReady}
onRestartEncode={handleRestartEncode}
onRestartReview={handleRestartReview}
onReencode={handleReencode}
onDeleteFiles={handleDeleteFiles}
onDeleteEntry={handleDeleteEntry}
onRemoveFromQueue={handleRemoveFromQueue}
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
omdbAssignBusy={metadataDialogBusy}
actionBusy={actionBusy}
reencodeBusy={reencodeBusyJobId === selectedJob?.id}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Card } from 'primereact/card';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
@@ -11,26 +11,31 @@ import { api } from '../api/client';
import JobDetailDialog from '../components/JobDetailDialog';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
const statusOptions = [
{ label: 'Alle', value: '' },
{ label: 'FINISHED', value: 'FINISHED' },
{ label: 'ERROR', value: 'ERROR' },
{ label: 'WAITING_FOR_USER_DECISION', value: 'WAITING_FOR_USER_DECISION' },
{ label: 'READY_TO_START', value: 'READY_TO_START' },
{ label: 'READY_TO_ENCODE', value: 'READY_TO_ENCODE' },
{ label: 'MEDIAINFO_CHECK', value: 'MEDIAINFO_CHECK' },
{ label: 'RIPPING', value: 'RIPPING' },
{ label: 'ENCODING', value: 'ENCODING' },
{ label: 'ANALYZING', value: 'ANALYZING' },
{ label: 'METADATA_SELECTION', value: 'METADATA_SELECTION' }
];
import {
getStatusLabel,
getStatusSeverity,
getProcessStatusLabel,
normalizeStatus,
STATUS_FILTER_OPTIONS
} from '../utils/statusPresentation';
function resolveMediaType(row) {
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
return raw === 'bluray' ? 'bluray' : 'disc';
}
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function getQueueActionResult(response) {
return response?.result && typeof response.result === 'object' ? response.result : {};
}
export default function HistoryPage() {
const [jobs, setJobs] = useState([]);
const [search, setSearch] = useState('');
@@ -42,13 +47,40 @@ export default function HistoryPage() {
const [actionBusy, setActionBusy] = useState(false);
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
const [loading, setLoading] = useState(false);
const [queuedJobIds, setQueuedJobIds] = useState([]);
const toastRef = useRef(null);
const queuedJobIdSet = useMemo(() => {
const next = new Set();
for (const value of Array.isArray(queuedJobIds) ? queuedJobIds : []) {
const id = normalizeJobId(value);
if (id) {
next.add(id);
}
}
return next;
}, [queuedJobIds]);
const load = async () => {
setLoading(true);
try {
const response = await api.getJobs({ search, status });
setJobs(response.jobs || []);
const [jobsResponse, queueResponse] = await Promise.allSettled([
api.getJobs({ search, status }),
api.getPipelineQueue()
]);
if (jobsResponse.status === 'fulfilled') {
setJobs(jobsResponse.value.jobs || []);
} else {
setJobs([]);
}
if (queueResponse.status === 'fulfilled') {
const queuedRows = Array.isArray(queueResponse.value?.queue?.queuedJobs) ? queueResponse.value.queue.queuedJobs : [];
const queuedIds = queuedRows
.map((item) => normalizeJobId(item?.jobId))
.filter(Boolean);
setQueuedJobIds(queuedIds);
} else {
setQueuedJobIds([]);
}
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
} finally {
@@ -189,13 +221,26 @@ export default function HistoryPage() {
setActionBusy(true);
try {
await api.restartEncodeWithLastSettings(row.id);
toastRef.current?.show({
severity: 'success',
summary: 'Encode-Neustart gestartet',
detail: 'Letzte bestätigte Einstellungen werden verwendet.',
life: 3500
});
const response = await api.restartEncodeWithLastSettings(row.id);
const result = getQueueActionResult(response);
if (result.queued) {
const queuePosition = Number(result?.queuePosition || 0);
toastRef.current?.show({
severity: 'info',
summary: 'Encode-Neustart in Queue',
detail: queuePosition > 0
? `Job wurde auf Position ${queuePosition} eingeplant.`
: 'Job wurde in die Warteschlange eingeplant.',
life: 3500
});
} else {
toastRef.current?.show({
severity: 'success',
summary: 'Encode-Neustart gestartet',
detail: 'Letzte bestätigte Einstellungen werden verwendet.',
life: 3500
});
}
await load();
await refreshDetailIfOpen(row.id);
} catch (error) {
@@ -205,17 +250,64 @@ export default function HistoryPage() {
}
};
const statusBody = (row) => <Tag value={row.status} />;
const handleRemoveFromQueue = async (row) => {
const jobId = normalizeJobId(row?.id || row);
if (!jobId) {
return;
}
setActionBusy(true);
try {
await api.cancelPipeline(jobId);
toastRef.current?.show({
severity: 'success',
summary: 'Aus Queue entfernt',
detail: `Job #${jobId} wurde aus der Warteschlange entfernt.`,
life: 3200
});
await load();
await refreshDetailIfOpen(jobId);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Queue-Entfernung fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setActionBusy(false);
}
};
const statusBody = (row) => {
const normalizedStatus = normalizeStatus(row?.status);
const rowId = normalizeJobId(row?.id);
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
return (
<Tag
value={getStatusLabel(row?.status, { queued: isQueued })}
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
/>
);
};
const mkBody = (row) => (
<span className="job-step-cell">
{row?.backupSuccess ? <i className="pi pi-check-circle job-step-ok-icon" aria-label="Backup erfolgreich" title="Backup erfolgreich" /> : null}
<span>{row.makemkvInfo ? `${row.makemkvInfo.status || '-'} ${typeof row.makemkvInfo.lastProgress === 'number' ? `${row.makemkvInfo.lastProgress.toFixed(1)}%` : ''}` : '-'}</span>
<span>
{row.makemkvInfo
? `${getProcessStatusLabel(row.makemkvInfo.status)} ${typeof row.makemkvInfo.lastProgress === 'number' ? `${row.makemkvInfo.lastProgress.toFixed(1)}%` : ''}`
: '-'}
</span>
</span>
);
const hbBody = (row) => (
<span className="job-step-cell">
{row?.encodeSuccess ? <i className="pi pi-check-circle job-step-ok-icon" aria-label="Encode erfolgreich" title="Encode erfolgreich" /> : null}
<span>{row.handbrakeInfo ? `${row.handbrakeInfo.status || '-'} ${typeof row.handbrakeInfo.lastProgress === 'number' ? `${row.handbrakeInfo.lastProgress.toFixed(1)}%` : ''}` : '-'}</span>
<span>
{row.handbrakeInfo
? `${getProcessStatusLabel(row.handbrakeInfo.status)} ${typeof row.handbrakeInfo.lastProgress === 'number' ? `${row.handbrakeInfo.lastProgress.toFixed(1)}%` : ''}`
: '-'}
</span>
</span>
);
const mediaBody = (row) => {
@@ -245,7 +337,7 @@ export default function HistoryPage() {
/>
<Dropdown
value={status}
options={statusOptions}
options={STATUS_FILTER_OPTIONS}
optionLabel="label"
optionValue="value"
onChange={(event) => setStatus(event.value)}
@@ -291,6 +383,8 @@ export default function HistoryPage() {
onRestartEncode={handleRestartEncode}
onReencode={handleReencode}
onDeleteFiles={handleDeleteFiles}
onRemoveFromQueue={handleRemoveFromQueue}
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
actionBusy={actionBusy}
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
onHide={() => {

View File

@@ -667,7 +667,7 @@ export default function SettingsPage() {
<div className="script-test-result">
<h4>Letzter Script-Test: {lastScriptTestResult.scriptName}</h4>
<small>
Status: {lastScriptTestResult.success ? 'SUCCESS' : 'ERROR'}
Status: {lastScriptTestResult.success ? 'Erfolgreich' : 'Fehler'}
{' | '}exit={lastScriptTestResult.exitCode ?? 'n/a'}
{' | '}timeout={lastScriptTestResult.timedOut ? 'ja' : 'nein'}
{' | '}dauer={Number(lastScriptTestResult.durationMs || 0)}ms