UI/Features

This commit is contained in:
2026-03-13 11:07:34 +00:00
parent 7948dd298c
commit 5b41f728c5
28 changed files with 5690 additions and 936 deletions

View File

@@ -34,17 +34,33 @@ function App() {
if (message.type === 'PIPELINE_PROGRESS') {
const payload = message.payload;
const progressJobId = payload?.activeJobId;
const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object'
? payload.contextPatch
: null;
setPipeline((prev) => {
const next = { ...prev };
// Update per-job progress map so concurrent jobs don't overwrite each other.
if (progressJobId != null) {
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
const mergedJobContext = contextPatch
? {
...(previousJobProgress?.context && typeof previousJobProgress.context === 'object'
? previousJobProgress.context
: {}),
...contextPatch
}
: (previousJobProgress?.context && typeof previousJobProgress.context === 'object'
? previousJobProgress.context
: undefined);
next.jobProgress = {
...(prev?.jobProgress || {}),
[progressJobId]: {
...previousJobProgress,
state: payload.state,
progress: payload.progress,
eta: payload.eta,
statusText: payload.statusText
statusText: payload.statusText,
...(mergedJobContext !== undefined ? { context: mergedJobContext } : {})
}
};
}
@@ -54,6 +70,12 @@ function App() {
next.progress = payload.progress ?? prev?.progress;
next.eta = payload.eta ?? prev?.eta;
next.statusText = payload.statusText ?? prev?.statusText;
if (contextPatch) {
next.context = {
...(prev?.context && typeof prev.context === 'object' ? prev.context : {}),
...contextPatch
};
}
}
return next;
});

View File

@@ -116,6 +116,12 @@ export const api = {
forceRefresh: options.forceRefresh
});
},
getEffectivePaths(options = {}) {
return requestCachedGet('/settings/effective-paths', {
ttlMs: 30 * 1000,
forceRefresh: options.forceRefresh
});
},
getHandBrakePresets(options = {}) {
return requestCachedGet('/settings/handbrake-presets', {
ttlMs: 10 * 60 * 1000,
@@ -437,10 +443,17 @@ export const api = {
afterMutationInvalidate(['/history']);
return result;
},
async deleteJobEntry(jobId, target = 'none') {
getJobDeletePreview(jobId, options = {}) {
const includeRelated = options?.includeRelated !== false;
const query = new URLSearchParams();
query.set('includeRelated', includeRelated ? '1' : '0');
return request(`/history/${jobId}/delete-preview?${query.toString()}`);
},
async deleteJobEntry(jobId, target = 'none', options = {}) {
const includeRelated = Boolean(options?.includeRelated);
const result = await request(`/history/${jobId}/delete`, {
method: 'POST',
body: JSON.stringify({ target })
body: JSON.stringify({ target, includeRelated })
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;

File diff suppressed because it is too large Load Diff

View File

@@ -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>
);
}

View File

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

View File

@@ -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();

View File

@@ -220,7 +220,11 @@ function normalizeQueue(queue) {
const queuedJobs = Array.isArray(payload.queuedJobs) ? payload.queuedJobs : [];
return {
maxParallelJobs: Number(payload.maxParallelJobs || 1),
maxParallelCdEncodes: Number(payload.maxParallelCdEncodes || 2),
maxTotalEncodes: Number(payload.maxTotalEncodes || 3),
cdBypassesQueue: Boolean(payload.cdBypassesQueue),
runningCount: Number(payload.runningCount || runningJobs.length || 0),
runningCdCount: Number(payload.runningCdCount || 0),
runningJobs,
queuedJobs,
queuedCount: Number(payload.queuedCount || queuedJobs.length || 0),
@@ -348,12 +352,13 @@ function getAnalyzeContext(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
@@ -373,6 +378,25 @@ function resolveMediaType(job) {
return 'cd';
}
}
const statusCandidates = [
job?.status,
job?.last_state,
job?.makemkvInfo?.lastState
];
if (statusCandidates.some((value) => String(value || '').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';
}
@@ -425,6 +449,84 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
const analyzeContext = getAnalyzeContext(job);
const normalizePlanIdList = (values) => {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
continue;
}
const id = Math.trunc(parsed);
const key = String(id);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(id);
}
return output;
};
const buildNamedSelection = (ids, entries, fallbackLabel) => {
const source = Array.isArray(entries) ? entries : [];
const namesById = new Map(
source
.map((entry) => {
const id = Number(entry?.id ?? entry?.scriptId ?? entry?.chainId);
const normalized = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null;
const name = String(entry?.name || entry?.scriptName || entry?.chainName || '').trim();
if (!normalized) {
return null;
}
return [normalized, name || null];
})
.filter(Boolean)
);
return ids.map((id) => ({
id,
name: namesById.get(id) || `${fallbackLabel} #${id}`
}));
};
const planPreScriptIds = normalizePlanIdList([
...(Array.isArray(encodePlan?.preEncodeScriptIds) ? encodePlan.preEncodeScriptIds : []),
...(Array.isArray(encodePlan?.preEncodeScripts) ? encodePlan.preEncodeScripts.map((entry) => entry?.id ?? entry?.scriptId) : [])
]);
const planPostScriptIds = normalizePlanIdList([
...(Array.isArray(encodePlan?.postEncodeScriptIds) ? encodePlan.postEncodeScriptIds : []),
...(Array.isArray(encodePlan?.postEncodeScripts) ? encodePlan.postEncodeScripts.map((entry) => entry?.id ?? entry?.scriptId) : [])
]);
const planPreChainIds = normalizePlanIdList([
...(Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : []),
...(Array.isArray(encodePlan?.preEncodeChains) ? encodePlan.preEncodeChains.map((entry) => entry?.id ?? entry?.chainId) : [])
]);
const planPostChainIds = normalizePlanIdList([
...(Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : []),
...(Array.isArray(encodePlan?.postEncodeChains) ? encodePlan.postEncodeChains.map((entry) => entry?.id ?? entry?.chainId) : [])
]);
const cdRipConfig = encodePlan && typeof encodePlan === 'object'
? {
format: String(encodePlan?.format || '').trim().toLowerCase() || null,
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
? encodePlan.formatOptions
: {},
selectedTracks: Array.isArray(encodePlan?.selectedTracks)
? encodePlan.selectedTracks
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value))
: [],
preEncodeScriptIds: planPreScriptIds,
postEncodeScriptIds: planPostScriptIds,
preEncodeChainIds: planPreChainIds,
postEncodeChainIds: planPostChainIds,
preEncodeScripts: buildNamedSelection(planPreScriptIds, encodePlan?.preEncodeScripts, 'Skript'),
postEncodeScripts: buildNamedSelection(planPostScriptIds, encodePlan?.postEncodeScripts, 'Skript'),
preEncodeChains: buildNamedSelection(planPreChainIds, encodePlan?.preEncodeChains, 'Kette'),
postEncodeChains: buildNamedSelection(planPostChainIds, encodePlan?.postEncodeChains, 'Kette'),
outputTemplate: String(encodePlan?.outputTemplate || '').trim() || null
}
: null;
const cdTracks = Array.isArray(makemkvInfo?.tracks)
? makemkvInfo.tracks
.map((track) => {
@@ -443,6 +545,23 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const cdSelectedMeta = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {};
const fallbackCdArtist = cdTracks
.map((track) => String(track?.artist || '').trim())
.find(Boolean) || null;
const resolvedCdMbId = String(
cdSelectedMeta?.mbId
|| cdSelectedMeta?.musicBrainzId
|| cdSelectedMeta?.musicbrainzId
|| cdSelectedMeta?.mbid
|| ''
).trim() || null;
const resolvedCdCoverUrl = String(
cdSelectedMeta?.coverUrl
|| cdSelectedMeta?.poster
|| cdSelectedMeta?.posterUrl
|| job?.poster_url
|| ''
).trim() || null;
const cdparanoiaCmd = String(makemkvInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia';
const devicePath = String(job?.disc_device || '').trim() || null;
const firstConfiguredTrack = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0
@@ -458,12 +577,12 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
const selectedMetadata = {
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
artist: cdSelectedMeta?.artist || null,
artist: cdSelectedMeta?.artist || fallbackCdArtist || null,
year: cdSelectedMeta?.year ?? job?.year ?? null,
mbId: cdSelectedMeta?.mbId || null,
coverUrl: cdSelectedMeta?.coverUrl || null,
mbId: resolvedCdMbId,
coverUrl: resolvedCdCoverUrl,
imdbId: job?.imdb_id || null,
poster: job?.poster_url || cdSelectedMeta?.coverUrl || null
poster: job?.poster_url || resolvedCdCoverUrl || null
};
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
@@ -508,11 +627,14 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const computedContext = {
jobId,
rawPath: job?.raw_path || null,
outputPath: job?.output_path || null,
detectedTitle: job?.detected_title || null,
mediaProfile: resolveMediaType(job),
lastState,
devicePath,
cdparanoiaCmd,
cdparanoiaCommandPreview,
cdRipConfig,
tracks: cdTracks,
inputPath,
hasEncodableTitle,
@@ -543,6 +665,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
...computedContext,
...existingContext,
rawPath: existingContext.rawPath || computedContext.rawPath,
outputPath: existingContext.outputPath || computedContext.outputPath,
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
? existingContext.tracks
: computedContext.tracks,
@@ -559,6 +682,20 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const liveJobProgress = currentPipeline?.jobProgress && jobId
? (currentPipeline.jobProgress[jobId] || null)
: null;
const liveContext = liveJobProgress?.context && typeof liveJobProgress.context === 'object'
? liveJobProgress.context
: null;
const mergedContext = liveContext
? {
...computedContext,
...liveContext,
tracks: (Array.isArray(liveContext.tracks) && liveContext.tracks.length > 0)
? liveContext.tracks
: computedContext.tracks,
selectedMetadata: liveContext.selectedMetadata || computedContext.selectedMetadata,
cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig
}
: computedContext;
return {
state: liveJobProgress?.state || jobStatus,
@@ -566,7 +703,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
progress: liveJobProgress != null ? Number(liveJobProgress.progress ?? 0) : 0,
eta: liveJobProgress?.eta || null,
statusText: liveJobProgress?.statusText || job?.error_message || null,
context: computedContext
context: mergedContext
};
}
@@ -1184,12 +1321,13 @@ export default function DashboardPage({
try {
const response = await api.retryJob(jobId);
const result = getQueueActionResult(response);
const retryJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
await loadDashboardJobs();
if (result.queued) {
showQueuedToast(toastRef, 'Retry', result);
} else {
setExpandedJobId(normalizedJobId);
setExpandedJobId(retryJobId);
}
} catch (error) {
showError(error);
@@ -1216,12 +1354,13 @@ export default function DashboardPage({
try {
const response = await api.restartEncodeWithLastSettings(jobId);
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
await loadDashboardJobs();
if (result.queued) {
showQueuedToast(toastRef, 'Encode-Neustart', result);
} else {
setExpandedJobId(normalizedJobId);
setExpandedJobId(replacementJobId);
}
} catch (error) {
showError(error);
@@ -1238,10 +1377,12 @@ export default function DashboardPage({
setJobBusy(normalizedJobId, true);
try {
await api.restartReviewFromRaw(normalizedJobId);
const response = await api.restartReviewFromRaw(normalizedJobId);
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
await loadDashboardJobs();
setExpandedJobId(normalizedJobId);
setExpandedJobId(replacementJobId);
} catch (error) {
showError(error);
} finally {
@@ -1426,15 +1567,28 @@ export default function DashboardPage({
if (!jobId) {
return;
}
setJobBusy(jobId, true);
const normalizedJobId = normalizeJobId(jobId);
if (normalizedJobId) {
setJobBusy(normalizedJobId, true);
}
try {
await api.startCdRip(jobId, ripConfig);
const response = await api.startCdRip(jobId, ripConfig);
const result = getQueueActionResult(response);
if (result.queued) {
showQueuedToast(toastRef, 'Audio CD', result);
}
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
await loadDashboardJobs();
if (replacementJobId) {
setExpandedJobId(replacementJobId);
}
} catch (error) {
showError(error);
} finally {
setJobBusy(jobId, false);
if (normalizedJobId) {
setJobBusy(normalizedJobId, false);
}
}
};
@@ -1772,10 +1926,14 @@ export default function DashboardPage({
)}
</Card>
<Card title="Job Queue" subTitle="Starts werden nach Parallel-Limit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
<Card title="Job Queue" subTitle="Starts werden nach Typ- und Gesamtlimit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
<div className="pipeline-queue-meta">
<Tag value={`Parallel: ${queueState?.maxParallelJobs || 1}`} severity="info" />
<Tag value={`Laufend: ${queueState?.runningCount || 0}`} severity={queueRunningJobs.length > 0 ? 'warning' : 'success'} />
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
<Tag value={`CD max.: ${queueState?.maxParallelCdEncodes || 2}`} severity="info" />
<Tag value={`Gesamt max.: ${queueState?.maxTotalEncodes || 3}`} severity="info" />
{queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio CDs überspringen die Film-Queue-Reihenfolge" />}
<Tag value={`Film laufend: ${queueState?.runningCount || 0}`} severity={(queueState?.runningCount || 0) > 0 ? 'warning' : 'success'} />
<Tag value={`CD laufend: ${queueState?.runningCdCount || 0}`} severity={(queueState?.runningCdCount || 0) > 0 ? 'warning' : 'success'} />
<Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} />
</div>
@@ -2110,6 +2268,15 @@ export default function DashboardPage({
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
const mediaIndicator = mediaIndicatorMeta(job);
const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
const pipelineStatusText = String(pipelineForJob?.statusText || '').trim().toUpperCase();
const isCdJob = jobState.startsWith('CD_')
|| pipelineStage.startsWith('CD_')
|| mediaProfile === 'cd'
|| mediaIndicator.mediaType === 'cd'
|| pipelineStatusText.includes('CD_');
const rawProgress = Number(pipelineForJob?.progress ?? 0);
const clampedProgress = Number.isFinite(rawProgress)
? Math.max(0, Math.min(100, rawProgress))
@@ -2151,30 +2318,22 @@ export default function DashboardPage({
/>
</div>
{(() => {
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
const isCdJob = jobState.startsWith('CD_');
if (isCdJob) {
return (
<>
{jobState === 'CD_METADATA_SELECTION' ? (
<Button
label="CD-Metadaten auswählen"
icon="pi pi-list"
onClick={() => {
{isCdJob ? (
<CdRipConfigPanel
pipeline={pipelineForJob}
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
onCancel={() => handleCancel(jobId, jobState)}
onRetry={() => handleRetry(jobId)}
onOpenMetadata={() => {
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
? pipelineForJob.context
: pipeline?.context || {};
setCdMetadataDialogContext({ ...ctx, jobId });
setCdMetadataDialogVisible(true);
}}
disabled={busyJobIds.has(jobId)}
/>
) : null}
{(jobState === 'CD_READY_TO_RIP' || jobState === 'CD_RIPPING' || jobState === 'CD_ENCODING') ? (
<CdRipConfigPanel
pipeline={pipelineForJob}
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
onCancel={() => handleCancel(jobId, jobState)}
busy={busyJobIds.has(jobId)}
/>
) : null}
@@ -2183,7 +2342,7 @@ export default function DashboardPage({
}
return null;
})()}
{!String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase().startsWith('CD_') ? (
{!isCdJob ? (
<PipelineStatusCard
pipeline={pipelineForJob}
onAnalyze={handleAnalyze}

View File

@@ -6,6 +6,7 @@ import { Dropdown } from 'primereact/dropdown';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
import { Dialog } from 'primereact/dialog';
import { api } from '../api/client';
import JobDetailDialog from '../components/JobDetailDialog';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
@@ -22,6 +23,7 @@ const MEDIA_FILTER_OPTIONS = [
{ label: 'Alle Medien', value: '' },
{ label: 'Blu-ray', value: 'bluray' },
{ label: 'DVD', value: 'dvd' },
{ label: 'Audio CD', value: 'cd' },
{ label: 'Sonstiges', value: 'other' }
];
@@ -36,13 +38,30 @@ const SORT_OPTIONS = [
{ label: 'Medium: Z -> A', value: '!sortMediaType' }
];
const CD_FORMAT_LABELS = {
flac: 'FLAC',
wav: 'WAV',
mp3: 'MP3',
opus: 'Opus',
ogg: 'Ogg Vorbis'
};
function normalizePositiveInteger(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function resolveMediaType(row) {
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null;
const candidates = [
row?.mediaType,
row?.media_type,
row?.mediaProfile,
row?.media_profile,
row?.encodePlan?.mediaProfile,
encodePlan?.mediaProfile,
row?.makemkvInfo?.analyzeContext?.mediaProfile,
row?.makemkvInfo?.mediaProfile,
row?.mediainfoInfo?.mediaProfile
@@ -58,6 +77,28 @@ function resolveMediaType(row) {
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 = [
row?.status,
row?.last_state,
row?.makemkvInfo?.lastState
];
if (statusCandidates.some((value) => String(value || '').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(row?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
return 'cd';
}
if (Array.isArray(row?.makemkvInfo?.tracks) && row.makemkvInfo.tracks.length > 0) {
return 'cd';
}
return 'other';
}
@@ -80,6 +121,14 @@ function resolveMediaTypeMeta(row) {
alt: 'DVD'
};
}
if (mediaType === 'cd') {
return {
mediaType,
icon: otherIndicatorIcon,
label: 'Audio CD',
alt: 'Audio CD'
};
}
return {
mediaType,
icon: otherIndicatorIcon,
@@ -88,6 +137,93 @@ function resolveMediaTypeMeta(row) {
};
}
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 resolveCdDetails(row) {
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.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((value) => normalizePositiveInteger(value))
.filter(Boolean)
: [];
const selectedTrackPositions = selectedTracksFromPlan.length > 0
? selectedTracksFromPlan
: tracks.filter((track) => track.selected !== false).map((track) => track.position);
const fallbackArtist = tracks
.map((track) => String(track?.artist || '').trim())
.find(Boolean) || null;
const totalDurationSec = tracks.reduce((sum, track) => {
const durationMs = Number(track?.durationMs);
const durationSec = Number(track?.durationSec);
if (Number.isFinite(durationMs) && durationMs > 0) {
return sum + (durationMs / 1000);
}
if (Number.isFinite(durationSec) && durationSec > 0) {
return sum + durationSec;
}
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,
trackCount: tracks.length,
selectedTrackCount: selectedTrackPositions.length,
format,
formatLabel: format ? (CD_FORMAT_LABELS[format] || format.toUpperCase()) : null,
totalDurationLabel: formatDurationSeconds(totalDurationSec),
mbId
};
}
function getOutputLabelForRow(row) {
return resolveMediaType(row) === 'cd' ? 'Audio-Dateien' : 'Movie-Datei(en)';
}
function getOutputShortLabelForRow(row) {
return resolveMediaType(row) === 'cd' ? 'Audio' : 'Movie';
}
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -173,6 +309,11 @@ export default function HistoryPage() {
const [actionBusy, setActionBusy] = useState(false);
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
const [deleteEntryBusy, setDeleteEntryBusy] = useState(false);
const [deleteEntryDialogVisible, setDeleteEntryDialogVisible] = useState(false);
const [deleteEntryDialogRow, setDeleteEntryDialogRow] = useState(null);
const [deleteEntryPreview, setDeleteEntryPreview] = useState(null);
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
const [loading, setLoading] = useState(false);
const [queuedJobIds, setQueuedJobIds] = useState([]);
const toastRef = useRef(null);
@@ -321,7 +462,9 @@ export default function HistoryPage() {
};
const handleDeleteFiles = async (row, target) => {
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? 'Movie-Datei(en)' : 'RAW + Movie';
const outputLabel = getOutputLabelForRow(row);
const outputShortLabel = getOutputShortLabelForRow(row);
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
const title = row.title || row.detected_title || `Job #${row.id}`;
const confirmed = window.confirm(`${label} für "${title}" wirklich löschen?`);
if (!confirmed) {
@@ -335,7 +478,7 @@ export default function HistoryPage() {
toastRef.current?.show({
severity: 'success',
summary: 'Dateien gelöscht',
detail: `RAW: ${summary.raw?.filesDeleted ?? 0}, MOVIE: ${summary.movie?.filesDeleted ?? 0}`,
detail: `RAW: ${summary.raw?.filesDeleted ?? 0}, ${outputShortLabel}: ${summary.movie?.filesDeleted ?? 0}`,
life: 3500
});
await load();
@@ -440,28 +583,129 @@ export default function HistoryPage() {
}
};
const handleDeleteEntry = async (row) => {
const handleRetry = async (row) => {
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
const confirmed = window.confirm(`Historieneintrag für "${title}" wirklich löschen?\nDateien werden NICHT gelöscht.`);
const mediaType = resolveMediaType(row);
const actionLabel = mediaType === 'cd' ? 'CD-Rip' : 'Retry';
const confirmed = window.confirm(`${actionLabel} für "${title}" neu starten?`);
if (!confirmed) {
return;
}
setActionBusy(true);
try {
const response = await api.retryJob(row.id);
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId);
toastRef.current?.show({
severity: result.queued ? 'info' : 'success',
summary: mediaType === 'cd' ? 'CD-Rip neu gestartet' : 'Retry gestartet',
detail: result.queued
? 'Job wurde in die Warteschlange eingeplant.'
: (replacementJobId ? `Neuer Job #${replacementJobId} wurde erstellt.` : 'Job wurde neu gestartet.'),
life: 4000
});
await load();
if (replacementJobId) {
const detailResponse = await api.getJob(replacementJobId, { includeLogs: false });
setSelectedJob(detailResponse.job);
setDetailVisible(true);
} else {
await refreshDetailIfOpen(row.id);
}
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: mediaType === 'cd' ? 'CD-Rip Neustart fehlgeschlagen' : 'Retry fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setActionBusy(false);
}
};
const closeDeleteEntryDialog = () => {
if (deleteEntryTargetBusy) {
return;
}
setDeleteEntryDialogVisible(false);
setDeleteEntryDialogRow(null);
setDeleteEntryPreview(null);
setDeleteEntryPreviewLoading(false);
setDeleteEntryTargetBusy(null);
};
const handleDeleteEntry = async (row) => {
const jobId = Number(row?.id || 0);
if (!jobId) {
return;
}
setDeleteEntryDialogRow(row);
setDeleteEntryPreview(null);
setDeleteEntryDialogVisible(true);
setDeleteEntryPreviewLoading(true);
setDeleteEntryBusy(true);
try {
await api.deleteJobEntry(row.id, 'none');
const response = await api.getJobDeletePreview(jobId, { includeRelated: true });
setDeleteEntryPreview(response?.preview || null);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Löschvorschau fehlgeschlagen',
detail: error.message,
life: 4500
});
setDeleteEntryDialogVisible(false);
setDeleteEntryDialogRow(null);
setDeleteEntryPreview(null);
} finally {
setDeleteEntryPreviewLoading(false);
setDeleteEntryBusy(false);
}
};
const confirmDeleteEntry = async (target) => {
const normalizedTarget = String(target || '').trim().toLowerCase();
if (!['raw', 'movie', 'both'].includes(normalizedTarget)) {
return;
}
const jobId = Number(deleteEntryDialogRow?.id || 0);
if (!jobId) {
return;
}
setDeleteEntryBusy(true);
setDeleteEntryTargetBusy(normalizedTarget);
try {
const response = await api.deleteJobEntry(jobId, normalizedTarget, { includeRelated: true });
const deletedJobIds = Array.isArray(response?.deletedJobIds) ? response.deletedJobIds : [];
const fileSummary = response?.fileSummary || {};
const rawFiles = Number(fileSummary?.raw?.filesDeleted || 0);
const movieFiles = Number(fileSummary?.movie?.filesDeleted || 0);
const rawDirs = Number(fileSummary?.raw?.dirsRemoved || 0);
const movieDirs = Number(fileSummary?.movie?.dirsRemoved || 0);
toastRef.current?.show({
severity: 'success',
summary: 'Eintrag gelöscht',
detail: `"${title}" wurde aus der Historie entfernt.`,
life: 3500
summary: 'Historie gelöscht',
detail: `${deletedJobIds.length || 1} Eintrag/Einträge entfernt | RAW: ${rawFiles} Dateien, ${rawDirs} Ordner | ${deleteEntryOutputShortLabel}: ${movieFiles} Dateien, ${movieDirs} Ordner`,
life: 5000
});
closeDeleteEntryDialog();
setDetailVisible(false);
setSelectedJob(null);
await load();
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Löschen fehlgeschlagen', detail: error.message, life: 4500 });
toastRef.current?.show({
severity: 'error',
summary: 'Löschen fehlgeschlagen',
detail: error.message,
life: 5000
});
} finally {
setDeleteEntryTargetBusy(null);
setDeleteEntryBusy(false);
}
};
@@ -508,11 +752,12 @@ export default function HistoryPage() {
};
const renderPoster = (row, className = 'history-dv-poster') => {
const mediaMeta = resolveMediaTypeMeta(row);
const title = row?.title || row?.detected_title || 'Poster';
if (row?.poster_url && row.poster_url !== 'N/A') {
return <img src={row.poster_url} alt={title} className={className} loading="lazy" />;
}
return <div className="history-dv-poster-fallback">Kein Poster</div>;
return <div className="history-dv-poster-fallback">{mediaMeta.mediaType === 'cd' ? 'Kein Cover' : 'Kein Poster'}</div>;
};
const renderPresenceChip = (label, available) => (
@@ -522,7 +767,39 @@ export default function HistoryPage() {
</span>
);
const renderRatings = (row) => {
const renderSupplementalInfo = (row) => {
if (resolveMediaType(row) === 'cd') {
const cdDetails = resolveCdDetails(row);
const infoItems = [];
if (cdDetails.trackCount > 0) {
infoItems.push({
key: 'tracks',
label: 'Tracks',
value: cdDetails.selectedTrackCount > 0 && cdDetails.selectedTrackCount !== cdDetails.trackCount
? `${cdDetails.selectedTrackCount}/${cdDetails.trackCount}`
: String(cdDetails.trackCount)
});
}
if (cdDetails.formatLabel) {
infoItems.push({ key: 'format', label: 'Format', value: cdDetails.formatLabel });
}
if (cdDetails.totalDurationLabel) {
infoItems.push({ key: 'duration', label: 'Dauer', value: cdDetails.totalDurationLabel });
}
if (cdDetails.mbId) {
infoItems.push({ key: 'mb', label: 'MusicBrainz', value: 'gesetzt' });
}
if (infoItems.length === 0) {
return <span className="history-dv-subtle">Keine CD-Details</span>;
}
return infoItems.map((item) => (
<span key={`${row?.id}-${item.key}`} className="history-dv-rating-chip">
<strong>{item.label}</strong>
<span>{item.value}</span>
</span>
));
}
const ratings = resolveRatings(row);
if (ratings.length === 0) {
return <span className="history-dv-subtle">Keine Ratings</span>;
@@ -545,6 +822,16 @@ export default function HistoryPage() {
const listItem = (row) => {
const mediaMeta = resolveMediaTypeMeta(row);
const isCdJob = mediaMeta.mediaType === 'cd';
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
const subtitle = isCdJob
? [
`#${row?.id || '-'}`,
cdDetails?.artist || '-',
row?.year || null,
cdDetails?.mbId ? 'MusicBrainz' : null
].filter(Boolean).join(' | ')
: `#${row?.id || '-'} | ${row?.year || '-'} | ${row?.imdb_id || '-'}`;
return (
<div className="col-12" key={row.id}>
@@ -565,9 +852,7 @@ export default function HistoryPage() {
<div className="history-dv-head">
<div className="history-dv-title-block">
<strong className="history-dv-title">{row?.title || row?.detected_title || '-'}</strong>
<small className="history-dv-subtle">
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
</small>
<small className="history-dv-subtle">{subtitle}</small>
</div>
{renderStatusTag(row)}
</div>
@@ -582,12 +867,22 @@ export default function HistoryPage() {
</div>
<div className="history-dv-flags-row">
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
{isCdJob ? (
<>
{renderPresenceChip('Audio', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Rip', Boolean(row?.ripSuccessful))}
{renderPresenceChip('Metadaten', Boolean(cdDetails?.artist || cdDetails?.mbId))}
</>
) : (
<>
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
</>
)}
</div>
<div className="history-dv-ratings-row">{renderRatings(row)}</div>
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
</div>
<div className="history-dv-actions">
@@ -608,6 +903,16 @@ export default function HistoryPage() {
const gridItem = (row) => {
const mediaMeta = resolveMediaTypeMeta(row);
const isCdJob = mediaMeta.mediaType === 'cd';
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
const subtitle = isCdJob
? [
`#${row?.id || '-'}`,
cdDetails?.artist || '-',
row?.year || null,
cdDetails?.mbId ? 'MusicBrainz' : null
].filter(Boolean).join(' | ')
: `#${row?.id || '-'} | ${row?.year || '-'} | ${row?.imdb_id || '-'}`;
return (
<div className="col-12 md-col-6 xl-col-4" key={row.id}>
@@ -630,9 +935,7 @@ export default function HistoryPage() {
{renderStatusTag(row)}
</div>
<small className="history-dv-subtle">
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
</small>
<small className="history-dv-subtle">{subtitle}</small>
<div className="history-dv-meta-row">
<span className="job-step-cell">
@@ -644,12 +947,22 @@ export default function HistoryPage() {
</div>
<div className="history-dv-flags-row">
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
{isCdJob ? (
<>
{renderPresenceChip('Audio', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Rip', Boolean(row?.ripSuccessful))}
{renderPresenceChip('Metadaten', Boolean(cdDetails?.artist || cdDetails?.mbId))}
</>
) : (
<>
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
</>
)}
</div>
<div className="history-dv-ratings-row">{renderRatings(row)}</div>
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
</div>
<div className="history-dv-actions history-dv-actions-grid">
@@ -675,12 +988,21 @@ export default function HistoryPage() {
return currentLayout === 'list' ? listItem(row) : gridItem(row);
};
const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
const previewMovieExisting = previewMoviePaths.filter((item) => Boolean(item?.exists));
const deleteTargetActionsDisabled = deleteEntryPreviewLoading || Boolean(deleteEntryTargetBusy) || !deleteEntryPreview;
const deleteEntryOutputLabel = getOutputLabelForRow(deleteEntryDialogRow);
const deleteEntryOutputShortLabel = getOutputShortLabelForRow(deleteEntryDialogRow);
const header = (
<div className="history-dv-toolbar">
<InputText
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Suche nach Titel oder IMDb"
placeholder="Suche nach Titel, Interpret oder IMDb"
/>
<Dropdown
@@ -748,6 +1070,7 @@ export default function HistoryPage() {
onRestartEncode={handleRestartEncode}
onRestartReview={handleRestartReview}
onReencode={handleReencode}
onRetry={handleRetry}
onDeleteFiles={handleDeleteFiles}
onDeleteEntry={handleDeleteEntry}
onRemoveFromQueue={handleRemoveFromQueue}
@@ -761,6 +1084,118 @@ export default function HistoryPage() {
setLogLoadingMode(null);
}}
/>
<Dialog
header="Historien-Eintrag löschen"
visible={deleteEntryDialogVisible}
onHide={closeDeleteEntryDialog}
style={{ width: '56rem', maxWidth: '96vw' }}
className="history-delete-dialog"
modal
>
<p>
{`Es werden ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge entfernt.`}
</p>
{deleteEntryDialogRow ? (
<small className="muted-inline">
Job: {deleteEntryDialogRow?.title || deleteEntryDialogRow?.detected_title || `Job #${deleteEntryDialogRow?.id || '-'}`}
</small>
) : null}
{deleteEntryPreviewLoading ? (
<p>Löschvorschau wird geladen ...</p>
) : (
<div className="history-delete-preview-grid">
<div>
<h4>Rip/Encode Historie</h4>
{previewRelatedJobs.length > 0 ? (
<ul className="history-delete-preview-list">
{previewRelatedJobs.map((item) => (
<li key={`delete-related-${item.id}`}>
<strong>#{item.id}</strong> | {item.title || '-'} | {item.status || '-'} {item.isPrimary ? '(aktuell)' : '(Alt-Eintrag)'}
</li>
))}
</ul>
) : (
<small className="history-dv-subtle">Keine verknüpften Alt-Einträge erkannt.</small>
)}
</div>
<div>
<h4>{`RAW (${previewRawExisting.length}/${previewRawPaths.length})`}</h4>
{previewRawPaths.length > 0 ? (
<ul className="history-delete-preview-list">
{previewRawPaths.map((item) => (
<li key={`delete-raw-${item.path}`}>
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
{item.exists ? 'vorhanden' : 'nicht gefunden'}
</span>
{' '}| {item.path}
</li>
))}
</ul>
) : (
<small className="history-dv-subtle">Keine RAW-Pfade.</small>
)}
</div>
<div>
<h4>{`${deleteEntryOutputShortLabel} (${previewMovieExisting.length}/${previewMoviePaths.length})`}</h4>
{previewMoviePaths.length > 0 ? (
<ul className="history-delete-preview-list">
{previewMoviePaths.map((item) => (
<li key={`delete-movie-${item.path}`}>
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
{item.exists ? 'vorhanden' : 'nicht gefunden'}
</span>
{' '}| {item.path}
</li>
))}
</ul>
) : (
<small className="history-dv-subtle">Keine Movie-Pfade.</small>
)}
</div>
</div>
)}
<div className="dialog-actions">
<Button
label="Nur RAW löschen"
icon="pi pi-trash"
severity="warning"
outlined
onClick={() => confirmDeleteEntry('raw')}
loading={deleteEntryTargetBusy === 'raw'}
disabled={deleteTargetActionsDisabled}
/>
<Button
label={`Nur ${deleteEntryOutputShortLabel} löschen`}
icon="pi pi-trash"
severity="warning"
outlined
onClick={() => confirmDeleteEntry('movie')}
loading={deleteEntryTargetBusy === 'movie'}
disabled={deleteTargetActionsDisabled}
/>
<Button
label="Beides löschen"
icon="pi pi-times"
severity="danger"
onClick={() => confirmDeleteEntry('both')}
loading={deleteEntryTargetBusy === 'both'}
disabled={deleteTargetActionsDisabled}
/>
<Button
label="Abbrechen"
severity="secondary"
outlined
onClick={closeDeleteEntryDialog}
disabled={Boolean(deleteEntryTargetBusy)}
/>
</div>
</Dialog>
</div>
);
}

View File

@@ -7,10 +7,13 @@ 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';
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
function buildValuesMap(categories) {
const next = {};
for (const category of categories || []) {
@@ -28,6 +31,17 @@ function isSameValue(a, 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);
@@ -138,6 +152,7 @@ export default function SettingsPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testingPushover, setTestingPushover] = useState(false);
const [updatingExpertMode, setUpdatingExpertMode] = useState(false);
const [activeTabIndex, setActiveTabIndex] = useState(0);
const [initialValues, setInitialValues] = useState({});
const [draftValues, setDraftValues] = useState({});
@@ -184,6 +199,7 @@ export default function SettingsPage() {
});
const [userPresetErrors, setUserPresetErrors] = useState({});
const [handBrakePresetSourceOptions, setHandBrakePresetSourceOptions] = useState([]);
const [effectivePaths, setEffectivePaths] = useState(null);
const toastRef = useRef(null);
@@ -317,6 +333,17 @@ export default function SettingsPage() {
}
};
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 {
@@ -327,6 +354,7 @@ export default function SettingsPage() {
setInitialValues(values);
setDraftValues(values);
setErrors({});
loadEffectivePaths({ silent: true });
const presetsPromise = api.getHandBrakePresets();
const scriptsPromise = api.getScripts();
@@ -389,12 +417,41 @@ export default function SettingsPage() {
}, [initialValues, draftValues]);
const hasUnsavedChanges = dirtyKeys.size > 0;
const expertModeEnabled = toBoolean(draftValues?.[EXPERT_MODE_SETTING_KEY]);
const handleFieldChange = (key, value) => {
setDraftValues((prev) => ({ ...prev, [key]: value }));
setErrors((prev) => ({ ...prev, [key]: 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({
@@ -415,6 +472,7 @@ export default function SettingsPage() {
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.'
@@ -946,7 +1004,7 @@ export default function SettingsPage() {
icon="pi pi-save"
onClick={handleSave}
loading={saving}
disabled={!hasUnsavedChanges}
disabled={!hasUnsavedChanges || updatingExpertMode}
/>
<Button
label="Änderungen verwerfen"
@@ -954,7 +1012,7 @@ export default function SettingsPage() {
severity="secondary"
outlined
onClick={handleDiscard}
disabled={!hasUnsavedChanges || saving}
disabled={!hasUnsavedChanges || saving || updatingExpertMode}
/>
<Button
label="Neu laden"
@@ -962,7 +1020,7 @@ export default function SettingsPage() {
severity="secondary"
onClick={load}
loading={loading}
disabled={saving}
disabled={saving || updatingExpertMode}
/>
<Button
label="PushOver Test"
@@ -970,8 +1028,16 @@ export default function SettingsPage() {
severity="info"
onClick={handlePushoverTest}
loading={testingPushover}
disabled={saving}
disabled={saving || updatingExpertMode}
/>
<div className="settings-expert-toggle">
<span>Expertenmodus</span>
<InputSwitch
checked={expertModeEnabled}
onChange={(event) => handleExpertModeToggle(event.value)}
disabled={loading || saving || updatingExpertMode}
/>
</div>
</div>
{loading ? (
@@ -983,6 +1049,7 @@ export default function SettingsPage() {
errors={errors}
dirtyKeys={dirtyKeys}
onChange={handleFieldChange}
effectivePaths={effectivePaths}
/>
)}
</TabPanel>
@@ -1526,7 +1593,7 @@ export default function SettingsPage() {
<small>
Encode-Presets fassen ein HandBrake-Preset und zusätzliche CLI-Argumente zusammen.
Sie sind medienbezogen (Blu-ray, DVD, Sonstiges oder Universell) und können vor dem Encode
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>
@@ -1544,7 +1611,6 @@ export default function SettingsPage() {
<span className="preset-media-type-tag">
{preset.mediaType === 'bluray' ? 'Blu-ray'
: preset.mediaType === 'dvd' ? 'DVD'
: preset.mediaType === 'other' ? 'Sonstiges'
: 'Universell'}
</span>
</div>
@@ -1604,7 +1670,6 @@ export default function SettingsPage() {
<option value="all">Universell (alle Medien)</option>
<option value="bluray">Blu-ray</option>
<option value="dvd">DVD</option>
<option value="other">Sonstiges</option>
</select>
</div>

View File

@@ -217,6 +217,22 @@ body {
flex-wrap: wrap;
}
.settings-expert-toggle {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.7rem;
border: 1px solid var(--rip-border);
border-radius: 0.5rem;
background: var(--rip-panel-soft);
}
.settings-expert-toggle > span {
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
.hardware-monitor-head {
display: flex;
flex-wrap: wrap;
@@ -389,17 +405,19 @@ body {
}
.hardware-storage-head {
display: flex;
align-items: center;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.5rem;
}
.hardware-storage-head strong {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.hardware-storage-percent {
@@ -409,6 +427,8 @@ body {
padding: 0.1rem 0.45rem;
border: 1px solid transparent;
white-space: nowrap;
justify-self: end;
align-self: start;
}
.hardware-storage-percent.tone-ok {
@@ -1100,22 +1120,39 @@ body {
gap: 0.85rem;
}
.cd-rip-status {
display: grid;
gap: 0.55rem;
}
.cd-meta-summary {
border: 1px solid var(--rip-border);
border-radius: 0.45rem;
background: var(--rip-panel-soft);
padding: 0.55rem 0.65rem;
display: flex;
flex-wrap: wrap;
align-items: baseline;
display: grid;
gap: 0.35rem;
}
.cd-media-meta-layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.75rem;
align-items: start;
}
.cd-cover-wrap {
width: 7rem;
min-width: 7rem;
border: 1px solid var(--rip-border);
border-radius: 0.45rem;
overflow: hidden;
background: #fff;
}
.cd-cover-image {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1/1;
object-fit: cover;
}
.cd-format-field {
display: grid;
gap: 0.35rem;
@@ -1185,6 +1222,13 @@ body {
white-space: nowrap;
}
.cd-track-table td.status,
.cd-track-table th.status {
width: 6.5rem;
white-space: nowrap;
text-align: center;
}
.cd-track-table td.artist .p-inputtext,
.cd-track-table td.title .p-inputtext {
width: 100%;
@@ -1258,6 +1302,55 @@ body {
width: fit-content;
}
.setting-description {
white-space: pre-wrap;
}
.notification-toggle-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
align-items: stretch;
}
.settings-grid + .notification-toggle-grid {
margin-top: 1rem;
}
.notification-toggle-box {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: 100%;
}
.notification-toggle-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.65rem;
}
.notification-toggle-head > label {
margin: 0;
line-height: 1.25;
}
.notification-toggle-head .p-inputswitch {
flex-shrink: 0;
}
.notification-toggle-box .setting-description {
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.25;
}
.notification-toggle-box .saved-tag {
margin-top: auto;
}
.setting-owner-row {
display: grid;
gap: 0.25rem;
@@ -1281,6 +1374,109 @@ body {
width: fit-content;
}
/* ── Path Category Tab ─────────────────────────────────────────────────────── */
.path-category-tab {
display: grid;
gap: 1.25rem;
}
.path-overview-card {
border: 1px solid var(--rip-border);
border-radius: 0.55rem;
background: var(--rip-panel-soft);
padding: 1rem;
}
.path-overview-header {
display: grid;
gap: 0.2rem;
margin-bottom: 0.75rem;
}
.path-overview-header h4 {
margin: 0;
color: var(--rip-brown-800);
font-size: 1rem;
}
.path-overview-header small {
color: var(--rip-muted);
}
.path-overview-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.path-overview-table th {
text-align: left;
padding: 0.4rem 0.6rem;
color: var(--rip-muted);
font-weight: 600;
border-bottom: 1px solid var(--rip-border);
}
.path-overview-table td {
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--rip-border);
vertical-align: middle;
}
.path-overview-table tr:last-child td {
border-bottom: none;
}
.path-overview-table code {
font-size: 0.8rem;
background: rgba(0, 0, 0, 0.06);
padding: 0.15rem 0.4rem;
border-radius: 3px;
word-break: break-all;
}
.path-default-badge {
display: inline-block;
margin-left: 0.5rem;
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
background: var(--rip-border);
color: var(--rip-muted);
border-radius: 999px;
vertical-align: middle;
white-space: nowrap;
}
.path-medium-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.path-medium-card {
border: 1px solid var(--rip-border);
border-radius: 0.55rem;
background: #fff7ea;
overflow: hidden;
}
.path-medium-card-header {
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--rip-border);
background: rgba(0, 0, 0, 0.03);
}
.path-medium-card-header h4 {
margin: 0;
font-size: 0.95rem;
color: var(--rip-brown-800);
}
.path-medium-card .settings-grid {
padding: 0.75rem;
}
.script-manager-wrap {
display: grid;
gap: 0.8rem;
@@ -1729,6 +1925,43 @@ body {
justify-content: flex-end;
}
.history-delete-preview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 0.75rem;
}
.history-delete-preview-grid h4 {
margin: 0 0 0.35rem;
font-size: 0.9rem;
}
.history-delete-preview-list {
margin: 0;
padding-left: 1rem;
display: grid;
gap: 0.22rem;
max-height: 12rem;
overflow: auto;
font-size: 0.78rem;
}
.history-delete-preview-list li {
overflow-wrap: anywhere;
word-break: break-word;
}
.history-delete-preview-list .exists-yes {
color: #176635;
font-weight: 600;
}
.history-delete-preview-list .exists-no {
color: #8b2c2c;
font-weight: 600;
}
.table-scroll-wrap {
width: 100%;
max-width: 100%;
@@ -2141,6 +2374,19 @@ body {
opacity: 0.45;
}
.cd-encode-item-order {
display: inline-flex;
align-items: center;
gap: 0.15rem;
}
.cd-encode-item-order .p-button {
width: 1.5rem;
min-width: 1.5rem;
height: 1.5rem;
padding: 0;
}
.media-title-list,
.media-track-list {
display: grid;
@@ -2294,6 +2540,7 @@ body {
.job-meta-grid,
.job-configured-selection-grid,
.job-film-info-grid,
.history-delete-preview-grid,
.table-filters,
.history-dv-toolbar,
.job-head-row,
@@ -2303,6 +2550,10 @@ body {
grid-template-columns: 1fr;
}
.notification-toggle-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dashboard-job-row {
grid-template-columns: 48px minmax(0, 1fr) auto;
}
@@ -2319,6 +2570,15 @@ body {
min-width: 36rem;
}
.cd-media-meta-layout {
grid-template-columns: 1fr;
}
.cd-cover-wrap {
width: 6.5rem;
min-width: 6.5rem;
}
.script-list {
grid-template-columns: 1fr;
}
@@ -2390,6 +2650,14 @@ body {
width: min(1280px, 98vw);
}
.hardware-storage-head {
grid-template-columns: 1fr;
}
.hardware-storage-percent {
justify-self: start;
}
.table-filters {
grid-template-columns: 1fr;
}
@@ -2422,6 +2690,15 @@ body {
width: 100%;
}
.settings-expert-toggle {
width: 100%;
justify-content: space-between;
}
.notification-toggle-grid {
grid-template-columns: 1fr;
}
.dialog-actions {
justify-content: stretch;
}

View File

@@ -79,6 +79,11 @@ export const STATUS_FILTER_OPTIONS = [
{ label: getStatusLabel('FINISHED'), value: 'FINISHED' },
{ label: getStatusLabel('CANCELLED'), value: 'CANCELLED' },
{ label: getStatusLabel('ERROR'), value: 'ERROR' },
{ label: getStatusLabel('CD_METADATA_SELECTION'), value: 'CD_METADATA_SELECTION' },
{ label: getStatusLabel('CD_READY_TO_RIP'), value: 'CD_READY_TO_RIP' },
{ label: getStatusLabel('CD_ANALYZING'), value: 'CD_ANALYZING' },
{ label: getStatusLabel('CD_RIPPING'), value: 'CD_RIPPING' },
{ label: getStatusLabel('CD_ENCODING'), value: 'CD_ENCODING' },
{ label: getStatusLabel('WAITING_FOR_USER_DECISION'), value: 'WAITING_FOR_USER_DECISION' },
{ label: getStatusLabel('READY_TO_START'), value: 'READY_TO_START' },
{ label: getStatusLabel('READY_TO_ENCODE'), value: 'READY_TO_ENCODE' },