0.15.0-1 merger
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.15.0",
|
"version": "0.15.0-1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.15.0",
|
"version": "0.15.0-1",
|
||||||
"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.15.0",
|
"version": "0.15.0-1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const downloadService = require('./services/downloadService');
|
|||||||
const diskDetectionService = require('./services/diskDetectionService');
|
const diskDetectionService = require('./services/diskDetectionService');
|
||||||
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
||||||
const coverArtRecoveryService = require('./services/coverArtRecoveryService');
|
const coverArtRecoveryService = require('./services/coverArtRecoveryService');
|
||||||
|
const tempCleanupService = require('./services/tempCleanupService');
|
||||||
const logger = require('./services/logger').child('BOOT');
|
const logger = require('./services/logger').child('BOOT');
|
||||||
const { errorToMeta } = require('./utils/errorMeta');
|
const { errorToMeta } = require('./utils/errorMeta');
|
||||||
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
|
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
|
||||||
@@ -29,6 +30,7 @@ const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thum
|
|||||||
async function start() {
|
async function start() {
|
||||||
logger.info('backend:start:init');
|
logger.info('backend:start:init');
|
||||||
await initDatabase();
|
await initDatabase();
|
||||||
|
await tempCleanupService.init();
|
||||||
await pipelineService.init();
|
await pipelineService.init();
|
||||||
await converterScanService.startPolling();
|
await converterScanService.startPolling();
|
||||||
await cronService.init();
|
await cronService.init();
|
||||||
@@ -95,6 +97,7 @@ async function start() {
|
|||||||
coverArtRecoveryService.stop();
|
coverArtRecoveryService.stop();
|
||||||
hardwareMonitorService.stop();
|
hardwareMonitorService.stop();
|
||||||
cronService.stop();
|
cronService.stop();
|
||||||
|
tempCleanupService.stop();
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
logger.warn('backend:shutdown:completed');
|
logger.warn('backend:shutdown:completed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -922,6 +922,23 @@ function enrichJobRow(job, settings = null, options = {}) {
|
|||||||
const makemkvInfo = resolvedPaths.makemkvInfo;
|
const makemkvInfo = resolvedPaths.makemkvInfo;
|
||||||
const mediainfoInfo = resolvedPaths.mediainfoInfo;
|
const mediainfoInfo = resolvedPaths.mediainfoInfo;
|
||||||
const encodePlan = resolvedPaths.encodePlan;
|
const encodePlan = resolvedPaths.encodePlan;
|
||||||
|
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
|
||||||
|
? makemkvInfo.analyzeContext
|
||||||
|
: {};
|
||||||
|
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
||||||
|
? analyzeContext.selectedMetadata
|
||||||
|
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||||
|
? makemkvInfo.selectedMetadata
|
||||||
|
: {});
|
||||||
|
const resolvedPosterUrl = String(
|
||||||
|
job?.poster_url
|
||||||
|
|| selectedMetadata?.poster
|
||||||
|
|| selectedMetadata?.coverUrl
|
||||||
|
|| selectedMetadata?.posterUrl
|
||||||
|
|| encodePlan?.metadata?.poster
|
||||||
|
|| encodePlan?.metadata?.coverUrl
|
||||||
|
|| ''
|
||||||
|
).trim() || null;
|
||||||
const mediaType = resolvedPaths.mediaType;
|
const mediaType = resolvedPaths.mediaType;
|
||||||
const normalizedJobStatus = String(job?.status || '').trim().toUpperCase();
|
const normalizedJobStatus = String(job?.status || '').trim().toUpperCase();
|
||||||
const ripSuccessful = Number(job?.rip_successful || 0) === 1
|
const ripSuccessful = Number(job?.rip_successful || 0) === 1
|
||||||
@@ -953,6 +970,7 @@ function enrichJobRow(job, settings = null, options = {}) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...job,
|
...job,
|
||||||
|
poster_url: resolvedPosterUrl,
|
||||||
raw_path: resolvedPaths.effectiveRawPath,
|
raw_path: resolvedPaths.effectiveRawPath,
|
||||||
output_path: resolvedPaths.effectiveOutputPath,
|
output_path: resolvedPaths.effectiveOutputPath,
|
||||||
makemkvInfo,
|
makemkvInfo,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const diskDetectionService = require('./diskDetectionService');
|
|||||||
const notificationService = require('./notificationService');
|
const notificationService = require('./notificationService');
|
||||||
const logger = require('./logger').child('PIPELINE');
|
const logger = require('./logger').child('PIPELINE');
|
||||||
const { spawnTrackedProcess } = require('./processRunner');
|
const { spawnTrackedProcess } = require('./processRunner');
|
||||||
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
|
const { parseMakeMkvProgress, parseHandBrakeProgress, parseMkvmergeProgress } = require('../utils/progressParsers');
|
||||||
const { ensureDir, sanitizeFileName, sanitizeFileNameWithExtension, renderTemplate, findMediaFiles } = require('../utils/files');
|
const { ensureDir, sanitizeFileName, sanitizeFileNameWithExtension, renderTemplate, findMediaFiles } = require('../utils/files');
|
||||||
const { buildMediainfoReview } = require('../utils/encodePlan');
|
const { buildMediainfoReview } = require('../utils/encodePlan');
|
||||||
const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis');
|
const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis');
|
||||||
@@ -2077,6 +2077,25 @@ function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) {
|
|||||||
return path.join(movieDir, incompleteFolder, `${baseName}.${ext}`);
|
return path.join(movieDir, incompleteFolder, `${baseName}.${ext}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildMultipartMergeOutputPath(templateOutputPath, sourceItems = []) {
|
||||||
|
const normalizedTemplateOutputPath = String(templateOutputPath || '').trim();
|
||||||
|
if (!normalizedTemplateOutputPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateParsed = path.parse(normalizedTemplateOutputPath);
|
||||||
|
const fallbackDir = String(templateParsed.dir || '').trim() || process.cwd();
|
||||||
|
const sourceDirs = Array.from(new Set(
|
||||||
|
(Array.isArray(sourceItems) ? sourceItems : [])
|
||||||
|
.map((item) => String(path.dirname(String(item?.outputPath || '').trim()) || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
));
|
||||||
|
const preferredDir = sourceDirs[0] || fallbackDir;
|
||||||
|
const preferredExt = String(templateParsed.ext || '').trim() || '.mkv';
|
||||||
|
const preferredName = String(templateParsed.name || '').trim() || 'merged_output';
|
||||||
|
return path.join(preferredDir, `${preferredName}${preferredExt}`);
|
||||||
|
}
|
||||||
|
|
||||||
function ensureUniqueOutputPath(outputPath) {
|
function ensureUniqueOutputPath(outputPath) {
|
||||||
if (!fs.existsSync(outputPath)) {
|
if (!fs.existsSync(outputPath)) {
|
||||||
return outputPath;
|
return outputPath;
|
||||||
@@ -2383,9 +2402,11 @@ function extractProgressDetail(source, line) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function composeStatusText(stage, percent, detail) {
|
function composeStatusText(stage, percent, detail) {
|
||||||
|
const normalizedStage = String(stage || '').trim().toUpperCase();
|
||||||
|
const baseLabel = normalizedStage === 'ENCODING' ? 'Fortschritt' : stage;
|
||||||
const base = percent !== null && percent !== undefined
|
const base = percent !== null && percent !== undefined
|
||||||
? `${stage} ${percent.toFixed(2)}%`
|
? `${baseLabel} ${percent.toFixed(2)}%`
|
||||||
: stage;
|
: baseLabel;
|
||||||
|
|
||||||
if (detail) {
|
if (detail) {
|
||||||
return `${base} - ${detail}`;
|
return `${base} - ${detail}`;
|
||||||
@@ -11503,8 +11524,10 @@ class PipelineService extends EventEmitter {
|
|||||||
const settings = await settingsService.getEffectiveSettingsMap(
|
const settings = await settingsService.getEffectiveSettingsMap(
|
||||||
this.resolveMediaProfileForJob(containerJob, { mediaProfile: containerJob?.media_type || null })
|
this.resolveMediaProfileForJob(containerJob, { mediaProfile: containerJob?.media_type || null })
|
||||||
);
|
);
|
||||||
const templateOutputPath = buildFinalOutputPathFromJob(settings, containerJob, normalizedContainerJobId);
|
const templateOutputPath = withPathBaseNameSuffix(
|
||||||
const mergeOutputPath = withPathBaseNameSuffix(templateOutputPath, '_merged');
|
buildFinalOutputPathFromJob(settings, containerJob, normalizedContainerJobId),
|
||||||
|
'_merged'
|
||||||
|
);
|
||||||
const existingMergeJob = mergeJobs[0] || null;
|
const existingMergeJob = mergeJobs[0] || null;
|
||||||
const createIfMissing = options?.createIfMissing !== false;
|
const createIfMissing = options?.createIfMissing !== false;
|
||||||
const existingMergePlan = this.safeParseJson(existingMergeJob?.encode_plan_json) || {};
|
const existingMergePlan = this.safeParseJson(existingMergeJob?.encode_plan_json) || {};
|
||||||
@@ -11560,6 +11583,8 @@ class PipelineService extends EventEmitter {
|
|||||||
sourceCount: sourceItems.length
|
sourceCount: sourceItems.length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const mergeOutputPath = buildMultipartMergeOutputPath(templateOutputPath, sourceItems)
|
||||||
|
|| templateOutputPath;
|
||||||
|
|
||||||
const mergePlan = {
|
const mergePlan = {
|
||||||
mode: 'multipart_merge',
|
mode: 'multipart_merge',
|
||||||
@@ -11933,8 +11958,12 @@ class PipelineService extends EventEmitter {
|
|||||||
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||||||
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
||||||
const mkvmergeCommand = String(settings?.mkvmerge_command || 'mkvmerge').trim() || 'mkvmerge';
|
const mkvmergeCommand = String(settings?.mkvmerge_command || 'mkvmerge').trim() || 'mkvmerge';
|
||||||
const expectedOutputPath = String(mergePlan?.mergeOutputPath || '').trim()
|
const mergeOutputTemplatePath = String(mergePlan?.mergeOutputPath || '').trim()
|
||||||
|| buildFinalOutputPathFromJob(settings, mergeJob, normalizedJobId);
|
|| buildFinalOutputPathFromJob(settings, mergeJob, normalizedJobId);
|
||||||
|
const expectedOutputPath = buildMultipartMergeOutputPath(
|
||||||
|
mergeOutputTemplatePath,
|
||||||
|
sourceItems
|
||||||
|
) || mergeOutputTemplatePath;
|
||||||
const analysis = await this.analyzeMultipartMergeInputs(sourceItems, { ffprobeCommand });
|
const analysis = await this.analyzeMultipartMergeInputs(sourceItems, { ffprobeCommand });
|
||||||
const hasGeneratedChapterPlan = Boolean(analysis?.chapterPlan?.content);
|
const hasGeneratedChapterPlan = Boolean(analysis?.chapterPlan?.content);
|
||||||
|
|
||||||
@@ -12022,9 +12051,19 @@ class PipelineService extends EventEmitter {
|
|||||||
mediaProfile: mergePlan?.mediaProfile || mergeJob?.media_type || null
|
mediaProfile: mergePlan?.mediaProfile || mergeJob?.media_type || null
|
||||||
});
|
});
|
||||||
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||||||
const incompleteOutputPath = buildIncompleteOutputPathFromJob(settings, mergeJob, normalizedJobId);
|
const mkvmergeCommand = String(settings?.mkvmerge_command || 'mkvmerge').trim() || 'mkvmerge';
|
||||||
const preferredFinalOutputPath = String(mergePlan?.mergeOutputPath || '').trim()
|
await this.ensureExternalToolAvailable(mkvmergeCommand, { label: 'mkvmerge' });
|
||||||
|
const mergeOutputTemplatePath = String(mergePlan?.mergeOutputPath || '').trim()
|
||||||
|| buildFinalOutputPathFromJob(settings, mergeJob, normalizedJobId);
|
|| buildFinalOutputPathFromJob(settings, mergeJob, normalizedJobId);
|
||||||
|
const preferredFinalOutputPath = buildMultipartMergeOutputPath(
|
||||||
|
mergeOutputTemplatePath,
|
||||||
|
sourceItems
|
||||||
|
) || mergeOutputTemplatePath;
|
||||||
|
const incompleteOutputPath = path.join(
|
||||||
|
path.dirname(preferredFinalOutputPath),
|
||||||
|
`Incomplete_job-${normalizedJobId}`,
|
||||||
|
path.basename(preferredFinalOutputPath)
|
||||||
|
);
|
||||||
ensureDir(path.dirname(incompleteOutputPath));
|
ensureDir(path.dirname(incompleteOutputPath));
|
||||||
|
|
||||||
await this.setState('ENCODING', {
|
await this.setState('ENCODING', {
|
||||||
@@ -12053,7 +12092,6 @@ class PipelineService extends EventEmitter {
|
|||||||
`Merge gestartet. Quellen: ${sourceItems.map((item) => item.outputPath).join(' | ')}`
|
`Merge gestartet. Quellen: ${sourceItems.map((item) => item.outputPath).join(' | ')}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const mkvmergeCommand = String(settings?.mkvmerge_command || 'mkvmerge').trim() || 'mkvmerge';
|
|
||||||
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
||||||
const mergeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ripster-merge-'));
|
const mergeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ripster-merge-'));
|
||||||
let chaptersFilePath = null;
|
let chaptersFilePath = null;
|
||||||
@@ -12113,7 +12151,8 @@ class PipelineService extends EventEmitter {
|
|||||||
stage: 'ENCODING',
|
stage: 'ENCODING',
|
||||||
source: 'MKVMERGE',
|
source: 'MKVMERGE',
|
||||||
cmd: mkvmergeCommand,
|
cmd: mkvmergeCommand,
|
||||||
args: mkvmergeArgs
|
args: mkvmergeArgs,
|
||||||
|
parser: parseMkvmergeProgress
|
||||||
});
|
});
|
||||||
const outputFinalization = finalizeOutputPathForCompletedEncode(
|
const outputFinalization = finalizeOutputPathForCompletedEncode(
|
||||||
incompleteOutputPath,
|
incompleteOutputPath,
|
||||||
@@ -14783,6 +14822,33 @@ class PipelineService extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async ensureExternalToolAvailable(cmd, options = {}) {
|
||||||
|
const command = String(cmd || '').trim();
|
||||||
|
const label = String(options?.label || command || 'Tool').trim() || 'Tool';
|
||||||
|
const versionArgs = Array.isArray(options?.versionArgs) && options.versionArgs.length > 0
|
||||||
|
? options.versionArgs.map((item) => String(item))
|
||||||
|
: ['--version'];
|
||||||
|
if (!command) {
|
||||||
|
const error = new Error(`${label} ist nicht konfiguriert.`);
|
||||||
|
error.statusCode = 500;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.runCapturedCommand(command, versionArgs);
|
||||||
|
} catch (error) {
|
||||||
|
if (String(error?.code || '').trim().toUpperCase() === 'ENOENT') {
|
||||||
|
const resolvedError = new Error(
|
||||||
|
`${label} konnte nicht gestartet werden. Bitte das Kommando in den Settings prüfen oder ${label} auf dem Server installieren.`
|
||||||
|
);
|
||||||
|
resolvedError.statusCode = 500;
|
||||||
|
resolvedError.code = 'ENOENT';
|
||||||
|
throw resolvedError;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async ensureMakeMKVRegistration(jobId, stage) {
|
async ensureMakeMKVRegistration(jobId, stage) {
|
||||||
const registrationConfig = await settingsService.buildMakeMKVRegisterConfig();
|
const registrationConfig = await settingsService.buildMakeMKVRegisterConfig();
|
||||||
if (!registrationConfig) {
|
if (!registrationConfig) {
|
||||||
@@ -23944,6 +24010,7 @@ class PipelineService extends EventEmitter {
|
|||||||
['converter', 'cd', 'audiobook'].includes(mediaProfile) ? mediaProfile : null
|
['converter', 'cd', 'audiobook'].includes(mediaProfile) ? mediaProfile : null
|
||||||
);
|
);
|
||||||
const isCdRetry = mediaProfile === 'cd';
|
const isCdRetry = mediaProfile === 'cd';
|
||||||
|
const isAudiobookRetry = mediaProfile === 'audiobook';
|
||||||
|
|
||||||
// CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden.
|
// CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden.
|
||||||
const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus)
|
const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus)
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const logger = require('./logger').child('TEMP_CLEANUP');
|
||||||
|
|
||||||
|
const TMP_ROOT = os.tmpdir();
|
||||||
|
const SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
const STALE_ENTRY_MIN_AGE_MS = 12 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const TOP_LEVEL_PREFIXES = [
|
||||||
|
'ripster-merge-',
|
||||||
|
'ripster-script-',
|
||||||
|
'ripster-export-'
|
||||||
|
];
|
||||||
|
|
||||||
|
const MANAGED_UPLOAD_DIRS = [
|
||||||
|
'ripster-converter-uploads',
|
||||||
|
'ripster-audiobook-uploads'
|
||||||
|
];
|
||||||
|
|
||||||
|
function getEntryAgeMs(stats) {
|
||||||
|
const referenceTime = Math.max(
|
||||||
|
Number(stats?.mtimeMs) || 0,
|
||||||
|
Number(stats?.ctimeMs) || 0,
|
||||||
|
Number(stats?.birthtimeMs) || 0
|
||||||
|
);
|
||||||
|
return Date.now() - referenceTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStale(stats, minAgeMs = STALE_ENTRY_MIN_AGE_MS) {
|
||||||
|
return getEntryAgeMs(stats) >= minAgeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEntry(targetPath, stats) {
|
||||||
|
const isDirectory = stats?.isDirectory?.() || false;
|
||||||
|
fs.rmSync(targetPath, {
|
||||||
|
recursive: isDirectory,
|
||||||
|
force: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class TempCleanupService {
|
||||||
|
constructor() {
|
||||||
|
this.interval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.runSweep('startup');
|
||||||
|
|
||||||
|
if (!this.interval) {
|
||||||
|
this.interval = setInterval(() => {
|
||||||
|
this.runSweep('interval').catch((error) => {
|
||||||
|
logger.warn('temp:sweep:interval-failed', { error: error?.message || String(error) });
|
||||||
|
});
|
||||||
|
}, SWEEP_INTERVAL_MS);
|
||||||
|
this.interval.unref?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.interval) {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
this.interval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runSweep(reason = 'manual') {
|
||||||
|
const deleted = [];
|
||||||
|
const skipped = [];
|
||||||
|
const failed = [];
|
||||||
|
|
||||||
|
let topLevelEntries = [];
|
||||||
|
try {
|
||||||
|
topLevelEntries = fs.readdirSync(TMP_ROOT, { withFileTypes: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('temp:sweep:readdir-failed', {
|
||||||
|
reason,
|
||||||
|
tmpRoot: TMP_ROOT,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
return { deleted, skipped, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of topLevelEntries) {
|
||||||
|
const entryName = String(entry?.name || '').trim();
|
||||||
|
if (!entryName || !TOP_LEVEL_PREFIXES.some((prefix) => entryName.startsWith(prefix))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const targetPath = path.join(TMP_ROOT, entryName);
|
||||||
|
try {
|
||||||
|
const stats = fs.lstatSync(targetPath);
|
||||||
|
if (!isStale(stats)) {
|
||||||
|
skipped.push({ path: targetPath, reason: 'fresh-top-level-entry' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
removeEntry(targetPath, stats);
|
||||||
|
deleted.push(targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({
|
||||||
|
path: targetPath,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dirName of MANAGED_UPLOAD_DIRS) {
|
||||||
|
const uploadDir = path.join(TMP_ROOT, dirName);
|
||||||
|
if (!fs.existsSync(uploadDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uploadEntries = [];
|
||||||
|
try {
|
||||||
|
uploadEntries = fs.readdirSync(uploadDir, { withFileTypes: true });
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({
|
||||||
|
path: uploadDir,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of uploadEntries) {
|
||||||
|
const targetPath = path.join(uploadDir, entry.name);
|
||||||
|
try {
|
||||||
|
const stats = fs.lstatSync(targetPath);
|
||||||
|
if (!isStale(stats)) {
|
||||||
|
skipped.push({ path: targetPath, reason: 'fresh-upload-entry' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
removeEntry(targetPath, stats);
|
||||||
|
deleted.push(targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({
|
||||||
|
path: targetPath,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('temp:sweep:completed', {
|
||||||
|
reason,
|
||||||
|
tmpRoot: TMP_ROOT,
|
||||||
|
deletedCount: deleted.length,
|
||||||
|
skippedCount: skipped.length,
|
||||||
|
failedCount: failed.length,
|
||||||
|
deleted: deleted.slice(0, 50),
|
||||||
|
failed: failed.slice(0, 20)
|
||||||
|
});
|
||||||
|
|
||||||
|
return { deleted, skipped, failed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new TempCleanupService();
|
||||||
@@ -93,8 +93,34 @@ function parseCdParanoiaProgress(line) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMkvmergeProgress(line) {
|
||||||
|
const normalized = String(line || '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common mkvmerge output examples:
|
||||||
|
// "Progress: 42%"
|
||||||
|
// "#GUI#progress 42%"
|
||||||
|
const explicitMatch = normalized.match(/(?:^|\s)(?:progress:?|#GUI#progress)\s*(\d{1,3}(?:\.\d+)?)\s*%/i);
|
||||||
|
if (explicitMatch) {
|
||||||
|
return {
|
||||||
|
percent: clampPercent(Number(explicitMatch[1])),
|
||||||
|
eta: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = parseGenericPercent(normalized);
|
||||||
|
if (percent !== null) {
|
||||||
|
return { percent, eta: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
parseMakeMkvProgress,
|
parseMakeMkvProgress,
|
||||||
parseHandBrakeProgress,
|
parseHandBrakeProgress,
|
||||||
parseCdParanoiaProgress
|
parseCdParanoiaProgress,
|
||||||
|
parseMkvmergeProgress
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.15.0",
|
"version": "0.15.0-1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.15.0",
|
"version": "0.15.0-1",
|
||||||
"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.15.0",
|
"version": "0.15.0-1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -549,9 +549,10 @@ function resolveAudiobookDetails(job) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeMeta(status, queued = false) {
|
function statusBadgeMeta(status, queued = false, options = {}) {
|
||||||
const normalized = String(status || '').trim().toUpperCase();
|
const normalized = String(status || '').trim().toUpperCase();
|
||||||
const label = getStatusLabel(normalized, { queued });
|
const labelOverride = String(options?.label || '').trim();
|
||||||
|
const label = labelOverride || getStatusLabel(normalized, { queued });
|
||||||
if (queued) {
|
if (queued) {
|
||||||
return { label, icon: 'pi-list', tone: 'info' };
|
return { label, icon: 'pi-list', tone: 'info' };
|
||||||
}
|
}
|
||||||
@@ -585,9 +586,42 @@ function statusBadgeMeta(status, queued = false) {
|
|||||||
if (normalized === 'ENCODING') {
|
if (normalized === 'ENCODING') {
|
||||||
return { label, icon: 'pi-cog', tone: 'warning' };
|
return { label, icon: 'pi-cog', tone: 'warning' };
|
||||||
}
|
}
|
||||||
|
if (normalized === 'MERGING') {
|
||||||
|
return { label, icon: 'pi-spinner pi-spin', tone: 'warning' };
|
||||||
|
}
|
||||||
return { label: label || '-', icon: 'pi-info-circle', tone: 'secondary' };
|
return { label: label || '-', icon: 'pi-info-circle', tone: 'secondary' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePathForCompare(value) {
|
||||||
|
return String(value || '').trim().replace(/[\\/]+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMergeToolLogFallback(job = null) {
|
||||||
|
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object'
|
||||||
|
? job.handbrakeInfo
|
||||||
|
: {};
|
||||||
|
const lines = [];
|
||||||
|
const highlights = Array.isArray(handbrakeInfo?.highlights) ? handbrakeInfo.highlights : [];
|
||||||
|
for (const entry of highlights) {
|
||||||
|
const text = String(entry || '').trim();
|
||||||
|
if (text) {
|
||||||
|
lines.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stdoutTail = String(handbrakeInfo?.stdoutTail || '').trim();
|
||||||
|
if (stdoutTail) {
|
||||||
|
lines.push(stdoutTail);
|
||||||
|
}
|
||||||
|
const stderrTail = String(handbrakeInfo?.stderrTail || '').trim();
|
||||||
|
if (stderrTail) {
|
||||||
|
lines.push(stderrTail);
|
||||||
|
}
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function omdbField(value) {
|
function omdbField(value) {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
return raw || '-';
|
return raw || '-';
|
||||||
@@ -1185,16 +1219,20 @@ export default function JobDetailDialog({
|
|||||||
? discIndicatorIcon
|
? discIndicatorIcon
|
||||||
: otherIndicatorIcon;
|
: otherIndicatorIcon;
|
||||||
const mediaTypeAlt = mediaTypeLabel;
|
const mediaTypeAlt = mediaTypeLabel;
|
||||||
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
|
||||||
|
const isMultipartMergeJob = isMultipartMergeChildJob(job);
|
||||||
|
const statusMeta = statusBadgeMeta(
|
||||||
|
isMultipartMergeJob && statusUpper === 'ENCODING' ? 'MERGING' : job?.status,
|
||||||
|
queueLocked,
|
||||||
|
{ label: isMultipartMergeJob && statusUpper === 'ENCODING' ? 'Merging' : '' }
|
||||||
|
);
|
||||||
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||||
const metadataContext = resolveJobMetadataContext(job);
|
const metadataContext = resolveJobMetadataContext(job);
|
||||||
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
|
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
|
||||||
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
|
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
|
||||||
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
|
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
|
||||||
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
|
|
||||||
const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container';
|
const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container';
|
||||||
const isMultipartContainer = jobKindRaw === 'multipart_movie_container';
|
const isMultipartContainer = jobKindRaw === 'multipart_movie_container';
|
||||||
const isMultipartMergeJob = isMultipartMergeChildJob(job);
|
|
||||||
const isDiskContainer = isSeriesContainer || isMultipartContainer;
|
const isDiskContainer = isSeriesContainer || isMultipartContainer;
|
||||||
const allChildJobs = Array.isArray(job?.children)
|
const allChildJobs = Array.isArray(job?.children)
|
||||||
? [...job.children].sort(compareSeriesChildJobsByDisc)
|
? [...job.children].sort(compareSeriesChildJobsByDisc)
|
||||||
@@ -1332,11 +1370,28 @@ export default function JobDetailDialog({
|
|||||||
(title) => title.selectedForEncode || title.encodeInput || String(title.id) === String(reviewSelectedEncodeTitleId)
|
(title) => title.selectedForEncode || title.encodeInput || String(title.id) === String(reviewSelectedEncodeTitleId)
|
||||||
);
|
);
|
||||||
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
|
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
|
||||||
|
const posterUrl = String(
|
||||||
|
job?.poster_url
|
||||||
|
|| metadataContext?.selectedMetadata?.poster
|
||||||
|
|| metadataContext?.selectedMetadata?.coverUrl
|
||||||
|
|| metadataContext?.selectedMetadata?.posterUrl
|
||||||
|
|| job?.encodePlan?.metadata?.poster
|
||||||
|
|| ''
|
||||||
|
).trim() || null;
|
||||||
const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null;
|
const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null;
|
||||||
const canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
|
const canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
|
||||||
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
|
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
|
||||||
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
|
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
|
||||||
|
const mergeOutputPathCandidates = new Set(
|
||||||
|
[
|
||||||
|
...(isMultipartMergeJob ? [job?.output_path] : []),
|
||||||
|
...(isMultipartContainer ? mergeChildJobs.map((child) => child?.output_path) : [])
|
||||||
|
]
|
||||||
|
.map((value) => normalizePathForCompare(value))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
const hasAnyOutputFolder = outputFolders.length > 0 || Boolean(job?.outputStatus?.exists);
|
const hasAnyOutputFolder = outputFolders.length > 0 || Boolean(job?.outputStatus?.exists);
|
||||||
|
const mergeToolLogFallback = isMultipartMergeJob ? buildMergeToolLogFallback(job) : '';
|
||||||
const canShowGeneralEncodeActions = canResumeReady
|
const canShowGeneralEncodeActions = canResumeReady
|
||||||
|| typeof onRestartEncode === 'function'
|
|| typeof onRestartEncode === 'function'
|
||||||
|| typeof onRestartReview === 'function'
|
|| typeof onRestartReview === 'function'
|
||||||
@@ -1407,8 +1462,8 @@ export default function JobDetailDialog({
|
|||||||
{detailLoading ? <p>Details werden geladen ...</p> : null}
|
{detailLoading ? <p>Details werden geladen ...</p> : null}
|
||||||
|
|
||||||
<div className="job-head-row">
|
<div className="job-head-row">
|
||||||
{job.poster_url && job.poster_url !== 'N/A' ? (
|
{posterUrl && posterUrl !== 'N/A' ? (
|
||||||
<img src={job.poster_url} alt={job.title || 'Poster'} className={useAudioPosterLayout ? 'poster-large-audio' : 'poster-large'} />
|
<img src={posterUrl} alt={job.title || 'Poster'} className={useAudioPosterLayout ? 'poster-large-audio' : 'poster-large'} />
|
||||||
) : (
|
) : (
|
||||||
<div className={`${useAudioPosterLayout ? 'poster-large-audio' : 'poster-large'} poster-fallback`}>{useAudioPosterLayout ? 'Kein Cover' : 'Kein Poster'}</div>
|
<div className={`${useAudioPosterLayout ? 'poster-large-audio' : 'poster-large'} poster-fallback`}>{useAudioPosterLayout ? 'Kein Cover' : 'Kein Poster'}</div>
|
||||||
)}
|
)}
|
||||||
@@ -1712,7 +1767,7 @@ export default function JobDetailDialog({
|
|||||||
<div><strong>Start:</strong> {job.start_time || '-'}</div>
|
<div><strong>Start:</strong> {job.start_time || '-'}</div>
|
||||||
<div><strong>Ende:</strong> {job.end_time || '-'}</div>
|
<div><strong>Ende:</strong> {job.end_time || '-'}</div>
|
||||||
{/* Zeile 3+4: Pfade */}
|
{/* Zeile 3+4: Pfade */}
|
||||||
{!isDiskContainer ? (
|
{!isDiskContainer && !isMultipartMergeJob ? (
|
||||||
<PathField
|
<PathField
|
||||||
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
|
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
|
||||||
value={isCd ? (job.raw_path || job.output_path) : resolvedRawPath}
|
value={isCd ? (job.raw_path || job.output_path) : resolvedRawPath}
|
||||||
@@ -1745,7 +1800,13 @@ export default function JobDetailDialog({
|
|||||||
if (!folderPath) {
|
if (!folderPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const label = outputFolders.length > 1 ? `Output ${idx + 1}:` : 'Output:';
|
const normalizedFolderPath = normalizePathForCompare(folderPath);
|
||||||
|
const isMergeOutput = Boolean(
|
||||||
|
(normalizedFolderPath && mergeOutputPathCandidates.has(normalizedFolderPath))
|
||||||
|
|| /_merged\.[^/.]+$/i.test(folderPath)
|
||||||
|
);
|
||||||
|
const outputLabel = outputFolders.length > 1 ? `Output ${idx + 1}` : 'Output';
|
||||||
|
const label = isMergeOutput ? `${outputLabel} (Merge):` : `${outputLabel}:`;
|
||||||
const canDownloadFolder = Boolean(typeof onDownloadOutputFolder === 'function');
|
const canDownloadFolder = Boolean(typeof onDownloadOutputFolder === 'function');
|
||||||
return (
|
return (
|
||||||
<PathField
|
<PathField
|
||||||
@@ -1760,7 +1821,7 @@ export default function JobDetailDialog({
|
|||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<PathField
|
<PathField
|
||||||
label="Output:"
|
label={isMultipartMergeJob ? 'Output (Merge):' : 'Output:'}
|
||||||
value={job.output_path}
|
value={job.output_path}
|
||||||
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
|
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
|
||||||
downloadDisabled={!canDownloadOutput}
|
downloadDisabled={!canDownloadOutput}
|
||||||
@@ -2414,6 +2475,23 @@ export default function JobDetailDialog({
|
|||||||
<span className="series-batch-episode-title">{`Merge${child?.id ? ` #${child.id}` : ''}`}</span>
|
<span className="series-batch-episode-title">{`Merge${child?.id ? ` #${child.id}` : ''}`}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<p>Für Merge-Jobs wird nur das Merge-Tool-Log angezeigt.</p>
|
<p>Für Merge-Jobs wird nur das Merge-Tool-Log angezeigt.</p>
|
||||||
|
{typeof onDeleteEntry === 'function' ? (
|
||||||
|
<div className="actions-section">
|
||||||
|
<div className="action-item">
|
||||||
|
<Button
|
||||||
|
label="Merge-Job löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
size="small"
|
||||||
|
onClick={() => onDeleteEntry?.(child, { includeRelated: false })}
|
||||||
|
loading={deleteEntryBusy}
|
||||||
|
disabled={String(child?.status || '').trim().toUpperCase() === 'ENCODING'}
|
||||||
|
/>
|
||||||
|
<span className="action-desc">Entfernt nur den Merge-Job. Ob die Merge-Datei ebenfalls gelöscht wird, kann im nächsten Schritt gewählt werden.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</details>
|
</details>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -2523,15 +2601,17 @@ export default function JobDetailDialog({
|
|||||||
<summary className="series-batch-episode-head">
|
<summary className="series-batch-episode-head">
|
||||||
<span className="series-batch-episode-title">{`Merge${child?.id ? ` #${child.id}` : ''}`}</span>
|
<span className="series-batch-episode-title">{`Merge${child?.id ? ` #${child.id}` : ''}`}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<pre>{child.log || ''}</pre>
|
<pre>{child.log || buildMergeToolLogFallback(child) || ''}</pre>
|
||||||
</details>
|
</details>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<pre className="log-box">{job.log || ''}</pre>
|
<pre className="log-box">{job.log || (isMultipartMergeJob ? mergeToolLogFallback : '') || ''}</pre>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<p>Log nicht vorgeladen. Über die Buttons oben laden.</p>
|
isMultipartMergeJob && mergeToolLogFallback
|
||||||
|
? <pre className="log-box">{mergeToolLogFallback}</pre>
|
||||||
|
: <p>Log nicht vorgeladen. Über die Buttons oben laden.</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -2764,14 +2844,18 @@ export default function JobDetailDialog({
|
|||||||
|
|
||||||
{!queueLocked ? (
|
{!queueLocked ? (
|
||||||
<div className="action-delete-entry">
|
<div className="action-delete-entry">
|
||||||
<span className="action-desc">Entfernt diesen Eintrag aus der Verlaufsliste — Dateien auf der Festplatte bleiben erhalten.</span>
|
<span className="action-desc">
|
||||||
|
{isMultipartMergeJob
|
||||||
|
? 'Entfernt diesen Merge-Job aus der Verlaufsliste. Im nächsten Schritt kann gewählt werden, ob die Merge-Datei ebenfalls gelöscht werden soll.'
|
||||||
|
: 'Entfernt diesen Eintrag aus der Verlaufsliste — Dateien auf der Festplatte bleiben erhalten.'}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
label="Historieneintrag löschen"
|
label={isMultipartMergeJob ? 'Merge-Job löschen' : 'Historieneintrag löschen'}
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
severity="danger"
|
severity="danger"
|
||||||
outlined
|
outlined
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => onDeleteEntry?.(job)}
|
onClick={() => onDeleteEntry?.(job, { includeRelated: !isMultipartMergeJob })}
|
||||||
loading={deleteEntryBusy}
|
loading={deleteEntryBusy}
|
||||||
disabled={!canDeleteEntry}
|
disabled={!canDeleteEntry}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2510,6 +2510,11 @@ export default function PipelineStatusCard({
|
|||||||
setLiveJobLog(String(liveJob?.log || '').trim());
|
setLiveJobLog(String(liveJob?.log || '').trim());
|
||||||
}
|
}
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
|
if (_err?.status === 404) {
|
||||||
|
cancelled = true;
|
||||||
|
setLiveJobLog('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// ignore transient polling errors
|
// ignore transient polling errors
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -203,6 +203,16 @@ function isMultipartContainerHistoryRow(row) {
|
|||||||
return kind === 'multipart_movie_container';
|
return kind === 'multipart_movie_container';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMultipartMergeHistoryRow(row) {
|
||||||
|
const kind = String(
|
||||||
|
row?.job_kind
|
||||||
|
|| row?.jobKind
|
||||||
|
|| row?.encodePlan?.jobKind
|
||||||
|
|| ''
|
||||||
|
).trim().toLowerCase();
|
||||||
|
return kind === 'multipart_movie_merge';
|
||||||
|
}
|
||||||
|
|
||||||
function isSeriesChildHistoryRow(row) {
|
function isSeriesChildHistoryRow(row) {
|
||||||
const kind = String(
|
const kind = String(
|
||||||
row?.job_kind
|
row?.job_kind
|
||||||
@@ -348,6 +358,9 @@ function hasAudioOutput(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getOutputLabelForRow(row) {
|
function getOutputLabelForRow(row) {
|
||||||
|
if (isMultipartMergeHistoryRow(row)) {
|
||||||
|
return 'Merge-Datei(en)';
|
||||||
|
}
|
||||||
if (isSeriesVideoJob(row)) {
|
if (isSeriesVideoJob(row)) {
|
||||||
return 'Folgen-Datei(en)';
|
return 'Folgen-Datei(en)';
|
||||||
}
|
}
|
||||||
@@ -361,6 +374,9 @@ function getOutputLabelForRow(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getOutputShortLabelForRow(row) {
|
function getOutputShortLabelForRow(row) {
|
||||||
|
if (isMultipartMergeHistoryRow(row)) {
|
||||||
|
return 'Merge';
|
||||||
|
}
|
||||||
if (isSeriesVideoJob(row)) {
|
if (isSeriesVideoJob(row)) {
|
||||||
return 'Folgen';
|
return 'Folgen';
|
||||||
}
|
}
|
||||||
@@ -1442,7 +1458,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const includeRelated = options?.includeRelated !== false;
|
const isMergeJob = isMultipartMergeHistoryRow(row);
|
||||||
|
const includeRelated = options?.includeRelated !== undefined
|
||||||
|
? options.includeRelated !== false
|
||||||
|
: !isMergeJob;
|
||||||
setDeleteEntryDialogRow(row);
|
setDeleteEntryDialogRow(row);
|
||||||
setDeleteEntryPreview(null);
|
setDeleteEntryPreview(null);
|
||||||
setDeleteEntryIncludeRelated(includeRelated);
|
setDeleteEntryIncludeRelated(includeRelated);
|
||||||
@@ -1695,12 +1714,19 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
if (mergeState === 'done') {
|
if (mergeState === 'done') {
|
||||||
return (
|
return (
|
||||||
<div className="history-status-tag-wrap">
|
<div className="history-status-tag-wrap">
|
||||||
<Tag value="Merge fertig" severity="success" />
|
<Tag value="Fertig" severity="success" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isMultipartMergeHistoryRow(row) && normalizeStatus(row?.status) === 'ENCODING') {
|
||||||
|
return (
|
||||||
|
<div className="history-status-tag-wrap">
|
||||||
|
<Tag value="Merging" severity="warning" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
const normalizedStatus = normalizeStatus(row?.status);
|
const normalizedStatus = normalizeStatus(row?.status);
|
||||||
const rowId = normalizeJobId(row?.id);
|
const rowId = normalizeJobId(row?.id);
|
||||||
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
|
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
|
||||||
|
|||||||
@@ -545,6 +545,15 @@ function isMultipartMergeJob(job) {
|
|||||||
return resolveJobKindRaw(job) === 'multipart_movie_merge';
|
return resolveJobKindRaw(job) === 'multipart_movie_merge';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMergeAwareStatusLabel(job, status, options = {}) {
|
||||||
|
const queued = Boolean(options?.queued);
|
||||||
|
const normalizedStatus = normalizeStatus(status);
|
||||||
|
if (!queued && isMultipartMergeJob(job) && normalizedStatus === 'ENCODING') {
|
||||||
|
return 'Merging';
|
||||||
|
}
|
||||||
|
return getStatusLabel(status, options);
|
||||||
|
}
|
||||||
|
|
||||||
function extractMultipartMergeSources(job) {
|
function extractMultipartMergeSources(job) {
|
||||||
const encodePlan = getEncodePlan(job) || {};
|
const encodePlan = getEncodePlan(job) || {};
|
||||||
const sourceItems = Array.isArray(encodePlan?.sourceItems) ? encodePlan.sourceItems : [];
|
const sourceItems = Array.isArray(encodePlan?.sourceItems) ? encodePlan.sourceItems : [];
|
||||||
@@ -1158,6 +1167,8 @@ export default function RipperPage({
|
|||||||
const [ripperJobs, setRipperJobs] = useState([]);
|
const [ripperJobs, setRipperJobs] = useState([]);
|
||||||
const [multipartMergePreviewByJobId, setMultipartMergePreviewByJobId] = useState({});
|
const [multipartMergePreviewByJobId, setMultipartMergePreviewByJobId] = useState({});
|
||||||
const [multipartMergePreviewLoadingByJobId, setMultipartMergePreviewLoadingByJobId] = useState({});
|
const [multipartMergePreviewLoadingByJobId, setMultipartMergePreviewLoadingByJobId] = useState({});
|
||||||
|
const [multipartMergeLiveLogByJobId, setMultipartMergeLiveLogByJobId] = useState({});
|
||||||
|
const [multipartMergeLiveLogLoadingByJobId, setMultipartMergeLiveLogLoadingByJobId] = useState({});
|
||||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||||
const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null });
|
const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null });
|
||||||
const [activationBytesInput, setActivationBytesInput] = useState('');
|
const [activationBytesInput, setActivationBytesInput] = useState('');
|
||||||
@@ -1270,6 +1281,17 @@ export default function RipperPage({
|
|||||||
if (!ripperStatuses.has(normalizedStatus)) {
|
if (!ripperStatuses.has(normalizedStatus)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (isMultipartMergeJob(job)) {
|
||||||
|
const mergeToolStatus = String(job?.handbrakeInfo?.status || '').trim().toUpperCase();
|
||||||
|
const mergeCompleted = Boolean(
|
||||||
|
normalizedStatus === 'FINISHED'
|
||||||
|
|| job?.encodeSuccess
|
||||||
|
|| mergeToolStatus === 'SUCCESS'
|
||||||
|
);
|
||||||
|
if (mergeCompleted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (normalizedStatus !== 'CANCELLED') {
|
if (normalizedStatus !== 'CANCELLED') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1506,6 +1528,80 @@ export default function RipperPage({
|
|||||||
void loadMultipartMergePreview(normalizedExpanded, { force: false, silent: true });
|
void loadMultipartMergePreview(normalizedExpanded, { force: false, silent: true });
|
||||||
}, [expandedJobId, ripperJobs]);
|
}, [expandedJobId, ripperJobs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const normalizedExpanded = normalizeJobId(expandedJobId);
|
||||||
|
if (!normalizedExpanded) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const expandedJob = ripperJobs.find((job) => normalizeJobId(job?.id) === normalizedExpanded) || null;
|
||||||
|
if (!expandedJob || !isMultipartMergeJob(expandedJob)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const expandedStatus = String(expandedJob?.status || expandedJob?.last_state || '').trim().toUpperCase();
|
||||||
|
if (expandedStatus !== 'ENCODING') {
|
||||||
|
setMultipartMergeLiveLogLoadingByJobId((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalizedExpanded]: false
|
||||||
|
}));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const refresh = async () => {
|
||||||
|
setMultipartMergeLiveLogLoadingByJobId((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalizedExpanded]: true
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const response = await api.getJob(normalizedExpanded, {
|
||||||
|
includeLiveLog: true,
|
||||||
|
lite: true,
|
||||||
|
logTailLines: 400
|
||||||
|
});
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const liveJob = response?.job && typeof response.job === 'object'
|
||||||
|
? response.job
|
||||||
|
: null;
|
||||||
|
setMultipartMergeLiveLogByJobId((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalizedExpanded]: String(liveJob?.log || '').trim()
|
||||||
|
}));
|
||||||
|
} catch (_error) {
|
||||||
|
if (_error?.status === 404) {
|
||||||
|
cancelled = true;
|
||||||
|
setMultipartMergeLiveLogByJobId((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalizedExpanded]: ''
|
||||||
|
}));
|
||||||
|
void loadRipperJobs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setMultipartMergeLiveLogByJobId((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalizedExpanded]: ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setMultipartMergeLiveLogLoadingByJobId((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[normalizedExpanded]: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void refresh();
|
||||||
|
const interval = setInterval(refresh, 2500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [expandedJobId, ripperJobs]);
|
||||||
|
|
||||||
const pipelineByJobId = useMemo(() => {
|
const pipelineByJobId = useMemo(() => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const job of ripperJobs) {
|
for (const job of ripperJobs) {
|
||||||
@@ -3154,7 +3250,7 @@ export default function RipperPage({
|
|||||||
}
|
}
|
||||||
const normalizedStatus = normalizeStatus(job?.status);
|
const normalizedStatus = normalizeStatus(job?.status);
|
||||||
const isQueued = queuedJobIdSet.has(jobId);
|
const isQueued = queuedJobIdSet.has(jobId);
|
||||||
const statusBadgeValue = getStatusLabel(job?.status, { queued: isQueued });
|
const statusBadgeValue = getMergeAwareStatusLabel(job, job?.status, { queued: isQueued });
|
||||||
const statusBadgeSeverity = getStatusSeverity(normalizedStatus, { queued: isQueued });
|
const statusBadgeSeverity = getStatusSeverity(normalizedStatus, { queued: isQueued });
|
||||||
const isExpanded = normalizeJobId(expandedJobId) === jobId;
|
const isExpanded = normalizeJobId(expandedJobId) === jobId;
|
||||||
const isCurrentSession = jobId === currentPipelineJobId && state !== 'IDLE';
|
const isCurrentSession = jobId === currentPipelineJobId && state !== 'IDLE';
|
||||||
@@ -3281,6 +3377,11 @@ export default function RipperPage({
|
|||||||
const multipartMergePreviewLoading = Boolean(
|
const multipartMergePreviewLoading = Boolean(
|
||||||
isMultipartMerge && multipartMergePreviewLoadingByJobId[jobId]
|
isMultipartMerge && multipartMergePreviewLoadingByJobId[jobId]
|
||||||
);
|
);
|
||||||
|
const multipartMergeLiveLog = String(multipartMergeLiveLogByJobId[jobId] || '').trim();
|
||||||
|
const multipartMergeLiveLogLoading = Boolean(
|
||||||
|
isMultipartMerge && multipartMergeLiveLogLoadingByJobId[jobId]
|
||||||
|
);
|
||||||
|
const isMultipartMergeRunning = isMultipartMerge && normalizedStatus === 'ENCODING';
|
||||||
const multipartMergeCommandPreview = String(multipartMergePreview?.command?.preview || '').trim();
|
const multipartMergeCommandPreview = String(multipartMergePreview?.command?.preview || '').trim();
|
||||||
const multipartMergeChapterEntries = Array.isArray(multipartMergePreview?.chapters?.entries)
|
const multipartMergeChapterEntries = Array.isArray(multipartMergePreview?.chapters?.entries)
|
||||||
? multipartMergePreview.chapters.entries
|
? multipartMergePreview.chapters.entries
|
||||||
@@ -3396,6 +3497,8 @@ export default function RipperPage({
|
|||||||
{!isCdJob && !isAudiobookJob ? (
|
{!isCdJob && !isAudiobookJob ? (
|
||||||
isMultipartMerge ? (
|
isMultipartMerge ? (
|
||||||
<div className="multipart-merge-panel">
|
<div className="multipart-merge-panel">
|
||||||
|
{!isMultipartMergeRunning ? (
|
||||||
|
<>
|
||||||
<div className="multipart-merge-head">
|
<div className="multipart-merge-head">
|
||||||
<strong>Merge-Quellen</strong>
|
<strong>Merge-Quellen</strong>
|
||||||
<small>Drag-and-Drop ändert die Reihenfolge der Disc-Dateien.</small>
|
<small>Drag-and-Drop ändert die Reihenfolge der Disc-Dateien.</small>
|
||||||
@@ -3464,6 +3567,33 @@ export default function RipperPage({
|
|||||||
) : (
|
) : (
|
||||||
<p className="muted-inline">Noch keine Merge-Quellen gefunden.</p>
|
<p className="muted-inline">Noch keine Merge-Quellen gefunden.</p>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{isMultipartMergeRunning ? (
|
||||||
|
<>
|
||||||
|
<div className="status-row multipart-merge-running-head">
|
||||||
|
<Tag value="Merging" severity="warning" />
|
||||||
|
<span>{String(pipelineForJob?.statusText || '').trim() || 'Merge läuft'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress-wrap multipart-merge-running-progress">
|
||||||
|
<ProgressBar
|
||||||
|
value={clampedProgress}
|
||||||
|
showValue
|
||||||
|
displayValueTemplate={() => progressLabel}
|
||||||
|
/>
|
||||||
|
<small>
|
||||||
|
{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="live-log-block multipart-merge-live-log">
|
||||||
|
<h4>Aktueller Merge-Log</h4>
|
||||||
|
<pre className="log-box">
|
||||||
|
{multipartMergeLiveLog
|
||||||
|
|| (multipartMergeLiveLogLoading ? 'Live-Log wird geladen ...' : 'Noch keine Log-Ausgabe vorhanden.')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<div className="multipart-merge-preview">
|
<div className="multipart-merge-preview">
|
||||||
<div className="multipart-merge-preview-head">
|
<div className="multipart-merge-preview-head">
|
||||||
<strong>Merge Vorschau</strong>
|
<strong>Merge Vorschau</strong>
|
||||||
@@ -3546,6 +3676,7 @@ export default function RipperPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="multipart-merge-actions">
|
<div className="multipart-merge-actions">
|
||||||
{(normalizedStatus === 'READY_TO_START' || normalizedStatus === 'READY_TO_ENCODE') ? (
|
{(normalizedStatus === 'READY_TO_START' || normalizedStatus === 'READY_TO_ENCODE') ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -3575,6 +3706,7 @@ export default function RipperPage({
|
|||||||
disabled={busyJobIds.has(jobId)}
|
disabled={busyJobIds.has(jobId)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!isMultipartMergeRunning ? (
|
||||||
<Button
|
<Button
|
||||||
label="Job löschen"
|
label="Job löschen"
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
@@ -3583,6 +3715,7 @@ export default function RipperPage({
|
|||||||
onClick={() => handleDeleteRipperJob(jobId)}
|
onClick={() => handleDeleteRipperJob(jobId)}
|
||||||
disabled={busyJobIds.has(jobId)}
|
disabled={busyJobIds.has(jobId)}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -3753,7 +3886,7 @@ export default function RipperPage({
|
|||||||
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||||
</strong>
|
</strong>
|
||||||
{item.subtitle ? <small>{item.subtitle}</small> : null}
|
{item.subtitle ? <small>{item.subtitle}</small> : null}
|
||||||
<small>#{item.jobId} | {getStatusLabel(item.status)}</small>
|
<small>#{item.jobId} | {getMergeAwareStatusLabel(item, item.status)}</small>
|
||||||
{clampedQueueProgress > 0 && (
|
{clampedQueueProgress > 0 && (
|
||||||
<ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
|
<ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
|
||||||
)}
|
)}
|
||||||
@@ -3794,7 +3927,7 @@ export default function RipperPage({
|
|||||||
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
|
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
|
||||||
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||||
</strong>
|
</strong>
|
||||||
<small>{getStatusLabel(item.status)}</small>
|
<small>{getMergeAwareStatusLabel(item, item.status)}</small>
|
||||||
</div>
|
</div>
|
||||||
{hasScriptSummary ? (
|
{hasScriptSummary ? (
|
||||||
<button
|
<button
|
||||||
@@ -3870,7 +4003,7 @@ export default function RipperPage({
|
|||||||
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||||
</strong>
|
</strong>
|
||||||
{item.subtitle ? <small>{item.subtitle}</small> : null}
|
{item.subtitle ? <small>{item.subtitle}</small> : null}
|
||||||
<small>{item.position || '-'} | #{item.jobId} | {item.actionLabel || item.action || '-'} | {getStatusLabel(item.status)}</small>
|
<small>{item.position || '-'} | #{item.jobId} | {item.actionLabel || item.action || '-'} | {getMergeAwareStatusLabel(item, item.status)}</small>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1666,7 +1666,11 @@ body {
|
|||||||
|
|
||||||
.multipart-merge-chapter-list {
|
.multipart-merge-chapter-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.25rem;
|
grid-template-columns: repeat(2, minmax(260px, 1fr));
|
||||||
|
gap: 0.25rem 0.55rem;
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.multipart-merge-chapter-item {
|
.multipart-merge-chapter-item {
|
||||||
@@ -1697,6 +1701,15 @@ body {
|
|||||||
gap: 0.18rem;
|
gap: 0.18rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.multipart-merge-chapter-list {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.playlist-waiting-box {
|
.playlist-waiting-box {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
padding: 0.6rem 0.7rem;
|
padding: 0.6rem 0.7rem;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.15.0",
|
"version": "0.15.0-1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.15.0",
|
"version": "0.15.0-1",
|
||||||
"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.15.0",
|
"version": "0.15.0-1",
|
||||||
"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