UI/Features

This commit is contained in:
2026-03-13 11:07:34 +00:00
parent 7948dd298c
commit 5b41f728c5
28 changed files with 5690 additions and 936 deletions

View File

@@ -220,7 +220,11 @@ function normalizeQueue(queue) {
const queuedJobs = Array.isArray(payload.queuedJobs) ? payload.queuedJobs : [];
return {
maxParallelJobs: Number(payload.maxParallelJobs || 1),
maxParallelCdEncodes: Number(payload.maxParallelCdEncodes || 2),
maxTotalEncodes: Number(payload.maxTotalEncodes || 3),
cdBypassesQueue: Boolean(payload.cdBypassesQueue),
runningCount: Number(payload.runningCount || runningJobs.length || 0),
runningCdCount: Number(payload.runningCdCount || 0),
runningJobs,
queuedJobs,
queuedCount: Number(payload.queuedCount || queuedJobs.length || 0),
@@ -348,12 +352,13 @@ function getAnalyzeContext(job) {
}
function resolveMediaType(job) {
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const candidates = [
job?.mediaType,
job?.media_type,
job?.mediaProfile,
job?.media_profile,
job?.encodePlan?.mediaProfile,
encodePlan?.mediaProfile,
job?.makemkvInfo?.analyzeContext?.mediaProfile,
job?.makemkvInfo?.mediaProfile,
job?.mediainfoInfo?.mediaProfile
@@ -373,6 +378,25 @@ function resolveMediaType(job) {
return 'cd';
}
}
const statusCandidates = [
job?.status,
job?.last_state,
job?.makemkvInfo?.lastState
];
if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) {
return 'cd';
}
const planFormat = String(encodePlan?.format || '').trim().toLowerCase();
const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0;
if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
return 'cd';
}
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
return 'cd';
}
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
return 'cd';
}
return 'other';
}
@@ -425,6 +449,84 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
const analyzeContext = getAnalyzeContext(job);
const normalizePlanIdList = (values) => {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
continue;
}
const id = Math.trunc(parsed);
const key = String(id);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(id);
}
return output;
};
const buildNamedSelection = (ids, entries, fallbackLabel) => {
const source = Array.isArray(entries) ? entries : [];
const namesById = new Map(
source
.map((entry) => {
const id = Number(entry?.id ?? entry?.scriptId ?? entry?.chainId);
const normalized = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null;
const name = String(entry?.name || entry?.scriptName || entry?.chainName || '').trim();
if (!normalized) {
return null;
}
return [normalized, name || null];
})
.filter(Boolean)
);
return ids.map((id) => ({
id,
name: namesById.get(id) || `${fallbackLabel} #${id}`
}));
};
const planPreScriptIds = normalizePlanIdList([
...(Array.isArray(encodePlan?.preEncodeScriptIds) ? encodePlan.preEncodeScriptIds : []),
...(Array.isArray(encodePlan?.preEncodeScripts) ? encodePlan.preEncodeScripts.map((entry) => entry?.id ?? entry?.scriptId) : [])
]);
const planPostScriptIds = normalizePlanIdList([
...(Array.isArray(encodePlan?.postEncodeScriptIds) ? encodePlan.postEncodeScriptIds : []),
...(Array.isArray(encodePlan?.postEncodeScripts) ? encodePlan.postEncodeScripts.map((entry) => entry?.id ?? entry?.scriptId) : [])
]);
const planPreChainIds = normalizePlanIdList([
...(Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : []),
...(Array.isArray(encodePlan?.preEncodeChains) ? encodePlan.preEncodeChains.map((entry) => entry?.id ?? entry?.chainId) : [])
]);
const planPostChainIds = normalizePlanIdList([
...(Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : []),
...(Array.isArray(encodePlan?.postEncodeChains) ? encodePlan.postEncodeChains.map((entry) => entry?.id ?? entry?.chainId) : [])
]);
const cdRipConfig = encodePlan && typeof encodePlan === 'object'
? {
format: String(encodePlan?.format || '').trim().toLowerCase() || null,
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
? encodePlan.formatOptions
: {},
selectedTracks: Array.isArray(encodePlan?.selectedTracks)
? encodePlan.selectedTracks
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value))
: [],
preEncodeScriptIds: planPreScriptIds,
postEncodeScriptIds: planPostScriptIds,
preEncodeChainIds: planPreChainIds,
postEncodeChainIds: planPostChainIds,
preEncodeScripts: buildNamedSelection(planPreScriptIds, encodePlan?.preEncodeScripts, 'Skript'),
postEncodeScripts: buildNamedSelection(planPostScriptIds, encodePlan?.postEncodeScripts, 'Skript'),
preEncodeChains: buildNamedSelection(planPreChainIds, encodePlan?.preEncodeChains, 'Kette'),
postEncodeChains: buildNamedSelection(planPostChainIds, encodePlan?.postEncodeChains, 'Kette'),
outputTemplate: String(encodePlan?.outputTemplate || '').trim() || null
}
: null;
const cdTracks = Array.isArray(makemkvInfo?.tracks)
? makemkvInfo.tracks
.map((track) => {
@@ -443,6 +545,23 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const cdSelectedMeta = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {};
const fallbackCdArtist = cdTracks
.map((track) => String(track?.artist || '').trim())
.find(Boolean) || null;
const resolvedCdMbId = String(
cdSelectedMeta?.mbId
|| cdSelectedMeta?.musicBrainzId
|| cdSelectedMeta?.musicbrainzId
|| cdSelectedMeta?.mbid
|| ''
).trim() || null;
const resolvedCdCoverUrl = String(
cdSelectedMeta?.coverUrl
|| cdSelectedMeta?.poster
|| cdSelectedMeta?.posterUrl
|| job?.poster_url
|| ''
).trim() || null;
const cdparanoiaCmd = String(makemkvInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia';
const devicePath = String(job?.disc_device || '').trim() || null;
const firstConfiguredTrack = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0
@@ -458,12 +577,12 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
const selectedMetadata = {
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
artist: cdSelectedMeta?.artist || null,
artist: cdSelectedMeta?.artist || fallbackCdArtist || null,
year: cdSelectedMeta?.year ?? job?.year ?? null,
mbId: cdSelectedMeta?.mbId || null,
coverUrl: cdSelectedMeta?.coverUrl || null,
mbId: resolvedCdMbId,
coverUrl: resolvedCdCoverUrl,
imdbId: job?.imdb_id || null,
poster: job?.poster_url || cdSelectedMeta?.coverUrl || null
poster: job?.poster_url || resolvedCdCoverUrl || null
};
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
@@ -508,11 +627,14 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const computedContext = {
jobId,
rawPath: job?.raw_path || null,
outputPath: job?.output_path || null,
detectedTitle: job?.detected_title || null,
mediaProfile: resolveMediaType(job),
lastState,
devicePath,
cdparanoiaCmd,
cdparanoiaCommandPreview,
cdRipConfig,
tracks: cdTracks,
inputPath,
hasEncodableTitle,
@@ -543,6 +665,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
...computedContext,
...existingContext,
rawPath: existingContext.rawPath || computedContext.rawPath,
outputPath: existingContext.outputPath || computedContext.outputPath,
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
? existingContext.tracks
: computedContext.tracks,
@@ -559,6 +682,20 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const liveJobProgress = currentPipeline?.jobProgress && jobId
? (currentPipeline.jobProgress[jobId] || null)
: null;
const liveContext = liveJobProgress?.context && typeof liveJobProgress.context === 'object'
? liveJobProgress.context
: null;
const mergedContext = liveContext
? {
...computedContext,
...liveContext,
tracks: (Array.isArray(liveContext.tracks) && liveContext.tracks.length > 0)
? liveContext.tracks
: computedContext.tracks,
selectedMetadata: liveContext.selectedMetadata || computedContext.selectedMetadata,
cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig
}
: computedContext;
return {
state: liveJobProgress?.state || jobStatus,
@@ -566,7 +703,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
progress: liveJobProgress != null ? Number(liveJobProgress.progress ?? 0) : 0,
eta: liveJobProgress?.eta || null,
statusText: liveJobProgress?.statusText || job?.error_message || null,
context: computedContext
context: mergedContext
};
}
@@ -1184,12 +1321,13 @@ export default function DashboardPage({
try {
const response = await api.retryJob(jobId);
const result = getQueueActionResult(response);
const retryJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
await loadDashboardJobs();
if (result.queued) {
showQueuedToast(toastRef, 'Retry', result);
} else {
setExpandedJobId(normalizedJobId);
setExpandedJobId(retryJobId);
}
} catch (error) {
showError(error);
@@ -1216,12 +1354,13 @@ export default function DashboardPage({
try {
const response = await api.restartEncodeWithLastSettings(jobId);
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
await loadDashboardJobs();
if (result.queued) {
showQueuedToast(toastRef, 'Encode-Neustart', result);
} else {
setExpandedJobId(normalizedJobId);
setExpandedJobId(replacementJobId);
}
} catch (error) {
showError(error);
@@ -1238,10 +1377,12 @@ export default function DashboardPage({
setJobBusy(normalizedJobId, true);
try {
await api.restartReviewFromRaw(normalizedJobId);
const response = await api.restartReviewFromRaw(normalizedJobId);
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
await loadDashboardJobs();
setExpandedJobId(normalizedJobId);
setExpandedJobId(replacementJobId);
} catch (error) {
showError(error);
} finally {
@@ -1426,15 +1567,28 @@ export default function DashboardPage({
if (!jobId) {
return;
}
setJobBusy(jobId, true);
const normalizedJobId = normalizeJobId(jobId);
if (normalizedJobId) {
setJobBusy(normalizedJobId, true);
}
try {
await api.startCdRip(jobId, ripConfig);
const response = await api.startCdRip(jobId, ripConfig);
const result = getQueueActionResult(response);
if (result.queued) {
showQueuedToast(toastRef, 'Audio CD', result);
}
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
await loadDashboardJobs();
if (replacementJobId) {
setExpandedJobId(replacementJobId);
}
} catch (error) {
showError(error);
} finally {
setJobBusy(jobId, false);
if (normalizedJobId) {
setJobBusy(normalizedJobId, false);
}
}
};
@@ -1772,10 +1926,14 @@ export default function DashboardPage({
)}
</Card>
<Card title="Job Queue" subTitle="Starts werden nach Parallel-Limit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
<Card title="Job Queue" subTitle="Starts werden nach Typ- und Gesamtlimit 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={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
<Tag value={`CD max.: ${queueState?.maxParallelCdEncodes || 2}`} severity="info" />
<Tag value={`Gesamt max.: ${queueState?.maxTotalEncodes || 3}`} severity="info" />
{queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio CDs überspringen die Film-Queue-Reihenfolge" />}
<Tag value={`Film laufend: ${queueState?.runningCount || 0}`} severity={(queueState?.runningCount || 0) > 0 ? 'warning' : 'success'} />
<Tag value={`CD laufend: ${queueState?.runningCdCount || 0}`} severity={(queueState?.runningCdCount || 0) > 0 ? 'warning' : 'success'} />
<Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} />
</div>
@@ -2110,6 +2268,15 @@ export default function DashboardPage({
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
const mediaIndicator = mediaIndicatorMeta(job);
const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
const pipelineStatusText = String(pipelineForJob?.statusText || '').trim().toUpperCase();
const isCdJob = jobState.startsWith('CD_')
|| pipelineStage.startsWith('CD_')
|| mediaProfile === 'cd'
|| mediaIndicator.mediaType === 'cd'
|| pipelineStatusText.includes('CD_');
const rawProgress = Number(pipelineForJob?.progress ?? 0);
const clampedProgress = Number.isFinite(rawProgress)
? Math.max(0, Math.min(100, rawProgress))
@@ -2151,30 +2318,22 @@ export default function DashboardPage({
/>
</div>
{(() => {
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
const isCdJob = jobState.startsWith('CD_');
if (isCdJob) {
return (
<>
{jobState === 'CD_METADATA_SELECTION' ? (
<Button
label="CD-Metadaten auswählen"
icon="pi pi-list"
onClick={() => {
{isCdJob ? (
<CdRipConfigPanel
pipeline={pipelineForJob}
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
onCancel={() => handleCancel(jobId, jobState)}
onRetry={() => handleRetry(jobId)}
onOpenMetadata={() => {
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
? pipelineForJob.context
: pipeline?.context || {};
setCdMetadataDialogContext({ ...ctx, jobId });
setCdMetadataDialogVisible(true);
}}
disabled={busyJobIds.has(jobId)}
/>
) : null}
{(jobState === 'CD_READY_TO_RIP' || jobState === 'CD_RIPPING' || jobState === 'CD_ENCODING') ? (
<CdRipConfigPanel
pipeline={pipelineForJob}
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
onCancel={() => handleCancel(jobId, jobState)}
busy={busyJobIds.has(jobId)}
/>
) : null}
@@ -2183,7 +2342,7 @@ export default function DashboardPage({
}
return null;
})()}
{!String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase().startsWith('CD_') ? (
{!isCdJob ? (
<PipelineStatusCard
pipeline={pipelineForJob}
onAnalyze={handleAnalyze}