0.16.1-5 Ebcode Fix / presets
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled

This commit is contained in:
2026-04-30 12:37:32 +00:00
parent f9cf3cef09
commit 6fbc5395a1
24 changed files with 1131 additions and 784 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.16.1-4",
"version": "0.16.1-5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.16.1-4",
"version": "0.16.1-5",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.16.1-4",
"version": "0.16.1-5",
"private": true,
"type": "module",
"scripts": {
+33 -5
View File
@@ -13,7 +13,6 @@ import HistoryPage from './pages/HistoryPage';
import DatabasePage from './pages/DatabasePage';
import DownloadsPage from './pages/DownloadsPage';
import ConverterPage from './pages/ConverterPage';
import JobsInboxPage from './pages/JobsInboxPage';
import AudiobooksPage from './pages/AudiobooksPage';
function normalizeJobId(value) {
@@ -150,6 +149,14 @@ function App() {
const navigate = useNavigate();
const globalToastRef = useRef(null);
const prevCdDrivesRef = useRef({});
const hardwareWarmupRef = useRef({ timer: null, attempts: 0 });
const clearHardwareWarmupRetry = () => {
if (hardwareWarmupRef.current?.timer) {
clearTimeout(hardwareWarmupRef.current.timer);
hardwareWarmupRef.current.timer = null;
}
};
// When a virtual CD drive is removed (CD encode/rip finished or failed),
// or when a CD drive transitions away from an active job, force both
@@ -194,7 +201,23 @@ function App() {
const refreshPipeline = async () => {
const response = await api.getPipelineState();
setPipeline(response.pipeline);
setHardwareMonitoring(response?.hardwareMonitoring || null);
const monitoringPayload = response?.hardwareMonitoring || null;
setHardwareMonitoring(monitoringPayload);
const monitoringEnabled = Boolean(monitoringPayload?.enabled);
const hasSample = Boolean(monitoringPayload?.sample && typeof monitoringPayload.sample === 'object');
if (monitoringEnabled && !hasSample) {
const nextAttempts = Number(hardwareWarmupRef.current?.attempts || 0) + 1;
hardwareWarmupRef.current.attempts = nextAttempts;
if (nextAttempts <= 10 && !hardwareWarmupRef.current.timer) {
hardwareWarmupRef.current.timer = setTimeout(() => {
hardwareWarmupRef.current.timer = null;
refreshPipeline().catch(() => null);
}, 1000);
}
} else {
hardwareWarmupRef.current.attempts = 0;
clearHardwareWarmupRetry();
}
return response;
};
@@ -310,10 +333,17 @@ function App() {
setExpertMode(val === 'true' || val === true);
})
.catch(() => null);
return () => {
clearHardwareWarmupRetry();
};
}, []);
useWebSocket({
onMessage: (message) => {
if (message.type === 'WS_CONNECTED') {
refreshPipeline().catch(() => null);
}
if (message.type === 'PIPELINE_STATE_CHANGED') {
setPipeline(message.payload);
}
@@ -575,7 +605,6 @@ function App() {
});
const nav = [
{ label: 'Jobs', path: '/jobs' },
{ label: 'Ripper', path: '/ripper' },
{ label: 'Converter', path: '/converter' },
{ label: 'Audiobooks', path: '/audiobooks' },
@@ -705,13 +734,12 @@ function App() {
</RipperPage>
}
>
<Route index element={<Navigate to="jobs" replace />} />
<Route index element={<Navigate to="ripper" replace />} />
<Route path="ripper" element={null} />
<Route path="settings" element={<SettingsPage />} />
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
<Route path="database" element={<DatabasePage />} />
<Route path="jobs" element={<JobsInboxPage />} />
<Route path="converter" element={<ConverterPage />} />
<Route
path="audiobooks"
+17 -3
View File
@@ -1012,12 +1012,26 @@ export const api = {
forceRefresh: options.forceRefresh
});
},
getUserPresetDefaults(options = {}) {
return requestCachedGet('/settings/user-preset-defaults', {
ttlMs: 2 * 60 * 1000,
forceRefresh: options.forceRefresh
});
},
async updateUserPresetDefaults(defaults = {}) {
const result = await request('/settings/user-preset-defaults', {
method: 'PUT',
body: JSON.stringify({ defaults })
});
afterMutationInvalidate(['/settings/user-preset-defaults']);
return result;
},
async createUserPreset(payload = {}) {
const result = await request('/settings/user-presets', {
method: 'POST',
body: JSON.stringify(payload)
});
afterMutationInvalidate(['/settings/user-presets']);
afterMutationInvalidate(['/settings/user-presets', '/settings/user-preset-defaults']);
return result;
},
async updateUserPreset(id, payload = {}) {
@@ -1025,14 +1039,14 @@ export const api = {
method: 'PUT',
body: JSON.stringify(payload)
});
afterMutationInvalidate(['/settings/user-presets']);
afterMutationInvalidate(['/settings/user-presets', '/settings/user-preset-defaults']);
return result;
},
async deleteUserPreset(id) {
const result = await request(`/settings/user-presets/${encodeURIComponent(id)}`, {
method: 'DELETE'
});
afterMutationInvalidate(['/settings/user-presets']);
afterMutationInvalidate(['/settings/user-presets', '/settings/user-preset-defaults']);
return result;
},
+280 -73
View File
@@ -955,32 +955,7 @@ function parseAudioSelectorFromArgs(extraArgsString, baseSelector) {
return base;
}
// Tokenize: split on whitespace but respect quoted strings
const tokens = [];
let current = '';
let inQuote = null;
for (let i = 0; i < args.length; i++) {
const ch = args[i];
if (inQuote) {
if (ch === inQuote) {
inQuote = null;
} else {
current += ch;
}
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === ' ' || ch === '\t') {
if (current) {
tokens.push(current);
current = '';
}
} else {
current += ch;
}
}
if (current) {
tokens.push(current);
}
const tokens = tokenizeCliArgs(args);
const result = { ...base };
@@ -1026,6 +1001,113 @@ function parseAudioSelectorFromArgs(extraArgsString, baseSelector) {
return result;
}
function parseSubtitleSelectorFromArgs(extraArgsString, baseSelector) {
const base = baseSelector && typeof baseSelector === 'object' ? baseSelector : {};
const args = String(extraArgsString || '').trim();
if (!args) {
return base;
}
const tokens = tokenizeCliArgs(args);
const result = { ...base };
const getNext = (i) => (i + 1 < tokens.length ? tokens[i + 1] : null);
const parseTrackId = (raw) => {
const first = String(raw || '').split(',').map((item) => String(item || '').trim()).filter(Boolean)[0] || '';
const parsed = Number(first);
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : null;
};
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const eqIdx = token.indexOf('=');
const flag = eqIdx !== -1 ? token.slice(0, eqIdx) : token;
const inlineVal = eqIdx !== -1 ? token.slice(eqIdx + 1) : null;
const getValue = () => {
if (inlineVal !== null) return inlineVal;
const next = getNext(i);
if (next && !next.startsWith('-')) {
i++;
return next;
}
return null;
};
if (flag === '--all-subtitles') {
result.mode = 'all';
} else if (flag === '--first-subtitle') {
result.mode = 'first';
result.firstOnly = true;
} else if (flag === '--subtitle-lang-list') {
const val = getValue();
if (val) {
result.mode = 'language';
result.languages = val.split(',').map((item) => String(item || '').trim()).filter(Boolean);
}
} else if (flag === '--subtitle' || flag === '-s') {
const val = getValue();
if (val) {
const normalized = String(val || '').trim().toLowerCase();
result.mode = normalized === 'none' ? 'none' : 'explicit';
}
} else if (flag === '--subtitle-burned') {
result.burnBehavior = 'first';
const val = getValue();
const parsedTrack = parseTrackId(val);
if (parsedTrack !== null) {
result.burnedTrackId = parsedTrack;
}
} else if (flag === '--subtitle-default') {
const val = getValue();
const parsedTrack = parseTrackId(val);
if (parsedTrack !== null) {
result.defaultTrackId = parsedTrack;
}
} else if (flag === '--subtitle-forced') {
const val = getValue();
const parsedTrack = parseTrackId(val);
if (parsedTrack !== null) {
result.forcedTrackId = parsedTrack;
result.forcedOnly = false;
} else {
result.forcedOnly = true;
}
}
}
return result;
}
function tokenizeCliArgs(argsString) {
const source = String(argsString || '');
const tokens = [];
let current = '';
let inQuote = null;
for (let i = 0; i < source.length; i++) {
const ch = source[i];
if (inQuote) {
if (ch === inQuote) {
inQuote = null;
} else {
current += ch;
}
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === ' ' || ch === '\t') {
if (current) {
tokens.push(current);
current = '';
}
} else {
current += ch;
}
}
if (current) {
tokens.push(current);
}
return tokens;
}
function buildAudioActionPreviewSummary(track, selectedIndex, audioSelector) {
const selector = audioSelector && typeof audioSelector === 'object' ? audioSelector : {};
const availableEncoders = Array.isArray(selector.encoders) ? selector.encoders : [];
@@ -1412,7 +1494,10 @@ export default function MediaInfoReviewPanel({
selectedUserPresetId = null,
onUserPresetChange = null,
selectedHandBrakePreset = '',
comparisonReviewItems = []
onHandBrakePresetChange = null,
handBrakePresetOptions = [],
comparisonReviewItems = [],
topContent = null
}) {
if (!review) {
return <p>Keine Mediainfo-Daten vorhanden.</p>;
@@ -1647,6 +1732,11 @@ export default function MediaInfoReviewPanel({
const processedFiles = Number(review.processedFiles || displayTitles.length || 0);
const totalFiles = Number(review.totalFiles || displayTitles.length || 0);
const playlistRecommendation = review.playlistRecommendation || null;
const selectedPlaylistId = String(
review?.selectedPlaylistId
?? review?.selectedPlaylist
?? ''
).trim();
const rawPreset = String(review.selectors?.preset || '').trim();
const presetLabel = String(presetDisplayValue || rawPreset).trim() || '(kein Preset)';
const multipartSettingsLock = review?.multipartSettingsLock && typeof review.multipartSettingsLock === 'object'
@@ -1661,14 +1751,47 @@ export default function MediaInfoReviewPanel({
&& Number(multipartSettingsLock.sourceDiscNumber) > 0
? Math.trunc(Number(multipartSettingsLock.sourceDiscNumber))
: null;
const isTitleSelectionMode = Boolean(
review?.playlistDecisionRequired
|| review?.titleSelectionRequired
|| review?.handBrakeTitleDecisionRequired
);
const playlistAlreadyAccepted = !isTitleSelectionMode && Boolean(selectedPlaylistId);
const hiddenTechnicalNotePatternsWhenPlaylistAccepted = [
/^Preset:\s/i,
/^Extra Args:\s/i,
/^Preset-Quelle:\s/i,
/^Preset-Defaults werden als Basis genutzt\./i,
/^Preset-Hinweis:\s/i,
/^Manuelle Playlist-Auswahl aktiv:\s/i,
/^MakeMKV Full-Analyse wurde einmal für Playlist-\/Titel-Mapping verwendet\./i,
/^HandBrake Track-Analyse aktiv:\s/i
];
const visibleReviewNotes = Array.isArray(review.notes)
? review.notes.filter((note) => {
const normalized = String(note || '').trim();
if (!normalized) {
return false;
}
if (!playlistAlreadyAccepted) {
return true;
}
return !hiddenTechnicalNotePatternsWhenPlaylistAccepted
.some((pattern) => pattern.test(normalized));
})
: [];
// User preset resolution
const normalizedUserPresets = Array.isArray(userPresets) ? userPresets : [];
const normalizedHandBrakePresetOptions = Array.isArray(handBrakePresetOptions) ? handBrakePresetOptions : [];
const selectedUserPreset = selectedUserPresetId
? normalizedUserPresets.find((p) => Number(p.id) === Number(selectedUserPresetId)) || null
: null;
const hasUserPresets = normalizedUserPresets.length > 0;
const allowUserPresetSelection = hasUserPresets && typeof onUserPresetChange === 'function' && allowEncodeItemSelection;
const hasHandBrakePresetOptions = normalizedHandBrakePresetOptions.some((option) => !option?.disabled);
const allowPresetSelection = allowEncodeItemSelection
&& (typeof onUserPresetChange === 'function' || typeof onHandBrakePresetChange === 'function')
&& (hasUserPresets || hasHandBrakePresetOptions);
const isUserPresetActive = Boolean(selectedUserPreset);
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
const isHandBrakePresetActive = !isUserPresetActive && Boolean(trimmedHandBrakePreset);
@@ -1678,9 +1801,97 @@ export default function MediaInfoReviewPanel({
const effectiveAudioSelector = effectivePresetOverride?.extraArgs
? parseAudioSelectorFromArgs(effectivePresetOverride.extraArgs, review?.selectors?.audio)
: (review?.selectors?.audio || null);
const presetSourceLabel = isUserPresetActive
? `User-Preset: ${selectedUserPreset?.name || 'Preset'}`
: (isHandBrakePresetActive ? 'HandBrake-Preset' : '');
const activePresetDisplayLabel = isUserPresetActive
? 'User-Preset'
: ((isHandBrakePresetActive || String(presetLabel || '').trim()) ? 'HandBrake-Preset' : '(kein Preset)');
const minLengthMinutes = Number(review?.minLengthMinutes);
const minLengthLabel = Number.isFinite(minLengthMinutes)
? (minLengthMinutes <= 0
? 'Kein Mindestwert (deaktiviert)'
: `${Math.trunc(minLengthMinutes)} Minute(n)`)
: '-';
const subtitleSelector = review?.selectors?.subtitle && typeof review.selectors.subtitle === 'object'
? review.selectors.subtitle
: {};
const effectiveSubtitleSelector = effectivePresetOverride?.extraArgs
? parseSubtitleSelectorFromArgs(effectivePresetOverride.extraArgs, subtitleSelector)
: subtitleSelector;
const subtitleModeLabelMap = {
all: 'Alle Spuren',
first: 'Erste Spur',
language: 'Nach Sprache',
explicit: 'Manuell (Track-IDs)',
none: 'Keine Spuren'
};
const subtitleModeLabel = subtitleModeLabelMap[String(effectiveSubtitleSelector?.mode || '').trim().toLowerCase()] || '-';
const subtitleFlagLabels = [];
if (effectiveSubtitleSelector?.burnBehavior === 'first') {
subtitleFlagLabels.push('Burn-In (erste Subtitle-Spur)');
}
if (effectiveSubtitleSelector?.defaultTrackId != null) {
subtitleFlagLabels.push(`Default-Track #${effectiveSubtitleSelector.defaultTrackId}`);
}
if (effectiveSubtitleSelector?.forcedTrackId != null) {
subtitleFlagLabels.push(`Forced-Track #${effectiveSubtitleSelector.forcedTrackId}`);
} else if (effectiveSubtitleSelector?.forcedOnly) {
subtitleFlagLabels.push('Forced-Only');
}
const subtitleFlagsLabel = subtitleFlagLabels.join(', ') || 'Keine';
const audioEncoderSourceLabel = String(effectiveAudioSelector?.encoderSource || '').trim() || 'default';
const audioCopyMaskSourceLabel = String(effectiveAudioSelector?.copyMaskSource || '').trim() || 'default';
const audioFallbackSourceLabel = String(effectiveAudioSelector?.fallbackSource || '').trim() || 'default';
const presetDropdownOptions = (() => {
const options = [];
if (hasUserPresets) {
options.push({ label: 'User-Presets/', value: '__group__userpresets', disabled: true });
for (const preset of normalizedUserPresets) {
const presetId = Number(preset?.id);
if (!Number.isFinite(presetId) || presetId <= 0) {
continue;
}
const mediaType = String(preset?.mediaType || '').trim().toLowerCase();
const mediaTypeLabel = mediaType && mediaType !== 'all'
? ` [${mediaType === 'bluray' ? 'Blu-ray' : mediaType === 'dvd' ? 'DVD' : mediaType}]`
: '';
options.push({
label: `\u00A0\u00A0\u00A0${String(preset?.name || `Preset #${presetId}`).trim() || `Preset #${presetId}`}${mediaTypeLabel}`,
value: `user:${Math.trunc(presetId)}`
});
}
}
for (const option of normalizedHandBrakePresetOptions) {
if (!option) {
continue;
}
if (option.disabled) {
options.push({
label: String(option?.label || '').trim() || 'HandBrake/',
value: String(option?.value || option?.label || '').trim() || `__group__hb_${options.length}`,
disabled: true
});
continue;
}
const value = String(option?.value || '').trim();
if (!value) {
continue;
}
options.push({
label: String(option?.label || value).trim() || value,
value: `hb:${value}`
});
}
const selectedPresetValue = String(selectedHandBrakePreset || '').trim();
if (selectedPresetValue && !options.some((entry) => String(entry?.value || '').trim() === `hb:${selectedPresetValue}`)) {
options.push({
label: selectedPresetValue,
value: `hb:${selectedPresetValue}`
});
}
return options;
})();
const selectedPresetToken = isUserPresetActive
? `user:${selectedUserPreset.id}`
: (trimmedHandBrakePreset ? `hb:${trimmedHandBrakePreset}` : null);
const scriptCatalog = (Array.isArray(availableScripts) ? availableScripts : [])
.map((item) => ({
@@ -1872,22 +2083,42 @@ export default function MediaInfoReviewPanel({
return (
<div className="media-review-wrap">
{!compactTitleSelection && allowUserPresetSelection && (
{topContent}
{!compactTitleSelection && allowPresetSelection && (
<div className="user-preset-selector" style={{ marginBottom: '0.75rem', padding: '0.75rem', border: '1px solid var(--surface-border, #e0e0e0)', borderRadius: '6px', background: 'var(--surface-ground, #f8f8f8)' }}>
<label style={{ display: 'block', marginBottom: '0.4rem', fontWeight: 600 }}>
Encode-Preset auswählen
</label>
<Dropdown
value={selectedUserPresetId ? Number(selectedUserPresetId) : null}
options={[
{ label: '(Einstellungen-Fallback)', value: null },
...normalizedUserPresets.map((p) => ({
label: `${p.name}${p.mediaType !== 'all' ? ` [${p.mediaType === 'bluray' ? 'Blu-ray' : p.mediaType === 'dvd' ? 'DVD' : 'Sonstiges'}]` : ''}`,
value: Number(p.id)
}))
]}
onChange={(e) => onUserPresetChange(e.value)}
placeholder="(Einstellungen-Fallback)"
value={selectedPresetToken}
options={presetDropdownOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(e) => {
const value = String(e?.value || '').trim();
if (value.startsWith('user:')) {
const presetId = Number(value.slice(5));
if (typeof onUserPresetChange === 'function') {
onUserPresetChange(Number.isFinite(presetId) && presetId > 0 ? Math.trunc(presetId) : null);
}
if (typeof onHandBrakePresetChange === 'function') {
onHandBrakePresetChange('');
}
return;
}
if (value.startsWith('hb:')) {
const presetName = value.slice(3);
if (typeof onUserPresetChange === 'function') {
onUserPresetChange(null);
}
if (typeof onHandBrakePresetChange === 'function') {
onHandBrakePresetChange(presetName);
}
}
}}
placeholder="Preset auswählen …"
filter
style={{ width: '100%' }}
/>
{selectedUserPreset && (
@@ -1905,45 +2136,21 @@ export default function MediaInfoReviewPanel({
)}
</div>
)}
{!compactTitleSelection ? (
{!compactTitleSelection && !isTitleSelectionMode ? (
<div className="media-review-meta">
<div>
<strong>Preset:</strong>{' '}
{effectivePresetOverride
? (effectivePresetOverride.handbrakePreset || '(kein Preset)')
: presetLabel}
{effectivePresetOverride && presetSourceLabel && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>
({presetSourceLabel})
</span>
)}
</div>
<div>
<strong>Extra Args:</strong>{' '}
{effectivePresetOverride
? (effectivePresetOverride.extraArgs || '(keine)')
: (review.selectors?.extraArgs || '(keine)')}
{effectivePresetOverride && isUserPresetActive && !selectedUserPreset?.extraArgs && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>(aus User-Preset)</span>
)}
</div>
<div><strong>Preset-Profil:</strong> {effectivePresetOverride ? (isUserPresetActive ? 'user-preset' : 'handbrake-preset') : (review.selectors?.presetProfileSource || '-')}</div>
<div><strong>MIN_LENGTH_MINUTES:</strong> {review.minLengthMinutes}</div>
<div><strong>Encode Input:</strong> {encodeInputTitle?.fileName || '-'}</div>
<div><strong>Audio Auswahl:</strong> {effectiveAudioSelector?.mode || '-'}</div>
<div><strong>Audio Encoder:</strong> {(effectiveAudioSelector?.encoders || []).join(', ') || 'Preset-Default'}</div>
<div><strong>Audio Copy-Mask:</strong> {(effectiveAudioSelector?.copyMask || []).join(', ') || '-'}</div>
<div><strong>Audio Fallback:</strong> {effectiveAudioSelector?.fallbackEncoder || '-'}</div>
<div><strong>Subtitle Auswahl:</strong> {review.selectors?.subtitle?.mode || '-'}</div>
<div><strong>Subtitle Flags:</strong> {review.selectors?.subtitle?.burnBehavior === 'first' ? 'burned(first)' : '-'}</div>
{activePresetDisplayLabel}
</div>
<div><strong>Titel-Mindestlänge:</strong> {minLengthLabel}</div>
</div>
) : null}
{!compactTitleSelection && review.partial ? (
<small>Zwischenstand: {processedFiles}/{totalFiles} Datei(en) analysiert.</small>
) : null}
{!compactTitleSelection && playlistRecommendation ? (
{!compactTitleSelection && !isTitleSelectionMode && !playlistAlreadyAccepted && playlistRecommendation ? (
<div className="playlist-recommendation-box">
<small>
<strong>Empfehlung:</strong> {playlistRecommendation.playlistFile || '-'}
@@ -1953,9 +2160,9 @@ export default function MediaInfoReviewPanel({
</div>
) : null}
{!compactTitleSelection && Array.isArray(review.notes) && review.notes.length > 0 ? (
{!compactTitleSelection && !isTitleSelectionMode && visibleReviewNotes.length > 0 ? (
<div className="media-review-notes">
{review.notes.map((note, idx) => (
{visibleReviewNotes.map((note, idx) => (
<small key={`${idx}-${note}`}>{note}</small>
))}
</div>
+100 -37
View File
@@ -829,6 +829,16 @@ function formatPercentNoDecimals(value) {
return `${Math.trunc(Math.max(0, Math.min(100, parsed)))}%`;
}
function isPlaylistTrackPreparationStatus(value) {
const normalized = String(value || '').trim().toUpperCase();
if (!normalized) {
return false;
}
return normalized.includes('PLAYLIST')
&& normalized.includes('TRACKDATEN')
&& normalized.includes('VORBEREIT');
}
function getSeriesBatchTagSeverity(status) {
const normalized = normalizeSeriesBatchStatus(status);
if (normalized === 'FINISHED') {
@@ -1401,7 +1411,7 @@ function resolveProfiledSetting(settings, key, mediaProfile) {
? ['dvd']
: mediaProfile === 'dvd'
? ['bluray']
: [];
: ['bluray', 'dvd', 'cd', 'audiobook'];
for (const fb of fallbackProfiles) {
const fbKey = `${key}_${fb}`;
if (settings?.[fbKey] != null && settings[fbKey] !== '') {
@@ -1850,12 +1860,17 @@ export default function PipelineStatusCard({
const nextOutputFormat = String(mediaInfoReview?.outputFormat || 'mkv').trim().toLowerCase() || 'mkv';
setSelectedOutputFormat(nextOutputFormat);
const userPresetId = Number(mediaInfoReview?.userPreset?.id);
const settingsPresetName = String(mediaInfoReview?.selectors?.preset || '').trim();
if (Number.isFinite(userPresetId) && userPresetId > 0) {
setSelectedUserPresetId(Math.trunc(userPresetId));
setSelectedHandBrakePreset('');
} else {
setSelectedUserPresetId(null);
const hbPresetName = String(mediaInfoReview?.userPreset?.handbrakePreset || '').trim();
const hbPresetName = String(
mediaInfoReview?.userPreset?.handbrakePreset
|| settingsPresetName
|| ''
).trim();
setSelectedHandBrakePreset(hbPresetName);
}
}, [
@@ -1866,6 +1881,7 @@ export default function PipelineStatusCard({
mediaInfoReview?.outputFormat,
mediaInfoReview?.userPreset?.id,
mediaInfoReview?.userPreset?.handbrakePreset,
mediaInfoReview?.selectors?.preset,
selectedMetadata?.seasonNumber,
selectedMetadata?.episodes,
selectedMetadata?.metadataProvider,
@@ -2294,10 +2310,12 @@ export default function PipelineStatusCard({
const hasSelectedEncodeTitle = normalizeTitleIdList(selectedEncodeTitleIds).length > 0;
const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle;
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
const settingsPresetFallback = String(mediaInfoReview?.selectors?.preset || '').trim();
const hasSelectedPreset = Boolean(selectedUserPresetId) || Boolean(trimmedHandBrakePreset);
const hasEffectiveSelectedPreset = hasSelectedPreset || Boolean(settingsPresetFallback);
const converterMediaType = String(mediaInfoReview?.converterMediaType || 'video').trim().toLowerCase() || 'video';
const showConverterConfig = jobMediaProfile === 'converter' && converterMediaType !== 'audio';
const requiresPresetSelection = showConverterConfig;
const requiresPresetSelection = showConverterConfig || jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray';
const multipartSettingsLock = mediaInfoReview?.multipartSettingsLock
&& typeof mediaInfoReview.multipartSettingsLock === 'object'
? mediaInfoReview.multipartSettingsLock
@@ -2335,7 +2353,7 @@ export default function PipelineStatusCard({
const canStartReadyJob = (isPreRipReview
? Boolean(retryJobId)
: Boolean(retryJobId && encodeInputPath))
&& (!requiresPresetSelection || hasSelectedPreset);
&& (!requiresPresetSelection || hasEffectiveSelectedPreset);
const canRestartEncodeFromLastSettings = Boolean(
(state === 'ERROR' || state === 'CANCELLED')
&& retryJobId
@@ -3076,6 +3094,8 @@ export default function PipelineStatusCard({
+ `${runningSeriesEpisode?.title ? ` - ${runningSeriesEpisode.title}` : ''}`
)
: (pipeline?.statusText || 'Bereit');
const showIndeterminatePrimaryProgress = !hasSeriesBatchProgress
&& isPlaylistTrackPreparationStatus(pipeline?.statusText);
const ripIssueEntries = useMemo(
() => extractMakeMkvRipIssues(jobRow?.makemkvInfo),
[jobRow?.makemkvInfo]
@@ -3594,13 +3614,19 @@ export default function PipelineStatusCard({
{running && (
<div className="progress-wrap">
<ProgressBar
value={primaryProgressValue}
showValue
displayValueTemplate={(value) => formatPercentNoDecimals(value)}
/>
{showIndeterminatePrimaryProgress ? (
<ProgressBar mode="indeterminate" showValue={false} />
) : (
<ProgressBar
value={primaryProgressValue}
showValue
displayValueTemplate={(value) => formatPercentNoDecimals(value)}
/>
)}
<small>
{primaryEta
{showIndeterminatePrimaryProgress
? 'Vorbereitung läuft …'
: primaryEta
? (hasSeriesBatchProgress ? `ETA aktuelle Episode ${primaryEta}` : `ETA ${primaryEta}`)
: 'ETA unbekannt'}
</small>
@@ -3791,10 +3817,14 @@ export default function PipelineStatusCard({
selectedPostEncodeScriptIds: selectedPostScriptIds,
selectedPreEncodeScriptIds: selectedPreScriptIds,
selectedPostEncodeChainIds: selectedPostChainIds,
selectedPreEncodeChainIds: selectedPreChainIds,
selectedHandBrakePreset
selectedPreEncodeChainIds: selectedPreChainIds
};
startPayload.selectedUserPresetId = selectedUserPresetId || null;
if (selectedUserPresetId !== null && selectedUserPresetId !== undefined) {
startPayload.selectedUserPresetId = selectedUserPresetId;
}
if (String(selectedHandBrakePreset || '').trim()) {
startPayload.selectedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
}
await onStart(retryJobId, startPayload);
}}
loading={busy}
@@ -4139,7 +4169,7 @@ export default function PipelineStatusCard({
</div>
) : null}
{(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? (
{(mediaInfoReview || state === 'READY_TO_ENCODE') && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? (
<div className="mediainfo-review-block">
{!isDiscTitleSelectionRequired ? <h3>Titel-/Spurprüfung</h3> : null}
{state === 'READY_TO_ENCODE' && !queueLocked && !isDiscTitleSelectionRequired ? (
@@ -4150,16 +4180,6 @@ export default function PipelineStatusCard({
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
</small>
) : null}
{multipartSettingsLocked ? (
<div className="playlist-decision-block">
<h3>Multipart-Lock aktiv</h3>
<small>
Die Encode-Einstellungen wurden automatisch von der bereits vorhandenen Disc übernommen und sind gesperrt.
{multipartLockSourceJobId ? ` Quelle: Job #${multipartLockSourceJobId}.` : ''}
{multipartLockSourceDiscNumber ? ` Referenz-Disc: ${multipartLockSourceDiscNumber}.` : ''}
</small>
</div>
) : null}
{showConverterConfig ? (
<div style={{ marginTop: '0.75rem', marginBottom: '0.75rem', padding: '0.75rem', border: '1px solid var(--surface-border, #e0e0e0)', borderRadius: '6px', background: 'var(--surface-ground, #f8f8f8)' }}>
<div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
@@ -4227,7 +4247,7 @@ export default function PipelineStatusCard({
) : null}
</div>
</div>
{allowConverterConfigEdit && !hasSelectedPreset ? (
{allowConverterConfigEdit && !hasEffectiveSelectedPreset ? (
<small style={{ display: 'block', marginTop: '0.5rem', color: 'var(--red-600)' }}>
Bitte wähle ein User- oder HandBrake-Preset aus, bevor das Encoding starten kann.
</small>
@@ -4245,22 +4265,49 @@ export default function PipelineStatusCard({
) : null}
</div>
) : null}
{isSeriesDvdReview && hasMultiEpisodeInJob ? (
<div className="playlist-decision-block">
<h3>Hinweis Episoden-Zuordnung</h3>
<small>
Im Job wurden Hinweise zur Episoden-Zuordnung erkannt. Bitte Episodentitel und Episodenbereich prüfen, bevor das Encoding startet.
</small>
{episodeAssignmentWarningLines.map((line, index) => (
<small key={`episode-assignment-warning-${index}`}>{line}</small>
))}
</div>
) : null}
<MediaInfoReviewPanel
review={mediaInfoReview}
presetDisplayValue={presetDisplayValue}
commandOutputPath={commandOutputPath}
commandOutputPathByTitle={commandOutputPathByTitle}
topContent={(
<>
{multipartSettingsLocked ? (
<div className="playlist-decision-block">
<h3>Multipart-Lock aktiv</h3>
<small>
Die Encode-Einstellungen wurden automatisch von der bereits vorhandenen Disc übernommen und sind gesperrt.
{multipartLockSourceJobId ? ` Quelle: Job #${multipartLockSourceJobId}.` : ''}
{multipartLockSourceDiscNumber ? ` Referenz-Disc: ${multipartLockSourceDiscNumber}.` : ''}
</small>
</div>
) : null}
{isSeriesDvdReview && hasMultiEpisodeInJob ? (
<div className="playlist-decision-block">
<h3>Hinweis Episoden-Zuordnung</h3>
<small>
Im Job wurden Hinweise zur Episoden-Zuordnung erkannt. Bitte Episodentitel und Episodenbereich prüfen, bevor das Encoding startet.
</small>
{episodeAssignmentWarningLines.map((line, index) => (
<small key={`episode-assignment-warning-${index}`}>{line}</small>
))}
</div>
) : null}
{!showConverterConfig
&& requiresPresetSelection
&& allowReviewSelectionEdit
&& !hasEffectiveSelectedPreset
&& state === 'READY_TO_ENCODE'
&& mediaInfoReview ? (
<div className="playlist-decision-block">
<h3>Preset-Auswahl erforderlich</h3>
<small>
Es ist weder ein User-Preset noch ein Settings-Preset gesetzt. Bitte ein Preset auswählen, bevor das Encoding starten kann.
</small>
</div>
) : null}
</>
)}
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
allowTitleSelection={allowTitleSelectionInReview}
@@ -4451,8 +4498,24 @@ export default function PipelineStatusCard({
postEncodeItems={postEncodeItems}
userPresets={effectiveUserPresets}
selectedUserPresetId={selectedUserPresetId}
onUserPresetChange={showConverterConfig || multipartSettingsLocked ? null : (presetId) => setSelectedUserPresetId(presetId)}
onUserPresetChange={showConverterConfig || multipartSettingsLocked ? null : (presetId) => {
const nextPresetId = Number.isFinite(Number(presetId)) && Number(presetId) > 0
? Math.trunc(Number(presetId))
: null;
setSelectedUserPresetId(nextPresetId);
if (nextPresetId) {
setSelectedHandBrakePreset('');
}
}}
selectedHandBrakePreset={selectedHandBrakePreset}
onHandBrakePresetChange={showConverterConfig || multipartSettingsLocked ? null : (presetName) => {
const nextPresetName = String(presetName || '').trim();
setSelectedHandBrakePreset(nextPresetName);
if (nextPresetName) {
setSelectedUserPresetId(null);
}
}}
handBrakePresetOptions={handBrakePresetOptions}
allowEncodeItemSelection={allowEncodeItemSelectionInReview}
comparisonReviewItems={multipartComparisonReviews}
onAddPreEncodeItem={(itemType) => {
-357
View File
@@ -1,357 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { InputText } from 'primereact/inputtext';
import { Tag } from 'primereact/tag';
import { Paginator } from 'primereact/paginator';
import { api } from '../api/client';
import { classifyJob } from '../utils/jobTaxonomy';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
const ACTIVE_STATUSES = [
'ANALYZING',
'METADATA_SELECTION',
'WAITING_FOR_USER_DECISION',
'READY_TO_START',
'MEDIAINFO_CHECK',
'READY_TO_ENCODE',
'RIPPING',
'ENCODING',
'CD_METADATA_SELECTION',
'CD_READY_TO_RIP',
'CD_ANALYZING',
'CD_RIPPING',
'CD_ENCODING'
];
const STATUS_FILTERS = [
{ key: 'all', label: 'Alle' },
{ key: 'active', label: 'Aktiv' },
{ key: 'errors', label: 'Fehler/Abbruch' }
];
const VIEW_FILTERS = [
{ key: 'all', label: 'Alle Jobs' },
{ key: 'ripper', label: 'Ripper-View' },
{ key: 'converter', label: 'Converter-View' },
{ key: 'audiobook', label: 'Audiobooks' }
];
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function formatUpdatedAt(value) {
if (!value) {
return '-';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return '-';
}
return parsed.toLocaleString('de-DE');
}
function getKindLabel(meta) {
if (meta.family === 'converter') {
if (meta.converterMediaType === 'audio') {
return 'Converter Audio';
}
if (meta.converterMediaType === 'iso') {
return 'Converter ISO';
}
return 'Converter Video';
}
if (meta.family === 'audiobook') {
return 'Audiobook';
}
if (meta.mediaType === 'bluray') {
return 'Blu-ray';
}
if (meta.mediaType === 'dvd') {
return 'DVD';
}
if (meta.mediaType === 'cd') {
return 'CD';
}
return 'Sonstiges';
}
function getKindSeverity(meta) {
if (meta.family === 'converter') {
return 'info';
}
if (meta.family === 'audiobook') {
return 'warning';
}
if (meta.mediaType === 'bluray' || meta.mediaType === 'dvd') {
return 'success';
}
return 'secondary';
}
export default function JobsInboxPage() {
const navigate = useNavigate();
const [jobs, setJobs] = useState([]);
const [loading, setLoading] = useState(false);
const [viewFilter, setViewFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [search, setSearch] = useState('');
const [queuedJobIds, setQueuedJobIds] = useState(new Set());
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
const [first, setFirst] = useState(0);
const [rows, setRows] = useState(25);
const loadJobs = useCallback(async () => {
setLoading(true);
try {
const query = { limit: 500, lite: true };
if (statusFilter === 'active') {
query.statuses = ACTIVE_STATUSES;
} else if (statusFilter === 'errors') {
query.statuses = ['ERROR', 'CANCELLED'];
}
const [jobsResponse, queueResponse] = await Promise.allSettled([
api.getJobs(query),
api.getPipelineQueue()
]);
const nextJobs = jobsResponse.status === 'fulfilled' && Array.isArray(jobsResponse.value?.jobs)
? jobsResponse.value.jobs
: [];
setJobs(nextJobs);
if (queueResponse.status === 'fulfilled') {
const rows = Array.isArray(queueResponse.value?.queue?.queuedJobs)
? queueResponse.value.queue.queuedJobs
: [];
setQueuedJobIds(new Set(rows.map((item) => normalizeJobId(item?.jobId)).filter(Boolean)));
} else {
setQueuedJobIds(new Set());
}
setLastUpdatedAt(new Date().toISOString());
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
loadJobs();
const interval = setInterval(() => {
loadJobs().catch(() => null);
}, 7000);
return () => clearInterval(interval);
}, [loadJobs]);
const jobsWithMeta = useMemo(() => (
jobs.map((job) => ({
job,
meta: classifyJob(job)
}))
), [jobs]);
const viewCounts = useMemo(() => {
let all = 0;
let ripper = 0;
let converter = 0;
let audiobook = 0;
for (const item of jobsWithMeta) {
all += 1;
if (item.meta.family === 'converter') {
converter += 1;
} else {
ripper += 1;
}
if (item.meta.family === 'audiobook') {
audiobook += 1;
}
}
return { all, ripper, converter, audiobook };
}, [jobsWithMeta]);
const filteredRows = useMemo(() => {
const normalizedSearch = String(search || '').trim().toLowerCase();
return jobsWithMeta.filter(({ job, meta }) => {
if (viewFilter === 'ripper' && meta.family === 'converter') {
return false;
}
if (viewFilter === 'converter' && meta.family !== 'converter') {
return false;
}
if (viewFilter === 'audiobook' && meta.family !== 'audiobook') {
return false;
}
if (!normalizedSearch) {
return true;
}
const haystack = [
job?.title,
job?.detected_title,
job?.imdb_id,
job?.status,
job?.job_kind,
job?.media_type
]
.map((value) => String(value || '').trim().toLowerCase())
.filter(Boolean)
.join(' ');
return haystack.includes(normalizedSearch);
});
}, [jobsWithMeta, search, viewFilter]);
useEffect(() => {
setFirst(0);
}, [viewFilter, statusFilter, search]);
useEffect(() => {
if (filteredRows.length === 0) {
if (first !== 0) {
setFirst(0);
}
return;
}
if (first >= filteredRows.length) {
const pageStart = Math.max(0, Math.floor((filteredRows.length - 1) / rows) * rows);
setFirst(pageStart);
}
}, [filteredRows.length, first, rows]);
const pagedRows = useMemo(
() => filteredRows.slice(first, first + rows),
[filteredRows, first, rows]
);
return (
<Card
title="Job Inbox"
subTitle="Zentrale Jobliste mit denselben Datensätzen für Ripper-, Converter- und Audiobook-Sicht."
>
<div className="job-inbox-toolbar">
<div className="job-inbox-filter-row">
{VIEW_FILTERS.map((filter) => {
const isActive = filter.key === viewFilter;
const count = viewCounts[filter.key] ?? 0;
return (
<Button
key={filter.key}
label={`${filter.label} (${count})`}
className={isActive ? 'job-inbox-filter-active' : 'job-inbox-filter'}
outlined={!isActive}
onClick={() => setViewFilter(filter.key)}
size="small"
/>
);
})}
</div>
<div className="job-inbox-filter-row">
{STATUS_FILTERS.map((filter) => {
const isActive = filter.key === statusFilter;
return (
<Button
key={filter.key}
label={filter.label}
className={isActive ? 'job-inbox-filter-active' : 'job-inbox-filter'}
outlined={!isActive}
onClick={() => setStatusFilter(filter.key)}
size="small"
/>
);
})}
<Button
label="Aktualisieren"
icon="pi pi-refresh"
outlined
size="small"
loading={loading}
onClick={() => {
loadJobs().catch(() => null);
}}
/>
</div>
</div>
<div className="job-inbox-search-row">
<InputText
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Suche nach Titel, Status oder IMDB-ID"
className="job-inbox-search-input"
/>
<small>
Letztes Update: {formatUpdatedAt(lastUpdatedAt)} | Treffer: {filteredRows.length}
</small>
</div>
<div className="job-inbox-list">
{loading && filteredRows.length === 0 ? (
<p>Inbox wird geladen ...</p>
) : filteredRows.length === 0 ? (
<p>Keine Jobs für den aktuellen Filter gefunden.</p>
) : (
pagedRows.map(({ job, meta }) => {
const jobId = normalizeJobId(job?.id);
if (!jobId) {
return null;
}
const isQueued = queuedJobIds.has(jobId);
const normalizedStatus = normalizeStatus(job?.status);
const targetPath = meta.family === 'converter' ? '/converter' : '/ripper';
const jobTitle = String(job?.title || job?.detected_title || '').trim() || `Job #${jobId}`;
return (
<article key={jobId} className="job-inbox-row">
<div className="job-inbox-row-main">
<strong>#{jobId} | {jobTitle}</strong>
<small>
Status: {getStatusLabel(normalizedStatus, { queued: isQueued })}
{' | '}
Aktualisiert: {formatUpdatedAt(job?.updated_at || job?.created_at || null)}
</small>
</div>
<div className="job-inbox-row-tags">
<Tag value={getKindLabel(meta)} severity={getKindSeverity(meta)} />
<Tag
value={getStatusLabel(normalizedStatus, { queued: isQueued })}
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
/>
</div>
<div className="job-inbox-row-actions">
<Button
label={meta.family === 'converter' ? 'Im Converter öffnen' : 'Im Ripper öffnen'}
icon={meta.family === 'converter' ? 'pi pi-external-link' : 'pi pi-arrow-right'}
outlined
size="small"
onClick={() => navigate(targetPath)}
/>
</div>
</article>
);
})
)}
</div>
{filteredRows.length > 0 ? (
<div className="job-inbox-pagination">
<Paginator
first={first}
rows={rows}
totalRecords={filteredRows.length}
rowsPerPageOptions={[25, 50, 100]}
onPageChange={(event) => {
setFirst(event.first);
setRows(event.rows);
}}
template="PrevPageLink PageLinks NextPageLink RowsPerPageDropdown CurrentPageReport"
currentPageReportTemplate="{first} - {last} von {totalRecords}"
/>
</div>
) : null}
</Card>
);
}
+48 -18
View File
@@ -129,6 +129,16 @@ function formatPercent(value) {
return `${Math.trunc(parsed)}%`;
}
function isPlaylistTrackPreparationStatus(value) {
const normalized = String(value || '').trim().toUpperCase();
if (!normalized) {
return false;
}
return normalized.includes('PLAYLIST')
&& normalized.includes('TRACKDATEN')
&& normalized.includes('VORBEREIT');
}
function formatTemperature(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
@@ -3132,9 +3142,10 @@ export default function RipperPage({
let stateLabel = null;
let stateSeverity = 'secondary';
let discInfo = null;
let showProgress = false;
let progressValue = 0;
let statusText = null;
let showProgress = false;
let progressValue = 0;
let statusText = null;
let showIndeterminateDriveProgress = false;
if (cdDrive) {
const s = String(cdDrive.state || '').toUpperCase();
@@ -3145,9 +3156,10 @@ export default function RipperPage({
: 'info';
const cd = cdDrive.device || {};
discInfo = cd.discLabel || cd.model || null;
showProgress = ['CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(s);
progressValue = Number(cdDrive.progress ?? 0);
statusText = cdDrive.statusText || null;
showProgress = ['CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(s);
progressValue = Number(cdDrive.progress ?? 0);
statusText = cdDrive.statusText || null;
showIndeterminateDriveProgress = isPlaylistTrackPreparationStatus(statusText);
} else if (pipelineDevice) {
const s = String(drv.pipelineState || '').toUpperCase();
stateLabel = s || 'DISC_DETECTED';
@@ -3286,12 +3298,14 @@ export default function RipperPage({
{discInfo ? <span className="drive-list-disc-label" title={discInfo}>{discInfo}</span> : null}
</div>
) : null}
{showProgress && (
<ProgressBar value={progressValue} style={{ height: '5px', margin: '0.2rem 0 0' }} showValue={false} />
)}
{showProgress && statusText && (
<div className="drive-list-status-text">{statusText}</div>
)}
{showProgress && (
showIndeterminateDriveProgress
? <ProgressBar mode="indeterminate" style={{ height: '5px', margin: '0.2rem 0 0' }} showValue={false} />
: <ProgressBar value={progressValue} style={{ height: '5px', margin: '0.2rem 0 0' }} showValue={false} />
)}
{showProgress && statusText && (
<div className="drive-list-status-text">{statusText}</div>
)}
</div>
);
})}
@@ -3463,7 +3477,8 @@ export default function RipperPage({
const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
const pipelineStatusText = String(pipelineForJob?.statusText || '').trim().toUpperCase();
const pipelineStatusTextRaw = String(pipelineForJob?.statusText || '').trim();
const pipelineStatusText = pipelineStatusTextRaw.toUpperCase();
const isCdJob = jobState.startsWith('CD_')
|| pipelineStage.startsWith('CD_')
|| mediaProfile === 'cd'
@@ -3509,6 +3524,8 @@ export default function RipperPage({
? Math.max(0, Math.min(100, rawProgress))
: 0;
const progressLabel = `${Math.trunc(clampedProgress)}%`;
const showIndeterminateProgress = !hasSeriesBatchProgress
&& isPlaylistTrackPreparationStatus(pipelineStatusTextRaw);
const etaLabel = isCancelledState ? '' : String(
hasSeriesBatchProgress
? (runningSeriesChild?.eta || pipelineForJob?.eta || '')
@@ -3982,9 +3999,16 @@ export default function RipperPage({
: null}
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
</div>
<div className="ripper-job-row-progress" aria-label={`Job Fortschritt ${progressLabel}`}>
<ProgressBar value={clampedProgress} showValue={false} />
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}</small>
<div
className="ripper-job-row-progress"
aria-label={showIndeterminateProgress ? 'Job Fortschritt Vorbereitung läuft' : `Job Fortschritt ${progressLabel}`}
>
{showIndeterminateProgress ? (
<ProgressBar mode="indeterminate" showValue={false} />
) : (
<ProgressBar value={clampedProgress} showValue={false} />
)}
<small>{showIndeterminateProgress ? 'Vorbereitung läuft …' : (etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel)}</small>
</div>
</div>
<i className="pi pi-angle-down" aria-hidden="true" />
@@ -4051,6 +4075,8 @@ export default function RipperPage({
: (jobProgressEntry?.progress ?? 0)
);
const clampedQueueProgress = Number.isFinite(rawQueueProgress) ? Math.max(0, Math.min(100, rawQueueProgress)) : 0;
const queueStatusText = String(jobProgressEntry?.statusText || '').trim();
const showIndeterminateQueueProgress = isPlaylistTrackPreparationStatus(queueStatusText);
return (
<div key={`running-${item.jobId}`} className="pipeline-queue-entry-wrap">
<div className="pipeline-queue-item running queue-job-item">
@@ -4062,8 +4088,12 @@ export default function RipperPage({
</strong>
{item.subtitle ? <small>{item.subtitle}</small> : null}
<small>#{item.jobId} | {getMergeAwareStatusLabel(item, item.status)}</small>
{clampedQueueProgress > 0 && (
<ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
{showIndeterminateQueueProgress ? (
<ProgressBar mode="indeterminate" style={{ height: '5px' }} showValue={false} />
) : (
clampedQueueProgress > 0
? <ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
: null
)}
</div>
{hasScriptSummary ? (
+210 -4
View File
@@ -14,6 +14,16 @@ import CronJobsTab from '../components/CronJobsTab';
import { confirmModal } from '../utils/confirmModal';
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
const USER_PRESET_DEFAULT_SLOTS = Object.freeze([
{ key: 'bluray_movie', label: 'Standard-Userpreset für BluRay Film', mediaType: 'bluray' },
{ key: 'bluray_series', label: 'Standard-Userpreset für BluRay Serie', mediaType: 'bluray' },
{ key: 'dvd_movie', label: 'Standard-Userpreset für DVD Film', mediaType: 'dvd' },
{ key: 'dvd_series', label: 'Standard-Userpreset für DVD Serie', mediaType: 'dvd' }
]);
const EMPTY_USER_PRESET_DEFAULTS = Object.freeze(
USER_PRESET_DEFAULT_SLOTS.reduce((acc, slot) => ({ ...acc, [slot.key]: null }), {})
);
// Validates template/string fields: only letters, digits, space, _, -, and {placeholders} allowed
function validateSettingValue(key, value) {
@@ -45,6 +55,68 @@ function buildValuesMap(categories) {
return next;
}
function normalizePositiveId(value) {
if (value === null || value === undefined) {
return null;
}
if (typeof value === 'string' && value.trim() === '') {
return null;
}
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return null;
}
return Math.trunc(numeric);
}
function normalizeUserPresetDefaultsMap(raw = {}) {
const source = raw && typeof raw === 'object' ? raw : {};
const normalized = { ...EMPTY_USER_PRESET_DEFAULTS };
for (const slot of USER_PRESET_DEFAULT_SLOTS) {
normalized[slot.key] = normalizePositiveId(source[slot.key]);
}
return normalized;
}
function isPresetCompatibleWithSlot(preset, slotMediaType) {
const mediaType = String(preset?.mediaType || '').trim().toLowerCase();
if (mediaType === 'all') {
return true;
}
return mediaType === slotMediaType;
}
function buildUserPresetDefaultOptions(userPresets, slotMediaType, selectedPresetId = null) {
const presets = Array.isArray(userPresets) ? userPresets : [];
const options = [{ label: '(keine Standardzuordnung)', value: null }];
const seen = new Set([null]);
for (const preset of presets) {
const presetId = normalizePositiveId(preset?.id);
if (!presetId || !isPresetCompatibleWithSlot(preset, slotMediaType)) {
continue;
}
seen.add(presetId);
options.push({
label: `#${presetId} - ${preset?.name || 'Preset'}`,
value: presetId
});
}
const normalizedSelectedId = normalizePositiveId(selectedPresetId);
if (normalizedSelectedId && !seen.has(normalizedSelectedId)) {
const existingPreset = presets.find((preset) => normalizePositiveId(preset?.id) === normalizedSelectedId);
options.push({
label: existingPreset
? `#${normalizedSelectedId} - ${existingPreset?.name || 'Preset'} (nicht kompatibel)`
: `#${normalizedSelectedId} - (nicht gefunden)`,
value: normalizedSelectedId
});
}
return options;
}
function isSameValue(a, b) {
if (typeof a === 'number' && typeof b === 'number') {
return Number(a) === Number(b);
@@ -213,6 +285,10 @@ export default function SettingsPage() {
const [userPresets, setUserPresets] = useState([]);
const [userPresetsLoading, setUserPresetsLoading] = useState(false);
const [userPresetSaving, setUserPresetSaving] = useState(false);
const [userPresetDefaults, setUserPresetDefaults] = useState(EMPTY_USER_PRESET_DEFAULTS);
const [userPresetDefaultsDraft, setUserPresetDefaultsDraft] = useState(EMPTY_USER_PRESET_DEFAULTS);
const [userPresetDefaultsLoading, setUserPresetDefaultsLoading] = useState(false);
const [userPresetDefaultsSaving, setUserPresetDefaultsSaving] = useState(false);
const [userPresetEditor, setUserPresetEditor] = useState({
open: false,
id: null,
@@ -235,6 +311,23 @@ export default function SettingsPage() {
),
[handBrakePresetSourceOptions, userPresetEditor.handbrakePreset]
);
const userPresetDefaultsDirty = useMemo(
() => USER_PRESET_DEFAULT_SLOTS.some((slot) => (
normalizePositiveId(userPresetDefaultsDraft?.[slot.key]) !== normalizePositiveId(userPresetDefaults?.[slot.key])
)),
[userPresetDefaultsDraft, userPresetDefaults]
);
const userPresetDefaultOptionsBySlot = useMemo(() => {
const next = {};
for (const slot of USER_PRESET_DEFAULT_SLOTS) {
next[slot.key] = buildUserPresetDefaultOptions(
userPresets,
slot.mediaType,
userPresetDefaultsDraft?.[slot.key]
);
}
return next;
}, [userPresets, userPresetDefaultsDraft]);
const loadScripts = async ({ silent = false } = {}) => {
if (!silent) {
@@ -291,6 +384,26 @@ export default function SettingsPage() {
}
};
const loadUserPresetDefaults = async ({ silent = false } = {}) => {
if (!silent) {
setUserPresetDefaultsLoading(true);
}
try {
const response = await api.getUserPresetDefaults();
const normalized = normalizeUserPresetDefaultsMap(response?.defaults || {});
setUserPresetDefaults(normalized);
setUserPresetDefaultsDraft(normalized);
} catch (error) {
if (!silent) {
toastRef.current?.show({ severity: 'error', summary: 'Preset-Standards', detail: error.message });
}
} finally {
if (!silent) {
setUserPresetDefaultsLoading(false);
}
}
};
const openNewUserPreset = () => {
setUserPresetEditor({ open: true, id: null, name: '', mediaType: 'all', handbrakePreset: '', extraArgs: '', description: '' });
setUserPresetErrors({});
@@ -352,12 +465,59 @@ export default function SettingsPage() {
try {
await api.deleteUserPreset(presetId);
toastRef.current?.show({ severity: 'success', summary: 'Preset', detail: 'Preset gelöscht.' });
await loadUserPresets({ silent: true });
await Promise.all([
loadUserPresets({ silent: true }),
loadUserPresetDefaults({ silent: true })
]);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Preset löschen', detail: error.message });
}
};
const handleChangeUserPresetDefault = (slotKey, rawValue) => {
const normalized = normalizePositiveId(rawValue);
setUserPresetDefaultsDraft((prev) => ({
...prev,
[slotKey]: normalized
}));
};
const handleResetUserPresetDefaults = () => {
setUserPresetDefaultsDraft({ ...userPresetDefaults });
};
const handleSaveUserPresetDefaults = async () => {
if (!userPresetDefaultsDirty) {
toastRef.current?.show({
severity: 'info',
summary: 'Preset-Standards',
detail: 'Keine Änderungen zum Speichern.'
});
return;
}
setUserPresetDefaultsSaving(true);
try {
const payload = normalizeUserPresetDefaultsMap(userPresetDefaultsDraft);
const response = await api.updateUserPresetDefaults(payload);
const normalized = normalizeUserPresetDefaultsMap(response?.defaults || payload);
setUserPresetDefaults(normalized);
setUserPresetDefaultsDraft(normalized);
toastRef.current?.show({
severity: 'success',
summary: 'Preset-Standards',
detail: 'Standard-Zuordnungen gespeichert.'
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Preset-Standards',
detail: error.message
});
} finally {
setUserPresetDefaultsSaving(false);
}
};
const loadEffectivePaths = async ({ silent = false } = {}) => {
try {
const paths = await api.getEffectivePaths({ forceRefresh: true });
@@ -430,6 +590,7 @@ export default function SettingsPage() {
useEffect(() => {
load();
loadUserPresets();
loadUserPresetDefaults();
}, []);
const dirtyKeys = useMemo(() => {
@@ -1670,12 +1831,57 @@ export default function SettingsPage() {
label="Presets neu laden"
icon="pi pi-refresh"
severity="secondary"
onClick={() => loadUserPresets()}
loading={userPresetsLoading}
disabled={userPresetSaving}
onClick={() => Promise.all([loadUserPresets(), loadUserPresetDefaults()])}
loading={userPresetsLoading || userPresetDefaultsLoading}
disabled={userPresetSaving || userPresetDefaultsSaving}
/>
</div>
<div className="script-list-box" style={{ marginTop: '1rem' }}>
<h4>Standard-Zuordnungen</h4>
<small>
Weise für jeden Medientyp/Fall ein Standard-Userpreset zu. Diese Zuordnungen werden persistent gespeichert.
</small>
<div className="user-preset-default-grid" style={{ marginTop: '0.8rem' }}>
{USER_PRESET_DEFAULT_SLOTS.map((slot) => (
<div key={slot.key} className="user-preset-default-grid-item">
<label htmlFor={`user-preset-default-${slot.key}`} style={{ display: 'block', marginBottom: '0.3rem' }}>
{slot.label}
</label>
<Dropdown
id={`user-preset-default-${slot.key}`}
value={normalizePositiveId(userPresetDefaultsDraft?.[slot.key])}
options={userPresetDefaultOptionsBySlot?.[slot.key] || []}
optionLabel="label"
optionValue="value"
onChange={(event) => handleChangeUserPresetDefault(slot.key, event.value)}
placeholder="Preset auswählen"
showClear
disabled={userPresetDefaultsLoading || userPresetDefaultsSaving}
style={{ width: '100%' }}
/>
</div>
))}
</div>
<div className="actions-row" style={{ marginTop: '0.8rem' }}>
<Button
label="Zuordnungen speichern"
icon="pi pi-save"
onClick={handleSaveUserPresetDefaults}
loading={userPresetDefaultsSaving}
disabled={userPresetDefaultsLoading || !userPresetDefaultsDirty}
/>
<Button
label="Änderungen verwerfen"
icon="pi pi-undo"
severity="secondary"
outlined
onClick={handleResetUserPresetDefaults}
disabled={userPresetDefaultsLoading || userPresetDefaultsSaving || !userPresetDefaultsDirty}
/>
</div>
</div>
<small>
Encode-Presets fassen ein HandBrake-Preset und zusätzliche CLI-Argumente zusammen.
Sie sind medienbezogen (Blu-ray, DVD oder Universell) und können vor dem Encode
+21 -102
View File
@@ -345,106 +345,6 @@ body {
min-width: 0;
}
.job-inbox-toolbar {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 0.85rem;
}
.job-inbox-filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.job-inbox-filter.p-button,
.job-inbox-filter-active.p-button {
border-radius: 999px;
}
.job-inbox-search-row {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.job-inbox-search-input {
width: min(40rem, 100%);
}
.job-inbox-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.job-inbox-row {
display: flex;
align-items: center;
gap: 0.9rem;
border: 1px solid var(--surface-border);
border-radius: 12px;
padding: 0.75rem 0.85rem;
background: var(--surface-card);
}
.job-inbox-row-main {
display: flex;
flex-direction: column;
gap: 0.22rem;
min-width: 0;
flex: 1 1 auto;
}
.job-inbox-row-main strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-inbox-row-main small {
color: var(--text-color-secondary);
}
.job-inbox-row-tags {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.job-inbox-row-actions {
display: flex;
justify-content: flex-end;
flex: 0 0 auto;
}
.job-inbox-pagination {
margin-top: 1rem;
}
.job-inbox-pagination .p-paginator {
justify-content: flex-end;
}
@media (max-width: 900px) {
.job-inbox-row {
flex-direction: column;
align-items: flex-start;
}
.job-inbox-row-tags,
.job-inbox-row-actions {
justify-content: flex-start;
}
.job-inbox-pagination .p-paginator {
justify-content: flex-start;
}
}
@media (max-width: 1100px) {
.ripper-3col-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
@@ -2809,6 +2709,22 @@ body {
white-space: nowrap;
}
.user-preset-default-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.user-preset-default-grid-item {
min-width: 0;
}
@media (max-width: 900px) {
.user-preset-default-grid {
grid-template-columns: 1fr;
}
}
.script-editor-fields {
display: grid;
gap: 0.4rem;
@@ -3730,16 +3646,19 @@ body {
.playlist-decision-block {
margin-top: 1rem;
border: 1px solid var(--rip-border);
border: 1px solid #d4a29e;
border-left: 4px solid #8b2c2c;
border-radius: 0.5rem;
padding: 0.75rem;
display: grid;
gap: 0.5rem;
background: var(--rip-panel-soft);
background: #f8e1df;
color: #8b2c2c;
}
.playlist-decision-block h3 {
margin: 0;
color: #7a2525;
}
.rip-error-notice {
-26
View File
@@ -297,29 +297,3 @@ export function isSeriesDvdJob(job) {
export function isConverterJob(job) {
return resolveMediaType(job) === 'converter';
}
export function classifyJob(job) {
const jobKind = resolveJobKind(job);
const mediaType = resolveMediaType(job);
const converterMediaType = normalizeConverterMediaType(
(jobKind && jobKind.startsWith('converter_'))
? jobKind.replace('converter_', '')
: (getEncodePlan(job)?.converterMediaType || job?.converterMediaType)
);
let family = 'other';
if (mediaType === 'converter') {
family = 'converter';
} else if (mediaType === 'audiobook') {
family = 'audiobook';
} else if (mediaType === 'cd' || mediaType === 'dvd' || mediaType === 'bluray') {
family = 'rip';
}
return {
jobKind,
mediaType,
converterMediaType,
family
};
}