0.15.0-1 merger
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.0-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-backend",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.0-1",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.0-1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.0-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.0-1",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.0-1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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 label = getStatusLabel(normalized, { queued });
|
||||
const labelOverride = String(options?.label || '').trim();
|
||||
const label = labelOverride || getStatusLabel(normalized, { queued });
|
||||
if (queued) {
|
||||
return { label, icon: 'pi-list', tone: 'info' };
|
||||
}
|
||||
@@ -585,9 +586,42 @@ function statusBadgeMeta(status, queued = false) {
|
||||
if (normalized === 'ENCODING') {
|
||||
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' };
|
||||
}
|
||||
|
||||
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) {
|
||||
const raw = String(value || '').trim();
|
||||
return raw || '-';
|
||||
@@ -1185,16 +1219,20 @@ export default function JobDetailDialog({
|
||||
? discIndicatorIcon
|
||||
: otherIndicatorIcon;
|
||||
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 metadataContext = resolveJobMetadataContext(job);
|
||||
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
|
||||
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
|
||||
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
|
||||
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
|
||||
const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container';
|
||||
const isMultipartContainer = jobKindRaw === 'multipart_movie_container';
|
||||
const isMultipartMergeJob = isMultipartMergeChildJob(job);
|
||||
const isDiskContainer = isSeriesContainer || isMultipartContainer;
|
||||
const allChildJobs = Array.isArray(job?.children)
|
||||
? [...job.children].sort(compareSeriesChildJobsByDisc)
|
||||
@@ -1332,11 +1370,28 @@ export default function JobDetailDialog({
|
||||
(title) => title.selectedForEncode || title.encodeInput || String(title.id) === String(reviewSelectedEncodeTitleId)
|
||||
);
|
||||
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 canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.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 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 mergeToolLogFallback = isMultipartMergeJob ? buildMergeToolLogFallback(job) : '';
|
||||
const canShowGeneralEncodeActions = canResumeReady
|
||||
|| typeof onRestartEncode === 'function'
|
||||
|| typeof onRestartReview === 'function'
|
||||
@@ -1407,8 +1462,8 @@ export default function JobDetailDialog({
|
||||
{detailLoading ? <p>Details werden geladen ...</p> : null}
|
||||
|
||||
<div className="job-head-row">
|
||||
{job.poster_url && job.poster_url !== 'N/A' ? (
|
||||
<img src={job.poster_url} alt={job.title || 'Poster'} className={useAudioPosterLayout ? 'poster-large-audio' : 'poster-large'} />
|
||||
{posterUrl && posterUrl !== 'N/A' ? (
|
||||
<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>
|
||||
)}
|
||||
@@ -1712,7 +1767,7 @@ export default function JobDetailDialog({
|
||||
<div><strong>Start:</strong> {job.start_time || '-'}</div>
|
||||
<div><strong>Ende:</strong> {job.end_time || '-'}</div>
|
||||
{/* Zeile 3+4: Pfade */}
|
||||
{!isDiskContainer ? (
|
||||
{!isDiskContainer && !isMultipartMergeJob ? (
|
||||
<PathField
|
||||
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
|
||||
value={isCd ? (job.raw_path || job.output_path) : resolvedRawPath}
|
||||
@@ -1745,7 +1800,13 @@ export default function JobDetailDialog({
|
||||
if (!folderPath) {
|
||||
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');
|
||||
return (
|
||||
<PathField
|
||||
@@ -1760,7 +1821,7 @@ export default function JobDetailDialog({
|
||||
})
|
||||
) : (
|
||||
<PathField
|
||||
label="Output:"
|
||||
label={isMultipartMergeJob ? 'Output (Merge):' : 'Output:'}
|
||||
value={job.output_path}
|
||||
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
|
||||
downloadDisabled={!canDownloadOutput}
|
||||
@@ -2414,6 +2475,23 @@ export default function JobDetailDialog({
|
||||
<span className="series-batch-episode-title">{`Merge${child?.id ? ` #${child.id}` : ''}`}</span>
|
||||
</summary>
|
||||
<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>
|
||||
))
|
||||
) : (
|
||||
@@ -2523,15 +2601,17 @@ export default function JobDetailDialog({
|
||||
<summary className="series-batch-episode-head">
|
||||
<span className="series-batch-episode-title">{`Merge${child?.id ? ` #${child.id}` : ''}`}</span>
|
||||
</summary>
|
||||
<pre>{child.log || ''}</pre>
|
||||
<pre>{child.log || buildMergeToolLogFallback(child) || ''}</pre>
|
||||
</details>
|
||||
))}
|
||||
</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 ? (
|
||||
<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
|
||||
label="Historieneintrag löschen"
|
||||
label={isMultipartMergeJob ? 'Merge-Job löschen' : 'Historieneintrag löschen'}
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onDeleteEntry?.(job)}
|
||||
onClick={() => onDeleteEntry?.(job, { includeRelated: !isMultipartMergeJob })}
|
||||
loading={deleteEntryBusy}
|
||||
disabled={!canDeleteEntry}
|
||||
/>
|
||||
|
||||
@@ -2510,6 +2510,11 @@ export default function PipelineStatusCard({
|
||||
setLiveJobLog(String(liveJob?.log || '').trim());
|
||||
}
|
||||
} catch (_err) {
|
||||
if (_err?.status === 404) {
|
||||
cancelled = true;
|
||||
setLiveJobLog('');
|
||||
return;
|
||||
}
|
||||
// ignore transient polling errors
|
||||
}
|
||||
};
|
||||
|
||||
@@ -203,6 +203,16 @@ function isMultipartContainerHistoryRow(row) {
|
||||
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) {
|
||||
const kind = String(
|
||||
row?.job_kind
|
||||
@@ -348,6 +358,9 @@ function hasAudioOutput(row) {
|
||||
}
|
||||
|
||||
function getOutputLabelForRow(row) {
|
||||
if (isMultipartMergeHistoryRow(row)) {
|
||||
return 'Merge-Datei(en)';
|
||||
}
|
||||
if (isSeriesVideoJob(row)) {
|
||||
return 'Folgen-Datei(en)';
|
||||
}
|
||||
@@ -361,6 +374,9 @@ function getOutputLabelForRow(row) {
|
||||
}
|
||||
|
||||
function getOutputShortLabelForRow(row) {
|
||||
if (isMultipartMergeHistoryRow(row)) {
|
||||
return 'Merge';
|
||||
}
|
||||
if (isSeriesVideoJob(row)) {
|
||||
return 'Folgen';
|
||||
}
|
||||
@@ -1442,7 +1458,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
const includeRelated = options?.includeRelated !== false;
|
||||
const isMergeJob = isMultipartMergeHistoryRow(row);
|
||||
const includeRelated = options?.includeRelated !== undefined
|
||||
? options.includeRelated !== false
|
||||
: !isMergeJob;
|
||||
setDeleteEntryDialogRow(row);
|
||||
setDeleteEntryPreview(null);
|
||||
setDeleteEntryIncludeRelated(includeRelated);
|
||||
@@ -1695,12 +1714,19 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (mergeState === 'done') {
|
||||
return (
|
||||
<div className="history-status-tag-wrap">
|
||||
<Tag value="Merge fertig" severity="success" />
|
||||
<Tag value="Fertig" severity="success" />
|
||||
</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 rowId = normalizeJobId(row?.id);
|
||||
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
|
||||
|
||||
@@ -545,6 +545,15 @@ function isMultipartMergeJob(job) {
|
||||
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) {
|
||||
const encodePlan = getEncodePlan(job) || {};
|
||||
const sourceItems = Array.isArray(encodePlan?.sourceItems) ? encodePlan.sourceItems : [];
|
||||
@@ -1158,6 +1167,8 @@ export default function RipperPage({
|
||||
const [ripperJobs, setRipperJobs] = useState([]);
|
||||
const [multipartMergePreviewByJobId, setMultipartMergePreviewByJobId] = useState({});
|
||||
const [multipartMergePreviewLoadingByJobId, setMultipartMergePreviewLoadingByJobId] = useState({});
|
||||
const [multipartMergeLiveLogByJobId, setMultipartMergeLiveLogByJobId] = useState({});
|
||||
const [multipartMergeLiveLogLoadingByJobId, setMultipartMergeLiveLogLoadingByJobId] = useState({});
|
||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||
const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null });
|
||||
const [activationBytesInput, setActivationBytesInput] = useState('');
|
||||
@@ -1270,6 +1281,17 @@ export default function RipperPage({
|
||||
if (!ripperStatuses.has(normalizedStatus)) {
|
||||
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') {
|
||||
return true;
|
||||
}
|
||||
@@ -1506,6 +1528,80 @@ export default function RipperPage({
|
||||
void loadMultipartMergePreview(normalizedExpanded, { force: false, silent: true });
|
||||
}, [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 map = new Map();
|
||||
for (const job of ripperJobs) {
|
||||
@@ -3154,7 +3250,7 @@ export default function RipperPage({
|
||||
}
|
||||
const normalizedStatus = normalizeStatus(job?.status);
|
||||
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 isExpanded = normalizeJobId(expandedJobId) === jobId;
|
||||
const isCurrentSession = jobId === currentPipelineJobId && state !== 'IDLE';
|
||||
@@ -3281,6 +3377,11 @@ export default function RipperPage({
|
||||
const multipartMergePreviewLoading = Boolean(
|
||||
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 multipartMergeChapterEntries = Array.isArray(multipartMergePreview?.chapters?.entries)
|
||||
? multipartMergePreview.chapters.entries
|
||||
@@ -3396,6 +3497,8 @@ export default function RipperPage({
|
||||
{!isCdJob && !isAudiobookJob ? (
|
||||
isMultipartMerge ? (
|
||||
<div className="multipart-merge-panel">
|
||||
{!isMultipartMergeRunning ? (
|
||||
<>
|
||||
<div className="multipart-merge-head">
|
||||
<strong>Merge-Quellen</strong>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
) : 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>
|
||||
@@ -3546,6 +3676,7 @@ export default function RipperPage({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="multipart-merge-actions">
|
||||
{(normalizedStatus === 'READY_TO_START' || normalizedStatus === 'READY_TO_ENCODE') ? (
|
||||
<Button
|
||||
@@ -3575,6 +3706,7 @@ export default function RipperPage({
|
||||
disabled={busyJobIds.has(jobId)}
|
||||
/>
|
||||
) : null}
|
||||
{!isMultipartMergeRunning ? (
|
||||
<Button
|
||||
label="Job löschen"
|
||||
icon="pi pi-trash"
|
||||
@@ -3583,6 +3715,7 @@ export default function RipperPage({
|
||||
onClick={() => handleDeleteRipperJob(jobId)}
|
||||
disabled={busyJobIds.has(jobId)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -3753,7 +3886,7 @@ export default function RipperPage({
|
||||
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||
</strong>
|
||||
{item.subtitle ? <small>{item.subtitle}</small> : null}
|
||||
<small>#{item.jobId} | {getStatusLabel(item.status)}</small>
|
||||
<small>#{item.jobId} | {getMergeAwareStatusLabel(item, item.status)}</small>
|
||||
{clampedQueueProgress > 0 && (
|
||||
<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.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||
</strong>
|
||||
<small>{getStatusLabel(item.status)}</small>
|
||||
<small>{getMergeAwareStatusLabel(item, item.status)}</small>
|
||||
</div>
|
||||
{hasScriptSummary ? (
|
||||
<button
|
||||
@@ -3870,7 +4003,7 @@ export default function RipperPage({
|
||||
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||
</strong>
|
||||
{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>
|
||||
|
||||
@@ -1666,7 +1666,11 @@ body {
|
||||
|
||||
.multipart-merge-chapter-list {
|
||||
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 {
|
||||
@@ -1697,6 +1701,15 @@ body {
|
||||
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 {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.0-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.0-1",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"private": true,
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.0-1",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||
"dev:backend": "npm run dev --prefix backend",
|
||||
|
||||
Reference in New Issue
Block a user