0.10.2-13 DVD Media Info Fix
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.2-12",
|
"version": "0.10.2-13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.2-12",
|
"version": "0.10.2-13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.2-12",
|
"version": "0.10.2-13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -8154,8 +8154,10 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
const mediaInfoByPath = {};
|
const mediaInfoByPath = {};
|
||||||
const mediaInfoRuns = [];
|
const mediaInfoRuns = [];
|
||||||
|
let effectiveMediaFiles = mediaFiles;
|
||||||
|
|
||||||
const buildReviewSnapshot = (processedCount) => {
|
const buildReviewSnapshot = (processedCount) => {
|
||||||
const processedFiles = mediaFiles
|
const processedFiles = effectiveMediaFiles
|
||||||
.slice(0, processedCount)
|
.slice(0, processedCount)
|
||||||
.filter((item) => Boolean(mediaInfoByPath[item.path]));
|
.filter((item) => Boolean(mediaInfoByPath[item.path]));
|
||||||
|
|
||||||
@@ -8178,73 +8180,95 @@ class PipelineService extends EventEmitter {
|
|||||||
mediaProfile,
|
mediaProfile,
|
||||||
sourceJobId: options.sourceJobId || null,
|
sourceJobId: options.sourceJobId || null,
|
||||||
reviewConfirmed: false,
|
reviewConfirmed: false,
|
||||||
partial: processedFiles.length < mediaFiles.length,
|
partial: processedFiles.length < effectiveMediaFiles.length,
|
||||||
processedFiles: processedFiles.length,
|
processedFiles: processedFiles.length,
|
||||||
totalFiles: mediaFiles.length
|
totalFiles: effectiveMediaFiles.length
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < mediaFiles.length; i += 1) {
|
// For DVD sources, use HandBrake scan directly instead of mediainfo (mediainfo
|
||||||
const file = mediaFiles[i];
|
// is unreliable on VOB files and often returns incomplete track information).
|
||||||
const percent = Number((((i + 1) / mediaFiles.length) * 100).toFixed(2));
|
const isDvdSource = rawMedia.source === 'dvd'
|
||||||
await this.updateProgress('MEDIAINFO_CHECK', percent, null, `Mediainfo ${i + 1}/${mediaFiles.length}: ${path.basename(file.path)}`, jobId);
|
|| rawMedia.source === 'dvd_image'
|
||||||
|
|| rawMedia.source === 'single_extensionless';
|
||||||
|
let dvdHandBrakeScanJson = null;
|
||||||
|
let dvdHandBrakeScanInputPath = null;
|
||||||
|
|
||||||
const result = await this.runMediainfoForFile(jobId, file.path, {
|
if (isDvdSource && mediaFiles.length > 0) {
|
||||||
|
// For 'dvd': scan the folder that contains VIDEO_TS (parent of the VIDEO_TS dir).
|
||||||
|
// For image/extensionless: scan the file directly.
|
||||||
|
dvdHandBrakeScanInputPath = rawMedia.source === 'dvd'
|
||||||
|
? path.dirname(path.dirname(mediaFiles[0].path))
|
||||||
|
: mediaFiles[0].path;
|
||||||
|
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`DVD-Analyse via HandBrake Scan: ${path.basename(dvdHandBrakeScanInputPath)}`
|
||||||
|
);
|
||||||
|
await this.updateProgress('MEDIAINFO_CHECK', 10, null, 'HandBrake DVD-Scan läuft', jobId);
|
||||||
|
|
||||||
|
const dvdScanLines = [];
|
||||||
|
const dvdScanConfig = await settingsService.buildHandBrakeScanConfigForInput(dvdHandBrakeScanInputPath, {
|
||||||
mediaProfile,
|
mediaProfile,
|
||||||
settingsMap: settings
|
settingsMap: settings
|
||||||
});
|
});
|
||||||
let parsedMediaInfo = result.parsed;
|
logger.info('mediainfo:review:dvd-handbrake-scan:command', {
|
||||||
let fallbackRunInfo = null;
|
jobId,
|
||||||
if (shouldRunDvdTrackFallback(parsedMediaInfo, mediaProfile, file.path)) {
|
dvdHandBrakeScanInputPath,
|
||||||
try {
|
cmd: dvdScanConfig.cmd,
|
||||||
const fallback = await this.runDvdTrackFallbackForFile(jobId, file.path, {
|
args: dvdScanConfig.args
|
||||||
mediaProfile,
|
|
||||||
settingsMap: settings
|
|
||||||
});
|
|
||||||
if (fallback?.parsedMediaInfo) {
|
|
||||||
parsedMediaInfo = fallback.parsedMediaInfo;
|
|
||||||
fallbackRunInfo = fallback.runInfo || null;
|
|
||||||
const audioCount = Array.isArray(fallback?.titleInfo?.audioTracks)
|
|
||||||
? fallback.titleInfo.audioTracks.length
|
|
||||||
: 0;
|
|
||||||
const subtitleCount = Array.isArray(fallback?.titleInfo?.subtitleTracks)
|
|
||||||
? fallback.titleInfo.subtitleTracks.length
|
|
||||||
: 0;
|
|
||||||
await historyService.appendLog(
|
|
||||||
jobId,
|
|
||||||
'SYSTEM',
|
|
||||||
`DVD Track-Fallback aktiv (${path.basename(file.path)}): Audio=${audioCount}, Subtitle=${subtitleCount}.`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await historyService.appendLog(
|
|
||||||
jobId,
|
|
||||||
'SYSTEM',
|
|
||||||
`DVD Track-Fallback ohne Ergebnis (${path.basename(file.path)}).`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('mediainfo:track-fallback:failed', {
|
|
||||||
jobId,
|
|
||||||
inputPath: file.path,
|
|
||||||
error: errorToMeta(error)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaInfoByPath[file.path] = parsedMediaInfo;
|
|
||||||
mediaInfoRuns.push({
|
|
||||||
filePath: file.path,
|
|
||||||
runInfo: result.runInfo,
|
|
||||||
fallbackRunInfo
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const partialReview = buildReviewSnapshot(i + 1);
|
const dvdScanRunInfo = await this.runCommand({
|
||||||
|
jobId,
|
||||||
|
stage: 'MEDIAINFO_CHECK',
|
||||||
|
source: 'HANDBRAKE_SCAN_DVD',
|
||||||
|
cmd: dvdScanConfig.cmd,
|
||||||
|
args: dvdScanConfig.args,
|
||||||
|
collectLines: dvdScanLines,
|
||||||
|
collectStderrLines: false
|
||||||
|
});
|
||||||
|
|
||||||
|
dvdHandBrakeScanJson = parseMediainfoJsonOutput(dvdScanLines.join('\n'));
|
||||||
|
if (dvdHandBrakeScanJson) {
|
||||||
|
const titleInfo = parseHandBrakeSelectedTitleInfo(dvdHandBrakeScanJson);
|
||||||
|
if (titleInfo) {
|
||||||
|
const syntheticMediaInfo = buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo);
|
||||||
|
mediaInfoByPath[dvdHandBrakeScanInputPath] = syntheticMediaInfo;
|
||||||
|
effectiveMediaFiles = [{ path: dvdHandBrakeScanInputPath, size: 0 }];
|
||||||
|
const audioCount = Array.isArray(titleInfo.audioTracks) ? titleInfo.audioTracks.length : 0;
|
||||||
|
const subtitleCount = Array.isArray(titleInfo.subtitleTracks) ? titleInfo.subtitleTracks.length : 0;
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`HandBrake DVD-Scan: Audio=${audioCount}, Subtitle=${subtitleCount}, Dauer=${formatDurationClock(titleInfo.durationSeconds)}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
'HandBrake DVD-Scan: Kein Titel erkannt, falle auf MediaInfo zurück.'
|
||||||
|
);
|
||||||
|
dvdHandBrakeScanJson = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
'HandBrake DVD-Scan: Ausgabe nicht lesbar, falle auf MediaInfo zurück.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaInfoRuns.push({ filePath: dvdHandBrakeScanInputPath, runInfo: dvdScanRunInfo, fallbackRunInfo: null });
|
||||||
|
|
||||||
|
const partialReview = buildReviewSnapshot(1);
|
||||||
if (this.isPrimaryJob(jobId)) {
|
if (this.isPrimaryJob(jobId)) {
|
||||||
await this.setState('MEDIAINFO_CHECK', {
|
await this.setState('MEDIAINFO_CHECK', {
|
||||||
activeJobId: jobId,
|
activeJobId: jobId,
|
||||||
progress: percent,
|
progress: 50,
|
||||||
eta: null,
|
eta: null,
|
||||||
statusText: `Mediainfo ${i + 1}/${mediaFiles.length} analysiert: ${path.basename(file.path)}`,
|
statusText: `HandBrake DVD-Scan abgeschlossen: ${path.basename(dvdHandBrakeScanInputPath)}`,
|
||||||
context: {
|
context: {
|
||||||
jobId,
|
jobId,
|
||||||
rawPath,
|
rawPath,
|
||||||
@@ -8261,8 +8285,88 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For non-DVD sources (or when HandBrake scan failed), run mediainfo on each file.
|
||||||
|
if (!isDvdSource || !dvdHandBrakeScanJson) {
|
||||||
|
for (let i = 0; i < mediaFiles.length; i += 1) {
|
||||||
|
const file = mediaFiles[i];
|
||||||
|
const percent = Number((((i + 1) / mediaFiles.length) * 100).toFixed(2));
|
||||||
|
await this.updateProgress('MEDIAINFO_CHECK', percent, null, `Mediainfo ${i + 1}/${mediaFiles.length}: ${path.basename(file.path)}`, jobId);
|
||||||
|
|
||||||
|
const result = await this.runMediainfoForFile(jobId, file.path, {
|
||||||
|
mediaProfile,
|
||||||
|
settingsMap: settings
|
||||||
|
});
|
||||||
|
let parsedMediaInfo = result.parsed;
|
||||||
|
let fallbackRunInfo = null;
|
||||||
|
if (shouldRunDvdTrackFallback(parsedMediaInfo, mediaProfile, file.path)) {
|
||||||
|
try {
|
||||||
|
const fallback = await this.runDvdTrackFallbackForFile(jobId, file.path, {
|
||||||
|
mediaProfile,
|
||||||
|
settingsMap: settings
|
||||||
|
});
|
||||||
|
if (fallback?.parsedMediaInfo) {
|
||||||
|
parsedMediaInfo = fallback.parsedMediaInfo;
|
||||||
|
fallbackRunInfo = fallback.runInfo || null;
|
||||||
|
const audioCount = Array.isArray(fallback?.titleInfo?.audioTracks)
|
||||||
|
? fallback.titleInfo.audioTracks.length
|
||||||
|
: 0;
|
||||||
|
const subtitleCount = Array.isArray(fallback?.titleInfo?.subtitleTracks)
|
||||||
|
? fallback.titleInfo.subtitleTracks.length
|
||||||
|
: 0;
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`DVD Track-Fallback aktiv (${path.basename(file.path)}): Audio=${audioCount}, Subtitle=${subtitleCount}.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await historyService.appendLog(
|
||||||
|
jobId,
|
||||||
|
'SYSTEM',
|
||||||
|
`DVD Track-Fallback ohne Ergebnis (${path.basename(file.path)}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('mediainfo:track-fallback:failed', {
|
||||||
|
jobId,
|
||||||
|
inputPath: file.path,
|
||||||
|
error: errorToMeta(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaInfoByPath[file.path] = parsedMediaInfo;
|
||||||
|
mediaInfoRuns.push({
|
||||||
|
filePath: file.path,
|
||||||
|
runInfo: result.runInfo,
|
||||||
|
fallbackRunInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
const partialReview = buildReviewSnapshot(i + 1);
|
||||||
|
if (this.isPrimaryJob(jobId)) {
|
||||||
|
await this.setState('MEDIAINFO_CHECK', {
|
||||||
|
activeJobId: jobId,
|
||||||
|
progress: percent,
|
||||||
|
eta: null,
|
||||||
|
statusText: `Mediainfo ${i + 1}/${mediaFiles.length} analysiert: ${path.basename(file.path)}`,
|
||||||
|
context: {
|
||||||
|
jobId,
|
||||||
|
rawPath,
|
||||||
|
inputPath: partialReview?.encodeInputPath || null,
|
||||||
|
hasEncodableTitle: Boolean(partialReview?.encodeInputPath),
|
||||||
|
reviewConfirmed: false,
|
||||||
|
mode: options.mode || 'rip',
|
||||||
|
mediaProfile,
|
||||||
|
sourceJobId: options.sourceJobId || null,
|
||||||
|
mediaInfoReview: partialReview,
|
||||||
|
selectedMetadata
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const review = buildMediainfoReview({
|
const review = buildMediainfoReview({
|
||||||
mediaFiles,
|
mediaFiles: effectiveMediaFiles,
|
||||||
mediaInfoByPath,
|
mediaInfoByPath,
|
||||||
settings,
|
settings,
|
||||||
presetProfile,
|
presetProfile,
|
||||||
@@ -8279,8 +8383,8 @@ class PipelineService extends EventEmitter {
|
|||||||
sourceJobId: options.sourceJobId || null,
|
sourceJobId: options.sourceJobId || null,
|
||||||
reviewConfirmed: false,
|
reviewConfirmed: false,
|
||||||
partial: false,
|
partial: false,
|
||||||
processedFiles: mediaFiles.length,
|
processedFiles: effectiveMediaFiles.length,
|
||||||
totalFiles: mediaFiles.length
|
totalFiles: effectiveMediaFiles.length
|
||||||
};
|
};
|
||||||
const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan(
|
const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan(
|
||||||
enrichedReview,
|
enrichedReview,
|
||||||
@@ -8295,7 +8399,10 @@ class PipelineService extends EventEmitter {
|
|||||||
|| rawMedia.source === 'dvd_image'
|
|| rawMedia.source === 'dvd_image'
|
||||||
|| rawMedia.source === 'single_extensionless';
|
|| rawMedia.source === 'single_extensionless';
|
||||||
if (needsHandBrakeTitleScan) {
|
if (needsHandBrakeTitleScan) {
|
||||||
const dvdScanInputPath = rawMedia.source === 'dvd' ? rawPath : mediaFiles[0].path;
|
// Use the same path that was used for the DVD HandBrake scan above, or fall back
|
||||||
|
// to the existing logic for cases where the scan was not yet performed.
|
||||||
|
const dvdScanInputPath = dvdHandBrakeScanInputPath
|
||||||
|
|| (rawMedia.source === 'dvd' ? rawPath : mediaFiles[0].path);
|
||||||
const preSelectedHandBrakeTitleId = normalizeReviewTitleId(options?.selectedHandBrakeTitleId);
|
const preSelectedHandBrakeTitleId = normalizeReviewTitleId(options?.selectedHandBrakeTitleId);
|
||||||
if (preSelectedHandBrakeTitleId) {
|
if (preSelectedHandBrakeTitleId) {
|
||||||
enrichedReview = {
|
enrichedReview = {
|
||||||
@@ -8309,33 +8416,38 @@ class PipelineService extends EventEmitter {
|
|||||||
`HandBrake Titel-Auswahl (manuell) übernommen: -t ${preSelectedHandBrakeTitleId}`
|
`HandBrake Titel-Auswahl (manuell) übernommen: -t ${preSelectedHandBrakeTitleId}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await historyService.appendLog(
|
// Reuse the HandBrake scan result from the DVD analysis step when available,
|
||||||
jobId,
|
// otherwise run a fresh scan for title selection.
|
||||||
'SYSTEM',
|
let dvdTitleScanJson = dvdHandBrakeScanJson || null;
|
||||||
'HandBrake Titel-Scan für DVD-Struktur wird gestartet...'
|
if (!dvdTitleScanJson) {
|
||||||
);
|
await historyService.appendLog(
|
||||||
await this.updateProgress('MEDIAINFO_CHECK', 100, null, 'HandBrake DVD Titel-Scan läuft', jobId);
|
jobId,
|
||||||
const dvdTitleScanLines = [];
|
'SYSTEM',
|
||||||
const dvdTitleScanConfig = await settingsService.buildHandBrakeScanConfigForInput(dvdScanInputPath, {
|
'HandBrake Titel-Scan für DVD-Struktur wird gestartet...'
|
||||||
mediaProfile,
|
);
|
||||||
settingsMap: settings
|
await this.updateProgress('MEDIAINFO_CHECK', 100, null, 'HandBrake DVD Titel-Scan läuft', jobId);
|
||||||
});
|
const dvdTitleScanLines = [];
|
||||||
logger.info('mediainfo:review:dvd-title-scan:command', {
|
const dvdTitleScanConfig = await settingsService.buildHandBrakeScanConfigForInput(dvdScanInputPath, {
|
||||||
jobId,
|
mediaProfile,
|
||||||
cmd: dvdTitleScanConfig.cmd,
|
settingsMap: settings
|
||||||
args: dvdTitleScanConfig.args,
|
});
|
||||||
dvdScanInputPath
|
logger.info('mediainfo:review:dvd-title-scan:command', {
|
||||||
});
|
jobId,
|
||||||
await this.runCommand({
|
cmd: dvdTitleScanConfig.cmd,
|
||||||
jobId,
|
args: dvdTitleScanConfig.args,
|
||||||
stage: 'MEDIAINFO_CHECK',
|
dvdScanInputPath
|
||||||
source: 'HANDBRAKE_SCAN_DVD_TITLES',
|
});
|
||||||
cmd: dvdTitleScanConfig.cmd,
|
await this.runCommand({
|
||||||
args: dvdTitleScanConfig.args,
|
jobId,
|
||||||
collectLines: dvdTitleScanLines,
|
stage: 'MEDIAINFO_CHECK',
|
||||||
collectStderrLines: false
|
source: 'HANDBRAKE_SCAN_DVD_TITLES',
|
||||||
});
|
cmd: dvdTitleScanConfig.cmd,
|
||||||
const dvdTitleScanJson = parseMediainfoJsonOutput(dvdTitleScanLines.join('\n'));
|
args: dvdTitleScanConfig.args,
|
||||||
|
collectLines: dvdTitleScanLines,
|
||||||
|
collectStderrLines: false
|
||||||
|
});
|
||||||
|
dvdTitleScanJson = parseMediainfoJsonOutput(dvdTitleScanLines.join('\n'));
|
||||||
|
}
|
||||||
if (dvdTitleScanJson) {
|
if (dvdTitleScanJson) {
|
||||||
const allTitles = parseHandBrakeTitleList(dvdTitleScanJson);
|
const allTitles = parseHandBrakeTitleList(dvdTitleScanJson);
|
||||||
const minLengthMinutes = Number(settings?.makemkv_min_length_minutes || 0);
|
const minLengthMinutes = Number(settings?.makemkv_min_length_minutes || 0);
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.2-12",
|
"version": "0.10.2-13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.2-12",
|
"version": "0.10.2-13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.2-12",
|
"version": "0.10.2-13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.10.2-12",
|
"version": "0.10.2-13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.10.2-12",
|
"version": "0.10.2-13",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.10.2-12",
|
"version": "0.10.2-13",
|
||||||
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user