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
-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