0.15.0-1 merger

This commit is contained in:
2026-04-22 20:32:47 +00:00
parent 1f0a357abf
commit 120a9e6ad1
16 changed files with 728 additions and 197 deletions
+3
View File
@@ -22,6 +22,7 @@ const downloadService = require('./services/downloadService');
const diskDetectionService = require('./services/diskDetectionService');
const hardwareMonitorService = require('./services/hardwareMonitorService');
const coverArtRecoveryService = require('./services/coverArtRecoveryService');
const tempCleanupService = require('./services/tempCleanupService');
const logger = require('./services/logger').child('BOOT');
const { errorToMeta } = require('./utils/errorMeta');
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
@@ -29,6 +30,7 @@ const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thum
async function start() {
logger.info('backend:start:init');
await initDatabase();
await tempCleanupService.init();
await pipelineService.init();
await converterScanService.startPolling();
await cronService.init();
@@ -95,6 +97,7 @@ async function start() {
coverArtRecoveryService.stop();
hardwareMonitorService.stop();
cronService.stop();
tempCleanupService.stop();
server.close(() => {
logger.warn('backend:shutdown:completed');
process.exit(0);
+18
View File
@@ -922,6 +922,23 @@ function enrichJobRow(job, settings = null, options = {}) {
const makemkvInfo = resolvedPaths.makemkvInfo;
const mediainfoInfo = resolvedPaths.mediainfoInfo;
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 normalizedJobStatus = String(job?.status || '').trim().toUpperCase();
const ripSuccessful = Number(job?.rip_successful || 0) === 1
@@ -953,6 +970,7 @@ function enrichJobRow(job, settings = null, options = {}) {
return {
...job,
poster_url: resolvedPosterUrl,
raw_path: resolvedPaths.effectiveRawPath,
output_path: resolvedPaths.effectiveOutputPath,
makemkvInfo,
+77 -10
View File
@@ -21,7 +21,7 @@ const diskDetectionService = require('./diskDetectionService');
const notificationService = require('./notificationService');
const logger = require('./logger').child('PIPELINE');
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 { buildMediainfoReview } = require('../utils/encodePlan');
const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis');
@@ -2077,6 +2077,25 @@ function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) {
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) {
if (!fs.existsSync(outputPath)) {
return outputPath;
@@ -2383,9 +2402,11 @@ function extractProgressDetail(source, line) {
}
function composeStatusText(stage, percent, detail) {
const normalizedStage = String(stage || '').trim().toUpperCase();
const baseLabel = normalizedStage === 'ENCODING' ? 'Fortschritt' : stage;
const base = percent !== null && percent !== undefined
? `${stage} ${percent.toFixed(2)}%`
: stage;
? `${baseLabel} ${percent.toFixed(2)}%`
: baseLabel;
if (detail) {
return `${base} - ${detail}`;
@@ -11503,8 +11524,10 @@ class PipelineService extends EventEmitter {
const settings = await settingsService.getEffectiveSettingsMap(
this.resolveMediaProfileForJob(containerJob, { mediaProfile: containerJob?.media_type || null })
);
const templateOutputPath = buildFinalOutputPathFromJob(settings, containerJob, normalizedContainerJobId);
const mergeOutputPath = withPathBaseNameSuffix(templateOutputPath, '_merged');
const templateOutputPath = withPathBaseNameSuffix(
buildFinalOutputPathFromJob(settings, containerJob, normalizedContainerJobId),
'_merged'
);
const existingMergeJob = mergeJobs[0] || null;
const createIfMissing = options?.createIfMissing !== false;
const existingMergePlan = this.safeParseJson(existingMergeJob?.encode_plan_json) || {};
@@ -11560,6 +11583,8 @@ class PipelineService extends EventEmitter {
sourceCount: sourceItems.length
};
}
const mergeOutputPath = buildMultipartMergeOutputPath(templateOutputPath, sourceItems)
|| templateOutputPath;
const mergePlan = {
mode: 'multipart_merge',
@@ -11933,8 +11958,12 @@ class PipelineService extends EventEmitter {
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
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);
const expectedOutputPath = buildMultipartMergeOutputPath(
mergeOutputTemplatePath,
sourceItems
) || mergeOutputTemplatePath;
const analysis = await this.analyzeMultipartMergeInputs(sourceItems, { ffprobeCommand });
const hasGeneratedChapterPlan = Boolean(analysis?.chapterPlan?.content);
@@ -12022,9 +12051,19 @@ class PipelineService extends EventEmitter {
mediaProfile: mergePlan?.mediaProfile || mergeJob?.media_type || null
});
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
const incompleteOutputPath = buildIncompleteOutputPathFromJob(settings, mergeJob, normalizedJobId);
const preferredFinalOutputPath = String(mergePlan?.mergeOutputPath || '').trim()
const mkvmergeCommand = String(settings?.mkvmerge_command || 'mkvmerge').trim() || 'mkvmerge';
await this.ensureExternalToolAvailable(mkvmergeCommand, { label: 'mkvmerge' });
const mergeOutputTemplatePath = String(mergePlan?.mergeOutputPath || '').trim()
|| 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));
await this.setState('ENCODING', {
@@ -12053,7 +12092,6 @@ class PipelineService extends EventEmitter {
`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 mergeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ripster-merge-'));
let chaptersFilePath = null;
@@ -12113,7 +12151,8 @@ class PipelineService extends EventEmitter {
stage: 'ENCODING',
source: 'MKVMERGE',
cmd: mkvmergeCommand,
args: mkvmergeArgs
args: mkvmergeArgs,
parser: parseMkvmergeProgress
});
const outputFinalization = finalizeOutputPathForCompletedEncode(
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) {
const registrationConfig = await settingsService.buildMakeMKVRegisterConfig();
if (!registrationConfig) {
@@ -23944,6 +24010,7 @@ class PipelineService extends EventEmitter {
['converter', 'cd', 'audiobook'].includes(mediaProfile) ? mediaProfile : null
);
const isCdRetry = mediaProfile === 'cd';
const isAudiobookRetry = mediaProfile === 'audiobook';
// CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden.
const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus)
+156
View File
@@ -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();
+27 -1
View File
@@ -93,8 +93,34 @@ function parseCdParanoiaProgress(line) {
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 = {
parseMakeMkvProgress,
parseHandBrakeProgress,
parseCdParanoiaProgress
parseCdParanoiaProgress,
parseMkvmergeProgress
};