Queue and UI fixes
This commit is contained in:
@@ -35,6 +35,13 @@ function App() {
|
||||
}));
|
||||
}
|
||||
|
||||
if (message.type === 'PIPELINE_QUEUE_CHANGED') {
|
||||
setPipeline((prev) => ({
|
||||
...(prev || {}),
|
||||
queue: message.payload || null
|
||||
}));
|
||||
}
|
||||
|
||||
if (message.type === 'DISC_DETECTED') {
|
||||
setLastDiscEvent(message.payload?.device || null);
|
||||
}
|
||||
|
||||
@@ -115,9 +115,10 @@ export const api = {
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
},
|
||||
cancelPipeline() {
|
||||
cancelPipeline(jobId = null) {
|
||||
return request('/pipeline/cancel', {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ jobId })
|
||||
});
|
||||
},
|
||||
retryJob(jobId) {
|
||||
@@ -135,11 +136,25 @@ export const api = {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
restartReviewFromRaw(jobId) {
|
||||
return request(`/pipeline/restart-review/${jobId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
restartEncodeWithLastSettings(jobId) {
|
||||
return request(`/pipeline/restart-encode/${jobId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
getPipelineQueue() {
|
||||
return request('/pipeline/queue');
|
||||
},
|
||||
reorderPipelineQueue(orderedJobIds = []) {
|
||||
return request('/pipeline/queue/reorder', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ orderedJobIds: Array.isArray(orderedJobIds) ? orderedJobIds : [] })
|
||||
});
|
||||
},
|
||||
getJobs(params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.status) query.set('status', params.status);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from 'primereact/button';
|
||||
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
|
||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||
import { getStatusLabel } from '../utils/statusPresentation';
|
||||
|
||||
function JsonView({ title, value }) {
|
||||
return (
|
||||
@@ -18,36 +19,43 @@ function resolveMediaType(job) {
|
||||
return raw === 'bluray' ? 'bluray' : 'disc';
|
||||
}
|
||||
|
||||
function statusBadgeMeta(status) {
|
||||
function statusBadgeMeta(status, queued = false) {
|
||||
const normalized = String(status || '').trim().toUpperCase();
|
||||
const label = getStatusLabel(normalized, { queued });
|
||||
if (queued) {
|
||||
return { label, icon: 'pi-list', tone: 'info' };
|
||||
}
|
||||
if (normalized === 'FINISHED') {
|
||||
return { label: normalized, icon: 'pi-check-circle', tone: 'success' };
|
||||
return { label, icon: 'pi-check-circle', tone: 'success' };
|
||||
}
|
||||
if (normalized === 'ERROR') {
|
||||
return { label: normalized, icon: 'pi-times-circle', tone: 'danger' };
|
||||
return { label, icon: 'pi-times-circle', tone: 'danger' };
|
||||
}
|
||||
if (normalized === 'CANCELLED') {
|
||||
return { label, icon: 'pi-ban', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'READY_TO_ENCODE' || normalized === 'READY_TO_START') {
|
||||
return { label: normalized, icon: 'pi-play-circle', tone: 'info' };
|
||||
return { label, icon: 'pi-play-circle', tone: 'info' };
|
||||
}
|
||||
if (normalized === 'WAITING_FOR_USER_DECISION') {
|
||||
return { label: normalized, icon: 'pi-exclamation-circle', tone: 'warning' };
|
||||
return { label, icon: 'pi-exclamation-circle', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'METADATA_SELECTION') {
|
||||
return { label: normalized, icon: 'pi-list', tone: 'warning' };
|
||||
return { label, icon: 'pi-list', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'ANALYZING') {
|
||||
return { label: normalized, icon: 'pi-search', tone: 'warning' };
|
||||
return { label, icon: 'pi-search', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'RIPPING') {
|
||||
return { label: normalized, icon: 'pi-download', tone: 'warning' };
|
||||
return { label, icon: 'pi-download', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'MEDIAINFO_CHECK') {
|
||||
return { label: normalized, icon: 'pi-sliders-h', tone: 'warning' };
|
||||
return { label, icon: 'pi-sliders-h', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'ENCODING') {
|
||||
return { label: normalized, icon: 'pi-cog', tone: 'warning' };
|
||||
return { label, icon: 'pi-cog', tone: 'warning' };
|
||||
}
|
||||
return { label: normalized || '-', icon: 'pi-info-circle', tone: 'secondary' };
|
||||
return { label: label || '-', icon: 'pi-info-circle', tone: 'secondary' };
|
||||
}
|
||||
|
||||
function omdbField(value) {
|
||||
@@ -82,10 +90,14 @@ export default function JobDetailDialog({
|
||||
onLoadLog,
|
||||
logLoadingMode = null,
|
||||
onAssignOmdb,
|
||||
onResumeReady,
|
||||
onRestartEncode,
|
||||
onRestartReview,
|
||||
onReencode,
|
||||
onDeleteFiles,
|
||||
onDeleteEntry,
|
||||
onRemoveFromQueue,
|
||||
isQueued = false,
|
||||
omdbAssignBusy = false,
|
||||
actionBusy = false,
|
||||
reencodeBusy = false,
|
||||
@@ -95,6 +107,11 @@ export default function JobDetailDialog({
|
||||
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
|
||||
const showFinalLog = !running;
|
||||
const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && mkDone && !running);
|
||||
const canResumeReady = Boolean(
|
||||
(String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' || String(job?.last_state || '').trim().toUpperCase() === 'READY_TO_ENCODE')
|
||||
&& !running
|
||||
&& typeof onResumeReady === 'function'
|
||||
);
|
||||
const hasConfirmedPlan = Boolean(
|
||||
job?.encodePlan
|
||||
&& Array.isArray(job?.encodePlan?.titles)
|
||||
@@ -103,7 +120,13 @@ export default function JobDetailDialog({
|
||||
);
|
||||
const hasRestartInput = Boolean(job?.encode_input_path || job?.raw_path || job?.encodePlan?.encodeInputPath);
|
||||
const canRestartEncode = Boolean(hasConfirmedPlan && hasRestartInput && !running);
|
||||
const canRestartReview = Boolean(
|
||||
(job?.rawStatus?.exists || job?.raw_path)
|
||||
&& !running
|
||||
&& typeof onRestartReview === 'function'
|
||||
);
|
||||
const canDeleteEntry = !running && typeof onDeleteEntry === 'function';
|
||||
const queueLocked = Boolean(isQueued && job?.id);
|
||||
const logCount = Number(job?.log_count || 0);
|
||||
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
|
||||
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
|
||||
@@ -112,7 +135,7 @@ export default function JobDetailDialog({
|
||||
const mediaTypeLabel = mediaType === 'bluray' ? 'Blu-ray' : 'Sonstiges Medium';
|
||||
const mediaTypeIcon = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
|
||||
const mediaTypeAlt = mediaType === 'bluray' ? 'Blu-ray' : 'Disc';
|
||||
const statusMeta = statusBadgeMeta(job?.status);
|
||||
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
||||
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||
|
||||
return (
|
||||
@@ -261,74 +284,112 @@ export default function JobDetailDialog({
|
||||
|
||||
<h4>Aktionen</h4>
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
label="OMDb neu zuordnen"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
onClick={() => onAssignOmdb?.(job)}
|
||||
loading={omdbAssignBusy}
|
||||
disabled={running || typeof onAssignOmdb !== 'function'}
|
||||
/>
|
||||
{typeof onRestartEncode === 'function' ? (
|
||||
{queueLocked ? (
|
||||
<Button
|
||||
label="Encode neu starten"
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
label="Aus Queue löschen"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onRestartEncode?.(job)}
|
||||
onClick={() => onRemoveFromQueue?.(job)}
|
||||
loading={actionBusy}
|
||||
disabled={!canRestartEncode}
|
||||
disabled={typeof onRemoveFromQueue !== 'function'}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
label="RAW neu encodieren"
|
||||
icon="pi pi-cog"
|
||||
severity="info"
|
||||
size="small"
|
||||
onClick={() => onReencode?.(job)}
|
||||
loading={reencodeBusy}
|
||||
disabled={!canReencode || typeof onReencode !== 'function'}
|
||||
/>
|
||||
<Button
|
||||
label="RAW löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="warning"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onDeleteFiles?.(job, 'raw')}
|
||||
loading={actionBusy}
|
||||
disabled={!job.rawStatus?.exists || typeof onDeleteFiles !== 'function'}
|
||||
/>
|
||||
<Button
|
||||
label="Movie löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="warning"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onDeleteFiles?.(job, 'movie')}
|
||||
loading={actionBusy}
|
||||
disabled={!job.outputStatus?.exists || typeof onDeleteFiles !== 'function'}
|
||||
/>
|
||||
<Button
|
||||
label="Beides löschen"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
size="small"
|
||||
onClick={() => onDeleteFiles?.(job, 'both')}
|
||||
loading={actionBusy}
|
||||
disabled={(!job.rawStatus?.exists && !job.outputStatus?.exists) || typeof onDeleteFiles !== 'function'}
|
||||
/>
|
||||
<Button
|
||||
label="Historieneintrag löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onDeleteEntry?.(job)}
|
||||
loading={deleteEntryBusy}
|
||||
disabled={!canDeleteEntry}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
label="OMDb neu zuordnen"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
onClick={() => onAssignOmdb?.(job)}
|
||||
loading={omdbAssignBusy}
|
||||
disabled={running || typeof onAssignOmdb !== 'function'}
|
||||
/>
|
||||
{canResumeReady ? (
|
||||
<Button
|
||||
label="Im Dashboard öffnen"
|
||||
icon="pi pi-window-maximize"
|
||||
severity="info"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onResumeReady?.(job)}
|
||||
loading={actionBusy}
|
||||
/>
|
||||
) : null}
|
||||
{typeof onRestartEncode === 'function' ? (
|
||||
<Button
|
||||
label="Encode neu starten"
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
size="small"
|
||||
onClick={() => onRestartEncode?.(job)}
|
||||
loading={actionBusy}
|
||||
disabled={!canRestartEncode}
|
||||
/>
|
||||
) : null}
|
||||
{typeof onRestartReview === 'function' ? (
|
||||
<Button
|
||||
label="Review neu starten"
|
||||
icon="pi pi-refresh"
|
||||
severity="info"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onRestartReview?.(job)}
|
||||
loading={actionBusy}
|
||||
disabled={!canRestartReview}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
label="RAW neu encodieren"
|
||||
icon="pi pi-cog"
|
||||
severity="info"
|
||||
size="small"
|
||||
onClick={() => onReencode?.(job)}
|
||||
loading={reencodeBusy}
|
||||
disabled={!canReencode || typeof onReencode !== 'function'}
|
||||
/>
|
||||
<Button
|
||||
label="RAW löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="warning"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onDeleteFiles?.(job, 'raw')}
|
||||
loading={actionBusy}
|
||||
disabled={!job.rawStatus?.exists || typeof onDeleteFiles !== 'function'}
|
||||
/>
|
||||
<Button
|
||||
label="Movie löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="warning"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onDeleteFiles?.(job, 'movie')}
|
||||
loading={actionBusy}
|
||||
disabled={!job.outputStatus?.exists || typeof onDeleteFiles !== 'function'}
|
||||
/>
|
||||
<Button
|
||||
label="Beides löschen"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
size="small"
|
||||
onClick={() => onDeleteFiles?.(job, 'both')}
|
||||
loading={actionBusy}
|
||||
disabled={(!job.rawStatus?.exists && !job.outputStatus?.exists) || typeof onDeleteFiles !== 'function'}
|
||||
/>
|
||||
<Button
|
||||
label="Historieneintrag löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onDeleteEntry?.(job)}
|
||||
loading={deleteEntryBusy}
|
||||
disabled={!canDeleteEntry}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h4>Log</h4>
|
||||
|
||||
@@ -51,6 +51,20 @@ function normalizeTrackIdList(values) {
|
||||
return output;
|
||||
}
|
||||
|
||||
function isBurnedSubtitleTrack(track) {
|
||||
const flags = Array.isArray(track?.subtitlePreviewFlags)
|
||||
? track.subtitlePreviewFlags
|
||||
: (Array.isArray(track?.flags) ? track.flags : []);
|
||||
const hasBurnedFlag = flags.some((flag) => String(flag || '').trim().toLowerCase() === 'burned');
|
||||
const summary = `${track?.subtitlePreviewSummary || ''} ${track?.subtitleActionSummary || ''}`;
|
||||
return Boolean(
|
||||
track?.subtitlePreviewBurnIn
|
||||
|| track?.burnIn
|
||||
|| hasBurnedFlag
|
||||
|| /burned/i.test(summary)
|
||||
);
|
||||
}
|
||||
|
||||
function splitArgs(input) {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return [];
|
||||
@@ -542,8 +556,9 @@ function TrackList({
|
||||
<div className="media-track-list">
|
||||
{tracks.map((track) => {
|
||||
const trackId = normalizeTrackId(track.id);
|
||||
const burned = type === 'subtitle' ? isBurnedSubtitleTrack(track) : false;
|
||||
const checked = allowSelection
|
||||
? (trackId !== null && selectedIds.includes(trackId))
|
||||
? (trackId !== null && selectedIds.includes(trackId) && !(type === 'subtitle' && burned))
|
||||
: Boolean(track.selectedForEncode);
|
||||
const selectedIndex = trackId !== null
|
||||
? checkedTrackOrder.indexOf(trackId)
|
||||
@@ -567,26 +582,13 @@ function TrackList({
|
||||
})()
|
||||
: 'Nicht übernommen')
|
||||
: null;
|
||||
const subtitleFlags = type === 'subtitle' && checked
|
||||
? (Array.isArray(track.subtitlePreviewFlags)
|
||||
? track.subtitlePreviewFlags
|
||||
: (Array.isArray(track.flags) ? track.flags : []))
|
||||
: [];
|
||||
|
||||
const displayLanguage = toLang2(track.language || track.languageLabel || 'und');
|
||||
const displayHint = track.description || track.title;
|
||||
const displayCodec = simplifyCodec(type, track.format, displayHint);
|
||||
const displayChannelCount = channelCount(track.channels);
|
||||
const displayAudioTitle = audioChannelLabel(track.channels);
|
||||
const audioVariant = type === 'audio' ? extractAudioVariant(displayHint) : '';
|
||||
const burned = type === 'subtitle' && checked
|
||||
? Boolean(
|
||||
track.subtitlePreviewBurnIn
|
||||
|| track.burnIn
|
||||
|| subtitleFlags.includes('burned')
|
||||
|| /burned/i.test(String(track.subtitlePreviewSummary || track.subtitleActionSummary || ''))
|
||||
)
|
||||
: false;
|
||||
const disabled = !allowSelection || (type === 'subtitle' && burned);
|
||||
|
||||
let displayText = `#${track.id} | ${displayLanguage} | ${displayCodec}`;
|
||||
if (type === 'audio') {
|
||||
@@ -611,13 +613,13 @@ function TrackList({
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => {
|
||||
if (!allowSelection || typeof onToggleTrack !== 'function' || trackId === null) {
|
||||
if (disabled || typeof onToggleTrack !== 'function' || trackId === null) {
|
||||
return;
|
||||
}
|
||||
onToggleTrack(trackId, event.target.checked);
|
||||
}}
|
||||
readOnly={!allowSelection}
|
||||
disabled={!allowSelection}
|
||||
readOnly={disabled}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{displayText}</span>
|
||||
</label>
|
||||
@@ -848,12 +850,18 @@ export default function MediaInfoReviewPanel({
|
||||
? currentSelectedId === normalizeTitleId(title.id)
|
||||
: Boolean(title.selectedForEncode);
|
||||
const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
|
||||
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
|
||||
const selectableSubtitleTrackIds = subtitleTracks
|
||||
.filter((track) => !isBurnedSubtitleTrack(track))
|
||||
.map((track) => normalizeTrackId(track?.id))
|
||||
.filter((id) => id !== null);
|
||||
const selectableSubtitleTrackIdSet = new Set(selectableSubtitleTrackIds.map((id) => String(id)));
|
||||
const defaultAudioTrackIds = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
|
||||
.filter((track) => Boolean(track?.selectedByRule))
|
||||
.map((track) => normalizeTrackId(track?.id))
|
||||
.filter((id) => id !== null);
|
||||
const defaultSubtitleTrackIds = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [])
|
||||
.filter((track) => Boolean(track?.selectedByRule))
|
||||
const defaultSubtitleTrackIds = subtitleTracks
|
||||
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track))
|
||||
.map((track) => normalizeTrackId(track?.id))
|
||||
.filter((id) => id !== null);
|
||||
const selectedAudioTrackIds = normalizeTrackIdList(
|
||||
@@ -865,7 +873,7 @@ export default function MediaInfoReviewPanel({
|
||||
Array.isArray(titleSelectionEntry?.subtitleTrackIds)
|
||||
? titleSelectionEntry.subtitleTrackIds
|
||||
: defaultSubtitleTrackIds
|
||||
);
|
||||
).filter((id) => selectableSubtitleTrackIdSet.has(String(id)));
|
||||
const allowTrackSelectionForTitle = Boolean(
|
||||
allowTrackSelection
|
||||
&& allowTitleSelection
|
||||
@@ -934,7 +942,7 @@ export default function MediaInfoReviewPanel({
|
||||
/>
|
||||
<TrackList
|
||||
title={`Subtitles (Titel #${title.id})`}
|
||||
tracks={title.subtitleTracks || []}
|
||||
tracks={allowTrackSelectionForTitle ? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track)) : subtitleTracks}
|
||||
type="subtitle"
|
||||
allowSelection={allowTrackSelectionForTitle}
|
||||
selectedTrackIds={selectedSubtitleTrackIds}
|
||||
|
||||
@@ -5,21 +5,7 @@ import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Button } from 'primereact/button';
|
||||
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
|
||||
import { api } from '../api/client';
|
||||
|
||||
const severityMap = {
|
||||
IDLE: 'success',
|
||||
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'
|
||||
};
|
||||
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
|
||||
|
||||
function normalizeTitleId(value) {
|
||||
const parsed = Number(value);
|
||||
@@ -92,6 +78,20 @@ function normalizeScriptIdList(values) {
|
||||
return output;
|
||||
}
|
||||
|
||||
function isBurnedSubtitleTrack(track) {
|
||||
const flags = Array.isArray(track?.subtitlePreviewFlags)
|
||||
? track.subtitlePreviewFlags
|
||||
: (Array.isArray(track?.flags) ? track.flags : []);
|
||||
const hasBurnedFlag = flags.some((flag) => String(flag || '').trim().toLowerCase() === 'burned');
|
||||
const summary = `${track?.subtitlePreviewSummary || ''} ${track?.subtitleActionSummary || ''}`;
|
||||
return Boolean(
|
||||
track?.subtitlePreviewBurnIn
|
||||
|| track?.burnIn
|
||||
|| hasBurnedFlag
|
||||
|| /burned/i.test(summary)
|
||||
);
|
||||
}
|
||||
|
||||
function buildDefaultTrackSelection(review) {
|
||||
const titles = Array.isArray(review?.titles) ? review.titles : [];
|
||||
const selection = {};
|
||||
@@ -110,7 +110,7 @@ function buildDefaultTrackSelection(review) {
|
||||
),
|
||||
subtitleTrackIds: normalizeTrackIdList(
|
||||
(Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [])
|
||||
.filter((track) => Boolean(track?.selectedByRule))
|
||||
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track))
|
||||
.map((track) => track?.id)
|
||||
)
|
||||
};
|
||||
@@ -192,19 +192,25 @@ export default function PipelineStatusCard({
|
||||
pipeline,
|
||||
onAnalyze,
|
||||
onReanalyze,
|
||||
onOpenMetadata,
|
||||
onStart,
|
||||
onRemoveFromQueue,
|
||||
onRestartEncode,
|
||||
onRestartReview,
|
||||
onConfirmReview,
|
||||
onSelectPlaylist,
|
||||
onCancel,
|
||||
onRetry,
|
||||
isQueued = false,
|
||||
busy,
|
||||
liveJobLog = ''
|
||||
}) {
|
||||
const state = pipeline?.state || 'IDLE';
|
||||
const stateLabel = getStatusLabel(state);
|
||||
const progress = Number(pipeline?.progress || 0);
|
||||
const running = state === 'ANALYZING' || state === 'RIPPING' || state === 'ENCODING' || state === 'MEDIAINFO_CHECK';
|
||||
const retryJobId = pipeline?.context?.jobId;
|
||||
const queueLocked = Boolean(isQueued && retryJobId);
|
||||
const selectedMetadata = pipeline?.context?.selectedMetadata || null;
|
||||
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
|
||||
const playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
|
||||
@@ -298,10 +304,15 @@ export default function PipelineStatusCard({
|
||||
? Boolean(retryJobId)
|
||||
: Boolean(retryJobId && encodeInputPath);
|
||||
const canRestartEncodeFromLastSettings = Boolean(
|
||||
state === 'ERROR'
|
||||
(state === 'ERROR' || state === 'CANCELLED')
|
||||
&& retryJobId
|
||||
&& pipeline?.context?.canRestartEncodeFromLastSettings
|
||||
);
|
||||
const canRestartReviewFromRaw = Boolean(
|
||||
retryJobId
|
||||
&& !running
|
||||
&& (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath)
|
||||
);
|
||||
|
||||
const waitingPlaylistRows = useMemo(() => {
|
||||
const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates)
|
||||
@@ -401,11 +412,24 @@ export default function PipelineStatusCard({
|
||||
? defaultTrackSelectionForTitle(mediaInfoReview, encodeTitleId)
|
||||
: { audioTrackIds: [], subtitleTrackIds: [] };
|
||||
const effectiveSelection = selectionEntry || fallbackSelection;
|
||||
const encodeTitle = encodeTitleId
|
||||
? (Array.isArray(mediaInfoReview?.titles)
|
||||
? (mediaInfoReview.titles.find((title) => normalizeTitleId(title?.id) === encodeTitleId) || null)
|
||||
: null)
|
||||
: null;
|
||||
const blockedSubtitleTrackIds = new Set(
|
||||
(Array.isArray(encodeTitle?.subtitleTracks) ? encodeTitle.subtitleTracks : [])
|
||||
.filter((track) => isBurnedSubtitleTrack(track))
|
||||
.map((track) => normalizeTrackId(track?.id))
|
||||
.filter((id) => id !== null)
|
||||
.map((id) => String(id))
|
||||
);
|
||||
const selectedTrackSelection = encodeTitleId
|
||||
? {
|
||||
[encodeTitleId]: {
|
||||
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
|
||||
subtitleTrackIds: normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
|
||||
.filter((id) => !blockedSubtitleTrackIds.has(String(id)))
|
||||
}
|
||||
}
|
||||
: null;
|
||||
@@ -418,9 +442,9 @@ export default function PipelineStatusCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Pipeline Status" subTitle="Live Zustand und Fortschritt">
|
||||
<Card title="Pipeline-Status" subTitle="Live-Zustand und Fortschritt">
|
||||
<div className="status-row">
|
||||
<Tag value={state} severity={severityMap[state] || 'secondary'} />
|
||||
<Tag value={stateLabel} severity={getStatusSeverity(state)} />
|
||||
<span>{pipeline?.statusText || 'Bereit'}</span>
|
||||
</div>
|
||||
|
||||
@@ -438,106 +462,142 @@ export default function PipelineStatusCard({
|
||||
)}
|
||||
|
||||
<div className="actions-row">
|
||||
{(state === 'DISC_DETECTED' || state === 'IDLE') && (
|
||||
{queueLocked ? (
|
||||
<Button
|
||||
label="Analyse starten"
|
||||
icon="pi pi-search"
|
||||
onClick={onAnalyze}
|
||||
loading={busy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'READY_TO_START' && retryJobId && (
|
||||
<Button
|
||||
label="Job starten"
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
onClick={() => onStart(retryJobId)}
|
||||
loading={busy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{playlistDecisionRequiredBeforeStart && retryJobId && (
|
||||
<Button
|
||||
label="Playlist übernehmen"
|
||||
icon="pi pi-check"
|
||||
severity="warning"
|
||||
outlined
|
||||
onClick={() => onSelectPlaylist?.(retryJobId, selectedPlaylistId)}
|
||||
loading={busy}
|
||||
disabled={!normalizePlaylistId(selectedPlaylistId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'READY_TO_ENCODE' && retryJobId && (
|
||||
<Button
|
||||
label={isPreRipReview ? 'Backup + Encoding starten' : 'Encoding starten'}
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
onClick={async () => {
|
||||
const requiresAutoConfirm = !reviewConfirmed;
|
||||
if (!requiresAutoConfirm) {
|
||||
await onStart(retryJobId);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
encodeTitleId,
|
||||
selectedTrackSelection,
|
||||
selectedPostScriptIds
|
||||
} = buildSelectedTrackSelectionForCurrentTitle();
|
||||
await onStart(retryJobId, {
|
||||
ensureConfirmed: true,
|
||||
selectedEncodeTitleId: encodeTitleId,
|
||||
selectedTrackSelection,
|
||||
selectedPostEncodeScriptIds: selectedPostScriptIds
|
||||
});
|
||||
}}
|
||||
loading={busy}
|
||||
disabled={!canStartReadyJob || !canConfirmReview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{running && (
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
icon="pi pi-stop"
|
||||
label="Aus Queue löschen"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
onClick={onCancel}
|
||||
outlined
|
||||
onClick={() => onRemoveFromQueue?.(retryJobId)}
|
||||
loading={busy}
|
||||
disabled={typeof onRemoveFromQueue !== 'function'}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{(state === 'DISC_DETECTED' || state === 'IDLE') && (
|
||||
<Button
|
||||
label="Analyse starten"
|
||||
icon="pi pi-search"
|
||||
onClick={onAnalyze}
|
||||
loading={busy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(state === 'METADATA_SELECTION' || state === 'WAITING_FOR_USER_DECISION') && retryJobId && typeof onOpenMetadata === 'function' ? (
|
||||
<Button
|
||||
label="Metadaten öffnen"
|
||||
icon="pi pi-list"
|
||||
severity="info"
|
||||
onClick={() => onOpenMetadata?.(retryJobId)}
|
||||
loading={busy}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state === 'READY_TO_START' && retryJobId ? (
|
||||
<Button
|
||||
label="Job starten"
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
onClick={() => onStart(retryJobId)}
|
||||
loading={busy}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{playlistDecisionRequiredBeforeStart && retryJobId && (
|
||||
<Button
|
||||
label="Playlist übernehmen"
|
||||
icon="pi pi-check"
|
||||
severity="warning"
|
||||
outlined
|
||||
onClick={() => onSelectPlaylist?.(retryJobId, selectedPlaylistId)}
|
||||
loading={busy}
|
||||
disabled={!normalizePlaylistId(selectedPlaylistId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'READY_TO_ENCODE' && retryJobId ? (
|
||||
<Button
|
||||
label={isPreRipReview ? 'Backup + Encoding starten' : 'Encoding starten'}
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
onClick={async () => {
|
||||
const requiresAutoConfirm = !reviewConfirmed;
|
||||
if (!requiresAutoConfirm) {
|
||||
await onStart(retryJobId);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
encodeTitleId,
|
||||
selectedTrackSelection,
|
||||
selectedPostScriptIds
|
||||
} = buildSelectedTrackSelectionForCurrentTitle();
|
||||
await onStart(retryJobId, {
|
||||
ensureConfirmed: true,
|
||||
selectedEncodeTitleId: encodeTitleId,
|
||||
selectedTrackSelection,
|
||||
selectedPostEncodeScriptIds: selectedPostScriptIds
|
||||
});
|
||||
}}
|
||||
loading={busy}
|
||||
disabled={!canStartReadyJob || !canConfirmReview}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{running && (
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
icon="pi pi-stop"
|
||||
severity="danger"
|
||||
onClick={() => onCancel?.(retryJobId, state)}
|
||||
loading={busy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canRestartReviewFromRaw ? (
|
||||
<Button
|
||||
label="Review neu starten"
|
||||
icon="pi pi-refresh"
|
||||
severity="info"
|
||||
outlined
|
||||
onClick={() => onRestartReview?.(retryJobId)}
|
||||
loading={busy}
|
||||
disabled={!retryJobId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{canRestartEncodeFromLastSettings ? (
|
||||
<Button
|
||||
label="Encode neu starten"
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
onClick={() => onRestartEncode?.(retryJobId)}
|
||||
loading={busy}
|
||||
disabled={!retryJobId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{(state === 'ERROR' || state === 'CANCELLED') && retryJobId && (
|
||||
<Button
|
||||
label="Retry Rippen"
|
||||
icon="pi pi-refresh"
|
||||
severity="warning"
|
||||
onClick={() => onRetry(retryJobId)}
|
||||
loading={busy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(state === 'ERROR' || state === 'CANCELLED') ? (
|
||||
<Button
|
||||
label="Disk-Analyse neu starten"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
onClick={onReanalyze || onAnalyze}
|
||||
loading={busy}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canRestartEncodeFromLastSettings ? (
|
||||
<Button
|
||||
label="Encode neu starten"
|
||||
icon="pi pi-play"
|
||||
severity="success"
|
||||
onClick={() => onRestartEncode?.(retryJobId)}
|
||||
loading={busy}
|
||||
disabled={!retryJobId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state === 'ERROR' && retryJobId && (
|
||||
<Button
|
||||
label="Retry Rippen"
|
||||
icon="pi pi-refresh"
|
||||
severity="warning"
|
||||
onClick={() => onRetry(retryJobId)}
|
||||
loading={busy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'ERROR' ? (
|
||||
<Button
|
||||
label="Disk-Analyse neu starten"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
onClick={onReanalyze || onAnalyze}
|
||||
loading={busy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{running ? (
|
||||
@@ -547,7 +607,7 @@ export default function PipelineStatusCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{playlistDecisionRequiredBeforeStart ? (
|
||||
{playlistDecisionRequiredBeforeStart && !queueLocked ? (
|
||||
<div className="playlist-decision-block">
|
||||
<h3>Playlist-Auswahl erforderlich</h3>
|
||||
<small>
|
||||
@@ -561,6 +621,7 @@ export default function PipelineStatusCard({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={normalizePlaylistId(selectedPlaylistId) === row.playlistId}
|
||||
disabled={queueLocked}
|
||||
onChange={() => {
|
||||
const next = normalizePlaylistId(selectedPlaylistId) === row.playlistId ? null : row.playlistId;
|
||||
setSelectedPlaylistId(next);
|
||||
@@ -629,7 +690,7 @@ export default function PipelineStatusCard({
|
||||
<strong>IMDb:</strong> {selectedMetadata.imdbId || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong> {state}
|
||||
<strong>Status:</strong> {stateLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -638,7 +699,7 @@ export default function PipelineStatusCard({
|
||||
{(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) ? (
|
||||
<div className="mediainfo-review-block">
|
||||
<h3>Titel-/Spurprüfung</h3>
|
||||
{state === 'READY_TO_ENCODE' && !reviewConfirmed ? (
|
||||
{state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked ? (
|
||||
<small>
|
||||
{isPreRipReview
|
||||
? 'Spurauswahl kann direkt übernommen werden. Beim Klick auf "Backup + Encoding starten" wird automatisch bestätigt und gestartet.'
|
||||
@@ -651,9 +712,9 @@ export default function PipelineStatusCard({
|
||||
presetDisplayValue={presetDisplayValue}
|
||||
commandOutputPath={commandOutputPath}
|
||||
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
||||
allowTitleSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
|
||||
allowTitleSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
|
||||
onSelectEncodeTitle={(titleId) => setSelectedEncodeTitleId(normalizeTitleId(titleId))}
|
||||
allowTrackSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
|
||||
allowTrackSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
|
||||
trackSelectionByTitle={trackSelectionByTitle}
|
||||
onTrackSelectionChange={(titleId, trackType, trackId, checked) => {
|
||||
const normalizedTitleId = normalizeTitleId(titleId);
|
||||
@@ -684,7 +745,7 @@ export default function PipelineStatusCard({
|
||||
}}
|
||||
availablePostScripts={scriptCatalog}
|
||||
selectedPostEncodeScriptIds={selectedPostEncodeScriptIds}
|
||||
allowPostScriptSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
|
||||
allowPostScriptSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
|
||||
onAddPostEncodeScript={() => {
|
||||
setSelectedPostEncodeScriptIds((prev) => {
|
||||
const normalizedCurrent = normalizeScriptIdList(prev);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -217,6 +217,89 @@ body {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pipeline-queue-meta {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.pipeline-queue-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.pipeline-queue-col {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
background: var(--rip-panel-soft);
|
||||
display: grid;
|
||||
align-content: start;
|
||||
grid-auto-rows: min-content;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.pipeline-queue-col h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pipeline-queue-item {
|
||||
border: 1px dashed var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.42rem 0.5rem;
|
||||
background: var(--rip-panel);
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.pipeline-queue-item.running {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.pipeline-queue-item.queued {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.pipeline-queue-item.queued.dragging {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.pipeline-queue-item-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.pipeline-queue-item-main strong,
|
||||
.pipeline-queue-item-main small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pipeline-queue-drag-handle {
|
||||
cursor: grab;
|
||||
color: var(--rip-muted);
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.3rem;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.pipeline-queue-drag-handle.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pipeline-queue-remove-btn {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
}
|
||||
|
||||
.dashboard-job-list {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
@@ -1206,6 +1289,7 @@ body {
|
||||
|
||||
.metadata-grid,
|
||||
.device-meta,
|
||||
.pipeline-queue-grid,
|
||||
.media-review-meta,
|
||||
.media-track-grid,
|
||||
.job-meta-grid,
|
||||
@@ -1309,6 +1393,11 @@ body {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.pipeline-queue-item-main strong,
|
||||
.pipeline-queue-item-main small {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.dashboard-job-title-line > span {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
80
frontend/src/utils/statusPresentation.js
Normal file
80
frontend/src/utils/statusPresentation.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const STATUS_LABELS = {
|
||||
IDLE: 'Bereit',
|
||||
DISC_DETECTED: 'Medium erkannt',
|
||||
ANALYZING: 'Analyse',
|
||||
METADATA_SELECTION: 'Metadatenauswahl',
|
||||
WAITING_FOR_USER_DECISION: 'Warte auf Auswahl',
|
||||
READY_TO_START: 'Startbereit',
|
||||
MEDIAINFO_CHECK: 'Mediainfo-Pruefung',
|
||||
READY_TO_ENCODE: 'Bereit zum Encodieren',
|
||||
RIPPING: 'Rippen',
|
||||
ENCODING: 'Encodieren',
|
||||
POST_ENCODE_SCRIPTS: 'Nachbearbeitung',
|
||||
FINISHED: 'Fertig',
|
||||
CANCELLED: 'Abgebrochen',
|
||||
ERROR: 'Fehler'
|
||||
};
|
||||
|
||||
const PROCESS_STATUS_LABELS = {
|
||||
SUCCESS: 'Erfolgreich',
|
||||
ERROR: 'Fehler',
|
||||
CANCELLED: 'Abgebrochen',
|
||||
RUNNING: 'Laeuft',
|
||||
STARTED: 'Gestartet',
|
||||
PENDING: 'Ausstehend'
|
||||
};
|
||||
|
||||
export function normalizeStatus(status) {
|
||||
return String(status || '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function getStatusLabel(status, options = {}) {
|
||||
if (options?.queued) {
|
||||
return 'In der Queue';
|
||||
}
|
||||
const normalized = normalizeStatus(status);
|
||||
return STATUS_LABELS[normalized] || (String(status || '').trim() || '-');
|
||||
}
|
||||
|
||||
export function getStatusSeverity(status, options = {}) {
|
||||
if (options?.queued) {
|
||||
return 'info';
|
||||
}
|
||||
const normalized = normalizeStatus(status);
|
||||
if (normalized === 'FINISHED') return 'success';
|
||||
if (normalized === 'CANCELLED') return 'warning';
|
||||
if (normalized === 'ERROR') return 'danger';
|
||||
if (normalized === 'READY_TO_START' || normalized === 'READY_TO_ENCODE') return 'info';
|
||||
if (normalized === 'WAITING_FOR_USER_DECISION') return 'warning';
|
||||
if (
|
||||
normalized === 'RIPPING'
|
||||
|| normalized === 'ENCODING'
|
||||
|| normalized === 'ANALYZING'
|
||||
|| normalized === 'MEDIAINFO_CHECK'
|
||||
|| normalized === 'METADATA_SELECTION'
|
||||
|| normalized === 'POST_ENCODE_SCRIPTS'
|
||||
) {
|
||||
return 'warning';
|
||||
}
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
export function getProcessStatusLabel(status) {
|
||||
const normalized = normalizeStatus(status);
|
||||
return PROCESS_STATUS_LABELS[normalized] || (String(status || '').trim() || '-');
|
||||
}
|
||||
|
||||
export const STATUS_FILTER_OPTIONS = [
|
||||
{ label: 'Alle', value: '' },
|
||||
{ label: getStatusLabel('FINISHED'), value: 'FINISHED' },
|
||||
{ label: getStatusLabel('CANCELLED'), value: 'CANCELLED' },
|
||||
{ label: getStatusLabel('ERROR'), value: 'ERROR' },
|
||||
{ label: getStatusLabel('WAITING_FOR_USER_DECISION'), value: 'WAITING_FOR_USER_DECISION' },
|
||||
{ label: getStatusLabel('READY_TO_START'), value: 'READY_TO_START' },
|
||||
{ label: getStatusLabel('READY_TO_ENCODE'), value: 'READY_TO_ENCODE' },
|
||||
{ label: getStatusLabel('MEDIAINFO_CHECK'), value: 'MEDIAINFO_CHECK' },
|
||||
{ label: getStatusLabel('RIPPING'), value: 'RIPPING' },
|
||||
{ label: getStatusLabel('ENCODING'), value: 'ENCODING' },
|
||||
{ label: getStatusLabel('ANALYZING'), value: 'ANALYZING' },
|
||||
{ label: getStatusLabel('METADATA_SELECTION'), value: 'METADATA_SELECTION' }
|
||||
];
|
||||
Reference in New Issue
Block a user