2063 lines
79 KiB
React
2063 lines
79 KiB
React
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Card } from 'primereact/card';
|
||
import { Button } from 'primereact/button';
|
||
import { Toast } from 'primereact/toast';
|
||
import { Dialog } from 'primereact/dialog';
|
||
import { TabView, TabPanel } from 'primereact/tabview';
|
||
import { InputText } from 'primereact/inputtext';
|
||
import { InputTextarea } from 'primereact/inputtextarea';
|
||
import { Dropdown } from 'primereact/dropdown';
|
||
import { InputSwitch } from 'primereact/inputswitch';
|
||
import { api } from '../api/client';
|
||
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
||
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) {
|
||
if (!key.includes('template')) return null;
|
||
const str = String(value ?? '');
|
||
if (!str) return null;
|
||
// Strip valid placeholder patterns ${...} and {...}
|
||
const stripped = str.replace(/\$\{[^}]*\}/g, '').replace(/\{[^}]*\}/g, '');
|
||
// Standalone { or } after stripping = unclosed placeholder
|
||
if (/[{}]/.test(stripped)) {
|
||
return 'Geschweifte Klammern sind nur als vollständige Platzhalter erlaubt, z.B. {title}.';
|
||
}
|
||
// Find first invalid special char (anything beyond letters, digits, space, _, -, (), [], /)
|
||
// . is not allowed (extensions are set separately); / is only for folder separators
|
||
const invalid = stripped.match(/[^a-zA-Z0-9 ()[\]/_-]/);
|
||
if (invalid) {
|
||
return `Ungültiges Zeichen "${invalid[0]}". Erlaubt sind nur Buchstaben, Ziffern, Leerzeichen, _, -, und {Platzhalter}.`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function buildValuesMap(categories) {
|
||
const next = {};
|
||
for (const category of categories || []) {
|
||
for (const setting of category.settings || []) {
|
||
next[setting.key] = setting.value;
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
return a === b;
|
||
}
|
||
|
||
function toBoolean(value) {
|
||
if (typeof value === 'boolean') {
|
||
return value;
|
||
}
|
||
if (typeof value === 'number') {
|
||
return value !== 0;
|
||
}
|
||
const normalized = String(value || '').trim().toLowerCase();
|
||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||
}
|
||
|
||
function reorderListById(items, sourceId, targetIndex) {
|
||
const list = Array.isArray(items) ? items : [];
|
||
const normalizedSourceId = Number(sourceId);
|
||
const normalizedTargetIndex = Number(targetIndex);
|
||
if (!Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0 || !Number.isFinite(normalizedTargetIndex)) {
|
||
return { changed: false, next: list };
|
||
}
|
||
const fromIndex = list.findIndex((item) => Number(item?.id) === normalizedSourceId);
|
||
if (fromIndex < 0) {
|
||
return { changed: false, next: list };
|
||
}
|
||
|
||
const boundedTarget = Math.max(0, Math.min(Math.trunc(normalizedTargetIndex), list.length));
|
||
const insertAt = fromIndex < boundedTarget ? boundedTarget - 1 : boundedTarget;
|
||
if (insertAt === fromIndex) {
|
||
return { changed: false, next: list };
|
||
}
|
||
|
||
const next = [...list];
|
||
const [moved] = next.splice(fromIndex, 1);
|
||
next.splice(insertAt, 0, moved);
|
||
return { changed: true, next };
|
||
}
|
||
|
||
function buildHandBrakePresetSelectOptions(sourceOptions, extraValues = []) {
|
||
const rawOptions = Array.isArray(sourceOptions) ? sourceOptions : [];
|
||
const rawExtraValues = Array.isArray(extraValues) ? extraValues : [];
|
||
const normalizedOptions = [];
|
||
const seenValues = new Set();
|
||
const seenGroupLabels = new Set();
|
||
|
||
const addGroupOption = (option) => {
|
||
const rawLabel = String(option?.label || '').trim();
|
||
if (!rawLabel || seenGroupLabels.has(rawLabel)) {
|
||
return;
|
||
}
|
||
seenGroupLabels.add(rawLabel);
|
||
normalizedOptions.push({
|
||
...option,
|
||
label: rawLabel,
|
||
value: String(option?.value || `__group__${rawLabel.toLowerCase().replace(/\s+/g, '_')}`),
|
||
disabled: true
|
||
});
|
||
};
|
||
|
||
const addSelectableOption = (optionValue, optionLabel = optionValue, option = null) => {
|
||
const value = String(optionValue || '').trim();
|
||
if (seenValues.has(value)) {
|
||
return;
|
||
}
|
||
seenValues.add(value);
|
||
normalizedOptions.push({
|
||
...(option && typeof option === 'object' ? option : {}),
|
||
label: String(optionLabel ?? value),
|
||
value,
|
||
disabled: false
|
||
});
|
||
};
|
||
|
||
normalizedOptions.push({ label: '(kein Preset – nur CLI-Parameter)', value: '', disabled: false });
|
||
seenValues.add('');
|
||
|
||
for (const option of rawOptions) {
|
||
if (option?.disabled) {
|
||
addGroupOption(option);
|
||
continue;
|
||
}
|
||
addSelectableOption(option?.value, option?.label, option);
|
||
}
|
||
for (const value of rawExtraValues) {
|
||
addSelectableOption(value);
|
||
}
|
||
|
||
return normalizedOptions;
|
||
}
|
||
|
||
function injectHandBrakePresetOptions(categories, presetPayload) {
|
||
const list = Array.isArray(categories) ? categories : [];
|
||
const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : [];
|
||
const presetSettingKeys = new Set(['handbrake_preset', 'handbrake_preset_bluray', 'handbrake_preset_dvd']);
|
||
|
||
return list.map((category) => ({
|
||
...category,
|
||
settings: (category?.settings || []).map((setting) => {
|
||
if (!presetSettingKeys.has(String(setting?.key || '').trim().toLowerCase())) {
|
||
return setting;
|
||
}
|
||
const normalizedOptions = buildHandBrakePresetSelectOptions(sourceOptions, [
|
||
setting?.value,
|
||
setting?.defaultValue
|
||
]);
|
||
|
||
if (normalizedOptions.length <= 1) {
|
||
return setting;
|
||
}
|
||
|
||
return {
|
||
...setting,
|
||
type: 'select',
|
||
options: normalizedOptions
|
||
};
|
||
})
|
||
}));
|
||
}
|
||
|
||
export default function SettingsPage() {
|
||
const [categories, setCategories] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [testingPushover, setTestingPushover] = useState(false);
|
||
const [coverArtRecoveryBusy, setCoverArtRecoveryBusy] = useState(false);
|
||
const [updatingExpertMode, setUpdatingExpertMode] = useState(false);
|
||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||
const [initialValues, setInitialValues] = useState({});
|
||
const [draftValues, setDraftValues] = useState({});
|
||
const [errors, setErrors] = useState({});
|
||
const [scripts, setScripts] = useState([]);
|
||
const [scriptsLoading, setScriptsLoading] = useState(false);
|
||
const [scriptSaving, setScriptSaving] = useState(false);
|
||
const [scriptReordering, setScriptReordering] = useState(false);
|
||
const [scriptListDragSourceId, setScriptListDragSourceId] = useState(null);
|
||
const [scriptActionBusyId, setScriptActionBusyId] = useState(null);
|
||
const [scriptEditor, setScriptEditor] = useState({
|
||
mode: 'none',
|
||
id: null,
|
||
name: '',
|
||
scriptBody: ''
|
||
});
|
||
const [scriptErrors, setScriptErrors] = useState({});
|
||
const [lastScriptTestResult, setLastScriptTestResult] = useState(null);
|
||
|
||
// Script chains state
|
||
const [chains, setChains] = useState([]);
|
||
const [chainsLoading, setChainsLoading] = useState(false);
|
||
const [chainSaving, setChainSaving] = useState(false);
|
||
const [chainReordering, setChainReordering] = useState(false);
|
||
const [chainListDragSourceId, setChainListDragSourceId] = useState(null);
|
||
const [chainActionBusyId, setChainActionBusyId] = useState(null);
|
||
const [lastChainTestResult, setLastChainTestResult] = useState(null);
|
||
const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] });
|
||
const [chainEditorErrors, setChainEditorErrors] = useState({});
|
||
const [chainDragSource, setChainDragSource] = useState(null);
|
||
|
||
// Activation Bytes state
|
||
const [activationBytes, setActivationBytes] = useState([]);
|
||
|
||
// User presets state
|
||
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,
|
||
name: '',
|
||
mediaType: 'all',
|
||
handbrakePreset: '',
|
||
extraArgs: '',
|
||
description: ''
|
||
});
|
||
const [userPresetErrors, setUserPresetErrors] = useState({});
|
||
const [handBrakePresetSourceOptions, setHandBrakePresetSourceOptions] = useState([]);
|
||
const [effectivePaths, setEffectivePaths] = useState(null);
|
||
|
||
const toastRef = useRef(null);
|
||
|
||
const userPresetHandBrakeOptions = useMemo(
|
||
() => buildHandBrakePresetSelectOptions(
|
||
handBrakePresetSourceOptions,
|
||
[userPresetEditor.handbrakePreset]
|
||
),
|
||
[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) {
|
||
setScriptsLoading(true);
|
||
}
|
||
try {
|
||
const response = await api.getScripts();
|
||
const next = Array.isArray(response?.scripts) ? response.scripts : [];
|
||
setScripts(next);
|
||
} catch (error) {
|
||
if (!silent) {
|
||
toastRef.current?.show({ severity: 'error', summary: 'Script-Liste', detail: error.message });
|
||
}
|
||
} finally {
|
||
if (!silent) {
|
||
setScriptsLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
const loadChains = async ({ silent = false } = {}) => {
|
||
if (!silent) {
|
||
setChainsLoading(true);
|
||
}
|
||
try {
|
||
const response = await api.getScriptChains();
|
||
setChains(Array.isArray(response?.chains) ? response.chains : []);
|
||
} catch (error) {
|
||
if (!silent) {
|
||
toastRef.current?.show({ severity: 'error', summary: 'Skriptketten', detail: error.message });
|
||
}
|
||
} finally {
|
||
if (!silent) {
|
||
setChainsLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
const loadUserPresets = async ({ silent = false } = {}) => {
|
||
if (!silent) {
|
||
setUserPresetsLoading(true);
|
||
}
|
||
try {
|
||
const response = await api.getUserPresets();
|
||
setUserPresets(Array.isArray(response?.presets) ? response.presets : []);
|
||
} catch (error) {
|
||
if (!silent) {
|
||
toastRef.current?.show({ severity: 'error', summary: 'User-Presets', detail: error.message });
|
||
}
|
||
} finally {
|
||
if (!silent) {
|
||
setUserPresetsLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
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({});
|
||
};
|
||
|
||
const openEditUserPreset = (preset) => {
|
||
setUserPresetEditor({
|
||
open: true,
|
||
id: preset.id,
|
||
name: preset.name || '',
|
||
mediaType: preset.mediaType || 'all',
|
||
handbrakePreset: preset.handbrakePreset || '',
|
||
extraArgs: preset.extraArgs || '',
|
||
description: preset.description || ''
|
||
});
|
||
setUserPresetErrors({});
|
||
};
|
||
|
||
const closeUserPresetEditor = () => {
|
||
setUserPresetEditor((prev) => ({ ...prev, open: false }));
|
||
setUserPresetErrors({});
|
||
};
|
||
|
||
const handleSaveUserPreset = async () => {
|
||
const errors = {};
|
||
if (!userPresetEditor.name.trim()) {
|
||
errors.name = 'Name ist erforderlich.';
|
||
}
|
||
if (Object.keys(errors).length > 0) {
|
||
setUserPresetErrors(errors);
|
||
return;
|
||
}
|
||
setUserPresetSaving(true);
|
||
try {
|
||
const payload = {
|
||
name: userPresetEditor.name.trim(),
|
||
mediaType: userPresetEditor.mediaType,
|
||
handbrakePreset: userPresetEditor.handbrakePreset.trim(),
|
||
extraArgs: userPresetEditor.extraArgs.trim(),
|
||
description: userPresetEditor.description.trim()
|
||
};
|
||
if (userPresetEditor.id) {
|
||
await api.updateUserPreset(userPresetEditor.id, payload);
|
||
toastRef.current?.show({ severity: 'success', summary: 'Preset', detail: 'Preset aktualisiert.' });
|
||
} else {
|
||
await api.createUserPreset(payload);
|
||
toastRef.current?.show({ severity: 'success', summary: 'Preset', detail: 'Preset erstellt.' });
|
||
}
|
||
closeUserPresetEditor();
|
||
await loadUserPresets({ silent: true });
|
||
} catch (error) {
|
||
toastRef.current?.show({ severity: 'error', summary: 'Preset speichern', detail: error.message });
|
||
} finally {
|
||
setUserPresetSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteUserPreset = async (presetId) => {
|
||
try {
|
||
await api.deleteUserPreset(presetId);
|
||
toastRef.current?.show({ severity: 'success', summary: 'Preset', detail: 'Preset gelöscht.' });
|
||
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 });
|
||
setEffectivePaths(paths || null);
|
||
} catch (_error) {
|
||
if (!silent) {
|
||
setEffectivePaths(null);
|
||
}
|
||
}
|
||
};
|
||
|
||
const load = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const settingsResponse = await api.getSettings();
|
||
let nextCategories = settingsResponse?.categories || [];
|
||
const values = buildValuesMap(nextCategories);
|
||
setCategories(nextCategories);
|
||
setInitialValues(values);
|
||
setDraftValues(values);
|
||
setErrors({});
|
||
loadEffectivePaths({ silent: true });
|
||
|
||
api.getActivationBytes().then(r => setActivationBytes(Array.isArray(r?.entries) ? r.entries : [])).catch(() => {});
|
||
|
||
const presetsPromise = api.getHandBrakePresets();
|
||
const scriptsPromise = api.getScripts();
|
||
const chainsPromise = api.getScriptChains();
|
||
const [scriptsResponse, chainsResponse] = await Promise.allSettled([scriptsPromise, chainsPromise]);
|
||
if (scriptsResponse.status === 'fulfilled') {
|
||
setScripts(Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []);
|
||
} else {
|
||
toastRef.current?.show({
|
||
severity: 'warn',
|
||
summary: 'Scripte',
|
||
detail: 'Script-Liste konnte nicht geladen werden.'
|
||
});
|
||
}
|
||
if (chainsResponse.status === 'fulfilled') {
|
||
setChains(Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []);
|
||
}
|
||
|
||
presetsPromise
|
||
.then((presetPayload) => {
|
||
setHandBrakePresetSourceOptions(Array.isArray(presetPayload?.options) ? presetPayload.options : []);
|
||
setCategories((prevCategories) => injectHandBrakePresetOptions(prevCategories, presetPayload));
|
||
if (presetPayload?.message) {
|
||
toastRef.current?.show({
|
||
severity: presetPayload?.source === 'fallback' ? 'warn' : 'info',
|
||
summary: 'HandBrake Presets',
|
||
detail: presetPayload.message
|
||
});
|
||
}
|
||
})
|
||
.catch(() => {
|
||
setHandBrakePresetSourceOptions([]);
|
||
toastRef.current?.show({
|
||
severity: 'warn',
|
||
summary: 'HandBrake Presets',
|
||
detail: 'Preset-Liste konnte nicht geladen werden. Aktueller Wert bleibt auswählbar.'
|
||
});
|
||
});
|
||
} catch (error) {
|
||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
load();
|
||
loadUserPresets();
|
||
loadUserPresetDefaults();
|
||
}, []);
|
||
|
||
const dirtyKeys = useMemo(() => {
|
||
const keys = new Set();
|
||
const allKeys = new Set([...Object.keys(initialValues), ...Object.keys(draftValues)]);
|
||
for (const key of allKeys) {
|
||
if (!isSameValue(initialValues[key], draftValues[key])) {
|
||
keys.add(key);
|
||
}
|
||
}
|
||
return keys;
|
||
}, [initialValues, draftValues]);
|
||
|
||
const hasUnsavedChanges = dirtyKeys.size > 0;
|
||
const expertModeEnabled = toBoolean(draftValues?.[EXPERT_MODE_SETTING_KEY]);
|
||
|
||
const handleFieldChange = (key, value) => {
|
||
setDraftValues((prev) => ({ ...prev, [key]: value }));
|
||
const validationError = validateSettingValue(key, value);
|
||
setErrors((prev) => ({ ...prev, [key]: validationError || null }));
|
||
};
|
||
|
||
const handleExpertModeToggle = async (checked) => {
|
||
const previousDraftValue = draftValues?.[EXPERT_MODE_SETTING_KEY];
|
||
const previousInitialValue = initialValues?.[EXPERT_MODE_SETTING_KEY];
|
||
const nextValue = Boolean(checked);
|
||
const currentValue = toBoolean(previousDraftValue);
|
||
if (nextValue === currentValue) {
|
||
return;
|
||
}
|
||
|
||
setUpdatingExpertMode(true);
|
||
setDraftValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: nextValue }));
|
||
setInitialValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: nextValue }));
|
||
setErrors((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: null }));
|
||
try {
|
||
await api.updateSetting(EXPERT_MODE_SETTING_KEY, nextValue);
|
||
} catch (error) {
|
||
setDraftValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: previousDraftValue }));
|
||
setInitialValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: previousInitialValue }));
|
||
toastRef.current?.show({
|
||
severity: 'error',
|
||
summary: 'Expertenmodus',
|
||
detail: error.message
|
||
});
|
||
} finally {
|
||
setUpdatingExpertMode(false);
|
||
}
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
if (!hasUnsavedChanges) {
|
||
toastRef.current?.show({
|
||
severity: 'info',
|
||
summary: 'Settings',
|
||
detail: 'Keine Änderungen zum Speichern.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
const hasValidationErrors = Object.values(errors).some(Boolean);
|
||
if (hasValidationErrors) {
|
||
toastRef.current?.show({
|
||
severity: 'error',
|
||
summary: 'Validierungsfehler',
|
||
detail: 'Bitte korrigiere die fehlerhaften Felder vor dem Speichern.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
const patch = {};
|
||
for (const key of dirtyKeys) {
|
||
patch[key] = draftValues[key];
|
||
}
|
||
|
||
setSaving(true);
|
||
try {
|
||
const response = await api.updateSettingsBulk(patch);
|
||
setInitialValues((prev) => ({ ...prev, ...patch }));
|
||
setErrors({});
|
||
loadEffectivePaths({ silent: true });
|
||
const reviewRefresh = response?.reviewRefresh || null;
|
||
const reviewRefreshHint = reviewRefresh?.triggered
|
||
? ' Mediainfo-Prüfung wird mit den neuen Settings automatisch neu berechnet.'
|
||
: '';
|
||
toastRef.current?.show({
|
||
severity: 'success',
|
||
summary: 'Settings',
|
||
detail: `${Object.keys(patch).length} Änderung(en) gespeichert.${reviewRefreshHint}`
|
||
});
|
||
} catch (error) {
|
||
let detail = error?.message || 'Unbekannter Fehler';
|
||
if (Array.isArray(error?.details)) {
|
||
const nextErrors = {};
|
||
for (const item of error.details) {
|
||
if (item?.key) {
|
||
nextErrors[item.key] = item.message || 'Ungültiger Wert';
|
||
}
|
||
}
|
||
setErrors(nextErrors);
|
||
detail = 'Mindestens ein Feld ist ungültig.';
|
||
}
|
||
toastRef.current?.show({ severity: 'error', summary: 'Speichern fehlgeschlagen', detail });
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDiscard = () => {
|
||
setDraftValues(initialValues);
|
||
setErrors({});
|
||
};
|
||
|
||
const handlePushoverTest = async () => {
|
||
setTestingPushover(true);
|
||
try {
|
||
const response = await api.testPushover();
|
||
const sent = response?.result?.sent;
|
||
if (sent) {
|
||
toastRef.current?.show({
|
||
severity: 'success',
|
||
summary: 'PushOver',
|
||
detail: 'Testnachricht wurde versendet.'
|
||
});
|
||
} else {
|
||
toastRef.current?.show({
|
||
severity: 'warn',
|
||
summary: 'PushOver',
|
||
detail: `Nicht versendet (${response?.result?.reason || 'unbekannt'}).`
|
||
});
|
||
}
|
||
} catch (error) {
|
||
toastRef.current?.show({ severity: 'error', summary: 'PushOver Fehler', detail: error.message });
|
||
} finally {
|
||
setTestingPushover(false);
|
||
}
|
||
};
|
||
|
||
const handleCoverArtRecovery = async () => {
|
||
setCoverArtRecoveryBusy(true);
|
||
try {
|
||
const response = await api.recoverMissingCoverArt({});
|
||
const result = response?.result || {};
|
||
const recovered = Number(result?.recovered || 0);
|
||
const failed = Number(result?.failed || 0);
|
||
const scanned = Number(result?.scannedJobs || 0);
|
||
toastRef.current?.show({
|
||
severity: failed > 0 ? 'warn' : 'success',
|
||
summary: 'Coverart-Prüfung',
|
||
detail: `Geprüft: ${scanned} | Nachgeladen: ${recovered} | Fehlgeschlagen: ${failed}`,
|
||
life: 5000
|
||
});
|
||
} catch (error) {
|
||
toastRef.current?.show({
|
||
severity: 'error',
|
||
summary: 'Coverart-Prüfung fehlgeschlagen',
|
||
detail: error.message
|
||
});
|
||
} finally {
|
||
setCoverArtRecoveryBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleScriptEditorChange = (key, value) => {
|
||
setScriptEditor((prev) => ({
|
||
...prev,
|
||
[key]: value
|
||
}));
|
||
setScriptErrors((prev) => ({
|
||
...prev,
|
||
[key]: null
|
||
}));
|
||
};
|
||
|
||
const clearScriptEditor = () => {
|
||
setScriptEditor({
|
||
mode: 'none',
|
||
id: null,
|
||
name: '',
|
||
scriptBody: ''
|
||
});
|
||
setScriptErrors({});
|
||
};
|
||
|
||
const startCreateScript = () => {
|
||
setScriptEditor({
|
||
mode: 'create',
|
||
id: null,
|
||
name: '',
|
||
scriptBody: ''
|
||
});
|
||
setScriptErrors({});
|
||
setLastScriptTestResult(null);
|
||
};
|
||
|
||
const startEditScript = (script) => {
|
||
setScriptEditor({
|
||
mode: 'edit',
|
||
id: script?.id || null,
|
||
name: script?.name || '',
|
||
scriptBody: script?.scriptBody || ''
|
||
});
|
||
setScriptErrors({});
|
||
setLastScriptTestResult(null);
|
||
};
|
||
|
||
const handleSaveScript = async () => {
|
||
if (scriptEditor?.mode !== 'create' && scriptEditor?.mode !== 'edit') {
|
||
return;
|
||
}
|
||
const payload = {
|
||
name: String(scriptEditor?.name || '').trim(),
|
||
scriptBody: String(scriptEditor?.scriptBody || '')
|
||
};
|
||
setScriptSaving(true);
|
||
try {
|
||
if (scriptEditor?.id) {
|
||
await api.updateScript(scriptEditor.id, payload);
|
||
toastRef.current?.show({
|
||
severity: 'success',
|
||
summary: 'Scripte',
|
||
detail: 'Script aktualisiert.'
|
||
});
|
||
} else {
|
||
await api.createScript(payload);
|
||
toastRef.current?.show({
|
||
severity: 'success',
|
||
summary: 'Scripte',
|
||
detail: 'Script angelegt.'
|
||
});
|
||
}
|
||
await loadScripts({ silent: true });
|
||
setScriptErrors({});
|
||
clearScriptEditor();
|
||
} catch (error) {
|
||
const details = Array.isArray(error?.details) ? error.details : [];
|
||
if (details.length > 0) {
|
||
const nextErrors = {};
|
||
for (const item of details) {
|
||
if (item?.field) {
|
||
nextErrors[item.field] = item.message || 'Ungültiger Wert';
|
||
}
|
||
}
|
||
setScriptErrors(nextErrors);
|
||
}
|
||
toastRef.current?.show({
|
||
severity: 'error',
|
||
summary: 'Script speichern fehlgeschlagen',
|
||
detail: error.message
|
||
});
|
||
} finally {
|
||
setScriptSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteScript = async (script) => {
|
||
const scriptId = Number(script?.id);
|
||
if (!Number.isFinite(scriptId) || scriptId <= 0) {
|
||
return;
|
||
}
|
||
const confirmed = await confirmModal({
|
||
header: 'Script löschen',
|
||
message: `Script "${script?.name || scriptId}" wirklich löschen?`,
|
||
acceptLabel: 'Löschen',
|
||
rejectLabel: 'Abbrechen',
|
||
danger: true
|
||
});
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
setScriptActionBusyId(scriptId);
|
||
try {
|
||
await api.deleteScript(scriptId);
|
||
toastRef.current?.show({
|
||
severity: 'success',
|
||
summary: 'Scripte',
|
||
detail: 'Script gelöscht.'
|
||
});
|
||
await loadScripts({ silent: true });
|
||
if (scriptEditor?.mode === 'edit' && Number(scriptEditor?.id) === scriptId) {
|
||
clearScriptEditor();
|
||
}
|
||
} catch (error) {
|
||
toastRef.current?.show({
|
||
severity: 'error',
|
||
summary: 'Script löschen fehlgeschlagen',
|
||
detail: error.message
|
||
});
|
||
} finally {
|
||
setScriptActionBusyId(null);
|
||
}
|
||
};
|
||
|
||
const handleTestScript = async (script) => {
|
||
const scriptId = Number(script?.id);
|
||
if (!Number.isFinite(scriptId) || scriptId <= 0) {
|
||
return;
|
||
}
|
||
setScriptActionBusyId(scriptId);
|
||
try {
|
||
const response = await api.testScript(scriptId);
|
||
const result = response?.result || null;
|
||
setLastScriptTestResult(result);
|
||
if (result?.success) {
|
||
toastRef.current?.show({
|
||
severity: 'success',
|
||
summary: 'Script-Test',
|
||
detail: `"${script?.name || scriptId}" erfolgreich ausgeführt.`
|
||
});
|
||
} else {
|
||
toastRef.current?.show({
|
||
severity: 'warn',
|
||
summary: 'Script-Test',
|
||
detail: `"${script?.name || scriptId}" fehlgeschlagen (exit=${result?.exitCode ?? 'n/a'}).`
|
||
});
|
||
}
|
||
} catch (error) {
|
||
toastRef.current?.show({
|
||
severity: 'error',
|
||
summary: 'Script-Test fehlgeschlagen',
|
||
detail: error.message
|
||
});
|
||
} finally {
|
||
setScriptActionBusyId(null);
|
||
}
|
||
};
|
||
|
||
const handleScriptListDragStart = (event, scriptId) => {
|
||
if (scriptSaving || scriptsLoading || scriptReordering || scriptEditor?.mode === 'create' || Boolean(scriptActionBusyId)) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
setScriptListDragSourceId(Number(scriptId));
|
||
event.dataTransfer.effectAllowed = 'move';
|
||
event.dataTransfer.setData('text/plain', String(scriptId));
|
||
};
|
||
|
||
const handleScriptListDragOver = (event) => {
|
||
const sourceId = Number(scriptListDragSourceId);
|
||
if (!Number.isFinite(sourceId) || sourceId <= 0) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = 'move';
|
||
};
|
||
|
||
const handleScriptListDrop = async (event, targetIndex) => {
|
||
event.preventDefault();
|
||
if (scriptReordering) {
|
||
setScriptListDragSourceId(null);
|
||
return;
|
||
}
|
||
const sourceId = Number(scriptListDragSourceId);
|
||
setScriptListDragSourceId(null);
|
||
const { changed, next } = reorderListById(scripts, sourceId, targetIndex);
|
||
if (!changed) {
|
||
return;
|
||
}
|
||
|
||
const orderedScriptIds = next
|
||
.map((script) => Number(script?.id))
|
||
.filter((id) => Number.isFinite(id) && id > 0);
|
||
setScripts(next);
|
||
setScriptReordering(true);
|
||
try {
|
||
await api.reorderScripts(orderedScriptIds);
|
||
} catch (error) {
|
||
toastRef.current?.show({
|
||
severity: 'error',
|
||
summary: 'Script-Reihenfolge',
|
||
detail: error.message
|
||
});
|
||
await loadScripts({ silent: true });
|
||
} finally {
|
||
setScriptReordering(false);
|
||
}
|
||
};
|
||
|
||
const handleTestChain = async (chain) => {
|
||
const chainId = Number(chain?.id);
|
||
if (!Number.isFinite(chainId) || chainId <= 0) {
|
||
return;
|
||
}
|
||
setChainActionBusyId(chainId);
|
||
setLastChainTestResult(null);
|
||
try {
|
||
const response = await api.testScriptChain(chainId);
|
||
const result = response?.result || null;
|
||
setLastChainTestResult(result);
|
||
if (!result?.aborted) {
|
||
toastRef.current?.show({
|
||
severity: 'success',
|
||
summary: 'Ketten-Test',
|
||
detail: `"${chain?.name || chainId}" erfolgreich ausgeführt (${result?.succeeded ?? 0}/${result?.steps ?? 0} Schritte).`
|
||
});
|
||
} else {
|
||
toastRef.current?.show({
|
||
severity: 'warn',
|
||
summary: 'Ketten-Test',
|
||
detail: `"${chain?.name || chainId}" abgebrochen (${result?.succeeded ?? 0}/${result?.steps ?? 0} Schritte OK).`
|
||
});
|
||
}
|
||
} catch (error) {
|
||
toastRef.current?.show({ severity: 'error', summary: 'Ketten-Test fehlgeschlagen', detail: error.message });
|
||
} finally {
|
||
setChainActionBusyId(null);
|
||
}
|
||
};
|
||
|
||
// Chain editor handlers
|
||
const openChainEditor = (chain = null) => {
|
||
if (chain) {
|
||
setChainEditor({ open: true, id: chain.id, name: chain.name, steps: (chain.steps || []).map((s, i) => ({ ...s, _key: `${s.id || i}-${Date.now()}` })) });
|
||
} else {
|
||
setChainEditor({ open: true, id: null, name: '', steps: [] });
|
||
}
|
||
setChainEditorErrors({});
|
||
};
|
||
|
||
const closeChainEditor = () => {
|
||
setChainEditor({ open: false, id: null, name: '', steps: [] });
|
||
setChainEditorErrors({});
|
||
};
|
||
|
||
const addChainStep = (stepType, scriptId = null, scriptName = null) => {
|
||
setChainEditor((prev) => ({
|
||
...prev,
|
||
steps: [
|
||
...prev.steps,
|
||
{
|
||
_key: `new-${Date.now()}-${Math.random()}`,
|
||
stepType,
|
||
scriptId: stepType === 'script' ? scriptId : null,
|
||
scriptName: stepType === 'script' ? scriptName : null,
|
||
waitSeconds: stepType === 'wait' ? 10 : null
|
||
}
|
||
]
|
||
}));
|
||
};
|
||
|
||
const removeChainStep = (index) => {
|
||
setChainEditor((prev) => ({ ...prev, steps: prev.steps.filter((_, i) => i !== index) }));
|
||
};
|
||
|
||
const updateChainStepWait = (index, seconds) => {
|
||
setChainEditor((prev) => ({
|
||
...prev,
|
||
steps: prev.steps.map((s, i) => i === index ? { ...s, waitSeconds: seconds } : s)
|
||
}));
|
||
};
|
||
|
||
const moveChainStep = (fromIndex, toIndex) => {
|
||
if (fromIndex === toIndex) {
|
||
return;
|
||
}
|
||
setChainEditor((prev) => {
|
||
const steps = [...prev.steps];
|
||
const [moved] = steps.splice(fromIndex, 1);
|
||
steps.splice(toIndex, 0, moved);
|
||
return { ...prev, steps };
|
||
});
|
||
};
|
||
|
||
const handleSaveChain = async () => {
|
||
const name = String(chainEditor.name || '').trim();
|
||
if (!name) {
|
||
setChainEditorErrors({ name: 'Name darf nicht leer sein.' });
|
||
return;
|
||
}
|
||
const payload = {
|
||
name,
|
||
steps: chainEditor.steps.map((s) => ({
|
||
stepType: s.stepType,
|
||
scriptId: s.stepType === 'script' ? s.scriptId : null,
|
||
waitSeconds: s.stepType === 'wait' ? Number(s.waitSeconds || 10) : null
|
||
}))
|
||
};
|
||
setChainSaving(true);
|
||
try {
|
||
if (chainEditor.id) {
|
||
await api.updateScriptChain(chainEditor.id, payload);
|
||
toastRef.current?.show({ severity: 'success', summary: 'Skriptkette', detail: 'Kette aktualisiert.' });
|
||
} else {
|
||
await api.createScriptChain(payload);
|
||
toastRef.current?.show({ severity: 'success', summary: 'Skriptkette', detail: 'Kette angelegt.' });
|
||
}
|
||
await loadChains({ silent: true });
|
||
closeChainEditor();
|
||
} catch (error) {
|
||
const details = Array.isArray(error?.details) ? error.details : [];
|
||
if (details.length > 0) {
|
||
const errs = {};
|
||
for (const item of details) {
|
||
if (item?.field) {
|
||
errs[item.field] = item.message || 'Ungültig';
|
||
}
|
||
}
|
||
setChainEditorErrors(errs);
|
||
}
|
||
toastRef.current?.show({ severity: 'error', summary: 'Kette speichern fehlgeschlagen', detail: error.message });
|
||
} finally {
|
||
setChainSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteChain = async (chain) => {
|
||
const chainId = Number(chain?.id);
|
||
if (!Number.isFinite(chainId) || chainId <= 0) {
|
||
return;
|
||
}
|
||
const confirmed = await confirmModal({
|
||
header: 'Skriptkette löschen',
|
||
message: `Skriptkette "${chain?.name || chainId}" wirklich löschen?`,
|
||
acceptLabel: 'Löschen',
|
||
rejectLabel: 'Abbrechen',
|
||
danger: true
|
||
});
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
try {
|
||
await api.deleteScriptChain(chainId);
|
||
toastRef.current?.show({ severity: 'success', summary: 'Skriptketten', detail: 'Kette gelöscht.' });
|
||
await loadChains({ silent: true });
|
||
} catch (error) {
|
||
toastRef.current?.show({ severity: 'error', summary: 'Kette löschen fehlgeschlagen', detail: error.message });
|
||
}
|
||
};
|
||
|
||
const handleChainListDragStart = (event, chainId) => {
|
||
if (chainSaving || chainsLoading || chainReordering || Boolean(chainActionBusyId)) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
setChainListDragSourceId(Number(chainId));
|
||
event.dataTransfer.effectAllowed = 'move';
|
||
event.dataTransfer.setData('text/plain', String(chainId));
|
||
};
|
||
|
||
const handleChainListDragOver = (event) => {
|
||
const sourceId = Number(chainListDragSourceId);
|
||
if (!Number.isFinite(sourceId) || sourceId <= 0) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = 'move';
|
||
};
|
||
|
||
const handleChainListDrop = async (event, targetIndex) => {
|
||
event.preventDefault();
|
||
if (chainReordering) {
|
||
setChainListDragSourceId(null);
|
||
return;
|
||
}
|
||
const sourceId = Number(chainListDragSourceId);
|
||
setChainListDragSourceId(null);
|
||
const { changed, next } = reorderListById(chains, sourceId, targetIndex);
|
||
if (!changed) {
|
||
return;
|
||
}
|
||
|
||
const orderedChainIds = next
|
||
.map((chain) => Number(chain?.id))
|
||
.filter((id) => Number.isFinite(id) && id > 0);
|
||
setChains(next);
|
||
setChainReordering(true);
|
||
try {
|
||
await api.reorderScriptChains(orderedChainIds);
|
||
} catch (error) {
|
||
toastRef.current?.show({
|
||
severity: 'error',
|
||
summary: 'Ketten-Reihenfolge',
|
||
detail: error.message
|
||
});
|
||
await loadChains({ silent: true });
|
||
} finally {
|
||
setChainReordering(false);
|
||
}
|
||
};
|
||
|
||
// Chain DnD handlers
|
||
const handleChainPaletteDragStart = (event, data) => {
|
||
setChainDragSource({ origin: 'palette', ...data });
|
||
event.dataTransfer.effectAllowed = 'copy';
|
||
event.dataTransfer.setData('text/plain', JSON.stringify(data));
|
||
};
|
||
|
||
const handleChainStepDragStart = (event, index) => {
|
||
setChainDragSource({ origin: 'step', index });
|
||
event.dataTransfer.effectAllowed = 'move';
|
||
event.dataTransfer.setData('text/plain', String(index));
|
||
};
|
||
|
||
const handleChainDropzoneDrop = (event, targetIndex) => {
|
||
event.preventDefault();
|
||
if (!chainDragSource) {
|
||
return;
|
||
}
|
||
if (chainDragSource.origin === 'palette') {
|
||
const newStep = {
|
||
_key: `new-${Date.now()}-${Math.random()}`,
|
||
stepType: chainDragSource.stepType,
|
||
scriptId: chainDragSource.stepType === 'script' ? chainDragSource.scriptId : null,
|
||
scriptName: chainDragSource.stepType === 'script' ? chainDragSource.scriptName : null,
|
||
waitSeconds: chainDragSource.stepType === 'wait' ? 10 : null
|
||
};
|
||
setChainEditor((prev) => {
|
||
const steps = [...prev.steps];
|
||
const insertAt = targetIndex != null ? targetIndex : steps.length;
|
||
steps.splice(insertAt, 0, newStep);
|
||
return { ...prev, steps };
|
||
});
|
||
} else if (chainDragSource.origin === 'step') {
|
||
moveChainStep(chainDragSource.index, targetIndex != null ? targetIndex : chainEditor.steps.length - 1);
|
||
}
|
||
setChainDragSource(null);
|
||
};
|
||
|
||
const handleChainDragOver = (event) => {
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = chainDragSource?.origin === 'palette' ? 'copy' : 'move';
|
||
};
|
||
|
||
const scriptListDnDDisabled = scriptSaving
|
||
|| scriptsLoading
|
||
|| scriptReordering
|
||
|| scriptEditor?.mode === 'create'
|
||
|| Boolean(scriptActionBusyId);
|
||
const chainListDnDDisabled = chainSaving
|
||
|| chainsLoading
|
||
|| chainReordering
|
||
|| Boolean(chainActionBusyId);
|
||
|
||
return (
|
||
<div className="page-grid">
|
||
<Toast ref={toastRef} />
|
||
|
||
<Card title="Einstellungen" subTitle="Änderungen werden erst beim Speichern in die Datenbank übernommen">
|
||
<TabView
|
||
className="settings-root-tabview"
|
||
activeIndex={activeTabIndex}
|
||
onTabChange={(event) => setActiveTabIndex(Number(event.index || 0))}
|
||
>
|
||
<TabPanel header="Konfiguration">
|
||
<div className="actions-row">
|
||
<Button
|
||
label="Änderungen speichern"
|
||
icon="pi pi-save"
|
||
onClick={handleSave}
|
||
loading={saving}
|
||
disabled={!hasUnsavedChanges || updatingExpertMode}
|
||
/>
|
||
<Button
|
||
label="Änderungen verwerfen"
|
||
icon="pi pi-undo"
|
||
severity="secondary"
|
||
outlined
|
||
onClick={handleDiscard}
|
||
disabled={!hasUnsavedChanges || saving || updatingExpertMode}
|
||
/>
|
||
<Button
|
||
label="Neu laden"
|
||
icon="pi pi-refresh"
|
||
severity="secondary"
|
||
onClick={load}
|
||
loading={loading}
|
||
disabled={saving || updatingExpertMode}
|
||
/>
|
||
<Button
|
||
label="PushOver Test"
|
||
icon="pi pi-send"
|
||
severity="info"
|
||
onClick={handlePushoverTest}
|
||
loading={testingPushover}
|
||
disabled={saving || updatingExpertMode}
|
||
/>
|
||
<Button
|
||
label="Coverarts prüfen"
|
||
icon="pi pi-images"
|
||
severity="help"
|
||
onClick={handleCoverArtRecovery}
|
||
loading={coverArtRecoveryBusy}
|
||
disabled={saving || updatingExpertMode || testingPushover}
|
||
/>
|
||
<div className="settings-expert-toggle">
|
||
<span>Expertenmodus</span>
|
||
<InputSwitch
|
||
checked={expertModeEnabled}
|
||
onChange={(event) => handleExpertModeToggle(event.value)}
|
||
disabled={loading || saving || updatingExpertMode}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<p>Lade Settings ...</p>
|
||
) : (
|
||
<DynamicSettingsForm
|
||
categories={categories}
|
||
values={draftValues}
|
||
errors={errors}
|
||
dirtyKeys={dirtyKeys}
|
||
onChange={handleFieldChange}
|
||
effectivePaths={effectivePaths}
|
||
onNotify={(msg) => toastRef.current?.show(msg)}
|
||
/>
|
||
)}
|
||
</TabPanel>
|
||
<TabPanel header="Scripte">
|
||
<div className="script-manager-wrap">
|
||
<div className="actions-row">
|
||
<Button
|
||
label="Neues Skript hinzufügen"
|
||
icon="pi pi-plus"
|
||
onClick={startCreateScript}
|
||
severity="success"
|
||
outlined
|
||
disabled={scriptSaving || scriptReordering || scriptEditor?.mode === 'create'}
|
||
/>
|
||
<Button
|
||
label="Scripts neu laden"
|
||
icon="pi pi-refresh"
|
||
severity="secondary"
|
||
onClick={() => loadScripts()}
|
||
loading={scriptsLoading}
|
||
disabled={scriptSaving || scriptReordering}
|
||
/>
|
||
</div>
|
||
|
||
<small>
|
||
Die ausgewählten Scripts werden später pro Job nach erfolgreichem Encode in Reihenfolge ausgeführt.
|
||
</small>
|
||
<small className="muted-inline">
|
||
Reihenfolge per Drag & Drop ändern.
|
||
{scriptReordering ? ' Speichere Reihenfolge ...' : ''}
|
||
</small>
|
||
|
||
<div className="script-list-box">
|
||
<h4>Verfügbare Scripts</h4>
|
||
{scriptsLoading ? (
|
||
<p>Lade Scripts ...</p>
|
||
) : (
|
||
<div className="script-list script-list--reorderable">
|
||
{scriptEditor?.mode === 'create' ? (
|
||
<div className="script-list-item script-list-item-editing">
|
||
<div className="script-list-main">
|
||
<div className="script-title-line">
|
||
<strong className="script-id-title">NEU - Titel</strong>
|
||
<InputText
|
||
id="script-name-new"
|
||
value={scriptEditor?.name || ''}
|
||
onChange={(event) => handleScriptEditorChange('name', event.target.value)}
|
||
placeholder="z.B. Library Refresh"
|
||
className="script-title-input"
|
||
/>
|
||
</div>
|
||
{scriptErrors?.name ? <small className="error-text">{scriptErrors.name}</small> : null}
|
||
</div>
|
||
<div className="script-editor-fields">
|
||
<label htmlFor="script-body-new">Bash Script</label>
|
||
<InputTextarea
|
||
id="script-body-new"
|
||
value={scriptEditor?.scriptBody || ''}
|
||
onChange={(event) => handleScriptEditorChange('scriptBody', event.target.value)}
|
||
rows={12}
|
||
autoResize={false}
|
||
placeholder={'#!/usr/bin/env bash\necho "Post-Encode Script"'}
|
||
/>
|
||
{scriptErrors?.scriptBody ? <small className="error-text">{scriptErrors.scriptBody}</small> : null}
|
||
</div>
|
||
<div className="script-list-actions">
|
||
<Button
|
||
label="Speichern"
|
||
icon="pi pi-save"
|
||
onClick={handleSaveScript}
|
||
loading={scriptSaving}
|
||
/>
|
||
<Button
|
||
label="Verwerfen"
|
||
icon="pi pi-times"
|
||
severity="secondary"
|
||
outlined
|
||
onClick={clearScriptEditor}
|
||
disabled={scriptSaving}
|
||
/>
|
||
<span className="script-action-spacer" aria-hidden />
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{scripts.length === 0 ? (
|
||
<p>Keine Scripts vorhanden.</p>
|
||
) : (
|
||
<div className="script-order-list">
|
||
{scripts.map((script, index) => {
|
||
const isDragging = Number(scriptListDragSourceId) === Number(script.id);
|
||
return (
|
||
<div key={script.id} className="script-order-wrapper">
|
||
<div
|
||
className="script-order-drop-zone"
|
||
onDragOver={handleScriptListDragOver}
|
||
onDrop={(event) => handleScriptListDrop(event, index)}
|
||
/>
|
||
<div
|
||
className={`script-list-item${isDragging ? ' script-list-item--dragging' : ''}`}
|
||
draggable={!scriptListDnDDisabled}
|
||
onDragStart={(event) => handleScriptListDragStart(event, script.id)}
|
||
onDragEnd={() => setScriptListDragSourceId(null)}
|
||
>
|
||
<div
|
||
className={`script-list-drag-handle${scriptListDnDDisabled ? ' disabled' : ''}`}
|
||
title={scriptListDnDDisabled ? 'Sortierung aktuell nicht verfügbar' : 'Ziehen zum Sortieren'}
|
||
>
|
||
<i className="pi pi-bars" />
|
||
</div>
|
||
<div className="script-list-main">
|
||
<strong className="script-id-title">{`ID #${script.id} - ${script.name}`}</strong>
|
||
</div>
|
||
|
||
<div className="script-list-actions">
|
||
<Button
|
||
icon="pi pi-pencil"
|
||
label="Bearbeiten"
|
||
severity="secondary"
|
||
outlined
|
||
onClick={() => startEditScript(script)}
|
||
disabled={Boolean(scriptActionBusyId) || scriptSaving || scriptReordering || scriptEditor?.mode === 'create'}
|
||
/>
|
||
<Button
|
||
icon="pi pi-play"
|
||
label="Test"
|
||
severity="info"
|
||
onClick={() => handleTestScript(script)}
|
||
loading={scriptActionBusyId === script.id}
|
||
disabled={scriptReordering || (Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id)}
|
||
/>
|
||
<Button
|
||
icon="pi pi-trash"
|
||
label="Löschen"
|
||
severity="danger"
|
||
outlined
|
||
onClick={() => handleDeleteScript(script)}
|
||
loading={scriptActionBusyId === script.id}
|
||
disabled={scriptReordering || (Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
<div
|
||
className="script-order-drop-zone script-order-drop-zone--end"
|
||
onDragOver={handleScriptListDragOver}
|
||
onDrop={(event) => handleScriptListDrop(event, scripts.length)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<Dialog
|
||
header={scriptEditor?.id ? `Script bearbeiten (#${scriptEditor.id})` : 'Script bearbeiten'}
|
||
visible={scriptEditor?.mode === 'edit'}
|
||
onHide={clearScriptEditor}
|
||
style={{ width: 'min(52rem, calc(100vw - 1.5rem))' }}
|
||
className="script-edit-dialog"
|
||
dismissableMask
|
||
draggable={false}
|
||
>
|
||
<div className="script-editor-fields">
|
||
<label htmlFor="script-edit-name">Name</label>
|
||
<InputText
|
||
id="script-edit-name"
|
||
value={scriptEditor?.name || ''}
|
||
onChange={(event) => handleScriptEditorChange('name', event.target.value)}
|
||
placeholder="z.B. Library Refresh"
|
||
/>
|
||
{scriptErrors?.name ? <small className="error-text">{scriptErrors.name}</small> : null}
|
||
<label htmlFor="script-edit-body">Bash Script</label>
|
||
<InputTextarea
|
||
id="script-edit-body"
|
||
value={scriptEditor?.scriptBody || ''}
|
||
onChange={(event) => handleScriptEditorChange('scriptBody', event.target.value)}
|
||
rows={14}
|
||
autoResize={false}
|
||
placeholder={'#!/usr/bin/env bash\necho "Post-Encode Script"'}
|
||
/>
|
||
{scriptErrors?.scriptBody ? <small className="error-text">{scriptErrors.scriptBody}</small> : null}
|
||
</div>
|
||
<div className="actions-row">
|
||
<Button
|
||
label="Script aktualisieren"
|
||
icon="pi pi-save"
|
||
onClick={handleSaveScript}
|
||
loading={scriptSaving}
|
||
/>
|
||
<Button
|
||
label="Abbrechen"
|
||
icon="pi pi-times"
|
||
severity="secondary"
|
||
outlined
|
||
onClick={clearScriptEditor}
|
||
disabled={scriptSaving}
|
||
/>
|
||
</div>
|
||
</Dialog>
|
||
|
||
{lastScriptTestResult ? (
|
||
<div className="script-test-result">
|
||
<h4>Letzter Script-Test: {lastScriptTestResult.scriptName}</h4>
|
||
<small>
|
||
Status: {lastScriptTestResult.success ? 'Erfolgreich' : 'Fehler'}
|
||
{' | '}exit={lastScriptTestResult.exitCode ?? 'n/a'}
|
||
{' | '}timeout={lastScriptTestResult.timedOut ? 'ja' : 'nein'}
|
||
{' | '}dauer={Number(lastScriptTestResult.durationMs || 0)}ms
|
||
</small>
|
||
<pre>{`${lastScriptTestResult.stdout || ''}${lastScriptTestResult.stderr ? `\n${lastScriptTestResult.stderr}` : ''}`.trim() || 'Keine Ausgabe.'}</pre>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</TabPanel>
|
||
|
||
<TabPanel header="Skriptketten">
|
||
<div className="script-manager-wrap">
|
||
<div className="actions-row">
|
||
<Button
|
||
label="Neue Kette erstellen"
|
||
icon="pi pi-plus"
|
||
severity="success"
|
||
outlined
|
||
onClick={() => openChainEditor()}
|
||
disabled={chainReordering}
|
||
/>
|
||
<Button
|
||
label="Ketten neu laden"
|
||
icon="pi pi-refresh"
|
||
severity="secondary"
|
||
onClick={() => loadChains()}
|
||
loading={chainsLoading}
|
||
disabled={chainReordering}
|
||
/>
|
||
</div>
|
||
|
||
<small>
|
||
Skriptketten kombinieren einzelne Scripte und Systemblöcke (z.B. Warten) zu einer ausführbaren Sequenz.
|
||
Ketten können an Jobs als Pre- oder Post-Encode-Aktion hinterlegt werden.
|
||
</small>
|
||
<small className="muted-inline">
|
||
Reihenfolge per Drag & Drop ändern.
|
||
{chainReordering ? ' Speichere Reihenfolge ...' : ''}
|
||
</small>
|
||
|
||
<div className="script-list-box">
|
||
<h4>Verfügbare Skriptketten</h4>
|
||
{chainsLoading ? (
|
||
<p>Lade Skriptketten...</p>
|
||
) : chains.length === 0 ? (
|
||
<p>Keine Skriptketten vorhanden.</p>
|
||
) : (
|
||
<div className="script-list script-list--reorderable">
|
||
<div className="script-order-list">
|
||
{chains.map((chain, index) => {
|
||
const isDragging = Number(chainListDragSourceId) === Number(chain.id);
|
||
return (
|
||
<div key={chain.id} className="script-order-wrapper">
|
||
<div
|
||
className="script-order-drop-zone"
|
||
onDragOver={handleChainListDragOver}
|
||
onDrop={(event) => handleChainListDrop(event, index)}
|
||
/>
|
||
<div
|
||
className={`script-list-item${isDragging ? ' script-list-item--dragging' : ''}`}
|
||
draggable={!chainListDnDDisabled}
|
||
onDragStart={(event) => handleChainListDragStart(event, chain.id)}
|
||
onDragEnd={() => setChainListDragSourceId(null)}
|
||
>
|
||
<div
|
||
className={`script-list-drag-handle${chainListDnDDisabled ? ' disabled' : ''}`}
|
||
title={chainListDnDDisabled ? 'Sortierung aktuell nicht verfügbar' : 'Ziehen zum Sortieren'}
|
||
>
|
||
<i className="pi pi-bars" />
|
||
</div>
|
||
<div className="script-list-main">
|
||
<strong className="script-id-title">{`ID #${chain.id} - ${chain.name}`}</strong>
|
||
<small>
|
||
{chain.steps?.length ?? 0} Schritt(e):
|
||
{' '}
|
||
{(chain.steps || []).map((s, i) => (
|
||
<span key={i}>
|
||
{i > 0 ? ' → ' : ''}
|
||
{s.stepType === 'wait'
|
||
? `⏱ ${s.waitSeconds}s`
|
||
: (s.scriptName || `Script #${s.scriptId}`)}
|
||
</span>
|
||
))}
|
||
</small>
|
||
</div>
|
||
<div className="script-list-actions">
|
||
<Button
|
||
icon="pi pi-pencil"
|
||
label="Bearbeiten"
|
||
severity="secondary"
|
||
outlined
|
||
onClick={() => openChainEditor(chain)}
|
||
disabled={chainReordering || Boolean(chainActionBusyId)}
|
||
/>
|
||
<Button
|
||
icon="pi pi-play"
|
||
label="Test"
|
||
severity="info"
|
||
onClick={() => handleTestChain(chain)}
|
||
loading={chainActionBusyId === chain.id}
|
||
disabled={chainReordering || (Boolean(chainActionBusyId) && chainActionBusyId !== chain.id)}
|
||
/>
|
||
<Button
|
||
icon="pi pi-trash"
|
||
label="Löschen"
|
||
severity="danger"
|
||
outlined
|
||
onClick={() => handleDeleteChain(chain)}
|
||
disabled={chainReordering || Boolean(chainActionBusyId)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
<div
|
||
className="script-order-drop-zone script-order-drop-zone--end"
|
||
onDragOver={handleChainListDragOver}
|
||
onDrop={(event) => handleChainListDrop(event, chains.length)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{lastChainTestResult ? (
|
||
<div className="script-test-result">
|
||
<h4>Letzter Ketten-Test: {lastChainTestResult.chainName}</h4>
|
||
<small>
|
||
Status: {lastChainTestResult.aborted ? 'Abgebrochen' : 'Erfolgreich'}
|
||
{' | '}Schritte: {lastChainTestResult.succeeded ?? 0}/{lastChainTestResult.steps ?? 0}
|
||
{lastChainTestResult.failed > 0 ? ` | Fehler: ${lastChainTestResult.failed}` : ''}
|
||
</small>
|
||
{(lastChainTestResult.results || []).map((step, i) => (
|
||
<div key={i} className="script-test-step">
|
||
<strong>
|
||
{`Schritt ${i + 1}: `}
|
||
{step.stepType === 'wait'
|
||
? `⏱ Warten (${step.waitSeconds}s)`
|
||
: (step.scriptName || `Script #${step.scriptId}`)}
|
||
{' — '}
|
||
{step.skipped ? 'Übersprungen' : (step.success ? '✓ OK' : `✗ Fehler (exit=${step.exitCode ?? 'n/a'})`)}
|
||
</strong>
|
||
{(step.stdout || step.stderr) ? (
|
||
<pre>{`${step.stdout || ''}${step.stderr ? `\n${step.stderr}` : ''}`.trim()}</pre>
|
||
) : null}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* Chain editor dialog */}
|
||
<Dialog
|
||
header={chainEditor.id ? `Skriptkette bearbeiten (#${chainEditor.id})` : 'Neue Skriptkette'}
|
||
visible={chainEditor.open}
|
||
onHide={closeChainEditor}
|
||
style={{ width: 'min(70rem, calc(100vw - 1.5rem))' }}
|
||
className="script-edit-dialog chain-editor-dialog"
|
||
dismissableMask={false}
|
||
draggable={false}
|
||
>
|
||
<div className="chain-editor-name-row">
|
||
<label htmlFor="chain-name">Name der Kette</label>
|
||
<InputText
|
||
id="chain-name"
|
||
value={chainEditor.name}
|
||
onChange={(e) => {
|
||
setChainEditor((prev) => ({ ...prev, name: e.target.value }));
|
||
setChainEditorErrors((prev) => ({ ...prev, name: null }));
|
||
}}
|
||
placeholder="z.B. Plex-Refresh + Cleanup"
|
||
/>
|
||
{chainEditorErrors.name ? <small className="error-text">{chainEditorErrors.name}</small> : null}
|
||
</div>
|
||
|
||
<div className="chain-editor-body">
|
||
{/* Palette */}
|
||
<div className="chain-palette">
|
||
<h4>Bausteine</h4>
|
||
<p className="chain-palette-hint">Auf Schritt klicken oder in die Kette ziehen</p>
|
||
|
||
<div className="chain-palette-section">
|
||
<strong>Systemblöcke</strong>
|
||
<div
|
||
className="chain-palette-item chain-palette-item--system"
|
||
draggable
|
||
onDragStart={(e) => handleChainPaletteDragStart(e, { stepType: 'wait' })}
|
||
onClick={() => addChainStep('wait')}
|
||
title="Wartezeit zwischen zwei Schritten"
|
||
>
|
||
<i className="pi pi-clock" />
|
||
{' '}Warten (Sekunden)
|
||
</div>
|
||
</div>
|
||
|
||
{scripts.length > 0 ? (
|
||
<div className="chain-palette-section">
|
||
<strong>Scripte</strong>
|
||
{scripts.map((script) => (
|
||
<div
|
||
key={script.id}
|
||
className="chain-palette-item chain-palette-item--script"
|
||
draggable
|
||
onDragStart={(e) => handleChainPaletteDragStart(e, { stepType: 'script', scriptId: script.id, scriptName: script.name })}
|
||
onClick={() => addChainStep('script', script.id, script.name)}
|
||
title={`Script #${script.id} hinzufügen`}
|
||
>
|
||
<i className="pi pi-code" />
|
||
{' '}{script.name}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<small>Keine Scripte verfügbar. Zuerst Scripte anlegen.</small>
|
||
)}
|
||
</div>
|
||
|
||
{/* Chain canvas */}
|
||
<div className="chain-canvas">
|
||
<h4>Kette ({chainEditor.steps.length} Schritt{chainEditor.steps.length !== 1 ? 'e' : ''})</h4>
|
||
|
||
{chainEditor.steps.length === 0 ? (
|
||
<div
|
||
className="chain-canvas-empty"
|
||
onDragOver={handleChainDragOver}
|
||
onDrop={(e) => handleChainDropzoneDrop(e, 0)}
|
||
>
|
||
Bausteine hierhin ziehen oder links anklicken
|
||
</div>
|
||
) : (
|
||
<div className="chain-steps-list">
|
||
{chainEditor.steps.map((step, index) => (
|
||
<div key={step._key || index} className="chain-step-wrapper">
|
||
{/* Drop zone before step */}
|
||
<div
|
||
className="chain-drop-zone"
|
||
onDragOver={handleChainDragOver}
|
||
onDrop={(e) => handleChainDropzoneDrop(e, index)}
|
||
/>
|
||
<div
|
||
className={`chain-step chain-step--${step.stepType}`}
|
||
draggable
|
||
onDragStart={(e) => handleChainStepDragStart(e, index)}
|
||
onDragEnd={() => setChainDragSource(null)}
|
||
>
|
||
<div className="chain-step-drag-handle">
|
||
<i className="pi pi-bars" />
|
||
</div>
|
||
<div className="chain-step-content">
|
||
{step.stepType === 'wait' ? (
|
||
<div className="chain-step-wait">
|
||
<i className="pi pi-clock" />
|
||
<span>Warten:</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="3600"
|
||
value={step.waitSeconds ?? 10}
|
||
onChange={(e) => updateChainStepWait(index, Number(e.target.value))}
|
||
className="chain-wait-input"
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
<span>Sekunden</span>
|
||
</div>
|
||
) : (
|
||
<div className="chain-step-script">
|
||
<i className="pi pi-code" />
|
||
<span>{step.scriptName || `Script #${step.scriptId}`}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<Button
|
||
icon="pi pi-times"
|
||
severity="danger"
|
||
text
|
||
rounded
|
||
className="chain-step-remove"
|
||
onClick={() => removeChainStep(index)}
|
||
title="Schritt entfernen"
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{/* Drop zone after last step */}
|
||
<div
|
||
className="chain-drop-zone chain-drop-zone--end"
|
||
onDragOver={handleChainDragOver}
|
||
onDrop={(e) => handleChainDropzoneDrop(e, chainEditor.steps.length)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="actions-row" style={{ marginTop: '1rem' }}>
|
||
<Button
|
||
label={chainEditor.id ? 'Kette aktualisieren' : 'Kette erstellen'}
|
||
icon="pi pi-save"
|
||
onClick={handleSaveChain}
|
||
loading={chainSaving}
|
||
/>
|
||
<Button
|
||
label="Abbrechen"
|
||
icon="pi pi-times"
|
||
severity="secondary"
|
||
outlined
|
||
onClick={closeChainEditor}
|
||
disabled={chainSaving}
|
||
/>
|
||
</div>
|
||
</Dialog>
|
||
</TabPanel>
|
||
|
||
<TabPanel header="Encode-Presets">
|
||
<div className="actions-row">
|
||
<Button
|
||
label="Neues Preset"
|
||
icon="pi pi-plus"
|
||
onClick={openNewUserPreset}
|
||
severity="success"
|
||
outlined
|
||
disabled={userPresetSaving}
|
||
/>
|
||
<Button
|
||
label="Presets neu laden"
|
||
icon="pi pi-refresh"
|
||
severity="secondary"
|
||
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
|
||
in der Mediainfo-Prüfung ausgewählt werden. Kein Preset gewählt = Fallback aus Einstellungen.
|
||
</small>
|
||
|
||
{userPresetsLoading ? (
|
||
<p style={{ marginTop: '1rem' }}>Lade Presets ...</p>
|
||
) : userPresets.length === 0 ? (
|
||
<p style={{ marginTop: '1rem' }}>Keine Presets vorhanden. Lege ein neues Preset an.</p>
|
||
) : (
|
||
<div className="script-list script-list--reorderable" style={{ marginTop: '1rem' }}>
|
||
{userPresets.map((preset) => (
|
||
<div key={preset.id} className="script-list-item">
|
||
<div className="script-list-main">
|
||
<div className="script-title-line">
|
||
<strong className="script-id-title">#{preset.id} - {preset.name}</strong>
|
||
<span className="preset-media-type-tag">
|
||
{preset.mediaType === 'bluray' ? 'Blu-ray'
|
||
: preset.mediaType === 'dvd' ? 'DVD'
|
||
: 'Universell'}
|
||
</span>
|
||
</div>
|
||
<small className="preset-description-line" title={preset.description || ''}>
|
||
{preset.description || '-'}
|
||
</small>
|
||
</div>
|
||
<div className="script-list-actions script-list-actions--two">
|
||
<Button
|
||
icon="pi pi-pencil"
|
||
label="Bearbeiten"
|
||
severity="secondary"
|
||
outlined
|
||
onClick={() => openEditUserPreset(preset)}
|
||
/>
|
||
<Button
|
||
icon="pi pi-trash"
|
||
label="Löschen"
|
||
severity="danger"
|
||
outlined
|
||
onClick={() => handleDeleteUserPreset(preset.id)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<Dialog
|
||
header={userPresetEditor.id ? 'Preset bearbeiten' : 'Neues Preset'}
|
||
visible={userPresetEditor.open}
|
||
style={{ width: '520px' }}
|
||
onHide={closeUserPresetEditor}
|
||
modal
|
||
>
|
||
<div className="script-editor-fields" style={{ display: 'flex', flexDirection: 'column', gap: '0.8rem' }}>
|
||
<div>
|
||
<label htmlFor="preset-name" style={{ display: 'block', marginBottom: '0.3rem' }}>Name *</label>
|
||
<InputText
|
||
id="preset-name"
|
||
value={userPresetEditor.name}
|
||
onChange={(e) => setUserPresetEditor((prev) => ({ ...prev, name: e.target.value }))}
|
||
placeholder="z.B. Blu-ray HQ"
|
||
style={{ width: '100%' }}
|
||
/>
|
||
{userPresetErrors.name && <small className="error-text">{userPresetErrors.name}</small>}
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="preset-media-type" style={{ display: 'block', marginBottom: '0.3rem' }}>Medientyp</label>
|
||
<select
|
||
id="preset-media-type"
|
||
value={userPresetEditor.mediaType}
|
||
onChange={(e) => setUserPresetEditor((prev) => ({ ...prev, mediaType: e.target.value }))}
|
||
style={{ width: '100%', padding: '0.5rem', borderRadius: '4px', border: '1px solid var(--surface-border, #ccc)', background: 'var(--surface-overlay, #fff)', color: 'var(--text-color, #000)' }}
|
||
>
|
||
<option value="all">Universell (alle Medien)</option>
|
||
<option value="bluray">Blu-ray</option>
|
||
<option value="dvd">DVD</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="preset-hb-preset" style={{ display: 'block', marginBottom: '0.3rem' }}>HandBrake Preset (-Z)</label>
|
||
<Dropdown
|
||
id="preset-hb-preset"
|
||
value={userPresetEditor.handbrakePreset}
|
||
options={userPresetHandBrakeOptions}
|
||
optionLabel="label"
|
||
optionValue="value"
|
||
optionDisabled="disabled"
|
||
onChange={(e) => setUserPresetEditor((prev) => ({ ...prev, handbrakePreset: String(e.value || '') }))}
|
||
placeholder="Preset auswählen"
|
||
showClear
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="preset-extra-args" style={{ display: 'block', marginBottom: '0.3rem' }}>Extra Args</label>
|
||
<InputText
|
||
id="preset-extra-args"
|
||
value={userPresetEditor.extraArgs}
|
||
onChange={(e) => setUserPresetEditor((prev) => ({ ...prev, extraArgs: e.target.value }))}
|
||
placeholder="z.B. -q 22 --encoder x264"
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="preset-description" style={{ display: 'block', marginBottom: '0.3rem' }}>Beschreibung (optional)</label>
|
||
<InputTextarea
|
||
id="preset-description"
|
||
value={userPresetEditor.description}
|
||
onChange={(e) => setUserPresetEditor((prev) => ({ ...prev, description: e.target.value }))}
|
||
rows={3}
|
||
autoResize
|
||
placeholder="Kurzbeschreibung für dieses Preset"
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
<div className="actions-row" style={{ marginTop: '0.5rem' }}>
|
||
<Button
|
||
label={userPresetEditor.id ? 'Aktualisieren' : 'Erstellen'}
|
||
icon="pi pi-save"
|
||
onClick={handleSaveUserPreset}
|
||
loading={userPresetSaving}
|
||
/>
|
||
<Button
|
||
label="Abbrechen"
|
||
icon="pi pi-times"
|
||
severity="secondary"
|
||
outlined
|
||
onClick={closeUserPresetEditor}
|
||
disabled={userPresetSaving}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Dialog>
|
||
</TabPanel>
|
||
|
||
<TabPanel header="Cronjobs">
|
||
<CronJobsTab />
|
||
</TabPanel>
|
||
</TabView>
|
||
</Card>
|
||
|
||
{expertModeEnabled && activationBytes.length > 0 && (
|
||
<Card
|
||
title="Activation Bytes Cache"
|
||
subTitle="Lokal gespeicherte AAX-Activation Bytes. Werden beim ersten Upload automatisch über die Audible-Tools API ermittelt."
|
||
style={{ marginTop: '1.5rem' }}
|
||
>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '1px solid var(--surface-border)' }}>
|
||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Checksum</th>
|
||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Activation Bytes</th>
|
||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Gespeichert am</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{activationBytes.map((entry) => (
|
||
<tr key={entry.checksum} style={{ borderBottom: '1px solid var(--surface-border)' }}>
|
||
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--text-color-secondary)' }}>{entry.checksum}</td>
|
||
<td style={{ padding: '0.5rem 0.75rem', fontWeight: 'bold' }}>{entry.activation_bytes}</td>
|
||
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--text-color-secondary)' }}>{new Date(entry.created_at).toLocaleString('de-DE')}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
)}
|
||
|
||
</div>
|
||
);
|
||
}
|