1.0.0-rc3 rc3

This commit is contained in:
2026-05-27 09:49:12 +02:00
parent eeef1fdb73
commit 2359c03bc8
13 changed files with 411 additions and 76 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "1.0.0-rc2",
"version": "1.0.0-rc3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "1.0.0-rc2",
"version": "1.0.0-rc3",
"dependencies": {
"chart.js": "^4.5.1",
"primeicons": "^7.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "1.0.0-rc2",
"version": "1.0.0-rc3",
"private": true,
"type": "module",
"scripts": {
+6 -1
View File
@@ -449,6 +449,11 @@ function App() {
)
)
);
const isUnknownRunningProgress = Boolean(
normalizedProgressJobId
&& incomingIsRunning
&& !hasKnownBinding
);
const allowRunningTransitionFromTerminal = Boolean(
normalizedProgressJobId
&& incomingIsRunning
@@ -465,7 +470,7 @@ function App() {
&& (
prevJobProgressIsTerminal
|| matchingCdDriveIsTerminal
|| !hasKnownBinding
|| isUnknownRunningProgress
|| regressesStableJobState
)
) {
@@ -587,12 +587,37 @@ function removeSelectionArgs(extraArgs) {
const isAudioWithValue = AUDIO_SELECTION_KEYS_WITH_VALUE.has(key);
const isAudioFlagOnly = AUDIO_SELECTION_KEYS_FLAG_ONLY.has(key);
const isSubtitleWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key)
|| SUBTITLE_FLAG_KEYS_WITH_VALUE.has(key);
const isSubtitleSelectionWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key);
const isSubtitleFlagWithValue = SUBTITLE_FLAG_KEYS_WITH_VALUE.has(key);
const isSubtitleWithValue = isSubtitleSelectionWithValue || isSubtitleFlagWithValue;
const isSubtitleFlagOnly = SUBTITLE_SELECTION_KEYS_FLAG_ONLY.has(key);
const isTitleWithValue = TITLE_SELECTION_KEYS_WITH_VALUE.has(key);
const skip = isAudioWithValue || isAudioFlagOnly || isSubtitleWithValue || isSubtitleFlagOnly || isTitleWithValue;
if (isSubtitleFlagWithValue) {
const inlineValue = token.includes('=')
? token.slice(token.indexOf('=') + 1)
: '';
const hasAttachedValue = token.includes('=');
const nextToken = String(args[i + 1] || '');
const hasSeparateValue = !hasAttachedValue && nextToken && !nextToken.startsWith('-');
const candidateValue = String(
hasAttachedValue
? inlineValue
: (hasSeparateValue ? nextToken : '')
).trim().toLowerCase();
// Keep explicit "none" subtitle flags in preview, same as backend command builder.
if (candidateValue === 'none') {
filtered.push(token);
if (hasSeparateValue) {
filtered.push(nextToken);
i += 1;
}
continue;
}
}
if (!skip) {
filtered.push(token);
continue;
@@ -709,9 +734,10 @@ function buildHandBrakeCommandPreview({
subtitleLanguageOrder: selectedSubtitleLanguageOrder,
fallbackSelectedSubtitleTrackIds: normalizedSelectedSubtitleTrackIds,
fallbackSelectedSubtitleTrackIdsOrdered: normalizedSelectedSubtitleTrackIds,
hasExplicitVariantSelection: normalizedSubtitleSelectionMode === 'track_ids'
? true
: hasExplicitSelectedVariantSelection
// Track-ID mode must use fallbackSelectedSubtitleTrackIds instead of forcing
// variant-explicit mode, otherwise preview can incorrectly collapse to "-s none".
hasExplicitVariantSelection: normalizedSubtitleSelectionMode === 'variants'
&& hasExplicitSelectedVariantSelection
});
const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIds.length > 0
? resolvedSubtitleSelection.subtitleTrackIds
@@ -1894,6 +1920,17 @@ export default function MediaInfoReviewPanel({
const isUserPresetActive = Boolean(selectedUserPreset);
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
const isHandBrakePresetActive = !isUserPresetActive && Boolean(trimmedHandBrakePreset);
const settingsPresetFallback = String(review?.selectors?.preset || '').trim();
const settingsExtraArgsFallback = String(review?.selectors?.extraArgs || '').trim();
const usesSettingsFallbackOnly = !isUserPresetActive && !isHandBrakePresetActive;
const hasSettingsPresetFallback = Boolean(settingsPresetFallback);
const hasSettingsExtraArgsFallback = Boolean(settingsExtraArgsFallback);
const hasInvalidPresetConfig = usesSettingsFallbackOnly
&& !hasSettingsPresetFallback
&& !hasSettingsExtraArgsFallback;
const hasSettingsArgsOnlyConfig = usesSettingsFallbackOnly
&& !hasSettingsPresetFallback
&& hasSettingsExtraArgsFallback;
const effectivePresetOverride = isUserPresetActive
? { handbrakePreset: selectedUserPreset.handbrakePreset || '', extraArgs: selectedUserPreset.extraArgs || '' }
: (isHandBrakePresetActive ? { handbrakePreset: trimmedHandBrakePreset, extraArgs: '' } : null);
@@ -1940,7 +1977,7 @@ export default function MediaInfoReviewPanel({
const audioCopyMaskSourceLabel = String(effectiveAudioSelector?.copyMaskSource || '').trim() || 'default';
const audioFallbackSourceLabel = String(effectiveAudioSelector?.fallbackSource || '').trim() || 'default';
const presetDropdownOptions = (() => {
const options = [];
const options = [{ label: 'Kein Preset', value: 'none' }];
if (hasUserPresets) {
options.push({ label: 'User-Presets/', value: '__group__userpresets', disabled: true });
for (const preset of normalizedUserPresets) {
@@ -1990,7 +2027,7 @@ export default function MediaInfoReviewPanel({
})();
const selectedPresetToken = isUserPresetActive
? `user:${selectedUserPreset.id}`
: (trimmedHandBrakePreset ? `hb:${trimmedHandBrakePreset}` : null);
: (trimmedHandBrakePreset ? `hb:${trimmedHandBrakePreset}` : 'none');
const scriptCatalog = (Array.isArray(availableScripts) ? availableScripts : [])
.map((item) => ({
@@ -2221,6 +2258,15 @@ export default function MediaInfoReviewPanel({
if (typeof onHandBrakePresetChange === 'function') {
onHandBrakePresetChange(presetName);
}
return;
}
if (value === 'none') {
if (typeof onUserPresetChange === 'function') {
onUserPresetChange(null);
}
if (typeof onHandBrakePresetChange === 'function') {
onHandBrakePresetChange('');
}
}
}}
placeholder="Preset auswählen …"
@@ -2242,6 +2288,20 @@ export default function MediaInfoReviewPanel({
)}
</div>
)}
{!compactTitleSelection && !isTitleSelectionMode && hasInvalidPresetConfig ? (
<div className="media-review-notes">
<small>
Warnung: Weder HandBrake-Preset noch Extra-Args sind in den Settings gesetzt. Der CLI-Command kann so nicht valide gebaut werden.
</small>
</div>
) : null}
{!compactTitleSelection && !isTitleSelectionMode && hasSettingsArgsOnlyConfig ? (
<div className="media-review-notes">
<small>
Hinweis: Es wird kein Preset verwendet. Nur CLI-Parameter aus den Settings werden genutzt - bitte auf Korrektheit prüfen.
</small>
</div>
) : null}
{!compactTitleSelection && !isTitleSelectionMode ? (
<div className="media-review-meta">
<div>
+10 -7
View File
@@ -2342,8 +2342,11 @@ export default function PipelineStatusCard({
const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle;
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
const settingsPresetFallback = String(mediaInfoReview?.selectors?.preset || '').trim();
const settingsExtraArgsFallback = String(mediaInfoReview?.selectors?.extraArgs || '').trim();
const hasSelectedPreset = Boolean(selectedUserPresetId) || Boolean(trimmedHandBrakePreset);
const hasEffectiveSelectedPreset = hasSelectedPreset || Boolean(settingsPresetFallback);
const hasEffectiveSelectedPreset = hasSelectedPreset
|| Boolean(settingsPresetFallback)
|| Boolean(settingsExtraArgsFallback);
const converterMediaType = String(mediaInfoReview?.converterMediaType || 'video').trim().toLowerCase() || 'video';
const showConverterConfig = jobMediaProfile === 'converter' && converterMediaType !== 'audio';
const requiresPresetSelection = showConverterConfig || jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray';
@@ -3856,12 +3859,12 @@ export default function PipelineStatusCard({
selectedPostEncodeChainIds: selectedPostChainIds,
selectedPreEncodeChainIds: selectedPreChainIds
};
if (selectedUserPresetId !== null && selectedUserPresetId !== undefined) {
startPayload.selectedUserPresetId = selectedUserPresetId;
}
if (String(selectedHandBrakePreset || '').trim()) {
startPayload.selectedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
}
startPayload.selectedUserPresetId = (
selectedUserPresetId !== null && selectedUserPresetId !== undefined
? selectedUserPresetId
: null
);
startPayload.selectedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
await onStart(retryJobId, startPayload);
}}
loading={busy}
+218 -13
View File
@@ -97,6 +97,10 @@ const HISTORY_WINDOWS = [
{ label: '4d', hours: 96 }
];
const HISTORY_STACK_BREAKPOINT = 1700;
const HISTORY_RESAMPLE_TARGET_POINTS = 720;
const HISTORY_INTERPOLATION_MIN_GAP_MS = 5 * 60 * 1000;
const HISTORY_INTERPOLATION_MAX_GAP_MS = 45 * 60 * 1000;
const HISTORY_INTERPOLATION_GAP_FACTOR = 6;
const CPU_SERIES_COLOR = '#c43d2f';
const GPU_SERIES_COLOR = '#c9961a';
@@ -113,6 +117,15 @@ const historyChartOptions = {
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
title: (items) => {
const first = Array.isArray(items) ? items[0] : null;
const rawLabel = first?.label ?? first?.raw?.x ?? first?.parsed?.x ?? '';
return formatTickLabel(rawLabel);
}
}
}
},
scales: {
@@ -120,7 +133,17 @@ const historyChartOptions = {
ticks: {
autoSkip: true,
maxTicksLimit: 8,
color: '#6a4d38'
color: '#6a4d38',
callback: function (value, index, ticks) {
const resolvedLabel = typeof this?.getLabelForValue === 'function'
? this.getLabelForValue(value)
: (
Array.isArray(ticks) && ticks[index]
? (ticks[index].label ?? ticks[index].value ?? value)
: value
);
return formatTickLabel(resolvedLabel);
}
},
grid: {
color: 'rgba(111,57,34,0.08)'
@@ -152,6 +175,182 @@ function formatTickLabel(timestampIso) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function formatDayMarkerLabel(timestampIso) {
const parsed = Date.parse(String(timestampIso || ''));
if (!Number.isFinite(parsed)) {
return '';
}
const date = new Date(parsed);
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.`;
}
function toDayKeyFromTimestamp(timestampIso) {
const parsed = Date.parse(String(timestampIso || ''));
if (!Number.isFinite(parsed)) {
return null;
}
const date = new Date(parsed);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function parseTimestampMs(value) {
const parsed = Date.parse(String(value || ''));
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeHistoryPoints(points = []) {
return (Array.isArray(points) ? points : [])
.map((point) => {
const timestampMs = parseTimestampMs(point?.capturedAt);
if (!Number.isFinite(timestampMs)) {
return null;
}
return {
capturedAt: new Date(timestampMs).toISOString(),
timestampMs,
cpuUsagePercent: toNumberOrNull(point?.cpuUsagePercent),
ramUsagePercent: toNumberOrNull(point?.ramUsagePercent),
gpuUsagePercent: toNumberOrNull(point?.gpuUsagePercent),
cpuTemperatureC: toNumberOrNull(point?.cpuTemperatureC),
gpuTemperatureC: toNumberOrNull(point?.gpuTemperatureC)
};
})
.filter(Boolean)
.sort((left, right) => left.timestampMs - right.timestampMs);
}
function interpolateMetric(prevPoint, nextPoint, metricKey, targetMs, maxGapMs) {
if (!prevPoint || !nextPoint) {
return null;
}
if (prevPoint.timestampMs === nextPoint.timestampMs) {
return toNumberOrNull(prevPoint[metricKey]);
}
const gapMs = nextPoint.timestampMs - prevPoint.timestampMs;
if (!Number.isFinite(gapMs) || gapMs <= 0 || gapMs > maxGapMs) {
return null;
}
const prevValue = toNumberOrNull(prevPoint[metricKey]);
const nextValue = toNumberOrNull(nextPoint[metricKey]);
if (!Number.isFinite(prevValue) || !Number.isFinite(nextValue)) {
return null;
}
const ratio = (targetMs - prevPoint.timestampMs) / gapMs;
if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) {
return null;
}
return Number((prevValue + ((nextValue - prevValue) * ratio)).toFixed(2));
}
function buildResampledHistoryPoints(points = [], historyHours = 24) {
const normalized = normalizeHistoryPoints(points);
if (normalized.length === 0) {
return [];
}
const historyWindowMs = Math.max(1, Number(historyHours || 24)) * 60 * 60 * 1000;
const stepMsRaw = Math.round(historyWindowMs / HISTORY_RESAMPLE_TARGET_POINTS);
const stepMs = Math.max(60 * 1000, stepMsRaw);
const maxInterpolationGapMs = Math.max(
HISTORY_INTERPOLATION_MIN_GAP_MS,
Math.min(HISTORY_INTERPOLATION_MAX_GAP_MS, stepMs * HISTORY_INTERPOLATION_GAP_FACTOR)
);
const latestSourceMs = normalized[normalized.length - 1].timestampMs;
const windowEndMs = Math.max(Date.now(), latestSourceMs);
const windowStartMs = windowEndMs - historyWindowMs;
const gridStartMs = Math.floor(windowStartMs / stepMs) * stepMs;
const gridEndMs = Math.ceil(windowEndMs / stepMs) * stepMs;
const source = normalized.filter((point) => point.timestampMs >= (gridStartMs - maxInterpolationGapMs));
const keys = [
'cpuUsagePercent',
'ramUsagePercent',
'gpuUsagePercent',
'cpuTemperatureC',
'gpuTemperatureC'
];
let sourceIndex = 0;
const output = [];
for (let ts = gridStartMs; ts <= gridEndMs; ts += stepMs) {
while (sourceIndex < source.length && source[sourceIndex].timestampMs < ts) {
sourceIndex += 1;
}
const nextPoint = sourceIndex < source.length ? source[sourceIndex] : null;
const prevPoint = sourceIndex > 0 ? source[sourceIndex - 1] : null;
const row = {
capturedAt: new Date(ts).toISOString(),
timestampMs: ts
};
for (const key of keys) {
const nextValue = nextPoint && nextPoint.timestampMs === ts
? toNumberOrNull(nextPoint[key])
: null;
const prevValue = prevPoint && prevPoint.timestampMs === ts
? toNumberOrNull(prevPoint[key])
: null;
row[key] = Number.isFinite(nextValue)
? nextValue
: (Number.isFinite(prevValue)
? prevValue
: interpolateMetric(prevPoint, nextPoint, key, ts, maxInterpolationGapMs));
}
output.push(row);
}
return output;
}
const historyDayBoundaryPlugin = {
id: 'historyDayBoundary',
afterDatasetsDraw(chart, _args, options) {
if (options === false) {
return;
}
const xScale = chart?.scales?.x;
const area = chart?.chartArea;
const labels = Array.isArray(chart?.data?.labels) ? chart.data.labels : [];
if (!xScale || !area || labels.length < 2) {
return;
}
const ctx = chart.ctx;
ctx.save();
ctx.strokeStyle = 'rgba(111,57,34,0.30)';
ctx.fillStyle = 'rgba(111,57,34,0.75)';
ctx.setLineDash([4, 4]);
ctx.lineWidth = 1;
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
for (let index = 1; index < labels.length; index += 1) {
const prevDayKey = toDayKeyFromTimestamp(labels[index - 1]);
const nextDayKey = toDayKeyFromTimestamp(labels[index]);
if (!prevDayKey || !nextDayKey || prevDayKey === nextDayKey) {
continue;
}
const xPrev = xScale.getPixelForValue(index - 1);
const xNext = xScale.getPixelForValue(index);
const x = (xPrev + xNext) / 2;
ctx.beginPath();
ctx.moveTo(x, area.top);
ctx.lineTo(x, area.bottom);
ctx.stroke();
const markerLabel = formatDayMarkerLabel(labels[index]);
if (markerLabel) {
ctx.fillText(markerLabel, x + 4, area.top + 4);
}
}
ctx.restore();
}
};
function buildSeriesToggleButtonStyle(color, active) {
if (active) {
return {
@@ -258,6 +457,10 @@ export default function HardwarePage({ hardwareMonitoring }) {
const ramTitle = String(memory?.name || memory?.vendor || memory?.model || '').trim() || 'Arbeitsspeicher';
const gpuTitle = String(primaryGpu?.name || gpu?.name || gpu?.vendor || '').trim() || 'GPU';
const historyPoints = Array.isArray(historyState.points) ? historyState.points : [];
const resampledHistoryPoints = useMemo(
() => buildResampledHistoryPoints(historyPoints, historyHours),
[historyPoints, historyHours]
);
useEffect(() => {
if (!monitoringState.enabled) {
@@ -338,12 +541,12 @@ export default function HardwarePage({ hardwareMonitoring }) {
};
}, []);
const historyUsageChartData = useMemo(() => ({
labels: historyPoints.map((point) => formatTickLabel(point?.capturedAt)),
const historyUsageChartData = useMemo(() => ({
labels: resampledHistoryPoints.map((point) => String(point?.capturedAt || '').trim()),
datasets: [
{
label: 'CPU',
data: historyPoints.map((point) => toNumberOrNull(point?.cpuUsagePercent)),
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.cpuUsagePercent)),
borderColor: CPU_SERIES_COLOR,
backgroundColor: CPU_SERIES_COLOR,
borderWidth: 2,
@@ -353,7 +556,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
},
{
label: 'RAM',
data: historyPoints.map((point) => toNumberOrNull(point?.ramUsagePercent)),
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.ramUsagePercent)),
borderColor: RAM_SERIES_COLOR,
backgroundColor: RAM_SERIES_COLOR,
borderWidth: 2,
@@ -363,7 +566,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
},
{
label: 'GPU',
data: historyPoints.map((point) => toNumberOrNull(point?.gpuUsagePercent)),
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.gpuUsagePercent)),
borderColor: GPU_SERIES_COLOR,
backgroundColor: GPU_SERIES_COLOR,
borderWidth: 2,
@@ -372,14 +575,14 @@ export default function HardwarePage({ hardwareMonitoring }) {
tension: 0.22
}
]
}), [historyPoints, usageSeriesVisible]);
}), [resampledHistoryPoints, usageSeriesVisible]);
const historyTempChartData = useMemo(() => ({
labels: historyPoints.map((point) => formatTickLabel(point?.capturedAt)),
const historyTempChartData = useMemo(() => ({
labels: resampledHistoryPoints.map((point) => String(point?.capturedAt || '').trim()),
datasets: [
{
label: 'CPU',
data: historyPoints.map((point) => toNumberOrNull(point?.cpuTemperatureC)),
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.cpuTemperatureC)),
borderColor: CPU_SERIES_COLOR,
backgroundColor: CPU_SERIES_COLOR,
borderWidth: 2,
@@ -389,7 +592,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
},
{
label: 'GPU',
data: historyPoints.map((point) => toNumberOrNull(point?.gpuTemperatureC)),
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.gpuTemperatureC)),
borderColor: GPU_SERIES_COLOR,
backgroundColor: GPU_SERIES_COLOR,
borderWidth: 2,
@@ -398,7 +601,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
tension: 0.22
}
]
}), [historyPoints, tempSeriesVisible]);
}), [resampledHistoryPoints, tempSeriesVisible]);
return (
<div className="hardware-detail-page">
@@ -463,7 +666,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
{historyState.error ? <small className="error-text">{historyState.error}</small> : null}
{historyState.loading && historyPoints.length === 0 ? (
<p>Lade Verlauf ...</p>
) : historyPoints.length === 0 ? (
) : resampledHistoryPoints.length === 0 ? (
<p>Noch keine Verlaufsdaten vorhanden.</p>
) : (
<div className="hardware-history-grid">
@@ -502,6 +705,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
type="line"
data={historyUsageChartData}
options={historyChartOptions}
plugins={[historyDayBoundaryPlugin]}
/>
</div>
<ChartLegendRow
@@ -539,6 +743,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
type="line"
data={historyTempChartData}
options={historyChartOptions}
plugins={[historyDayBoundaryPlugin]}
/>
</div>
<ChartLegendRow
+14 -9
View File
@@ -1476,22 +1476,27 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
: null;
const liveState = String(liveJobProgress?.state || '').trim().toUpperCase();
const isTerminalJobStatus = jobStatus === 'FINISHED' || jobStatus === 'ERROR' || jobStatus === 'CANCELLED';
const ignoreStaleLiveProgress = isTerminalJobStatus && processingStates.includes(liveState);
const mergedContext = liveContext
const jobStatusIsRunning = processingStates.includes(jobStatus);
const ignoreStaleLiveProgress = (
(isTerminalJobStatus && processingStates.includes(liveState))
|| (!jobStatusIsRunning && jobStatus !== 'IDLE' && processingStates.includes(liveState))
);
const effectiveLiveContext = ignoreStaleLiveProgress ? null : liveContext;
const mergedContext = effectiveLiveContext
? {
...computedContext,
...liveContext,
...effectiveLiveContext,
// Prefer the DB plan when it has been confirmed but the live (jobProgress) plan hasn't
// this happens when confirmEncodeReview is called with skipPipelineStateUpdate=true
// (always the case for queued jobs), leaving jobProgress with the pre-confirmation plan.
mediaInfoReview: (computedContext.mediaInfoReview?.reviewConfirmedAt && !liveContext.mediaInfoReview?.reviewConfirmedAt)
mediaInfoReview: (computedContext.mediaInfoReview?.reviewConfirmedAt && !effectiveLiveContext.mediaInfoReview?.reviewConfirmedAt)
? computedContext.mediaInfoReview
: (liveContext.mediaInfoReview || computedContext.mediaInfoReview),
tracks: (Array.isArray(liveContext.tracks) && liveContext.tracks.length > 0)
? liveContext.tracks
: (effectiveLiveContext.mediaInfoReview || computedContext.mediaInfoReview),
tracks: (Array.isArray(effectiveLiveContext.tracks) && effectiveLiveContext.tracks.length > 0)
? effectiveLiveContext.tracks
: computedContext.tracks,
selectedMetadata: computedContext.selectedMetadata || liveContext.selectedMetadata,
cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig
selectedMetadata: computedContext.selectedMetadata || effectiveLiveContext.selectedMetadata,
cdRipConfig: effectiveLiveContext.cdRipConfig || computedContext.cdRipConfig
}
: computedContext;
// The card always represents `job.id`; never let stale live context override it.