diff --git a/backend/package-lock.json b/backend/package-lock.json index 282905d..81e2da2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "1.0.0-rc2", + "version": "1.0.0-rc3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "1.0.0-rc2", + "version": "1.0.0-rc3", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 397446b..aadd6a9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "1.0.0-rc2", + "version": "1.0.0-rc3", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 3c37ca5..d13e58c 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -107,6 +107,16 @@ function nowIso() { return new Date().toISOString(); } +function appendLinesInPlace(target, lines) { + if (!Array.isArray(target) || !Array.isArray(lines) || lines.length === 0) { + return target; + } + for (const line of lines) { + target.push(line); + } + return target; +} + function normalizeLifecycleJobState(value) { return String(value || '').trim().toUpperCase(); } @@ -9637,8 +9647,6 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n const postEncodeScriptIds = normalizeScriptIdList(previousPlan?.postEncodeScriptIds || []); const preEncodeChainIds = normalizeChainIdList(previousPlan?.preEncodeChainIds || []); const postEncodeChainIds = normalizeChainIdList(previousPlan?.postEncodeChainIds || []); - const userPreset = normalizeUserPresetForPlan(previousPlan?.userPreset || null); - nextPlan = { ...nextPlan, preEncodeScriptIds, @@ -9647,7 +9655,7 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n postEncodeScripts: buildScriptDescriptorList(postEncodeScriptIds, previousPlan?.postEncodeScripts || []), preEncodeChainIds, postEncodeChainIds, - userPreset, + userPreset: null, reviewConfirmed: false, reviewConfirmedAt: null, prefilledFromPreviousRun: true, @@ -9659,8 +9667,7 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n || preEncodeScriptIds.length > 0 || postEncodeScriptIds.length > 0 || preEncodeChainIds.length > 0 - || postEncodeChainIds.length > 0 - || Boolean(userPreset); + || postEncodeChainIds.length > 0; return { plan: nextPlan, @@ -9670,7 +9677,7 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n postEncodeScriptCount: postEncodeScriptIds.length, preEncodeChainCount: preEncodeChainIds.length, postEncodeChainCount: postEncodeChainIds.length, - userPresetApplied: Boolean(userPreset) + userPresetApplied: false }; } @@ -18149,7 +18156,7 @@ class PipelineService extends EventEmitter { source: 'HANDBRAKE_SCAN', silent: !this.isPrimaryJob(jobId) }); - lines.push(...pluginScan.scanLines); + appendLinesInPlace(lines, pluginScan.scanLines); runInfo = pluginScan.runInfo || null; sourceArg = pluginScan.sourceArg || null; reviewPluginExecution = this.mergePluginExecutionState( @@ -18514,7 +18521,7 @@ class PipelineService extends EventEmitter { source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', silent: !this.isPrimaryJob(jobId) }); - resolveScanLines.push(...pluginScan.scanLines); + appendLinesInPlace(resolveScanLines, pluginScan.scanLines); reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, pluginScan.pluginExecution @@ -18656,7 +18663,7 @@ class PipelineService extends EventEmitter { source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', silent: !this.isPrimaryJob(jobId) }); - scanLines.push(...pluginScan.scanLines); + appendLinesInPlace(scanLines, pluginScan.scanLines); reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, pluginScan.pluginExecution @@ -19315,8 +19322,15 @@ class PipelineService extends EventEmitter { source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', silent: !this.isPrimaryJob(jobId) }); - resolveScanLines.push(...pluginScan.scanLines); + appendLinesInPlace(resolveScanLines, pluginScan.scanLines); handBrakeResolveRunInfo = pluginScan.runInfo || null; + const resolveScanStatus = String(handBrakeResolveRunInfo?.status || '').trim().toUpperCase(); + if (resolveScanStatus === 'CANCELLED') { + const error = new Error('HandBrake Playlist-Mapping wurde abgebrochen.'); + error.statusCode = 409; + error.runInfo = handBrakeResolveRunInfo; + throw error; + } reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, pluginScan.pluginExecution @@ -19328,6 +19342,16 @@ class PipelineService extends EventEmitter { error.runInfo = handBrakeResolveRunInfo; throw error; } + const knownPlaylists = listAvailableHandBrakePlaylists(resolveScanJson); + if (knownPlaylists.length === 0 && resolveScanStatus && resolveScanStatus !== 'SUCCESS') { + const error = new Error( + `HandBrake Playlist-Mapping unvollständig (${resolveScanStatus}). ` + + 'Scan enthält keine erkennbaren Playlist-IDs.' + ); + error.statusCode = resolveScanStatus === 'CANCELLED' ? 409 : 502; + error.runInfo = handBrakeResolveRunInfo; + throw error; + } resolvedHandBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(resolveScanJson, resolvedPlaylistId, { expectedMakemkvTitleId: selectedTitleForReview, @@ -19336,7 +19360,6 @@ class PipelineService extends EventEmitter { playlistAliases: playlistAliasesForResolve }); if (!resolvedHandBrakeTitleId) { - const knownPlaylists = listAvailableHandBrakePlaylists(resolveScanJson); const error = new Error( `Kein HandBrake-Titel für ${toPlaylistFile(resolvedPlaylistId)} gefunden.` + ` ${knownPlaylists.length > 0 ? `Scan-Playlists: ${knownPlaylists.map((id) => `${id}.mpls`).join(', ')}` : 'Scan enthält keine erkennbaren Playlist-IDs.'}` @@ -22781,6 +22804,10 @@ class PipelineService extends EventEmitter { : ''; const hasExplicitUserPresetSelection = !multipartSettingsLocked && explicitUserPresetId !== null; const hasExplicitHandBrakePresetSelection = !multipartSettingsLocked && Boolean(rawHandBrakePreset); + const explicitPresetClearRequested = !multipartSettingsLocked + && (hasUserPresetIdField || hasHandBrakePresetField) + && explicitUserPresetId === null + && !rawHandBrakePreset; let resolvedUserPreset = null; if (hasExplicitUserPresetSelection) { resolvedUserPreset = await userPresetService.getPresetById(explicitUserPresetId); @@ -22799,7 +22826,7 @@ class PipelineService extends EventEmitter { } : null; } - if (!resolvedUserPreset) { + if (!resolvedUserPreset && !explicitPresetClearRequested) { resolvedUserPreset = normalizeUserPresetForPlan( planForConfirm?.userPreset || encodePlan?.userPreset || null ); @@ -22833,20 +22860,24 @@ class PipelineService extends EventEmitter { const requiresExplicitPreset = readyMediaProfile === 'dvd' || readyMediaProfile === 'bluray' || (readyMediaProfile === 'converter' && confirmedConverterMediaType !== 'audio'); - const planPresetName = String( - planForConfirm?.userPreset?.handbrakePreset - || encodePlan?.userPreset?.handbrakePreset - || planForConfirm?.selectors?.preset - || encodePlan?.selectors?.preset - || '' - ).trim(); - const planExtraArgs = String( - planForConfirm?.userPreset?.extraArgs - || encodePlan?.userPreset?.extraArgs - || planForConfirm?.selectors?.extraArgs - || encodePlan?.selectors?.extraArgs - || '' - ).trim(); + const planPresetName = explicitPresetClearRequested + ? '' + : String( + planForConfirm?.userPreset?.handbrakePreset + || encodePlan?.userPreset?.handbrakePreset + || planForConfirm?.selectors?.preset + || encodePlan?.selectors?.preset + || '' + ).trim(); + const planExtraArgs = explicitPresetClearRequested + ? '' + : String( + planForConfirm?.userPreset?.extraArgs + || encodePlan?.userPreset?.extraArgs + || planForConfirm?.selectors?.extraArgs + || encodePlan?.selectors?.extraArgs + || '' + ).trim(); const effectivePresetName = String( resolvedUserPreset?.handbrakePreset || rawHandBrakePreset @@ -23800,7 +23831,7 @@ class PipelineService extends EventEmitter { source: 'HANDBRAKE_SCAN_DVD', silent: !this.isPrimaryJob(jobId) }); - dvdScanLines.push(...pluginScan.scanLines); + appendLinesInPlace(dvdScanLines, pluginScan.scanLines); dvdScanRunInfo = pluginScan.runInfo || null; reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, @@ -24548,7 +24579,7 @@ class PipelineService extends EventEmitter { source: 'HANDBRAKE_SCAN_DVD_TITLES', silent: !this.isPrimaryJob(jobId) }); - dvdTitleScanLines.push(...pluginScan.scanLines); + appendLinesInPlace(dvdTitleScanLines, pluginScan.scanLines); dvdTitleScanRunInfo = pluginScan.runInfo || null; reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution, diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 88fc2de..adf6bf3 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -308,12 +308,38 @@ 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 from extra args. + // This allows users to intentionally clear subtitle burn/default/forced behavior. + if (candidateValue === 'none') { + filtered.push(token); + if (hasSeparateValue) { + filtered.push(nextToken); + i += 1; + } + continue; + } + } + if (!skip) { filtered.push(token); continue; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 78b2cc4..c7c71ae 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index a02e1bf..4fd373c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "1.0.0-rc2", + "version": "1.0.0-rc3", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a6b6102..6efd5d6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ) ) { diff --git a/frontend/src/components/MediaInfoReviewPanel.jsx b/frontend/src/components/MediaInfoReviewPanel.jsx index 2f1612d..9b4a984 100644 --- a/frontend/src/components/MediaInfoReviewPanel.jsx +++ b/frontend/src/components/MediaInfoReviewPanel.jsx @@ -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({ )} )} + {!compactTitleSelection && !isTitleSelectionMode && hasInvalidPresetConfig ? ( +
+ + Warnung: Weder HandBrake-Preset noch Extra-Args sind in den Settings gesetzt. Der CLI-Command kann so nicht valide gebaut werden. + +
+ ) : null} + {!compactTitleSelection && !isTitleSelectionMode && hasSettingsArgsOnlyConfig ? ( +
+ + Hinweis: Es wird kein Preset verwendet. Nur CLI-Parameter aus den Settings werden genutzt - bitte auf Korrektheit prüfen. + +
+ ) : null} {!compactTitleSelection && !isTitleSelectionMode ? (
diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx index 89dd739..d5eceab 100644 --- a/frontend/src/components/PipelineStatusCard.jsx +++ b/frontend/src/components/PipelineStatusCard.jsx @@ -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} diff --git a/frontend/src/pages/HardwarePage.jsx b/frontend/src/pages/HardwarePage.jsx index 3b56ea9..91693ce 100644 --- a/frontend/src/pages/HardwarePage.jsx +++ b/frontend/src/pages/HardwarePage.jsx @@ -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 (
@@ -463,7 +666,7 @@ export default function HardwarePage({ hardwareMonitoring }) { {historyState.error ? {historyState.error} : null} {historyState.loading && historyPoints.length === 0 ? (

Lade Verlauf ...

- ) : historyPoints.length === 0 ? ( + ) : resampledHistoryPoints.length === 0 ? (

Noch keine Verlaufsdaten vorhanden.

) : (
@@ -502,6 +705,7 @@ export default function HardwarePage({ hardwareMonitoring }) { type="line" data={historyUsageChartData} options={historyChartOptions} + plugins={[historyDayBoundaryPlugin]} />
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. diff --git a/package-lock.json b/package-lock.json index 1be7ce6..3c14f34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster", - "version": "1.0.0-rc2", + "version": "1.0.0-rc3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster", - "version": "1.0.0-rc2", + "version": "1.0.0-rc3", "devDependencies": { "concurrently": "^9.1.2" } diff --git a/package.json b/package.json index 1efa575..1295b00 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ripster", "private": true, - "version": "1.0.0-rc2", + "version": "1.0.0-rc3", "scripts": { "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev:backend": "npm run dev --prefix backend",