This commit is contained in:
2026-03-04 14:48:56 +00:00
parent 31d3e36597
commit 3b293bb743
11 changed files with 1116 additions and 310 deletions

View File

@@ -20,6 +20,7 @@ export default function JobDetailDialog({
onLoadLog,
logLoadingMode = null,
onAssignOmdb,
onRestartEncode,
onReencode,
onDeleteFiles,
onDeleteEntry,
@@ -32,7 +33,15 @@ 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 canDeleteEntry = !running;
const hasConfirmedPlan = Boolean(
job?.encodePlan
&& Array.isArray(job?.encodePlan?.titles)
&& job?.encodePlan?.titles.length > 0
&& Number(job?.encode_review_confirmed || 0) === 1
);
const hasRestartInput = Boolean(job?.encode_input_path || job?.raw_path || job?.encodePlan?.encodeInputPath);
const canRestartEncode = Boolean(hasConfirmedPlan && hasRestartInput && !running);
const canDeleteEntry = !running && typeof onDeleteEntry === 'function';
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);
@@ -106,6 +115,14 @@ export default function JobDetailDialog({
<div>
<strong>Movie-Dir leer:</strong> {job.movieDirStatus?.isEmpty === null ? '-' : job.movieDirStatus?.isEmpty ? 'ja' : 'nein'}
</div>
<div>
<strong>Backup erfolgreich:</strong>{' '}
{job?.backupSuccess ? <span className="job-step-inline-ok"><i className="pi pi-check-circle" aria-hidden="true" /><span>ja</span></span> : 'nein'}
</div>
<div>
<strong>Encode erfolgreich:</strong>{' '}
{job?.encodeSuccess ? <span className="job-step-inline-ok"><i className="pi pi-check-circle" aria-hidden="true" /><span>ja</span></span> : 'nein'}
</div>
<div>
<strong>Fehler:</strong> {job.error_message || '-'}
</div>
@@ -136,8 +153,19 @@ export default function JobDetailDialog({
size="small"
onClick={() => onAssignOmdb?.(job)}
loading={omdbAssignBusy}
disabled={running}
disabled={running || typeof onAssignOmdb !== 'function'}
/>
{typeof onRestartEncode === 'function' ? (
<Button
label="Encode neu starten"
icon="pi pi-play"
severity="success"
size="small"
onClick={() => onRestartEncode?.(job)}
loading={actionBusy}
disabled={!canRestartEncode}
/>
) : null}
<Button
label="RAW neu encodieren"
icon="pi pi-cog"
@@ -145,7 +173,7 @@ export default function JobDetailDialog({
size="small"
onClick={() => onReencode?.(job)}
loading={reencodeBusy}
disabled={!canReencode}
disabled={!canReencode || typeof onReencode !== 'function'}
/>
<Button
label="RAW löschen"
@@ -155,7 +183,7 @@ export default function JobDetailDialog({
size="small"
onClick={() => onDeleteFiles?.(job, 'raw')}
loading={actionBusy}
disabled={!job.rawStatus?.exists}
disabled={!job.rawStatus?.exists || typeof onDeleteFiles !== 'function'}
/>
<Button
label="Movie löschen"
@@ -165,7 +193,7 @@ export default function JobDetailDialog({
size="small"
onClick={() => onDeleteFiles?.(job, 'movie')}
loading={actionBusy}
disabled={!job.outputStatus?.exists}
disabled={!job.outputStatus?.exists || typeof onDeleteFiles !== 'function'}
/>
<Button
label="Beides löschen"
@@ -174,7 +202,7 @@ export default function JobDetailDialog({
size="small"
onClick={() => onDeleteFiles?.(job, 'both')}
loading={actionBusy}
disabled={!job.rawStatus?.exists && !job.outputStatus?.exists}
disabled={(!job.rawStatus?.exists && !job.outputStatus?.exists) || typeof onDeleteFiles !== 'function'}
/>
<Button
label="Historieneintrag löschen"

View File

@@ -312,6 +312,28 @@ export default function PipelineStatusCard({
() => buildOutputPathPreview(settingsMap, selectedMetadata, retryJobId),
[settingsMap, selectedMetadata, retryJobId]
);
const buildSelectedTrackSelectionForCurrentTitle = () => {
const encodeTitleId = normalizeTitleId(selectedEncodeTitleId);
const selectionEntry = encodeTitleId
? (trackSelectionByTitle?.[encodeTitleId] || trackSelectionByTitle?.[String(encodeTitleId)] || null)
: null;
const fallbackSelection = encodeTitleId
? defaultTrackSelectionForTitle(mediaInfoReview, encodeTitleId)
: { audioTrackIds: [], subtitleTrackIds: [] };
const effectiveSelection = selectionEntry || fallbackSelection;
const selectedTrackSelection = encodeTitleId
? {
[encodeTitleId]: {
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
subtitleTrackIds: normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
}
}
: null;
return {
encodeTitleId,
selectedTrackSelection
};
};
return (
<Card title="Pipeline Status" subTitle="Live Zustand und Fortschritt">
@@ -353,37 +375,6 @@ export default function PipelineStatusCard({
/>
)}
{state === 'READY_TO_ENCODE' && retryJobId && (
<Button
label="Auswahl bestätigen"
icon="pi pi-check"
severity="warning"
outlined
onClick={() => {
const encodeTitleId = normalizeTitleId(selectedEncodeTitleId);
const selectionEntry = encodeTitleId
? (trackSelectionByTitle?.[encodeTitleId] || trackSelectionByTitle?.[String(encodeTitleId)] || null)
: null;
const fallbackSelection = encodeTitleId
? defaultTrackSelectionForTitle(mediaInfoReview, encodeTitleId)
: { audioTrackIds: [], subtitleTrackIds: [] };
const effectiveSelection = selectionEntry || fallbackSelection;
const selectedTrackSelection = encodeTitleId
? {
[encodeTitleId]: {
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
subtitleTrackIds: normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
}
}
: null;
onConfirmReview(retryJobId, encodeTitleId, selectedTrackSelection);
}}
loading={busy}
disabled={reviewConfirmed || !canConfirmReview}
/>
)}
{playlistDecisionRequiredBeforeStart && retryJobId && (
<Button
label="Playlist übernehmen"
@@ -398,12 +389,25 @@ export default function PipelineStatusCard({
{state === 'READY_TO_ENCODE' && retryJobId && (
<Button
label={isPreRipReview ? 'Backup + Encode starten' : 'Encode starten'}
label={isPreRipReview ? 'Backup + Encoding starten' : 'Encoding starten'}
icon="pi pi-play"
severity="success"
onClick={() => onStart(retryJobId)}
onClick={async () => {
const requiresAutoConfirm = !reviewConfirmed;
if (!requiresAutoConfirm) {
await onStart(retryJobId);
return;
}
const { encodeTitleId, selectedTrackSelection } = buildSelectedTrackSelectionForCurrentTitle();
await onStart(retryJobId, {
ensureConfirmed: true,
selectedEncodeTitleId: encodeTitleId,
selectedTrackSelection
});
}}
loading={busy}
disabled={!canStartReadyJob || !reviewConfirmed}
disabled={!canStartReadyJob || !canConfirmReview}
/>
)}
@@ -550,8 +554,8 @@ export default function PipelineStatusCard({
{state === 'READY_TO_ENCODE' && !reviewConfirmed ? (
<small>
{isPreRipReview
? 'Backup/Rip + Encode ist gesperrt, bis die Spurauswahl bestätigt wurde.'
: 'Encode ist gesperrt, bis die Titel-/Spurauswahl bestätigt wurde.'}
? 'Spurauswahl kann direkt übernommen werden. Beim Klick auf "Backup + Encoding starten" wird automatisch bestätigt und gestartet.'
: 'Spurauswahl kann direkt übernommen werden. Beim Klick auf "Encoding starten" wird automatisch bestätigt und gestartet.'}
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
</small>
) : null}

View File

@@ -4,6 +4,7 @@ import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Dialog } from 'primereact/dialog';
import { api } from '../api/client';
import PipelineStatusCard from '../components/PipelineStatusCard';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
@@ -73,6 +74,29 @@ function mediaIndicatorMeta(job) {
};
}
function JobStepChecks({ backupSuccess, encodeSuccess }) {
const hasAny = Boolean(backupSuccess || encodeSuccess);
if (!hasAny) {
return null;
}
return (
<div className="job-step-checks">
{backupSuccess ? (
<span className="job-step-inline-ok" title="Backup/Rip erfolgreich">
<i className="pi pi-check-circle" aria-hidden="true" />
<span>Backup</span>
</span>
) : null}
{encodeSuccess ? (
<span className="job-step-inline-ok" title="Encode erfolgreich">
<i className="pi pi-check-circle" aria-hidden="true" />
<span>Encode</span>
</span>
) : null}
</div>
);
}
function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const jobId = normalizeJobId(job?.id);
if (
@@ -98,12 +122,18 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const jobStatus = String(job?.status || job?.last_state || 'IDLE').trim().toUpperCase() || 'IDLE';
const lastState = String(job?.last_state || '').trim().toUpperCase();
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' && (
(errorText.includes('ABGEBROCHEN') || errorText.includes('CANCELLED'))
&& (hasOutputPath || Boolean(job?.encode_input_path) || Boolean(job?.handbrakeInfo))
);
const looksLikeEncodingError = jobStatus === 'ERROR' && (
errorText.includes('ENCODING')
|| errorText.includes('HANDBRAKE')
|| lastState === 'ENCODING'
|| Boolean(job?.handbrakeInfo)
|| looksLikeCancelledEncode
);
const canRestartEncodeFromLastSettings = Boolean(
hasEncodePlan
@@ -151,6 +181,12 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline }) {
const [busy, setBusy] = useState(false);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [cancelCleanupDialog, setCancelCleanupDialog] = useState({
visible: false,
jobId: null,
outputPath: null
});
const [cancelCleanupBusy, setCancelCleanupBusy] = useState(false);
const [liveJobLog, setLiveJobLog] = useState('');
const [jobsLoading, setJobsLoading] = useState(false);
const [dashboardJobs, setDashboardJobs] = useState([]);
@@ -326,11 +362,22 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
};
const handleCancel = async () => {
const cancelledState = state;
const cancelledJobId = currentPipelineJobId;
const cancelledJob = dashboardJobs.find((item) => normalizeJobId(item?.id) === cancelledJobId) || null;
setBusy(true);
try {
await api.cancelPipeline();
await refreshPipeline();
await loadDashboardJobs();
if (cancelledState === 'ENCODING' && cancelledJobId) {
setCancelCleanupDialog({
visible: true,
jobId: cancelledJobId,
outputPath: cancelledJob?.output_path || null
});
}
} catch (error) {
showError(error);
} finally {
@@ -338,13 +385,52 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
}
};
const handleStartJob = async (jobId) => {
const handleDeleteCancelledOutput = async () => {
const jobId = normalizeJobId(cancelCleanupDialog?.jobId);
if (!jobId) {
setCancelCleanupDialog({ visible: false, jobId: null, outputPath: null });
return;
}
setCancelCleanupBusy(true);
try {
const response = await api.deleteJobFiles(jobId, 'movie');
const summary = response?.summary || {};
toastRef.current?.show({
severity: 'success',
summary: 'Movie gelöscht',
detail: `Entfernt: ${summary.movie?.filesDeleted ?? 0} Datei(en), ${summary.movie?.dirsRemoved ?? 0} Ordner.`,
life: 4000
});
await loadDashboardJobs();
await refreshPipeline();
setCancelCleanupDialog({ visible: false, jobId: null, outputPath: null });
} catch (error) {
showError(error);
} finally {
setCancelCleanupBusy(false);
}
};
const handleStartJob = async (jobId, options = null) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
const startOptions = options && typeof options === 'object' ? options : {};
setBusy(true);
try {
await api.startJob(jobId);
if (startOptions.ensureConfirmed) {
await api.confirmEncodeReview(normalizedJobId, {
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
selectedTrackSelection: startOptions.selectedTrackSelection ?? null
});
}
await api.startJob(normalizedJobId);
await refreshPipeline();
await loadDashboardJobs();
setExpandedJobId(normalizeJobId(jobId));
setExpandedJobId(normalizedJobId);
} catch (error) {
showError(error);
} finally {
@@ -401,6 +487,18 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
};
const handleRestartEncodeWithLastSettings = async (jobId) => {
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
const title = job?.title || job?.detected_title || `Job #${jobId}`;
if (job?.encodeSuccess) {
const confirmed = window.confirm(
`Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n` +
'Es wird eine neue Datei mit Kollisionsprüfung angelegt.'
);
if (!confirmed) {
return;
}
}
setBusy(true);
try {
await api.restartEncodeWithLastSettings(jobId);
@@ -493,6 +591,7 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
{String(job?.status || '').trim().toUpperCase() === '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)} />
</div>
</div>
<Button
@@ -560,6 +659,7 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
{String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE'
? <Tag value={reviewConfirmed ? 'Bestätigt' : 'Unbestätigt'} severity={reviewConfirmed ? 'success' : 'warning'} />
: null}
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
</div>
<i className="pi pi-angle-down" aria-hidden="true" />
</button>
@@ -624,6 +724,37 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
onSearch={handleOmdbSearch}
busy={busy}
/>
<Dialog
header="Encode abgebrochen"
visible={Boolean(cancelCleanupDialog.visible)}
onHide={() => setCancelCleanupDialog({ visible: false, jobId: null, outputPath: null })}
style={{ width: '32rem', maxWidth: '96vw' }}
modal
>
<p>
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>
) : null}
<div className="dialog-actions">
<Button
label="Behalten"
severity="secondary"
outlined
onClick={() => setCancelCleanupDialog({ visible: false, jobId: null, outputPath: null })}
disabled={cancelCleanupBusy}
/>
<Button
label="Movie löschen"
icon="pi pi-trash"
severity="danger"
onClick={handleDeleteCancelledOutput}
loading={cancelCleanupBusy}
/>
</div>
</Dialog>
</div>
);
}

View File

@@ -39,6 +39,8 @@ export default function HistoryPage() {
const [detailVisible, setDetailVisible] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [logLoadingMode, setLogLoadingMode] = useState(null);
const [actionBusy, setActionBusy] = useState(false);
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
const [loading, setLoading] = useState(false);
const toastRef = useRef(null);
@@ -113,9 +115,109 @@ export default function HistoryPage() {
}
};
const refreshDetailIfOpen = async (jobId) => {
if (!detailVisible || Number(selectedJob?.id || 0) !== Number(jobId || 0)) {
return;
}
const response = await api.getJob(jobId, { includeLogs: false });
setSelectedJob(response.job);
};
const handleDeleteFiles = async (row, target) => {
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? 'Movie-Datei(en)' : 'RAW + Movie';
const title = row.title || row.detected_title || `Job #${row.id}`;
const confirmed = window.confirm(`${label} für "${title}" wirklich löschen?`);
if (!confirmed) {
return;
}
setActionBusy(true);
try {
const response = await api.deleteJobFiles(row.id, target);
const summary = response.summary || {};
toastRef.current?.show({
severity: 'success',
summary: 'Dateien gelöscht',
detail: `RAW: ${summary.raw?.filesDeleted ?? 0}, MOVIE: ${summary.movie?.filesDeleted ?? 0}`,
life: 3500
});
await load();
await refreshDetailIfOpen(row.id);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Löschen fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
setActionBusy(false);
}
};
const handleReencode = async (row) => {
const title = row.title || row.detected_title || `Job #${row.id}`;
const confirmed = window.confirm(`RAW neu encodieren für "${title}" starten?`);
if (!confirmed) {
return;
}
setReencodeBusyJobId(row.id);
try {
await api.reencodeJob(row.id);
toastRef.current?.show({
severity: 'success',
summary: 'Re-Encode gestartet',
detail: 'Job wurde in die Mediainfo-Prüfung gesetzt.',
life: 3500
});
await load();
await refreshDetailIfOpen(row.id);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Re-Encode fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
setReencodeBusyJobId(null);
}
};
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 {
await api.restartEncodeWithLastSettings(row.id);
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 statusBody = (row) => <Tag value={row.status} />;
const mkBody = (row) => row.makemkvInfo ? `${row.makemkvInfo.status || '-'} ${typeof row.makemkvInfo.lastProgress === 'number' ? `${row.makemkvInfo.lastProgress.toFixed(1)}%` : ''}` : '-';
const hbBody = (row) => row.handbrakeInfo ? `${row.handbrakeInfo.status || '-'} ${typeof row.handbrakeInfo.lastProgress === 'number' ? `${row.handbrakeInfo.lastProgress.toFixed(1)}%` : ''}` : '-';
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>
);
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>
);
const mediaBody = (row) => {
const mediaType = resolveMediaType(row);
const src = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
@@ -186,6 +288,11 @@ export default function HistoryPage() {
detailLoading={detailLoading}
onLoadLog={handleLoadLog}
logLoadingMode={logLoadingMode}
onRestartEncode={handleRestartEncode}
onReencode={handleReencode}
onDeleteFiles={handleDeleteFiles}
actionBusy={actionBusy}
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
onHide={() => {
setDetailVisible(false);
setDetailLoading(false);

View File

@@ -298,6 +298,40 @@ body {
justify-content: flex-end;
}
.job-step-checks {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.job-step-inline-ok {
display: inline-flex;
align-items: center;
gap: 0.28rem;
color: #1c8a3a;
font-weight: 600;
font-size: 0.8rem;
}
.job-step-inline-ok .pi {
font-size: 0.85rem;
}
.job-step-cell {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.job-step-ok-icon {
color: #1c8a3a;
}
.muted-inline {
color: var(--rip-muted);
display: block;
}
.dashboard-job-poster-fallback {
display: flex;
align-items: center;