UI/Features
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { TabView, TabPanel } from 'primereact/tabview';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
@@ -20,7 +20,8 @@ const GENERAL_TOOL_KEYS = new Set([
|
||||
'makemkv_min_length_minutes',
|
||||
'mediainfo_command',
|
||||
'handbrake_command',
|
||||
'handbrake_restart_delete_incomplete_output'
|
||||
'handbrake_restart_delete_incomplete_output',
|
||||
'script_test_timeout_ms'
|
||||
]);
|
||||
|
||||
const HANDBRAKE_PRESET_SETTING_KEYS = new Set([
|
||||
@@ -29,6 +30,78 @@ const HANDBRAKE_PRESET_SETTING_KEYS = new Set([
|
||||
'handbrake_preset_dvd'
|
||||
]);
|
||||
|
||||
const NOTIFICATION_EVENT_TOGGLE_KEYS = new Set([
|
||||
'pushover_notify_metadata_ready',
|
||||
'pushover_notify_rip_started',
|
||||
'pushover_notify_encoding_started',
|
||||
'pushover_notify_job_finished',
|
||||
'pushover_notify_job_error',
|
||||
'pushover_notify_job_cancelled',
|
||||
'pushover_notify_reencode_started',
|
||||
'pushover_notify_reencode_finished'
|
||||
]);
|
||||
|
||||
const PUSHOVER_ENABLED_SETTING_KEY = 'pushover_enabled';
|
||||
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
|
||||
const ALWAYS_HIDDEN_SETTING_KEYS = new Set([
|
||||
'drive_device',
|
||||
'makemkv_rip_mode',
|
||||
'makemkv_rip_mode_bluray',
|
||||
'makemkv_rip_mode_dvd',
|
||||
'makemkv_backup_mode'
|
||||
]);
|
||||
const EXPERT_ONLY_SETTING_KEYS = new Set([
|
||||
'pushover_device',
|
||||
'pushover_priority',
|
||||
'pushover_timeout_ms',
|
||||
'makemkv_source_index',
|
||||
'disc_poll_interval_ms',
|
||||
'hardware_monitoring_interval_ms',
|
||||
'makemkv_command',
|
||||
'mediainfo_command',
|
||||
'handbrake_command',
|
||||
'mediainfo_extra_args_bluray',
|
||||
'mediainfo_extra_args_dvd',
|
||||
'makemkv_analyze_extra_args_bluray',
|
||||
'makemkv_analyze_extra_args_dvd',
|
||||
'makemkv_rip_extra_args_bluray',
|
||||
'makemkv_rip_extra_args_dvd',
|
||||
'cdparanoia_command'
|
||||
]);
|
||||
|
||||
function toBoolean(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
|
||||
function shouldHideSettingByExpertMode(settingKey, expertModeEnabled) {
|
||||
const key = normalizeSettingKey(settingKey);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
if (ALWAYS_HIDDEN_SETTING_KEYS.has(key)) {
|
||||
return true;
|
||||
}
|
||||
if (key === EXPERT_MODE_SETTING_KEY) {
|
||||
return true;
|
||||
}
|
||||
return !expertModeEnabled && EXPERT_ONLY_SETTING_KEYS.has(key);
|
||||
}
|
||||
|
||||
function filterSettingsByVisibility(settings, expertModeEnabled) {
|
||||
const list = Array.isArray(settings) ? settings : [];
|
||||
return list.filter((setting) => !shouldHideSettingByExpertMode(setting?.key, expertModeEnabled));
|
||||
}
|
||||
|
||||
function buildToolSections(settings) {
|
||||
const list = Array.isArray(settings) ? settings : [];
|
||||
const generalBucket = {
|
||||
@@ -84,6 +157,12 @@ function buildToolSections(settings) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Path keys per medium — _owner keys are rendered inline
|
||||
const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray'];
|
||||
const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd'];
|
||||
const CD_PATH_KEYS = ['raw_dir_cd', 'cd_output_template'];
|
||||
const LOG_PATH_KEYS = ['log_dir'];
|
||||
|
||||
function buildSectionsForCategory(categoryName, settings) {
|
||||
const list = Array.isArray(settings) ? settings : [];
|
||||
const normalizedCategory = normalizeText(categoryName);
|
||||
@@ -108,187 +187,499 @@ function isHandBrakePresetSetting(setting) {
|
||||
return HANDBRAKE_PRESET_SETTING_KEYS.has(key);
|
||||
}
|
||||
|
||||
function isNotificationEventToggleSetting(setting) {
|
||||
return setting?.type === 'boolean' && NOTIFICATION_EVENT_TOGGLE_KEYS.has(normalizeSettingKey(setting?.key));
|
||||
}
|
||||
|
||||
function SettingField({
|
||||
setting,
|
||||
value,
|
||||
error,
|
||||
dirty,
|
||||
ownerSetting,
|
||||
ownerValue,
|
||||
ownerError,
|
||||
ownerDirty,
|
||||
onChange,
|
||||
variant = 'default'
|
||||
}) {
|
||||
const ownerKey = ownerSetting?.key;
|
||||
const pathHasValue = Boolean(String(value ?? '').trim());
|
||||
const isNotificationToggleBox = variant === 'notification-toggle' && setting?.type === 'boolean';
|
||||
|
||||
return (
|
||||
<div className={`setting-row${isNotificationToggleBox ? ' notification-toggle-box' : ''}`}>
|
||||
{isNotificationToggleBox ? (
|
||||
<div className="notification-toggle-head">
|
||||
<label htmlFor={setting.key}>
|
||||
{setting.label}
|
||||
{setting.required && <span className="required">*</span>}
|
||||
</label>
|
||||
<InputSwitch
|
||||
id={setting.key}
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<label htmlFor={setting.key}>
|
||||
{setting.label}
|
||||
{setting.required && <span className="required">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{setting.type === 'string' || setting.type === 'path' ? (
|
||||
<InputText
|
||||
id={setting.key}
|
||||
value={value ?? ''}
|
||||
onChange={(event) => onChange?.(setting.key, event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'number' ? (
|
||||
<InputNumber
|
||||
id={setting.key}
|
||||
value={value ?? 0}
|
||||
onValueChange={(event) => onChange?.(setting.key, event.value)}
|
||||
mode="decimal"
|
||||
useGrouping={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'boolean' && !isNotificationToggleBox ? (
|
||||
<InputSwitch
|
||||
id={setting.key}
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'select' ? (
|
||||
<Dropdown
|
||||
id={setting.key}
|
||||
value={value}
|
||||
options={setting.options}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<small className="setting-description">{setting.description || ''}</small>
|
||||
{isHandBrakePresetSetting(setting) ? (
|
||||
<small>
|
||||
Preset-Erklärung:{' '}
|
||||
<a
|
||||
href="https://handbrake.fr/docs/en/latest/technical/official-presets.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
HandBrake Official Presets
|
||||
</a>
|
||||
</small>
|
||||
) : null}
|
||||
{error ? (
|
||||
<small className="error-text">{error}</small>
|
||||
) : (
|
||||
<Tag
|
||||
value={dirty ? 'Ungespeichert' : 'Gespeichert'}
|
||||
severity={dirty ? 'warning' : 'success'}
|
||||
className="saved-tag"
|
||||
/>
|
||||
)}
|
||||
|
||||
{ownerSetting ? (
|
||||
<div className="setting-owner-row">
|
||||
<label htmlFor={ownerKey} className="setting-owner-label">
|
||||
Eigentümer (user:gruppe)
|
||||
</label>
|
||||
<InputText
|
||||
id={ownerKey}
|
||||
value={ownerValue ?? ''}
|
||||
placeholder="z.B. michael:ripster"
|
||||
disabled={!pathHasValue}
|
||||
onChange={(event) => onChange?.(ownerKey, event.target.value)}
|
||||
/>
|
||||
{ownerError ? (
|
||||
<small className="error-text">{ownerError}</small>
|
||||
) : (
|
||||
<Tag
|
||||
value={ownerDirty ? 'Ungespeichert' : 'Gespeichert'}
|
||||
severity={ownerDirty ? 'warning' : 'success'}
|
||||
className="saved-tag"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PathMediumCard({ title, pathSettings, settingsByKey, values, errors, dirtyKeys, onChange }) {
|
||||
// Filter out _owner keys since they're rendered inline
|
||||
const visibleSettings = pathSettings.filter(
|
||||
(s) => !String(s?.key || '').endsWith('_owner')
|
||||
);
|
||||
|
||||
if (visibleSettings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="path-medium-card">
|
||||
<div className="path-medium-card-header">
|
||||
<h4>{title}</h4>
|
||||
</div>
|
||||
<div className="settings-grid">
|
||||
{visibleSettings.map((setting) => {
|
||||
const value = values?.[setting.key];
|
||||
const error = errors?.[setting.key] || null;
|
||||
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||
const ownerKey = `${setting.key}_owner`;
|
||||
const ownerSetting = settingsByKey.get(ownerKey) || null;
|
||||
const ownerValue = values?.[ownerKey];
|
||||
const ownerError = errors?.[ownerKey] || null;
|
||||
const ownerDirty = Boolean(dirtyKeys?.has?.(ownerKey));
|
||||
|
||||
return (
|
||||
<SettingField
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
value={value}
|
||||
error={error}
|
||||
dirty={dirty}
|
||||
ownerSetting={ownerSetting}
|
||||
ownerValue={ownerValue}
|
||||
ownerError={ownerError}
|
||||
ownerDirty={ownerDirty}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effectivePaths }) {
|
||||
const list = Array.isArray(settings) ? settings : [];
|
||||
const settingsByKey = new Map(list.map((s) => [s.key, s]));
|
||||
|
||||
const bluraySettings = list.filter((s) => BLURAY_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && BLURAY_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||
const dvdSettings = list.filter((s) => DVD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DVD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||
const cdSettings = list.filter((s) => CD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && CD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||
const logSettings = list.filter((s) => LOG_PATH_KEYS.includes(s.key));
|
||||
|
||||
const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
|
||||
const defaultMovies = effectivePaths?.defaults?.movies || 'data/output/movies';
|
||||
const defaultCd = effectivePaths?.defaults?.cd || 'data/output/cd';
|
||||
|
||||
const ep = effectivePaths || {};
|
||||
const blurayRaw = ep.bluray?.raw || defaultRaw;
|
||||
const blurayMovies = ep.bluray?.movies || defaultMovies;
|
||||
const dvdRaw = ep.dvd?.raw || defaultRaw;
|
||||
const dvdMovies = ep.dvd?.movies || defaultMovies;
|
||||
const cdOutput = ep.cd?.raw || defaultCd;
|
||||
|
||||
const isDefault = (path, def) => path === def;
|
||||
|
||||
return (
|
||||
<div className="path-category-tab">
|
||||
{/* Effektive Pfade Übersicht */}
|
||||
<div className="path-overview-card">
|
||||
<div className="path-overview-header">
|
||||
<h4>Effektive Pfade</h4>
|
||||
<small>Zeigt die tatsächlich verwendeten Pfade entsprechend der aktuellen Konfiguration.</small>
|
||||
</div>
|
||||
<table className="path-overview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Medium</th>
|
||||
<th>RAW-Ordner</th>
|
||||
<th>Film-Ordner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Blu-ray</strong></td>
|
||||
<td>
|
||||
<code>{blurayRaw}</code>
|
||||
{isDefault(blurayRaw, defaultRaw) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
<td>
|
||||
<code>{blurayMovies}</code>
|
||||
{isDefault(blurayMovies, defaultMovies) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>DVD</strong></td>
|
||||
<td>
|
||||
<code>{dvdRaw}</code>
|
||||
{isDefault(dvdRaw, defaultRaw) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
<td>
|
||||
<code>{dvdMovies}</code>
|
||||
{isDefault(dvdMovies, defaultMovies) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>CD / Audio</strong></td>
|
||||
<td colSpan={2}>
|
||||
<code>{cdOutput}</code>
|
||||
{isDefault(cdOutput, defaultCd) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Medium-Karten */}
|
||||
<div className="path-medium-cards">
|
||||
<PathMediumCard
|
||||
title="Blu-ray"
|
||||
pathSettings={bluraySettings}
|
||||
settingsByKey={settingsByKey}
|
||||
values={values}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<PathMediumCard
|
||||
title="DVD"
|
||||
pathSettings={dvdSettings}
|
||||
settingsByKey={settingsByKey}
|
||||
values={values}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<PathMediumCard
|
||||
title="CD / Audio"
|
||||
pathSettings={cdSettings}
|
||||
settingsByKey={settingsByKey}
|
||||
values={values}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Log-Ordner */}
|
||||
{logSettings.length > 0 && (
|
||||
<div className="path-medium-card">
|
||||
<div className="path-medium-card-header">
|
||||
<h4>Logs</h4>
|
||||
</div>
|
||||
<div className="settings-grid">
|
||||
{logSettings.map((setting) => {
|
||||
const value = values?.[setting.key];
|
||||
const error = errors?.[setting.key] || null;
|
||||
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||
return (
|
||||
<SettingField
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
value={value}
|
||||
error={error}
|
||||
dirty={dirty}
|
||||
ownerSetting={null}
|
||||
ownerValue={null}
|
||||
ownerError={null}
|
||||
ownerDirty={false}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DynamicSettingsForm({
|
||||
categories,
|
||||
values,
|
||||
errors,
|
||||
dirtyKeys,
|
||||
onChange
|
||||
onChange,
|
||||
effectivePaths
|
||||
}) {
|
||||
const safeCategories = Array.isArray(categories) ? categories : [];
|
||||
const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]);
|
||||
const visibleCategories = safeCategories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
settings: filterSettingsByVisibility(category?.settings, expertModeEnabled)
|
||||
}))
|
||||
.filter((category) => Array.isArray(category?.settings) && category.settings.length > 0);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const rootRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (safeCategories.length === 0) {
|
||||
if (visibleCategories.length === 0) {
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
if (activeIndex < 0 || activeIndex >= safeCategories.length) {
|
||||
if (activeIndex < 0 || activeIndex >= visibleCategories.length) {
|
||||
setActiveIndex(0);
|
||||
}
|
||||
}, [activeIndex, safeCategories.length]);
|
||||
}, [activeIndex, visibleCategories.length]);
|
||||
|
||||
if (safeCategories.length === 0) {
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
const syncToggleHeights = () => {
|
||||
const root = rootRef.current;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
const grids = root.querySelectorAll('.notification-toggle-grid');
|
||||
for (const grid of grids) {
|
||||
const cards = Array.from(grid.querySelectorAll('.notification-toggle-box'));
|
||||
if (cards.length === 0) {
|
||||
continue;
|
||||
}
|
||||
for (const card of cards) {
|
||||
card.style.minHeight = '0px';
|
||||
}
|
||||
const maxHeight = cards.reduce((acc, card) => Math.max(acc, Number(card.offsetHeight || 0)), 0);
|
||||
if (maxHeight <= 0) {
|
||||
continue;
|
||||
}
|
||||
for (const card of cards) {
|
||||
card.style.minHeight = `${maxHeight}px`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const frameId = window.requestAnimationFrame(syncToggleHeights);
|
||||
window.addEventListener('resize', syncToggleHeights);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
window.removeEventListener('resize', syncToggleHeights);
|
||||
};
|
||||
}, [activeIndex, visibleCategories, values]);
|
||||
|
||||
if (visibleCategories.length === 0) {
|
||||
return <p>Keine Kategorien vorhanden.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TabView
|
||||
className="settings-tabview"
|
||||
activeIndex={activeIndex}
|
||||
onTabChange={(event) => setActiveIndex(Number(event.index || 0))}
|
||||
scrollable
|
||||
>
|
||||
{safeCategories.map((category, categoryIndex) => (
|
||||
<TabPanel
|
||||
key={`${category.category || 'category'}-${categoryIndex}`}
|
||||
header={category.category || `Kategorie ${categoryIndex + 1}`}
|
||||
>
|
||||
{(() => {
|
||||
const sections = buildSectionsForCategory(category?.category, category?.settings || []);
|
||||
const grouped = sections.length > 1;
|
||||
<div className="dynamic-settings-form" ref={rootRef}>
|
||||
<TabView
|
||||
className="settings-tabview"
|
||||
activeIndex={activeIndex}
|
||||
onTabChange={(event) => setActiveIndex(Number(event.index || 0))}
|
||||
scrollable
|
||||
>
|
||||
{visibleCategories.map((category, categoryIndex) => (
|
||||
<TabPanel
|
||||
key={`${category.category || 'category'}-${categoryIndex}`}
|
||||
header={category.category || `Kategorie ${categoryIndex + 1}`}
|
||||
>
|
||||
{normalizeText(category?.category) === 'pfade' ? (
|
||||
<PathCategoryTab
|
||||
settings={category?.settings || []}
|
||||
values={values}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={onChange}
|
||||
effectivePaths={effectivePaths}
|
||||
/>
|
||||
) : (() => {
|
||||
const sections = buildSectionsForCategory(category?.category, category?.settings || []);
|
||||
const grouped = sections.length > 1;
|
||||
const isNotificationCategory = normalizeText(category?.category) === 'benachrichtigungen';
|
||||
const pushoverEnabled = toBoolean(values?.[PUSHOVER_ENABLED_SETTING_KEY]);
|
||||
|
||||
return (
|
||||
<div className="settings-sections">
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={`${category?.category || 'category'}-${section.id}`}
|
||||
className={`settings-section${grouped ? ' grouped' : ''}`}
|
||||
>
|
||||
{section.title ? (
|
||||
<div className="settings-section-head">
|
||||
<h4>{section.title}</h4>
|
||||
{section.description ? <small>{section.description}</small> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{(() => {
|
||||
const ownerKeySet = new Set(
|
||||
(section.settings || [])
|
||||
.filter((s) => String(s.key || '').endsWith('_owner'))
|
||||
.map((s) => s.key)
|
||||
);
|
||||
const settingsByKey = new Map(
|
||||
(section.settings || []).map((s) => [s.key, s])
|
||||
);
|
||||
const visibleSettings = (section.settings || []).filter(
|
||||
(s) => !ownerKeySet.has(s.key)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
{visibleSettings.map((setting) => {
|
||||
const value = values?.[setting.key];
|
||||
const error = errors?.[setting.key] || null;
|
||||
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||
|
||||
const ownerKey = `${setting.key}_owner`;
|
||||
const ownerSetting = settingsByKey.get(ownerKey) || null;
|
||||
const pathHasValue = Boolean(String(value ?? '').trim());
|
||||
|
||||
return (
|
||||
<div key={setting.key} className="setting-row">
|
||||
<label htmlFor={setting.key}>
|
||||
{setting.label}
|
||||
{setting.required && <span className="required">*</span>}
|
||||
</label>
|
||||
|
||||
{setting.type === 'string' || setting.type === 'path' ? (
|
||||
<InputText
|
||||
id={setting.key}
|
||||
value={value ?? ''}
|
||||
onChange={(event) => onChange?.(setting.key, event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'number' ? (
|
||||
<InputNumber
|
||||
id={setting.key}
|
||||
value={value ?? 0}
|
||||
onValueChange={(event) => onChange?.(setting.key, event.value)}
|
||||
mode="decimal"
|
||||
useGrouping={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'boolean' ? (
|
||||
<InputSwitch
|
||||
id={setting.key}
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'select' ? (
|
||||
<Dropdown
|
||||
id={setting.key}
|
||||
value={value}
|
||||
options={setting.options}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<small>{setting.description || ''}</small>
|
||||
{isHandBrakePresetSetting(setting) ? (
|
||||
<small>
|
||||
Preset-Erklärung:{' '}
|
||||
<a
|
||||
href="https://handbrake.fr/docs/en/latest/technical/official-presets.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
HandBrake Official Presets
|
||||
</a>
|
||||
</small>
|
||||
) : null}
|
||||
{error ? (
|
||||
<small className="error-text">{error}</small>
|
||||
) : (
|
||||
<Tag
|
||||
value={dirty ? 'Ungespeichert' : 'Gespeichert'}
|
||||
severity={dirty ? 'warning' : 'success'}
|
||||
className="saved-tag"
|
||||
/>
|
||||
)}
|
||||
|
||||
{ownerSetting ? (
|
||||
<div className="setting-owner-row">
|
||||
<label htmlFor={ownerKey} className="setting-owner-label">
|
||||
Eigentümer (user:gruppe)
|
||||
</label>
|
||||
<InputText
|
||||
id={ownerKey}
|
||||
value={values?.[ownerKey] ?? ''}
|
||||
placeholder="z.B. michael:ripster"
|
||||
disabled={!pathHasValue}
|
||||
onChange={(event) => onChange?.(ownerKey, event.target.value)}
|
||||
/>
|
||||
{errors?.[ownerKey] ? (
|
||||
<small className="error-text">{errors[ownerKey]}</small>
|
||||
) : (
|
||||
<Tag
|
||||
value={dirtyKeys?.has?.(ownerKey) ? 'Ungespeichert' : 'Gespeichert'}
|
||||
severity={dirtyKeys?.has?.(ownerKey) ? 'warning' : 'success'}
|
||||
className="saved-tag"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<div className="settings-sections">
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={`${category?.category || 'category'}-${section.id}`}
|
||||
className={`settings-section${grouped ? ' grouped' : ''}`}
|
||||
>
|
||||
{section.title ? (
|
||||
<div className="settings-section-head">
|
||||
<h4>{section.title}</h4>
|
||||
{section.description ? <small>{section.description}</small> : null}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabView>
|
||||
) : null}
|
||||
{(() => {
|
||||
const ownerKeySet = new Set(
|
||||
(section.settings || [])
|
||||
.filter((s) => String(s.key || '').endsWith('_owner'))
|
||||
.map((s) => s.key)
|
||||
);
|
||||
const settingsByKey = new Map(
|
||||
(section.settings || []).map((s) => [s.key, s])
|
||||
);
|
||||
const baseSettings = (section.settings || []).filter(
|
||||
(s) => !ownerKeySet.has(s.key)
|
||||
);
|
||||
const notificationToggleSettings = isNotificationCategory
|
||||
? baseSettings.filter((setting) => isNotificationEventToggleSetting(setting))
|
||||
: [];
|
||||
const notificationToggleKeys = new Set(
|
||||
notificationToggleSettings.map((setting) => normalizeSettingKey(setting?.key))
|
||||
);
|
||||
const regularSettings = baseSettings.filter(
|
||||
(setting) => !notificationToggleKeys.has(normalizeSettingKey(setting?.key))
|
||||
);
|
||||
const renderSetting = (setting, variant = 'default') => {
|
||||
const value = values?.[setting.key];
|
||||
const error = errors?.[setting.key] || null;
|
||||
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||
|
||||
const ownerKey = `${setting.key}_owner`;
|
||||
const ownerSetting = settingsByKey.get(ownerKey) || null;
|
||||
const ownerValue = values?.[ownerKey];
|
||||
const ownerError = errors?.[ownerKey] || null;
|
||||
const ownerDirty = Boolean(dirtyKeys?.has?.(ownerKey));
|
||||
|
||||
return (
|
||||
<SettingField
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
value={value}
|
||||
error={error}
|
||||
dirty={dirty}
|
||||
ownerSetting={ownerSetting}
|
||||
ownerValue={ownerValue}
|
||||
ownerError={ownerError}
|
||||
ownerDirty={ownerDirty}
|
||||
onChange={onChange}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{regularSettings.length > 0 ? (
|
||||
<div className="settings-grid">
|
||||
{regularSettings.map((setting) => renderSetting(setting))}
|
||||
</div>
|
||||
) : null}
|
||||
{pushoverEnabled && notificationToggleSettings.length > 0 ? (
|
||||
<div className="notification-toggle-grid">
|
||||
{notificationToggleSettings.map((setting) => renderSetting(setting, 'notification-toggle'))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ import discIndicatorIcon from '../assets/media-disc.svg';
|
||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||
import { getStatusLabel } from '../utils/statusPresentation';
|
||||
|
||||
const CD_FORMAT_LABELS = {
|
||||
flac: 'FLAC',
|
||||
wav: 'WAV',
|
||||
mp3: 'MP3',
|
||||
opus: 'Opus',
|
||||
ogg: 'Ogg Vorbis'
|
||||
};
|
||||
|
||||
function JsonView({ title, value }) {
|
||||
return (
|
||||
<div>
|
||||
@@ -19,7 +27,6 @@ function ScriptResultRow({ result }) {
|
||||
const status = String(result?.status || '').toUpperCase();
|
||||
const isSuccess = status === 'SUCCESS';
|
||||
const isError = status === 'ERROR';
|
||||
const isSkipped = status.startsWith('SKIPPED');
|
||||
const icon = isSuccess ? 'pi-check-circle' : isError ? 'pi-times-circle' : 'pi-minus-circle';
|
||||
const tone = isSuccess ? 'success' : isError ? 'danger' : 'warning';
|
||||
return (
|
||||
@@ -74,6 +81,29 @@ function normalizeIdList(values) {
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function formatDurationSeconds(totalSeconds) {
|
||||
const parsed = Number(totalSeconds);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
const rounded = Math.max(0, Math.trunc(parsed));
|
||||
const hours = Math.floor(rounded / 3600);
|
||||
const minutes = Math.floor((rounded % 3600) / 60);
|
||||
const seconds = rounded % 60;
|
||||
if (hours > 0) {
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
const raw = String(value ?? '');
|
||||
if (raw.length === 0) {
|
||||
@@ -176,12 +206,13 @@ function buildConfiguredScriptAndChainSelection(job) {
|
||||
}
|
||||
|
||||
function resolveMediaType(job) {
|
||||
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
||||
const candidates = [
|
||||
job?.mediaType,
|
||||
job?.media_type,
|
||||
job?.mediaProfile,
|
||||
job?.media_profile,
|
||||
job?.encodePlan?.mediaProfile,
|
||||
encodePlan?.mediaProfile,
|
||||
job?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||
job?.makemkvInfo?.mediaProfile,
|
||||
job?.mediainfoInfo?.mediaProfile
|
||||
@@ -197,10 +228,86 @@ function resolveMediaType(job) {
|
||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
|
||||
return 'cd';
|
||||
}
|
||||
}
|
||||
const statusCandidates = [job?.status, job?.last_state, job?.makemkvInfo?.lastState];
|
||||
if (statusCandidates.some((v) => String(v || '').trim().toUpperCase().startsWith('CD_'))) {
|
||||
return 'cd';
|
||||
}
|
||||
const planFormat = String(encodePlan?.format || '').trim().toLowerCase();
|
||||
const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0;
|
||||
if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
|
||||
return 'cd';
|
||||
}
|
||||
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
|
||||
return 'cd';
|
||||
}
|
||||
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
|
||||
return 'cd';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function resolveCdDetails(job) {
|
||||
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
|
||||
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
||||
const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {};
|
||||
const tracksSource = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0
|
||||
? makemkvInfo.tracks
|
||||
: (Array.isArray(encodePlan?.tracks) ? encodePlan.tracks : []);
|
||||
const tracks = tracksSource
|
||||
.map((track) => {
|
||||
const position = normalizePositiveInteger(track?.position);
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
return { ...track, position, selected: track?.selected !== false };
|
||||
})
|
||||
.filter(Boolean);
|
||||
const selectedTracksFromPlan = Array.isArray(encodePlan?.selectedTracks)
|
||||
? encodePlan.selectedTracks.map((v) => normalizePositiveInteger(v)).filter(Boolean)
|
||||
: [];
|
||||
const selectedTrackPositions = selectedTracksFromPlan.length > 0
|
||||
? selectedTracksFromPlan
|
||||
: tracks.filter((t) => t.selected !== false).map((t) => t.position);
|
||||
const fallbackArtist = tracks.map((t) => String(t?.artist || '').trim()).find(Boolean) || null;
|
||||
const fallbackAlbum = tracks.map((t) => String(t?.album || '').trim()).find(Boolean) || null;
|
||||
const totalDurationSec = tracks.reduce((sum, t) => {
|
||||
const ms = Number(t?.durationMs);
|
||||
const sec = Number(t?.durationSec);
|
||||
if (Number.isFinite(ms) && ms > 0) {
|
||||
return sum + ms / 1000;
|
||||
}
|
||||
if (Number.isFinite(sec) && sec > 0) {
|
||||
return sum + sec;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
const format = String(encodePlan?.format || '').trim().toLowerCase();
|
||||
const mbId = String(
|
||||
selectedMetadata?.mbId
|
||||
|| selectedMetadata?.musicBrainzId
|
||||
|| selectedMetadata?.musicbrainzId
|
||||
|| selectedMetadata?.mbid
|
||||
|| ''
|
||||
).trim() || null;
|
||||
|
||||
return {
|
||||
artist: String(selectedMetadata?.artist || '').trim() || fallbackArtist || null,
|
||||
album: String(selectedMetadata?.album || '').trim() || fallbackAlbum || null,
|
||||
trackCount: tracks.length,
|
||||
selectedTrackCount: selectedTrackPositions.length,
|
||||
format,
|
||||
formatLabel: format ? (CD_FORMAT_LABELS[format] || format.toUpperCase()) : null,
|
||||
totalDurationLabel: formatDurationSeconds(totalDurationSec),
|
||||
mbId
|
||||
};
|
||||
}
|
||||
|
||||
function statusBadgeMeta(status, queued = false) {
|
||||
const normalized = String(status || '').trim().toUpperCase();
|
||||
const label = getStatusLabel(normalized, { queued });
|
||||
@@ -276,6 +383,7 @@ export default function JobDetailDialog({
|
||||
onRestartEncode,
|
||||
onRestartReview,
|
||||
onReencode,
|
||||
onRetry,
|
||||
onDeleteFiles,
|
||||
onDeleteEntry,
|
||||
onRemoveFromQueue,
|
||||
@@ -315,15 +423,22 @@ export default function JobDetailDialog({
|
||||
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
|
||||
const logTruncated = Boolean(logMeta?.truncated);
|
||||
const mediaType = resolveMediaType(job);
|
||||
const isCd = mediaType === 'cd';
|
||||
const cdDetails = isCd ? resolveCdDetails(job) : null;
|
||||
const canRetry = isCd && !running && typeof onRetry === 'function';
|
||||
const mediaTypeLabel = mediaType === 'bluray'
|
||||
? 'Blu-ray'
|
||||
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
||||
: mediaType === 'dvd'
|
||||
? 'DVD'
|
||||
: isCd
|
||||
? 'Audio CD'
|
||||
: 'Sonstiges Medium';
|
||||
const mediaTypeIcon = mediaType === 'bluray'
|
||||
? blurayIndicatorIcon
|
||||
: (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon);
|
||||
const mediaTypeAlt = mediaType === 'bluray'
|
||||
? 'Blu-ray'
|
||||
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
||||
: mediaType === 'dvd'
|
||||
? discIndicatorIcon
|
||||
: otherIndicatorIcon;
|
||||
const mediaTypeAlt = mediaTypeLabel;
|
||||
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
||||
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
|
||||
@@ -364,68 +479,119 @@ export default function JobDetailDialog({
|
||||
{job.poster_url && job.poster_url !== 'N/A' ? (
|
||||
<img src={job.poster_url} alt={job.title || 'Poster'} className="poster-large" />
|
||||
) : (
|
||||
<div className="poster-large poster-fallback">Kein Poster</div>
|
||||
<div className="poster-large poster-fallback">{isCd ? 'Kein Cover' : 'Kein Poster'}</div>
|
||||
)}
|
||||
|
||||
<div className="job-film-info-grid">
|
||||
<section className="job-meta-block job-meta-block-film">
|
||||
<h4>Film-Infos</h4>
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<strong>Titel:</strong>
|
||||
<span>{job.title || job.detected_title || '-'}</span>
|
||||
{isCd ? (
|
||||
<section className="job-meta-block job-meta-block-film">
|
||||
<h4>Musik-Infos</h4>
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<strong>Album:</strong>
|
||||
<span>{job.title || job.detected_title || cdDetails?.album || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Interpret:</strong>
|
||||
<span>{cdDetails?.artist || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Jahr:</strong>
|
||||
<span>{job.year || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Tracks:</strong>
|
||||
<span>
|
||||
{cdDetails?.trackCount > 0
|
||||
? (cdDetails.selectedTrackCount > 0 && cdDetails.selectedTrackCount !== cdDetails.trackCount
|
||||
? `${cdDetails.selectedTrackCount}/${cdDetails.trackCount}`
|
||||
: String(cdDetails.trackCount))
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Format:</strong>
|
||||
<span>{cdDetails?.formatLabel || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Gesamtdauer:</strong>
|
||||
<span>{cdDetails?.totalDurationLabel || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>MusicBrainz ID:</strong>
|
||||
<span>{cdDetails?.mbId || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Medium:</strong>
|
||||
<span className="job-step-cell">
|
||||
<img src={mediaTypeIcon} alt={mediaTypeAlt} title={mediaTypeLabel} className="media-indicator-icon" />
|
||||
<span>{mediaTypeLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Jahr:</strong>
|
||||
<span>{job.year || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>IMDb:</strong>
|
||||
<span>{job.imdb_id || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>OMDb Match:</strong>
|
||||
<BoolState value={job.selected_from_omdb} />
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Medium:</strong>
|
||||
<span className="job-step-cell">
|
||||
<img src={mediaTypeIcon} alt={mediaTypeAlt} title={mediaTypeLabel} className="media-indicator-icon" />
|
||||
<span>{mediaTypeLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="job-meta-block job-meta-block-film">
|
||||
<h4>Film-Infos</h4>
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<strong>Titel:</strong>
|
||||
<span>{job.title || job.detected_title || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Jahr:</strong>
|
||||
<span>{job.year || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>IMDb:</strong>
|
||||
<span>{job.imdb_id || '-'}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>OMDb Match:</strong>
|
||||
<BoolState value={job.selected_from_omdb} />
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Medium:</strong>
|
||||
<span className="job-step-cell">
|
||||
<img src={mediaTypeIcon} alt={mediaTypeAlt} title={mediaTypeLabel} className="media-indicator-icon" />
|
||||
<span>{mediaTypeLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="job-meta-block job-meta-block-film">
|
||||
<h4>OMDb Details</h4>
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<strong>Regisseur:</strong>
|
||||
<span>{omdbField(omdbInfo?.Director)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Schauspieler:</strong>
|
||||
<span>{omdbField(omdbInfo?.Actors)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Laufzeit:</strong>
|
||||
<span>{omdbField(omdbInfo?.Runtime)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Genre:</strong>
|
||||
<span>{omdbField(omdbInfo?.Genre)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Rotten Tomatoes:</strong>
|
||||
<span>{omdbRottenTomatoesScore(omdbInfo)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>imdbRating:</strong>
|
||||
<span>{omdbField(omdbInfo?.imdbRating)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="job-meta-block job-meta-block-film">
|
||||
<h4>OMDb Details</h4>
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<strong>Regisseur:</strong>
|
||||
<span>{omdbField(omdbInfo?.Director)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Schauspieler:</strong>
|
||||
<span>{omdbField(omdbInfo?.Actors)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Laufzeit:</strong>
|
||||
<span>{omdbField(omdbInfo?.Runtime)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Genre:</strong>
|
||||
<span>{omdbField(omdbInfo?.Genre)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Rotten Tomatoes:</strong>
|
||||
<span>{omdbRottenTomatoesScore(omdbInfo)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>imdbRating:</strong>
|
||||
<span>{omdbField(omdbInfo?.imdbRating)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -449,33 +615,43 @@ export default function JobDetailDialog({
|
||||
<strong>Ende:</strong> {job.end_time || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>RAW Pfad:</strong> {job.raw_path || '-'}
|
||||
<strong>{isCd ? 'WAV Pfad:' : 'RAW Pfad:'}</strong> {job.raw_path || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Output:</strong> {job.output_path || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Encode Input:</strong> {job.encode_input_path || '-'}
|
||||
</div>
|
||||
{!isCd ? (
|
||||
<div>
|
||||
<strong>Encode Input:</strong> {job.encode_input_path || '-'}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<strong>RAW vorhanden:</strong> <BoolState value={job.rawStatus?.exists} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Movie Datei vorhanden:</strong> <BoolState value={job.outputStatus?.exists} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Backup erfolgreich:</strong> <BoolState value={job?.backupSuccess} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Encode erfolgreich:</strong> <BoolState value={job?.encodeSuccess} />
|
||||
<strong>{isCd ? 'Audio-Dateien vorhanden:' : 'Movie Datei vorhanden:'}</strong> <BoolState value={job.outputStatus?.exists} />
|
||||
</div>
|
||||
{isCd ? (
|
||||
<div>
|
||||
<strong>Rip erfolgreich:</strong> <BoolState value={job?.ripSuccessful} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<strong>Backup erfolgreich:</strong> <BoolState value={job?.backupSuccess} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Encode erfolgreich:</strong> <BoolState value={job?.encodeSuccess} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="job-meta-col-span-2">
|
||||
<strong>Letzter Fehler:</strong> {job.error_message || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{hasConfiguredSelection || encodePlanUserPreset ? (
|
||||
{!isCd && (hasConfiguredSelection || encodePlanUserPreset) ? (
|
||||
<section className="job-meta-block job-meta-block-full">
|
||||
<h4>Hinterlegte Encode-Auswahl</h4>
|
||||
<div className="job-configured-selection-grid">
|
||||
@@ -501,7 +677,7 @@ export default function JobDetailDialog({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{executedHandBrakeCommand ? (
|
||||
{!isCd && executedHandBrakeCommand ? (
|
||||
<section className="job-meta-block job-meta-block-full">
|
||||
<h4>Ausgeführter Encode-Befehl</h4>
|
||||
<div className="handbrake-command-preview">
|
||||
@@ -511,7 +687,7 @@ export default function JobDetailDialog({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{(job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
|
||||
{!isCd && (job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
|
||||
<section className="job-meta-block job-meta-block-full">
|
||||
<h4>Skripte</h4>
|
||||
<div className="script-results-grid">
|
||||
@@ -522,14 +698,14 @@ export default function JobDetailDialog({
|
||||
) : null}
|
||||
|
||||
<div className="job-json-grid">
|
||||
<JsonView title="OMDb Info" value={job.omdbInfo} />
|
||||
<JsonView title="MakeMKV Info" value={job.makemkvInfo} />
|
||||
<JsonView title="Mediainfo Info" value={job.mediainfoInfo} />
|
||||
<JsonView title="Encode Plan" value={job.encodePlan} />
|
||||
<JsonView title="HandBrake Info" value={job.handbrakeInfo} />
|
||||
{!isCd ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
|
||||
<JsonView title={isCd ? 'cdparanoia Info' : 'MakeMKV Info'} value={job.makemkvInfo} />
|
||||
{!isCd ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
|
||||
<JsonView title={isCd ? 'Rip-Plan' : 'Encode Plan'} value={job.encodePlan} />
|
||||
{!isCd ? <JsonView title="HandBrake Info" value={job.handbrakeInfo} /> : null}
|
||||
</div>
|
||||
|
||||
{job.encodePlan ? (
|
||||
{!isCd && job.encodePlan ? (
|
||||
<>
|
||||
<h4>Mediainfo-Prüfung (Auswertung)</h4>
|
||||
<MediaInfoReviewPanel
|
||||
@@ -562,16 +738,18 @@ export default function JobDetailDialog({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
label="OMDb neu zuordnen"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
onClick={() => onAssignOmdb?.(job)}
|
||||
loading={omdbAssignBusy}
|
||||
disabled={running || typeof onAssignOmdb !== 'function'}
|
||||
/>
|
||||
{canResumeReady ? (
|
||||
{!isCd ? (
|
||||
<Button
|
||||
label="OMDb neu zuordnen"
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
onClick={() => onAssignOmdb?.(job)}
|
||||
loading={omdbAssignBusy}
|
||||
disabled={running || typeof onAssignOmdb !== 'function'}
|
||||
/>
|
||||
) : null}
|
||||
{!isCd && canResumeReady ? (
|
||||
<Button
|
||||
label="Im Dashboard öffnen"
|
||||
icon="pi pi-window-maximize"
|
||||
@@ -582,7 +760,7 @@ export default function JobDetailDialog({
|
||||
loading={actionBusy}
|
||||
/>
|
||||
) : null}
|
||||
{typeof onRestartEncode === 'function' ? (
|
||||
{!isCd && typeof onRestartEncode === 'function' ? (
|
||||
<Button
|
||||
label="Encode neu starten"
|
||||
icon="pi pi-play"
|
||||
@@ -593,7 +771,7 @@ export default function JobDetailDialog({
|
||||
disabled={!canRestartEncode}
|
||||
/>
|
||||
) : null}
|
||||
{typeof onRestartReview === 'function' ? (
|
||||
{!isCd && typeof onRestartReview === 'function' ? (
|
||||
<Button
|
||||
label="Review neu starten"
|
||||
icon="pi pi-refresh"
|
||||
@@ -605,15 +783,17 @@ export default function JobDetailDialog({
|
||||
disabled={!canRestartReview}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
label="RAW neu encodieren"
|
||||
icon="pi pi-cog"
|
||||
severity="info"
|
||||
size="small"
|
||||
onClick={() => onReencode?.(job)}
|
||||
loading={reencodeBusy}
|
||||
disabled={!canReencode || typeof onReencode !== 'function'}
|
||||
/>
|
||||
{!isCd ? (
|
||||
<Button
|
||||
label="RAW neu encodieren"
|
||||
icon="pi pi-cog"
|
||||
severity="info"
|
||||
size="small"
|
||||
onClick={() => onReencode?.(job)}
|
||||
loading={reencodeBusy}
|
||||
disabled={!canReencode || typeof onReencode !== 'function'}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
label="RAW löschen"
|
||||
icon="pi pi-trash"
|
||||
@@ -625,7 +805,7 @@ export default function JobDetailDialog({
|
||||
disabled={!job.rawStatus?.exists || typeof onDeleteFiles !== 'function'}
|
||||
/>
|
||||
<Button
|
||||
label="Movie löschen"
|
||||
label={isCd ? 'Audio löschen' : 'Movie löschen'}
|
||||
icon="pi pi-trash"
|
||||
severity="warning"
|
||||
outlined
|
||||
|
||||
@@ -243,8 +243,23 @@ function renderTemplate(template, values) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildOutputPathPreview(settings, metadata, fallbackJobId = null) {
|
||||
const movieDir = String(settings?.movie_dir || '').trim();
|
||||
function resolveProfiledSetting(settings, key, mediaProfile) {
|
||||
const profileKey = mediaProfile ? `${key}_${mediaProfile}` : null;
|
||||
if (profileKey && settings?.[profileKey] != null && settings[profileKey] !== '') {
|
||||
return settings[profileKey];
|
||||
}
|
||||
const fallbackProfiles = mediaProfile === 'bluray' ? ['dvd'] : ['bluray'];
|
||||
for (const fb of fallbackProfiles) {
|
||||
const fbKey = `${key}_${fb}`;
|
||||
if (settings?.[fbKey] != null && settings[fbKey] !== '') {
|
||||
return settings[fbKey];
|
||||
}
|
||||
}
|
||||
return settings?.[key] ?? null;
|
||||
}
|
||||
|
||||
function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId = null) {
|
||||
const movieDir = String(resolveProfiledSetting(settings, 'movie_dir', mediaProfile) || '').trim();
|
||||
if (!movieDir) {
|
||||
return null;
|
||||
}
|
||||
@@ -252,13 +267,26 @@ function buildOutputPathPreview(settings, metadata, fallbackJobId = null) {
|
||||
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
|
||||
const year = metadata?.year || new Date().getFullYear();
|
||||
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
|
||||
const fileTemplate = settings?.filename_template || '${title} (${year})';
|
||||
const folderTemplate = String(settings?.output_folder_template || '').trim() || fileTemplate;
|
||||
const folderName = sanitizeFileName(renderTemplate(folderTemplate, { title, year, imdbId }));
|
||||
const baseName = sanitizeFileName(renderTemplate(fileTemplate, { title, year, imdbId }));
|
||||
const ext = String(settings?.output_extension || 'mkv').trim() || 'mkv';
|
||||
const DEFAULT_TEMPLATE = '${title} (${year})/${title} (${year})';
|
||||
const rawTemplate = resolveProfiledSetting(settings, 'output_template', mediaProfile);
|
||||
const template = String(rawTemplate || DEFAULT_TEMPLATE).trim() || DEFAULT_TEMPLATE;
|
||||
const rendered = renderTemplate(template, { title, year, imdbId });
|
||||
const segments = rendered
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.split('/')
|
||||
.map((seg) => sanitizeFileName(seg))
|
||||
.filter(Boolean);
|
||||
const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled';
|
||||
const folderParts = segments.slice(0, -1);
|
||||
const rawExt = resolveProfiledSetting(settings, 'output_extension', mediaProfile);
|
||||
const ext = String(rawExt || 'mkv').trim() || 'mkv';
|
||||
const root = movieDir.replace(/\/+$/g, '');
|
||||
return `${root}/${folderName}/${baseName}.${ext}`;
|
||||
if (folderParts.length > 0) {
|
||||
return `${root}/${folderParts.join('/')}/${baseName}.${ext}`;
|
||||
}
|
||||
return `${root}/${baseName}.${ext}`;
|
||||
}
|
||||
|
||||
export default function PipelineStatusCard({
|
||||
@@ -515,8 +543,8 @@ export default function PipelineStatusCard({
|
||||
|
||||
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION';
|
||||
const commandOutputPath = useMemo(
|
||||
() => buildOutputPathPreview(settingsMap, selectedMetadata, retryJobId),
|
||||
[settingsMap, selectedMetadata, retryJobId]
|
||||
() => buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId),
|
||||
[settingsMap, jobMediaProfile, selectedMetadata, retryJobId]
|
||||
);
|
||||
const presetDisplayValue = useMemo(() => {
|
||||
const preset = String(mediaInfoReview?.selectors?.preset || '').trim();
|
||||
|
||||
Reference in New Issue
Block a user