0.16.1-7 Release-Bugfixes
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Toast } from 'primereact/toast';
|
||||
@@ -10,7 +9,6 @@ import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import AudiobookUploadPanel from '../components/AudiobookUploadPanel';
|
||||
import AudiobookConfigPanel from '../components/AudiobookConfigPanel';
|
||||
import AudiobookOutputExplorer from '../components/AudiobookOutputExplorer';
|
||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||
import { resolveMediaType } from '../utils/jobTaxonomy';
|
||||
import { confirmModal } from '../utils/confirmModal';
|
||||
@@ -25,6 +23,44 @@ function normalizeJobId(value) {
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function hasOwn(source, key) {
|
||||
return source != null && Object.prototype.hasOwnProperty.call(source, key);
|
||||
}
|
||||
|
||||
function normalizeLiveProgressForJob(job) {
|
||||
const live = job?.liveProgress && typeof job.liveProgress === 'object' ? job.liveProgress : null;
|
||||
if (!live) {
|
||||
return null;
|
||||
}
|
||||
const liveContext = live?.context && typeof live.context === 'object' ? live.context : {};
|
||||
return {
|
||||
progress: live?.progress ?? null,
|
||||
eta: live?.eta ?? null,
|
||||
statusText: live?.statusText ?? null,
|
||||
state: live?.state ?? null,
|
||||
currentChapter: liveContext?.currentChapter && typeof liveContext.currentChapter === 'object'
|
||||
? liveContext.currentChapter
|
||||
: null,
|
||||
completedChapterCount: Number.isFinite(Number(liveContext?.completedChapterCount))
|
||||
? Number(liveContext.completedChapterCount)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
function extractUploadJobIdFromResponse(response) {
|
||||
const payload = response && typeof response === 'object' ? response : {};
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {};
|
||||
return (
|
||||
normalizeJobId(result?.jobId)
|
||||
|| normalizeJobId(payload?.jobId)
|
||||
|| normalizeJobId(result?.id)
|
||||
|| normalizeJobId(payload?.id)
|
||||
|| normalizeJobId(result?.job?.id)
|
||||
|| normalizeJobId(payload?.job?.id)
|
||||
|| null
|
||||
);
|
||||
}
|
||||
|
||||
function parseEncodePlan(job) {
|
||||
if (job?.encodePlan && typeof job.encodePlan === 'object') {
|
||||
return job.encodePlan;
|
||||
@@ -71,6 +107,22 @@ function resolveAudiobookMetadata(job, encodePlan) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildAudiobookJobMetaLine(jobId, metadata = {}, fallbackYear = null) {
|
||||
const author = String(metadata?.author || metadata?.artist || '').trim() || null;
|
||||
const narrator = String(metadata?.narrator || '').trim() || null;
|
||||
const chapterCount = Array.isArray(metadata?.chapters) ? metadata.chapters.length : 0;
|
||||
const year = Number.isFinite(Number(metadata?.year))
|
||||
? Math.trunc(Number(metadata.year))
|
||||
: (Number.isFinite(Number(fallbackYear)) ? Math.trunc(Number(fallbackYear)) : null);
|
||||
return (
|
||||
`#${jobId}`
|
||||
+ (author ? ` | ${author}` : '')
|
||||
+ (narrator ? ` | ${narrator}` : '')
|
||||
+ (chapterCount > 0 ? ` | ${chapterCount} Kapitel` : '')
|
||||
+ (year ? ` | ${year}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
function buildAudiobookPipeline(job, progress = null) {
|
||||
const encodePlan = parseEncodePlan(job);
|
||||
const selectedMetadata = resolveAudiobookMetadata(job, encodePlan);
|
||||
@@ -93,7 +145,13 @@ function buildAudiobookPipeline(job, progress = null) {
|
||||
format: String(encodePlan?.format || job?.handbrakeInfo?.format || 'mp3').trim().toLowerCase() || 'mp3',
|
||||
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
|
||||
? encodePlan.formatOptions
|
||||
: {}
|
||||
: {},
|
||||
preEncodeScriptIds: Array.isArray(encodePlan?.preEncodeScriptIds) ? encodePlan.preEncodeScriptIds : [],
|
||||
postEncodeScriptIds: Array.isArray(encodePlan?.postEncodeScriptIds) ? encodePlan.postEncodeScriptIds : [],
|
||||
preEncodeChainIds: Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [],
|
||||
postEncodeChainIds: Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : [],
|
||||
preEncodeItems: Array.isArray(encodePlan?.preEncodeItems) ? encodePlan.preEncodeItems : [],
|
||||
postEncodeItems: Array.isArray(encodePlan?.postEncodeItems) ? encodePlan.postEncodeItems : []
|
||||
},
|
||||
mediaInfoReview: reviewData,
|
||||
outputPath: String(job?.output_path || encodePlan?.outputPath || '').trim() || null,
|
||||
@@ -109,16 +167,15 @@ function buildAudiobookPipeline(job, progress = null) {
|
||||
|
||||
export default function AudiobooksPage({
|
||||
audiobookUpload,
|
||||
onAudiobookUpload
|
||||
onAudiobookUpload,
|
||||
onCancelAudiobookUpload = null
|
||||
}) {
|
||||
const toastRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [loadingJobs, setLoadingJobs] = useState(false);
|
||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||
const [jobProgress, setJobProgress] = useState({});
|
||||
const [actionBusyJobIds, setActionBusyJobIds] = useState(() => new Set());
|
||||
const explorerRefreshToken = 0;
|
||||
|
||||
const setActionBusy = (jobId, busy) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
@@ -146,21 +203,32 @@ export default function AudiobooksPage({
|
||||
.sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0));
|
||||
setJobs(audiobookRows);
|
||||
setJobProgress((prev) => {
|
||||
const next = { ...prev };
|
||||
const next = {};
|
||||
for (const job of audiobookRows) {
|
||||
const jobId = normalizeJobId(job?.id);
|
||||
if (!jobId) {
|
||||
continue;
|
||||
}
|
||||
const status = String(job?.status || '').trim().toUpperCase();
|
||||
if (!['ANALYZING', 'ENCODING'].includes(status)) {
|
||||
delete next[jobId];
|
||||
const isLiveStatus = ['ANALYZING', 'ENCODING'].includes(status);
|
||||
if (!isLiveStatus) {
|
||||
continue;
|
||||
}
|
||||
const serverProgress = normalizeLiveProgressForJob(job);
|
||||
if (serverProgress) {
|
||||
next[jobId] = serverProgress;
|
||||
continue;
|
||||
}
|
||||
if (prev?.[jobId] && typeof prev[jobId] === 'object') {
|
||||
next[jobId] = prev[jobId];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return audiobookRows;
|
||||
} catch (error) {
|
||||
console.error('AudiobooksPage load jobs error:', error);
|
||||
return [];
|
||||
} finally {
|
||||
setLoadingJobs(false);
|
||||
}
|
||||
@@ -183,17 +251,31 @@ export default function AudiobooksPage({
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
setJobProgress((prev) => ({
|
||||
...prev,
|
||||
[jobId]: {
|
||||
progress: payload?.progress ?? null,
|
||||
eta: payload?.eta ?? null,
|
||||
statusText: payload?.statusText ?? null,
|
||||
state: payload?.state ?? null,
|
||||
currentChapter: payload?.contextPatch?.currentChapter ?? null,
|
||||
completedChapterCount: payload?.contextPatch?.completedChapterCount ?? null
|
||||
}
|
||||
}));
|
||||
setJobProgress((prev) => {
|
||||
const previousEntry = prev?.[jobId] && typeof prev[jobId] === 'object' ? prev[jobId] : {};
|
||||
const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object'
|
||||
? payload.contextPatch
|
||||
: null;
|
||||
const nextCurrentChapter = hasOwn(contextPatch, 'currentChapter')
|
||||
? (contextPatch?.currentChapter ?? null)
|
||||
: (previousEntry?.currentChapter ?? null);
|
||||
const nextCompletedChapterCount = hasOwn(contextPatch, 'completedChapterCount')
|
||||
? (Number.isFinite(Number(contextPatch?.completedChapterCount))
|
||||
? Number(contextPatch.completedChapterCount)
|
||||
: null)
|
||||
: (previousEntry?.completedChapterCount ?? null);
|
||||
return {
|
||||
...prev,
|
||||
[jobId]: {
|
||||
progress: payload?.progress ?? previousEntry?.progress ?? null,
|
||||
eta: payload?.eta ?? previousEntry?.eta ?? null,
|
||||
statusText: payload?.statusText ?? previousEntry?.statusText ?? null,
|
||||
state: payload?.state ?? previousEntry?.state ?? null,
|
||||
currentChapter: nextCurrentChapter,
|
||||
completedChapterCount: nextCompletedChapterCount
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
if (
|
||||
message.type === 'PIPELINE_UPDATE'
|
||||
@@ -262,7 +344,7 @@ export default function AudiobooksPage({
|
||||
}
|
||||
setActionBusy(normalizedJobId, true);
|
||||
try {
|
||||
await api.cancelJob(normalizedJobId);
|
||||
await api.cancelPipeline(normalizedJobId);
|
||||
toastRef.current?.show({
|
||||
severity: 'info',
|
||||
summary: 'Abbruch angefordert',
|
||||
@@ -282,39 +364,6 @@ export default function AudiobooksPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = async (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
setActionBusy(normalizedJobId, true);
|
||||
try {
|
||||
const response = await api.retryJob(normalizedJobId);
|
||||
const retryJobId = normalizeJobId(response?.retryJob?.id);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Retry erstellt',
|
||||
detail: retryJobId
|
||||
? `Neuer Job #${retryJobId} wurde angelegt.`
|
||||
: `Retry fuer Job #${normalizedJobId} wurde angelegt.`,
|
||||
life: 3200
|
||||
});
|
||||
await loadJobs();
|
||||
if (retryJobId) {
|
||||
setExpandedJobId(retryJobId);
|
||||
}
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Retry fehlgeschlagen',
|
||||
detail: error?.message || 'Bitte Logs pruefen.',
|
||||
life: 4200
|
||||
});
|
||||
} finally {
|
||||
setActionBusy(normalizedJobId, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
@@ -357,11 +406,14 @@ export default function AudiobooksPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploaded = async (uploadedJobId) => {
|
||||
const normalizedJobId = normalizeJobId(uploadedJobId);
|
||||
await loadJobs();
|
||||
if (normalizedJobId) {
|
||||
setExpandedJobId(normalizedJobId);
|
||||
const handleUploaded = async (uploadedJobId, response = null) => {
|
||||
const normalizedJobId = normalizeJobId(uploadedJobId)
|
||||
|| extractUploadJobIdFromResponse(response);
|
||||
const rows = await loadJobs();
|
||||
const fallbackJobId = normalizeJobId(rows?.[0]?.id);
|
||||
const targetJobId = normalizedJobId || fallbackJobId;
|
||||
if (targetJobId) {
|
||||
setExpandedJobId(targetJobId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -393,6 +445,7 @@ export default function AudiobooksPage({
|
||||
<AudiobookUploadPanel
|
||||
audiobookUpload={audiobookUpload}
|
||||
onAudiobookUpload={onAudiobookUpload}
|
||||
onCancelUpload={onCancelAudiobookUpload}
|
||||
onUploaded={handleUploaded}
|
||||
/>
|
||||
</Card>
|
||||
@@ -417,7 +470,17 @@ export default function AudiobooksPage({
|
||||
const progressValue = Number.isFinite(Number(pipelineForJob?.progress))
|
||||
? Math.max(0, Math.min(100, Number(pipelineForJob.progress)))
|
||||
: 0;
|
||||
const showProgress = ['ANALYZING', 'ENCODING'].includes(state) && progressValue > 0;
|
||||
const isRunning = ['ANALYZING', 'ENCODING'].includes(state);
|
||||
const showProgress = isRunning || progressValue > 0;
|
||||
const progressLabel = `${Math.trunc(progressValue)}%`;
|
||||
const etaLabel = String(pipelineForJob?.eta || '').trim();
|
||||
const currentChapter = pipelineForJob?.context?.currentChapter && typeof pipelineForJob.context.currentChapter === 'object'
|
||||
? pipelineForJob.context.currentChapter
|
||||
: null;
|
||||
const chapterLabel = currentChapter?.index && currentChapter?.total
|
||||
? ` | Kapitel ${currentChapter.index}/${currentChapter.total}${currentChapter.title ? `: ${currentChapter.title}` : ''}`
|
||||
: '';
|
||||
const metaLine = buildAudiobookJobMetaLine(jobId, pipelineForJob?.context?.selectedMetadata, job?.year);
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
@@ -427,25 +490,30 @@ export default function AudiobooksPage({
|
||||
className="ripper-job-row"
|
||||
onClick={() => setExpandedJobId(jobId)}
|
||||
>
|
||||
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
|
||||
{(job?.poster_url && job.poster_url !== 'N/A') ? (
|
||||
<img src={job.poster_url} alt={title} className="poster-thumb" />
|
||||
) : (
|
||||
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
|
||||
)}
|
||||
<div className="ripper-job-row-content">
|
||||
<div className="ripper-job-row-main">
|
||||
<strong className="ripper-job-title-line">
|
||||
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
|
||||
<span>{title}</span>
|
||||
</strong>
|
||||
<small>#{jobId}</small>
|
||||
<small>{metaLine}</small>
|
||||
</div>
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
|
||||
<Tag value="Audiobook" severity="info" />
|
||||
</div>
|
||||
{showProgress ? (
|
||||
<div className="ripper-job-row-progress">
|
||||
<ProgressBar value={progressValue} showValue={false} />
|
||||
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}</small>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<i className="pi pi-angle-down" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -453,25 +521,22 @@ export default function AudiobooksPage({
|
||||
return (
|
||||
<div key={jobId} className="ripper-job-expanded">
|
||||
<div className="ripper-job-expanded-head">
|
||||
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
|
||||
{(job?.poster_url && job.poster_url !== 'N/A') ? (
|
||||
<img src={job.poster_url} alt={title} className="poster-thumb" />
|
||||
) : (
|
||||
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
|
||||
)}
|
||||
<div className="ripper-job-expanded-title">
|
||||
<strong className="ripper-job-title-line">
|
||||
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
|
||||
<span>#{jobId} | {title}</span>
|
||||
<span>{title}</span>
|
||||
</strong>
|
||||
<small className="ripper-job-subtitle">{metaLine}</small>
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
|
||||
<Tag value="Audiobook" severity="info" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<Button
|
||||
label="Im Ripper"
|
||||
icon="pi pi-arrow-right"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => navigate('/ripper')}
|
||||
/>
|
||||
<Button
|
||||
label="Einklappen"
|
||||
icon="pi pi-angle-up"
|
||||
@@ -482,11 +547,16 @@ export default function AudiobooksPage({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showProgress ? (
|
||||
<div className="ripper-job-row-progress">
|
||||
<ProgressBar value={progressValue} showValue={false} />
|
||||
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}</small>
|
||||
</div>
|
||||
) : null}
|
||||
<AudiobookConfigPanel
|
||||
pipeline={pipelineForJob}
|
||||
onStart={(config) => void handleAudiobookStart(jobId, config)}
|
||||
onCancel={() => void handleCancel(jobId)}
|
||||
onRetry={() => void handleRetry(jobId)}
|
||||
onDeleteJob={() => void handleDelete(jobId)}
|
||||
busy={busy}
|
||||
/>
|
||||
@@ -496,13 +566,6 @@ export default function AudiobooksPage({
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Audiobook Output Explorer"
|
||||
subTitle="Read-only Explorer auf dem Audiobook-Ausgabeordner"
|
||||
>
|
||||
<AudiobookOutputExplorer refreshToken={explorerRefreshToken} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -507,7 +507,7 @@ export default function ConverterPage() {
|
||||
imdbId: row?.imdb_id || metadata?.imdbId || null,
|
||||
poster: row?.poster_url || metadata?.poster || null
|
||||
},
|
||||
omdbCandidates: []
|
||||
metadataCandidates: []
|
||||
});
|
||||
setMetadataDialogVisible(true);
|
||||
};
|
||||
@@ -537,9 +537,9 @@ export default function ConverterPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOmdbSearch = async (query) => {
|
||||
const handleMetadataSearch = async (query) => {
|
||||
try {
|
||||
const response = await api.searchOmdb(query);
|
||||
const response = await api.searchTmdbMovie(query);
|
||||
return Array.isArray(response?.results) ? response.results : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
@@ -978,7 +978,7 @@ export default function ConverterPage() {
|
||||
onExpand={() => setExpandedJobId(Number(job.id))}
|
||||
onCollapse={() => setExpandedJobId(null)}
|
||||
onOpenMetadata={handleOpenMetadataDialog}
|
||||
onReassignOmdb={handleOpenMetadataDialog}
|
||||
onReassignMetadata={handleOpenMetadataDialog}
|
||||
onStart={handleStartPipelineJob}
|
||||
onConfirmReview={handleConfirmReview}
|
||||
onSelectPlaylist={handleSelectPlaylist}
|
||||
@@ -1027,7 +1027,7 @@ export default function ConverterPage() {
|
||||
setMetadataDialogContext(null);
|
||||
}}
|
||||
onSubmit={handleMetadataSubmit}
|
||||
onSearch={handleOmdbSearch}
|
||||
onSearch={handleMetadataSearch}
|
||||
busy={metadataDialogBusy}
|
||||
/>
|
||||
|
||||
@@ -1196,7 +1196,7 @@ function ConverterVideoJobCard({
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onOpenMetadata,
|
||||
onReassignOmdb,
|
||||
onReassignMetadata,
|
||||
onStart,
|
||||
onConfirmReview,
|
||||
onSelectPlaylist,
|
||||
@@ -1282,7 +1282,7 @@ function ConverterVideoJobCard({
|
||||
jobRow={job}
|
||||
pipeline={pipeline}
|
||||
onOpenMetadata={onOpenMetadata}
|
||||
onReassignOmdb={onReassignOmdb}
|
||||
onReassignMetadata={onReassignMetadata}
|
||||
onStart={onStart}
|
||||
onConfirmReview={onConfirmReview}
|
||||
onSelectPlaylist={onSelectPlaylist}
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function DatabasePage() {
|
||||
const [orphanRows, setOrphanRows] = useState([]);
|
||||
const [orphanLoading, setOrphanLoading] = useState(false);
|
||||
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
|
||||
const [orphanDeleteBusyPath, setOrphanDeleteBusyPath] = useState(null);
|
||||
const toastRef = useRef(null);
|
||||
const orphanLoadRequestRef = useRef(0);
|
||||
|
||||
@@ -130,6 +131,50 @@ export default function DatabasePage() {
|
||||
setOrphanImportBusyPath(null);
|
||||
}
|
||||
};
|
||||
const handleDeleteOrphanRaw = async (row) => {
|
||||
const target = String(row?.rawPath || row?.folderName || '-').trim() || '-';
|
||||
const confirmed = await confirmModal({
|
||||
header: 'RAW löschen',
|
||||
message:
|
||||
`RAW-Ordner "${target}" wirklich löschen?\n` +
|
||||
'Der Ordner wird dauerhaft aus dem Dateisystem entfernt und verschwindet aus der /database-Liste.',
|
||||
acceptLabel: 'RAW löschen',
|
||||
rejectLabel: 'Abbrechen',
|
||||
danger: true
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOrphanDeleteBusyPath(row.rawPath);
|
||||
try {
|
||||
const response = await api.deleteOrphanRawFolder(row.rawPath);
|
||||
const deletedRawPath = String(response?.rawPath || row?.rawPath || '').trim();
|
||||
const deletedFiles = Number(response?.filesDeleted || 0);
|
||||
const deletedDirs = Number(response?.dirsRemoved || 0);
|
||||
if (deletedRawPath) {
|
||||
setOrphanRows((previousRows) => (
|
||||
(Array.isArray(previousRows) ? previousRows : []).filter((entry) => String(entry?.rawPath || '').trim() !== deletedRawPath)
|
||||
));
|
||||
}
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'RAW gelöscht',
|
||||
detail: `Ordner entfernt | Dateien: ${deletedFiles}, Ordner: ${deletedDirs}`,
|
||||
life: 3500
|
||||
});
|
||||
await loadOrphans({ silent: true });
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Löschen fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setOrphanDeleteBusyPath(null);
|
||||
}
|
||||
};
|
||||
const orphanTitleBody = (row) => (
|
||||
<div>
|
||||
<div><strong>{row.title || '-'}</strong></div>
|
||||
@@ -145,14 +190,26 @@ export default function DatabasePage() {
|
||||
</div>
|
||||
);
|
||||
const orphanActionBody = (row) => (
|
||||
<Button
|
||||
label="Job anlegen"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
onClick={() => handleImportOrphanRaw(row)}
|
||||
loading={orphanImportBusyPath === row.rawPath}
|
||||
disabled={Boolean(orphanImportBusyPath)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
label="Job anlegen"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
onClick={() => handleImportOrphanRaw(row)}
|
||||
loading={orphanImportBusyPath === row.rawPath}
|
||||
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
|
||||
/>
|
||||
<Button
|
||||
label="RAW löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => handleDeleteOrphanRaw(row)}
|
||||
loading={orphanDeleteBusyPath === row.rawPath}
|
||||
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -169,7 +226,7 @@ export default function DatabasePage() {
|
||||
icon="pi pi-search"
|
||||
onClick={loadOrphans}
|
||||
loading={orphanLoading}
|
||||
disabled={Boolean(orphanImportBusyPath)}
|
||||
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
|
||||
/>
|
||||
<Tag value={`${orphanRows.length} gefunden`} severity={orphanRows.length > 0 ? 'warning' : 'success'} />
|
||||
</div>
|
||||
@@ -190,7 +247,7 @@ export default function DatabasePage() {
|
||||
<Column field="entryCount" header="Dateien" style={{ width: '8rem' }} />
|
||||
<Column header="Pfad" body={orphanPathBody} style={{ minWidth: '22rem' }} />
|
||||
<Column field="lastModifiedAt" header="Geändert" style={{ width: '16rem' }} />
|
||||
<Column header="Aktion" body={orphanActionBody} style={{ width: '10rem' }} />
|
||||
<Column header="Aktion" body={orphanActionBody} style={{ minWidth: '18rem' }} />
|
||||
</DataTable>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
+208
-184
@@ -9,6 +9,7 @@ import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import JobDetailDialog from '../components/JobDetailDialog';
|
||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||
import CdMetadataDialog from '../components/CdMetadataDialog';
|
||||
@@ -459,33 +460,63 @@ function sanitizeRating(value) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
function findOmdbRatingBySource(omdbInfo, sourceName) {
|
||||
const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : [];
|
||||
const source = String(sourceName || '').trim().toLowerCase();
|
||||
const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === source);
|
||||
return sanitizeRating(entry?.Value);
|
||||
function normalizeRatingValue(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
const numericValue = Number(value);
|
||||
if (Number.isFinite(numericValue) && numericValue > 0) {
|
||||
return numericValue.toFixed(1);
|
||||
}
|
||||
return sanitizeRating(value);
|
||||
}
|
||||
|
||||
function resolveRatings(row) {
|
||||
const omdbInfo = row?.omdbInfo && typeof row.omdbInfo === 'object' ? row.omdbInfo : null;
|
||||
if (!omdbInfo) {
|
||||
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
|
||||
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
|
||||
? makemkvInfo.analyzeContext
|
||||
: {};
|
||||
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
||||
? analyzeContext.selectedMetadata
|
||||
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {});
|
||||
const planMetadata = encodePlan?.metadata && typeof encodePlan.metadata === 'object'
|
||||
? encodePlan.metadata
|
||||
: {};
|
||||
const selectedTmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object'
|
||||
? selectedMetadata.tmdbDetails
|
||||
: null;
|
||||
const planTmdbDetails = planMetadata?.tmdbDetails && typeof planMetadata.tmdbDetails === 'object'
|
||||
? planMetadata.tmdbDetails
|
||||
: null;
|
||||
const rowTmdbDetails = row?.tmdbDetails && typeof row.tmdbDetails === 'object'
|
||||
? row.tmdbDetails
|
||||
: null;
|
||||
const tmdbDetails = selectedTmdbDetails || planTmdbDetails || rowTmdbDetails;
|
||||
|
||||
const tmdbRating = normalizeRatingValue(
|
||||
tmdbDetails?.seasonVoteAverage
|
||||
?? tmdbDetails?.voteAverage
|
||||
?? tmdbDetails?.imdbRating
|
||||
?? selectedMetadata?.seasonVoteAverage
|
||||
?? selectedMetadata?.voteAverage
|
||||
?? selectedMetadata?.imdbRating
|
||||
?? planMetadata?.seasonVoteAverage
|
||||
?? planMetadata?.voteAverage
|
||||
?? planMetadata?.imdbRating
|
||||
?? row?.voteAverage
|
||||
?? row?.imdbRating
|
||||
);
|
||||
|
||||
if (!tmdbRating) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const imdb = sanitizeRating(omdbInfo?.imdbRating)
|
||||
|| findOmdbRatingBySource(omdbInfo, 'Internet Movie Database');
|
||||
const rotten = findOmdbRatingBySource(omdbInfo, 'Rotten Tomatoes');
|
||||
const metascore = sanitizeRating(omdbInfo?.Metascore);
|
||||
|
||||
const ratings = [];
|
||||
if (imdb) {
|
||||
ratings.push({ key: 'imdb', label: 'IMDb', value: imdb });
|
||||
}
|
||||
if (rotten) {
|
||||
ratings.push({ key: 'rt', label: 'RT', value: rotten });
|
||||
}
|
||||
if (metascore) {
|
||||
ratings.push({ key: 'meta', label: 'Meta', value: metascore });
|
||||
if (tmdbRating) {
|
||||
ratings.push({ key: 'tmdb', label: 'TMDb', value: tmdbRating });
|
||||
}
|
||||
return ratings;
|
||||
}
|
||||
@@ -553,7 +584,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const [downloadFolderBusyPath, setDownloadFolderBusyPath] = useState(null);
|
||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
|
||||
const [metadataAssignBusy, setMetadataAssignBusy] = useState(false);
|
||||
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
||||
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
||||
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
|
||||
@@ -568,6 +599,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const [conflictModalMode, setConflictModalMode] = useState('reencode'); // 'reencode' | 'delete'
|
||||
const [conflictModalBusy, setConflictModalBusy] = useState(false);
|
||||
const toastRef = useRef(null);
|
||||
const wsReloadTimerRef = useRef(null);
|
||||
const progressStateByJobRef = useRef(new Map());
|
||||
|
||||
const queuedJobIdSet = useMemo(() => {
|
||||
const next = new Set();
|
||||
@@ -638,6 +671,16 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleLiveReload = (delayMs = 120) => {
|
||||
if (wsReloadTimerRef.current) {
|
||||
return;
|
||||
}
|
||||
wsReloadTimerRef.current = setTimeout(() => {
|
||||
wsReloadTimerRef.current = null;
|
||||
void load();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api.getPref('history_layout').then((res) => {
|
||||
if (res?.value === 'list' || res?.value === 'grid') {
|
||||
@@ -654,6 +697,55 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, status, refreshToken]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (wsReloadTimerRef.current) {
|
||||
clearTimeout(wsReloadTimerRef.current);
|
||||
wsReloadTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useWebSocket({
|
||||
onMessage: (message) => {
|
||||
const type = String(message?.type || '').trim().toUpperCase();
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
type === 'PIPELINE_STATE_CHANGED'
|
||||
|| type === 'PIPELINE_QUEUE_CHANGED'
|
||||
|| type === 'DISC_DETECTED'
|
||||
|| type === 'DISC_REMOVED'
|
||||
) {
|
||||
scheduleLiveReload(80);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type !== 'PIPELINE_PROGRESS') {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = message?.payload && typeof message.payload === 'object'
|
||||
? message.payload
|
||||
: {};
|
||||
const normalizedJobId = normalizeJobId(payload?.activeJobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
const nextState = String(payload?.state || '').trim().toUpperCase();
|
||||
if (!nextState) {
|
||||
return;
|
||||
}
|
||||
const key = String(normalizedJobId);
|
||||
const prevState = progressStateByJobRef.current.get(key) || '';
|
||||
if (nextState === prevState) {
|
||||
return;
|
||||
}
|
||||
progressStateByJobRef.current.set(key, nextState);
|
||||
scheduleLiveReload(120);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const openJobId = Number(params.get('open') || 0);
|
||||
@@ -869,11 +961,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
} else if (action === 'restart_review') {
|
||||
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
|
||||
const isAudiobookJob = resolveMediaType(job) === 'audiobook';
|
||||
if (!skipConfirm) {
|
||||
const confirmed = await confirmModal({
|
||||
header: 'Review neu starten',
|
||||
message: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Ripper neu getroffen werden.`,
|
||||
acceptLabel: 'Review starten',
|
||||
header: isAudiobookJob ? 'Vorprüfung starten' : 'Review neu starten',
|
||||
message: isAudiobookJob
|
||||
? `Vorprüfung für "${title}" starten?\nDer Job wird neu angelegt und kann danach im Ripper vor dem Encode angepasst werden.`
|
||||
: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Ripper neu getroffen werden.`,
|
||||
acceptLabel: isAudiobookJob ? 'Vorprüfung starten' : 'Review starten',
|
||||
rejectLabel: 'Abbrechen'
|
||||
});
|
||||
if (!confirmed) {
|
||||
@@ -887,14 +982,21 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const replacementJobId = normalizeJobId(result?.jobId);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Review-Neustart',
|
||||
detail: 'Analyse gestartet. Job ist jetzt im Ripper verfügbar.',
|
||||
summary: isAudiobookJob ? 'Vorprüfung gestartet' : 'Review-Neustart',
|
||||
detail: isAudiobookJob
|
||||
? 'Job neu angelegt. Im Ripper können die Encode-Einstellungen angepasst werden.'
|
||||
: 'Analyse gestartet. Job ist jetzt im Ripper verfügbar.',
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
await refreshDetailAfterReplacement(job.id, replacementJobId);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: isAudiobookJob ? 'Vorprüfung fehlgeschlagen' : 'Review-Neustart fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
@@ -903,7 +1005,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (!skipConfirm) {
|
||||
const confirmed = await confirmModal({
|
||||
header: 'CD-Vorprüfung starten',
|
||||
message: `CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Ripper geöffnet.`,
|
||||
message: `CD-Vorprüfung für "${title}" starten?\nTrackauswahl und Ausgabeeinstellungen werden im Ripper geöffnet.`,
|
||||
acceptLabel: 'Vorprüfung starten',
|
||||
rejectLabel: 'Abbrechen'
|
||||
});
|
||||
@@ -917,7 +1019,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'CD-Vorprüfung gestartet',
|
||||
detail: 'Job ist jetzt im Ripper verfügbar — bitte Metadaten und Einstellungen wählen.',
|
||||
detail: 'Job ist jetzt im Ripper verfügbar — bitte Tracks und Einstellungen prüfen.',
|
||||
life: 4000
|
||||
});
|
||||
await load();
|
||||
@@ -1129,8 +1231,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadOutputFolder = async (row, folderPath) => {
|
||||
const jobId = Number(row?.id || selectedJob?.id || 0);
|
||||
const handleDownloadOutputFolder = async (row, folderPath, ownerJobId = null) => {
|
||||
const jobId = Number(ownerJobId || row?.id || selectedJob?.id || 0);
|
||||
if (!jobId || !folderPath) return;
|
||||
setDownloadFolderBusyPath(folderPath);
|
||||
try {
|
||||
@@ -1269,7 +1371,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignOmdb = (row) => {
|
||||
const handleAssignMetadata = (row) => {
|
||||
const jobId = Number(row?.id || 0);
|
||||
if (!jobId) return;
|
||||
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||
@@ -1291,11 +1393,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
: (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null);
|
||||
const rowMediaType = resolveMediaType(row);
|
||||
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
|
||||
const metadataProvider = workflowKind === 'series'
|
||||
? 'tmdb'
|
||||
: (workflowKind === 'film'
|
||||
? 'omdb'
|
||||
: (String(selectedMetadata?.metadataProvider || analyzeContext?.metadataProvider || (isSeriesDvd ? 'tmdb' : 'omdb')).trim().toLowerCase() || 'omdb'));
|
||||
const metadataProvider = 'tmdb';
|
||||
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
|
||||
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
|
||||
const context = {
|
||||
@@ -1326,47 +1424,63 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
...(discNumber ? { discNumber } : {})
|
||||
},
|
||||
metadataProvider,
|
||||
workflowKind: workflowKind || analyzeContext?.workflowKind || null,
|
||||
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
|
||||
seriesAnalysis: analyzeContext?.seriesAnalysis || null,
|
||||
seriesLookupHint: analyzeContext?.seriesLookupHint || null,
|
||||
omdbCandidates: []
|
||||
seriesDecision: analyzeContext?.seriesDecision || null
|
||||
};
|
||||
setMetadataDialogContext(context);
|
||||
setMetadataDialogVisible(true);
|
||||
};
|
||||
|
||||
const handleOmdbSearch = async (query, options = {}) => {
|
||||
const provider = String(
|
||||
options?.metadataProvider
|
||||
|| metadataDialogContext?.metadataProvider
|
||||
|| 'omdb'
|
||||
).trim().toLowerCase() || 'omdb';
|
||||
const handleMetadataSearch = async (query, options = {}) => {
|
||||
const workflow = String(
|
||||
options?.workflowKind
|
||||
|| metadataDialogContext?.selectedMetadata?.workflowKind
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
try {
|
||||
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
|
||||
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
|
||||
?? null;
|
||||
const response = provider === 'tmdb'
|
||||
const response = workflow === 'series'
|
||||
? await api.searchTmdbSeries(query, tmdbSeasonHint)
|
||||
: await api.searchOmdb(query);
|
||||
: await api.searchTmdbMovie(query);
|
||||
return response.results || [];
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: provider === 'tmdb' ? 'TMDb-Suche fehlgeschlagen' : 'OMDb-Suche fehlgeschlagen',
|
||||
summary: 'TMDb-Suche fehlgeschlagen',
|
||||
detail: error.message
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleOmdbSubmit = async (payload) => {
|
||||
const provider = String(payload?.metadataProvider || metadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
|
||||
const handleMetadataSubmit = async (payload) => {
|
||||
const submitMode = String(metadataDialogContext?.submitMode || 'assign').trim().toLowerCase() || 'assign';
|
||||
const isPipelineFlow = submitMode === 'pipeline';
|
||||
setOmdbAssignBusy(true);
|
||||
setMetadataAssignBusy(true);
|
||||
try {
|
||||
if (isPipelineFlow) {
|
||||
await api.selectMetadata(payload);
|
||||
const selectResponse = await api.selectMetadata(payload);
|
||||
const selectedJobUpdate = selectResponse?.job && typeof selectResponse.job === 'object'
|
||||
? selectResponse.job
|
||||
: null;
|
||||
if (selectedJobUpdate?.id) {
|
||||
const normalizedSelectedId = Number(selectedJobUpdate.id);
|
||||
setJobs((prev) => (
|
||||
Array.isArray(prev)
|
||||
? prev.map((row) => (Number(row?.id || 0) === normalizedSelectedId ? { ...row, ...selectedJobUpdate } : row))
|
||||
: prev
|
||||
));
|
||||
setSelectedJob((prev) => (
|
||||
Number(prev?.id || 0) === normalizedSelectedId
|
||||
? { ...(prev || {}), ...selectedJobUpdate }
|
||||
: prev
|
||||
));
|
||||
}
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
|
||||
@@ -1408,11 +1522,27 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api.assignJobOmdb(payload.jobId, payload);
|
||||
const assignResponse = await api.assignJobMetadata(payload.jobId, payload);
|
||||
const assignedJobUpdate = assignResponse?.job && typeof assignResponse.job === 'object'
|
||||
? assignResponse.job
|
||||
: null;
|
||||
if (assignedJobUpdate?.id) {
|
||||
const normalizedAssignedId = Number(assignedJobUpdate.id);
|
||||
setJobs((prev) => (
|
||||
Array.isArray(prev)
|
||||
? prev.map((row) => (Number(row?.id || 0) === normalizedAssignedId ? { ...row, ...assignedJobUpdate } : row))
|
||||
: prev
|
||||
));
|
||||
setSelectedJob((prev) => (
|
||||
Number(prev?.id || 0) === normalizedAssignedId
|
||||
? { ...(prev || {}), ...assignedJobUpdate }
|
||||
: prev
|
||||
));
|
||||
}
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Metadaten zugewiesen',
|
||||
detail: provider === 'tmdb' ? 'TMDb-Metadaten wurden aktualisiert.' : 'OMDb-Metadaten wurden aktualisiert.',
|
||||
detail: 'TMDb-Metadaten wurden aktualisiert.',
|
||||
life: 3000
|
||||
});
|
||||
setMetadataDialogVisible(false);
|
||||
@@ -1425,11 +1555,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: provider === 'tmdb' ? 'TMDb-Zuweisung fehlgeschlagen' : 'OMDb-Zuweisung fehlgeschlagen',
|
||||
summary: 'TMDb-Zuweisung fehlgeschlagen',
|
||||
detail: error.message
|
||||
});
|
||||
} finally {
|
||||
setOmdbAssignBusy(false);
|
||||
setMetadataAssignBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1451,9 +1581,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
setCdMetadataDialogVisible(true);
|
||||
};
|
||||
|
||||
const handleMusicBrainzSearch = async (query) => {
|
||||
const handleMusicBrainzSearch = async (query, options = {}) => {
|
||||
try {
|
||||
const response = await api.searchMusicBrainz(query);
|
||||
const response = await api.searchMusicBrainz(query, {
|
||||
trackCount: Number(options?.trackCount || 0) > 0 ? Math.trunc(Number(options.trackCount)) : null
|
||||
});
|
||||
return response.results || [];
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz-Suche fehlgeschlagen', detail: error.message });
|
||||
@@ -1594,71 +1726,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshDeleteEntryPreview = async (jobId, selectedJobIds) => {
|
||||
setDeleteEntryPreviewLoading(true);
|
||||
try {
|
||||
const response = await api.getJobDeletePreview(jobId, {
|
||||
includeRelated: deleteEntryIncludeRelated,
|
||||
selectedJobIds
|
||||
});
|
||||
const preview = response?.preview || null;
|
||||
const relatedJobs = Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [];
|
||||
const nextSelectedJobIds = Array.isArray(preview?.selectedJobIds)
|
||||
? preview.selectedJobIds.map((id) => normalizeJobId(id)).filter(Boolean)
|
||||
: relatedJobs
|
||||
.map((item) => normalizeJobId(item?.id))
|
||||
.filter(Boolean);
|
||||
setDeleteEntryPreview(preview);
|
||||
setDeleteEntrySelectedJobIds(nextSelectedJobIds);
|
||||
const rawCandidates = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : [];
|
||||
const defaultSelectedRawPaths = rawCandidates
|
||||
.filter((item) => Boolean(item?.exists))
|
||||
.map((item) => String(item?.path || '').trim())
|
||||
.filter(Boolean);
|
||||
setDeleteEntrySelectedRawPaths(defaultSelectedRawPaths);
|
||||
const movieCandidates = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [];
|
||||
const defaultSelectedMoviePaths = movieCandidates
|
||||
.filter((item) => Boolean(item?.exists))
|
||||
.map((item) => String(item?.path || '').trim())
|
||||
.filter(Boolean);
|
||||
setDeleteEntrySelectedMoviePaths(defaultSelectedMoviePaths);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Löschvorschau fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setDeleteEntryPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDeleteRelatedJobSelection = async (relatedJobId, checked) => {
|
||||
const normalizedRelatedJobId = normalizeJobId(relatedJobId);
|
||||
const dialogJobId = normalizeJobId(deleteEntryDialogRow?.id);
|
||||
if (!normalizedRelatedJobId || !dialogJobId) {
|
||||
return;
|
||||
}
|
||||
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
const knownJobIds = relatedJobs
|
||||
.map((item) => normalizeJobId(item?.id))
|
||||
.filter(Boolean);
|
||||
const currentSelected = new Set(
|
||||
deleteEntrySelectedJobIds
|
||||
.map((item) => normalizeJobId(item))
|
||||
.filter(Boolean)
|
||||
);
|
||||
if (checked) {
|
||||
currentSelected.add(normalizedRelatedJobId);
|
||||
} else {
|
||||
currentSelected.delete(normalizedRelatedJobId);
|
||||
}
|
||||
const orderedSelected = knownJobIds.filter((jobId) => currentSelected.has(jobId));
|
||||
setDeleteEntrySelectedJobIds(orderedSelected);
|
||||
await refreshDeleteEntryPreview(dialogJobId, orderedSelected);
|
||||
};
|
||||
|
||||
const confirmDeleteEntry = async (target) => {
|
||||
const normalizedTarget = String(target || '').trim().toLowerCase();
|
||||
if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) {
|
||||
@@ -1672,16 +1739,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
if (deleteEntryIncludeRelated && relatedJobs.length > 0 && deleteEntrySelectedJobIds.length === 0) {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Historie-Auswahl erforderlich',
|
||||
detail: 'Bitte mindestens einen Rip/Encode-Eintrag auswählen.',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(normalizedTarget === 'raw' || normalizedTarget === 'both')
|
||||
&& rawDeleteSelectionEnabled
|
||||
@@ -1761,16 +1818,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
if (deleteEntryIncludeRelated && relatedJobs.length > 0 && deleteEntrySelectedJobIds.length === 0) {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Historie-Auswahl erforderlich',
|
||||
detail: 'Bitte mindestens einen Rip/Encode-Eintrag auswählen.',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(normalizedTarget === 'raw' || normalizedTarget === 'both')
|
||||
&& rawDeleteSelectionEnabled
|
||||
@@ -2455,12 +2502,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
};
|
||||
|
||||
const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
const previewSelectedJobIds = Array.isArray(deleteEntryPreview?.selectedJobIds)
|
||||
? deleteEntryPreview.selectedJobIds.map((item) => normalizeJobId(item)).filter(Boolean)
|
||||
: previewRelatedJobs
|
||||
.filter((item) => Boolean(item?.selected))
|
||||
.map((item) => normalizeJobId(item?.id))
|
||||
.filter(Boolean);
|
||||
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
|
||||
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
|
||||
const deleteEntryIsMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow);
|
||||
@@ -2474,17 +2515,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
() => new Set(deleteEntrySelectedRawPaths.map((item) => String(item || '').trim()).filter(Boolean)),
|
||||
[deleteEntrySelectedRawPaths]
|
||||
);
|
||||
const selectedDeleteRelatedJobSet = useMemo(
|
||||
() => new Set(deleteEntrySelectedJobIds.map((item) => normalizeJobId(item)).filter(Boolean)),
|
||||
[deleteEntrySelectedJobIds]
|
||||
);
|
||||
const selectedDeleteMoviePathSet = useMemo(
|
||||
() => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)),
|
||||
[deleteEntrySelectedMoviePaths]
|
||||
);
|
||||
const relatedDeleteSelectionRequired = deleteEntryIncludeRelated
|
||||
&& previewRelatedJobs.length > 0
|
||||
&& deleteEntrySelectedJobIds.length === 0;
|
||||
const rawDeleteSelectionRequired = !deleteEntryIsMergeJob
|
||||
&& rawDeleteSelectionEnabled
|
||||
&& deleteEntrySelectedRawPaths.length === 0;
|
||||
@@ -2571,7 +2605,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
onRestartCdReview={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartCdReview}
|
||||
onReencode={resolveMediaType(selectedJob) === 'converter' ? null : handleReencode}
|
||||
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
|
||||
onAssignOmdb={handleAssignOmdb}
|
||||
onAssignMetadata={handleAssignMetadata}
|
||||
onAssignCdMetadata={handleAssignCdMetadata}
|
||||
onGenerateNfo={handleGenerateNfo}
|
||||
onAcknowledgeError={handleAcknowledgeError}
|
||||
@@ -2586,7 +2620,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
actionBusy={actionBusy}
|
||||
cancelBusy={actionBusy}
|
||||
restoreMergeBusy={actionBusy}
|
||||
omdbAssignBusy={omdbAssignBusy}
|
||||
metadataAssignBusy={metadataAssignBusy}
|
||||
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
||||
generateNfoBusy={generateNfoBusy}
|
||||
acknowledgeErrorBusy={acknowledgeErrorBusy}
|
||||
@@ -2612,7 +2646,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
modal
|
||||
>
|
||||
<p>
|
||||
{`Es sind ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge verknüpft. Ausgewählt: ${deleteEntrySelectedJobIds.length || previewSelectedJobIds.length || 0}.`}
|
||||
{`Es sind ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge im Lösch-Scope enthalten.`}
|
||||
</p>
|
||||
<small className="history-dv-subtle">
|
||||
{deleteEntryIsMergeJob
|
||||
@@ -2636,29 +2670,19 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
<ul className="history-delete-preview-list">
|
||||
{previewRelatedJobs.map((item) => (
|
||||
<li key={`delete-related-${item.id}`}>
|
||||
<label className="history-delete-preview-checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDeleteRelatedJobSet.has(normalizeJobId(item?.id))}
|
||||
onChange={(event) => toggleDeleteRelatedJobSelection(item.id, Boolean(event.target.checked))}
|
||||
/>
|
||||
<span>
|
||||
<strong>#{item.id}</strong>
|
||||
{' '}| {item.title || '-'}
|
||||
{' '}| {item.roleLabel || 'Job'}
|
||||
{' '}| {getStatusLabel(item.status)}
|
||||
{' '}{item.isPrimary ? '(aktuell)' : '(verknüpft)'}
|
||||
</span>
|
||||
</label>
|
||||
<span>
|
||||
<strong>#{item.id}</strong>
|
||||
{' '}| {item.title || '-'}
|
||||
{' '}| {item.roleLabel || 'Job'}
|
||||
{' '}| {getStatusLabel(item.status)}
|
||||
{' '}{item.isPrimary ? '(aktuell)' : '(verknüpft)'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<small className="history-dv-subtle">Keine verknüpften Alt-Einträge erkannt.</small>
|
||||
)}
|
||||
{relatedDeleteSelectionRequired ? (
|
||||
<small className="history-dv-subtle">Bitte mindestens einen Historien-Eintrag auswählen.</small>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!deleteEntryIsMergeJob ? (
|
||||
@@ -2751,7 +2775,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('movie')}
|
||||
loading={deleteEntryTargetBusy === 'movie'}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -2762,7 +2786,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('raw')}
|
||||
loading={deleteEntryTargetBusy === 'raw'}
|
||||
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired}
|
||||
/>
|
||||
<Button
|
||||
label={`Eintrag + nur ${deleteEntryOutputShortLabel}`}
|
||||
@@ -2771,7 +2795,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('movie')}
|
||||
loading={deleteEntryTargetBusy === 'movie'}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
||||
/>
|
||||
<Button
|
||||
label={`Eintrag + RAW & ${deleteEntryOutputShortLabel}`}
|
||||
@@ -2779,7 +2803,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
severity="danger"
|
||||
onClick={() => confirmDeleteEntry('both')}
|
||||
loading={deleteEntryTargetBusy === 'both'}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -2791,7 +2815,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteFilesOnly('both')}
|
||||
loading={deleteEntryTargetBusy === 'files-both'}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -2801,7 +2825,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('none')}
|
||||
loading={deleteEntryTargetBusy === 'none'}
|
||||
disabled={deleteTargetActionsDisabled || relatedDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled}
|
||||
/>
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
@@ -2820,9 +2844,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
}}
|
||||
onSubmit={handleOmdbSubmit}
|
||||
onSearch={handleOmdbSearch}
|
||||
busy={omdbAssignBusy}
|
||||
onSubmit={handleMetadataSubmit}
|
||||
onSearch={handleMetadataSearch}
|
||||
busy={metadataAssignBusy}
|
||||
/>
|
||||
|
||||
<CdMetadataDialog
|
||||
|
||||
+601
-175
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,53 @@ function normalizeUserPresetDefaultsMap(raw = {}) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRunningChainIds(rawPayload) {
|
||||
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
|
||||
const active = Array.isArray(payload.active) ? payload.active : [];
|
||||
const ids = [];
|
||||
const seen = new Set();
|
||||
for (const item of active) {
|
||||
const type = String(item?.type || '').trim().toLowerCase();
|
||||
if (type !== 'chain') {
|
||||
continue;
|
||||
}
|
||||
const chainIdRaw = Number(item?.chainId || 0);
|
||||
if (!Number.isFinite(chainIdRaw) || chainIdRaw <= 0) {
|
||||
continue;
|
||||
}
|
||||
const chainId = Math.trunc(chainIdRaw);
|
||||
if (seen.has(chainId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(chainId);
|
||||
ids.push(chainId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function isLikelyNetworkRequestError(error) {
|
||||
if (!error) {
|
||||
return false;
|
||||
}
|
||||
const status = Number(error?.status);
|
||||
if (Number.isFinite(status) && status > 0) {
|
||||
return false;
|
||||
}
|
||||
const message = String(error?.message || '').trim().toLowerCase();
|
||||
if (!message) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
message.includes('failed to fetch')
|
||||
|| message.includes('networkerror')
|
||||
|| message.includes('network error')
|
||||
|| message.includes('load failed')
|
||||
|| message.includes('timeout')
|
||||
|| message.includes('aborted')
|
||||
|| message.includes('socket hang up')
|
||||
);
|
||||
}
|
||||
|
||||
function isPresetCompatibleWithSlot(preset, slotMediaType) {
|
||||
const mediaType = String(preset?.mediaType || '').trim().toLowerCase();
|
||||
if (mediaType === 'all') {
|
||||
@@ -272,7 +319,8 @@ export default function SettingsPage() {
|
||||
const [chainSaving, setChainSaving] = useState(false);
|
||||
const [chainReordering, setChainReordering] = useState(false);
|
||||
const [chainListDragSourceId, setChainListDragSourceId] = useState(null);
|
||||
const [chainActionBusyId, setChainActionBusyId] = useState(null);
|
||||
const [chainActionBusyIds, setChainActionBusyIds] = useState(() => new Set());
|
||||
const [runningChainIds, setRunningChainIds] = useState([]);
|
||||
const [lastChainTestResult, setLastChainTestResult] = useState(null);
|
||||
const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] });
|
||||
const [chainEditorErrors, setChainEditorErrors] = useState({});
|
||||
@@ -328,6 +376,27 @@ export default function SettingsPage() {
|
||||
}
|
||||
return next;
|
||||
}, [userPresets, userPresetDefaultsDraft]);
|
||||
const runningChainIdSet = useMemo(
|
||||
() => new Set(Array.isArray(runningChainIds) ? runningChainIds : []),
|
||||
[runningChainIds]
|
||||
);
|
||||
|
||||
const setChainActionBusy = (chainId, busyFlag) => {
|
||||
const normalizedId = Number(chainId);
|
||||
if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
|
||||
return;
|
||||
}
|
||||
const targetId = Math.trunc(normalizedId);
|
||||
setChainActionBusyIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (busyFlag) {
|
||||
next.add(targetId);
|
||||
} else {
|
||||
next.delete(targetId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const loadScripts = async ({ silent = false } = {}) => {
|
||||
if (!silent) {
|
||||
@@ -404,6 +473,15 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshRunningChains = async () => {
|
||||
try {
|
||||
const response = await api.getRuntimeActivities();
|
||||
setRunningChainIds(normalizeRunningChainIds(response));
|
||||
} catch (_error) {
|
||||
// ignore polling errors
|
||||
}
|
||||
};
|
||||
|
||||
const openNewUserPreset = () => {
|
||||
setUserPresetEditor({ open: true, id: null, name: '', mediaType: 'all', handbrakePreset: '', extraArgs: '', description: '' });
|
||||
setUserPresetErrors({});
|
||||
@@ -546,7 +624,8 @@ export default function SettingsPage() {
|
||||
const presetsPromise = api.getHandBrakePresets();
|
||||
const scriptsPromise = api.getScripts();
|
||||
const chainsPromise = api.getScriptChains();
|
||||
const [scriptsResponse, chainsResponse] = await Promise.allSettled([scriptsPromise, chainsPromise]);
|
||||
const runtimePromise = api.getRuntimeActivities();
|
||||
const [scriptsResponse, chainsResponse, runtimeResponse] = await Promise.allSettled([scriptsPromise, chainsPromise, runtimePromise]);
|
||||
if (scriptsResponse.status === 'fulfilled') {
|
||||
setScripts(Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []);
|
||||
} else {
|
||||
@@ -559,6 +638,9 @@ export default function SettingsPage() {
|
||||
if (chainsResponse.status === 'fulfilled') {
|
||||
setChains(Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []);
|
||||
}
|
||||
if (runtimeResponse.status === 'fulfilled') {
|
||||
setRunningChainIds(normalizeRunningChainIds(runtimeResponse.value));
|
||||
}
|
||||
|
||||
presetsPromise
|
||||
.then((presetPayload) => {
|
||||
@@ -593,6 +675,25 @@ export default function SettingsPage() {
|
||||
loadUserPresetDefaults();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const pollRuntime = async () => {
|
||||
try {
|
||||
const response = await api.getRuntimeActivities();
|
||||
if (!cancelled) {
|
||||
setRunningChainIds(normalizeRunningChainIds(response));
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore polling errors
|
||||
}
|
||||
};
|
||||
const timer = setInterval(pollRuntime, 2500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dirtyKeys = useMemo(() => {
|
||||
const keys = new Set();
|
||||
const allKeys = new Set([...Object.keys(initialValues), ...Object.keys(draftValues)]);
|
||||
@@ -974,7 +1075,8 @@ export default function SettingsPage() {
|
||||
if (!Number.isFinite(chainId) || chainId <= 0) {
|
||||
return;
|
||||
}
|
||||
setChainActionBusyId(chainId);
|
||||
const normalizedChainId = Math.trunc(chainId);
|
||||
setChainActionBusy(normalizedChainId, true);
|
||||
setLastChainTestResult(null);
|
||||
try {
|
||||
const response = await api.testScriptChain(chainId);
|
||||
@@ -994,9 +1096,27 @@ export default function SettingsPage() {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isLikelyNetworkRequestError(error)) {
|
||||
try {
|
||||
const runtimeResponse = await api.getRuntimeActivities();
|
||||
const activeIds = normalizeRunningChainIds(runtimeResponse);
|
||||
setRunningChainIds(activeIds);
|
||||
if (activeIds.includes(normalizedChainId)) {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Ketten-Test läuft weiter',
|
||||
detail: `"${chain?.name || chainId}" läuft im Hintergrund weiter. Der Test-Request wurde vorzeitig beendet (z.B. Timeout/Verbindungsabbruch).`
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (_runtimeError) {
|
||||
// ignore follow-up runtime fetch errors and show default error toast below
|
||||
}
|
||||
}
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Ketten-Test fehlgeschlagen', detail: error.message });
|
||||
} finally {
|
||||
setChainActionBusyId(null);
|
||||
setChainActionBusy(normalizedChainId, false);
|
||||
refreshRunningChains();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1121,7 +1241,7 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
const handleChainListDragStart = (event, chainId) => {
|
||||
if (chainSaving || chainsLoading || chainReordering || Boolean(chainActionBusyId)) {
|
||||
if (chainSaving || chainsLoading || chainReordering) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -1221,8 +1341,7 @@ export default function SettingsPage() {
|
||||
|| Boolean(scriptActionBusyId);
|
||||
const chainListDnDDisabled = chainSaving
|
||||
|| chainsLoading
|
||||
|| chainReordering
|
||||
|| Boolean(chainActionBusyId);
|
||||
|| chainReordering;
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
@@ -1555,6 +1674,10 @@ export default function SettingsPage() {
|
||||
<div className="script-order-list">
|
||||
{chains.map((chain, index) => {
|
||||
const isDragging = Number(chainListDragSourceId) === Number(chain.id);
|
||||
const chainId = Number(chain?.id);
|
||||
const normalizedChainId = Number.isFinite(chainId) && chainId > 0 ? Math.trunc(chainId) : null;
|
||||
const isChainRuntimeRunning = normalizedChainId !== null && runningChainIdSet.has(normalizedChainId);
|
||||
const isChainActionBusy = normalizedChainId !== null && chainActionBusyIds.has(normalizedChainId);
|
||||
return (
|
||||
<div key={chain.id} className="script-order-wrapper">
|
||||
<div
|
||||
@@ -1596,15 +1719,15 @@ export default function SettingsPage() {
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => openChainEditor(chain)}
|
||||
disabled={chainReordering || Boolean(chainActionBusyId)}
|
||||
disabled={chainReordering}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-play"
|
||||
label="Test"
|
||||
severity="info"
|
||||
onClick={() => handleTestChain(chain)}
|
||||
loading={chainActionBusyId === chain.id}
|
||||
disabled={chainReordering || (Boolean(chainActionBusyId) && chainActionBusyId !== chain.id)}
|
||||
loading={isChainActionBusy}
|
||||
disabled={chainReordering || isChainActionBusy || isChainRuntimeRunning}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
@@ -1612,7 +1735,7 @@ export default function SettingsPage() {
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => handleDeleteChain(chain)}
|
||||
disabled={chainReordering || Boolean(chainActionBusyId)}
|
||||
disabled={chainReordering}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { api } from '../api/client';
|
||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||
import { isSeriesVideoJob } from '../utils/jobTaxonomy';
|
||||
|
||||
function normalizeJobId(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,
|
||||
encodePlan?.mediaProfile,
|
||||
row?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||
row?.makemkvInfo?.mediaProfile,
|
||||
row?.mediainfoInfo?.mediaProfile
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const raw = String(candidate || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
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 (raw === 'converter') {
|
||||
return 'converter';
|
||||
}
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function buildMetadataContextFromJob(row) {
|
||||
const jobId = normalizeJobId(row?.id);
|
||||
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
|
||||
? makemkvInfo.analyzeContext
|
||||
: {};
|
||||
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
||||
? analyzeContext.selectedMetadata
|
||||
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||
? makemkvInfo.selectedMetadata
|
||||
: {});
|
||||
const workflowKindRaw = String(
|
||||
selectedMetadata?.workflowKind
|
||||
|| analyzeContext?.workflowKind
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
const workflowKind = ['film', 'movie', 'feature'].includes(workflowKindRaw)
|
||||
? 'film'
|
||||
: (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null);
|
||||
const rowMediaType = resolveMediaType(row);
|
||||
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
|
||||
const metadataProvider = 'tmdb';
|
||||
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
|
||||
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
|
||||
|
||||
return {
|
||||
jobId,
|
||||
status: row?.status || null,
|
||||
lastState: row?.last_state || null,
|
||||
submitMode: 'assign',
|
||||
mediaProfile: rowMediaType,
|
||||
detectedTitle: row?.detected_title || row?.title || '',
|
||||
selectedMetadata: {
|
||||
...(selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}),
|
||||
title: row?.title || selectedMetadata?.title || row?.detected_title || '',
|
||||
year: row?.year || selectedMetadata?.year || null,
|
||||
imdbId: row?.imdb_id || selectedMetadata?.imdbId || null,
|
||||
poster: row?.poster_url || selectedMetadata?.poster || null,
|
||||
metadataProvider,
|
||||
workflowKind,
|
||||
tmdbId: selectedMetadata?.tmdbId || null,
|
||||
providerId: selectedMetadata?.providerId || null,
|
||||
metadataKind: selectedMetadata?.metadataKind || (isSeriesDvd ? 'season' : null),
|
||||
seasonNumber,
|
||||
seasonName: selectedMetadata?.seasonName || null,
|
||||
episodeCount: selectedMetadata?.episodeCount || 0,
|
||||
episodes: Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes : [],
|
||||
...(discNumber ? { discNumber } : {})
|
||||
},
|
||||
metadataProvider,
|
||||
workflowKind: workflowKind || analyzeContext?.workflowKind || null,
|
||||
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
|
||||
seriesAnalysis: analyzeContext?.seriesAnalysis || null,
|
||||
seriesLookupHint: analyzeContext?.seriesLookupHint || null,
|
||||
seriesDecision: analyzeContext?.seriesDecision || null
|
||||
};
|
||||
}
|
||||
|
||||
export default function TmdbMigrationPage() {
|
||||
const toastRef = useRef(null);
|
||||
const countdownIntervalRef = useRef(null);
|
||||
const nextJobTimeoutRef = useRef(null);
|
||||
const jobsRef = useRef([]);
|
||||
const runningRef = useRef(false);
|
||||
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||
const [metadataAssignBusy, setMetadataAssignBusy] = useState(false);
|
||||
const [cooldownRemaining, setCooldownRemaining] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
jobsRef.current = Array.isArray(jobs) ? jobs : [];
|
||||
}, [jobs]);
|
||||
|
||||
useEffect(() => {
|
||||
runningRef.current = running;
|
||||
}, [running]);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
countdownIntervalRef.current = null;
|
||||
}
|
||||
if (nextJobTimeoutRef.current) {
|
||||
clearTimeout(nextJobTimeoutRef.current);
|
||||
nextJobTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
clearTimers();
|
||||
}, [clearTimers]);
|
||||
|
||||
const loadPendingJobs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getTmdbMigrationPendingJobs({ limit: 1000 });
|
||||
const rows = Array.isArray(response?.jobs) ? response.jobs : [];
|
||||
setJobs(rows);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Laden fehlgeschlagen',
|
||||
detail: error?.message || 'TMDb-Migrationsliste konnte nicht geladen werden.'
|
||||
});
|
||||
setJobs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPendingJobs();
|
||||
}, [loadPendingJobs]);
|
||||
|
||||
const openJobInDialog = useCallback((job) => {
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
const context = buildMetadataContextFromJob(job);
|
||||
if (!context?.jobId) {
|
||||
return;
|
||||
}
|
||||
setMetadataDialogContext(context);
|
||||
setMetadataDialogVisible(true);
|
||||
}, []);
|
||||
|
||||
const scheduleNextJob = useCallback((nextJob, seconds = 10) => {
|
||||
clearTimers();
|
||||
const normalizedSeconds = Math.max(0, Math.trunc(Number(seconds || 0)));
|
||||
if (!nextJob) {
|
||||
setRunning(false);
|
||||
setCooldownRemaining(0);
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setCooldownRemaining(normalizedSeconds);
|
||||
if (normalizedSeconds > 0) {
|
||||
countdownIntervalRef.current = setInterval(() => {
|
||||
setCooldownRemaining((prev) => {
|
||||
const next = Number(prev || 0) - 1;
|
||||
return next > 0 ? next : 0;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
nextJobTimeoutRef.current = setTimeout(() => {
|
||||
clearTimers();
|
||||
setCooldownRemaining(0);
|
||||
if (!runningRef.current) {
|
||||
return;
|
||||
}
|
||||
openJobInDialog(nextJob);
|
||||
}, normalizedSeconds * 1000);
|
||||
}, [clearTimers, openJobInDialog]);
|
||||
|
||||
const startSequence = () => {
|
||||
const pending = jobsRef.current;
|
||||
if (!Array.isArray(pending) || pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
clearTimers();
|
||||
setRunning(true);
|
||||
setMetadataDialogVisible(true);
|
||||
setCooldownRemaining(0);
|
||||
openJobInDialog(pending[0]);
|
||||
};
|
||||
|
||||
const stopSequence = useCallback(() => {
|
||||
clearTimers();
|
||||
setRunning(false);
|
||||
setCooldownRemaining(0);
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
}, [clearTimers]);
|
||||
|
||||
const handleMetadataSearch = async (query, options = {}) => {
|
||||
const workflow = String(
|
||||
options?.workflowKind
|
||||
|| metadataDialogContext?.selectedMetadata?.workflowKind
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
try {
|
||||
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
|
||||
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
|
||||
?? null;
|
||||
const response = workflow === 'series'
|
||||
? await api.searchTmdbSeries(query, tmdbSeasonHint)
|
||||
: await api.searchTmdbMovie(query);
|
||||
return response?.results || [];
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'TMDb-Suche fehlgeschlagen',
|
||||
detail: error?.message || 'Suche fehlgeschlagen.'
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleMetadataSubmit = async (payload) => {
|
||||
const normalizedJobId = normalizeJobId(payload?.jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMetadataAssignBusy(true);
|
||||
clearTimers();
|
||||
setCooldownRemaining(0);
|
||||
|
||||
try {
|
||||
await api.assignJobMetadata(normalizedJobId, payload || {});
|
||||
|
||||
const remaining = jobsRef.current.filter((row) => normalizeJobId(row?.id) !== normalizedJobId);
|
||||
jobsRef.current = remaining;
|
||||
setJobs(remaining);
|
||||
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Job migriert',
|
||||
detail: `Job #${normalizedJobId} wurde auf TMDb aktualisiert.`,
|
||||
life: 2600
|
||||
});
|
||||
|
||||
if (remaining.length === 0) {
|
||||
setRunning(false);
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Migration abgeschlossen',
|
||||
detail: 'Alle ausstehenden Jobs wurden abgearbeitet.',
|
||||
life: 3200
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runningRef.current) {
|
||||
return;
|
||||
}
|
||||
scheduleNextJob(remaining[0], 10);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Migration fehlgeschlagen',
|
||||
detail: error?.message || 'Metadaten konnten nicht übernommen werden.'
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
setMetadataAssignBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const mediumTagBody = (row) => {
|
||||
const mediaType = resolveMediaType(row);
|
||||
const label = mediaType === 'bluray' ? 'Blu-ray' : mediaType === 'dvd' ? 'DVD' : mediaType === 'converter' ? 'Converter' : 'Other';
|
||||
return <Tag value={label} severity={mediaType === 'converter' ? 'info' : 'warning'} />;
|
||||
};
|
||||
|
||||
const titleBody = (row) => String(row?.title || row?.detected_title || `Job #${row?.id || '-'}`).trim() || '-';
|
||||
|
||||
const countdownProgressValue = useMemo(() => {
|
||||
if (cooldownRemaining <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const elapsed = 10 - Math.min(10, cooldownRemaining);
|
||||
return Math.max(0, Math.min(100, Math.round((elapsed / 10) * 100)));
|
||||
}, [cooldownRemaining]);
|
||||
|
||||
return (
|
||||
<div className="page-grid tmdb-migration-page">
|
||||
<Toast ref={toastRef} position="top-right" />
|
||||
|
||||
<Card title="TMDb Migration (Direktlink)" subTitle="Diese Seite ist nicht in der Navigation verlinkt.">
|
||||
<div className="tmdb-migration-toolbar">
|
||||
<div className="tmdb-migration-toolbar-left">
|
||||
<Tag value={`${jobs.length} offen`} severity={jobs.length > 0 ? 'warning' : 'success'} />
|
||||
{running ? <Tag value="Sequenz aktiv" severity="info" /> : null}
|
||||
</div>
|
||||
<div className="tmdb-migration-toolbar-actions">
|
||||
<Button
|
||||
label="Liste neu laden"
|
||||
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
|
||||
outlined
|
||||
onClick={() => {
|
||||
if (!metadataAssignBusy) {
|
||||
void loadPendingJobs();
|
||||
}
|
||||
}}
|
||||
disabled={metadataAssignBusy}
|
||||
/>
|
||||
{!running ? (
|
||||
<Button
|
||||
label="Start"
|
||||
icon="pi pi-play"
|
||||
onClick={startSequence}
|
||||
disabled={jobs.length === 0 || loading || metadataAssignBusy}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
label="Stop"
|
||||
icon="pi pi-stop"
|
||||
severity="secondary"
|
||||
onClick={stopSequence}
|
||||
disabled={metadataAssignBusy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{running && cooldownRemaining > 0 ? (
|
||||
<div className="tmdb-migration-countdown">
|
||||
<ProgressSpinner style={{ width: '2rem', height: '2rem' }} strokeWidth="5" />
|
||||
<div className="tmdb-migration-countdown-copy">
|
||||
<strong>Nächste Suche startet in {cooldownRemaining}s</strong>
|
||||
<small>API-Wartezeit aktiv (mind. 10 Sekunden zwischen Jobs).</small>
|
||||
</div>
|
||||
<div className="tmdb-migration-countdown-progress" aria-label="Cooldown Fortschritt">
|
||||
<div className="tmdb-migration-countdown-bar" style={{ width: `${countdownProgressValue}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DataTable
|
||||
value={jobs}
|
||||
loading={loading}
|
||||
dataKey="id"
|
||||
emptyMessage="Keine offenen Jobs für TMDb-Migration."
|
||||
className="tmdb-migration-table"
|
||||
rows={50}
|
||||
paginator={jobs.length > 50}
|
||||
>
|
||||
<Column field="id" header="ID" style={{ width: '6rem' }} body={(row) => `#${row?.id || '-'}`} />
|
||||
<Column header="Titel" body={titleBody} />
|
||||
<Column field="year" header="Jahr" style={{ width: '8rem' }} body={(row) => row?.year || '-'} />
|
||||
<Column header="Medium" body={mediumTagBody} style={{ width: '10rem' }} />
|
||||
<Column
|
||||
header="Status"
|
||||
style={{ width: '14rem' }}
|
||||
body={(row) => String(row?.status || row?.last_state || '-').trim() || '-'}
|
||||
/>
|
||||
</DataTable>
|
||||
</Card>
|
||||
|
||||
<MetadataSelectionDialog
|
||||
visible={metadataDialogVisible}
|
||||
context={metadataDialogContext}
|
||||
onHide={stopSequence}
|
||||
onSubmit={handleMetadataSubmit}
|
||||
onSearch={handleMetadataSearch}
|
||||
busy={metadataAssignBusy || (running && cooldownRemaining > 0)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user