0.15.0-3 Misc Fixes

This commit is contained in:
2026-04-28 09:18:56 +00:00
parent 730bc50715
commit ff8a8f67bf
19 changed files with 834 additions and 157 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.15.0-2",
"version": "0.15.0-3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.15.0-2",
"version": "0.15.0-3",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.15.0-2",
"version": "0.15.0-3",
"private": true,
"type": "module",
"scripts": {
+153 -18
View File
@@ -184,6 +184,78 @@ function trackSizeFormat(bytes) {
return `${size.toFixed(2)} ${units[i]}`;
}
function resolveManualTrackSelectionForTitle(encodePlan, titleId) {
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
const normalizedTitleId = normalizePositiveInteger(titleId);
if (!normalizedTitleId) {
return null;
}
const byTitle = plan?.manualTrackSelectionByTitle && typeof plan.manualTrackSelectionByTitle === 'object'
? plan.manualTrackSelectionByTitle
: {};
const directSelection = byTitle[normalizedTitleId] || byTitle[String(normalizedTitleId)] || null;
if (directSelection && typeof directSelection === 'object') {
return directSelection;
}
const fallbackSelection = plan?.manualTrackSelection && typeof plan.manualTrackSelection === 'object'
? plan.manualTrackSelection
: null;
if (!fallbackSelection) {
return null;
}
const fallbackTitleId = normalizePositiveInteger(fallbackSelection?.titleId);
return fallbackTitleId === normalizedTitleId ? fallbackSelection : null;
}
function buildEffectiveTitleTrackSelection(encodePlan, title) {
const manualSelection = resolveManualTrackSelectionForTitle(encodePlan, title?.id);
const hasManualAudio = Array.isArray(manualSelection?.audioTrackIds);
const hasManualSubtitle = Array.isArray(manualSelection?.subtitleTrackIdsOrdered) || Array.isArray(manualSelection?.subtitleTrackIds);
const selectedAudioIds = hasManualAudio
? normalizeIdList(manualSelection.audioTrackIds)
: normalizeIdList((Array.isArray(title?.audioTracks) ? title.audioTracks : [])
.filter((track) => Boolean(track?.selectedForEncode))
.map((track) => track?.id));
const selectedSubtitleIds = hasManualSubtitle
? normalizeIdList(
Array.isArray(manualSelection?.subtitleTrackIdsOrdered) && manualSelection.subtitleTrackIdsOrdered.length > 0
? manualSelection.subtitleTrackIdsOrdered
: manualSelection?.subtitleTrackIds
)
: normalizeIdList((Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [])
.filter((track) => Boolean(track?.selectedForEncode))
.map((track) => track?.id));
return {
selectedAudioSet: new Set(selectedAudioIds.map((id) => String(id))),
selectedSubtitleSet: new Set(selectedSubtitleIds.map((id) => String(id))),
hasManualAudio,
hasManualSubtitle
};
}
function getTrackActionLabel({
selected = false,
summary = '',
manualSelection = false,
fallback = 'Übernehmen'
}) {
if (!selected) {
return 'Nicht übernommen';
}
const rawSummary = String(summary || '').trim();
if (!rawSummary) {
return manualSelection ? `${fallback} (manuell)` : fallback;
}
if (/^nicht übernommen/i.test(rawSummary)) {
return manualSelection ? `${fallback} (manuell)` : fallback;
}
if (/^preset-default\b/i.test(rawSummary) || /Preset-Default \(HandBrake\)/i.test(rawSummary)) {
return manualSelection ? `${fallback} (manuell)` : 'Übernehmen';
}
return rawSummary;
}
function buildExecutedHandBrakeCommand(handbrakeInfo) {
const cmd = String(handbrakeInfo?.cmd || '').trim();
const args = Array.isArray(handbrakeInfo?.args) ? handbrakeInfo.args : [];
@@ -1378,6 +1450,7 @@ export default function JobDetailDialog({
const selectedEncodeTitles = encodePlanTitles.filter(
(title) => title.selectedForEncode || title.encodeInput || String(title.id) === String(reviewSelectedEncodeTitleId)
);
const trackDisplayTitles = selectedEncodeTitles.length > 0 ? selectedEncodeTitles : encodePlanTitles;
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
const posterUrl = String(
job?.poster_url
@@ -1390,7 +1463,41 @@ export default function JobDetailDialog({
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 outputFolders = (() => {
const baseFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
if (!isDiskContainer) {
return baseFolders;
}
const merged = [];
const seen = new Set();
const addFolder = (folder, fallbackId = null) => {
const outputPath = String(folder?.output_path || '').trim();
if (!outputPath || seen.has(outputPath)) {
return;
}
seen.add(outputPath);
merged.push({
id: folder?.id ?? fallbackId ?? outputPath,
output_path: outputPath
});
};
for (const folder of baseFolders) {
addFolder(folder);
}
for (const child of childJobs) {
const childOutputFolders = Array.isArray(child?.outputFolders) ? child.outputFolders : [];
for (const childFolder of childOutputFolders) {
addFolder(childFolder, `child-folder-${child?.id || 'x'}-${String(childFolder?.output_path || '')}`);
}
if (child?.output_path) {
addFolder(
{ output_path: child.output_path },
`child-output-${child?.id || 'x'}-${String(child.output_path || '')}`
);
}
}
return merged;
})();
const mergeOutputPathCandidates = new Set(
[
...(isMultipartMergeJob ? [job?.output_path] : []),
@@ -2083,7 +2190,7 @@ export default function JobDetailDialog({
</div>
) : null}
{/* Spurauswahl read-only */}
{selectedEncodeTitles.length > 0 ? (
{trackDisplayTitles.length > 0 ? (
isDvdSeries ? (
<details className="episode-track-accordion">
<summary className="series-batch-episode-head">
@@ -2091,9 +2198,10 @@ export default function JobDetailDialog({
</summary>
<div className="spurauswahl-block">
<div className="spurauswahl-label">Spurauswahl</div>
{selectedEncodeTitles.map((title) => {
{trackDisplayTitles.map((title) => {
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
const trackSelection = buildEffectiveTitleTrackSelection(job?.encodePlan, title);
return (
<div key={title.id} className="track-title-block">
<div className="track-title-row">
@@ -2109,9 +2217,16 @@ export default function JobDetailDialog({
const chLayout = trackChLayout(track.channels);
let displayText = `#${track.id} | ${lang} | ${codec}`;
if (chLayout) displayText += ` | ${chLayout}`;
const actionInfo = track.selectedForEncode
? (String(track.encodePreviewSummary || track.encodeActionSummary || '').trim() || 'Copy')
: 'Nicht übernommen';
const normalizedTrackId = normalizePositiveInteger(track?.id);
const selected = normalizedTrackId
? trackSelection.selectedAudioSet.has(String(normalizedTrackId))
: Boolean(track?.selectedForEncode);
const actionInfo = getTrackActionLabel({
selected,
summary: track.encodeActionSummary || track.encodePreviewSummary,
manualSelection: trackSelection.hasManualAudio,
fallback: 'Übernehmen'
});
return (
<div key={track.id} className="track-item">
<span>{displayText}</span>
@@ -2128,10 +2243,16 @@ export default function JobDetailDialog({
const lang = trackLang(track.language || track.languageLabel);
const codec = trackCodec('subtitle', track.format);
const displayText = `#${track.id} | ${lang} | ${codec}`;
const rawAction = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim();
const actionInfo = track.selectedForEncode
? (/^nicht übernommen$/i.test(rawAction) ? 'Übernehmen' : (rawAction || 'Übernehmen'))
: 'Nicht übernommen';
const normalizedTrackId = normalizePositiveInteger(track?.id);
const selected = normalizedTrackId
? trackSelection.selectedSubtitleSet.has(String(normalizedTrackId))
: Boolean(track?.selectedForEncode);
const actionInfo = getTrackActionLabel({
selected,
summary: track.subtitleActionSummary || track.subtitlePreviewSummary,
manualSelection: trackSelection.hasManualSubtitle,
fallback: 'Übernehmen'
});
return (
<div key={track.id} className="track-item">
<span>{displayText}</span>
@@ -2150,9 +2271,10 @@ export default function JobDetailDialog({
) : (
<div className="spurauswahl-block">
<div className="spurauswahl-label">Spurauswahl</div>
{selectedEncodeTitles.map((title) => {
{trackDisplayTitles.map((title) => {
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
const trackSelection = buildEffectiveTitleTrackSelection(job?.encodePlan, title);
return (
<div key={title.id} className="track-title-block">
<div className="track-title-row">
@@ -2168,9 +2290,16 @@ export default function JobDetailDialog({
const chLayout = trackChLayout(track.channels);
let displayText = `#${track.id} | ${lang} | ${codec}`;
if (chLayout) displayText += ` | ${chLayout}`;
const actionInfo = track.selectedForEncode
? (String(track.encodePreviewSummary || track.encodeActionSummary || '').trim() || 'Copy')
: 'Nicht übernommen';
const normalizedTrackId = normalizePositiveInteger(track?.id);
const selected = normalizedTrackId
? trackSelection.selectedAudioSet.has(String(normalizedTrackId))
: Boolean(track?.selectedForEncode);
const actionInfo = getTrackActionLabel({
selected,
summary: track.encodeActionSummary || track.encodePreviewSummary,
manualSelection: trackSelection.hasManualAudio,
fallback: 'Übernehmen'
});
return (
<div key={track.id} className="track-item">
<span>{displayText}</span>
@@ -2187,10 +2316,16 @@ export default function JobDetailDialog({
const lang = trackLang(track.language || track.languageLabel);
const codec = trackCodec('subtitle', track.format);
const displayText = `#${track.id} | ${lang} | ${codec}`;
const rawAction = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim();
const actionInfo = track.selectedForEncode
? (/^nicht übernommen$/i.test(rawAction) ? 'Übernehmen' : (rawAction || 'Übernehmen'))
: 'Nicht übernommen';
const normalizedTrackId = normalizePositiveInteger(track?.id);
const selected = normalizedTrackId
? trackSelection.selectedSubtitleSet.has(String(normalizedTrackId))
: Boolean(track?.selectedForEncode);
const actionInfo = getTrackActionLabel({
selected,
summary: track.subtitleActionSummary || track.subtitlePreviewSummary,
manualSelection: trackSelection.hasManualSubtitle,
fallback: 'Übernehmen'
});
return (
<div key={track.id} className="track-item">
<span>{displayText}</span>
@@ -179,6 +179,85 @@ function resolvePipelineMediaProfile(pipeline, mediaInfoReview) {
return null;
}
function normalizeMakeMkvRipIssueMessage(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function parseMakeMkvRipIssueLine(rawLine) {
const line = String(rawLine || '').trim();
if (!line) {
return null;
}
const sourceMatch = line.match(/^\[[^\]]+\]\s+\[([A-Z0-9_]+)\]\s+/i);
const source = String(sourceMatch?.[1] || '').trim().toUpperCase();
if (source && source !== 'MAKEMKV_RIP') {
return null;
}
const msgCodeMatch = line.match(/\bMSG:(\d+),/i);
const parsedCode = msgCodeMatch ? Number(msgCodeMatch[1]) : null;
const code = Number.isFinite(parsedCode) ? Math.trunc(parsedCode) : null;
const quotedMessageMatch = line.match(/\bMSG:\d+,[^,]*,[^,]*,"((?:[^"\\]|\\.)*)"/i);
const fallback = line
.replace(/^\[[^\]]+\]\s+\[[^\]]+\]\s*/i, '')
.trim();
const rawMessage = quotedMessageMatch?.[1]
? quotedMessageMatch[1].replace(/\\"/g, '"')
: (fallback || line);
const message = normalizeMakeMkvRipIssueMessage(rawMessage);
if (!message) {
return null;
}
const isErrorLine = /\berror\b|\bfail(?:ed|ure)?\b|\bhash check\b|\buncorrectable\b/i.test(message);
if (!isErrorLine) {
return null;
}
return { code, message };
}
function extractMakeMkvRipIssues(makemkvInfo) {
if (!makemkvInfo || typeof makemkvInfo !== 'object') {
return [];
}
const sourceLines = [];
if (Array.isArray(makemkvInfo?.highlights)) {
sourceLines.push(...makemkvInfo.highlights);
}
const stdoutTail = String(makemkvInfo?.stdoutTail || '').trim();
if (stdoutTail) {
sourceLines.push(...stdoutTail.split('\n'));
}
const stderrTail = String(makemkvInfo?.stderrTail || '').trim();
if (stderrTail) {
sourceLines.push(...stderrTail.split('\n'));
}
const seen = new Set();
const issues = [];
for (const line of sourceLines) {
const parsed = parseMakeMkvRipIssueLine(line);
if (!parsed) {
continue;
}
const key = `${parsed.code ?? '-'}|${parsed.message.toLowerCase()}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
issues.push(parsed);
if (issues.length >= 12) {
break;
}
}
return issues;
}
function isBurnedSubtitleTrack(track) {
const flags = Array.isArray(track?.subtitlePreviewFlags)
? track.subtitlePreviewFlags
@@ -2880,6 +2959,33 @@ export default function PipelineStatusCard({
+ `${runningSeriesEpisode?.title ? ` - ${runningSeriesEpisode.title}` : ''}`
)
: (pipeline?.statusText || 'Bereit');
const ripIssueEntries = useMemo(
() => extractMakeMkvRipIssues(jobRow?.makemkvInfo),
[jobRow?.makemkvInfo]
);
const ripIssueVisibleEntries = ripIssueEntries.slice(0, 8);
const hiddenRipIssueCount = Math.max(0, ripIssueEntries.length - ripIssueVisibleEntries.length);
const ripIssueSummaryLabel = ripIssueEntries.length === 1
? '1 Rip-Fehler erkannt'
: `${ripIssueEntries.length} Rip-Fehler erkannt`;
const showRipIssueNotice = Boolean(
(jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray')
&& ripCompleted
&& ripIssueEntries.length > 0
&& !running
&& (
state === 'WAITING_FOR_USER_DECISION'
|| state === 'READY_TO_ENCODE'
|| state === 'MEDIAINFO_CHECK'
|| Boolean(mediaInfoReview)
)
);
const canTriggerRipRetry = Boolean(
showRipIssueNotice
&& retryJobId
&& typeof onRetry === 'function'
&& !queueLocked
);
const isSeriesBatchParentReview = Boolean(
mediaInfoReview?.seriesBatchParent
|| pipeline?.context?.mediaInfoReview?.seriesBatchParent
@@ -3672,6 +3778,42 @@ export default function PipelineStatusCard({
</div>
) : null}
{showRipIssueNotice ? (
<div className="rip-error-notice" role="alert" aria-live="polite">
<div className="rip-error-notice-head">
<i className="pi pi-exclamation-triangle" aria-hidden="true" />
<strong>Hinweis: Beim Rip sind Fehler aufgetreten.</strong>
</div>
<small>{ripIssueSummaryLabel}. Bitte vor der Bestätigung von Playlist-/Spurauswahl prüfen.</small>
<div className="rip-error-notice-list">
{ripIssueVisibleEntries.map((entry, index) => (
<div key={`rip-issue-${entry.code ?? 'none'}-${index}`} className="rip-error-notice-item">
<span className="rip-error-notice-code">{entry.code !== null ? `MSG:${entry.code}` : 'Fehler'}</span>
<span className="rip-error-notice-message">{entry.message}</span>
</div>
))}
</div>
<small className="rip-error-notice-more">
{hiddenRipIssueCount > 0
? `+${hiddenRipIssueCount} weitere Meldung(en) im Job-Log.`
: 'Vollständige Details stehen im Job-Log.'}
</small>
{canTriggerRipRetry ? (
<div className="rip-error-notice-actions">
<Button
label="Retry Rippen"
icon="pi pi-refresh"
severity="warning"
outlined
onClick={() => onRetry?.(retryJobId)}
loading={busy}
disabled={busy || queueLocked || !retryJobId}
/>
</div>
) : null}
</div>
) : null}
{rawDecisionRequiredBeforeStart && !queueLocked ? (
<div className="playlist-decision-block">
<h3>RAW Entscheidung erforderlich</h3>
+4 -4
View File
@@ -1581,8 +1581,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
) {
toastRef.current?.show({
severity: 'warn',
summary: `${deleteEntryOutputShortLabel}-Ordner auswählen`,
detail: `Bitte mindestens einen ${deleteEntryOutputShortLabel}-Ordner zum Löschen auswählen.`,
summary: `${deleteEntryOutputShortLabel}-Dateien auswählen`,
detail: `Bitte mindestens eine ${deleteEntryOutputShortLabel}-Datei zum Löschen auswählen.`,
life: 3500
});
return;
@@ -2471,7 +2471,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
) : null}
<div>
<h4>{deleteEntryOutputShortLabel}</h4>
<h4>{deleteEntryOutputShortLabel} (Dateien)</h4>
{previewMoviePaths.length > 0 ? (() => {
return (
<ul className="history-delete-preview-list">
@@ -2502,7 +2502,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
</ul>
);
})() : (
<small className="history-dv-subtle">Keine Movie-Pfade.</small>
<small className="history-dv-subtle">Keine Output-Dateien.</small>
)}
</div>
</div>
+2 -2
View File
@@ -1079,7 +1079,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
? existingContext.tracks
: computedContext.tracks,
selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata,
selectedMetadata: computedContext.selectedMetadata || existingContext.selectedMetadata,
canRestartEncodeFromLastSettings:
existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
canRestartReviewFromRaw:
@@ -1116,7 +1116,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
tracks: (Array.isArray(liveContext.tracks) && liveContext.tracks.length > 0)
? liveContext.tracks
: computedContext.tracks,
selectedMetadata: liveContext.selectedMetadata || computedContext.selectedMetadata,
selectedMetadata: computedContext.selectedMetadata || liveContext.selectedMetadata,
cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig
}
: computedContext;
+68
View File
@@ -3705,6 +3705,74 @@ body {
margin: 0;
}
.rip-error-notice {
margin-top: 1rem;
border: 1px solid #d48c7c;
border-left: 4px solid #b6452c;
border-radius: 0.5rem;
padding: 0.75rem;
display: grid;
gap: 0.5rem;
background: #fff1ec;
color: #4e2418;
}
.rip-error-notice-head {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.rip-error-notice-head .pi {
color: #b6452c;
}
.rip-error-notice-list {
display: grid;
gap: 0.4rem;
}
.rip-error-notice-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.5rem;
align-items: flex-start;
padding: 0.35rem 0.45rem;
border: 1px dashed #d8a79b;
border-radius: 0.4rem;
background: #fff8f6;
}
.rip-error-notice-code {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 4.9rem;
padding: 0.1rem 0.4rem;
border-radius: 0.3rem;
background: #b6452c;
color: #fff;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.rip-error-notice-message {
font-size: 0.83rem;
line-height: 1.3;
word-break: break-word;
}
.rip-error-notice-more {
color: #7a3a2a;
}
.rip-error-notice-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.playlist-decision-list {
display: grid;
gap: 0.75rem;