final dev

This commit is contained in:
2026-03-11 11:56:17 +00:00
parent 2fdf54d2e6
commit 7979b353aa
18 changed files with 3651 additions and 440 deletions

View File

@@ -1,4 +1,81 @@
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
const GET_RESPONSE_CACHE = new Map();
function invalidateCachedGet(prefixes = []) {
const list = Array.isArray(prefixes) ? prefixes.filter(Boolean) : [];
if (list.length === 0) {
GET_RESPONSE_CACHE.clear();
return;
}
for (const key of GET_RESPONSE_CACHE.keys()) {
if (list.some((prefix) => key.startsWith(prefix))) {
GET_RESPONSE_CACHE.delete(key);
}
}
}
function refreshCachedGet(path, ttlMs) {
const cacheKey = String(path || '');
const nextEntry = GET_RESPONSE_CACHE.get(cacheKey) || {
value: undefined,
expiresAt: 0,
promise: null
};
const nextPromise = request(path)
.then((payload) => {
GET_RESPONSE_CACHE.set(cacheKey, {
value: payload,
expiresAt: Date.now() + Math.max(1000, Number(ttlMs || 0)),
promise: null
});
return payload;
})
.catch((error) => {
const current = GET_RESPONSE_CACHE.get(cacheKey);
if (current && current.promise === nextPromise) {
GET_RESPONSE_CACHE.set(cacheKey, {
value: current.value,
expiresAt: current.expiresAt || 0,
promise: null
});
}
throw error;
});
GET_RESPONSE_CACHE.set(cacheKey, {
value: nextEntry.value,
expiresAt: nextEntry.expiresAt || 0,
promise: nextPromise
});
return nextPromise;
}
async function requestCachedGet(path, options = {}) {
const ttlMs = Math.max(1000, Number(options?.ttlMs || 0));
const forceRefresh = Boolean(options?.forceRefresh);
const cacheKey = String(path || '');
const current = GET_RESPONSE_CACHE.get(cacheKey);
const now = Date.now();
if (!forceRefresh && current && current.value !== undefined) {
if (current.expiresAt > now) {
return current.value;
}
if (!current.promise) {
void refreshCachedGet(path, ttlMs);
}
return current.value;
}
if (!forceRefresh && current?.promise) {
return current.promise;
}
return refreshCachedGet(path, ttlMs);
}
function afterMutationInvalidate(prefixes = []) {
invalidateCachedGet(prefixes);
}
async function request(path, options = {}) {
const response = await fetch(`${API_BASE}${path}`, {
@@ -33,89 +110,121 @@ async function request(path, options = {}) {
}
export const api = {
getSettings() {
return request('/settings');
getSettings(options = {}) {
return requestCachedGet('/settings', {
ttlMs: 5 * 60 * 1000,
forceRefresh: options.forceRefresh
});
},
getHandBrakePresets() {
return request('/settings/handbrake-presets');
getHandBrakePresets(options = {}) {
return requestCachedGet('/settings/handbrake-presets', {
ttlMs: 10 * 60 * 1000,
forceRefresh: options.forceRefresh
});
},
getScripts() {
return request('/settings/scripts');
getScripts(options = {}) {
return requestCachedGet('/settings/scripts', {
ttlMs: 2 * 60 * 1000,
forceRefresh: options.forceRefresh
});
},
createScript(payload = {}) {
return request('/settings/scripts', {
async createScript(payload = {}) {
const result = await request('/settings/scripts', {
method: 'POST',
body: JSON.stringify(payload || {})
});
afterMutationInvalidate(['/settings/scripts']);
return result;
},
reorderScripts(orderedScriptIds = []) {
return request('/settings/scripts/reorder', {
async reorderScripts(orderedScriptIds = []) {
const result = await request('/settings/scripts/reorder', {
method: 'POST',
body: JSON.stringify({
orderedScriptIds: Array.isArray(orderedScriptIds) ? orderedScriptIds : []
})
});
afterMutationInvalidate(['/settings/scripts']);
return result;
},
updateScript(scriptId, payload = {}) {
return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
async updateScript(scriptId, payload = {}) {
const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
method: 'PUT',
body: JSON.stringify(payload || {})
});
afterMutationInvalidate(['/settings/scripts']);
return result;
},
deleteScript(scriptId) {
return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
async deleteScript(scriptId) {
const result = await request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
method: 'DELETE'
});
afterMutationInvalidate(['/settings/scripts']);
return result;
},
testScript(scriptId) {
return request(`/settings/scripts/${encodeURIComponent(scriptId)}/test`, {
method: 'POST'
});
},
getScriptChains() {
return request('/settings/script-chains');
getScriptChains(options = {}) {
return requestCachedGet('/settings/script-chains', {
ttlMs: 2 * 60 * 1000,
forceRefresh: options.forceRefresh
});
},
createScriptChain(payload = {}) {
return request('/settings/script-chains', {
async createScriptChain(payload = {}) {
const result = await request('/settings/script-chains', {
method: 'POST',
body: JSON.stringify(payload)
});
afterMutationInvalidate(['/settings/script-chains']);
return result;
},
reorderScriptChains(orderedChainIds = []) {
return request('/settings/script-chains/reorder', {
async reorderScriptChains(orderedChainIds = []) {
const result = await request('/settings/script-chains/reorder', {
method: 'POST',
body: JSON.stringify({
orderedChainIds: Array.isArray(orderedChainIds) ? orderedChainIds : []
})
});
afterMutationInvalidate(['/settings/script-chains']);
return result;
},
updateScriptChain(chainId, payload = {}) {
return request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
async updateScriptChain(chainId, payload = {}) {
const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
afterMutationInvalidate(['/settings/script-chains']);
return result;
},
deleteScriptChain(chainId) {
return request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
async deleteScriptChain(chainId) {
const result = await request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
method: 'DELETE'
});
afterMutationInvalidate(['/settings/script-chains']);
return result;
},
testScriptChain(chainId) {
return request(`/settings/script-chains/${encodeURIComponent(chainId)}/test`, {
method: 'POST'
});
},
updateSetting(key, value) {
return request(`/settings/${encodeURIComponent(key)}`, {
async updateSetting(key, value) {
const result = await request(`/settings/${encodeURIComponent(key)}`, {
method: 'PUT',
body: JSON.stringify({ value })
});
afterMutationInvalidate(['/settings', '/settings/handbrake-presets']);
return result;
},
updateSettingsBulk(settings) {
return request('/settings', {
async updateSettingsBulk(settings) {
const result = await request('/settings', {
method: 'PUT',
body: JSON.stringify({ settings })
});
afterMutationInvalidate(['/settings', '/settings/handbrake-presets']);
return result;
},
testPushover(payload = {}) {
return request('/settings/pushover/test', {
@@ -126,91 +235,143 @@ export const api = {
getPipelineState() {
return request('/pipeline/state');
},
analyzeDisc() {
return request('/pipeline/analyze', {
method: 'POST'
});
getRuntimeActivities() {
return request('/runtime/activities');
},
rescanDisc() {
return request('/pipeline/rescan-disc', {
method: 'POST'
});
},
searchOmdb(q) {
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
},
selectMetadata(payload) {
return request('/pipeline/select-metadata', {
method: 'POST',
body: JSON.stringify(payload)
});
},
startJob(jobId) {
return request(`/pipeline/start/${jobId}`, {
method: 'POST'
});
},
confirmEncodeReview(jobId, payload = {}) {
return request(`/pipeline/confirm-encode/${jobId}`, {
cancelRuntimeActivity(activityId, payload = {}) {
return request(`/runtime/activities/${encodeURIComponent(activityId)}/cancel`, {
method: 'POST',
body: JSON.stringify(payload || {})
});
},
cancelPipeline(jobId = null) {
return request('/pipeline/cancel', {
requestRuntimeNextStep(activityId, payload = {}) {
return request(`/runtime/activities/${encodeURIComponent(activityId)}/next-step`, {
method: 'POST',
body: JSON.stringify(payload || {})
});
},
async analyzeDisc() {
const result = await request('/pipeline/analyze', {
method: 'POST'
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async rescanDisc() {
const result = await request('/pipeline/rescan-disc', {
method: 'POST'
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
searchOmdb(q) {
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
},
async selectMetadata(payload) {
const result = await request('/pipeline/select-metadata', {
method: 'POST',
body: JSON.stringify(payload)
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async startJob(jobId) {
const result = await request(`/pipeline/start/${jobId}`, {
method: 'POST'
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async confirmEncodeReview(jobId, payload = {}) {
const result = await request(`/pipeline/confirm-encode/${jobId}`, {
method: 'POST',
body: JSON.stringify(payload || {})
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async cancelPipeline(jobId = null) {
const result = await request('/pipeline/cancel', {
method: 'POST',
body: JSON.stringify({ jobId })
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
retryJob(jobId) {
return request(`/pipeline/retry/${jobId}`, {
async retryJob(jobId) {
const result = await request(`/pipeline/retry/${jobId}`, {
method: 'POST'
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
resumeReadyJob(jobId) {
return request(`/pipeline/resume-ready/${jobId}`, {
async resumeReadyJob(jobId) {
const result = await request(`/pipeline/resume-ready/${jobId}`, {
method: 'POST'
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
reencodeJob(jobId) {
return request(`/pipeline/reencode/${jobId}`, {
async reencodeJob(jobId) {
const result = await request(`/pipeline/reencode/${jobId}`, {
method: 'POST'
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
restartReviewFromRaw(jobId) {
return request(`/pipeline/restart-review/${jobId}`, {
async restartReviewFromRaw(jobId) {
const result = await request(`/pipeline/restart-review/${jobId}`, {
method: 'POST'
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
restartEncodeWithLastSettings(jobId) {
return request(`/pipeline/restart-encode/${jobId}`, {
async restartEncodeWithLastSettings(jobId) {
const result = await request(`/pipeline/restart-encode/${jobId}`, {
method: 'POST'
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
getPipelineQueue() {
return request('/pipeline/queue');
},
reorderPipelineQueue(orderedEntryIds = []) {
return request('/pipeline/queue/reorder', {
async reorderPipelineQueue(orderedEntryIds = []) {
const result = await request('/pipeline/queue/reorder', {
method: 'POST',
body: JSON.stringify({ orderedEntryIds: Array.isArray(orderedEntryIds) ? orderedEntryIds : [] })
});
afterMutationInvalidate(['/pipeline/queue']);
return result;
},
addQueueEntry(payload = {}) {
return request('/pipeline/queue/entry', {
async addQueueEntry(payload = {}) {
const result = await request('/pipeline/queue/entry', {
method: 'POST',
body: JSON.stringify(payload)
});
afterMutationInvalidate(['/pipeline/queue']);
return result;
},
removeQueueEntry(entryId) {
return request(`/pipeline/queue/entry/${encodeURIComponent(entryId)}`, {
async removeQueueEntry(entryId) {
const result = await request(`/pipeline/queue/entry/${encodeURIComponent(entryId)}`, {
method: 'DELETE'
});
afterMutationInvalidate(['/pipeline/queue']);
return result;
},
getJobs(params = {}) {
const query = new URLSearchParams();
if (params.status) query.set('status', params.status);
if (Array.isArray(params.statuses) && params.statuses.length > 0) {
query.set('statuses', params.statuses.join(','));
}
if (params.search) query.set('search', params.search);
if (Number.isFinite(Number(params.limit)) && Number(params.limit) > 0) {
query.set('limit', String(Math.trunc(Number(params.limit))));
}
if (params.lite) {
query.set('lite', '1');
}
const suffix = query.toString() ? `?${query.toString()}` : '';
return request(`/history${suffix}`);
},
@@ -224,32 +385,43 @@ export const api = {
getOrphanRawFolders() {
return request('/history/orphan-raw');
},
importOrphanRawFolder(rawPath) {
return request('/history/orphan-raw/import', {
async importOrphanRawFolder(rawPath) {
const result = await request('/history/orphan-raw/import', {
method: 'POST',
body: JSON.stringify({ rawPath })
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
assignJobOmdb(jobId, payload = {}) {
return request(`/history/${jobId}/omdb/assign`, {
async assignJobOmdb(jobId, payload = {}) {
const result = await request(`/history/${jobId}/omdb/assign`, {
method: 'POST',
body: JSON.stringify(payload || {})
});
afterMutationInvalidate(['/history']);
return result;
},
deleteJobFiles(jobId, target = 'both') {
return request(`/history/${jobId}/delete-files`, {
async deleteJobFiles(jobId, target = 'both') {
const result = await request(`/history/${jobId}/delete-files`, {
method: 'POST',
body: JSON.stringify({ target })
});
afterMutationInvalidate(['/history']);
return result;
},
deleteJobEntry(jobId, target = 'none') {
return request(`/history/${jobId}/delete`, {
async deleteJobEntry(jobId, target = 'none') {
const result = await request(`/history/${jobId}/delete`, {
method: 'POST',
body: JSON.stringify({ target })
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
getJob(jobId, options = {}) {
const query = new URLSearchParams();
const includeLiveLog = Boolean(options.includeLiveLog);
const includeLogs = Boolean(options.includeLogs);
const includeAllLogs = Boolean(options.includeAllLogs);
if (options.includeLiveLog) {
query.set('includeLiveLog', '1');
}
@@ -262,31 +434,51 @@ export const api = {
if (Number.isFinite(Number(options.logTailLines)) && Number(options.logTailLines) > 0) {
query.set('logTailLines', String(Math.trunc(Number(options.logTailLines))));
}
if (options.lite) {
query.set('lite', '1');
}
const suffix = query.toString() ? `?${query.toString()}` : '';
return request(`/history/${jobId}${suffix}`);
const path = `/history/${jobId}${suffix}`;
const canUseCache = !includeLiveLog && !includeLogs && !includeAllLogs;
if (!canUseCache) {
return request(path);
}
return requestCachedGet(path, {
ttlMs: 8000,
forceRefresh: options.forceRefresh
});
},
// ── User Presets ───────────────────────────────────────────────────────────
getUserPresets(mediaType = null) {
getUserPresets(mediaType = null, options = {}) {
const suffix = mediaType ? `?media_type=${encodeURIComponent(mediaType)}` : '';
return request(`/settings/user-presets${suffix}`);
return requestCachedGet(`/settings/user-presets${suffix}`, {
ttlMs: 2 * 60 * 1000,
forceRefresh: options.forceRefresh
});
},
createUserPreset(payload = {}) {
return request('/settings/user-presets', {
async createUserPreset(payload = {}) {
const result = await request('/settings/user-presets', {
method: 'POST',
body: JSON.stringify(payload)
});
afterMutationInvalidate(['/settings/user-presets']);
return result;
},
updateUserPreset(id, payload = {}) {
return request(`/settings/user-presets/${encodeURIComponent(id)}`, {
async updateUserPreset(id, payload = {}) {
const result = await request(`/settings/user-presets/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
afterMutationInvalidate(['/settings/user-presets']);
return result;
},
deleteUserPreset(id) {
return request(`/settings/user-presets/${encodeURIComponent(id)}`, {
async deleteUserPreset(id) {
const result = await request(`/settings/user-presets/${encodeURIComponent(id)}`, {
method: 'DELETE'
});
afterMutationInvalidate(['/settings/user-presets']);
return result;
},
// ── Cron Jobs ──────────────────────────────────────────────────────────────

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { Dialog } from 'primereact/dialog';
import { InputText } from 'primereact/inputtext';
@@ -32,6 +32,23 @@ function StatusBadge({ status }) {
return <span className={`cron-status cron-status--${info.cls}`}>{info.label}</span>;
}
function normalizeActiveCronRuns(rawPayload) {
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
const active = Array.isArray(payload.active) ? payload.active : [];
return active
.map((item) => (item && typeof item === 'object' ? item : null))
.filter(Boolean)
.filter((item) => String(item.type || '').trim().toLowerCase() === 'cron')
.map((item) => ({
id: Number(item.id),
cronJobId: Number(item.cronJobId || 0),
currentStep: String(item.currentStep || '').trim() || null,
currentScriptName: String(item.currentScriptName || '').trim() || null,
startedAt: item.startedAt || null
}))
.filter((item) => Number.isFinite(item.cronJobId) && item.cronJobId > 0);
}
const EMPTY_FORM = {
name: '',
cronExpression: '',
@@ -50,6 +67,7 @@ export default function CronJobsTab({ onWsMessage }) {
const [loading, setLoading] = useState(false);
const [scripts, setScripts] = useState([]);
const [chains, setChains] = useState([]);
const [activeCronRuns, setActiveCronRuns] = useState([]);
// Editor-Dialog
const [editorOpen, setEditorOpen] = useState(false);
@@ -76,14 +94,18 @@ export default function CronJobsTab({ onWsMessage }) {
const loadAll = useCallback(async () => {
setLoading(true);
try {
const [cronResp, scriptsResp, chainsResp] = await Promise.allSettled([
const [cronResp, scriptsResp, chainsResp, runtimeResp] = await Promise.allSettled([
api.getCronJobs(),
api.getScripts(),
api.getScriptChains()
api.getScriptChains(),
api.getRuntimeActivities()
]);
if (cronResp.status === 'fulfilled') setJobs(cronResp.value?.jobs || []);
if (scriptsResp.status === 'fulfilled') setScripts(scriptsResp.value?.scripts || []);
if (chainsResp.status === 'fulfilled') setChains(chainsResp.value?.chains || []);
if (runtimeResp.status === 'fulfilled') {
setActiveCronRuns(normalizeActiveCronRuns(runtimeResp.value));
}
} finally {
setLoading(false);
}
@@ -93,6 +115,36 @@ export default function CronJobsTab({ onWsMessage }) {
loadAll();
}, [loadAll]);
useEffect(() => {
let cancelled = false;
const refreshRuntime = async () => {
try {
const response = await api.getRuntimeActivities();
if (!cancelled) {
setActiveCronRuns(normalizeActiveCronRuns(response));
}
} catch (_error) {
// ignore polling errors
}
};
const interval = setInterval(refreshRuntime, 2500);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
const activeCronRunByJobId = useMemo(() => {
const map = new Map();
for (const item of activeCronRuns) {
if (!item?.cronJobId) {
continue;
}
map.set(item.cronJobId, item);
}
return map;
}, [activeCronRuns]);
// WebSocket: Cronjob-Updates empfangen
useEffect(() => {
if (!onWsMessage) return;
@@ -279,6 +331,7 @@ export default function CronJobsTab({ onWsMessage }) {
<div className="cron-list">
{jobs.map((job) => {
const isBusy = busyId === job.id;
const activeCronRun = activeCronRunByJobId.get(Number(job.id)) || null;
return (
<div key={job.id} className={`cron-item${job.enabled ? '' : ' cron-item--disabled'}`}>
<div className="cron-item-header">
@@ -305,6 +358,17 @@ export default function CronJobsTab({ onWsMessage }) {
<span className="cron-meta-label">Nächster Lauf:</span>
<span className="cron-meta-value">{formatDateTime(job.nextRunAt)}</span>
</span>
{activeCronRun ? (
<span className="cron-meta-entry">
<span className="cron-meta-label">Aktuell:</span>
<span className="cron-meta-value">
<StatusBadge status="running" />
{activeCronRun.currentScriptName
? `Skript: ${activeCronRun.currentScriptName}`
: (activeCronRun.currentStep || 'Ausführung läuft')}
</span>
</span>
) : null}
</div>
<div className="cron-item-toggles">

View File

@@ -54,6 +54,127 @@ function ScriptSummarySection({ title, summary }) {
);
}
function normalizeIdList(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;
}
function shellQuote(value) {
const raw = String(value ?? '');
if (raw.length === 0) {
return "''";
}
if (/^[A-Za-z0-9_./:=,+-]+$/.test(raw)) {
return raw;
}
return `'${raw.replace(/'/g, `'"'"'`)}'`;
}
function buildExecutedHandBrakeCommand(handbrakeInfo) {
const cmd = String(handbrakeInfo?.cmd || '').trim();
const args = Array.isArray(handbrakeInfo?.args) ? handbrakeInfo.args : [];
if (!cmd) {
return null;
}
return `${cmd} ${args.map((arg) => shellQuote(arg)).join(' ')}`.trim();
}
function buildConfiguredScriptAndChainSelection(job) {
const plan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object' ? job.handbrakeInfo : {};
const scriptNameById = new Map();
const chainNameById = new Map();
const addScriptHint = (idValue, nameValue) => {
const id = normalizeIdList([idValue])[0] || null;
const name = String(nameValue || '').trim();
if (!id || !name || scriptNameById.has(id)) {
return;
}
scriptNameById.set(id, name);
};
const addChainHint = (idValue, nameValue) => {
const id = normalizeIdList([idValue])[0] || null;
const name = String(nameValue || '').trim();
if (!id || !name || chainNameById.has(id)) {
return;
}
chainNameById.set(id, name);
};
const addScriptHintsFromList = (list) => {
for (const item of (Array.isArray(list) ? list : [])) {
addScriptHint(item?.id ?? item?.scriptId, item?.name ?? item?.scriptName);
}
};
const addChainHintsFromList = (list) => {
for (const item of (Array.isArray(list) ? list : [])) {
addChainHint(item?.id ?? item?.chainId, item?.name ?? item?.chainName);
}
};
addScriptHintsFromList(plan?.preEncodeScripts);
addScriptHintsFromList(plan?.postEncodeScripts);
addChainHintsFromList(plan?.preEncodeChains);
addChainHintsFromList(plan?.postEncodeChains);
const scriptSummaries = [handbrakeInfo?.preEncodeScripts, handbrakeInfo?.postEncodeScripts];
for (const summary of scriptSummaries) {
const results = Array.isArray(summary?.results) ? summary.results : [];
for (const result of results) {
addScriptHint(result?.scriptId, result?.scriptName);
addChainHint(result?.chainId, result?.chainName);
}
}
const preScriptIds = normalizeIdList([
...(Array.isArray(plan?.preEncodeScriptIds) ? plan.preEncodeScriptIds : []),
...(Array.isArray(plan?.preEncodeScripts) ? plan.preEncodeScripts.map((item) => item?.id ?? item?.scriptId) : [])
]);
const postScriptIds = normalizeIdList([
...(Array.isArray(plan?.postEncodeScriptIds) ? plan.postEncodeScriptIds : []),
...(Array.isArray(plan?.postEncodeScripts) ? plan.postEncodeScripts.map((item) => item?.id ?? item?.scriptId) : [])
]);
const preChainIds = normalizeIdList([
...(Array.isArray(plan?.preEncodeChainIds) ? plan.preEncodeChainIds : []),
...(Array.isArray(plan?.preEncodeChains) ? plan.preEncodeChains.map((item) => item?.id ?? item?.chainId) : [])
]);
const postChainIds = normalizeIdList([
...(Array.isArray(plan?.postEncodeChainIds) ? plan.postEncodeChainIds : []),
...(Array.isArray(plan?.postEncodeChains) ? plan.postEncodeChains.map((item) => item?.id ?? item?.chainId) : [])
]);
return {
preScriptIds,
postScriptIds,
preChainIds,
postChainIds,
preScripts: preScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`),
postScripts: postScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`),
preChains: preChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`),
postChains: postChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`),
scriptCatalog: Array.from(scriptNameById.entries()).map(([id, name]) => ({ id, name })),
chainCatalog: Array.from(chainNameById.entries()).map(([id, name]) => ({ id, name }))
};
}
function resolveMediaType(job) {
const candidates = [
job?.mediaType,
@@ -205,6 +326,25 @@ export default function JobDetailDialog({
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|| configuredSelection.postScriptIds.length > 0
|| configuredSelection.preChainIds.length > 0
|| configuredSelection.postChainIds.length > 0;
const reviewPreEncodeItems = [
...configuredSelection.preScriptIds.map((id) => ({ type: 'script', id })),
...configuredSelection.preChainIds.map((id) => ({ type: 'chain', id }))
];
const reviewPostEncodeItems = [
...configuredSelection.postScriptIds.map((id) => ({ type: 'script', id })),
...configuredSelection.postChainIds.map((id) => ({ type: 'chain', id }))
];
const encodePlanUserPreset = job?.encodePlan?.userPreset && typeof job.encodePlan.userPreset === 'object'
? job.encodePlan.userPreset
: null;
const encodePlanUserPresetId = Number(encodePlanUserPreset?.id);
const reviewUserPresets = encodePlanUserPreset ? [encodePlanUserPreset] : [];
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
return (
<Dialog
@@ -335,6 +475,42 @@ export default function JobDetailDialog({
</div>
</section>
{hasConfiguredSelection || encodePlanUserPreset ? (
<section className="job-meta-block job-meta-block-full">
<h4>Hinterlegte Encode-Auswahl</h4>
<div className="job-configured-selection-grid">
<div>
<strong>Pre-Encode Skripte:</strong> {configuredSelection.preScripts.length > 0 ? configuredSelection.preScripts.join(' | ') : '-'}
</div>
<div>
<strong>Pre-Encode Ketten:</strong> {configuredSelection.preChains.length > 0 ? configuredSelection.preChains.join(' | ') : '-'}
</div>
<div>
<strong>Post-Encode Skripte:</strong> {configuredSelection.postScripts.length > 0 ? configuredSelection.postScripts.join(' | ') : '-'}
</div>
<div>
<strong>Post-Encode Ketten:</strong> {configuredSelection.postChains.length > 0 ? configuredSelection.postChains.join(' | ') : '-'}
</div>
<div className="job-meta-col-span-2">
<strong>User-Preset:</strong>{' '}
{encodePlanUserPreset
? `${encodePlanUserPreset.name || '-'} | Preset=${encodePlanUserPreset.handbrakePreset || '-'} | ExtraArgs=${encodePlanUserPreset.extraArgs || '-'}`
: '-'}
</div>
</div>
</section>
) : null}
{executedHandBrakeCommand ? (
<section className="job-meta-block job-meta-block-full">
<h4>Ausgeführter Encode-Befehl</h4>
<div className="handbrake-command-preview">
<small><strong>HandBrakeCLI (tatsächlich gestartet):</strong></small>
<pre>{executedHandBrakeCommand}</pre>
</div>
</section>
) : null}
{(job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
<section className="job-meta-block job-meta-block-full">
<h4>Skripte</h4>
@@ -356,7 +532,18 @@ export default function JobDetailDialog({
{job.encodePlan ? (
<>
<h4>Mediainfo-Prüfung (Auswertung)</h4>
<MediaInfoReviewPanel review={job.encodePlan} />
<MediaInfoReviewPanel
review={job.encodePlan}
commandOutputPath={job.output_path || null}
availableScripts={configuredSelection.scriptCatalog}
availableChains={configuredSelection.chainCatalog}
preEncodeItems={reviewPreEncodeItems}
postEncodeItems={reviewPostEncodeItems}
userPresets={reviewUserPresets}
selectedUserPresetId={Number.isFinite(encodePlanUserPresetId) && encodePlanUserPresetId > 0
? Math.trunc(encodePlanUserPresetId)
: null}
/>
</>
) : null}

View File

@@ -684,6 +684,14 @@ function normalizeScriptId(value) {
return Math.trunc(parsed);
}
function normalizeChainId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeScriptIdList(values) {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
@@ -762,8 +770,8 @@ export default function MediaInfoReviewPanel({
.filter((item) => item.id !== null && item.name.length > 0);
const scriptById = new Map(scriptCatalog.map((item) => [item.id, item]));
const chainCatalog = (Array.isArray(availableChains) ? availableChains : [])
.map((item) => ({ id: Number(item?.id), name: String(item?.name || '').trim() }))
.filter((item) => Number.isFinite(item.id) && item.id > 0 && item.name.length > 0);
.map((item) => ({ id: normalizeChainId(item?.id), name: String(item?.name || '').trim() }))
.filter((item) => item.id !== null && item.name.length > 0);
const chainById = new Map(chainCatalog.map((item) => [item.id, item]));
const makeHandleDrop = (items, onReorder) => (event, targetIndex) => {
@@ -884,13 +892,29 @@ export default function MediaInfoReviewPanel({
? (scriptObj?.name || `Skript #${item.id}`)
: (chainObj?.name || `Kette #${item.id}`);
const usedScriptIds = new Set(
preEncodeItems.filter((it, i) => it.type === 'script' && i !== rowIndex).map((it) => String(normalizeScriptId(it.id)))
preEncodeItems
.filter((it, i) => it.type === 'script' && i !== rowIndex)
.map((it) => normalizeScriptId(it.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const usedChainIds = new Set(
preEncodeItems
.filter((it, i) => it.type === 'chain' && i !== rowIndex)
.map((it) => normalizeChainId(it.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((s) => ({
label: s.name,
value: s.id,
disabled: usedScriptIds.has(String(s.id))
}));
const chainOptions = chainCatalog.map((c) => ({
label: c.name,
value: c.id,
disabled: usedChainIds.has(String(c.id))
}));
return (
<div
key={`pre-item-${rowIndex}-${item.type}-${item.id}`}
@@ -926,7 +950,15 @@ export default function MediaInfoReviewPanel({
className="full-width"
/>
) : (
<span className="post-script-chain-name">{name}</span>
<Dropdown
value={normalizeChainId(item.id)}
options={chainOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => onChangePreEncodeItem?.(rowIndex, 'chain', event.value)}
className="full-width"
/>
)}
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePreEncodeItem?.(rowIndex)} />
</>
@@ -980,13 +1012,29 @@ export default function MediaInfoReviewPanel({
? (scriptObj?.name || `Skript #${item.id}`)
: (chainObj?.name || `Kette #${item.id}`);
const usedScriptIds = new Set(
postEncodeItems.filter((it, i) => it.type === 'script' && i !== rowIndex).map((it) => String(normalizeScriptId(it.id)))
postEncodeItems
.filter((it, i) => it.type === 'script' && i !== rowIndex)
.map((it) => normalizeScriptId(it.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const usedChainIds = new Set(
postEncodeItems
.filter((it, i) => it.type === 'chain' && i !== rowIndex)
.map((it) => normalizeChainId(it.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((s) => ({
label: s.name,
value: s.id,
disabled: usedScriptIds.has(String(s.id))
}));
const chainOptions = chainCatalog.map((c) => ({
label: c.name,
value: c.id,
disabled: usedChainIds.has(String(c.id))
}));
return (
<div
key={`post-item-${rowIndex}-${item.type}-${item.id}`}
@@ -1022,7 +1070,15 @@ export default function MediaInfoReviewPanel({
className="full-width"
/>
) : (
<span className="post-script-chain-name">{name}</span>
<Dropdown
value={normalizeChainId(item.id)}
options={chainOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => onChangePostEncodeItem?.(rowIndex, 'chain', event.value)}
className="full-width"
/>
)}
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePostEncodeItem?.(rowIndex)} />
</>

View File

@@ -98,6 +98,46 @@ function normalizeChainId(value) {
return Math.trunc(parsed);
}
function normalizeMediaProfile(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
return 'bluray';
}
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
return 'dvd';
}
if (['other', 'sonstiges', 'unknown'].includes(raw)) {
return 'other';
}
return null;
}
function resolvePipelineMediaProfile(pipeline, mediaInfoReview) {
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
const device = context?.device && typeof context.device === 'object' ? context.device : {};
const review = mediaInfoReview && typeof mediaInfoReview === 'object' ? mediaInfoReview : {};
const candidates = [
context?.mediaProfile,
context?.media_profile,
review?.mediaProfile,
review?.media_profile,
device?.mediaProfile,
device?.media_profile,
device?.profile,
device?.type
];
for (const candidate of candidates) {
const normalized = normalizeMediaProfile(candidate);
if (normalized) {
return normalized;
}
}
return null;
}
function isBurnedSubtitleTrack(track) {
const flags = Array.isArray(track?.subtitlePreviewFlags)
? track.subtitlePreviewFlags
@@ -115,6 +155,7 @@ function isBurnedSubtitleTrack(track) {
function buildDefaultTrackSelection(review) {
const titles = Array.isArray(review?.titles) ? review.titles : [];
const selection = {};
const reviewEncodeInputTitleId = normalizeTitleId(review?.encodeInputTitleId);
for (const title of titles) {
const titleId = normalizeTitleId(title?.id);
@@ -122,15 +163,27 @@ function buildDefaultTrackSelection(review) {
continue;
}
const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : [];
const subtitleTracks = Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [];
const isEncodeInputTitle = Boolean(
title?.selectedForEncode
|| title?.encodeInput
|| (reviewEncodeInputTitleId && reviewEncodeInputTitleId === titleId)
);
const audioSelectionSource = isEncodeInputTitle
? audioTracks.filter((track) => Boolean(track?.selectedForEncode))
: audioTracks.filter((track) => Boolean(track?.selectedByRule));
const subtitleSelectionSource = isEncodeInputTitle
? subtitleTracks.filter((track) => Boolean(track?.selectedForEncode))
: subtitleTracks.filter((track) => Boolean(track?.selectedByRule));
selection[titleId] = {
audioTrackIds: normalizeTrackIdList(
(Array.isArray(title?.audioTracks) ? title.audioTracks : [])
.filter((track) => Boolean(track?.selectedByRule))
.map((track) => track?.id)
audioSelectionSource.map((track) => track?.id)
),
subtitleTrackIds: normalizeTrackIdList(
(Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [])
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track))
subtitleSelectionSource
.filter((track) => !isBurnedSubtitleTrack(track))
.map((track) => track?.id)
)
};
@@ -235,10 +288,9 @@ export default function PipelineStatusCard({
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
const playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null;
const reviewConfirmed = Boolean(pipeline?.context?.reviewConfirmed || mediaInfoReview?.reviewConfirmed);
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
const jobMediaProfile = String(pipeline?.context?.mediaProfile || '').trim().toLowerCase() || null;
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
@@ -319,8 +371,16 @@ export default function PipelineStatusCard({
...normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
...normChain(mediaInfoReview?.postEncodeChainIds).map((id) => ({ type: 'chain', id }))
]);
setSelectedUserPresetId(null);
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
const userPresetId = Number(mediaInfoReview?.userPreset?.id);
setSelectedUserPresetId(Number.isFinite(userPresetId) && userPresetId > 0 ? Math.trunc(userPresetId) : null);
}, [
mediaInfoReview?.encodeInputTitleId,
mediaInfoReview?.generatedAt,
mediaInfoReview?.reviewConfirmedAt,
mediaInfoReview?.prefilledFromPreviousRunAt,
mediaInfoReview?.userPreset?.id,
retryJobId
]);
useEffect(() => {
const currentTitleId = normalizeTitleId(selectedEncodeTitleId);
@@ -348,10 +408,11 @@ export default function PipelineStatusCard({
// Filter user presets by job media profile ('all' presets always shown)
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
const presetMediaType = normalizeMediaProfile(p?.mediaType) || 'all';
if (!jobMediaProfile) {
return true;
}
return p.mediaType === 'all' || p.mediaType === jobMediaProfile;
return presetMediaType === 'all' || presetMediaType === jobMediaProfile;
});
const canStartReadyJob = isPreRipReview
? Boolean(retryJobId)
@@ -592,12 +653,6 @@ export default function PipelineStatusCard({
icon="pi pi-play"
severity="success"
onClick={async () => {
const requiresAutoConfirm = !reviewConfirmed;
if (!requiresAutoConfirm) {
await onStart(retryJobId);
return;
}
const {
encodeTitleId,
selectedTrackSelection,

View File

@@ -82,6 +82,103 @@ function formatUpdatedAt(value) {
return date.toLocaleString('de-DE');
}
function formatDurationMs(value) {
const ms = Number(value);
if (!Number.isFinite(ms) || ms < 0) {
return '-';
}
if (ms < 1000) {
return `${Math.round(ms)} ms`;
}
const seconds = Math.round(ms / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const restSeconds = seconds % 60;
return `${minutes}m ${restSeconds}s`;
}
function normalizeRuntimeActivitiesPayload(rawPayload) {
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
const normalizeItem = (item) => {
const source = item && typeof item === 'object' ? item : {};
const parsedId = Number(source.id);
const id = Number.isFinite(parsedId) && parsedId > 0 ? Math.trunc(parsedId) : null;
return {
id,
type: String(source.type || '').trim().toLowerCase() || 'task',
name: String(source.name || '').trim() || '-',
status: String(source.status || '').trim().toLowerCase() || 'running',
outcome: String(source.outcome || '').trim().toLowerCase() || null,
source: String(source.source || '').trim() || null,
message: String(source.message || '').trim() || null,
errorMessage: String(source.errorMessage || '').trim() || null,
currentStep: String(source.currentStep || '').trim() || null,
currentScriptName: String(source.currentScriptName || '').trim() || null,
output: source.output != null ? String(source.output) : null,
stdout: source.stdout != null ? String(source.stdout) : null,
stderr: source.stderr != null ? String(source.stderr) : null,
stdoutTruncated: Boolean(source.stdoutTruncated),
stderrTruncated: Boolean(source.stderrTruncated),
exitCode: Number.isFinite(Number(source.exitCode)) ? Number(source.exitCode) : null,
startedAt: source.startedAt || null,
finishedAt: source.finishedAt || null,
durationMs: Number.isFinite(Number(source.durationMs)) ? Number(source.durationMs) : null,
jobId: Number.isFinite(Number(source.jobId)) && Number(source.jobId) > 0 ? Math.trunc(Number(source.jobId)) : null,
cronJobId: Number.isFinite(Number(source.cronJobId)) && Number(source.cronJobId) > 0 ? Math.trunc(Number(source.cronJobId)) : null,
canCancel: Boolean(source.canCancel),
canNextStep: Boolean(source.canNextStep)
};
};
const active = (Array.isArray(payload.active) ? payload.active : []).map(normalizeItem);
const recent = (Array.isArray(payload.recent) ? payload.recent : []).map(normalizeItem);
return {
active,
recent,
updatedAt: payload.updatedAt || null
};
}
function runtimeTypeLabel(type) {
const normalized = String(type || '').trim().toLowerCase();
if (normalized === 'script') return 'Skript';
if (normalized === 'chain') return 'Kette';
if (normalized === 'cron') return 'Cronjob';
return normalized || 'Task';
}
function runtimeStatusMeta(status) {
const normalized = String(status || '').trim().toLowerCase();
if (normalized === 'running') return { label: 'Läuft', severity: 'warning' };
if (normalized === 'success') return { label: 'Abgeschlossen', severity: 'success' };
if (normalized === 'error') return { label: 'Fehler', severity: 'danger' };
return { label: normalized || '-', severity: 'secondary' };
}
function runtimeOutcomeMeta(outcome, status) {
const normalized = String(outcome || '').trim().toLowerCase();
if (normalized === 'success') return { label: 'Erfolg', severity: 'success' };
if (normalized === 'error') return { label: 'Fehler', severity: 'danger' };
if (normalized === 'cancelled') return { label: 'Abgebrochen', severity: 'warning' };
if (normalized === 'skipped') return { label: 'Übersprungen', severity: 'info' };
return runtimeStatusMeta(status);
}
function hasRuntimeOutputDetails(item) {
if (!item || typeof item !== 'object') {
return false;
}
const hasRelevantExitCode = Number.isFinite(Number(item.exitCode)) && Number(item.exitCode) !== 0;
return Boolean(
String(item.errorMessage || '').trim()
|| String(item.output || '').trim()
|| String(item.stdout || '').trim()
|| String(item.stderr || '').trim()
|| hasRelevantExitCode
);
}
function normalizeHardwareMonitoringPayload(rawPayload) {
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
return {
@@ -172,6 +269,71 @@ function queueEntryLabel(item) {
return item.title || `Job #${item.jobId}`;
}
function normalizeQueueNameList(values) {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const item of list) {
const name = String(item || '').trim();
if (!name) {
continue;
}
const key = name.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(name);
}
return output;
}
function normalizeQueueScriptSummary(item) {
const source = item?.scriptSummary && typeof item.scriptSummary === 'object' ? item.scriptSummary : {};
return {
preScripts: normalizeQueueNameList(source.preScripts),
postScripts: normalizeQueueNameList(source.postScripts),
preChains: normalizeQueueNameList(source.preChains),
postChains: normalizeQueueNameList(source.postChains)
};
}
function hasQueueScriptSummary(item) {
const summary = normalizeQueueScriptSummary(item);
return summary.preScripts.length > 0
|| summary.postScripts.length > 0
|| summary.preChains.length > 0
|| summary.postChains.length > 0;
}
function QueueJobScriptSummary({ item }) {
const summary = normalizeQueueScriptSummary(item);
const groups = [
{ key: 'pre-scripts', icon: 'pi pi-code', label: 'Pre-Encode Skripte', values: summary.preScripts },
{ key: 'post-scripts', icon: 'pi pi-code', label: 'Post-Encode Skripte', values: summary.postScripts },
{ key: 'pre-chains', icon: 'pi pi-link', label: 'Pre-Encode Ketten', values: summary.preChains },
{ key: 'post-chains', icon: 'pi pi-link', label: 'Post-Encode Ketten', values: summary.postChains }
].filter((group) => group.values.length > 0);
if (groups.length === 0) {
return null;
}
return (
<div className="queue-job-script-details">
{groups.map((group) => {
const text = group.values.join(' | ');
return (
<div key={group.key} className="queue-job-script-group">
<strong><i className={group.icon} /> {group.label}</strong>
<small title={text}>{text}</small>
</div>
);
})}
</div>
);
}
function getAnalyzeContext(job) {
return job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object'
? job.makemkvInfo.analyzeContext
@@ -397,10 +559,14 @@ export default function DashboardPage({
const [draggingQueueEntryId, setDraggingQueueEntryId] = useState(null);
const [insertQueueDialog, setInsertQueueDialog] = useState({ visible: false, afterEntryId: null });
const [liveJobLog, setLiveJobLog] = useState('');
const [runtimeActivities, setRuntimeActivities] = useState(() => normalizeRuntimeActivitiesPayload(null));
const [runtimeLoading, setRuntimeLoading] = useState(false);
const [runtimeActionBusyKeys, setRuntimeActionBusyKeys] = useState(() => new Set());
const [jobsLoading, setJobsLoading] = useState(false);
const [dashboardJobs, setDashboardJobs] = useState([]);
const [expandedJobId, setExpandedJobId] = useState(undefined);
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
const toastRef = useRef(null);
@@ -438,7 +604,11 @@ export default function DashboardPage({
setJobsLoading(true);
try {
const [jobsResponse, queueResponse] = await Promise.allSettled([
api.getJobs(),
api.getJobs({
statuses: Array.from(dashboardStatuses),
limit: 160,
lite: true
}),
api.getPipelineQueue()
]);
const allJobs = jobsResponse.status === 'fulfilled'
@@ -453,7 +623,7 @@ export default function DashboardPage({
if (currentPipelineJobId && !next.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
try {
const active = await api.getJob(currentPipelineJobId);
const active = await api.getJob(currentPipelineJobId, { lite: true });
if (active?.job) {
next.unshift(active.job);
}
@@ -501,6 +671,35 @@ export default function DashboardPage({
void loadDashboardJobs();
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId]);
useEffect(() => {
let cancelled = false;
const load = async (silent = false) => {
try {
const response = await api.getRuntimeActivities();
if (!cancelled) {
setRuntimeActivities(normalizeRuntimeActivitiesPayload(response));
if (!silent) {
setRuntimeLoading(false);
}
}
} catch (_error) {
if (!cancelled && !silent) {
setRuntimeActivities(normalizeRuntimeActivitiesPayload(null));
setRuntimeLoading(false);
}
}
};
setRuntimeLoading(true);
void load(false);
const interval = setInterval(() => {
void load(true);
}, 2500);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
useEffect(() => {
const normalizedExpanded = normalizeJobId(expandedJobId);
const hasExpanded = dashboardJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
@@ -529,7 +728,7 @@ export default function DashboardPage({
let cancelled = false;
const refreshLiveLog = async () => {
try {
const response = await api.getJob(currentPipelineJobId, { includeLiveLog: true });
const response = await api.getJob(currentPipelineJobId, { includeLiveLog: true, lite: true });
if (!cancelled) {
setLiveJobLog(response?.job?.log || '');
}
@@ -730,19 +929,55 @@ export default function DashboardPage({
await api.cancelPipeline(cancelledJobId);
await refreshPipeline();
await loadDashboardJobs();
if (cancelledState === 'ENCODING' && cancelledJobId) {
let latestCancelledJob = null;
const fetchLatestCancelledJob = async () => {
if (!cancelledJobId) {
return null;
}
try {
const latestResponse = await api.getJob(cancelledJobId, { lite: true });
return latestResponse?.job && typeof latestResponse.job === 'object'
? latestResponse.job
: null;
} catch (_error) {
return null;
}
};
latestCancelledJob = await fetchLatestCancelledJob();
if (cancelledState === 'ENCODING') {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
for (let attempt = 0; attempt < 8; attempt += 1) {
const latestStatus = String(
latestCancelledJob?.status
|| latestCancelledJob?.last_state
|| ''
).trim().toUpperCase();
if (latestStatus && latestStatus !== 'ENCODING') {
break;
}
await wait(250);
latestCancelledJob = await fetchLatestCancelledJob();
}
}
const latestStatus = String(
latestCancelledJob?.status
|| latestCancelledJob?.last_state
|| ''
).trim().toUpperCase();
const autoPreparedForRestart = cancelledState === 'ENCODING' && latestStatus === 'READY_TO_ENCODE';
if (cancelledState === 'ENCODING' && cancelledJobId && !autoPreparedForRestart) {
setCancelCleanupDialog({
visible: true,
jobId: cancelledJobId,
target: 'movie',
path: cancelledJob?.output_path || null
path: latestCancelledJob?.output_path || cancelledJob?.output_path || null
});
} else if (cancelledState === 'RIPPING' && cancelledJobId) {
setCancelCleanupDialog({
visible: true,
jobId: cancelledJobId,
target: 'raw',
path: cancelledJob?.raw_path || null
path: latestCancelledJob?.raw_path || cancelledJob?.raw_path || null
});
}
} catch (error) {
@@ -797,16 +1032,27 @@ export default function DashboardPage({
setJobBusy(normalizedJobId, true);
try {
if (startOptions.ensureConfirmed) {
await api.confirmEncodeReview(normalizedJobId, {
const confirmPayload = {
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
selectedPostEncodeScriptIds: startOptions.selectedPostEncodeScriptIds ?? [],
selectedPreEncodeScriptIds: startOptions.selectedPreEncodeScriptIds ?? [],
selectedPostEncodeChainIds: startOptions.selectedPostEncodeChainIds ?? [],
selectedPreEncodeChainIds: startOptions.selectedPreEncodeChainIds ?? [],
selectedUserPresetId: startOptions.selectedUserPresetId ?? null,
skipPipelineStateUpdate: true
});
};
if (startOptions.selectedPostEncodeScriptIds !== undefined) {
confirmPayload.selectedPostEncodeScriptIds = startOptions.selectedPostEncodeScriptIds;
}
if (startOptions.selectedPreEncodeScriptIds !== undefined) {
confirmPayload.selectedPreEncodeScriptIds = startOptions.selectedPreEncodeScriptIds;
}
if (startOptions.selectedPostEncodeChainIds !== undefined) {
confirmPayload.selectedPostEncodeChainIds = startOptions.selectedPostEncodeChainIds;
}
if (startOptions.selectedPreEncodeChainIds !== undefined) {
confirmPayload.selectedPreEncodeChainIds = startOptions.selectedPreEncodeChainIds;
}
if (startOptions.selectedUserPresetId !== undefined) {
confirmPayload.selectedUserPresetId = startOptions.selectedUserPresetId;
}
await api.confirmEncodeReview(normalizedJobId, confirmPayload);
}
const response = await api.startJob(normalizedJobId);
const result = getQueueActionResult(response);
@@ -1083,6 +1329,49 @@ export default function DashboardPage({
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
const canReorderQueue = queuedJobs.length > 1 && !queueReorderBusy;
const buildRunningQueueScriptKey = (jobId) => `running-${normalizeJobId(jobId) || '-'}`;
const buildQueuedQueueScriptKey = (entryId) => `queued-${Number(entryId) || '-'}`;
const toggleQueueScriptDetails = (key) => {
if (!key) {
return;
}
setExpandedQueueScriptKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
useEffect(() => {
const validKeys = new Set();
for (const item of queueRunningJobs) {
if (!hasQueueScriptSummary(item)) {
continue;
}
validKeys.add(buildRunningQueueScriptKey(item?.jobId));
}
for (const item of queuedJobs) {
if (String(item?.type || 'job') !== 'job' || !hasQueueScriptSummary(item)) {
continue;
}
validKeys.add(buildQueuedQueueScriptKey(item?.entryId));
}
setExpandedQueueScriptKeys((prev) => {
let changed = false;
const next = new Set();
for (const key of prev) {
if (validKeys.has(key)) {
next.add(key);
} else {
changed = true;
}
}
return changed ? next : prev;
});
}, [queueRunningJobs, queuedJobs]);
const queuedJobIdSet = useMemo(() => {
const set = new Set();
for (const item of queuedJobs) {
@@ -1094,6 +1383,66 @@ export default function DashboardPage({
return set;
}, [queuedJobs]);
const setRuntimeActionBusy = (activityId, action, busyFlag) => {
const key = `${Number(activityId) || 0}:${String(action || '')}`;
setRuntimeActionBusyKeys((prev) => {
const next = new Set(prev);
if (busyFlag) {
next.add(key);
} else {
next.delete(key);
}
return next;
});
};
const isRuntimeActionBusy = (activityId, action) => runtimeActionBusyKeys.has(
`${Number(activityId) || 0}:${String(action || '')}`
);
const handleRuntimeControl = async (item, action) => {
const activityId = Number(item?.id);
if (!Number.isFinite(activityId) || activityId <= 0) {
return;
}
const normalizedAction = String(action || '').trim().toLowerCase();
const actionLabel = normalizedAction === 'next-step' ? 'Nächster Schritt' : 'Abbrechen';
setRuntimeActionBusy(activityId, normalizedAction, true);
try {
const response = normalizedAction === 'next-step'
? await api.requestRuntimeNextStep(activityId)
: await api.cancelRuntimeActivity(activityId, { reason: 'Benutzerabbruch via Dashboard' });
if (response?.snapshot) {
setRuntimeActivities(normalizeRuntimeActivitiesPayload(response.snapshot));
} else {
const fresh = await api.getRuntimeActivities();
setRuntimeActivities(normalizeRuntimeActivitiesPayload(fresh));
}
const accepted = response?.action?.accepted !== false;
const actionMessage = String(response?.action?.message || '').trim();
toastRef.current?.show({
severity: accepted ? 'info' : 'warn',
summary: actionLabel,
detail: actionMessage || (accepted ? 'Aktion ausgelöst.' : 'Aktion aktuell nicht möglich.'),
life: 2600
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: actionLabel,
detail: error?.message || 'Aktion fehlgeschlagen.',
life: 3200
});
} finally {
setRuntimeActionBusy(activityId, normalizedAction, false);
}
};
const runtimeActiveItems = Array.isArray(runtimeActivities?.active) ? runtimeActivities.active : [];
const runtimeRecentItems = Array.isArray(runtimeActivities?.recent)
? runtimeActivities.recent.slice(0, 8)
: [];
return (
<div className="page-grid">
<Toast ref={toastRef} />
@@ -1288,12 +1637,37 @@ export default function DashboardPage({
{queueRunningJobs.length === 0 ? (
<small>Keine laufenden Jobs.</small>
) : (
queueRunningJobs.map((item) => (
<div key={`running-${item.jobId}`} className="pipeline-queue-item running">
<strong>#{item.jobId} | {item.title || `Job #${item.jobId}`}</strong>
<small>{getStatusLabel(item.status)}</small>
</div>
))
queueRunningJobs.map((item) => {
const hasScriptSummary = hasQueueScriptSummary(item);
const detailKey = buildRunningQueueScriptKey(item?.jobId);
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
return (
<div key={`running-${item.jobId}`} className="pipeline-queue-entry-wrap">
<div className="pipeline-queue-item running queue-job-item">
<div className="pipeline-queue-item-main">
<strong>
#{item.jobId} | {item.title || `Job #${item.jobId}`}
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
</strong>
<small>{getStatusLabel(item.status)}</small>
</div>
{hasScriptSummary ? (
<button
type="button"
className="queue-job-expand-btn"
aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
aria-expanded={detailsExpanded}
onClick={() => toggleQueueScriptDetails(detailKey)}
>
<i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
</button>
) : null}
</div>
{detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
</div>
);
})
)}
</div>
<div className="pipeline-queue-col">
@@ -1316,6 +1690,9 @@ export default function DashboardPage({
const entryId = Number(item?.entryId);
const isNonJob = item.type && item.type !== 'job';
const isDragging = Number(draggingQueueEntryId) === entryId;
const hasScriptSummary = !isNonJob && hasQueueScriptSummary(item);
const detailKey = buildQueuedQueueScriptKey(entryId);
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
return (
<div key={`queued-entry-${entryId}`} className="pipeline-queue-entry-wrap">
<div
@@ -1351,25 +1728,43 @@ export default function DashboardPage({
</>
)}
</div>
<Button
icon="pi pi-times"
severity="danger"
text
rounded
size="small"
className="pipeline-queue-remove-btn"
disabled={queueReorderBusy}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (isNonJob) {
void handleRemoveQueueEntry(entryId);
} else {
void handleRemoveQueuedJob(item.jobId);
}
}}
/>
<div className="pipeline-queue-item-actions">
{hasScriptSummary ? (
<button
type="button"
className="queue-job-expand-btn"
aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
aria-expanded={detailsExpanded}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
toggleQueueScriptDetails(detailKey);
}}
>
<i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
</button>
) : null}
<Button
icon="pi pi-times"
severity="danger"
text
rounded
size="small"
className="pipeline-queue-remove-btn"
disabled={queueReorderBusy}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (isNonJob) {
void handleRemoveQueueEntry(entryId);
} else {
void handleRemoveQueuedJob(item.jobId);
}
}}
/>
</div>
</div>
{detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
<button
type="button"
className="queue-insert-btn"
@@ -1387,6 +1782,150 @@ export default function DashboardPage({
</div>
</Card>
<Card title="Skript- / Cron-Status" subTitle="Laufende und zuletzt abgeschlossene Skript-, Ketten- und Cron-Ausführungen.">
<div className="runtime-activity-meta">
<Tag value={`Laufend: ${runtimeActiveItems.length}`} severity={runtimeActiveItems.length > 0 ? 'warning' : 'success'} />
<Tag value={`Zuletzt: ${runtimeRecentItems.length}`} severity="info" />
<Tag value={`Update: ${formatUpdatedAt(runtimeActivities?.updatedAt)}`} severity="secondary" />
</div>
{runtimeLoading && runtimeActiveItems.length === 0 && runtimeRecentItems.length === 0 ? (
<p>Aktivitäten werden geladen ...</p>
) : (
<div className="runtime-activity-grid">
<div className="runtime-activity-col">
<h4>Aktiv</h4>
{runtimeActiveItems.length === 0 ? (
<small>Keine laufenden Skript-/Ketten-/Cron-Ausführungen.</small>
) : (
<div className="runtime-activity-list">
{runtimeActiveItems.map((item, index) => {
const statusMeta = runtimeStatusMeta(item?.status);
const canCancel = Boolean(item?.canCancel);
const canNextStep = String(item?.type || '').trim().toLowerCase() === 'chain' && Boolean(item?.canNextStep);
const cancelBusy = isRuntimeActionBusy(item?.id, 'cancel');
const nextStepBusy = isRuntimeActionBusy(item?.id, 'next-step');
return (
<div key={`runtime-active-${item?.id || index}`} className="runtime-activity-item">
<div className="runtime-activity-head">
<strong>{item?.name || '-'}</strong>
<div className="runtime-activity-tags">
<Tag value={runtimeTypeLabel(item?.type)} severity="info" />
<Tag value={statusMeta.label} severity={statusMeta.severity} />
</div>
</div>
<small>
Quelle: {item?.source || '-'}
{item?.jobId ? ` | Job #${item.jobId}` : ''}
{item?.cronJobId ? ` | Cron #${item.cronJobId}` : ''}
</small>
{item?.currentStep ? <small>Schritt: {item.currentStep}</small> : null}
{item?.currentScriptName ? <small>Laufendes Skript: {item.currentScriptName}</small> : null}
{item?.message ? <small>{item.message}</small> : null}
<small>Gestartet: {formatUpdatedAt(item?.startedAt)}</small>
{canCancel || canNextStep ? (
<div className="runtime-activity-actions">
{canNextStep ? (
<Button
type="button"
icon="pi pi-step-forward"
label="Nächster Schritt"
outlined
severity="secondary"
size="small"
loading={nextStepBusy}
disabled={cancelBusy}
onClick={() => {
void handleRuntimeControl(item, 'next-step');
}}
/>
) : null}
{canCancel ? (
<Button
type="button"
icon="pi pi-stop"
label="Abbrechen"
outlined
severity="danger"
size="small"
loading={cancelBusy}
disabled={nextStepBusy}
onClick={() => {
void handleRuntimeControl(item, 'cancel');
}}
/>
) : null}
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
<div className="runtime-activity-col">
<h4>Zuletzt abgeschlossen</h4>
{runtimeRecentItems.length === 0 ? (
<small>Keine abgeschlossenen Einträge vorhanden.</small>
) : (
<div className="runtime-activity-list">
{runtimeRecentItems.map((item, index) => {
const outcomeMeta = runtimeOutcomeMeta(item?.outcome, item?.status);
return (
<div key={`runtime-recent-${item?.id || index}`} className="runtime-activity-item done">
<div className="runtime-activity-head">
<strong>{item?.name || '-'}</strong>
<div className="runtime-activity-tags">
<Tag value={runtimeTypeLabel(item?.type)} severity="info" />
<Tag value={outcomeMeta.label} severity={outcomeMeta.severity} />
</div>
</div>
<small>
Quelle: {item?.source || '-'}
{item?.jobId ? ` | Job #${item.jobId}` : ''}
{item?.cronJobId ? ` | Cron #${item.cronJobId}` : ''}
</small>
{Number.isFinite(Number(item?.exitCode)) ? <small>Exit-Code: {item.exitCode}</small> : null}
{item?.message ? <small>{item.message}</small> : null}
{item?.errorMessage ? <small className="error-text">{item.errorMessage}</small> : null}
{hasRuntimeOutputDetails(item) ? (
<details className="runtime-activity-details">
<summary>Details anzeigen</summary>
{item?.output ? (
<div className="runtime-activity-details-block">
<small><strong>Ausgabe:</strong></small>
<pre>{item.output}</pre>
</div>
) : null}
{item?.stderr ? (
<div className="runtime-activity-details-block">
<small><strong>stderr:</strong>{item?.stderrTruncated ? ' (gekürzt)' : ''}</small>
<pre>{item.stderr}</pre>
</div>
) : null}
{item?.stdout ? (
<div className="runtime-activity-details-block">
<small><strong>stdout:</strong>{item?.stdoutTruncated ? ' (gekürzt)' : ''}</small>
<pre>{item.stdout}</pre>
</div>
) : null}
</details>
) : null}
<small>
Ende: {formatUpdatedAt(item?.finishedAt || item?.startedAt)}
{item?.durationMs != null ? ` | Dauer: ${formatDurationMs(item.durationMs)}` : ''}
</small>
</div>
);
})}
</div>
)}
</div>
</div>
)}
</Card>
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
{jobsLoading ? (
<p>Jobs werden geladen ...</p>

View File

@@ -6,6 +6,7 @@ import { Dialog } from 'primereact/dialog';
import { TabView, TabPanel } from 'primereact/tabview';
import { InputText } from 'primereact/inputtext';
import { InputTextarea } from 'primereact/inputtextarea';
import { Dropdown } from 'primereact/dropdown';
import { api } from '../api/client';
import DynamicSettingsForm from '../components/DynamicSettingsForm';
import CronJobsTab from '../components/CronJobsTab';
@@ -51,6 +52,58 @@ function reorderListById(items, sourceId, targetIndex) {
return { changed: true, next };
}
function buildHandBrakePresetSelectOptions(sourceOptions, extraValues = []) {
const rawOptions = Array.isArray(sourceOptions) ? sourceOptions : [];
const rawExtraValues = Array.isArray(extraValues) ? extraValues : [];
const normalizedOptions = [];
const seenValues = new Set();
const seenGroupLabels = new Set();
const addGroupOption = (option) => {
const rawLabel = String(option?.label || '').trim();
if (!rawLabel || seenGroupLabels.has(rawLabel)) {
return;
}
seenGroupLabels.add(rawLabel);
normalizedOptions.push({
...option,
label: rawLabel,
value: String(option?.value || `__group__${rawLabel.toLowerCase().replace(/\s+/g, '_')}`),
disabled: true
});
};
const addSelectableOption = (optionValue, optionLabel = optionValue, option = null) => {
const value = String(optionValue || '').trim();
if (seenValues.has(value)) {
return;
}
seenValues.add(value);
normalizedOptions.push({
...(option && typeof option === 'object' ? option : {}),
label: String(optionLabel ?? value),
value,
disabled: false
});
};
normalizedOptions.push({ label: '(kein Preset nur CLI-Parameter)', value: '', disabled: false });
seenValues.add('');
for (const option of rawOptions) {
if (option?.disabled) {
addGroupOption(option);
continue;
}
addSelectableOption(option?.value, option?.label, option);
}
for (const value of rawExtraValues) {
addSelectableOption(value);
}
return normalizedOptions;
}
function injectHandBrakePresetOptions(categories, presetPayload) {
const list = Array.isArray(categories) ? categories : [];
const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : [];
@@ -62,50 +115,10 @@ function injectHandBrakePresetOptions(categories, presetPayload) {
if (!presetSettingKeys.has(String(setting?.key || '').trim().toLowerCase())) {
return setting;
}
const normalizedOptions = [];
const seenValues = new Set();
const seenGroupLabels = new Set();
const addGroupOption = (option) => {
const rawLabel = String(option?.label || '').trim();
if (!rawLabel || seenGroupLabels.has(rawLabel)) {
return;
}
seenGroupLabels.add(rawLabel);
normalizedOptions.push({
...option,
label: rawLabel,
value: String(option?.value || `__group__${rawLabel.toLowerCase().replace(/\s+/g, '_')}`),
disabled: true
});
};
const addSelectableOption = (optionValue, optionLabel = optionValue, option = null) => {
const value = String(optionValue || '').trim();
if (!value || seenValues.has(value)) {
return;
}
seenValues.add(value);
normalizedOptions.push({
...(option && typeof option === 'object' ? option : {}),
label: String(optionLabel ?? value),
value,
disabled: false
});
};
// "(kein Preset)" immer als erste Option — ermöglicht reinen CLI-Betrieb
normalizedOptions.push({ label: '(kein Preset nur CLI-Parameter)', value: '', disabled: false });
seenValues.add('');
for (const option of sourceOptions) {
if (option?.disabled) {
addGroupOption(option);
continue;
}
addSelectableOption(option?.value, option?.label, option);
}
addSelectableOption(setting?.value);
addSelectableOption(setting?.defaultValue);
const normalizedOptions = buildHandBrakePresetSelectOptions(sourceOptions, [
setting?.value,
setting?.defaultValue
]);
if (normalizedOptions.length <= 1) {
return setting;
@@ -170,9 +183,18 @@ export default function SettingsPage() {
description: ''
});
const [userPresetErrors, setUserPresetErrors] = useState({});
const [handBrakePresetSourceOptions, setHandBrakePresetSourceOptions] = useState([]);
const toastRef = useRef(null);
const userPresetHandBrakeOptions = useMemo(
() => buildHandBrakePresetSelectOptions(
handBrakePresetSourceOptions,
[userPresetEditor.handbrakePreset]
),
[handBrakePresetSourceOptions, userPresetEditor.handbrakePreset]
);
const loadScripts = async ({ silent = false } = {}) => {
if (!silent) {
setScriptsLoading(true);
@@ -298,37 +320,18 @@ export default function SettingsPage() {
const load = async () => {
setLoading(true);
try {
const [settingsResponse, presetsResponse, scriptsResponse, chainsResponse] = await Promise.allSettled([
api.getSettings(),
api.getHandBrakePresets(),
api.getScripts(),
api.getScriptChains()
]);
if (settingsResponse.status !== 'fulfilled') {
throw settingsResponse.reason;
}
let nextCategories = settingsResponse.value?.categories || [];
const presetPayload = presetsResponse.status === 'fulfilled' ? presetsResponse.value : null;
nextCategories = injectHandBrakePresetOptions(nextCategories, presetPayload);
if (presetsResponse.status === 'fulfilled' && presetsResponse.value?.message) {
toastRef.current?.show({
severity: presetsResponse.value?.source === 'fallback' ? 'warn' : 'info',
summary: 'HandBrake Presets',
detail: presetsResponse.value.message
});
}
if (presetsResponse.status === 'rejected') {
toastRef.current?.show({
severity: 'warn',
summary: 'HandBrake Presets',
detail: 'Preset-Liste konnte nicht geladen werden. Aktueller Wert bleibt auswählbar.'
});
}
const settingsResponse = await api.getSettings();
let nextCategories = settingsResponse?.categories || [];
const values = buildValuesMap(nextCategories);
setCategories(nextCategories);
setInitialValues(values);
setDraftValues(values);
setErrors({});
const presetsPromise = api.getHandBrakePresets();
const scriptsPromise = api.getScripts();
const chainsPromise = api.getScriptChains();
const [scriptsResponse, chainsResponse] = await Promise.allSettled([scriptsPromise, chainsPromise]);
if (scriptsResponse.status === 'fulfilled') {
setScripts(Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []);
} else {
@@ -341,6 +344,27 @@ export default function SettingsPage() {
if (chainsResponse.status === 'fulfilled') {
setChains(Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []);
}
presetsPromise
.then((presetPayload) => {
setHandBrakePresetSourceOptions(Array.isArray(presetPayload?.options) ? presetPayload.options : []);
setCategories((prevCategories) => injectHandBrakePresetOptions(prevCategories, presetPayload));
if (presetPayload?.message) {
toastRef.current?.show({
severity: presetPayload?.source === 'fallback' ? 'warn' : 'info',
summary: 'HandBrake Presets',
detail: presetPayload.message
});
}
})
.catch(() => {
setHandBrakePresetSourceOptions([]);
toastRef.current?.show({
severity: 'warn',
summary: 'HandBrake Presets',
detail: 'Preset-Liste konnte nicht geladen werden. Aktueller Wert bleibt auswählbar.'
});
});
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
} finally {
@@ -1511,44 +1535,36 @@ export default function SettingsPage() {
) : userPresets.length === 0 ? (
<p style={{ marginTop: '1rem' }}>Keine Presets vorhanden. Lege ein neues Preset an.</p>
) : (
<div className="script-list" style={{ marginTop: '1rem' }}>
<div className="script-list script-list--reorderable" style={{ marginTop: '1rem' }}>
{userPresets.map((preset) => (
<div key={preset.id} className="script-list-item">
<div className="script-list-main">
<div className="script-title-line">
<strong>#{preset.id} {preset.name}</strong>
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', opacity: 0.7 }}>
<strong className="script-id-title">#{preset.id} - {preset.name}</strong>
<span className="preset-media-type-tag">
{preset.mediaType === 'bluray' ? 'Blu-ray'
: preset.mediaType === 'dvd' ? 'DVD'
: preset.mediaType === 'other' ? 'Sonstiges'
: 'Universell'}
</span>
</div>
{preset.description && <small style={{ display: 'block', marginTop: '0.2rem', opacity: 0.8 }}>{preset.description}</small>}
<div style={{ marginTop: '0.3rem', fontFamily: 'monospace', fontSize: '0.8rem' }}>
{preset.handbrakePreset
? <span><span style={{ opacity: 0.6 }}>Preset:</span> {preset.handbrakePreset}</span>
: <span style={{ opacity: 0.5 }}>(kein Preset-Name)</span>}
{preset.extraArgs && (
<span style={{ marginLeft: '1rem' }}><span style={{ opacity: 0.6 }}>Args:</span> {preset.extraArgs}</span>
)}
</div>
<small className="preset-description-line" title={preset.description || ''}>
{preset.description || '-'}
</small>
</div>
<div className="script-list-actions">
<div className="script-list-actions script-list-actions--two">
<Button
icon="pi pi-pencil"
label="Bearbeiten"
severity="secondary"
outlined
rounded
title="Bearbeiten"
onClick={() => openEditUserPreset(preset)}
/>
<Button
icon="pi pi-trash"
label="Löschen"
severity="danger"
outlined
rounded
title="Löschen"
onClick={() => handleDeleteUserPreset(preset.id)}
/>
</div>
@@ -1594,11 +1610,16 @@ export default function SettingsPage() {
<div>
<label htmlFor="preset-hb-preset" style={{ display: 'block', marginBottom: '0.3rem' }}>HandBrake Preset (-Z)</label>
<InputText
<Dropdown
id="preset-hb-preset"
value={userPresetEditor.handbrakePreset}
onChange={(e) => setUserPresetEditor((prev) => ({ ...prev, handbrakePreset: e.target.value }))}
placeholder="z.B. H.264 MKV 1080p30 (leer = kein Preset)"
options={userPresetHandBrakeOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(e) => setUserPresetEditor((prev) => ({ ...prev, handbrakePreset: String(e.value || '') }))}
placeholder="Preset auswählen"
showClear
style={{ width: '100%' }}
/>
</div>

View File

@@ -637,6 +637,12 @@ body {
border-style: solid;
}
.pipeline-queue-item.queue-job-item {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.45rem;
}
.pipeline-queue-item.queued {
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
@@ -662,6 +668,63 @@ body {
vertical-align: middle;
}
.pipeline-queue-item-actions {
display: inline-flex;
align-items: center;
gap: 0.15rem;
}
.queue-job-expand-btn {
width: 1.6rem;
height: 1.6rem;
border: 1px solid var(--rip-border);
border-radius: 0.35rem;
background: transparent;
color: var(--rip-muted);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.queue-job-expand-btn:hover {
border-color: var(--rip-brown-600);
color: var(--rip-brown-700);
background: var(--rip-surface);
}
.queue-job-script-details {
margin-top: 0.2rem;
border: 1px dashed var(--rip-border);
border-radius: 0.4rem;
background: var(--rip-panel-soft);
padding: 0.4rem 0.5rem;
display: grid;
gap: 0.3rem;
}
.queue-job-script-group {
display: grid;
gap: 0.1rem;
}
.queue-job-script-group strong {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.78rem;
color: var(--rip-muted);
}
.queue-job-script-group small {
font-size: 0.8rem;
color: var(--rip-ink);
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.pipeline-queue-entry-wrap {
display: flex;
flex-direction: column;
@@ -1258,6 +1321,10 @@ body {
gap: 0.45rem;
}
.script-list-actions--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.script-list-actions .p-button {
width: 100%;
justify-content: center;
@@ -1273,6 +1340,22 @@ body {
display: block;
}
.preset-media-type-tag {
font-size: 0.8rem;
opacity: 0.7;
white-space: nowrap;
}
.preset-description-line {
display: block;
margin-top: 0.2rem;
opacity: 0.8;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.script-editor-fields {
display: grid;
gap: 0.4rem;
@@ -1672,6 +1755,19 @@ body {
grid-column: 1 / -1;
}
.job-configured-selection-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem 0.65rem;
font-size: 0.84rem;
}
.job-configured-selection-grid > div {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.job-status-icon {
display: inline-flex;
align-items: center;
@@ -1917,7 +2013,14 @@ body {
}
.post-script-row.editable {
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-columns: auto auto minmax(0, 1fr) auto;
}
.post-script-type-icon {
color: var(--rip-muted);
font-size: 0.95rem;
width: 1rem;
text-align: center;
}
.post-script-drag-handle {
@@ -2094,6 +2197,7 @@ body {
.media-review-meta,
.media-track-grid,
.job-meta-grid,
.job-configured-selection-grid,
.job-film-info-grid,
.table-filters,
.history-dv-toolbar,
@@ -2133,7 +2237,7 @@ body {
}
.post-script-row.editable {
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-columns: auto auto minmax(0, 1fr) auto;
}
.orphan-path-cell {
@@ -2785,3 +2889,96 @@ body {
white-space: pre-wrap;
word-break: break-all;
}
.runtime-activity-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.runtime-activity-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.runtime-activity-col {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.runtime-activity-list {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.runtime-activity-item {
border: 1px solid var(--surface-border, #d8d3c6);
border-radius: 8px;
padding: 0.6rem 0.75rem;
background: var(--surface-card, #f8f6ef);
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.runtime-activity-item.done {
opacity: 0.94;
}
.runtime-activity-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.runtime-activity-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.runtime-activity-actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-top: 0.25rem;
}
.runtime-activity-details {
margin-top: 0.2rem;
}
.runtime-activity-details summary {
cursor: pointer;
font-size: 0.82rem;
color: var(--rip-muted, #666);
}
.runtime-activity-details-block {
margin-top: 0.4rem;
}
.runtime-activity-details-block pre {
margin: 0.25rem 0 0;
padding: 0.55rem;
border-radius: 6px;
border: 1px solid var(--surface-border, #d8d3c6);
background: #1f1f1f;
color: #e8e8e8;
max-height: 180px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.78rem;
}
@media (max-width: 980px) {
.runtime-activity-grid {
grid-template-columns: 1fr;
}
}