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
+2 -2
View File
@@ -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 -1
View File
@@ -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": {
+3
View File
@@ -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);
+18
View File
@@ -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,
+77 -10
View File
@@ -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)
+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; 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
}; };
+2 -2
View File
@@ -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 -1
View File
@@ -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": {
+100 -16
View File
@@ -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
} }
}; };
+28 -2
View File
@@ -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));
+291 -158
View File
@@ -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,156 +3497,186 @@ export default function RipperPage({
{!isCdJob && !isAudiobookJob ? ( {!isCdJob && !isAudiobookJob ? (
isMultipartMerge ? ( isMultipartMerge ? (
<div className="multipart-merge-panel"> <div className="multipart-merge-panel">
<div className="multipart-merge-head"> {!isMultipartMergeRunning ? (
<strong>Merge-Quellen</strong> <>
<small>Drag-and-Drop ändert die Reihenfolge der Disc-Dateien.</small> <div className="multipart-merge-head">
</div> <strong>Merge-Quellen</strong>
{hasMultipartMergeSources ? ( <small>Drag-and-Drop ändert die Reihenfolge der Disc-Dateien.</small>
<div className="multipart-merge-list">
{multipartMergeSources.map((source, sourceIndex) => {
const sourceKey = `${jobId}-${source.sourceJobId}-${sourceIndex}`;
const canReorder = !busyJobIds.has(jobId)
&& !processingStates.includes(normalizedStatus)
&& multipartMergeSources.length > 1;
return (
<div
key={sourceKey}
className="multipart-merge-item"
draggable={canReorder}
onDragStart={() => {
if (!canReorder) return;
setDraggingMergeSource({ jobId, sourceJobId: source.sourceJobId });
}}
onDragEnd={() => setDraggingMergeSource(null)}
onDragOver={(event) => {
if (!canReorder) return;
if (!draggingMergeSource || draggingMergeSource.jobId !== jobId) return;
event.preventDefault();
}}
onDrop={(event) => {
if (!canReorder) return;
if (!draggingMergeSource || draggingMergeSource.jobId !== jobId) return;
event.preventDefault();
const draggedId = normalizeJobId(draggingMergeSource.sourceJobId);
const targetId = normalizeJobId(source.sourceJobId);
if (!draggedId || !targetId || draggedId === targetId) {
setDraggingMergeSource(null);
return;
}
const currentOrder = multipartMergeSources
.map((item) => normalizeJobId(item.sourceJobId))
.filter(Boolean);
const fromIndex = currentOrder.findIndex((id) => id === draggedId);
const toIndex = currentOrder.findIndex((id) => id === targetId);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
setDraggingMergeSource(null);
return;
}
const nextOrder = [...currentOrder];
const [moved] = nextOrder.splice(fromIndex, 1);
nextOrder.splice(toIndex, 0, moved);
setDraggingMergeSource(null);
handleReorderMultipartMergeSources(jobId, nextOrder);
}}
>
<div className="multipart-merge-item-meta">
<Tag value={`#${sourceIndex + 1}`} severity="contrast" />
<Tag value={source.discNumber ? `Disc ${source.discNumber}` : 'Disc ?'} severity="info" />
<Tag value={`Job #${source.sourceJobId}`} severity="secondary" />
</div>
<div className="multipart-merge-item-text">
<strong>{source.fileName || source.outputPath}</strong>
<small>{source.outputPath}</small>
</div>
</div>
);
})}
</div>
) : (
<p className="muted-inline">Noch keine Merge-Quellen gefunden.</p>
)}
<div className="multipart-merge-preview">
<div className="multipart-merge-preview-head">
<strong>Merge Vorschau</strong>
<Button
icon="pi pi-refresh"
text
severity="secondary"
aria-label="Merge-Vorschau aktualisieren"
onClick={() => loadMultipartMergePreview(jobId, { force: true, silent: false })}
disabled={busyJobIds.has(jobId) || multipartMergePreviewLoading}
/>
</div>
{multipartMergePreviewLoading ? (
<small className="muted-inline">Vorschau wird aktualisiert ...</small>
) : null}
<div className="multipart-merge-preview-section">
<strong>1. CLI-Befehl</strong>
{multipartMergeCommandPreview ? (
<code className="multipart-merge-command-preview">{multipartMergeCommandPreview}</code>
) : (
<small className="muted-inline">Noch keine Vorschau verfügbar.</small>
)}
</div>
<div className="multipart-merge-preview-section">
<strong>2. Kapitel (gesamt)</strong>
{multipartMergeChapterEntries.length > 0 ? (
<div className="multipart-merge-chapter-list">
{multipartMergeChapterEntries.map((entry) => (
<div key={`merge-chapter-${jobId}-${entry.index}`} className="multipart-merge-chapter-item">
<small>#{entry.index}</small>
<small>{entry.startClock || entry.start || '-'}</small>
<span>{entry.title || '-'}</span>
</div>
))}
</div>
) : (
<small className="muted-inline">Keine berechnete Kapitel-Gesamtliste verfügbar.</small>
)}
</div>
<div className="multipart-merge-preview-section">
<strong>3. Erwartete Medieninfos (nach Merge)</strong>
<div className="multipart-merge-track-columns">
<div className="multipart-merge-track-column">
<small className="multipart-merge-track-heading">Audio</small>
{multipartMergeAudioTracks.length > 0 ? (
<div className="multipart-merge-track-list">
{multipartMergeAudioTracks.map((track) => (
<small key={`merge-audio-${jobId}-${track.order || track.streamIndex}`}>
{formatMergeTrackSummary(track, 'Audio')}
</small>
))}
</div>
) : (
<small className="muted-inline">Keine Audio-Infos.</small>
)}
</div>
<div className="multipart-merge-track-column">
<small className="multipart-merge-track-heading">Untertitel</small>
{multipartMergeSubtitleTracks.length > 0 ? (
<div className="multipart-merge-track-list">
{multipartMergeSubtitleTracks.map((track) => (
<small key={`merge-sub-${jobId}-${track.order || track.streamIndex}`}>
{formatMergeTrackSummary(track, 'UT')}
</small>
))}
</div>
) : (
<small className="muted-inline">Keine Untertitel-Infos.</small>
)}
</div>
</div> </div>
{multipartMergePreview?.media?.available ? ( {hasMultipartMergeSources ? (
<small className={multipartMergeMediaAligned ? 'muted-inline' : 'error-text'}> <div className="multipart-merge-list">
{multipartMergeMediaAligned {multipartMergeSources.map((source, sourceIndex) => {
? 'Track-Layout über alle Quellen konsistent.' const sourceKey = `${jobId}-${source.sourceJobId}-${sourceIndex}`;
: `Achtung: Track-Layout unterscheidet sich zwischen Quellen (${multipartMergeMediaMismatchCount} Abweichung(en)).`} const canReorder = !busyJobIds.has(jobId)
</small> && !processingStates.includes(normalizedStatus)
&& multipartMergeSources.length > 1;
return (
<div
key={sourceKey}
className="multipart-merge-item"
draggable={canReorder}
onDragStart={() => {
if (!canReorder) return;
setDraggingMergeSource({ jobId, sourceJobId: source.sourceJobId });
}}
onDragEnd={() => setDraggingMergeSource(null)}
onDragOver={(event) => {
if (!canReorder) return;
if (!draggingMergeSource || draggingMergeSource.jobId !== jobId) return;
event.preventDefault();
}}
onDrop={(event) => {
if (!canReorder) return;
if (!draggingMergeSource || draggingMergeSource.jobId !== jobId) return;
event.preventDefault();
const draggedId = normalizeJobId(draggingMergeSource.sourceJobId);
const targetId = normalizeJobId(source.sourceJobId);
if (!draggedId || !targetId || draggedId === targetId) {
setDraggingMergeSource(null);
return;
}
const currentOrder = multipartMergeSources
.map((item) => normalizeJobId(item.sourceJobId))
.filter(Boolean);
const fromIndex = currentOrder.findIndex((id) => id === draggedId);
const toIndex = currentOrder.findIndex((id) => id === targetId);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
setDraggingMergeSource(null);
return;
}
const nextOrder = [...currentOrder];
const [moved] = nextOrder.splice(fromIndex, 1);
nextOrder.splice(toIndex, 0, moved);
setDraggingMergeSource(null);
handleReorderMultipartMergeSources(jobId, nextOrder);
}}
>
<div className="multipart-merge-item-meta">
<Tag value={`#${sourceIndex + 1}`} severity="contrast" />
<Tag value={source.discNumber ? `Disc ${source.discNumber}` : 'Disc ?'} severity="info" />
<Tag value={`Job #${source.sourceJobId}`} severity="secondary" />
</div>
<div className="multipart-merge-item-text">
<strong>{source.fileName || source.outputPath}</strong>
<small>{source.outputPath}</small>
</div>
</div>
);
})}
</div>
) : ( ) : (
<small className="muted-inline">Stream-Vergleich noch nicht verfügbar.</small> <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-head">
<strong>Merge Vorschau</strong>
<Button
icon="pi pi-refresh"
text
severity="secondary"
aria-label="Merge-Vorschau aktualisieren"
onClick={() => loadMultipartMergePreview(jobId, { force: true, silent: false })}
disabled={busyJobIds.has(jobId) || multipartMergePreviewLoading}
/>
</div>
{multipartMergePreviewLoading ? (
<small className="muted-inline">Vorschau wird aktualisiert ...</small>
) : null}
<div className="multipart-merge-preview-section">
<strong>1. CLI-Befehl</strong>
{multipartMergeCommandPreview ? (
<code className="multipart-merge-command-preview">{multipartMergeCommandPreview}</code>
) : (
<small className="muted-inline">Noch keine Vorschau verfügbar.</small>
)}
</div>
<div className="multipart-merge-preview-section">
<strong>2. Kapitel (gesamt)</strong>
{multipartMergeChapterEntries.length > 0 ? (
<div className="multipart-merge-chapter-list">
{multipartMergeChapterEntries.map((entry) => (
<div key={`merge-chapter-${jobId}-${entry.index}`} className="multipart-merge-chapter-item">
<small>#{entry.index}</small>
<small>{entry.startClock || entry.start || '-'}</small>
<span>{entry.title || '-'}</span>
</div>
))}
</div>
) : (
<small className="muted-inline">Keine berechnete Kapitel-Gesamtliste verfügbar.</small>
)}
</div>
<div className="multipart-merge-preview-section">
<strong>3. Erwartete Medieninfos (nach Merge)</strong>
<div className="multipart-merge-track-columns">
<div className="multipart-merge-track-column">
<small className="multipart-merge-track-heading">Audio</small>
{multipartMergeAudioTracks.length > 0 ? (
<div className="multipart-merge-track-list">
{multipartMergeAudioTracks.map((track) => (
<small key={`merge-audio-${jobId}-${track.order || track.streamIndex}`}>
{formatMergeTrackSummary(track, 'Audio')}
</small>
))}
</div>
) : (
<small className="muted-inline">Keine Audio-Infos.</small>
)}
</div>
<div className="multipart-merge-track-column">
<small className="multipart-merge-track-heading">Untertitel</small>
{multipartMergeSubtitleTracks.length > 0 ? (
<div className="multipart-merge-track-list">
{multipartMergeSubtitleTracks.map((track) => (
<small key={`merge-sub-${jobId}-${track.order || track.streamIndex}`}>
{formatMergeTrackSummary(track, 'UT')}
</small>
))}
</div>
) : (
<small className="muted-inline">Keine Untertitel-Infos.</small>
)}
</div>
</div>
{multipartMergePreview?.media?.available ? (
<small className={multipartMergeMediaAligned ? 'muted-inline' : 'error-text'}>
{multipartMergeMediaAligned
? 'Track-Layout über alle Quellen konsistent.'
: `Achtung: Track-Layout unterscheidet sich zwischen Quellen (${multipartMergeMediaMismatchCount} Abweichung(en)).`}
</small>
) : (
<small className="muted-inline">Stream-Vergleich noch nicht verfügbar.</small>
)}
</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,14 +3706,16 @@ export default function RipperPage({
disabled={busyJobIds.has(jobId)} disabled={busyJobIds.has(jobId)}
/> />
) : null} ) : null}
<Button {!isMultipartMergeRunning ? (
label="Job löschen" <Button
icon="pi pi-trash" label="Job löschen"
severity="danger" icon="pi pi-trash"
outlined severity="danger"
onClick={() => handleDeleteRipperJob(jobId)} outlined
disabled={busyJobIds.has(jobId)} onClick={() => handleDeleteRipperJob(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>
+14 -1
View File
@@ -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;
+2 -2
View File
@@ -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
View File
@@ -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",