1.0.0-rc2 RC2
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled

This commit is contained in:
2026-05-21 10:19:00 +02:00
parent 9b38d78042
commit eeef1fdb73
31 changed files with 4046 additions and 1449 deletions
+32 -1
View File
@@ -13,6 +13,7 @@ import DownloadsPage from './pages/DownloadsPage';
import ConverterPage from './pages/ConverterPage';
import AudiobooksPage from './pages/AudiobooksPage';
import TmdbMigrationPage from './pages/TmdbMigrationPage';
import HardwarePage from './pages/HardwarePage';
function normalizeJobId(value) {
const parsed = Number(value);
@@ -64,6 +65,35 @@ function isTerminalStage(value) {
return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED';
}
function parseTimestamp(value) {
if (!value) {
return 0;
}
const ts = Date.parse(String(value));
return Number.isFinite(ts) ? ts : 0;
}
function mergePipelineStatePreservingNewestQueue(previousPipeline, incomingPipeline) {
const prev = previousPipeline && typeof previousPipeline === 'object' ? previousPipeline : {};
const incoming = incomingPipeline && typeof incomingPipeline === 'object' ? incomingPipeline : {};
const prevQueue = prev?.queue && typeof prev.queue === 'object' ? prev.queue : null;
const incomingQueue = incoming?.queue && typeof incoming.queue === 'object' ? incoming.queue : null;
if (!prevQueue || !incomingQueue) {
return {
...prev,
...incoming
};
}
const prevQueueTs = parseTimestamp(prevQueue.updatedAt);
const incomingQueueTs = parseTimestamp(incomingQueue.updatedAt);
const queue = incomingQueueTs >= prevQueueTs ? incomingQueue : prevQueue;
return {
...prev,
...incoming,
queue
};
}
function createInitialAudiobookUploadState() {
return {
phase: 'idle',
@@ -363,7 +393,7 @@ function App() {
}
if (message.type === 'PIPELINE_STATE_CHANGED') {
setPipeline(message.payload);
setPipeline((prev) => mergePipelineStatePreservingNewestQueue(prev, message.payload));
}
if (message.type === 'PIPELINE_PROGRESS') {
@@ -705,6 +735,7 @@ function App() {
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
<Route path="database" element={<DatabasePage />} />
<Route path="converter" element={<ConverterPage />} />
<Route path="hardware" element={<HardwarePage hardwareMonitoring={hardwareMonitoring} />} />
<Route
path="audiobooks"
element={
+38 -2
View File
@@ -428,6 +428,14 @@ export const api = {
afterMutationInvalidate(['/settings/scripts']);
return result;
},
async setScriptFavorite(scriptId, isFavorite) {
const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}/favorite`, {
method: 'PUT',
body: JSON.stringify({ isFavorite: isFavorite === true })
});
afterMutationInvalidate(['/settings/scripts']);
return result;
},
async deleteScript(scriptId) {
const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
method: 'DELETE'
@@ -472,6 +480,14 @@ export const api = {
afterMutationInvalidate(['/settings/script-chains']);
return result;
},
async setScriptChainFavorite(chainId, isFavorite) {
const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}/favorite`, {
method: 'PUT',
body: JSON.stringify({ isFavorite: isFavorite === true })
});
afterMutationInvalidate(['/settings/script-chains']);
return result;
},
async deleteScriptChain(chainId) {
const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
method: 'DELETE'
@@ -526,6 +542,21 @@ export const api = {
getPipelineState() {
return request('/pipeline/state');
},
getHardwareHistory(options = {}) {
const hoursRaw = Number(options?.hours);
const maxPointsRaw = Number(options?.maxPoints);
const hours = Number.isFinite(hoursRaw) ? Math.max(1, Math.min(96, Math.trunc(hoursRaw))) : 24;
const maxPoints = Number.isFinite(maxPointsRaw) ? Math.max(120, Math.min(5000, Math.trunc(maxPointsRaw))) : 720;
const query = new URLSearchParams({
hours: String(hours),
maxPoints: String(maxPoints)
});
const path = `/pipeline/hardware/history?${query.toString()}`;
return requestCachedGet(path, {
ttlMs: 10 * 1000,
forceRefresh: options.forceRefresh
});
},
getRuntimeActivities() {
return request('/runtime/activities');
},
@@ -709,9 +740,12 @@ export const api = {
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async retryJob(jobId) {
async retryJob(jobId, options = {}) {
const body = {};
if (options.createNewJob) body.createNewJob = true;
const result = await request(`/pipeline/retry/${jobId}`, {
method: 'POST'
method: 'POST',
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
@@ -739,6 +773,7 @@ export const api = {
if (options.keepBoth) body.keepBoth = true;
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
if (options.reuseCurrentJob) body.reuseCurrentJob = true;
if (options.createNewJob) body.createNewJob = true;
const result = await request(`/pipeline/restart-review/${jobId}`, {
method: 'POST',
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
@@ -751,6 +786,7 @@ export const api = {
if (options.keepBoth) body.keepBoth = true;
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
if (options.restartMode) body.restartMode = options.restartMode;
if (options.createNewJob) body.createNewJob = true;
const result = await request(`/pipeline/restart-encode/${jobId}`, {
method: 'POST',
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
+25 -3
View File
@@ -508,10 +508,32 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
}), [job.id, audioTracks, trackFields]);
// ── Dialog handlers ─────────────────────────────────────────────────────────
const handleMetadataSearch = async (query) => {
const handleMetadataSearch = async (query, options = {}) => {
try {
const response = await api.searchTmdbMovie(query);
return Array.isArray(response?.results) ? response.results : [];
const filterRaw = String(options?.resultFilter || '').trim().toLowerCase();
const includeMovies = filterRaw !== 'series';
const includeSeries = filterRaw !== 'movies' && filterRaw !== 'movie';
const [movieResponse, seriesResponse] = await Promise.all([
includeMovies ? api.searchTmdbMovie(query) : Promise.resolve({ results: [] }),
includeSeries ? api.searchTmdbSeries(query) : Promise.resolve({ results: [] })
]);
const movieRows = Array.isArray(movieResponse?.results)
? movieResponse.results.map((row) => ({
...row,
workflowKind: 'film',
metadataKind: 'movie',
resultType: 'movie'
}))
: [];
const seriesRows = Array.isArray(seriesResponse?.results)
? seriesResponse.results.map((row) => ({
...row,
workflowKind: 'series',
metadataKind: String(row?.metadataKind || '').trim().toLowerCase() || 'series',
resultType: 'series'
}))
: [];
return [...movieRows, ...seriesRows];
} catch { return []; }
};
@@ -57,8 +57,7 @@ const ALWAYS_HIDDEN_SETTING_KEYS = new Set([
'makemkv_rip_mode',
'makemkv_rip_mode_bluray',
'makemkv_rip_mode_dvd',
'makemkv_backup_mode',
'handbrake_pre_metadata_scan_enabled'
'makemkv_backup_mode'
]);
const EXPERT_ONLY_SETTING_KEYS = new Set([
'pushover_device',
@@ -727,6 +726,42 @@ function MakeMKVBetaKeyHint({ onApply, disabled = false }) {
);
}
function ActivationBytesCacheBox({ entries = [] }) {
const list = Array.isArray(entries) ? entries : [];
return (
<section className="settings-section grouped activation-bytes-cache-box">
<div className="settings-section-head">
<h4>Activation Bytes Cache</h4>
<small>Lokal gespeicherte AAX-Activation Bytes. Werden beim ersten Upload automatisch über die Audible-Tools API ermittelt.</small>
</div>
{list.length === 0 ? (
<p>Keine Einträge vorhanden.</p>
) : (
<div className="activation-bytes-table-wrap">
<table className="activation-bytes-table">
<thead>
<tr>
<th>Checksum</th>
<th>Activation Bytes</th>
<th>Gespeichert am</th>
</tr>
</thead>
<tbody>
{list.map((entry, index) => (
<tr key={String(entry?.checksum || `activation-bytes-${index}`)}>
<td>{String(entry?.checksum || '-')}</td>
<td>{String(entry?.activation_bytes || '-')}</td>
<td>{entry?.created_at ? new Date(entry.created_at).toLocaleString('de-DE') : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
function SettingField({
setting,
value,
@@ -1124,6 +1159,7 @@ export default function DynamicSettingsForm({
dirtyKeys,
onChange,
effectivePaths,
activationBytes = [],
onNotify
}) {
const safeCategories = Array.isArray(categories) ? categories : [];
@@ -1217,6 +1253,7 @@ export default function DynamicSettingsForm({
const pushoverEnabled = toBoolean(values?.[PUSHOVER_ENABLED_SETTING_KEY]);
const isDriveCategory = normalizeText(category?.category) === 'laufwerk';
const isToolsCategory = normalizeText(category?.category) === 'tools';
return (
<div className="settings-sections">
{isDriveCategory && <DetectedDrivesInfo expertModeEnabled={expertModeEnabled} onNotify={onNotify} />}
@@ -1345,6 +1382,7 @@ export default function DynamicSettingsForm({
})()}
</section>
))}
{isToolsCategory ? <ActivationBytesCacheBox entries={activationBytes} /> : null}
</div>
);
})()}
+116 -20
View File
@@ -266,6 +266,14 @@ function normalizeSubtitleVariantSelectionMap(rawSelection) {
return map;
}
function normalizeSubtitleSelectionMode(value, fallback = 'variants') {
const mode = String(value || '').trim().toLowerCase();
if (mode === 'track_ids' || mode === 'variants') {
return mode;
}
return fallback;
}
function normalizeSubtitleConfidenceValue(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'high' || raw === 'medium' || raw === 'low') {
@@ -338,10 +346,34 @@ function compareFullSubtitleCandidate(a, b) {
return a.originalIndex - b.originalIndex;
}
function buildSubtitleVariantSelectionForPreviewFromTrackIds(subtitleTracks, selectedTrackIds = []) {
const selectedSet = new Set(normalizeTrackIdList(selectedTrackIds).map((id) => String(id)));
const map = {};
const tracks = Array.isArray(subtitleTracks) ? subtitleTracks : [];
for (const track of tracks) {
if (!selectedSet.has(String(track?.id))) {
continue;
}
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
if (!map[language]) {
map[language] = { full: false, forced: false };
}
if (track.isForcedOnly) {
map[language].forced = true;
} else {
map[language].full = true;
}
}
return map;
}
function resolveDeterministicSubtitleCliSelectionForPreview({
subtitleTracks,
subtitleVariantSelection,
subtitleLanguageOrder
subtitleLanguageOrder,
fallbackSelectedSubtitleTrackIds = [],
fallbackSelectedSubtitleTrackIdsOrdered = [],
hasExplicitVariantSelection = false
}) {
const tracks = (Array.isArray(subtitleTracks) ? subtitleTracks : [])
.map((track, index) => {
@@ -375,8 +407,17 @@ function resolveDeterministicSubtitleCliSelectionForPreview({
}
const normalizedVariantSelection = normalizeSubtitleVariantSelectionMap(subtitleVariantSelection || {});
const hasExplicitVariantSelection = Object.keys(normalizedVariantSelection).length > 0;
const normalizedLanguageOrder = normalizeSubtitleLanguageOrder(subtitleLanguageOrder || []);
const fallbackSelectionFromTrackIds = normalizeSubtitleVariantSelectionMap(
buildSubtitleVariantSelectionForPreviewFromTrackIds(tracks, fallbackSelectedSubtitleTrackIds)
);
const fallbackSelectionFromOrderedTrackIds = normalizeSubtitleVariantSelectionMap(
buildSubtitleVariantSelectionForPreviewFromTrackIds(
tracks,
normalizeTrackIdSequence(fallbackSelectedSubtitleTrackIdsOrdered, { dedupe: false })
)
);
const fallbackSelectionSet = new Set(normalizeTrackIdList(fallbackSelectedSubtitleTrackIds).map((id) => String(id)));
const sortedLanguages = Array.from(grouped.entries())
.map(([language, languageTracks]) => ({
language,
@@ -395,6 +436,11 @@ function resolveDeterministicSubtitleCliSelectionForPreview({
order.push(language);
};
normalizedLanguageOrder.forEach(pushLanguage);
if (!hasExplicitVariantSelection) {
Object.keys(normalizedVariantSelection).forEach(pushLanguage);
Object.keys(fallbackSelectionFromOrderedTrackIds).forEach(pushLanguage);
Object.keys(fallbackSelectionFromTrackIds).forEach(pushLanguage);
}
sortedLanguages.forEach(pushLanguage);
const subtitleTrackIds = [];
@@ -406,15 +452,33 @@ function resolveDeterministicSubtitleCliSelectionForPreview({
const bestFull = fullTracks[0] || null;
const bestFullHasForced = fullTracks.filter((track) => track.fullHasForced).sort(compareFullSubtitleCandidate)[0] || null;
const explicit = normalizedVariantSelection[language] || null;
const fallbackFull = languageTracks.some((track) => track.selectedForEncode && !track.isForcedOnly) || Boolean(bestFull);
const fallbackForced = languageTracks.some((track) => track.selectedForEncode && track.isForcedOnly) || Boolean(forcedOnly);
const requestedFull = explicit
? Boolean(explicit.full)
: (hasExplicitVariantSelection ? false : fallbackFull);
const requestedForced = explicit
? Boolean(explicit.forced)
: (hasExplicitVariantSelection ? false : fallbackForced);
const explicitEntry = normalizedVariantSelection[language] || null;
const fallbackEntryFromOrderedTrackIds = fallbackSelectionFromOrderedTrackIds[language] || null;
const fallbackEntryFromTrackIds = fallbackSelectionFromTrackIds[language] || null;
const hasAnyFallbackSelectedTrack = languageTracks.some((track) => (
fallbackSelectionSet.has(String(track.id)) || track.selectedForEncode
));
let requestedFull = false;
let requestedForced = false;
if (explicitEntry) {
requestedFull = Boolean(explicitEntry.full);
requestedForced = Boolean(explicitEntry.forced);
} else if (hasExplicitVariantSelection) {
requestedFull = false;
requestedForced = false;
} else if (fallbackEntryFromOrderedTrackIds) {
requestedFull = Boolean(fallbackEntryFromOrderedTrackIds.full);
requestedForced = Boolean(fallbackEntryFromOrderedTrackIds.forced);
} else if (fallbackEntryFromTrackIds) {
requestedFull = Boolean(fallbackEntryFromTrackIds.full);
requestedForced = Boolean(fallbackEntryFromTrackIds.forced);
} else if (hasAnyFallbackSelectedTrack) {
requestedFull = Boolean(bestFull);
requestedForced = Boolean(forcedOnly);
} else {
requestedFull = Boolean(bestFull);
requestedForced = Boolean(forcedOnly);
}
if (!requestedFull && !requestedForced) {
continue;
}
@@ -601,6 +665,7 @@ function buildHandBrakeCommandPreview({
title,
selectedAudioTrackIds,
selectedSubtitleTrackIds,
selectedSubtitleSelectionMode = 'variants',
selectedSubtitleVariantSelection = {},
selectedSubtitleLanguageOrder = [],
commandOutputPath = null,
@@ -628,18 +693,31 @@ function buildHandBrakeCommandPreview({
? Math.trunc(rawMappedTitleId)
: null;
const resolvedSubtitleSelection = resolveDeterministicSubtitleCliSelectionForPreview({
subtitleTracks: Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [],
subtitleVariantSelection: selectedSubtitleVariantSelection,
subtitleLanguageOrder: selectedSubtitleLanguageOrder
});
const normalizedSelectedVariantSelection = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {});
const hasExplicitSelectedVariantSelection = Object.keys(normalizedSelectedVariantSelection).length > 0;
const normalizedSubtitleSelectionMode = normalizeSubtitleSelectionMode(
selectedSubtitleSelectionMode,
hasExplicitSelectedVariantSelection ? 'variants' : 'track_ids'
);
const normalizedSelectedSubtitleTrackIds = normalizeTrackIdSequence(selectedSubtitleTrackIds, { dedupe: false });
const resolvedSubtitleSelection = resolveDeterministicSubtitleCliSelectionForPreview({
subtitleTracks: Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [],
subtitleVariantSelection: normalizedSubtitleSelectionMode === 'variants'
? normalizedSelectedVariantSelection
: {},
subtitleLanguageOrder: selectedSubtitleLanguageOrder,
fallbackSelectedSubtitleTrackIds: normalizedSelectedSubtitleTrackIds,
fallbackSelectedSubtitleTrackIdsOrdered: normalizedSelectedSubtitleTrackIds,
hasExplicitVariantSelection: normalizedSubtitleSelectionMode === 'track_ids'
? true
: hasExplicitSelectedVariantSelection
});
const subtitleTrackIds = resolvedSubtitleSelection.subtitleTrackIds.length > 0
? resolvedSubtitleSelection.subtitleTrackIds
: (hasExplicitSelectedVariantSelection
? []
: normalizeTrackIdSequence(selectedSubtitleTrackIds, { dedupe: false }));
: normalizedSelectedSubtitleTrackIds);
const selectedSubtitleSet = new Set(normalizeTrackIdList(subtitleTrackIds).map((id) => String(id)));
const selectedSubtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).filter((track) => {
const id = normalizeTrackId(track?.id);
@@ -1480,6 +1558,7 @@ export default function MediaInfoReviewPanel({
commandOutputPathByTitle = {},
selectedEncodeTitleId = null,
selectedEncodeTitleIds = [],
titleSelectionTouched = false,
allowTitleSelection = false,
compactTitleSelection = false,
onSelectEncodeTitle = null,
@@ -1546,13 +1625,16 @@ export default function MediaInfoReviewPanel({
.filter(Boolean)
: null;
const displayTitles = candidateTitles && candidateTitles.length > 0 ? candidateTitles : titles;
const selectedTitleIds = normalizeTrackIdList([
...normalizeTrackIdList(selectedEncodeTitleIds),
const explicitSelectedTitleIds = normalizeTrackIdList(selectedEncodeTitleIds);
const fallbackSelectedTitleIds = normalizeTrackIdList([
...normalizeTrackIdList(review?.selectedTitleIds || []),
...displayTitles.filter((item) => Boolean(item?.selectedForEncode)).map((item) => item?.id)
]);
const selectedTitleIds = titleSelectionTouched
? explicitSelectedTitleIds
: (explicitSelectedTitleIds.length > 0 ? explicitSelectedTitleIds : fallbackSelectedTitleIds);
const currentSelectedId = normalizeTitleId(selectedEncodeTitleId)
|| normalizeTitleId(review.encodeInputTitleId)
|| (titleSelectionTouched ? null : normalizeTitleId(review.encodeInputTitleId))
|| selectedTitleIds[0]
|| null;
const selectedTitleIdSet = new Set(selectedTitleIds.map((id) => String(id)));
@@ -1993,6 +2075,12 @@ export default function MediaInfoReviewPanel({
const selectedSubtitleVariantSelection = normalizeSubtitleVariantSelectionMap(
titleSelectionEntry?.subtitleVariantSelection || defaultSubtitleVariantSelection
);
const selectedSubtitleSelectionMode = normalizeSubtitleSelectionMode(
titleSelectionEntry?.subtitleSelectionMode,
Object.keys(selectedSubtitleVariantSelection).length > 0
? 'variants'
: 'track_ids'
);
const selectedSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(
Array.isArray(titleSelectionEntry?.subtitleLanguageOrder)
? titleSelectionEntry.subtitleLanguageOrder
@@ -2003,6 +2091,7 @@ export default function MediaInfoReviewPanel({
subtitleTracks,
selectedAudioTrackIds,
selectedSubtitleTrackIds,
selectedSubtitleSelectionMode,
selectedSubtitleVariantSelection,
selectedSubtitleLanguageOrder
};
@@ -2621,6 +2710,12 @@ export default function MediaInfoReviewPanel({
const selectedSubtitleVariantSelection = normalizeSubtitleVariantSelectionMap(
titleSelectionEntry?.subtitleVariantSelection || defaultSubtitleVariantSelection
);
const selectedSubtitleSelectionMode = normalizeSubtitleSelectionMode(
titleSelectionEntry?.subtitleSelectionMode,
Object.keys(selectedSubtitleVariantSelection).length > 0
? 'variants'
: 'track_ids'
);
const selectedSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(
Array.isArray(titleSelectionEntry?.subtitleLanguageOrder)
? titleSelectionEntry.subtitleLanguageOrder
@@ -2803,6 +2898,7 @@ export default function MediaInfoReviewPanel({
title,
selectedAudioTrackIds,
selectedSubtitleTrackIds,
selectedSubtitleSelectionMode,
selectedSubtitleVariantSelection,
selectedSubtitleLanguageOrder,
commandOutputPath: resolvedCommandOutputPath,
@@ -5,6 +5,49 @@ import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { InputText } from 'primereact/inputtext';
function normalizeWorkflowKind(value) {
const raw = String(value || '').trim().toLowerCase();
if (['film', 'movie', 'feature'].includes(raw)) {
return 'film';
}
if (['series', 'tv', 'season', 'episode', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) {
return 'series';
}
return null;
}
function normalizeResultType(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'series') {
return 'series';
}
if (raw === 'movie' || raw === 'film' || raw === 'movies') {
return 'movie';
}
return null;
}
function normalizeMetadataRow(row = {}) {
const workflowKind = normalizeWorkflowKind(
row?.workflowKind
|| row?.kind
|| row?.metadataKind
|| null
);
const metadataKindRaw = String(row?.metadataKind || '').trim().toLowerCase();
const metadataKind = metadataKindRaw || (workflowKind === 'series' ? 'series' : 'movie');
const resultType = normalizeResultType(row?.resultType)
|| (workflowKind === 'series' ? 'series' : (workflowKind === 'film' ? 'movie' : null))
|| ((metadataKind === 'series' || metadataKind === 'season') ? 'series' : 'movie');
const normalizedWorkflowKind = workflowKind || (resultType === 'series' ? 'series' : 'film');
return {
...row,
metadataKind,
resultType,
workflowKind: normalizedWorkflowKind
};
}
export default function MetadataSelectionDialog({
visible,
context,
@@ -91,26 +134,6 @@ export default function MetadataSelectionDialog({
const preferred = stripped || separatorNormalized || raw;
return toSearchTitleCase(preferred);
};
const normalizeWorkflowKind = (value) => {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (['film', 'movie', 'feature'].includes(raw)) {
return 'film';
}
if (['series', 'tv', 'season', 'episode'].includes(raw)) {
return 'series';
}
return null;
};
const normalizeSeriesClassification = (value) => {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'series' || raw === 'film' || raw === 'ambiguous') {
return raw;
}
return null;
};
const parseDiscNumber = (rawValue) => {
const text = String(rawValue ?? '').trim();
if (!/^\d+$/.test(text)) {
@@ -122,6 +145,7 @@ export default function MetadataSelectionDialog({
}
return Math.trunc(parsed);
};
const [selected, setSelected] = useState(null);
const [query, setQuery] = useState('');
const [manualTitle, setManualTitle] = useState('');
@@ -138,18 +162,13 @@ export default function MetadataSelectionDialog({
const [conflictNewDiscNumber, setConflictNewDiscNumber] = useState('');
const [conflictError, setConflictError] = useState('');
const [seasonFilter, setSeasonFilter] = useState('');
const [resultFilter, setResultFilter] = useState('all');
const [discValidationError, setDiscValidationError] = useState('');
const [manualWorkflowKind, setManualWorkflowKind] = useState(null);
const activeSearchRequestRef = useRef(0);
const autoSearchDoneRef = useRef(false);
const autoSearchWorkflowRef = useRef(null);
const wasVisibleRef = useRef(false);
const lastVisibleContextIdentityRef = useRef(null);
const contextWorkflowKind = normalizeWorkflowKind(
context?.selectedMetadata?.workflowKind
|| context?.workflowKind
|| null
);
const mediaProfile = String(
context?.mediaProfile
|| context?.selectedMetadata?.mediaProfile
@@ -157,46 +176,7 @@ export default function MetadataSelectionDialog({
).trim().toLowerCase();
const metadataProvider = 'tmdb';
const providerLabel = 'TMDb';
const seriesDecisionClassification = normalizeSeriesClassification(context?.seriesDecision?.classification);
const hasSeriesSignals = useMemo(() => {
if (contextWorkflowKind === 'series') {
return true;
}
const selectedMetadata = context?.selectedMetadata && typeof context.selectedMetadata === 'object'
? context.selectedMetadata
: {};
const metadataKind = String(selectedMetadata?.metadataKind || '').trim().toLowerCase();
if (metadataKind === 'series' || metadataKind === 'season') {
return true;
}
if (Number(selectedMetadata?.seasonNumber || 0) > 0) {
return true;
}
if (Number(context?.seriesLookupHint?.seasonNumber || 0) > 0) {
return true;
}
if (Boolean(context?.seriesAnalysis?.summary?.seriesLike)) {
return true;
}
const contextCandidates = Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : [];
return contextCandidates.some((row) => Number(row?.seasonNumber || 0) > 0);
}, [context, contextWorkflowKind]);
const recommendedWorkflowKind = normalizeWorkflowKind(
context?.seriesDecision?.recommendedWorkflowKind
|| contextWorkflowKind
|| null
) || (hasSeriesSignals ? 'series' : 'film');
const effectiveWorkflowKind = manualWorkflowKind || recommendedWorkflowKind;
const allowsWorkflowSwitch = (
(mediaProfile === 'dvd' || mediaProfile === 'bluray')
&& seriesDecisionClassification === 'ambiguous'
);
const supportsExternalIdInput = false;
const requiresDiscNumber = metadataProvider === 'tmdb'
&& effectiveWorkflowKind === 'series'
&& (mediaProfile === 'dvd' || mediaProfile === 'bluray');
const effectiveDiscNumber = parseDiscNumber(manualDiscNumber);
const hasValidDiscNumber = effectiveDiscNumber !== null;
const contextJobId = Number(context?.jobId || 0) || null;
const contextIdentity = contextJobId
? `job:${contextJobId}`
@@ -236,29 +216,19 @@ export default function MetadataSelectionDialog({
setConflictNewDiscNumber('');
setConflictError('');
setSeasonFilter('');
setResultFilter('all');
setDiscValidationError('');
setManualWorkflowKind(allowsWorkflowSwitch ? recommendedWorkflowKind : null);
autoSearchDoneRef.current = false;
autoSearchWorkflowRef.current = null;
activeSearchRequestRef.current += 1;
wasVisibleRef.current = true;
lastVisibleContextIdentityRef.current = contextIdentity;
}, [visible, contextIdentity, context, allowsWorkflowSwitch, recommendedWorkflowKind]);
useEffect(() => {
if (!discValidationError) {
return;
}
if (!requiresDiscNumber || hasValidDiscNumber) {
setDiscValidationError('');
}
}, [discValidationError, requiresDiscNumber, hasValidDiscNumber]);
}, [visible, contextIdentity, context]);
useEffect(() => {
if (submitError) {
setSubmitError('');
}
}, [manualDiscNumber, seasonFilter, selected, metadataProvider, conflictExistingDiscNumber, conflictNewDiscNumber]);
}, [manualDiscNumber, seasonFilter, selected, resultFilter, conflictExistingDiscNumber, conflictNewDiscNumber]);
const rows = useMemo(() => {
const base = Array.isArray(context?.metadataCandidates)
@@ -269,17 +239,17 @@ export default function MetadataSelectionDialog({
const map = new Map();
all.forEach((item) => {
const normalized = normalizeMetadataRow(item);
const rowType = normalizeResultType(normalized?.resultType) || 'movie';
const rowKey = String(
item?.providerId
|| item?.imdbId
|| item?.tmdbId
|| `${item?.title || '-'}::${item?.year || '-'}::${item?.seasonNumber || '-'}`
normalized?.providerId
|| `${rowType}:${normalized?.imdbId || normalized?.tmdbId || '-'}:${normalized?.seasonNumber || '-'}:${normalized?.title || '-'}:${normalized?.year || '-'}`
).trim();
if (!rowKey) {
return;
}
map.set(rowKey, {
...item,
...normalized,
_metadataKey: rowKey
});
});
@@ -287,20 +257,56 @@ export default function MetadataSelectionDialog({
return Array.from(map.values());
}, [context, extraResults, hasSearchOverride]);
const seriesRowsAvailable = useMemo(
() => rows.some((row) => normalizeResultType(row?.resultType) === 'series'),
[rows]
);
const selectedResultType = normalizeResultType(selected?.resultType)
|| (normalizeWorkflowKind(selected?.workflowKind) === 'series' ? 'series' : 'movie');
const selectedIsSeries = Boolean(selected) && selectedResultType === 'series';
const showSeasonFilter = resultFilter !== 'movies' && seriesRowsAvailable;
const filteredRows = useMemo(() => {
if (effectiveWorkflowKind !== 'series') {
return rows;
let output = [...rows];
if (resultFilter === 'movies') {
output = output.filter((row) => normalizeResultType(row?.resultType) !== 'series');
} else if (resultFilter === 'series') {
output = output.filter((row) => normalizeResultType(row?.resultType) === 'series');
}
const normalizedFilter = String(seasonFilter || '').trim().toLowerCase();
if (!normalizedFilter) {
return rows;
if (showSeasonFilter) {
const normalizedFilter = String(seasonFilter || '').trim().toLowerCase();
if (normalizedFilter) {
output = output.filter((row) => {
if (normalizeResultType(row?.resultType) !== 'series') {
return false;
}
const seasonNo = String(row?.seasonNumber ?? '').trim().toLowerCase();
const seasonName = String(row?.seasonName || '').trim().toLowerCase();
return seasonNo.includes(normalizedFilter) || seasonName.includes(normalizedFilter);
});
}
}
return rows.filter((row) => {
const seasonNo = String(row?.seasonNumber ?? '').trim().toLowerCase();
const seasonName = String(row?.seasonName || '').trim().toLowerCase();
return seasonNo.includes(normalizedFilter) || seasonName.includes(normalizedFilter);
});
}, [metadataProvider, rows, seasonFilter]);
return output;
}, [rows, resultFilter, seasonFilter, showSeasonFilter]);
const effectiveDiscNumber = parseDiscNumber(manualDiscNumber);
const hasValidDiscNumber = effectiveDiscNumber !== null;
const requiresDiscNumber = metadataProvider === 'tmdb'
&& selectedIsSeries
&& (mediaProfile === 'dvd' || mediaProfile === 'bluray');
useEffect(() => {
if (!discValidationError) {
return;
}
if (!requiresDiscNumber || hasValidDiscNumber) {
setDiscValidationError('');
}
}, [discValidationError, requiresDiscNumber, hasValidDiscNumber]);
const titleWithPosterBody = (row) => (
<div className="metadata-row">
@@ -341,8 +347,7 @@ export default function MetadataSelectionDialog({
let effectiveResults = [];
for (const candidateQuery of queryVariants) {
const results = await onSearch(candidateQuery, {
metadataProvider,
workflowKind: effectiveWorkflowKind
metadataProvider
});
if (activeSearchRequestRef.current !== requestId) {
return;
@@ -371,21 +376,18 @@ export default function MetadataSelectionDialog({
}
if (typeof onSearch !== 'function') {
autoSearchDoneRef.current = true;
autoSearchWorkflowRef.current = effectiveWorkflowKind;
return;
}
const trimmedQuery = String(query || '').trim();
if (!trimmedQuery) {
return;
}
const workflowChanged = autoSearchWorkflowRef.current !== effectiveWorkflowKind;
if (autoSearchDoneRef.current && !workflowChanged) {
if (autoSearchDoneRef.current) {
return;
}
autoSearchDoneRef.current = true;
autoSearchWorkflowRef.current = effectiveWorkflowKind;
void handleSearch();
}, [visible, query, effectiveWorkflowKind, onSearch]);
}, [visible, query, onSearch]);
const handleSubmitError = (error, pendingPayload = null) => {
const details = Array.isArray(error?.details) ? error.details : [];
@@ -435,6 +437,9 @@ export default function MetadataSelectionDialog({
return;
}
const selectionWorkflowKind = selectedIsSeries ? 'series' : 'film';
const manualWorkflowKind = resultFilter === 'series' ? 'series' : 'film';
const payload = selected
? {
jobId: context.jobId,
@@ -443,10 +448,10 @@ export default function MetadataSelectionDialog({
imdbId: selected.imdbId,
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
metadataProvider,
workflowKind: effectiveWorkflowKind,
workflowKind: selectionWorkflowKind,
providerId: selected.providerId || null,
tmdbId: selected.tmdbId || null,
metadataKind: selected.metadataKind || null,
metadataKind: selected.metadataKind || (selectedIsSeries ? 'series' : 'movie'),
voteAverage: Number.isFinite(Number(selected?.voteAverage))
? Number(Number(selected.voteAverage).toFixed(1))
: null,
@@ -458,10 +463,10 @@ export default function MetadataSelectionDialog({
tmdbDetails: selected?.tmdbDetails && typeof selected.tmdbDetails === 'object'
? selected.tmdbDetails
: null,
seasonNumber: selected.seasonNumber || null,
seasonName: selected.seasonName || null,
episodeCount: selected.episodeCount || 0,
episodes: Array.isArray(selected.episodes) ? selected.episodes : [],
seasonNumber: selectedIsSeries ? (selected.seasonNumber || null) : null,
seasonName: selectedIsSeries ? (selected.seasonName || null) : null,
episodeCount: selectedIsSeries ? (selected.episodeCount || 0) : 0,
episodes: selectedIsSeries && Array.isArray(selected.episodes) ? selected.episodes : [],
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
}
: {
@@ -471,7 +476,7 @@ export default function MetadataSelectionDialog({
imdbId: null,
poster: null,
metadataProvider,
workflowKind: effectiveWorkflowKind,
workflowKind: manualWorkflowKind,
seasonNumber: null,
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
};
@@ -624,67 +629,77 @@ export default function MetadataSelectionDialog({
header="Metadaten auswählen"
visible={visible}
onHide={handleHideRequest}
style={{ width: '52rem', maxWidth: '95vw' }}
style={{ width: '58.25rem', maxWidth: '95vw', maxHeight: '950px' }}
className="metadata-selection-dialog"
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
modal
>
<div className="search-row">
<InputText
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void handleSearch();
}
}}
placeholder="Titel suchen"
/>
<Button label={`${providerLabel} Suche`} icon="pi pi-search" onClick={handleSearch} loading={searchBusy} disabled={searchBusy || busy} />
</div>
{allowsWorkflowSwitch ? (
<div className="dialog-actions" style={{ justifyContent: 'flex-start', marginTop: '-0.25rem' }}>
<div className="metadata-dialog-controls">
<div className="search-row">
<InputText
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void handleSearch();
}
}}
placeholder="Titel suchen"
/>
<Button label={`${providerLabel} Suche`} icon="pi pi-search" onClick={handleSearch} loading={searchBusy} disabled={searchBusy || busy} />
</div>
<div className={`metadata-filter-grid${showSeasonFilter ? '' : ' metadata-filter-grid-single'}`}>
{showSeasonFilter ? (
<InputText
value={seasonFilter}
onChange={(event) => setSeasonFilter(event.target.value)}
placeholder="Staffel-Filter (optional, z.B. 1 oder 5.2)"
disabled={searchBusy || busy}
/>
) : null}
<div className="metadata-filter-disc">
<InputText
value={manualDiscNumber}
onChange={(event) => setManualDiscNumber(event.target.value)}
placeholder={requiresDiscNumber ? 'Disc-Nr (Pflicht)' : 'Disc-Nr (optional)'}
inputMode="numeric"
disabled={searchBusy || busy}
/>
<small className="metadata-filter-subtitle">
{requiresDiscNumber
? 'Pflichtfeld für Serien-DVD/Blu-ray. Ohne Disc-Nummer können die Metadaten nicht übernommen werden.'
: 'Optional für Mapping und Output-Templates. Gespeichert werden nur Werte > 0.'}
</small>
</div>
</div>
<div className="metadata-result-filter-row">
<Button
label="Als Film suchen"
severity={effectiveWorkflowKind === 'film' ? 'primary' : 'secondary'}
outlined={effectiveWorkflowKind !== 'film'}
onClick={() => setManualWorkflowKind('film')}
label="Beides"
severity={resultFilter === 'all' ? 'primary' : 'secondary'}
outlined={resultFilter !== 'all'}
onClick={() => setResultFilter('all')}
disabled={searchBusy || busy}
/>
<Button
label="Als Serie suchen"
severity={effectiveWorkflowKind === 'series' ? 'primary' : 'secondary'}
outlined={effectiveWorkflowKind !== 'series'}
onClick={() => setManualWorkflowKind('series')}
label="Filme"
severity={resultFilter === 'movies' ? 'primary' : 'secondary'}
outlined={resultFilter !== 'movies'}
onClick={() => setResultFilter('movies')}
disabled={searchBusy || busy}
/>
</div>
) : null}
<div className={`metadata-filter-grid${effectiveWorkflowKind !== 'series' ? ' metadata-filter-grid-single' : ''}`}>
{effectiveWorkflowKind === 'series' ? (
<InputText
value={seasonFilter}
onChange={(event) => setSeasonFilter(event.target.value)}
placeholder="Staffel-Filter (optional, z.B. 1 oder 5.2)"
<Button
label="Serien"
severity={resultFilter === 'series' ? 'primary' : 'secondary'}
outlined={resultFilter !== 'series'}
onClick={() => setResultFilter('series')}
disabled={searchBusy || busy}
/>
) : null}
<div className="metadata-filter-disc">
<InputText
value={manualDiscNumber}
onChange={(event) => setManualDiscNumber(event.target.value)}
placeholder={requiresDiscNumber ? 'Disc-Nr (Pflicht)' : 'Disc-Nr (optional)'}
inputMode="numeric"
disabled={searchBusy || busy}
/>
<small className="metadata-filter-subtitle">
{requiresDiscNumber
? 'Pflichtfeld fuer Serien-DVDs. Ohne Disk-Nummer koennen die Metadaten nicht uebernommen werden.'
: 'Optionale Disc-Nummer fuer internes Mapping und Output-Templates. Nur Werte groesser 0 werden gespeichert.'}
</small>
</div>
</div>
{searchError ? <small className="error-text">{searchError}</small> : null}
{discValidationError ? <small className="error-text">{discValidationError}</small> : null}
{submitError ? <small className="error-text">{submitError}</small> : null}
@@ -705,12 +720,17 @@ export default function MetadataSelectionDialog({
>
<Column header="Titel" body={titleWithPosterBody} />
<Column field="year" header="Jahr" style={{ width: '8rem' }} />
<Column
header="Typ"
body={(row) => (normalizeResultType(row?.resultType) === 'series' ? 'Serie' : 'Film')}
style={{ width: '7rem' }}
/>
<Column
header="Staffel"
body={(row) => (effectiveWorkflowKind === 'series'
body={(row) => (normalizeResultType(row?.resultType) === 'series'
? (row.seasonNumber ? `S${row.seasonNumber}` : '-')
: (row.imdbId || '-'))}
style={{ width: '10rem' }}
: '-')}
style={{ width: '8rem' }}
/>
</DataTable>
</div>
+40 -32
View File
@@ -858,6 +858,10 @@ function isPlaylistTrackPreparationStatus(value) {
&& normalized.includes('VORBEREIT');
}
function isMediainfoCheckStatus(value) {
return String(value || '').trim().toUpperCase() === 'MEDIAINFO_CHECK';
}
function getSeriesBatchTagSeverity(status) {
const normalized = normalizeSeriesBatchStatus(status);
if (normalized === 'FINISHED') {
@@ -1655,6 +1659,7 @@ export default function PipelineStatusCard({
const [liveJobLog, setLiveJobLog] = useState('');
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
const [selectedEncodeTitleIds, setSelectedEncodeTitleIds] = useState([]);
const [titleSelectionTouched, setTitleSelectionTouched] = useState(false);
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
const [selectedHandBrakeTitleIdsState, setSelectedHandBrakeTitleIdsState] = useState([]);
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
@@ -1832,6 +1837,7 @@ export default function PipelineStatusCard({
const effectiveSelected = defaultAllTitles
? normalizeTitleIdList(reviewTitles.map((title) => title?.id))
: selectedFromReview;
setTitleSelectionTouched(false);
setSelectedEncodeTitleIds(effectiveSelected);
setSelectedEncodeTitleId(effectiveSelected[0] || fromReview || null);
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
@@ -1912,6 +1918,7 @@ export default function PipelineStatusCard({
if (mediaInfoReview) {
return;
}
setTitleSelectionTouched(false);
setSelectedEncodeTitleIds([]);
setSelectedEncodeTitleId(null);
setTrackSelectionByTitle({});
@@ -1923,6 +1930,26 @@ export default function PipelineStatusCard({
setSelectedHandBrakePreset('');
}, [mediaInfoReview, retryJobId]);
const resolveEffectiveSelectedTitleIds = (reviewTitles = []) => {
const normalizedReviewTitles = Array.isArray(reviewTitles) ? reviewTitles : [];
const explicitSelectedIds = normalizeTitleIdList(selectedEncodeTitleIds);
if (titleSelectionTouched) {
return explicitSelectedIds;
}
const fallbackSelectedIds = normalizeTitleIdList([
...explicitSelectedIds,
selectedEncodeTitleId,
mediaInfoReview?.encodeInputTitleId,
...normalizedReviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
]);
const selectedTitleIdsInReviewOrder = normalizedReviewTitles
.map((title) => normalizeTitleId(title?.id))
.filter((id) => id !== null && fallbackSelectedIds.includes(id));
return selectedTitleIdsInReviewOrder.length > 0
? selectedTitleIdsInReviewOrder
: fallbackSelectedIds;
};
useEffect(() => {
const normalizedSelectedIds = normalizeTitleIdList(selectedEncodeTitleIds);
const primaryTitleId = normalizeTitleId(selectedEncodeTitleId);
@@ -1962,11 +1989,7 @@ export default function PipelineStatusCard({
const resolveEpisodeFillContext = () => {
const episodeRows = Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : [];
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
const selectedTitleIds = normalizeTitleIdList([
...normalizeTitleIdList(selectedEncodeTitleIds),
...(Array.isArray(mediaInfoReview?.selectedTitleIds) ? mediaInfoReview.selectedTitleIds : []),
...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
]);
const selectedTitleIds = resolveEffectiveSelectedTitleIds(reviewTitles);
const orderedTitles = reviewTitles
.map((title) => ({
id: normalizeTitleId(title?.id),
@@ -2268,18 +2291,7 @@ export default function PipelineStatusCard({
}
const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
const fallbackSelectedIds = normalizeTitleIdList([
...selectedEncodeTitleIds,
selectedEncodeTitleId,
mediaInfoReview?.encodeInputTitleId,
...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
]);
const selectedTitleIdsInReviewOrder = reviewTitles
.map((title) => normalizeTitleId(title?.id))
.filter((id) => id !== null && fallbackSelectedIds.includes(id));
const effectiveSelectedTitleIds = selectedTitleIdsInReviewOrder.length > 0
? selectedTitleIdsInReviewOrder
: fallbackSelectedIds;
const effectiveSelectedTitleIds = resolveEffectiveSelectedTitleIds(reviewTitles);
const hasEpisodeAssignmentPayload = (assignment) => {
if (!assignment || typeof assignment !== 'object') {
@@ -3113,8 +3125,12 @@ export default function PipelineStatusCard({
+ `${runningSeriesEpisode?.title ? ` - ${runningSeriesEpisode.title}` : ''}`
)
: (pipeline?.statusText || 'Bereit');
const showIndeterminatePrimaryProgress = !hasSeriesBatchProgress
&& isPlaylistTrackPreparationStatus(pipeline?.statusText);
const showMediainfoIndeterminatePrimaryProgress = !hasSeriesBatchProgress
&& isMediainfoCheckStatus(state);
const showIndeterminatePrimaryProgress = showMediainfoIndeterminatePrimaryProgress || (
!hasSeriesBatchProgress
&& isPlaylistTrackPreparationStatus(pipeline?.statusText)
);
const ripIssueEntries = useMemo(
() => extractMakeMkvRipIssues(jobRow?.makemkvInfo),
[jobRow?.makemkvInfo]
@@ -3461,18 +3477,7 @@ export default function PipelineStatusCard({
.map((title) => [normalizeTitleId(title?.id), title])
.filter((entry) => entry[0] !== null)
);
const fallbackSelectedIds = normalizeTitleIdList([
...selectedEncodeTitleIds,
selectedEncodeTitleId,
mediaInfoReview?.encodeInputTitleId,
...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id)
]);
const selectedTitleIdsInReviewOrder = reviewTitles
.map((title) => normalizeTitleId(title?.id))
.filter((id) => id !== null && fallbackSelectedIds.includes(id));
const effectiveSelectedTitleIds = selectedTitleIdsInReviewOrder.length > 0
? selectedTitleIdsInReviewOrder
: fallbackSelectedIds;
const effectiveSelectedTitleIds = resolveEffectiveSelectedTitleIds(reviewTitles);
const preferredPrimaryTitleId = normalizeTitleId(selectedEncodeTitleId)
|| normalizeTitleId(mediaInfoReview?.encodeInputTitleId)
|| null;
@@ -3657,7 +3662,7 @@ export default function PipelineStatusCard({
)}
<small>
{showIndeterminatePrimaryProgress
? 'Vorbereitung läuft …'
? (showMediainfoIndeterminatePrimaryProgress ? 'Mediainfo-Prüfung läuft …' : 'Vorbereitung läuft …')
: primaryEta
? (hasSeriesBatchProgress ? `ETA aktuelle Episode ${primaryEta}` : `ETA ${primaryEta}`)
: 'ETA unbekannt'}
@@ -4342,11 +4347,13 @@ export default function PipelineStatusCard({
)}
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
titleSelectionTouched={titleSelectionTouched}
allowTitleSelection={allowTitleSelectionInReview}
compactTitleSelection={isDiscTitleSelectionRequired}
onSelectEncodeTitle={(titleId) => {
const normalizedTitleId = normalizeTitleId(titleId);
setSelectedEncodeTitleId(normalizedTitleId);
setTitleSelectionTouched(true);
if (!normalizedTitleId) {
return;
}
@@ -4357,6 +4364,7 @@ export default function PipelineStatusCard({
if (!normalizedTitleId) {
return;
}
setTitleSelectionTouched(true);
setSelectedEncodeTitleIds((prev) => {
const current = normalizeTitleIdList(prev);
if (checked) {
+7
View File
@@ -911,6 +911,13 @@ export default function ConverterPage() {
<div className="ripper-subpage-content">
<Toast ref={toastRef} position="top-right" />
<div className="converter-beta-notice" role="note" aria-live="polite">
<i className="pi pi-exclamation-triangle" aria-hidden="true" />
<span>
Hinweis: Der Converter befindet sich aktuell im Beta-Stadium und ist noch nicht vollständig geprüft.
</span>
</div>
{/* Import-Ordner */}
<Card title="Import-Ordner" subTitle="Dateien aus dem Raw-Ordner auswählen und als Job anlegen">
<ConverterFileExplorer
+634
View File
@@ -0,0 +1,634 @@
import { useEffect, useMemo, useState } from 'react';
import 'chart.js/auto';
import { Card } from 'primereact/card';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Chart } from 'primereact/chart';
import { Button } from 'primereact/button';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return 0;
}
return Math.max(0, Math.min(100, parsed));
}
function formatPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return '-';
}
return `${Math.round(parsed)}%`;
}
function formatBytes(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes < 0) {
return '-';
}
if (bytes === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const normalized = bytes / (1024 ** exponent);
return `${normalized.toFixed(normalized >= 100 ? 0 : (normalized >= 10 ? 1 : 2))} ${units[exponent]}`;
}
function formatUpdatedAt(value) {
const raw = String(value || '').trim();
if (!raw) {
return '-';
}
const parsed = Date.parse(raw);
if (!Number.isFinite(parsed)) {
return raw;
}
return new Date(parsed).toLocaleString('de-DE');
}
function normalizeHardwareMonitoringPayload(rawPayload) {
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
return {
enabled: Boolean(payload.enabled),
intervalMs: Number(payload.intervalMs || 0),
updatedAt: payload.updatedAt || null,
sample: payload.sample && typeof payload.sample === 'object' ? payload.sample : null,
error: payload.error ? String(payload.error) : null
};
}
function buildGaugeDataset(value, color) {
const safe = clampPercent(value);
return {
datasets: [
{
data: [safe, Math.max(0, 100 - safe)],
backgroundColor: [color, 'rgba(255,255,255,0.08)'],
borderWidth: 0,
hoverOffset: 0
}
]
};
}
const gaugeOptions = {
responsive: true,
maintainAspectRatio: false,
cutout: '82%',
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
},
animation: false
};
const HISTORY_WINDOWS = [
{ label: '1h', hours: 1 },
{ label: '6h', hours: 6 },
{ label: '24h', hours: 24 },
{ label: '4d', hours: 96 }
];
const HISTORY_STACK_BREAKPOINT = 1700;
const CPU_SERIES_COLOR = '#c43d2f';
const GPU_SERIES_COLOR = '#c9961a';
const RAM_SERIES_COLOR = '#2e7d4f';
const historyChartOptions = {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: false
}
},
scales: {
x: {
ticks: {
autoSkip: true,
maxTicksLimit: 8,
color: '#6a4d38'
},
grid: {
color: 'rgba(111,57,34,0.08)'
}
},
y: {
beginAtZero: true,
ticks: {
color: '#6a4d38'
},
grid: {
color: 'rgba(111,57,34,0.08)'
}
}
}
};
function toNumberOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function formatTickLabel(timestampIso) {
const parsed = Date.parse(String(timestampIso || ''));
if (!Number.isFinite(parsed)) {
return '-';
}
const date = new Date(parsed);
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function buildSeriesToggleButtonStyle(color, active) {
if (active) {
return {
background: color,
borderColor: color,
color: '#fffaf1'
};
}
return {
background: 'transparent',
borderColor: color,
color
};
}
function getHistoryViewportBucket() {
if (typeof window === 'undefined') {
return 'wide';
}
return window.innerWidth < HISTORY_STACK_BREAKPOINT ? 'narrow' : 'wide';
}
function ChartLegendRow({ items = [] }) {
return (
<div className="hardware-history-legend-row" aria-hidden="true">
{items.map((item) => (
<span key={item.label} className="hardware-history-legend-item">
<span className="hardware-history-legend-dot" style={{ background: item.color }} />
<span>{item.label}</span>
</span>
))}
</div>
);
}
function GaugeBlock({ title, subtitle, icon, valuePercent, gaugeColor, bars = [], footer = null }) {
return (
<Card className="hardware-detail-card hardware-detail-card-top">
<div className="hardware-detail-card-head">
<div className="hardware-detail-card-icon"><i className={`pi ${icon}`} /></div>
<div>
<h3>{title}</h3>
{subtitle ? <small>{subtitle}</small> : null}
</div>
</div>
<div className="hardware-detail-gauge-layout">
<div className="hardware-detail-gauge-wrap">
<Chart type="doughnut" data={buildGaugeDataset(valuePercent, gaugeColor)} options={gaugeOptions} className="hardware-detail-gauge-chart" />
<div className="hardware-detail-gauge-center">{formatPercent(valuePercent)}</div>
</div>
<div className="hardware-detail-bar-list">
{bars.map((bar) => (
<div key={bar.label} className="hardware-detail-bar-item">
<div className="hardware-detail-bar-head">
<span>{bar.label}</span>
<span>{bar.valueLabel}</span>
</div>
<ProgressBar value={clampPercent(bar.valuePercent)} showValue={false} />
</div>
))}
</div>
</div>
{footer}
</Card>
);
}
export default function HardwarePage({ hardwareMonitoring }) {
const navigate = useNavigate();
const [historyHours, setHistoryHours] = useState(24);
const [historyViewportBucket, setHistoryViewportBucket] = useState(() => getHistoryViewportBucket());
const [usageSeriesVisible, setUsageSeriesVisible] = useState({
cpu: true,
ram: true,
gpu: true
});
const [tempSeriesVisible, setTempSeriesVisible] = useState({
cpu: true,
gpu: true
});
const [historyState, setHistoryState] = useState({
loading: false,
points: [],
totalPoints: 0,
error: null
});
const monitoringState = useMemo(
() => normalizeHardwareMonitoringPayload(hardwareMonitoring),
[hardwareMonitoring]
);
const sample = monitoringState.sample;
const cpu = sample?.cpu && typeof sample.cpu === 'object' ? sample.cpu : {};
const memory = sample?.memory && typeof sample.memory === 'object' ? sample.memory : {};
const gpu = sample?.gpu && typeof sample.gpu === 'object' ? sample.gpu : {};
const cpuPerCore = Array.isArray(cpu?.perCore) ? cpu.perCore : [];
const gpuDevices = Array.isArray(gpu?.devices) ? gpu.devices : [];
const primaryGpu = gpuDevices[0] || null;
const cpuTitle = String(cpu?.name || cpu?.model || cpu?.modelName || cpu?.brand || '').trim() || 'CPU';
const ramTitle = String(memory?.name || memory?.vendor || memory?.model || '').trim() || 'Arbeitsspeicher';
const gpuTitle = String(primaryGpu?.name || gpu?.name || gpu?.vendor || '').trim() || 'GPU';
const historyPoints = Array.isArray(historyState.points) ? historyState.points : [];
useEffect(() => {
if (!monitoringState.enabled) {
setHistoryState({
loading: false,
points: [],
totalPoints: 0,
error: null
});
return undefined;
}
let cancelled = false;
const loadHistory = async (forceRefresh = false) => {
setHistoryState((prev) => ({
...prev,
loading: prev.points.length === 0
}));
try {
const response = await api.getHardwareHistory({
hours: historyHours,
maxPoints: 900,
forceRefresh
});
if (cancelled) {
return;
}
const payload = response?.history && typeof response.history === 'object' ? response.history : {};
setHistoryState({
loading: false,
points: Array.isArray(payload.points) ? payload.points : [],
totalPoints: Number(payload.totalPoints || 0),
error: null
});
} catch (error) {
if (cancelled) {
return;
}
setHistoryState((prev) => ({
...prev,
loading: false,
error: error?.message || 'Historische Hardware-Daten konnten nicht geladen werden.'
}));
}
};
loadHistory(true);
const refreshMs = Math.max(5000, Number(monitoringState.intervalMs || 5000) * 2);
const timer = setInterval(() => {
loadHistory(true);
}, refreshMs);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [historyHours, monitoringState.enabled, monitoringState.intervalMs]);
useEffect(() => {
let frame = null;
const handleResize = () => {
if (frame) {
cancelAnimationFrame(frame);
}
frame = requestAnimationFrame(() => {
frame = null;
setHistoryViewportBucket((prev) => {
const next = getHistoryViewportBucket();
return prev === next ? prev : next;
});
});
};
window.addEventListener('resize', handleResize);
return () => {
if (frame) {
cancelAnimationFrame(frame);
}
window.removeEventListener('resize', handleResize);
};
}, []);
const historyUsageChartData = useMemo(() => ({
labels: historyPoints.map((point) => formatTickLabel(point?.capturedAt)),
datasets: [
{
label: 'CPU',
data: historyPoints.map((point) => toNumberOrNull(point?.cpuUsagePercent)),
borderColor: CPU_SERIES_COLOR,
backgroundColor: CPU_SERIES_COLOR,
borderWidth: 2,
hidden: !usageSeriesVisible.cpu,
pointRadius: 0,
tension: 0.22
},
{
label: 'RAM',
data: historyPoints.map((point) => toNumberOrNull(point?.ramUsagePercent)),
borderColor: RAM_SERIES_COLOR,
backgroundColor: RAM_SERIES_COLOR,
borderWidth: 2,
hidden: !usageSeriesVisible.ram,
pointRadius: 0,
tension: 0.22
},
{
label: 'GPU',
data: historyPoints.map((point) => toNumberOrNull(point?.gpuUsagePercent)),
borderColor: GPU_SERIES_COLOR,
backgroundColor: GPU_SERIES_COLOR,
borderWidth: 2,
hidden: !usageSeriesVisible.gpu,
pointRadius: 0,
tension: 0.22
}
]
}), [historyPoints, usageSeriesVisible]);
const historyTempChartData = useMemo(() => ({
labels: historyPoints.map((point) => formatTickLabel(point?.capturedAt)),
datasets: [
{
label: 'CPU',
data: historyPoints.map((point) => toNumberOrNull(point?.cpuTemperatureC)),
borderColor: CPU_SERIES_COLOR,
backgroundColor: CPU_SERIES_COLOR,
borderWidth: 2,
hidden: !tempSeriesVisible.cpu,
pointRadius: 0,
tension: 0.22
},
{
label: 'GPU',
data: historyPoints.map((point) => toNumberOrNull(point?.gpuTemperatureC)),
borderColor: GPU_SERIES_COLOR,
backgroundColor: GPU_SERIES_COLOR,
borderWidth: 2,
hidden: !tempSeriesVisible.gpu,
pointRadius: 0,
tension: 0.22
}
]
}), [historyPoints, tempSeriesVisible]);
return (
<div className="hardware-detail-page">
<Card className="hardware-detail-hero">
<div className="hardware-detail-hero-head">
<div>
<h2>Hardware Monitoring</h2>
<small>Ausführliche Live-Ansicht für CPU, RAM und GPU</small>
</div>
<div className="hardware-detail-hero-tags">
<Tag value={monitoringState.enabled ? 'Aktiv' : 'Inaktiv'} severity={monitoringState.enabled ? 'success' : 'warning'} />
<Tag value={`Intervall: ${monitoringState.intervalMs > 0 ? `${monitoringState.intervalMs} ms` : '-'}`} severity="secondary" />
<Tag value={`Letztes Update: ${formatUpdatedAt(monitoringState.updatedAt)}`} severity="info" />
</div>
</div>
</Card>
{!monitoringState.enabled ? (
<Card className="hardware-detail-empty">
<h3>Monitoring ist deaktiviert</h3>
<p>
Aktiviere <code>hardware_monitoring_enabled</code> in den Einstellungen,
um die Live-Ansicht auf dieser Seite zu nutzen.
</p>
<Button
label="Zu den Einstellungen"
icon="pi pi-cog"
onClick={() => navigate('/settings')}
/>
</Card>
) : null}
{monitoringState.enabled && !sample ? (
<Card className="hardware-detail-empty">
<h3>Warte auf erste Messung ...</h3>
<p>Die Hardware-Metriken werden gerade aufgebaut.</p>
</Card>
) : null}
{monitoringState.enabled ? (
<Card className="hardware-detail-card hardware-history-card">
<div className="hardware-detail-card-head hardware-history-card-head">
<div>
<h3>Historie</h3>
<small>
Verlauf aus Backend-Log ({historyState.totalPoints} Messpunkte, Ansicht: letzte {historyHours}h)
</small>
</div>
<div className="hardware-history-window-buttons">
{HISTORY_WINDOWS.map((window) => (
<Button
key={`history-window-${window.hours}`}
label={window.label}
size="small"
severity={historyHours === window.hours ? 'primary' : 'secondary'}
outlined={historyHours !== window.hours}
onClick={() => setHistoryHours(window.hours)}
/>
))}
</div>
</div>
{historyState.error ? <small className="error-text">{historyState.error}</small> : null}
{historyState.loading && historyPoints.length === 0 ? (
<p>Lade Verlauf ...</p>
) : historyPoints.length === 0 ? (
<p>Noch keine Verlaufsdaten vorhanden.</p>
) : (
<div className="hardware-history-grid">
<div className="hardware-history-chart-wrap">
<h4>Auslastung (%)</h4>
<div className="hardware-history-series-toggles">
<Button
type="button"
size="small"
label="CPU"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(CPU_SERIES_COLOR, usageSeriesVisible.cpu)}
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, cpu: !prev.cpu }))}
/>
<Button
type="button"
size="small"
label="RAM"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(RAM_SERIES_COLOR, usageSeriesVisible.ram)}
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, ram: !prev.ram }))}
/>
<Button
type="button"
size="small"
label="GPU"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(GPU_SERIES_COLOR, usageSeriesVisible.gpu)}
onClick={() => setUsageSeriesVisible((prev) => ({ ...prev, gpu: !prev.gpu }))}
/>
</div>
<div className="hardware-history-chart">
<Chart
key={`usage-${historyViewportBucket}`}
className="hardware-history-chart-canvas"
type="line"
data={historyUsageChartData}
options={historyChartOptions}
/>
</div>
<ChartLegendRow
items={[
{ label: 'CPU', color: CPU_SERIES_COLOR },
{ label: 'RAM', color: RAM_SERIES_COLOR },
{ label: 'GPU', color: GPU_SERIES_COLOR }
]}
/>
</div>
<div className="hardware-history-chart-wrap">
<h4>Temperatur (°C)</h4>
<div className="hardware-history-series-toggles">
<Button
type="button"
size="small"
label="CPU °C"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(CPU_SERIES_COLOR, tempSeriesVisible.cpu)}
onClick={() => setTempSeriesVisible((prev) => ({ ...prev, cpu: !prev.cpu }))}
/>
<Button
type="button"
size="small"
label="GPU °C"
className="hardware-series-toggle-btn"
style={buildSeriesToggleButtonStyle(GPU_SERIES_COLOR, tempSeriesVisible.gpu)}
onClick={() => setTempSeriesVisible((prev) => ({ ...prev, gpu: !prev.gpu }))}
/>
</div>
<div className="hardware-history-chart">
<Chart
key={`temp-${historyViewportBucket}`}
className="hardware-history-chart-canvas"
type="line"
data={historyTempChartData}
options={historyChartOptions}
/>
</div>
<ChartLegendRow
items={[
{ label: 'CPU', color: CPU_SERIES_COLOR },
{ label: 'GPU', color: GPU_SERIES_COLOR }
]}
/>
</div>
</div>
)}
</Card>
) : null}
{monitoringState.enabled && sample ? (
<div className="hardware-detail-grid">
<GaugeBlock
title="CPU"
subtitle={cpuTitle}
icon="pi-microchip"
valuePercent={cpu?.overallUsagePercent}
gaugeColor="#b07a24"
bars={[
{ label: 'Gesamtauslastung', valuePercent: cpu?.overallUsagePercent, valueLabel: formatPercent(cpu?.overallUsagePercent) },
{ label: 'Load Avg 1m', valuePercent: clampPercent(Number(cpu?.loadAverage?.[0]) * 100 / 8), valueLabel: Array.isArray(cpu?.loadAverage) ? String(cpu.loadAverage[0] ?? '-') : '-' }
]}
footer={(
<div className="hardware-detail-core-grid">
{cpuPerCore.length === 0 ? <small>Keine Core-Metriken verfügbar.</small> : cpuPerCore.map((core, index) => (
<div key={`cpu-core-${core?.index ?? index}`} className="hardware-detail-core-item">
<div className="hardware-detail-bar-head">
<span>Core {core?.index ?? index}</span>
<span>{formatPercent(core?.usagePercent)}</span>
</div>
<ProgressBar value={clampPercent(core?.usagePercent)} showValue={false} />
</div>
))}
</div>
)}
/>
<GaugeBlock
title="RAM"
subtitle={ramTitle}
icon="pi-server"
valuePercent={memory?.usagePercent}
gaugeColor="#9f6b1d"
bars={[
{ label: 'Verwendet', valuePercent: memory?.usagePercent, valueLabel: formatPercent(memory?.usagePercent) },
{ label: 'Belegt', valuePercent: memory?.usagePercent, valueLabel: formatBytes(memory?.usedBytes) },
{ label: 'Frei', valuePercent: Math.max(0, 100 - clampPercent(memory?.usagePercent)), valueLabel: formatBytes(memory?.freeBytes) }
]}
footer={(
<div className="hardware-detail-meter-wrap">
<div className="hardware-detail-meter-head">
<span>RAM Nutzung</span>
<span>{formatBytes(memory?.usedBytes)} / {formatBytes(memory?.totalBytes)}</span>
</div>
<ProgressBar value={clampPercent(memory?.usagePercent)} showValue={false} />
</div>
)}
/>
<GaugeBlock
title="GPU"
subtitle={gpuTitle}
icon="pi-desktop"
valuePercent={primaryGpu?.utilizationPercent}
gaugeColor="#996521"
bars={[
{ label: '3D / Compute', valuePercent: primaryGpu?.utilizationPercent, valueLabel: formatPercent(primaryGpu?.utilizationPercent) },
{ label: 'Speicher', valuePercent: primaryGpu?.memoryUtilizationPercent, valueLabel: formatPercent(primaryGpu?.memoryUtilizationPercent) },
{ label: 'VRAM', valuePercent: primaryGpu?.memoryUtilizationPercent, valueLabel: `${formatBytes(primaryGpu?.memoryUsedBytes)} / ${formatBytes(primaryGpu?.memoryTotalBytes)}` }
]}
footer={(
<div className="hardware-detail-meter-wrap">
<div className="hardware-detail-meter-head">
<span>GPU Nutzung</span>
<span>
GPU {primaryGpu?.index ?? 0}
{primaryGpu?.name ? ` | ${primaryGpu.name}` : ''}
</span>
</div>
<ProgressBar value={clampPercent(primaryGpu?.utilizationPercent)} showValue={false} />
</div>
)}
/>
</div>
) : null}
</div>
);
}
+35 -13
View File
@@ -59,6 +59,7 @@ const CD_FORMAT_LABELS = {
const MULTIPART_CONTAINER_LIVE_STATUSES = new Set([
'ANALYZING',
'METADATA_LOOKUP',
'METADATA_SELECTION',
'WAITING_FOR_USER_DECISION',
'READY_TO_START',
@@ -445,7 +446,7 @@ function getQueueActionResult(response) {
function isPipelineMetadataFlowStatus(value) {
const normalized = String(value || '').trim().toUpperCase();
return ['METADATA_SELECTION', 'READY_TO_START', 'WAITING_FOR_USER_DECISION'].includes(normalized);
return ['METADATA_LOOKUP', 'METADATA_SELECTION', 'READY_TO_START', 'WAITING_FOR_USER_DECISION'].includes(normalized);
}
function normalizeSortText(value) {
@@ -944,7 +945,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
} else if (action === 'restart_encode') {
setActionBusy(true);
try {
const response = await api.restartEncodeWithLastSettings(job.id, options);
const response = await api.restartEncodeWithLastSettings(job.id, {
...(options || {}),
createNewJob: true
});
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId);
if (result.queued) {
@@ -977,7 +981,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
setActionBusy(true);
try {
const response = await api.restartReviewFromRaw(job.id, options);
const response = await api.restartReviewFromRaw(job.id, {
...(options || {}),
createNewJob: true
});
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId);
toastRef.current?.show({
@@ -1307,7 +1314,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
setActionBusy(true);
try {
const response = await api.retryJob(row.id);
const response = await api.retryJob(row.id, { createNewJob: true });
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId);
toastRef.current?.show({
@@ -1435,19 +1442,34 @@ export default function HistoryPage({ refreshToken = 0 }) {
};
const handleMetadataSearch = async (query, options = {}) => {
const workflow = String(
options?.workflowKind
|| metadataDialogContext?.selectedMetadata?.workflowKind
|| ''
).trim().toLowerCase();
try {
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
?? null;
const response = workflow === 'series'
? await api.searchTmdbSeries(query, tmdbSeasonHint)
: await api.searchTmdbMovie(query);
return response.results || [];
const filterRaw = String(options?.resultFilter || '').trim().toLowerCase();
const includeMovies = filterRaw !== 'series';
const includeSeries = filterRaw !== 'movies' && filterRaw !== 'movie';
const [movieResponse, seriesResponse] = await Promise.all([
includeMovies ? api.searchTmdbMovie(query) : Promise.resolve({ results: [] }),
includeSeries ? api.searchTmdbSeries(query, tmdbSeasonHint) : Promise.resolve({ results: [] })
]);
const movieRows = Array.isArray(movieResponse?.results)
? movieResponse.results.map((row) => ({
...row,
workflowKind: 'film',
metadataKind: 'movie',
resultType: 'movie'
}))
: [];
const seriesRows = Array.isArray(seriesResponse?.results)
? seriesResponse.results.map((row) => ({
...row,
workflowKind: 'series',
metadataKind: String(row?.metadataKind || '').trim().toLowerCase() || 'series',
resultType: 'series'
}))
: [];
return [...movieRows, ...seriesRows];
} catch (error) {
toastRef.current?.show({
severity: 'error',
File diff suppressed because it is too large Load Diff
+104 -45
View File
@@ -322,7 +322,7 @@ export default function SettingsPage() {
const [chainActionBusyIds, setChainActionBusyIds] = useState(() => new Set());
const [runningChainIds, setRunningChainIds] = useState([]);
const [lastChainTestResult, setLastChainTestResult] = useState(null);
const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] });
const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', description: '', steps: [] });
const [chainEditorErrors, setChainEditorErrors] = useState({});
const [chainDragSource, setChainDragSource] = useState(null);
@@ -1019,6 +1019,32 @@ export default function SettingsPage() {
}
};
const handleToggleScriptFavorite = async (script) => {
const scriptId = Number(script?.id);
if (!Number.isFinite(scriptId) || scriptId <= 0) {
return;
}
setScriptActionBusyId(scriptId);
try {
const nextFavorite = !(script?.isFavorite === true);
await api.setScriptFavorite(scriptId, nextFavorite);
await loadScripts({ silent: true });
toastRef.current?.show({
severity: 'success',
summary: 'Scripte',
detail: nextFavorite ? 'Favorit aktiviert.' : 'Favorit entfernt.'
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Favorit konnte nicht geändert werden',
detail: error.message
});
} finally {
setScriptActionBusyId(null);
}
};
const handleScriptListDragStart = (event, scriptId) => {
if (scriptSaving || scriptsLoading || scriptReordering || scriptEditor?.mode === 'create' || Boolean(scriptActionBusyId)) {
event.preventDefault();
@@ -1120,18 +1146,51 @@ export default function SettingsPage() {
}
};
const handleToggleChainFavorite = async (chain) => {
const chainId = Number(chain?.id);
if (!Number.isFinite(chainId) || chainId <= 0) {
return;
}
const normalizedChainId = Math.trunc(chainId);
setChainActionBusy(normalizedChainId, true);
try {
const nextFavorite = !(chain?.isFavorite === true);
await api.setScriptChainFavorite(normalizedChainId, nextFavorite);
await loadChains({ silent: true });
toastRef.current?.show({
severity: 'success',
summary: 'Skriptketten',
detail: nextFavorite ? 'Favorit aktiviert.' : 'Favorit entfernt.'
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Favorit konnte nicht geändert werden',
detail: error.message
});
} finally {
setChainActionBusy(normalizedChainId, false);
}
};
// Chain editor handlers
const openChainEditor = (chain = null) => {
if (chain) {
setChainEditor({ open: true, id: chain.id, name: chain.name, steps: (chain.steps || []).map((s, i) => ({ ...s, _key: `${s.id || i}-${Date.now()}` })) });
setChainEditor({
open: true,
id: chain.id,
name: chain.name,
description: chain.description || '',
steps: (chain.steps || []).map((s, i) => ({ ...s, _key: `${s.id || i}-${Date.now()}` }))
});
} else {
setChainEditor({ open: true, id: null, name: '', steps: [] });
setChainEditor({ open: true, id: null, name: '', description: '', steps: [] });
}
setChainEditorErrors({});
};
const closeChainEditor = () => {
setChainEditor({ open: false, id: null, name: '', steps: [] });
setChainEditor({ open: false, id: null, name: '', description: '', steps: [] });
setChainEditorErrors({});
};
@@ -1182,6 +1241,7 @@ export default function SettingsPage() {
}
const payload = {
name,
description: String(chainEditor.description || '').trim(),
steps: chainEditor.steps.map((s) => ({
stepType: s.stepType,
scriptId: s.stepType === 'script' ? s.scriptId : null,
@@ -1414,6 +1474,7 @@ export default function SettingsPage() {
dirtyKeys={dirtyKeys}
onChange={handleFieldChange}
effectivePaths={effectivePaths}
activationBytes={activationBytes}
onNotify={(msg) => toastRef.current?.show(msg)}
/>
)}
@@ -1506,6 +1567,7 @@ export default function SettingsPage() {
<div className="script-order-list">
{scripts.map((script, index) => {
const isDragging = Number(scriptListDragSourceId) === Number(script.id);
const isFavorite = script?.isFavorite === true;
return (
<div key={script.id} className="script-order-wrapper">
<div
@@ -1530,6 +1592,15 @@ export default function SettingsPage() {
</div>
<div className="script-list-actions">
<Button
icon={isFavorite ? 'pi pi-star-fill' : 'pi pi-star'}
label="Favorit"
severity={isFavorite ? 'warning' : 'secondary'}
outlined={!isFavorite}
onClick={() => handleToggleScriptFavorite(script)}
loading={scriptActionBusyId === script.id}
disabled={scriptReordering || (Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id)}
/>
<Button
icon="pi pi-pencil"
label="Bearbeiten"
@@ -1678,6 +1749,7 @@ export default function SettingsPage() {
const normalizedChainId = Number.isFinite(chainId) && chainId > 0 ? Math.trunc(chainId) : null;
const isChainRuntimeRunning = normalizedChainId !== null && runningChainIdSet.has(normalizedChainId);
const isChainActionBusy = normalizedChainId !== null && chainActionBusyIds.has(normalizedChainId);
const isFavorite = chain?.isFavorite === true;
return (
<div key={chain.id} className="script-order-wrapper">
<div
@@ -1699,20 +1771,20 @@ export default function SettingsPage() {
</div>
<div className="script-list-main">
<strong className="script-id-title">{`ID #${chain.id} - ${chain.name}`}</strong>
<small>
{chain.steps?.length ?? 0} Schritt(e):
{' '}
{(chain.steps || []).map((s, i) => (
<span key={i}>
{i > 0 ? ' → ' : ''}
{s.stepType === 'wait'
? `${s.waitSeconds}s`
: (s.scriptName || `Script #${s.scriptId}`)}
</span>
))}
<small className="chain-description-line" title={String(chain.description || '').trim()}>
{String(chain.description || '').trim() || '—'}
</small>
</div>
<div className="script-list-actions">
<Button
icon={isFavorite ? 'pi pi-star-fill' : 'pi pi-star'}
label="Favorit"
severity={isFavorite ? 'warning' : 'secondary'}
outlined={!isFavorite}
onClick={() => handleToggleChainFavorite(chain)}
loading={isChainActionBusy}
disabled={chainReordering || isChainActionBusy || isChainRuntimeRunning}
/>
<Button
icon="pi pi-pencil"
label="Bearbeiten"
@@ -1801,6 +1873,20 @@ export default function SettingsPage() {
/>
{chainEditorErrors.name ? <small className="error-text">{chainEditorErrors.name}</small> : null}
</div>
<div className="chain-editor-name-row" style={{ marginTop: '0.7rem' }}>
<label htmlFor="chain-description">Kurzbeschreibung (max. 50 Zeichen)</label>
<InputText
id="chain-description"
value={chainEditor.description}
maxLength={50}
onChange={(e) => {
setChainEditor((prev) => ({ ...prev, description: e.target.value }));
setChainEditorErrors((prev) => ({ ...prev, description: null }));
}}
placeholder="Kurzer Hinweis zur Kette"
/>
{chainEditorErrors.description ? <small className="error-text">{chainEditorErrors.description}</small> : null}
</div>
<div className="chain-editor-body">
{/* Palette */}
@@ -2016,9 +2102,9 @@ export default function SettingsPage() {
) : userPresets.length === 0 ? (
<p style={{ marginTop: '1rem' }}>Keine Presets vorhanden. Lege ein neues Preset an.</p>
) : (
<div className="script-list script-list--reorderable" style={{ marginTop: '1rem' }}>
<div className="preset-list" style={{ marginTop: '1rem' }}>
{userPresets.map((preset) => (
<div key={preset.id} className="script-list-item">
<div key={preset.id} className="script-list-item preset-list-item">
<div className="script-list-main">
<div className="script-title-line">
<strong className="script-id-title">#{preset.id} - {preset.name}</strong>
@@ -2032,7 +2118,7 @@ export default function SettingsPage() {
{preset.description || '-'}
</small>
</div>
<div className="script-list-actions script-list-actions--two">
<div className="script-list-actions script-list-actions--two preset-list-actions">
<Button
icon="pi pi-pencil"
label="Bearbeiten"
@@ -2153,33 +2239,6 @@ export default function SettingsPage() {
</TabView>
</Card>
{expertModeEnabled && activationBytes.length > 0 && (
<Card
title="Activation Bytes Cache"
subTitle="Lokal gespeicherte AAX-Activation Bytes. Werden beim ersten Upload automatisch über die Audible-Tools API ermittelt."
style={{ marginTop: '1.5rem' }}
>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'monospace', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--surface-border)' }}>
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Checksum</th>
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Activation Bytes</th>
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Gespeichert am</th>
</tr>
</thead>
<tbody>
{activationBytes.map((entry) => (
<tr key={entry.checksum} style={{ borderBottom: '1px solid var(--surface-border)' }}>
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--text-color-secondary)' }}>{entry.checksum}</td>
<td style={{ padding: '0.5rem 0.75rem', fontWeight: 'bold' }}>{entry.activation_bytes}</td>
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--text-color-secondary)' }}>{new Date(entry.created_at).toLocaleString('de-DE')}</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
</div>
);
}
+654 -12
View File
@@ -459,6 +459,7 @@ body {
.hardware-monitor-head {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.45rem;
margin-bottom: 0.7rem;
}
@@ -466,13 +467,16 @@ body {
.hardware-monitor-grid {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
flex-wrap: wrap;
gap: 0.75rem;
align-items: stretch;
justify-content: center;
width: 100%;
}
.hardware-monitor-grid > .hardware-monitor-block {
flex: 0 0 calc(33.333% - 0.5rem);
flex: 1 1 18rem;
max-width: 24rem;
min-width: 0;
}
@@ -484,6 +488,8 @@ body {
display: grid;
gap: 0.55rem;
align-content: start;
justify-items: center;
text-align: center;
}
.hardware-monitor-block h4 {
@@ -498,9 +504,10 @@ body {
.hardware-cpu-summary {
display: flex;
flex-wrap: nowrap;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
justify-content: center;
min-width: 0;
overflow: hidden;
}
@@ -597,9 +604,10 @@ body {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.2rem 0.3rem;
padding: 0.3rem 0.45rem;
text-align: left;
text-align: center;
overflow: hidden;
}
@@ -607,14 +615,14 @@ body {
font-size: 0.74rem;
font-weight: 700;
color: var(--rip-brown-700);
min-width: 2.2rem;
min-width: 0;
}
.hardware-core-metric {
display: inline-flex;
align-items: center;
gap: 0.28rem;
justify-self: start;
justify-self: center;
}
.hardware-core-metric .pi {
@@ -628,6 +636,73 @@ body {
gap: 0.45rem;
}
.hardware-panel-section {
display: grid;
gap: 0.45rem;
}
.hardware-panel-section h4 {
margin: 0;
}
.hardware-panel-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
}
.hardware-panel-link-btn.p-button {
width: 1.8rem;
height: 1.8rem;
color: var(--rip-brown-700);
}
.hardware-panel-divider {
border-top: 1px solid var(--surface-border);
margin: 0.75rem 0;
}
.hardware-mini-monitor-list {
display: grid;
gap: 0.45rem;
}
.hardware-mini-monitor-item {
border: 1px dashed var(--rip-border);
border-radius: 0.45rem;
padding: 0.45rem 0.55rem;
background: var(--rip-panel);
display: grid;
gap: 0.15rem;
}
.hardware-mini-monitor-item strong {
font-size: 0.82rem;
}
.hardware-mini-monitor-line {
display: flex;
align-items: center;
gap: 0.38rem;
flex-wrap: wrap;
}
.hardware-mini-monitor-metric {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.hardware-mini-monitor-sep {
color: var(--rip-muted);
}
.hardware-mini-monitor-metric .pi {
font-size: 0.78rem;
color: var(--rip-brown-700);
}
.hardware-gpu-item,
.hardware-storage-item {
border: 1px dashed var(--rip-border);
@@ -642,12 +717,14 @@ body {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.25rem 0.35rem;
}
.hardware-gpu-item strong {
flex: 0 0 100%;
font-size: 0.82rem;
text-align: center;
}
.hardware-gpu-chip {
@@ -1213,6 +1290,33 @@ body {
white-space: nowrap;
}
.queue-running-progress {
margin-top: 0.15rem;
}
.queue-running-progress .p-progressbar {
height: 0.34rem;
}
.queue-nonjob-subtitle {
display: flex;
align-items: center;
gap: 0.35rem;
}
.queue-nonjob-subtitle i {
font-size: 0.82rem;
color: var(--rip-muted);
flex: 0 0 auto;
}
.queue-nonjob-subtitle span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pipeline-queue-drag-handle {
cursor: grab;
color: var(--rip-muted);
@@ -1802,6 +1906,11 @@ body {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
}
.metadata-dialog-controls {
display: grid;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
@@ -1809,7 +1918,6 @@ body {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-bottom: 0.75rem;
align-items: start;
}
@@ -1817,6 +1925,13 @@ body {
grid-template-columns: 1fr;
}
.metadata-result-filter-row {
display: flex;
justify-content: flex-start;
gap: 0.5rem;
flex-wrap: wrap;
}
.metadata-filter-disc {
display: grid;
gap: 0.2rem;
@@ -2047,6 +2162,35 @@ body {
color: var(--rip-muted);
}
.activation-bytes-cache-box p {
margin: 0;
}
.activation-bytes-table-wrap {
width: 100%;
overflow-x: auto;
}
.activation-bytes-table {
width: 100%;
border-collapse: collapse;
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
}
.activation-bytes-table th,
.activation-bytes-table td {
text-align: left;
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--surface-border);
white-space: nowrap;
}
.activation-bytes-table th {
color: var(--rip-brown-800);
font-weight: 700;
}
.setting-row {
display: grid;
gap: 0.25rem;
@@ -2222,6 +2366,56 @@ body {
font-size: 0.9rem;
}
.quick-access-list {
display: grid;
gap: 0.42rem;
}
.quick-access-empty {
margin: 0.2rem 0 0;
font-size: 0.82rem;
color: var(--rip-muted);
}
.quick-access-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.45rem;
border: 1px solid var(--rip-border);
border-radius: 0.4rem;
background: var(--rip-panel-soft);
}
.quick-access-item-main {
display: flex;
align-items: center;
min-width: 0;
}
.quick-access-item-title {
display: inline-flex;
align-items: center;
gap: 0.32rem;
font-size: 0.83rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quick-access-item-title-icon {
font-size: 0.75rem;
color: var(--rip-muted);
flex: 0 0 auto;
}
.quick-access-run-btn.p-button.p-button-icon-only {
width: 1.5rem;
height: 1.5rem;
padding: 0;
}
.converter-scan-extensions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7.2rem, 1fr));
@@ -2769,6 +2963,14 @@ body {
min-width: 0;
}
.chain-description-line {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.script-title-line {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@@ -2791,7 +2993,7 @@ body {
.script-list-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.45rem;
}
@@ -2810,6 +3012,16 @@ body {
font-size: 0.8rem;
}
@media (max-width: 1750px) {
.script-list--reorderable .script-list-item:not(.script-list-item-editing) {
grid-template-columns: auto minmax(0, 1fr) minmax(14.5rem, auto);
}
.script-list-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.script-action-spacer {
display: block;
}
@@ -2840,6 +3052,31 @@ body {
min-width: 0;
}
.preset-list {
display: grid;
gap: 0.45rem;
}
.preset-list-item {
grid-template-columns: minmax(0, 1fr) minmax(19rem, 40%);
align-items: center;
gap: 0.65rem;
}
.preset-list-actions .p-button {
min-height: 2.35rem;
}
@media (max-width: 1400px) {
.preset-list-item {
grid-template-columns: 1fr;
}
.preset-list-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.user-preset-default-grid {
grid-template-columns: 1fr;
@@ -4342,12 +4579,16 @@ body {
}
.job-detail-dialog .p-dialog-content,
.metadata-selection-dialog .p-dialog-content,
.disc-detected-dialog .p-dialog-content {
max-height: min(80vh, 56rem);
overflow: auto;
}
.metadata-selection-dialog .p-dialog-content {
max-height: min(84vh, 60rem);
overflow: hidden;
}
.job-detail-dialog,
.metadata-selection-dialog,
.disc-detected-dialog {
@@ -4479,9 +4720,9 @@ body {
.hardware-core-item.compact {
grid-template-columns: auto auto auto;
align-items: center;
justify-content: start;
justify-content: center;
gap: 0.3rem;
text-align: left;
text-align: center;
}
.hardware-core-grid.compact {
@@ -4493,7 +4734,7 @@ body {
}
.hardware-core-metric {
justify-self: start;
justify-self: center;
}
.history-dv-item-list {
@@ -5531,8 +5772,409 @@ body {
flex-wrap: wrap;
}
/* ===== Hardware Detail Page ===== */
.hardware-detail-page {
display: grid;
gap: 1rem;
}
.hardware-detail-hero .p-card-content,
.hardware-detail-card .p-card-content,
.hardware-detail-empty .p-card-content {
padding-top: 0.2rem;
}
.hardware-detail-hero {
border: 1px solid var(--rip-border);
background: #ffffff;
color: var(--text-color);
}
.hardware-detail-hero-head {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: flex-start;
flex-wrap: wrap;
}
.hardware-detail-hero-head h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
line-height: 1.2;
}
.hardware-detail-hero-head small {
color: var(--rip-muted);
}
.hardware-detail-hero-tags {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.hardware-detail-empty {
border: 1px solid var(--rip-border);
}
.hardware-detail-empty h3 {
margin: 0 0 0.45rem;
}
.hardware-detail-empty p {
margin: 0 0 0.9rem;
color: var(--rip-muted);
}
.hardware-detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.hardware-detail-card {
border: 1px solid var(--rip-border);
background: #ffffff;
color: var(--text-color);
}
.hardware-detail-card h3 {
margin: 0;
font-size: 1rem;
line-height: 1.25;
font-weight: 700;
}
.hardware-detail-card h4 {
margin: 0;
font-size: 0.9rem;
font-weight: 700;
}
.hardware-detail-card small {
color: var(--rip-muted);
}
.hardware-detail-card-head {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.85rem;
}
.hardware-detail-card-icon {
width: 2.4rem;
height: 2.4rem;
border-radius: 0.5rem;
background: #f5f1e7;
display: inline-flex;
align-items: center;
justify-content: center;
}
.hardware-detail-card-icon .pi {
font-size: 0.95rem;
}
.hardware-detail-gauge-layout {
display: grid;
grid-template-columns: 170px minmax(0, 1fr);
align-items: center;
gap: 0.85rem;
}
.hardware-detail-gauge-wrap {
position: relative;
width: 170px;
height: 170px;
}
.hardware-detail-gauge-chart {
width: 170px;
height: 170px;
}
.hardware-detail-gauge-center {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
font-weight: 700;
}
.hardware-detail-bar-list {
display: grid;
gap: 0.5rem;
}
.hardware-detail-bar-item,
.hardware-detail-temp-item,
.hardware-detail-list-item {
display: grid;
gap: 0.25rem;
}
.hardware-detail-bar-item .p-progressbar,
.hardware-detail-temp-item .p-progressbar,
.hardware-detail-list-item .p-progressbar {
height: 0.38rem;
}
.hardware-detail-page .p-progressbar .p-progressbar-value {
background: #b07a24;
}
.hardware-detail-bar-head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: baseline;
font-size: 0.88rem;
}
.hardware-detail-scroll-list {
max-height: 12rem;
overflow: auto;
margin-top: 0.7rem;
padding-right: 0.2rem;
display: grid;
gap: 0.5rem;
}
.hardware-detail-core-grid {
margin-top: 0.65rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem 0.55rem;
}
.hardware-detail-core-item {
border: 1px solid var(--rip-border);
border-radius: 0.35rem;
background: #ffffff;
padding: 0.35rem 0.42rem;
display: grid;
gap: 0.15rem;
}
.hardware-detail-core-item .p-progressbar {
height: 0.26rem;
}
.hardware-detail-scroll-item {
display: grid;
gap: 0.2rem;
}
.hardware-detail-meter-wrap {
margin-top: 0.8rem;
border-top: 1px solid var(--surface-border);
padding-top: 0.65rem;
display: grid;
gap: 0.45rem;
}
.hardware-detail-meter-wrap .p-progressbar {
height: 0.38rem;
}
.hardware-detail-meter-head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.hardware-detail-list {
display: grid;
gap: 0.65rem;
}
.hardware-detail-list-item small {
color: var(--rip-muted);
}
.hardware-detail-list-item.tone-warn .p-progressbar-value {
background: linear-gradient(90deg, #d5a242, #e0b25f);
}
.hardware-detail-list-item.tone-high .p-progressbar-value {
background: linear-gradient(90deg, #d9924d, #c97931);
}
.hardware-detail-list-item.tone-critical .p-progressbar-value {
background: linear-gradient(90deg, #c64d37, #ad3a2f);
}
.hardware-history-card {
border: 1px solid var(--rip-border);
background: #ffffff;
}
.hardware-history-card-head {
justify-content: space-between;
align-items: center;
margin-bottom: 0.65rem;
}
.hardware-history-window-buttons {
display: inline-flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.hardware-history-window-buttons .p-button {
min-width: 3.2rem;
}
.hardware-history-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
min-width: 0;
}
.hardware-history-chart-wrap {
border: 1px solid var(--rip-border);
border-radius: 0.45rem;
background: #ffffff;
padding: 0.6rem;
min-width: 0;
overflow: hidden;
}
.hardware-history-chart-wrap h4 {
margin: 0 0 0.5rem;
font-size: 0.9rem;
}
.hardware-history-series-toggles {
display: inline-flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.5rem;
}
.hardware-history-series-toggles .p-button {
min-height: 1.9rem;
padding: 0.2rem 0.55rem;
font-size: 0.78rem;
}
.hardware-history-series-toggles .p-button.hardware-series-toggle-btn {
box-shadow: none;
}
.hardware-history-series-toggles .p-button.hardware-series-toggle-btn:focus {
box-shadow: 0 0 0 1px rgba(58, 29, 18, 0.18);
}
.hardware-history-chart {
position: relative;
height: 320px;
width: 100%;
min-width: 0;
overflow: hidden;
}
.hardware-history-chart canvas {
display: block;
}
.hardware-history-chart .hardware-history-chart-canvas,
.hardware-history-chart .hardware-history-chart-canvas canvas {
height: 100% !important;
}
.hardware-history-chart .hardware-history-chart-canvas canvas {
width: 100% !important;
}
.hardware-history-legend-row {
margin-top: 0.45rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
flex-wrap: wrap;
color: var(--rip-muted);
font-weight: 600;
}
.hardware-history-legend-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.hardware-history-legend-dot {
width: 0.62rem;
height: 0.62rem;
border-radius: 2px;
display: inline-block;
}
@media (max-width: 1700px) {
.hardware-history-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 1900px) {
.hardware-detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 1500px) {
.hardware-detail-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 980px) {
.hardware-detail-grid,
.hardware-detail-gauge-layout {
grid-template-columns: 1fr;
}
.hardware-detail-core-grid {
grid-template-columns: 1fr;
}
.hardware-detail-gauge-wrap {
margin-inline: auto;
}
.hardware-detail-card h3 {
font-size: 0.95rem;
}
}
/* ===== Converter Page ===== */
.converter-beta-notice {
display: flex;
align-items: flex-start;
gap: 0.55rem;
padding: 0.75rem 0.9rem;
border-radius: 10px;
border: 1px solid #f5d28a;
background: #fff8e7;
color: #7a4b00;
font-size: 0.92rem;
line-height: 1.45;
}
.converter-beta-notice .pi {
margin-top: 0.05rem;
color: #d17d00;
}
.converter-card-header {
display: flex;
align-items: center;
+4
View File
@@ -2,6 +2,7 @@ const STATUS_LABELS = {
IDLE: 'Bereit',
DISC_DETECTED: 'Medium erkannt',
ANALYZING: 'Analyse',
METADATA_LOOKUP: 'TMDb-Suche',
METADATA_SELECTION: 'Metadatenauswahl',
WAITING_FOR_USER_DECISION: 'Warte auf Auswahl',
READY_TO_START: 'Startbereit',
@@ -46,6 +47,7 @@ const FALLBACK_TOKEN_LABELS = {
ENCODE: 'encodieren',
ENCODING: 'encodieren',
ANALYZING: 'analyse',
LOOKUP: 'suche',
METADATA: 'metadaten',
SELECTION: 'auswahl',
WAITING: 'warte',
@@ -120,6 +122,7 @@ export function getStatusSeverity(status, options = {}) {
normalized === 'RIPPING'
|| normalized === 'ENCODING'
|| normalized === 'ANALYZING'
|| normalized === 'METADATA_LOOKUP'
|| normalized === 'MEDIAINFO_CHECK'
|| normalized === 'METADATA_SELECTION'
|| normalized === 'POST_ENCODE_SCRIPTS'
@@ -154,5 +157,6 @@ export const STATUS_FILTER_OPTIONS = [
{ label: getStatusLabel('RIPPING'), value: 'RIPPING' },
{ label: getStatusLabel('ENCODING'), value: 'ENCODING' },
{ label: getStatusLabel('ANALYZING'), value: 'ANALYZING' },
{ label: getStatusLabel('METADATA_LOOKUP'), value: 'METADATA_LOOKUP' },
{ label: getStatusLabel('METADATA_SELECTION'), value: 'METADATA_SELECTION' }
];