0.13.0 DVD Series
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user