0.15.0-1 merger
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user