0.15.0-2 Misc Fixes

This commit is contained in:
2026-04-24 10:17:55 +00:00
parent 3e5840f329
commit 730bc50715
19 changed files with 1481 additions and 139 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "0.15.0-1",
"version": "0.15.0-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "0.15.0-1",
"version": "0.15.0-2",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.15.0-1",
"version": "0.15.0-2",
"private": true,
"type": "commonjs",
"scripts": {
+2
View File
@@ -770,6 +770,8 @@ async function migrateOutputTemplates(db) {
async function removeDeprecatedSettings(db) {
const deprecatedKeys = [
'pushover_notify_disc_detected',
'pushover_notify_cron_success',
'pushover_notify_cron_error',
'mediainfo_extra_args',
'makemkv_rip_mode',
'makemkv_analyze_extra_args',
+86 -4
View File
@@ -591,6 +591,32 @@ function toProcessLogPath(jobId) {
return path.join(getJobLogDir(), `job-${Math.trunc(normalizedId)}.process.log`);
}
function buildArchivedProcessLogPath(sourceJobId, options = {}) {
const normalizedId = Number(sourceJobId);
if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
return null;
}
const targetJobId = Number(options?.replacementJobId);
const safeReason = String(options?.reason || 'retired')
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
|| 'retired';
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const archiveDir = path.join(getJobLogDir(), 'archived');
const targetSuffix = Number.isFinite(targetJobId) && targetJobId > 0
? `.to-${Math.trunc(targetJobId)}`
: '';
return {
archiveDir,
archivePath: path.join(
archiveDir,
`job-${Math.trunc(normalizedId)}.process.${safeReason}${targetSuffix}.${timestamp}.log`
)
};
}
function hasProcessLogFile(jobId) {
const filePath = toProcessLogPath(jobId);
return Boolean(filePath && fs.existsSync(filePath));
@@ -2944,7 +2970,17 @@ class HistoryService {
}
await this.closeProcessLog(fromJobId);
this._deleteProcessLogFile(fromJobId);
const archivedProcessLogPath = this._archiveProcessLogFile(fromJobId, {
replacementJobId: toJobId,
reason
});
if (archivedProcessLogPath) {
this.appendProcessLog(
toJobId,
'SYSTEM',
`Vorheriger Prozess-Log von Job #${fromJobId} archiviert: ${archivedProcessLogPath}`
);
}
logger.warn('job:retired', {
sourceJobId: fromJobId,
@@ -2957,7 +2993,8 @@ class HistoryService {
retired: true,
sourceJobId: fromJobId,
replacementJobId: toJobId,
reason
reason,
archivedProcessLogPath: archivedProcessLogPath || null
};
}
@@ -6708,6 +6745,33 @@ class HistoryService {
}
}
_archiveProcessLogFile(jobId, options = {}) {
const processLogPath = toProcessLogPath(jobId);
if (!processLogPath || !fs.existsSync(processLogPath)) {
return null;
}
const archiveTarget = buildArchivedProcessLogPath(jobId, options);
if (!archiveTarget?.archivePath) {
return null;
}
try {
fs.mkdirSync(archiveTarget.archiveDir, { recursive: true });
fs.renameSync(processLogPath, archiveTarget.archivePath);
return archiveTarget.archivePath;
} catch (error) {
logger.warn('job:process-log:archive-failed', {
jobId,
fromPath: processLogPath,
toPath: archiveTarget.archivePath,
reason: String(options?.reason || '').trim() || null,
replacementJobId: Number(options?.replacementJobId) || null,
error: error?.message || String(error)
});
return null;
}
}
async deleteJobFiles(jobId, target = 'both', options = {}) {
const allowedTargets = new Set(['raw', 'movie', 'both']);
if (!allowedTargets.has(target)) {
@@ -6992,9 +7056,12 @@ class HistoryService {
movieCandidatePathForTracking = outputPath;
} else {
const parentDir = normalizeComparablePath(path.dirname(outputPath));
const parentDirName = String(path.basename(parentDir || '') || '').trim();
const isIncompleteMergeParentDir = /^incomplete_merge_.+_job_\d+\s*$/i.test(parentDirName);
// Converter jobs output a single file — never delete the parent dir
const isConverterJob = resolvedPaths.mediaType === 'converter';
const canDeleteParentDir = !isConverterJob
&& !isIncompleteMergeParentDir
&& parentDir
&& parentDir !== movieRoot
&& isPathInside(movieRoot, parentDir)
@@ -7388,11 +7455,26 @@ class HistoryService {
};
const collectIncompleteMoviePathsFromPreview = (preview) => {
const rows = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [];
const incompleteJobPattern = /(^|[\\/])incomplete_job-\d+([\\/]|$)/i;
const incompleteMergePattern = /(^|[\\/])incomplete_merge_[^\\/]+_job_\d+([\\/]|$)/i;
return rows
.filter((row) => Boolean(row?.exists))
.filter((row) => {
const candidatePath = String(row?.path || '').trim();
if (!candidatePath) {
return false;
}
if (incompleteJobPattern.test(candidatePath)) {
return true;
}
if (incompleteMergePattern.test(candidatePath)) {
// Shared multipart merge folders must never be auto-selected as a whole directory.
return Boolean(row?.isFile);
}
return false;
})
.map((row) => String(row?.path || '').trim())
.filter((candidatePath) => Boolean(candidatePath))
.filter((candidatePath) => /(^|[\\/])incomplete_job-\d+([\\/]|$)/i.test(candidatePath));
.filter(Boolean);
};
const cleanupIncompleteMovieFoldersForJobs = async (jobRows = [], ownerJobIds = []) => {
const normalizedOwnerJobIds = Array.from(new Set(
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.15.0-1",
"version": "0.15.0-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.15.0-1",
"version": "0.15.0-2",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.15.0-1",
"version": "0.15.0-2",
"private": true,
"type": "module",
"scripts": {
+3 -3
View File
@@ -602,15 +602,15 @@ function App() {
<div
className="app-upload-banner-progress"
aria-label={`Audiobook Upload ${Math.round(uploadProgress)} Prozent`}
aria-label={`Audiobook Upload ${Math.trunc(uploadProgress)} Prozent`}
>
<ProgressBar value={uploadProgress} showValue={false} />
<small>
{uploadPhase === 'processing'
? `100% | ${uploadBytesLabel || 'Upload abgeschlossen'}`
: uploadBytesLabel
? `${Math.round(uploadProgress)}% | ${uploadBytesLabel}`
: `${Math.round(uploadProgress)}%`}
? `${Math.trunc(uploadProgress)}% | ${uploadBytesLabel}`
: `${Math.trunc(uploadProgress)}%`}
</small>
</div>
@@ -297,9 +297,9 @@ export default function AudiobookConfigPanel({
</div>
{isRunning ? (
<div className="ripper-job-row-progress" aria-label={`Audiobook Fortschritt ${Math.round(progress)}%`}>
<div className="ripper-job-row-progress" aria-label={`Audiobook Fortschritt ${Math.trunc(progress)}%`}>
<ProgressBar value={progress} showValue={false} />
<small>{Math.round(progress)}%{currentChapter ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}: ${currentChapter.title}` : ''}</small>
<small>{Math.trunc(progress)}%{currentChapter ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}: ${currentChapter.title}` : ''}</small>
</div>
) : null}
@@ -182,15 +182,15 @@ export default function AudiobookUploadPanel({
) : null}
<div
className="ripper-job-row-progress audiobook-upload-progress"
aria-label={`Audiobook Upload ${Math.round(progress)} Prozent`}
aria-label={`Audiobook Upload ${Math.trunc(progress)} Prozent`}
>
<ProgressBar value={progress} showValue={false} />
<small>
{phase === 'processing'
? '100% | Upload fertig, Job wird vorbereitet ...'
: totalBytes > 0
? `${Math.round(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}`
: `${Math.round(progress)}%`}
? `${Math.trunc(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}`
: `${Math.trunc(progress)}%`}
</small>
</div>
</div>
+5 -8
View File
@@ -115,11 +115,8 @@ function formatProgressLabel(value) {
return '0%';
}
const clamped = Math.max(0, Math.min(100, parsed));
const rounded = Math.round(clamped * 10) / 10;
if (Number.isInteger(rounded)) {
return `${rounded}%`;
}
return `${rounded.toFixed(1)}%`;
const whole = Math.trunc(clamped);
return `${whole}%`;
}
function normalizeTrackStageStatus(value) {
@@ -617,7 +614,7 @@ export default function CdRipConfigPanel({
}).length;
const progress = Number(pipeline?.progress ?? 0);
const clampedProgress = Math.max(0, Math.min(100, progress));
const roundedProgress = Math.round(clampedProgress * 10) / 10;
const wholeProgress = Math.trunc(clampedProgress);
const eta = String(pipeline?.eta || '').trim();
const statusText = String(pipeline?.statusText || '').trim();
const stateLabel = getStatusLabel(state);
@@ -793,12 +790,12 @@ export default function CdRipConfigPanel({
{isRipping ? (
<div className="progress-wrap">
<ProgressBar
value={roundedProgress}
value={wholeProgress}
showValue
displayValueTemplate={(value) => formatProgressLabel(value)}
/>
<small>
{`Fortschritt: ${formatProgressLabel(roundedProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode`}
{`Fortschritt: ${formatProgressLabel(wholeProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode`}
</small>
<small>{eta ? `ETA ${eta}` : 'ETA unbekannt'}</small>
</div>
+1 -1
View File
@@ -1071,7 +1071,7 @@ export default function ConverterJobCard({
const liveProgress = jobProgress?.progress ?? null;
const liveEta = jobProgress?.eta || null;
const liveStatusText = jobProgress?.statusText || null;
const progressValue = liveProgress !== null ? Math.round(liveProgress) : null;
const progressValue = liveProgress !== null ? Math.trunc(liveProgress) : null;
// ── Audio details (for active/terminal state) ─────────────────────────────
const audioTracks = isAudio ? deriveAudioTracks(plan) : [];
@@ -125,7 +125,7 @@ export default function ConverterUploadPanel({ onUploaded, allowedExtensions = n
// XHR für Upload-Fortschritt
const result = await uploadWithProgress(formData, (pct) => {
setProgress(pct);
setStatusText(`Upload: ${Math.round(pct)}%`);
setStatusText(`Upload: ${Math.trunc(pct)}%`);
});
setPhase('done');
+1 -1
View File
@@ -2001,7 +2001,7 @@ export default function JobDetailDialog({
<span><strong>Titel-ID:</strong> {episode?.titleId || '-'}</span>
</div>
<div className="track-item">
<span><strong>Fortschritt:</strong> {`${boundedProgress.toFixed(2)}%`}</span>
<span><strong>Fortschritt:</strong> {`${Math.trunc(boundedProgress)}%`}</span>
</div>
<div className="track-item">
<span><strong>Gestartet:</strong> {formatDateTimeOrDash(episode?.startedAt)}</span>
+33 -6
View File
@@ -660,12 +660,12 @@ function normalizeSeriesBatchProgress(value, fallback = 0) {
return Math.max(0, Math.min(100, parsed));
}
function formatPercentTwoDecimals(value) {
function formatPercentNoDecimals(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return '0.00%';
return '0%';
}
return `${parsed.toFixed(2)}%`;
return `${Math.trunc(Math.max(0, Math.min(100, parsed)))}%`;
}
function getSeriesBatchTagSeverity(status) {
@@ -2528,6 +2528,15 @@ export default function PipelineStatusCard({
const manualDecisionState = pipeline?.context?.manualDecisionState || null;
const rawDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && manualDecisionState === 'waiting_for_raw_decision';
const rawDecisionOptions = pipeline?.context?.rawDecisionOptions && typeof pipeline.context.rawDecisionOptions === 'object'
? pipeline.context.rawDecisionOptions
: null;
const rawDecisionAllowsMultipartMergeWithOrphan = Boolean(
rawDecisionRequiredBeforeStart
&& rawDecisionOptions?.allowMultipartMergeWithOrphan
);
const rawDecisionOrphanDiscNumber = normalizePositiveInt(rawDecisionOptions?.orphanDiscNumber);
const rawDecisionCurrentDiscNumber = normalizePositiveInt(rawDecisionOptions?.currentDiscNumber);
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && !handBrakeTitleDecisionRequired && !rawDecisionRequiredBeforeStart;
const isSeriesDvdReview = useMemo(() => {
if (jobMediaProfile !== 'dvd' && jobMediaProfile !== 'bluray') {
@@ -2867,7 +2876,7 @@ export default function PipelineStatusCard({
: (pipeline?.eta || null);
const primaryStatusText = hasSeriesBatchProgress
? (
`Serien-Encoding ${primaryProgressValue.toFixed(2)}%`
`Serien-Encoding ${formatPercentNoDecimals(primaryProgressValue)}`
+ `${runningSeriesEpisode?.title ? ` - ${runningSeriesEpisode.title}` : ''}`
)
: (pipeline?.statusText || 'Bereit');
@@ -3365,7 +3374,7 @@ export default function PipelineStatusCard({
<ProgressBar
value={primaryProgressValue}
showValue
displayValueTemplate={(value) => formatPercentTwoDecimals(value)}
displayValueTemplate={(value) => formatPercentNoDecimals(value)}
/>
<small>
{primaryEta
@@ -3404,7 +3413,7 @@ export default function PipelineStatusCard({
<Tag value={getStatusLabel(child.status)} severity={getSeriesBatchTagSeverity(child.status)} />
</div>
<ProgressBar value={child.progress} showValue={false} style={{ height: '5px' }} />
<small>{child.eta ? `${Math.round(child.progress)}% | ETA ${child.eta}` : `${Math.round(child.progress)}%`}</small>
<small>{child.eta ? `${formatPercentNoDecimals(child.progress)} | ETA ${child.eta}` : `${formatPercentNoDecimals(child.progress)}`}</small>
</div>
))}
</div>
@@ -3669,6 +3678,14 @@ export default function PipelineStatusCard({
<small>
Vorhandenes Raw zur Disk gefunden. Wie soll weiter verfahren werden?
</small>
{rawDecisionAllowsMultipartMergeWithOrphan ? (
<small className="muted-inline mt-2">
Optional: Orphan-RAW als eigene Disc in einen Multipart-Container uebernehmen
{(rawDecisionOrphanDiscNumber || rawDecisionCurrentDiscNumber)
? ` (Orphan Disc ${rawDecisionOrphanDiscNumber || '?'} | Laufwerk Disc ${rawDecisionCurrentDiscNumber || '?'})`
: ''}.
</small>
) : null}
<div className="actions-row mt-3">
<Button
label="mit RAW weiter machen"
@@ -3677,6 +3694,16 @@ export default function PipelineStatusCard({
onClick={() => onSubmitRawDecision?.(retryJobId, 'continue')}
loading={busy}
/>
{rawDecisionAllowsMultipartMergeWithOrphan ? (
<Button
label="Multipart movie (Orphan + LW)"
icon="pi pi-sitemap"
severity="info"
outlined
onClick={() => onSubmitRawDecision?.(retryJobId, 'multipart_orphan_merge')}
loading={busy}
/>
) : null}
<Button
label="RAW löschen und Rip erneut starten"
icon="pi pi-trash"
+33 -11
View File
@@ -56,6 +56,23 @@ const CD_FORMAT_LABELS = {
ogg: 'Ogg Vorbis'
};
const MULTIPART_CONTAINER_LIVE_STATUSES = new Set([
'ANALYZING',
'METADATA_SELECTION',
'WAITING_FOR_USER_DECISION',
'READY_TO_START',
'MEDIAINFO_CHECK',
'READY_TO_ENCODE',
'RIPPING',
'ENCODING',
'POST_ENCODE_SCRIPTS',
'CD_METADATA_SELECTION',
'CD_READY_TO_RIP',
'CD_ANALYZING',
'CD_RIPPING',
'CD_ENCODING'
]);
function normalizePositiveInteger(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -1774,6 +1791,18 @@ export default function HistoryPage({ refreshToken = 0 }) {
};
const renderStatusTag = (row) => {
const normalizedStatus = normalizeStatus(row?.status || row?.last_state);
const rowId = normalizeJobId(row?.id);
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
const renderDefaultStatusTag = () => (
<div className="history-status-tag-wrap">
<Tag
value={getStatusLabel(row?.status || row?.last_state, { queued: isQueued })}
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
/>
</div>
);
if (isMultipartContainerHistoryRow(row)) {
const mergeSummary = row?.seriesChildSummary?.merge && typeof row.seriesChildSummary.merge === 'object'
? row.seriesChildSummary.merge
@@ -1792,6 +1821,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
</div>
);
}
if (MULTIPART_CONTAINER_LIVE_STATUSES.has(normalizedStatus)) {
return renderDefaultStatusTag();
}
if (mergeState === 'done' || isCompleted) {
return (
<div className="history-status-tag-wrap">
@@ -1830,17 +1862,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
</div>
);
}
const normalizedStatus = normalizeStatus(row?.status);
const rowId = normalizeJobId(row?.id);
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
return (
<div className="history-status-tag-wrap">
<Tag
value={getStatusLabel(row?.status, { queued: isQueued })}
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
/>
</div>
);
return renderDefaultStatusTag();
};
const renderPoster = (row, className = 'history-dv-poster') => {
+32 -9
View File
@@ -87,7 +87,16 @@ function isIncompleteOutputPath(outputPath) {
if (!normalized) {
return false;
}
return /(^|\/)incomplete_job-\d+(\/|$)/i.test(normalized);
return /(^|\/)incomplete_job-\d+(\/|$)/i.test(normalized)
|| /(^|\/)incomplete_merge_[^/]+_job_\d+(\/|$)/i.test(normalized);
}
function isIncompleteMergeOutputPath(outputPath) {
const normalized = normalizePathForMatch(outputPath);
if (!normalized) {
return false;
}
return /(^|\/)incomplete_merge_[^/]+_job_\d+(\/|$)/i.test(normalized);
}
function getIncompleteOutputSelectionPaths(outputPath) {
@@ -96,20 +105,28 @@ function getIncompleteOutputSelectionPaths(outputPath) {
return [];
}
const segments = normalized.split('/').filter(Boolean);
const incompleteIndex = segments.findIndex((segment) => /^incomplete_job-\d+$/i.test(segment));
if (incompleteIndex < 0) {
return [];
const incompleteJobIndex = segments.findIndex((segment) => /^incomplete_job-\d+$/i.test(segment));
if (incompleteJobIndex >= 0) {
const folderPath = `/${segments.slice(0, incompleteJobIndex + 1).join('/')}`;
return Array.from(new Set([normalized, folderPath]));
}
const folderPath = `/${segments.slice(0, incompleteIndex + 1).join('/')}`;
return Array.from(new Set([normalized, folderPath]));
const incompleteMergeIndex = segments.findIndex((segment) => /^incomplete_merge_[^/]+_job_\d+$/i.test(segment));
if (incompleteMergeIndex >= 0) {
const mergeFolderPath = `/${segments.slice(0, incompleteMergeIndex + 1).join('/')}`;
const normalizedLooksLikeFolder = incompleteMergeIndex === (segments.length - 1);
return normalizedLooksLikeFolder
? [mergeFolderPath]
: [normalized];
}
return [];
}
function formatPercent(value, digits = 1) {
function formatPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return 'n/a';
}
return `${parsed.toFixed(digits)}%`;
return `${Math.trunc(parsed)}%`;
}
function formatTemperature(value) {
@@ -2338,6 +2355,12 @@ export default function RipperPage({
incompleteMovieSelectionPaths = Array.from(new Set(
movieCandidates
.filter((candidate) => Boolean(candidate?.exists) && isIncompleteOutputPath(candidate?.path))
.filter((candidate) => {
if (!isIncompleteMergeOutputPath(candidate?.path)) {
return true;
}
return Boolean(candidate?.isFile);
})
.map((candidate) => String(candidate?.path || '').trim())
.filter(Boolean)
));
@@ -3384,7 +3407,7 @@ export default function RipperPage({
const clampedProgress = Number.isFinite(rawProgress)
? Math.max(0, Math.min(100, rawProgress))
: 0;
const progressLabel = `${Math.round(clampedProgress)}%`;
const progressLabel = `${Math.trunc(clampedProgress)}%`;
const etaLabel = isCancelledState ? '' : String(
hasSeriesBatchProgress
? (runningSeriesChild?.eta || pipelineForJob?.eta || '')
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster",
"version": "0.15.0-1",
"version": "0.15.0-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
"version": "0.15.0-1",
"version": "0.15.0-2",
"devDependencies": {
"concurrently": "^9.1.2"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
"version": "0.15.0-1",
"version": "0.15.0-2",
"scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend",