0.13.0 DVD Series

This commit is contained in:
2026-04-10 18:44:41 +00:00
parent 43702b0138
commit 974b7bfad3
36 changed files with 9666 additions and 739 deletions
+200 -46
View File
@@ -23,11 +23,13 @@ import {
STATUS_FILTER_OPTIONS
} from '../utils/statusPresentation';
import { confirmModal } from '../utils/confirmModal';
import { isSeriesDvdJob } from '../utils/jobTaxonomy';
const MEDIA_FILTER_OPTIONS = [
{ label: 'Alle Medien', value: '' },
{ label: 'Blu-ray', value: 'bluray' },
{ label: 'DVD', value: 'dvd' },
{ label: 'DVD Serie', value: 'dvd_series' },
{ label: 'Audio CD', value: 'cd' },
{ label: 'Audiobook', value: 'audiobook' },
{ label: 'Sonstiges', value: 'other' }
@@ -123,6 +125,7 @@ function resolveMediaType(row) {
function resolveMediaTypeMeta(row) {
const mediaType = resolveMediaType(row);
const isSeriesDvd = mediaType === 'dvd' && isSeriesDvdJob(row);
if (mediaType === 'bluray') {
return {
mediaType,
@@ -135,8 +138,8 @@ function resolveMediaTypeMeta(row) {
return {
mediaType,
icon: discIndicatorIcon,
label: 'DVD',
alt: 'DVD'
label: isSeriesDvd ? 'DVD Serie' : 'DVD',
alt: isSeriesDvd ? 'DVD Serie' : 'DVD'
};
}
if (mediaType === 'cd') {
@@ -171,6 +174,14 @@ function resolveMediaTypeMeta(row) {
};
}
function isSeriesBatchChildHistoryRow(row) {
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null;
if (!encodePlan) {
return false;
}
return Boolean(encodePlan?.seriesBatchChild || encodePlan?.seriesBatchVirtualEpisode);
}
function formatDurationSeconds(totalSeconds) {
const parsed = Number(totalSeconds);
if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -438,10 +449,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
const [pendingRestartRow, setPendingRestartRow] = useState(null);
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
const [acknowledgeErrorBusy, setAcknowledgeErrorBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [queuedJobIds, setQueuedJobIds] = useState([]);
const [conflictModalVisible, setConflictModalVisible] = useState(false);
@@ -464,19 +475,26 @@ export default function HistoryPage({ refreshToken = 0 }) {
}, [queuedJobIds]);
const preparedJobs = useMemo(
() => jobs.map((job) => ({
...job,
sortTitle: normalizeSortText(job?.title || job?.detected_title || ''),
sortMediaType: resolveMediaType(job),
sortStartTime: resolveSortTimestamp(job?.start_time, job?.updated_at, job?.created_at, job?.end_time),
sortEndTime: resolveSortTimestamp(job?.end_time, job?.updated_at, job?.created_at, job?.start_time)
})),
() => jobs
.filter((job) => !isSeriesBatchChildHistoryRow(job))
.map((job) => ({
...job,
sortTitle: normalizeSortText(job?.title || job?.detected_title || ''),
sortMediaType: resolveMediaType(job),
sortStartTime: resolveSortTimestamp(job?.start_time, job?.updated_at, job?.created_at, job?.end_time),
sortEndTime: resolveSortTimestamp(job?.end_time, job?.updated_at, job?.created_at, job?.start_time)
})),
[jobs]
);
const visibleJobs = useMemo(
() => (mediumFilter
? preparedJobs.filter((job) => job.sortMediaType === mediumFilter)
? preparedJobs.filter((job) => {
if (mediumFilter === 'dvd_series') {
return job.sortMediaType === 'dvd' && isSeriesDvdJob(job);
}
return job.sortMediaType === mediumFilter;
})
: preparedJobs),
[preparedJobs, mediumFilter]
);
@@ -1003,12 +1021,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
};
const handleReencode = async (row) => {
const mediaType = resolveMediaType(row);
if (mediaType === 'bluray' || mediaType === 'dvd') {
setPendingRestartRow(row);
handleAssignOmdb(row);
return;
}
if (await maybeOpenOutputConflictModal(row, 'reencode')) {
return;
}
@@ -1086,50 +1098,93 @@ export default function HistoryPage({ refreshToken = 0 }) {
const handleAssignOmdb = (row) => {
const jobId = Number(row?.id || 0);
if (!jobId) return;
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 isSeriesDvd = resolveMediaType(row) === 'dvd' && isSeriesDvdJob(row);
const metadataProvider = isSeriesDvd ? 'tmdb' : 'omdb';
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
const context = {
jobId,
detectedTitle: row?.detected_title || row?.title || '',
selectedMetadata: {
title: row?.title || row?.detected_title || '',
year: row?.year || null,
imdbId: row?.imdb_id || null,
poster: row?.poster_url || null
...(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,
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,
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
seriesAnalysis: analyzeContext?.seriesAnalysis || null,
seriesLookupHint: analyzeContext?.seriesLookupHint || null,
omdbCandidates: []
};
setMetadataDialogContext(context);
setMetadataDialogVisible(true);
};
const handleOmdbSearch = async (query) => {
const handleOmdbSearch = async (query, options = {}) => {
const provider = String(
options?.metadataProvider
|| metadataDialogContext?.metadataProvider
|| 'omdb'
).trim().toLowerCase() || 'omdb';
try {
const response = await api.searchOmdb(query);
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
?? null;
const response = provider === 'tmdb'
? await api.searchTmdbSeries(query, tmdbSeasonHint)
: await api.searchOmdb(query);
return response.results || [];
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Suche fehlgeschlagen', detail: error.message });
toastRef.current?.show({
severity: 'error',
summary: provider === 'tmdb' ? 'TMDb-Suche fehlgeschlagen' : 'OMDb-Suche fehlgeschlagen',
detail: error.message
});
return [];
}
};
const handleOmdbSubmit = async (payload) => {
const provider = String(payload?.metadataProvider || metadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
setOmdbAssignBusy(true);
try {
await api.assignJobOmdb(payload.jobId, payload);
toastRef.current?.show({ severity: 'success', summary: 'Metadaten zugewiesen', detail: 'OMDB-Metadaten wurden aktualisiert.', life: 3000 });
toastRef.current?.show({
severity: 'success',
summary: 'Metadaten zugewiesen',
detail: provider === 'tmdb' ? 'TMDb-Metadaten wurden aktualisiert.' : 'OMDb-Metadaten wurden aktualisiert.',
life: 3000
});
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
await load();
await refreshDetailIfOpen(payload.jobId);
const rowToRestart = pendingRestartRow;
if (rowToRestart && Number(rowToRestart.id) === Number(payload.jobId)) {
setPendingRestartRow(null);
if (!(await maybeOpenOutputConflictModal(rowToRestart, 'reencode'))) {
await executeHistoryAction(rowToRestart, 'reencode');
}
}
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Zuweisung fehlgeschlagen', detail: error.message });
toastRef.current?.show({
severity: 'error',
summary: provider === 'tmdb' ? 'TMDb-Zuweisung fehlgeschlagen' : 'OMDb-Zuweisung fehlgeschlagen',
detail: error.message
});
} finally {
setOmdbAssignBusy(false);
}
@@ -1189,6 +1244,37 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const handleAcknowledgeError = async (job) => {
const jobId = normalizeJobId(job?.id || selectedJob?.id);
if (!jobId) {
return;
}
setAcknowledgeErrorBusy(true);
try {
const response = await api.acknowledgeJobError(jobId);
const updated = response?.job || null;
if (updated) {
setSelectedJob(updated);
}
await load();
toastRef.current?.show({
severity: 'success',
summary: 'Fehler quittiert',
detail: `Job #${jobId} wurde quittiert.`,
life: 2500
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Quittierung fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setAcknowledgeErrorBusy(false);
}
};
const closeDeleteEntryDialog = () => {
if (deleteEntryTargetBusy) {
return;
@@ -1373,12 +1459,56 @@ export default function HistoryPage({ refreshToken = 0 }) {
return <div className="history-dv-poster-fallback">{isAudioSquare ? 'Kein Cover' : 'Kein Poster'}</div>;
};
const renderPresenceChip = (label, available) => (
<span className={`history-dv-chip ${available ? 'tone-ok' : 'tone-no'}`}>
<i className={`pi ${available ? 'pi-check-circle' : 'pi-times-circle'}`} aria-hidden="true" />
<span>{label}: {available ? 'Ja' : 'Nein'}</span>
</span>
);
const renderPresenceChip = (label, available, tone = null, suffix = '') => {
const resolvedTone = tone || (available ? 'tone-ok' : 'tone-no');
const iconClass = available ? 'pi-check-circle' : 'pi-times-circle';
return (
<span className={`history-dv-chip ${resolvedTone}`}>
<i className={`pi ${iconClass}`} aria-hidden="true" />
<span>{label}: {available ? 'Ja' : 'Nein'}{suffix}</span>
</span>
);
};
const renderSeriesOutputChip = (row) => {
const summary = row?.seriesOutputSummary || null;
if (!summary) {
return renderPresenceChip('Episoden', Boolean(row?.outputStatus?.exists));
}
const expected = Number(summary.expected || 0);
const existing = Number(summary.existing || 0);
const hasExpected = Number.isFinite(expected) && expected > 0;
const safeExpected = hasExpected ? expected : 0;
const safeExisting = Number.isFinite(existing) && existing >= 0 ? existing : 0;
const suffix = hasExpected ? ` (${safeExisting}/${safeExpected})` : '';
if (safeExisting <= 0) {
return renderPresenceChip('Episoden', false, 'tone-no', suffix || ' (0/0)');
}
if (hasExpected && safeExisting < safeExpected) {
return renderPresenceChip('Episoden', true, 'tone-warn', suffix);
}
return renderPresenceChip('Episoden', true, 'tone-ok', suffix || ` (${safeExisting}/${safeExpected || safeExisting})`);
};
const renderSeriesRawChip = (row) => {
const summary = row?.seriesChildSummary?.raw || null;
if (!summary) {
return renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists));
}
const expected = Number(summary.expected || 0);
const existing = Number(summary.existing || 0);
const hasExpected = Number.isFinite(expected) && expected > 0;
const safeExpected = hasExpected ? expected : 0;
const safeExisting = Number.isFinite(existing) && existing >= 0 ? existing : 0;
const suffix = hasExpected ? ` (${safeExisting}/${safeExpected})` : '';
if (safeExisting <= 0) {
return renderPresenceChip('RAW', false, 'tone-no', suffix || ' (0/0)');
}
if (hasExpected && safeExisting < safeExpected) {
return renderPresenceChip('RAW', true, 'tone-warn', suffix);
}
return renderPresenceChip('RAW', true, 'tone-ok', suffix || ` (${safeExisting}/${safeExpected || safeExisting})`);
};
const renderSupplementalInfo = (row) => {
if (resolveMediaType(row) === 'cd') {
@@ -1464,6 +1594,16 @@ export default function HistoryPage({ refreshToken = 0 }) {
const isCdJob = mediaMeta.mediaType === 'cd';
const outputIsAudio = hasAudioOutput(row);
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
const selectedMetadata = row?.makemkvInfo?.selectedMetadata && typeof row.makemkvInfo.selectedMetadata === 'object'
? row.makemkvInfo.selectedMetadata
: {};
const isSeriesDvd = resolveMediaType(row) === 'dvd' && isSeriesDvdJob(row);
const seasonLabel = isSeriesDvd && selectedMetadata?.seasonNumber
? `Staffel ${selectedMetadata.seasonNumber}`
: null;
const tmdbLabel = isSeriesDvd && selectedMetadata?.tmdbId
? `TMDb ${selectedMetadata.tmdbId}`
: null;
const subtitle = isCdJob
? [
`#${row?.id || '-'}`,
@@ -1471,7 +1611,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
row?.year || null,
cdDetails?.mbId ? 'MusicBrainz' : null
].filter(Boolean).join(' | ')
: `#${row?.id || '-'} | ${row?.year || '-'} | ${row?.imdb_id || '-'}`;
: [
`#${row?.id || '-'}`,
row?.year || '-',
isSeriesDvd ? seasonLabel : (row?.imdb_id || '-'),
tmdbLabel
].filter(Boolean).join(' | ');
return (
<div className="col-12" key={row.id}>
@@ -1507,8 +1652,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
</div>
<div className="history-dv-flags-row">
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
{isSeriesDvd
? renderSeriesRawChip(row)
: renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{isSeriesDvd
? renderSeriesOutputChip(row)
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
</div>
@@ -1571,8 +1720,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
</div>
<div className="history-dv-flags-row">
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
{isSeriesDvd
? renderSeriesRawChip(row)
: renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{isSeriesDvd
? renderSeriesOutputChip(row)
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
</div>
@@ -1686,6 +1839,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
onAssignOmdb={handleAssignOmdb}
onAssignCdMetadata={handleAssignCdMetadata}
onAcknowledgeError={handleAcknowledgeError}
onDeleteFiles={handleDeleteFiles}
onDeleteEntry={handleDeleteEntry}
onDownloadArchive={handleDownloadArchive}
@@ -1695,6 +1849,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
actionBusy={actionBusy}
omdbAssignBusy={omdbAssignBusy}
cdMetadataAssignBusy={cdMetadataAssignBusy}
acknowledgeErrorBusy={acknowledgeErrorBusy}
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
deleteEntryBusy={deleteEntryBusy}
downloadBusyTarget={downloadBusyTarget}
@@ -1855,7 +2010,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
onHide={() => {
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
setPendingRestartRow(null);
}}
onSubmit={handleOmdbSubmit}
onSearch={handleOmdbSearch}