0.16.1-5 Ebcode Fix / presets
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user