UI/Features
This commit is contained in:
@@ -220,7 +220,11 @@ function normalizeQueue(queue) {
|
||||
const queuedJobs = Array.isArray(payload.queuedJobs) ? payload.queuedJobs : [];
|
||||
return {
|
||||
maxParallelJobs: Number(payload.maxParallelJobs || 1),
|
||||
maxParallelCdEncodes: Number(payload.maxParallelCdEncodes || 2),
|
||||
maxTotalEncodes: Number(payload.maxTotalEncodes || 3),
|
||||
cdBypassesQueue: Boolean(payload.cdBypassesQueue),
|
||||
runningCount: Number(payload.runningCount || runningJobs.length || 0),
|
||||
runningCdCount: Number(payload.runningCdCount || 0),
|
||||
runningJobs,
|
||||
queuedJobs,
|
||||
queuedCount: Number(payload.queuedCount || queuedJobs.length || 0),
|
||||
@@ -348,12 +352,13 @@ function getAnalyzeContext(job) {
|
||||
}
|
||||
|
||||
function resolveMediaType(job) {
|
||||
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
||||
const candidates = [
|
||||
job?.mediaType,
|
||||
job?.media_type,
|
||||
job?.mediaProfile,
|
||||
job?.media_profile,
|
||||
job?.encodePlan?.mediaProfile,
|
||||
encodePlan?.mediaProfile,
|
||||
job?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||
job?.makemkvInfo?.mediaProfile,
|
||||
job?.mediainfoInfo?.mediaProfile
|
||||
@@ -373,6 +378,25 @@ function resolveMediaType(job) {
|
||||
return 'cd';
|
||||
}
|
||||
}
|
||||
const statusCandidates = [
|
||||
job?.status,
|
||||
job?.last_state,
|
||||
job?.makemkvInfo?.lastState
|
||||
];
|
||||
if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) {
|
||||
return 'cd';
|
||||
}
|
||||
const planFormat = String(encodePlan?.format || '').trim().toLowerCase();
|
||||
const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0;
|
||||
if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
|
||||
return 'cd';
|
||||
}
|
||||
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
|
||||
return 'cd';
|
||||
}
|
||||
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
|
||||
return 'cd';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
@@ -425,6 +449,84 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
||||
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
||||
const analyzeContext = getAnalyzeContext(job);
|
||||
const normalizePlanIdList = (values) => {
|
||||
const list = Array.isArray(values) ? values : [];
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const value of list) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
continue;
|
||||
}
|
||||
const id = Math.trunc(parsed);
|
||||
const key = String(id);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(id);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
const buildNamedSelection = (ids, entries, fallbackLabel) => {
|
||||
const source = Array.isArray(entries) ? entries : [];
|
||||
const namesById = new Map(
|
||||
source
|
||||
.map((entry) => {
|
||||
const id = Number(entry?.id ?? entry?.scriptId ?? entry?.chainId);
|
||||
const normalized = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null;
|
||||
const name = String(entry?.name || entry?.scriptName || entry?.chainName || '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return [normalized, name || null];
|
||||
})
|
||||
.filter(Boolean)
|
||||
);
|
||||
return ids.map((id) => ({
|
||||
id,
|
||||
name: namesById.get(id) || `${fallbackLabel} #${id}`
|
||||
}));
|
||||
};
|
||||
const planPreScriptIds = normalizePlanIdList([
|
||||
...(Array.isArray(encodePlan?.preEncodeScriptIds) ? encodePlan.preEncodeScriptIds : []),
|
||||
...(Array.isArray(encodePlan?.preEncodeScripts) ? encodePlan.preEncodeScripts.map((entry) => entry?.id ?? entry?.scriptId) : [])
|
||||
]);
|
||||
const planPostScriptIds = normalizePlanIdList([
|
||||
...(Array.isArray(encodePlan?.postEncodeScriptIds) ? encodePlan.postEncodeScriptIds : []),
|
||||
...(Array.isArray(encodePlan?.postEncodeScripts) ? encodePlan.postEncodeScripts.map((entry) => entry?.id ?? entry?.scriptId) : [])
|
||||
]);
|
||||
const planPreChainIds = normalizePlanIdList([
|
||||
...(Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : []),
|
||||
...(Array.isArray(encodePlan?.preEncodeChains) ? encodePlan.preEncodeChains.map((entry) => entry?.id ?? entry?.chainId) : [])
|
||||
]);
|
||||
const planPostChainIds = normalizePlanIdList([
|
||||
...(Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : []),
|
||||
...(Array.isArray(encodePlan?.postEncodeChains) ? encodePlan.postEncodeChains.map((entry) => entry?.id ?? entry?.chainId) : [])
|
||||
]);
|
||||
const cdRipConfig = encodePlan && typeof encodePlan === 'object'
|
||||
? {
|
||||
format: String(encodePlan?.format || '').trim().toLowerCase() || null,
|
||||
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
|
||||
? encodePlan.formatOptions
|
||||
: {},
|
||||
selectedTracks: Array.isArray(encodePlan?.selectedTracks)
|
||||
? encodePlan.selectedTracks
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.trunc(value))
|
||||
: [],
|
||||
preEncodeScriptIds: planPreScriptIds,
|
||||
postEncodeScriptIds: planPostScriptIds,
|
||||
preEncodeChainIds: planPreChainIds,
|
||||
postEncodeChainIds: planPostChainIds,
|
||||
preEncodeScripts: buildNamedSelection(planPreScriptIds, encodePlan?.preEncodeScripts, 'Skript'),
|
||||
postEncodeScripts: buildNamedSelection(planPostScriptIds, encodePlan?.postEncodeScripts, 'Skript'),
|
||||
preEncodeChains: buildNamedSelection(planPreChainIds, encodePlan?.preEncodeChains, 'Kette'),
|
||||
postEncodeChains: buildNamedSelection(planPostChainIds, encodePlan?.postEncodeChains, 'Kette'),
|
||||
outputTemplate: String(encodePlan?.outputTemplate || '').trim() || null
|
||||
}
|
||||
: null;
|
||||
const cdTracks = Array.isArray(makemkvInfo?.tracks)
|
||||
? makemkvInfo.tracks
|
||||
.map((track) => {
|
||||
@@ -443,6 +545,23 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
const cdSelectedMeta = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {};
|
||||
const fallbackCdArtist = cdTracks
|
||||
.map((track) => String(track?.artist || '').trim())
|
||||
.find(Boolean) || null;
|
||||
const resolvedCdMbId = String(
|
||||
cdSelectedMeta?.mbId
|
||||
|| cdSelectedMeta?.musicBrainzId
|
||||
|| cdSelectedMeta?.musicbrainzId
|
||||
|| cdSelectedMeta?.mbid
|
||||
|| ''
|
||||
).trim() || null;
|
||||
const resolvedCdCoverUrl = String(
|
||||
cdSelectedMeta?.coverUrl
|
||||
|| cdSelectedMeta?.poster
|
||||
|| cdSelectedMeta?.posterUrl
|
||||
|| job?.poster_url
|
||||
|| ''
|
||||
).trim() || null;
|
||||
const cdparanoiaCmd = String(makemkvInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia';
|
||||
const devicePath = String(job?.disc_device || '').trim() || null;
|
||||
const firstConfiguredTrack = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0
|
||||
@@ -458,12 +577,12 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
|
||||
const selectedMetadata = {
|
||||
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
|
||||
artist: cdSelectedMeta?.artist || null,
|
||||
artist: cdSelectedMeta?.artist || fallbackCdArtist || null,
|
||||
year: cdSelectedMeta?.year ?? job?.year ?? null,
|
||||
mbId: cdSelectedMeta?.mbId || null,
|
||||
coverUrl: cdSelectedMeta?.coverUrl || null,
|
||||
mbId: resolvedCdMbId,
|
||||
coverUrl: resolvedCdCoverUrl,
|
||||
imdbId: job?.imdb_id || null,
|
||||
poster: job?.poster_url || cdSelectedMeta?.coverUrl || null
|
||||
poster: job?.poster_url || resolvedCdCoverUrl || null
|
||||
};
|
||||
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
||||
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
||||
@@ -508,11 +627,14 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
const computedContext = {
|
||||
jobId,
|
||||
rawPath: job?.raw_path || null,
|
||||
outputPath: job?.output_path || null,
|
||||
detectedTitle: job?.detected_title || null,
|
||||
mediaProfile: resolveMediaType(job),
|
||||
lastState,
|
||||
devicePath,
|
||||
cdparanoiaCmd,
|
||||
cdparanoiaCommandPreview,
|
||||
cdRipConfig,
|
||||
tracks: cdTracks,
|
||||
inputPath,
|
||||
hasEncodableTitle,
|
||||
@@ -543,6 +665,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
...computedContext,
|
||||
...existingContext,
|
||||
rawPath: existingContext.rawPath || computedContext.rawPath,
|
||||
outputPath: existingContext.outputPath || computedContext.outputPath,
|
||||
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
|
||||
? existingContext.tracks
|
||||
: computedContext.tracks,
|
||||
@@ -559,6 +682,20 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
const liveJobProgress = currentPipeline?.jobProgress && jobId
|
||||
? (currentPipeline.jobProgress[jobId] || null)
|
||||
: null;
|
||||
const liveContext = liveJobProgress?.context && typeof liveJobProgress.context === 'object'
|
||||
? liveJobProgress.context
|
||||
: null;
|
||||
const mergedContext = liveContext
|
||||
? {
|
||||
...computedContext,
|
||||
...liveContext,
|
||||
tracks: (Array.isArray(liveContext.tracks) && liveContext.tracks.length > 0)
|
||||
? liveContext.tracks
|
||||
: computedContext.tracks,
|
||||
selectedMetadata: liveContext.selectedMetadata || computedContext.selectedMetadata,
|
||||
cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig
|
||||
}
|
||||
: computedContext;
|
||||
|
||||
return {
|
||||
state: liveJobProgress?.state || jobStatus,
|
||||
@@ -566,7 +703,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
progress: liveJobProgress != null ? Number(liveJobProgress.progress ?? 0) : 0,
|
||||
eta: liveJobProgress?.eta || null,
|
||||
statusText: liveJobProgress?.statusText || job?.error_message || null,
|
||||
context: computedContext
|
||||
context: mergedContext
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1184,12 +1321,13 @@ export default function DashboardPage({
|
||||
try {
|
||||
const response = await api.retryJob(jobId);
|
||||
const result = getQueueActionResult(response);
|
||||
const retryJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
if (result.queued) {
|
||||
showQueuedToast(toastRef, 'Retry', result);
|
||||
} else {
|
||||
setExpandedJobId(normalizedJobId);
|
||||
setExpandedJobId(retryJobId);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
@@ -1216,12 +1354,13 @@ export default function DashboardPage({
|
||||
try {
|
||||
const response = await api.restartEncodeWithLastSettings(jobId);
|
||||
const result = getQueueActionResult(response);
|
||||
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
if (result.queued) {
|
||||
showQueuedToast(toastRef, 'Encode-Neustart', result);
|
||||
} else {
|
||||
setExpandedJobId(normalizedJobId);
|
||||
setExpandedJobId(replacementJobId);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
@@ -1238,10 +1377,12 @@ export default function DashboardPage({
|
||||
|
||||
setJobBusy(normalizedJobId, true);
|
||||
try {
|
||||
await api.restartReviewFromRaw(normalizedJobId);
|
||||
const response = await api.restartReviewFromRaw(normalizedJobId);
|
||||
const result = getQueueActionResult(response);
|
||||
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
setExpandedJobId(normalizedJobId);
|
||||
setExpandedJobId(replacementJobId);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
@@ -1426,15 +1567,28 @@ export default function DashboardPage({
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
setJobBusy(jobId, true);
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (normalizedJobId) {
|
||||
setJobBusy(normalizedJobId, true);
|
||||
}
|
||||
try {
|
||||
await api.startCdRip(jobId, ripConfig);
|
||||
const response = await api.startCdRip(jobId, ripConfig);
|
||||
const result = getQueueActionResult(response);
|
||||
if (result.queued) {
|
||||
showQueuedToast(toastRef, 'Audio CD', result);
|
||||
}
|
||||
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
if (replacementJobId) {
|
||||
setExpandedJobId(replacementJobId);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setJobBusy(jobId, false);
|
||||
if (normalizedJobId) {
|
||||
setJobBusy(normalizedJobId, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1772,10 +1926,14 @@ export default function DashboardPage({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Job Queue" subTitle="Starts werden nach Parallel-Limit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
|
||||
<Card title="Job Queue" subTitle="Starts werden nach Typ- und Gesamtlimit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
|
||||
<div className="pipeline-queue-meta">
|
||||
<Tag value={`Parallel: ${queueState?.maxParallelJobs || 1}`} severity="info" />
|
||||
<Tag value={`Laufend: ${queueState?.runningCount || 0}`} severity={queueRunningJobs.length > 0 ? 'warning' : 'success'} />
|
||||
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
|
||||
<Tag value={`CD max.: ${queueState?.maxParallelCdEncodes || 2}`} severity="info" />
|
||||
<Tag value={`Gesamt max.: ${queueState?.maxTotalEncodes || 3}`} severity="info" />
|
||||
{queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio CDs überspringen die Film-Queue-Reihenfolge" />}
|
||||
<Tag value={`Film laufend: ${queueState?.runningCount || 0}`} severity={(queueState?.runningCount || 0) > 0 ? 'warning' : 'success'} />
|
||||
<Tag value={`CD laufend: ${queueState?.runningCdCount || 0}`} severity={(queueState?.runningCdCount || 0) > 0 ? 'warning' : 'success'} />
|
||||
<Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} />
|
||||
</div>
|
||||
|
||||
@@ -2110,6 +2268,15 @@ export default function DashboardPage({
|
||||
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
|
||||
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
|
||||
const mediaIndicator = mediaIndicatorMeta(job);
|
||||
const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
|
||||
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
|
||||
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
|
||||
const pipelineStatusText = String(pipelineForJob?.statusText || '').trim().toUpperCase();
|
||||
const isCdJob = jobState.startsWith('CD_')
|
||||
|| pipelineStage.startsWith('CD_')
|
||||
|| mediaProfile === 'cd'
|
||||
|| mediaIndicator.mediaType === 'cd'
|
||||
|| pipelineStatusText.includes('CD_');
|
||||
const rawProgress = Number(pipelineForJob?.progress ?? 0);
|
||||
const clampedProgress = Number.isFinite(rawProgress)
|
||||
? Math.max(0, Math.min(100, rawProgress))
|
||||
@@ -2151,30 +2318,22 @@ export default function DashboardPage({
|
||||
/>
|
||||
</div>
|
||||
{(() => {
|
||||
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
|
||||
const isCdJob = jobState.startsWith('CD_');
|
||||
if (isCdJob) {
|
||||
return (
|
||||
<>
|
||||
{jobState === 'CD_METADATA_SELECTION' ? (
|
||||
<Button
|
||||
label="CD-Metadaten auswählen"
|
||||
icon="pi pi-list"
|
||||
onClick={() => {
|
||||
{isCdJob ? (
|
||||
<CdRipConfigPanel
|
||||
pipeline={pipelineForJob}
|
||||
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
|
||||
onCancel={() => handleCancel(jobId, jobState)}
|
||||
onRetry={() => handleRetry(jobId)}
|
||||
onOpenMetadata={() => {
|
||||
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
||||
? pipelineForJob.context
|
||||
: pipeline?.context || {};
|
||||
setCdMetadataDialogContext({ ...ctx, jobId });
|
||||
setCdMetadataDialogVisible(true);
|
||||
}}
|
||||
disabled={busyJobIds.has(jobId)}
|
||||
/>
|
||||
) : null}
|
||||
{(jobState === 'CD_READY_TO_RIP' || jobState === 'CD_RIPPING' || jobState === 'CD_ENCODING') ? (
|
||||
<CdRipConfigPanel
|
||||
pipeline={pipelineForJob}
|
||||
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
|
||||
onCancel={() => handleCancel(jobId, jobState)}
|
||||
busy={busyJobIds.has(jobId)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -2183,7 +2342,7 @@ export default function DashboardPage({
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{!String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase().startsWith('CD_') ? (
|
||||
{!isCdJob ? (
|
||||
<PipelineStatusCard
|
||||
pipeline={pipelineForJob}
|
||||
onAnalyze={handleAnalyze}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Dropdown } from 'primereact/dropdown';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { api } from '../api/client';
|
||||
import JobDetailDialog from '../components/JobDetailDialog';
|
||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||
@@ -22,6 +23,7 @@ const MEDIA_FILTER_OPTIONS = [
|
||||
{ label: 'Alle Medien', value: '' },
|
||||
{ label: 'Blu-ray', value: 'bluray' },
|
||||
{ label: 'DVD', value: 'dvd' },
|
||||
{ label: 'Audio CD', value: 'cd' },
|
||||
{ label: 'Sonstiges', value: 'other' }
|
||||
];
|
||||
|
||||
@@ -36,13 +38,30 @@ const SORT_OPTIONS = [
|
||||
{ label: 'Medium: Z -> A', value: '!sortMediaType' }
|
||||
];
|
||||
|
||||
const CD_FORMAT_LABELS = {
|
||||
flac: 'FLAC',
|
||||
wav: 'WAV',
|
||||
mp3: 'MP3',
|
||||
opus: 'Opus',
|
||||
ogg: 'Ogg Vorbis'
|
||||
};
|
||||
|
||||
function normalizePositiveInteger(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function resolveMediaType(row) {
|
||||
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null;
|
||||
const candidates = [
|
||||
row?.mediaType,
|
||||
row?.media_type,
|
||||
row?.mediaProfile,
|
||||
row?.media_profile,
|
||||
row?.encodePlan?.mediaProfile,
|
||||
encodePlan?.mediaProfile,
|
||||
row?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||
row?.makemkvInfo?.mediaProfile,
|
||||
row?.mediainfoInfo?.mediaProfile
|
||||
@@ -58,6 +77,28 @@ function resolveMediaType(row) {
|
||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||
return 'dvd';
|
||||
}
|
||||
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
|
||||
return 'cd';
|
||||
}
|
||||
}
|
||||
const statusCandidates = [
|
||||
row?.status,
|
||||
row?.last_state,
|
||||
row?.makemkvInfo?.lastState
|
||||
];
|
||||
if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) {
|
||||
return 'cd';
|
||||
}
|
||||
const planFormat = String(encodePlan?.format || '').trim().toLowerCase();
|
||||
const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0;
|
||||
if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
|
||||
return 'cd';
|
||||
}
|
||||
if (String(row?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
|
||||
return 'cd';
|
||||
}
|
||||
if (Array.isArray(row?.makemkvInfo?.tracks) && row.makemkvInfo.tracks.length > 0) {
|
||||
return 'cd';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
@@ -80,6 +121,14 @@ function resolveMediaTypeMeta(row) {
|
||||
alt: 'DVD'
|
||||
};
|
||||
}
|
||||
if (mediaType === 'cd') {
|
||||
return {
|
||||
mediaType,
|
||||
icon: otherIndicatorIcon,
|
||||
label: 'Audio CD',
|
||||
alt: 'Audio CD'
|
||||
};
|
||||
}
|
||||
return {
|
||||
mediaType,
|
||||
icon: otherIndicatorIcon,
|
||||
@@ -88,6 +137,93 @@ function resolveMediaTypeMeta(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function formatDurationSeconds(totalSeconds) {
|
||||
const parsed = Number(totalSeconds);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
const rounded = Math.max(0, Math.trunc(parsed));
|
||||
const hours = Math.floor(rounded / 3600);
|
||||
const minutes = Math.floor((rounded % 3600) / 60);
|
||||
const seconds = rounded % 60;
|
||||
if (hours > 0) {
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function resolveCdDetails(row) {
|
||||
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
|
||||
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||
const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {};
|
||||
const tracksSource = Array.isArray(makemkvInfo?.tracks) && makemkvInfo.tracks.length > 0
|
||||
? makemkvInfo.tracks
|
||||
: (Array.isArray(encodePlan?.tracks) ? encodePlan.tracks : []);
|
||||
const tracks = tracksSource
|
||||
.map((track) => {
|
||||
const position = normalizePositiveInteger(track?.position);
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...track,
|
||||
position,
|
||||
selected: track?.selected !== false
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
const selectedTracksFromPlan = Array.isArray(encodePlan?.selectedTracks)
|
||||
? encodePlan.selectedTracks
|
||||
.map((value) => normalizePositiveInteger(value))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const selectedTrackPositions = selectedTracksFromPlan.length > 0
|
||||
? selectedTracksFromPlan
|
||||
: tracks.filter((track) => track.selected !== false).map((track) => track.position);
|
||||
const fallbackArtist = tracks
|
||||
.map((track) => String(track?.artist || '').trim())
|
||||
.find(Boolean) || null;
|
||||
const totalDurationSec = tracks.reduce((sum, track) => {
|
||||
const durationMs = Number(track?.durationMs);
|
||||
const durationSec = Number(track?.durationSec);
|
||||
if (Number.isFinite(durationMs) && durationMs > 0) {
|
||||
return sum + (durationMs / 1000);
|
||||
}
|
||||
if (Number.isFinite(durationSec) && durationSec > 0) {
|
||||
return sum + durationSec;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
const format = String(encodePlan?.format || '').trim().toLowerCase();
|
||||
const mbId = String(
|
||||
selectedMetadata?.mbId
|
||||
|| selectedMetadata?.musicBrainzId
|
||||
|| selectedMetadata?.musicbrainzId
|
||||
|| selectedMetadata?.mbid
|
||||
|| ''
|
||||
).trim() || null;
|
||||
|
||||
return {
|
||||
artist: String(selectedMetadata?.artist || '').trim() || fallbackArtist || null,
|
||||
trackCount: tracks.length,
|
||||
selectedTrackCount: selectedTrackPositions.length,
|
||||
format,
|
||||
formatLabel: format ? (CD_FORMAT_LABELS[format] || format.toUpperCase()) : null,
|
||||
totalDurationLabel: formatDurationSeconds(totalDurationSec),
|
||||
mbId
|
||||
};
|
||||
}
|
||||
|
||||
function getOutputLabelForRow(row) {
|
||||
return resolveMediaType(row) === 'cd' ? 'Audio-Dateien' : 'Movie-Datei(en)';
|
||||
}
|
||||
|
||||
function getOutputShortLabelForRow(row) {
|
||||
return resolveMediaType(row) === 'cd' ? 'Audio' : 'Movie';
|
||||
}
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
@@ -173,6 +309,11 @@ export default function HistoryPage() {
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
|
||||
const [deleteEntryBusy, setDeleteEntryBusy] = useState(false);
|
||||
const [deleteEntryDialogVisible, setDeleteEntryDialogVisible] = useState(false);
|
||||
const [deleteEntryDialogRow, setDeleteEntryDialogRow] = useState(null);
|
||||
const [deleteEntryPreview, setDeleteEntryPreview] = useState(null);
|
||||
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
|
||||
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||
const toastRef = useRef(null);
|
||||
@@ -321,7 +462,9 @@ export default function HistoryPage() {
|
||||
};
|
||||
|
||||
const handleDeleteFiles = async (row, target) => {
|
||||
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? 'Movie-Datei(en)' : 'RAW + Movie';
|
||||
const outputLabel = getOutputLabelForRow(row);
|
||||
const outputShortLabel = getOutputShortLabelForRow(row);
|
||||
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
|
||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||
const confirmed = window.confirm(`${label} für "${title}" wirklich löschen?`);
|
||||
if (!confirmed) {
|
||||
@@ -335,7 +478,7 @@ export default function HistoryPage() {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Dateien gelöscht',
|
||||
detail: `RAW: ${summary.raw?.filesDeleted ?? 0}, MOVIE: ${summary.movie?.filesDeleted ?? 0}`,
|
||||
detail: `RAW: ${summary.raw?.filesDeleted ?? 0}, ${outputShortLabel}: ${summary.movie?.filesDeleted ?? 0}`,
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
@@ -440,28 +583,129 @@ export default function HistoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEntry = async (row) => {
|
||||
const handleRetry = async (row) => {
|
||||
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
||||
const confirmed = window.confirm(`Historieneintrag für "${title}" wirklich löschen?\nDateien werden NICHT gelöscht.`);
|
||||
const mediaType = resolveMediaType(row);
|
||||
const actionLabel = mediaType === 'cd' ? 'CD-Rip' : 'Retry';
|
||||
const confirmed = window.confirm(`${actionLabel} für "${title}" neu starten?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const response = await api.retryJob(row.id);
|
||||
const result = getQueueActionResult(response);
|
||||
const replacementJobId = normalizeJobId(result?.jobId);
|
||||
toastRef.current?.show({
|
||||
severity: result.queued ? 'info' : 'success',
|
||||
summary: mediaType === 'cd' ? 'CD-Rip neu gestartet' : 'Retry gestartet',
|
||||
detail: result.queued
|
||||
? 'Job wurde in die Warteschlange eingeplant.'
|
||||
: (replacementJobId ? `Neuer Job #${replacementJobId} wurde erstellt.` : 'Job wurde neu gestartet.'),
|
||||
life: 4000
|
||||
});
|
||||
await load();
|
||||
if (replacementJobId) {
|
||||
const detailResponse = await api.getJob(replacementJobId, { includeLogs: false });
|
||||
setSelectedJob(detailResponse.job);
|
||||
setDetailVisible(true);
|
||||
} else {
|
||||
await refreshDetailIfOpen(row.id);
|
||||
}
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: mediaType === 'cd' ? 'CD-Rip Neustart fehlgeschlagen' : 'Retry fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDeleteEntryDialog = () => {
|
||||
if (deleteEntryTargetBusy) {
|
||||
return;
|
||||
}
|
||||
setDeleteEntryDialogVisible(false);
|
||||
setDeleteEntryDialogRow(null);
|
||||
setDeleteEntryPreview(null);
|
||||
setDeleteEntryPreviewLoading(false);
|
||||
setDeleteEntryTargetBusy(null);
|
||||
};
|
||||
|
||||
const handleDeleteEntry = async (row) => {
|
||||
const jobId = Number(row?.id || 0);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
setDeleteEntryDialogRow(row);
|
||||
setDeleteEntryPreview(null);
|
||||
setDeleteEntryDialogVisible(true);
|
||||
setDeleteEntryPreviewLoading(true);
|
||||
setDeleteEntryBusy(true);
|
||||
try {
|
||||
await api.deleteJobEntry(row.id, 'none');
|
||||
const response = await api.getJobDeletePreview(jobId, { includeRelated: true });
|
||||
setDeleteEntryPreview(response?.preview || null);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Löschvorschau fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
setDeleteEntryDialogVisible(false);
|
||||
setDeleteEntryDialogRow(null);
|
||||
setDeleteEntryPreview(null);
|
||||
} finally {
|
||||
setDeleteEntryPreviewLoading(false);
|
||||
setDeleteEntryBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteEntry = async (target) => {
|
||||
const normalizedTarget = String(target || '').trim().toLowerCase();
|
||||
if (!['raw', 'movie', 'both'].includes(normalizedTarget)) {
|
||||
return;
|
||||
}
|
||||
const jobId = Number(deleteEntryDialogRow?.id || 0);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteEntryBusy(true);
|
||||
setDeleteEntryTargetBusy(normalizedTarget);
|
||||
try {
|
||||
const response = await api.deleteJobEntry(jobId, normalizedTarget, { includeRelated: true });
|
||||
const deletedJobIds = Array.isArray(response?.deletedJobIds) ? response.deletedJobIds : [];
|
||||
const fileSummary = response?.fileSummary || {};
|
||||
const rawFiles = Number(fileSummary?.raw?.filesDeleted || 0);
|
||||
const movieFiles = Number(fileSummary?.movie?.filesDeleted || 0);
|
||||
const rawDirs = Number(fileSummary?.raw?.dirsRemoved || 0);
|
||||
const movieDirs = Number(fileSummary?.movie?.dirsRemoved || 0);
|
||||
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Eintrag gelöscht',
|
||||
detail: `"${title}" wurde aus der Historie entfernt.`,
|
||||
life: 3500
|
||||
summary: 'Historie gelöscht',
|
||||
detail: `${deletedJobIds.length || 1} Eintrag/Einträge entfernt | RAW: ${rawFiles} Dateien, ${rawDirs} Ordner | ${deleteEntryOutputShortLabel}: ${movieFiles} Dateien, ${movieDirs} Ordner`,
|
||||
life: 5000
|
||||
});
|
||||
|
||||
closeDeleteEntryDialog();
|
||||
setDetailVisible(false);
|
||||
setSelectedJob(null);
|
||||
await load();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Löschen fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Löschen fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 5000
|
||||
});
|
||||
} finally {
|
||||
setDeleteEntryTargetBusy(null);
|
||||
setDeleteEntryBusy(false);
|
||||
}
|
||||
};
|
||||
@@ -508,11 +752,12 @@ export default function HistoryPage() {
|
||||
};
|
||||
|
||||
const renderPoster = (row, className = 'history-dv-poster') => {
|
||||
const mediaMeta = resolveMediaTypeMeta(row);
|
||||
const title = row?.title || row?.detected_title || 'Poster';
|
||||
if (row?.poster_url && row.poster_url !== 'N/A') {
|
||||
return <img src={row.poster_url} alt={title} className={className} loading="lazy" />;
|
||||
}
|
||||
return <div className="history-dv-poster-fallback">Kein Poster</div>;
|
||||
return <div className="history-dv-poster-fallback">{mediaMeta.mediaType === 'cd' ? 'Kein Cover' : 'Kein Poster'}</div>;
|
||||
};
|
||||
|
||||
const renderPresenceChip = (label, available) => (
|
||||
@@ -522,7 +767,39 @@ export default function HistoryPage() {
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderRatings = (row) => {
|
||||
const renderSupplementalInfo = (row) => {
|
||||
if (resolveMediaType(row) === 'cd') {
|
||||
const cdDetails = resolveCdDetails(row);
|
||||
const infoItems = [];
|
||||
if (cdDetails.trackCount > 0) {
|
||||
infoItems.push({
|
||||
key: 'tracks',
|
||||
label: 'Tracks',
|
||||
value: cdDetails.selectedTrackCount > 0 && cdDetails.selectedTrackCount !== cdDetails.trackCount
|
||||
? `${cdDetails.selectedTrackCount}/${cdDetails.trackCount}`
|
||||
: String(cdDetails.trackCount)
|
||||
});
|
||||
}
|
||||
if (cdDetails.formatLabel) {
|
||||
infoItems.push({ key: 'format', label: 'Format', value: cdDetails.formatLabel });
|
||||
}
|
||||
if (cdDetails.totalDurationLabel) {
|
||||
infoItems.push({ key: 'duration', label: 'Dauer', value: cdDetails.totalDurationLabel });
|
||||
}
|
||||
if (cdDetails.mbId) {
|
||||
infoItems.push({ key: 'mb', label: 'MusicBrainz', value: 'gesetzt' });
|
||||
}
|
||||
if (infoItems.length === 0) {
|
||||
return <span className="history-dv-subtle">Keine CD-Details</span>;
|
||||
}
|
||||
return infoItems.map((item) => (
|
||||
<span key={`${row?.id}-${item.key}`} className="history-dv-rating-chip">
|
||||
<strong>{item.label}</strong>
|
||||
<span>{item.value}</span>
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
const ratings = resolveRatings(row);
|
||||
if (ratings.length === 0) {
|
||||
return <span className="history-dv-subtle">Keine Ratings</span>;
|
||||
@@ -545,6 +822,16 @@ export default function HistoryPage() {
|
||||
|
||||
const listItem = (row) => {
|
||||
const mediaMeta = resolveMediaTypeMeta(row);
|
||||
const isCdJob = mediaMeta.mediaType === 'cd';
|
||||
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
|
||||
const subtitle = isCdJob
|
||||
? [
|
||||
`#${row?.id || '-'}`,
|
||||
cdDetails?.artist || '-',
|
||||
row?.year || null,
|
||||
cdDetails?.mbId ? 'MusicBrainz' : null
|
||||
].filter(Boolean).join(' | ')
|
||||
: `#${row?.id || '-'} | ${row?.year || '-'} | ${row?.imdb_id || '-'}`;
|
||||
|
||||
return (
|
||||
<div className="col-12" key={row.id}>
|
||||
@@ -565,9 +852,7 @@ export default function HistoryPage() {
|
||||
<div className="history-dv-head">
|
||||
<div className="history-dv-title-block">
|
||||
<strong className="history-dv-title">{row?.title || row?.detected_title || '-'}</strong>
|
||||
<small className="history-dv-subtle">
|
||||
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
|
||||
</small>
|
||||
<small className="history-dv-subtle">{subtitle}</small>
|
||||
</div>
|
||||
{renderStatusTag(row)}
|
||||
</div>
|
||||
@@ -582,12 +867,22 @@ export default function HistoryPage() {
|
||||
</div>
|
||||
|
||||
<div className="history-dv-flags-row">
|
||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
{isCdJob ? (
|
||||
<>
|
||||
{renderPresenceChip('Audio', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Rip', Boolean(row?.ripSuccessful))}
|
||||
{renderPresenceChip('Metadaten', Boolean(cdDetails?.artist || cdDetails?.mbId))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="history-dv-ratings-row">{renderRatings(row)}</div>
|
||||
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
|
||||
</div>
|
||||
|
||||
<div className="history-dv-actions">
|
||||
@@ -608,6 +903,16 @@ export default function HistoryPage() {
|
||||
|
||||
const gridItem = (row) => {
|
||||
const mediaMeta = resolveMediaTypeMeta(row);
|
||||
const isCdJob = mediaMeta.mediaType === 'cd';
|
||||
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
|
||||
const subtitle = isCdJob
|
||||
? [
|
||||
`#${row?.id || '-'}`,
|
||||
cdDetails?.artist || '-',
|
||||
row?.year || null,
|
||||
cdDetails?.mbId ? 'MusicBrainz' : null
|
||||
].filter(Boolean).join(' | ')
|
||||
: `#${row?.id || '-'} | ${row?.year || '-'} | ${row?.imdb_id || '-'}`;
|
||||
|
||||
return (
|
||||
<div className="col-12 md-col-6 xl-col-4" key={row.id}>
|
||||
@@ -630,9 +935,7 @@ export default function HistoryPage() {
|
||||
{renderStatusTag(row)}
|
||||
</div>
|
||||
|
||||
<small className="history-dv-subtle">
|
||||
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
|
||||
</small>
|
||||
<small className="history-dv-subtle">{subtitle}</small>
|
||||
|
||||
<div className="history-dv-meta-row">
|
||||
<span className="job-step-cell">
|
||||
@@ -644,12 +947,22 @@ export default function HistoryPage() {
|
||||
</div>
|
||||
|
||||
<div className="history-dv-flags-row">
|
||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
{isCdJob ? (
|
||||
<>
|
||||
{renderPresenceChip('Audio', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Rip', Boolean(row?.ripSuccessful))}
|
||||
{renderPresenceChip('Metadaten', Boolean(cdDetails?.artist || cdDetails?.mbId))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="history-dv-ratings-row">{renderRatings(row)}</div>
|
||||
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
|
||||
</div>
|
||||
|
||||
<div className="history-dv-actions history-dv-actions-grid">
|
||||
@@ -675,12 +988,21 @@ export default function HistoryPage() {
|
||||
return currentLayout === 'list' ? listItem(row) : gridItem(row);
|
||||
};
|
||||
|
||||
const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
|
||||
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
|
||||
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
|
||||
const previewMovieExisting = previewMoviePaths.filter((item) => Boolean(item?.exists));
|
||||
const deleteTargetActionsDisabled = deleteEntryPreviewLoading || Boolean(deleteEntryTargetBusy) || !deleteEntryPreview;
|
||||
const deleteEntryOutputLabel = getOutputLabelForRow(deleteEntryDialogRow);
|
||||
const deleteEntryOutputShortLabel = getOutputShortLabelForRow(deleteEntryDialogRow);
|
||||
|
||||
const header = (
|
||||
<div className="history-dv-toolbar">
|
||||
<InputText
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Suche nach Titel oder IMDb"
|
||||
placeholder="Suche nach Titel, Interpret oder IMDb"
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
@@ -748,6 +1070,7 @@ export default function HistoryPage() {
|
||||
onRestartEncode={handleRestartEncode}
|
||||
onRestartReview={handleRestartReview}
|
||||
onReencode={handleReencode}
|
||||
onRetry={handleRetry}
|
||||
onDeleteFiles={handleDeleteFiles}
|
||||
onDeleteEntry={handleDeleteEntry}
|
||||
onRemoveFromQueue={handleRemoveFromQueue}
|
||||
@@ -761,6 +1084,118 @@ export default function HistoryPage() {
|
||||
setLogLoadingMode(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
header="Historien-Eintrag löschen"
|
||||
visible={deleteEntryDialogVisible}
|
||||
onHide={closeDeleteEntryDialog}
|
||||
style={{ width: '56rem', maxWidth: '96vw' }}
|
||||
className="history-delete-dialog"
|
||||
modal
|
||||
>
|
||||
<p>
|
||||
{`Es werden ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge entfernt.`}
|
||||
</p>
|
||||
|
||||
{deleteEntryDialogRow ? (
|
||||
<small className="muted-inline">
|
||||
Job: {deleteEntryDialogRow?.title || deleteEntryDialogRow?.detected_title || `Job #${deleteEntryDialogRow?.id || '-'}`}
|
||||
</small>
|
||||
) : null}
|
||||
|
||||
{deleteEntryPreviewLoading ? (
|
||||
<p>Löschvorschau wird geladen ...</p>
|
||||
) : (
|
||||
<div className="history-delete-preview-grid">
|
||||
<div>
|
||||
<h4>Rip/Encode Historie</h4>
|
||||
{previewRelatedJobs.length > 0 ? (
|
||||
<ul className="history-delete-preview-list">
|
||||
{previewRelatedJobs.map((item) => (
|
||||
<li key={`delete-related-${item.id}`}>
|
||||
<strong>#{item.id}</strong> | {item.title || '-'} | {item.status || '-'} {item.isPrimary ? '(aktuell)' : '(Alt-Eintrag)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<small className="history-dv-subtle">Keine verknüpften Alt-Einträge erkannt.</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>{`RAW (${previewRawExisting.length}/${previewRawPaths.length})`}</h4>
|
||||
{previewRawPaths.length > 0 ? (
|
||||
<ul className="history-delete-preview-list">
|
||||
{previewRawPaths.map((item) => (
|
||||
<li key={`delete-raw-${item.path}`}>
|
||||
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
||||
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
||||
</span>
|
||||
{' '}| {item.path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<small className="history-dv-subtle">Keine RAW-Pfade.</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>{`${deleteEntryOutputShortLabel} (${previewMovieExisting.length}/${previewMoviePaths.length})`}</h4>
|
||||
{previewMoviePaths.length > 0 ? (
|
||||
<ul className="history-delete-preview-list">
|
||||
{previewMoviePaths.map((item) => (
|
||||
<li key={`delete-movie-${item.path}`}>
|
||||
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
|
||||
{item.exists ? 'vorhanden' : 'nicht gefunden'}
|
||||
</span>
|
||||
{' '}| {item.path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<small className="history-dv-subtle">Keine Movie-Pfade.</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<Button
|
||||
label="Nur RAW löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="warning"
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('raw')}
|
||||
loading={deleteEntryTargetBusy === 'raw'}
|
||||
disabled={deleteTargetActionsDisabled}
|
||||
/>
|
||||
<Button
|
||||
label={`Nur ${deleteEntryOutputShortLabel} löschen`}
|
||||
icon="pi pi-trash"
|
||||
severity="warning"
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('movie')}
|
||||
loading={deleteEntryTargetBusy === 'movie'}
|
||||
disabled={deleteTargetActionsDisabled}
|
||||
/>
|
||||
<Button
|
||||
label="Beides löschen"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
onClick={() => confirmDeleteEntry('both')}
|
||||
loading={deleteEntryTargetBusy === 'both'}
|
||||
disabled={deleteTargetActionsDisabled}
|
||||
/>
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={closeDeleteEntryDialog}
|
||||
disabled={Boolean(deleteEntryTargetBusy)}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import { TabView, TabPanel } from 'primereact/tabview';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { InputSwitch } from 'primereact/inputswitch';
|
||||
import { api } from '../api/client';
|
||||
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
||||
import CronJobsTab from '../components/CronJobsTab';
|
||||
|
||||
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
|
||||
|
||||
function buildValuesMap(categories) {
|
||||
const next = {};
|
||||
for (const category of categories || []) {
|
||||
@@ -28,6 +31,17 @@ function isSameValue(a, b) {
|
||||
return a === b;
|
||||
}
|
||||
|
||||
function toBoolean(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
|
||||
function reorderListById(items, sourceId, targetIndex) {
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
const normalizedSourceId = Number(sourceId);
|
||||
@@ -138,6 +152,7 @@ export default function SettingsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testingPushover, setTestingPushover] = useState(false);
|
||||
const [updatingExpertMode, setUpdatingExpertMode] = useState(false);
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||
const [initialValues, setInitialValues] = useState({});
|
||||
const [draftValues, setDraftValues] = useState({});
|
||||
@@ -184,6 +199,7 @@ export default function SettingsPage() {
|
||||
});
|
||||
const [userPresetErrors, setUserPresetErrors] = useState({});
|
||||
const [handBrakePresetSourceOptions, setHandBrakePresetSourceOptions] = useState([]);
|
||||
const [effectivePaths, setEffectivePaths] = useState(null);
|
||||
|
||||
const toastRef = useRef(null);
|
||||
|
||||
@@ -317,6 +333,17 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadEffectivePaths = async ({ silent = false } = {}) => {
|
||||
try {
|
||||
const paths = await api.getEffectivePaths({ forceRefresh: true });
|
||||
setEffectivePaths(paths || null);
|
||||
} catch (_error) {
|
||||
if (!silent) {
|
||||
setEffectivePaths(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -327,6 +354,7 @@ export default function SettingsPage() {
|
||||
setInitialValues(values);
|
||||
setDraftValues(values);
|
||||
setErrors({});
|
||||
loadEffectivePaths({ silent: true });
|
||||
|
||||
const presetsPromise = api.getHandBrakePresets();
|
||||
const scriptsPromise = api.getScripts();
|
||||
@@ -389,12 +417,41 @@ export default function SettingsPage() {
|
||||
}, [initialValues, draftValues]);
|
||||
|
||||
const hasUnsavedChanges = dirtyKeys.size > 0;
|
||||
const expertModeEnabled = toBoolean(draftValues?.[EXPERT_MODE_SETTING_KEY]);
|
||||
|
||||
const handleFieldChange = (key, value) => {
|
||||
setDraftValues((prev) => ({ ...prev, [key]: value }));
|
||||
setErrors((prev) => ({ ...prev, [key]: null }));
|
||||
};
|
||||
|
||||
const handleExpertModeToggle = async (checked) => {
|
||||
const previousDraftValue = draftValues?.[EXPERT_MODE_SETTING_KEY];
|
||||
const previousInitialValue = initialValues?.[EXPERT_MODE_SETTING_KEY];
|
||||
const nextValue = Boolean(checked);
|
||||
const currentValue = toBoolean(previousDraftValue);
|
||||
if (nextValue === currentValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdatingExpertMode(true);
|
||||
setDraftValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: nextValue }));
|
||||
setInitialValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: nextValue }));
|
||||
setErrors((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: null }));
|
||||
try {
|
||||
await api.updateSetting(EXPERT_MODE_SETTING_KEY, nextValue);
|
||||
} catch (error) {
|
||||
setDraftValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: previousDraftValue }));
|
||||
setInitialValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: previousInitialValue }));
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Expertenmodus',
|
||||
detail: error.message
|
||||
});
|
||||
} finally {
|
||||
setUpdatingExpertMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasUnsavedChanges) {
|
||||
toastRef.current?.show({
|
||||
@@ -415,6 +472,7 @@ export default function SettingsPage() {
|
||||
const response = await api.updateSettingsBulk(patch);
|
||||
setInitialValues((prev) => ({ ...prev, ...patch }));
|
||||
setErrors({});
|
||||
loadEffectivePaths({ silent: true });
|
||||
const reviewRefresh = response?.reviewRefresh || null;
|
||||
const reviewRefreshHint = reviewRefresh?.triggered
|
||||
? ' Mediainfo-Prüfung wird mit den neuen Settings automatisch neu berechnet.'
|
||||
@@ -946,7 +1004,7 @@ export default function SettingsPage() {
|
||||
icon="pi pi-save"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!hasUnsavedChanges}
|
||||
disabled={!hasUnsavedChanges || updatingExpertMode}
|
||||
/>
|
||||
<Button
|
||||
label="Änderungen verwerfen"
|
||||
@@ -954,7 +1012,7 @@ export default function SettingsPage() {
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={handleDiscard}
|
||||
disabled={!hasUnsavedChanges || saving}
|
||||
disabled={!hasUnsavedChanges || saving || updatingExpertMode}
|
||||
/>
|
||||
<Button
|
||||
label="Neu laden"
|
||||
@@ -962,7 +1020,7 @@ export default function SettingsPage() {
|
||||
severity="secondary"
|
||||
onClick={load}
|
||||
loading={loading}
|
||||
disabled={saving}
|
||||
disabled={saving || updatingExpertMode}
|
||||
/>
|
||||
<Button
|
||||
label="PushOver Test"
|
||||
@@ -970,8 +1028,16 @@ export default function SettingsPage() {
|
||||
severity="info"
|
||||
onClick={handlePushoverTest}
|
||||
loading={testingPushover}
|
||||
disabled={saving}
|
||||
disabled={saving || updatingExpertMode}
|
||||
/>
|
||||
<div className="settings-expert-toggle">
|
||||
<span>Expertenmodus</span>
|
||||
<InputSwitch
|
||||
checked={expertModeEnabled}
|
||||
onChange={(event) => handleExpertModeToggle(event.value)}
|
||||
disabled={loading || saving || updatingExpertMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -983,6 +1049,7 @@ export default function SettingsPage() {
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={handleFieldChange}
|
||||
effectivePaths={effectivePaths}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
@@ -1526,7 +1593,7 @@ export default function SettingsPage() {
|
||||
|
||||
<small>
|
||||
Encode-Presets fassen ein HandBrake-Preset und zusätzliche CLI-Argumente zusammen.
|
||||
Sie sind medienbezogen (Blu-ray, DVD, Sonstiges oder Universell) und können vor dem Encode
|
||||
Sie sind medienbezogen (Blu-ray, DVD oder Universell) und können vor dem Encode
|
||||
in der Mediainfo-Prüfung ausgewählt werden. Kein Preset gewählt = Fallback aus Einstellungen.
|
||||
</small>
|
||||
|
||||
@@ -1544,7 +1611,6 @@ export default function SettingsPage() {
|
||||
<span className="preset-media-type-tag">
|
||||
{preset.mediaType === 'bluray' ? 'Blu-ray'
|
||||
: preset.mediaType === 'dvd' ? 'DVD'
|
||||
: preset.mediaType === 'other' ? 'Sonstiges'
|
||||
: 'Universell'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1604,7 +1670,6 @@ export default function SettingsPage() {
|
||||
<option value="all">Universell (alle Medien)</option>
|
||||
<option value="bluray">Blu-ray</option>
|
||||
<option value="dvd">DVD</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user