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-backend", "name": "ripster-backend",
"version": "1.0.0-rc2", "version": "1.0.0-rc3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-backend", "name": "ripster-backend",
"version": "1.0.0-rc2", "version": "1.0.0-rc3",
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "1.0.0-rc2", "version": "1.0.0-rc3",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
+47 -16
View File
@@ -107,6 +107,16 @@ function nowIso() {
return new Date().toISOString(); 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) { function normalizeLifecycleJobState(value) {
return String(value || '').trim().toUpperCase(); return String(value || '').trim().toUpperCase();
} }
@@ -9637,8 +9647,6 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n
const postEncodeScriptIds = normalizeScriptIdList(previousPlan?.postEncodeScriptIds || []); const postEncodeScriptIds = normalizeScriptIdList(previousPlan?.postEncodeScriptIds || []);
const preEncodeChainIds = normalizeChainIdList(previousPlan?.preEncodeChainIds || []); const preEncodeChainIds = normalizeChainIdList(previousPlan?.preEncodeChainIds || []);
const postEncodeChainIds = normalizeChainIdList(previousPlan?.postEncodeChainIds || []); const postEncodeChainIds = normalizeChainIdList(previousPlan?.postEncodeChainIds || []);
const userPreset = normalizeUserPresetForPlan(previousPlan?.userPreset || null);
nextPlan = { nextPlan = {
...nextPlan, ...nextPlan,
preEncodeScriptIds, preEncodeScriptIds,
@@ -9647,7 +9655,7 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n
postEncodeScripts: buildScriptDescriptorList(postEncodeScriptIds, previousPlan?.postEncodeScripts || []), postEncodeScripts: buildScriptDescriptorList(postEncodeScriptIds, previousPlan?.postEncodeScripts || []),
preEncodeChainIds, preEncodeChainIds,
postEncodeChainIds, postEncodeChainIds,
userPreset, userPreset: null,
reviewConfirmed: false, reviewConfirmed: false,
reviewConfirmedAt: null, reviewConfirmedAt: null,
prefilledFromPreviousRun: true, prefilledFromPreviousRun: true,
@@ -9659,8 +9667,7 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n
|| preEncodeScriptIds.length > 0 || preEncodeScriptIds.length > 0
|| postEncodeScriptIds.length > 0 || postEncodeScriptIds.length > 0
|| preEncodeChainIds.length > 0 || preEncodeChainIds.length > 0
|| postEncodeChainIds.length > 0 || postEncodeChainIds.length > 0;
|| Boolean(userPreset);
return { return {
plan: nextPlan, plan: nextPlan,
@@ -9670,7 +9677,7 @@ function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = n
postEncodeScriptCount: postEncodeScriptIds.length, postEncodeScriptCount: postEncodeScriptIds.length,
preEncodeChainCount: preEncodeChainIds.length, preEncodeChainCount: preEncodeChainIds.length,
postEncodeChainCount: postEncodeChainIds.length, postEncodeChainCount: postEncodeChainIds.length,
userPresetApplied: Boolean(userPreset) userPresetApplied: false
}; };
} }
@@ -18149,7 +18156,7 @@ class PipelineService extends EventEmitter {
source: 'HANDBRAKE_SCAN', source: 'HANDBRAKE_SCAN',
silent: !this.isPrimaryJob(jobId) silent: !this.isPrimaryJob(jobId)
}); });
lines.push(...pluginScan.scanLines); appendLinesInPlace(lines, pluginScan.scanLines);
runInfo = pluginScan.runInfo || null; runInfo = pluginScan.runInfo || null;
sourceArg = pluginScan.sourceArg || null; sourceArg = pluginScan.sourceArg || null;
reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution = this.mergePluginExecutionState(
@@ -18514,7 +18521,7 @@ class PipelineService extends EventEmitter {
source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', source: 'HANDBRAKE_SCAN_PLAYLIST_MAP',
silent: !this.isPrimaryJob(jobId) silent: !this.isPrimaryJob(jobId)
}); });
resolveScanLines.push(...pluginScan.scanLines); appendLinesInPlace(resolveScanLines, pluginScan.scanLines);
reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution = this.mergePluginExecutionState(
reviewPluginExecution, reviewPluginExecution,
pluginScan.pluginExecution pluginScan.pluginExecution
@@ -18656,7 +18663,7 @@ class PipelineService extends EventEmitter {
source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', source: 'HANDBRAKE_SCAN_PLAYLIST_MAP',
silent: !this.isPrimaryJob(jobId) silent: !this.isPrimaryJob(jobId)
}); });
scanLines.push(...pluginScan.scanLines); appendLinesInPlace(scanLines, pluginScan.scanLines);
reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution = this.mergePluginExecutionState(
reviewPluginExecution, reviewPluginExecution,
pluginScan.pluginExecution pluginScan.pluginExecution
@@ -19315,8 +19322,15 @@ class PipelineService extends EventEmitter {
source: 'HANDBRAKE_SCAN_PLAYLIST_MAP', source: 'HANDBRAKE_SCAN_PLAYLIST_MAP',
silent: !this.isPrimaryJob(jobId) silent: !this.isPrimaryJob(jobId)
}); });
resolveScanLines.push(...pluginScan.scanLines); appendLinesInPlace(resolveScanLines, pluginScan.scanLines);
handBrakeResolveRunInfo = pluginScan.runInfo || null; 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 = this.mergePluginExecutionState(
reviewPluginExecution, reviewPluginExecution,
pluginScan.pluginExecution pluginScan.pluginExecution
@@ -19328,6 +19342,16 @@ class PipelineService extends EventEmitter {
error.runInfo = handBrakeResolveRunInfo; error.runInfo = handBrakeResolveRunInfo;
throw error; 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, { resolvedHandBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(resolveScanJson, resolvedPlaylistId, {
expectedMakemkvTitleId: selectedTitleForReview, expectedMakemkvTitleId: selectedTitleForReview,
@@ -19336,7 +19360,6 @@ class PipelineService extends EventEmitter {
playlistAliases: playlistAliasesForResolve playlistAliases: playlistAliasesForResolve
}); });
if (!resolvedHandBrakeTitleId) { if (!resolvedHandBrakeTitleId) {
const knownPlaylists = listAvailableHandBrakePlaylists(resolveScanJson);
const error = new Error( const error = new Error(
`Kein HandBrake-Titel für ${toPlaylistFile(resolvedPlaylistId)} gefunden.` `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.'}` + ` ${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 hasExplicitUserPresetSelection = !multipartSettingsLocked && explicitUserPresetId !== null;
const hasExplicitHandBrakePresetSelection = !multipartSettingsLocked && Boolean(rawHandBrakePreset); const hasExplicitHandBrakePresetSelection = !multipartSettingsLocked && Boolean(rawHandBrakePreset);
const explicitPresetClearRequested = !multipartSettingsLocked
&& (hasUserPresetIdField || hasHandBrakePresetField)
&& explicitUserPresetId === null
&& !rawHandBrakePreset;
let resolvedUserPreset = null; let resolvedUserPreset = null;
if (hasExplicitUserPresetSelection) { if (hasExplicitUserPresetSelection) {
resolvedUserPreset = await userPresetService.getPresetById(explicitUserPresetId); resolvedUserPreset = await userPresetService.getPresetById(explicitUserPresetId);
@@ -22799,7 +22826,7 @@ class PipelineService extends EventEmitter {
} }
: null; : null;
} }
if (!resolvedUserPreset) { if (!resolvedUserPreset && !explicitPresetClearRequested) {
resolvedUserPreset = normalizeUserPresetForPlan( resolvedUserPreset = normalizeUserPresetForPlan(
planForConfirm?.userPreset || encodePlan?.userPreset || null planForConfirm?.userPreset || encodePlan?.userPreset || null
); );
@@ -22833,14 +22860,18 @@ class PipelineService extends EventEmitter {
const requiresExplicitPreset = readyMediaProfile === 'dvd' const requiresExplicitPreset = readyMediaProfile === 'dvd'
|| readyMediaProfile === 'bluray' || readyMediaProfile === 'bluray'
|| (readyMediaProfile === 'converter' && confirmedConverterMediaType !== 'audio'); || (readyMediaProfile === 'converter' && confirmedConverterMediaType !== 'audio');
const planPresetName = String( const planPresetName = explicitPresetClearRequested
? ''
: String(
planForConfirm?.userPreset?.handbrakePreset planForConfirm?.userPreset?.handbrakePreset
|| encodePlan?.userPreset?.handbrakePreset || encodePlan?.userPreset?.handbrakePreset
|| planForConfirm?.selectors?.preset || planForConfirm?.selectors?.preset
|| encodePlan?.selectors?.preset || encodePlan?.selectors?.preset
|| '' || ''
).trim(); ).trim();
const planExtraArgs = String( const planExtraArgs = explicitPresetClearRequested
? ''
: String(
planForConfirm?.userPreset?.extraArgs planForConfirm?.userPreset?.extraArgs
|| encodePlan?.userPreset?.extraArgs || encodePlan?.userPreset?.extraArgs
|| planForConfirm?.selectors?.extraArgs || planForConfirm?.selectors?.extraArgs
@@ -23800,7 +23831,7 @@ class PipelineService extends EventEmitter {
source: 'HANDBRAKE_SCAN_DVD', source: 'HANDBRAKE_SCAN_DVD',
silent: !this.isPrimaryJob(jobId) silent: !this.isPrimaryJob(jobId)
}); });
dvdScanLines.push(...pluginScan.scanLines); appendLinesInPlace(dvdScanLines, pluginScan.scanLines);
dvdScanRunInfo = pluginScan.runInfo || null; dvdScanRunInfo = pluginScan.runInfo || null;
reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution = this.mergePluginExecutionState(
reviewPluginExecution, reviewPluginExecution,
@@ -24548,7 +24579,7 @@ class PipelineService extends EventEmitter {
source: 'HANDBRAKE_SCAN_DVD_TITLES', source: 'HANDBRAKE_SCAN_DVD_TITLES',
silent: !this.isPrimaryJob(jobId) silent: !this.isPrimaryJob(jobId)
}); });
dvdTitleScanLines.push(...pluginScan.scanLines); appendLinesInPlace(dvdTitleScanLines, pluginScan.scanLines);
dvdTitleScanRunInfo = pluginScan.runInfo || null; dvdTitleScanRunInfo = pluginScan.runInfo || null;
reviewPluginExecution = this.mergePluginExecutionState( reviewPluginExecution = this.mergePluginExecutionState(
reviewPluginExecution, reviewPluginExecution,
+28 -2
View File
@@ -308,12 +308,38 @@ function removeSelectionArgs(extraArgs) {
const isAudioWithValue = AUDIO_SELECTION_KEYS_WITH_VALUE.has(key); const isAudioWithValue = AUDIO_SELECTION_KEYS_WITH_VALUE.has(key);
const isAudioFlagOnly = AUDIO_SELECTION_KEYS_FLAG_ONLY.has(key); const isAudioFlagOnly = AUDIO_SELECTION_KEYS_FLAG_ONLY.has(key);
const isSubtitleWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key) const isSubtitleSelectionWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key);
|| SUBTITLE_FLAG_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 isSubtitleFlagOnly = SUBTITLE_SELECTION_KEYS_FLAG_ONLY.has(key);
const isTitleWithValue = TITLE_SELECTION_KEYS_WITH_VALUE.has(key); const isTitleWithValue = TITLE_SELECTION_KEYS_WITH_VALUE.has(key);
const skip = isAudioWithValue || isAudioFlagOnly || isSubtitleWithValue || isSubtitleFlagOnly || isTitleWithValue; 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) { if (!skip) {
filtered.push(token); filtered.push(token);
continue; continue;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "1.0.0-rc2", "version": "1.0.0-rc3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "1.0.0-rc2", "version": "1.0.0-rc3",
"dependencies": { "dependencies": {
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "1.0.0-rc2", "version": "1.0.0-rc3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+6 -1
View File
@@ -449,6 +449,11 @@ function App() {
) )
) )
); );
const isUnknownRunningProgress = Boolean(
normalizedProgressJobId
&& incomingIsRunning
&& !hasKnownBinding
);
const allowRunningTransitionFromTerminal = Boolean( const allowRunningTransitionFromTerminal = Boolean(
normalizedProgressJobId normalizedProgressJobId
&& incomingIsRunning && incomingIsRunning
@@ -465,7 +470,7 @@ function App() {
&& ( && (
prevJobProgressIsTerminal prevJobProgressIsTerminal
|| matchingCdDriveIsTerminal || matchingCdDriveIsTerminal
|| !hasKnownBinding || isUnknownRunningProgress
|| regressesStableJobState || regressesStableJobState
) )
) { ) {
@@ -587,12 +587,37 @@ function removeSelectionArgs(extraArgs) {
const isAudioWithValue = AUDIO_SELECTION_KEYS_WITH_VALUE.has(key); const isAudioWithValue = AUDIO_SELECTION_KEYS_WITH_VALUE.has(key);
const isAudioFlagOnly = AUDIO_SELECTION_KEYS_FLAG_ONLY.has(key); const isAudioFlagOnly = AUDIO_SELECTION_KEYS_FLAG_ONLY.has(key);
const isSubtitleWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key) const isSubtitleSelectionWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key);
|| SUBTITLE_FLAG_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 isSubtitleFlagOnly = SUBTITLE_SELECTION_KEYS_FLAG_ONLY.has(key);
const isTitleWithValue = TITLE_SELECTION_KEYS_WITH_VALUE.has(key); const isTitleWithValue = TITLE_SELECTION_KEYS_WITH_VALUE.has(key);
const skip = isAudioWithValue || isAudioFlagOnly || isSubtitleWithValue || isSubtitleFlagOnly || isTitleWithValue; 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) { if (!skip) {
filtered.push(token); filtered.push(token);
continue; continue;
@@ -709,9 +734,10 @@ function buildHandBrakeCommandPreview({
subtitleLanguageOrder: selectedSubtitleLanguageOrder, subtitleLanguageOrder: selectedSubtitleLanguageOrder,
fallbackSelectedSubtitleTrackIds: normalizedSelectedSubtitleTrackIds, fallbackSelectedSubtitleTrackIds: normalizedSelectedSubtitleTrackIds,
fallbackSelectedSubtitleTrackIdsOrdered: normalizedSelectedSubtitleTrackIds, fallbackSelectedSubtitleTrackIdsOrdered: normalizedSelectedSubtitleTrackIds,
hasExplicitVariantSelection: normalizedSubtitleSelectionMode === 'track_ids' // Track-ID mode must use fallbackSelectedSubtitleTrackIds instead of forcing
? true // variant-explicit mode, otherwise preview can incorrectly collapse to "-s none".
: hasExplicitSelectedVariantSelection hasExplicitVariantSelection: normalizedSubtitleSelectionMode === 'variants'
&& hasExplicitSelectedVariantSelection
}); });
const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIds.length > 0 const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIds.length > 0
? resolvedSubtitleSelection.subtitleTrackIds ? resolvedSubtitleSelection.subtitleTrackIds
@@ -1894,6 +1920,17 @@ export default function MediaInfoReviewPanel({
const isUserPresetActive = Boolean(selectedUserPreset); const isUserPresetActive = Boolean(selectedUserPreset);
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim(); const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
const isHandBrakePresetActive = !isUserPresetActive && Boolean(trimmedHandBrakePreset); 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 const effectivePresetOverride = isUserPresetActive
? { handbrakePreset: selectedUserPreset.handbrakePreset || '', extraArgs: selectedUserPreset.extraArgs || '' } ? { handbrakePreset: selectedUserPreset.handbrakePreset || '', extraArgs: selectedUserPreset.extraArgs || '' }
: (isHandBrakePresetActive ? { handbrakePreset: trimmedHandBrakePreset, extraArgs: '' } : null); : (isHandBrakePresetActive ? { handbrakePreset: trimmedHandBrakePreset, extraArgs: '' } : null);
@@ -1940,7 +1977,7 @@ export default function MediaInfoReviewPanel({
const audioCopyMaskSourceLabel = String(effectiveAudioSelector?.copyMaskSource || '').trim() || 'default'; const audioCopyMaskSourceLabel = String(effectiveAudioSelector?.copyMaskSource || '').trim() || 'default';
const audioFallbackSourceLabel = String(effectiveAudioSelector?.fallbackSource || '').trim() || 'default'; const audioFallbackSourceLabel = String(effectiveAudioSelector?.fallbackSource || '').trim() || 'default';
const presetDropdownOptions = (() => { const presetDropdownOptions = (() => {
const options = []; const options = [{ label: 'Kein Preset', value: 'none' }];
if (hasUserPresets) { if (hasUserPresets) {
options.push({ label: 'User-Presets/', value: '__group__userpresets', disabled: true }); options.push({ label: 'User-Presets/', value: '__group__userpresets', disabled: true });
for (const preset of normalizedUserPresets) { for (const preset of normalizedUserPresets) {
@@ -1990,7 +2027,7 @@ export default function MediaInfoReviewPanel({
})(); })();
const selectedPresetToken = isUserPresetActive const selectedPresetToken = isUserPresetActive
? `user:${selectedUserPreset.id}` ? `user:${selectedUserPreset.id}`
: (trimmedHandBrakePreset ? `hb:${trimmedHandBrakePreset}` : null); : (trimmedHandBrakePreset ? `hb:${trimmedHandBrakePreset}` : 'none');
const scriptCatalog = (Array.isArray(availableScripts) ? availableScripts : []) const scriptCatalog = (Array.isArray(availableScripts) ? availableScripts : [])
.map((item) => ({ .map((item) => ({
@@ -2221,6 +2258,15 @@ export default function MediaInfoReviewPanel({
if (typeof onHandBrakePresetChange === 'function') { if (typeof onHandBrakePresetChange === 'function') {
onHandBrakePresetChange(presetName); onHandBrakePresetChange(presetName);
} }
return;
}
if (value === 'none') {
if (typeof onUserPresetChange === 'function') {
onUserPresetChange(null);
}
if (typeof onHandBrakePresetChange === 'function') {
onHandBrakePresetChange('');
}
} }
}} }}
placeholder="Preset auswählen …" placeholder="Preset auswählen …"
@@ -2242,6 +2288,20 @@ export default function MediaInfoReviewPanel({
)} )}
</div> </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 ? ( {!compactTitleSelection && !isTitleSelectionMode ? (
<div className="media-review-meta"> <div className="media-review-meta">
<div> <div>
@@ -2342,8 +2342,11 @@ export default function PipelineStatusCard({
const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle; const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle;
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim(); const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
const settingsPresetFallback = String(mediaInfoReview?.selectors?.preset || '').trim(); const settingsPresetFallback = String(mediaInfoReview?.selectors?.preset || '').trim();
const settingsExtraArgsFallback = String(mediaInfoReview?.selectors?.extraArgs || '').trim();
const hasSelectedPreset = Boolean(selectedUserPresetId) || Boolean(trimmedHandBrakePreset); 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 converterMediaType = String(mediaInfoReview?.converterMediaType || 'video').trim().toLowerCase() || 'video';
const showConverterConfig = jobMediaProfile === 'converter' && converterMediaType !== 'audio'; const showConverterConfig = jobMediaProfile === 'converter' && converterMediaType !== 'audio';
const requiresPresetSelection = showConverterConfig || jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray'; const requiresPresetSelection = showConverterConfig || jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray';
@@ -3856,12 +3859,12 @@ export default function PipelineStatusCard({
selectedPostEncodeChainIds: selectedPostChainIds, selectedPostEncodeChainIds: selectedPostChainIds,
selectedPreEncodeChainIds: selectedPreChainIds selectedPreEncodeChainIds: selectedPreChainIds
}; };
if (selectedUserPresetId !== null && selectedUserPresetId !== undefined) { startPayload.selectedUserPresetId = (
startPayload.selectedUserPresetId = selectedUserPresetId; selectedUserPresetId !== null && selectedUserPresetId !== undefined
} ? selectedUserPresetId
if (String(selectedHandBrakePreset || '').trim()) { : null
);
startPayload.selectedHandBrakePreset = String(selectedHandBrakePreset || '').trim(); startPayload.selectedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
}
await onStart(retryJobId, startPayload); await onStart(retryJobId, startPayload);
}} }}
loading={busy} loading={busy}
+218 -13
View File
@@ -97,6 +97,10 @@ const HISTORY_WINDOWS = [
{ label: '4d', hours: 96 } { label: '4d', hours: 96 }
]; ];
const HISTORY_STACK_BREAKPOINT = 1700; 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 CPU_SERIES_COLOR = '#c43d2f';
const GPU_SERIES_COLOR = '#c9961a'; const GPU_SERIES_COLOR = '#c9961a';
@@ -113,6 +117,15 @@ const historyChartOptions = {
plugins: { plugins: {
legend: { legend: {
display: false 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: { scales: {
@@ -120,7 +133,17 @@ const historyChartOptions = {
ticks: { ticks: {
autoSkip: true, autoSkip: true,
maxTicksLimit: 8, 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: { grid: {
color: 'rgba(111,57,34,0.08)' 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')}`; 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) { function buildSeriesToggleButtonStyle(color, active) {
if (active) { if (active) {
return { return {
@@ -258,6 +457,10 @@ export default function HardwarePage({ hardwareMonitoring }) {
const ramTitle = String(memory?.name || memory?.vendor || memory?.model || '').trim() || 'Arbeitsspeicher'; const ramTitle = String(memory?.name || memory?.vendor || memory?.model || '').trim() || 'Arbeitsspeicher';
const gpuTitle = String(primaryGpu?.name || gpu?.name || gpu?.vendor || '').trim() || 'GPU'; const gpuTitle = String(primaryGpu?.name || gpu?.name || gpu?.vendor || '').trim() || 'GPU';
const historyPoints = Array.isArray(historyState.points) ? historyState.points : []; const historyPoints = Array.isArray(historyState.points) ? historyState.points : [];
const resampledHistoryPoints = useMemo(
() => buildResampledHistoryPoints(historyPoints, historyHours),
[historyPoints, historyHours]
);
useEffect(() => { useEffect(() => {
if (!monitoringState.enabled) { if (!monitoringState.enabled) {
@@ -338,12 +541,12 @@ export default function HardwarePage({ hardwareMonitoring }) {
}; };
}, []); }, []);
const historyUsageChartData = useMemo(() => ({ const historyUsageChartData = useMemo(() => ({
labels: historyPoints.map((point) => formatTickLabel(point?.capturedAt)), labels: resampledHistoryPoints.map((point) => String(point?.capturedAt || '').trim()),
datasets: [ datasets: [
{ {
label: 'CPU', label: 'CPU',
data: historyPoints.map((point) => toNumberOrNull(point?.cpuUsagePercent)), data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.cpuUsagePercent)),
borderColor: CPU_SERIES_COLOR, borderColor: CPU_SERIES_COLOR,
backgroundColor: CPU_SERIES_COLOR, backgroundColor: CPU_SERIES_COLOR,
borderWidth: 2, borderWidth: 2,
@@ -353,7 +556,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
}, },
{ {
label: 'RAM', label: 'RAM',
data: historyPoints.map((point) => toNumberOrNull(point?.ramUsagePercent)), data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.ramUsagePercent)),
borderColor: RAM_SERIES_COLOR, borderColor: RAM_SERIES_COLOR,
backgroundColor: RAM_SERIES_COLOR, backgroundColor: RAM_SERIES_COLOR,
borderWidth: 2, borderWidth: 2,
@@ -363,7 +566,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
}, },
{ {
label: 'GPU', label: 'GPU',
data: historyPoints.map((point) => toNumberOrNull(point?.gpuUsagePercent)), data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.gpuUsagePercent)),
borderColor: GPU_SERIES_COLOR, borderColor: GPU_SERIES_COLOR,
backgroundColor: GPU_SERIES_COLOR, backgroundColor: GPU_SERIES_COLOR,
borderWidth: 2, borderWidth: 2,
@@ -372,14 +575,14 @@ export default function HardwarePage({ hardwareMonitoring }) {
tension: 0.22 tension: 0.22
} }
] ]
}), [historyPoints, usageSeriesVisible]); }), [resampledHistoryPoints, usageSeriesVisible]);
const historyTempChartData = useMemo(() => ({ const historyTempChartData = useMemo(() => ({
labels: historyPoints.map((point) => formatTickLabel(point?.capturedAt)), labels: resampledHistoryPoints.map((point) => String(point?.capturedAt || '').trim()),
datasets: [ datasets: [
{ {
label: 'CPU', label: 'CPU',
data: historyPoints.map((point) => toNumberOrNull(point?.cpuTemperatureC)), data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.cpuTemperatureC)),
borderColor: CPU_SERIES_COLOR, borderColor: CPU_SERIES_COLOR,
backgroundColor: CPU_SERIES_COLOR, backgroundColor: CPU_SERIES_COLOR,
borderWidth: 2, borderWidth: 2,
@@ -389,7 +592,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
}, },
{ {
label: 'GPU', label: 'GPU',
data: historyPoints.map((point) => toNumberOrNull(point?.gpuTemperatureC)), data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.gpuTemperatureC)),
borderColor: GPU_SERIES_COLOR, borderColor: GPU_SERIES_COLOR,
backgroundColor: GPU_SERIES_COLOR, backgroundColor: GPU_SERIES_COLOR,
borderWidth: 2, borderWidth: 2,
@@ -398,7 +601,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
tension: 0.22 tension: 0.22
} }
] ]
}), [historyPoints, tempSeriesVisible]); }), [resampledHistoryPoints, tempSeriesVisible]);
return ( return (
<div className="hardware-detail-page"> <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.error ? <small className="error-text">{historyState.error}</small> : null}
{historyState.loading && historyPoints.length === 0 ? ( {historyState.loading && historyPoints.length === 0 ? (
<p>Lade Verlauf ...</p> <p>Lade Verlauf ...</p>
) : historyPoints.length === 0 ? ( ) : resampledHistoryPoints.length === 0 ? (
<p>Noch keine Verlaufsdaten vorhanden.</p> <p>Noch keine Verlaufsdaten vorhanden.</p>
) : ( ) : (
<div className="hardware-history-grid"> <div className="hardware-history-grid">
@@ -502,6 +705,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
type="line" type="line"
data={historyUsageChartData} data={historyUsageChartData}
options={historyChartOptions} options={historyChartOptions}
plugins={[historyDayBoundaryPlugin]}
/> />
</div> </div>
<ChartLegendRow <ChartLegendRow
@@ -539,6 +743,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
type="line" type="line"
data={historyTempChartData} data={historyTempChartData}
options={historyChartOptions} options={historyChartOptions}
plugins={[historyDayBoundaryPlugin]}
/> />
</div> </div>
<ChartLegendRow <ChartLegendRow
+14 -9
View File
@@ -1476,22 +1476,27 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
: null; : null;
const liveState = String(liveJobProgress?.state || '').trim().toUpperCase(); const liveState = String(liveJobProgress?.state || '').trim().toUpperCase();
const isTerminalJobStatus = jobStatus === 'FINISHED' || jobStatus === 'ERROR' || jobStatus === 'CANCELLED'; const isTerminalJobStatus = jobStatus === 'FINISHED' || jobStatus === 'ERROR' || jobStatus === 'CANCELLED';
const ignoreStaleLiveProgress = isTerminalJobStatus && processingStates.includes(liveState); const jobStatusIsRunning = processingStates.includes(jobStatus);
const mergedContext = liveContext const ignoreStaleLiveProgress = (
(isTerminalJobStatus && processingStates.includes(liveState))
|| (!jobStatusIsRunning && jobStatus !== 'IDLE' && processingStates.includes(liveState))
);
const effectiveLiveContext = ignoreStaleLiveProgress ? null : liveContext;
const mergedContext = effectiveLiveContext
? { ? {
...computedContext, ...computedContext,
...liveContext, ...effectiveLiveContext,
// Prefer the DB plan when it has been confirmed but the live (jobProgress) plan hasn't // 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 // this happens when confirmEncodeReview is called with skipPipelineStateUpdate=true
// (always the case for queued jobs), leaving jobProgress with the pre-confirmation plan. // (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 ? computedContext.mediaInfoReview
: (liveContext.mediaInfoReview || computedContext.mediaInfoReview), : (effectiveLiveContext.mediaInfoReview || computedContext.mediaInfoReview),
tracks: (Array.isArray(liveContext.tracks) && liveContext.tracks.length > 0) tracks: (Array.isArray(effectiveLiveContext.tracks) && effectiveLiveContext.tracks.length > 0)
? liveContext.tracks ? effectiveLiveContext.tracks
: computedContext.tracks, : computedContext.tracks,
selectedMetadata: computedContext.selectedMetadata || liveContext.selectedMetadata, selectedMetadata: computedContext.selectedMetadata || effectiveLiveContext.selectedMetadata,
cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig cdRipConfig: effectiveLiveContext.cdRipConfig || computedContext.cdRipConfig
} }
: computedContext; : computedContext;
// The card always represents `job.id`; never let stale live context override it. // The card always represents `job.id`; never let stale live context override it.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster", "name": "ripster",
"version": "1.0.0-rc2", "version": "1.0.0-rc3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster", "name": "ripster",
"version": "1.0.0-rc2", "version": "1.0.0-rc3",
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ripster", "name": "ripster",
"private": true, "private": true,
"version": "1.0.0-rc2", "version": "1.0.0-rc3",
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend", "dev:backend": "npm run dev --prefix backend",