0.13.1 DVD Series rc1

This commit is contained in:
2026-04-11 21:26:28 +00:00
parent 1f8e6d3ce8
commit bc193db50a
14 changed files with 387 additions and 30 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.13.0-1", "version": "0.13.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.13.0-1", "version": "0.13.1",
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.13.0-1", "version": "0.13.1",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
+63 -4
View File
@@ -1159,6 +1159,29 @@ function normalizePositiveIntegerOrNull(value) {
return Math.trunc(parsed); return Math.trunc(parsed);
} }
function isSeriesBatchEpisodeSubJobPlan(encodePlan = null) {
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
if (Boolean(plan?.seriesBatchChild) || Boolean(plan?.seriesBatchVirtualEpisode)) {
return true;
}
const parentJobId = normalizePositiveIntegerOrNull(plan?.seriesBatchParentJobId);
const titleId = normalizePositiveIntegerOrNull(plan?.seriesBatchTitleId);
const childIndex = normalizePositiveIntegerOrNull(plan?.seriesBatchChildIndex);
const childCount = normalizePositiveIntegerOrNull(plan?.seriesBatchChildCount);
if (parentJobId && (titleId || childIndex || childCount)) {
return true;
}
return false;
}
function isSeriesBatchEpisodeSubJobRow(row = null) {
const source = row && typeof row === 'object' ? row : {};
const plan = source?.encodePlan && typeof source.encodePlan === 'object'
? source.encodePlan
: parseJsonSafe(source?.encode_plan_json, null);
return isSeriesBatchEpisodeSubJobPlan(plan);
}
function extractSeasonNumberFromText(value) { function extractSeasonNumberFromText(value) {
const text = String(value || '').trim(); const text = String(value || '').trim();
if (!text) { if (!text) {
@@ -3675,8 +3698,24 @@ class HistoryService {
} }
const allContainerChildRows = [...childRows, ...nestedChildRows]; const allContainerChildRows = [...childRows, ...nestedChildRows];
const directDiskRowsByContainerId = new Map();
const directEpisodeRowsByContainerId = new Map();
const directChildRowById = new Map(); const directChildRowById = new Map();
for (const row of childRows) { for (const row of childRows) {
const containerId = normalizeJobIdValue(row?.parent_job_id);
const isEpisodeSubJob = isSeriesBatchEpisodeSubJobRow(row);
if (containerId) {
const map = isEpisodeSubJob
? directEpisodeRowsByContainerId
: directDiskRowsByContainerId;
if (!map.has(containerId)) {
map.set(containerId, []);
}
map.get(containerId).push(row);
}
if (isEpisodeSubJob) {
continue;
}
const childId = normalizeJobIdValue(row?.id); const childId = normalizeJobIdValue(row?.id);
if (childId) { if (childId) {
directChildRowById.set(childId, row); directChildRowById.set(childId, row);
@@ -3737,8 +3776,9 @@ class HistoryService {
const episodeCount = Number(selectedMetadata?.episodeCount || 0); const episodeCount = Number(selectedMetadata?.episodeCount || 0);
const episodesLength = Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes.length : 0; const episodesLength = Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes.length : 0;
const expectedTotal = episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0); const expectedTotal = episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0);
const childIds = childRows const containerChildRows = directDiskRowsByContainerId.get(containerId) || [];
.filter((row) => normalizeJobIdValue(row?.parent_job_id) === containerId) const containerEpisodeRows = directEpisodeRowsByContainerId.get(containerId) || [];
const childIds = containerChildRows
.map((row) => normalizeJobIdValue(row?.id)) .map((row) => normalizeJobIdValue(row?.id))
.filter(Boolean); .filter(Boolean);
let rawExistsAny = false; let rawExistsAny = false;
@@ -3851,6 +3891,22 @@ class HistoryService {
} }
} }
} }
for (const episodeRow of containerEpisodeRows) {
const episodeRowId = normalizeJobIdValue(episodeRow?.id);
if (!episodeRowId) {
continue;
}
const outputs = Array.from(childOutputMap.get(episodeRowId) || []);
for (const outputPath of outputs) {
if (!outputPath || seenOutputs.has(outputPath)) {
continue;
}
seenOutputs.add(outputPath);
if (!includeFsChecks || fs.existsSync(outputPath)) {
existingCount += 1;
}
}
}
if (existingCountFromSeriesBatch > existingCount) { if (existingCountFromSeriesBatch > existingCount) {
existingCount = existingCountFromSeriesBatch; existingCount = existingCountFromSeriesBatch;
} }
@@ -4319,6 +4375,10 @@ class HistoryService {
const childJobs = await Promise.all( const childJobs = await Promise.all(
childRows.map((row) => this.repairImportedOrphanJobClassification(row, settings)) childRows.map((row) => this.repairImportedOrphanJobClassification(row, settings))
); );
const isSeriesContainer = String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
const detailChildJobs = isSeriesContainer
? childJobs.filter((child) => !isSeriesBatchEpisodeSubJobRow(child))
: childJobs;
const parsedTail = Number(options.logTailLines); const parsedTail = Number(options.logTailLines);
const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0 const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0
? Math.trunc(parsedTail) ? Math.trunc(parsedTail)
@@ -4330,7 +4390,7 @@ class HistoryService {
const hasProcessLog = (!shouldLoadLogs && includeFsChecks) ? hasProcessLogFile(jobId) : false; const hasProcessLog = (!shouldLoadLogs && includeFsChecks) ? hasProcessLogFile(jobId) : false;
const baseLogCount = hasProcessLog ? 1 : 0; const baseLogCount = hasProcessLog ? 1 : 0;
const enrichedChildren = await Promise.all( const enrichedChildren = await Promise.all(
childJobs.map(async (child) => { detailChildJobs.map(async (child) => {
const base = { const base = {
...enrichJobRow(child, settings, { includeFsChecks }), ...enrichJobRow(child, settings, { includeFsChecks }),
log_count: includeFsChecks ? (hasProcessLogFile(child.id) ? 1 : 0) : 0 log_count: includeFsChecks ? (hasProcessLogFile(child.id) ? 1 : 0) : 0
@@ -4365,7 +4425,6 @@ class HistoryService {
}) })
); );
const isSeriesContainer = String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
let seriesChildSummary = null; let seriesChildSummary = null;
if (isSeriesContainer && enrichedChildren.length > 0) { if (isSeriesContainer && enrichedChildren.length > 0) {
const total = enrichedChildren.length; const total = enrichedChildren.length;
+45
View File
@@ -13172,6 +13172,19 @@ class PipelineService extends EventEmitter {
let parentContainerJobId = normalizePositiveInteger(job.parent_job_id || null); let parentContainerJobId = normalizePositiveInteger(job.parent_job_id || null);
const isDvdSeriesSelection = isDvdTmdbMetadataSelection; const isDvdSeriesSelection = isDvdTmdbMetadataSelection;
if (isDvdSeriesSelection) { if (isDvdSeriesSelection) {
const resolveDiscNumberFromJobRow = (row) => {
const rowMkInfo = this.safeParseJson(row?.makemkv_info_json) || {};
const rowAnalyzeContext = rowMkInfo?.analyzeContext && typeof rowMkInfo.analyzeContext === 'object'
? rowMkInfo.analyzeContext
: {};
const rowSelectedMetadata = rowAnalyzeContext?.selectedMetadata && typeof rowAnalyzeContext.selectedMetadata === 'object'
? rowAnalyzeContext.selectedMetadata
: (rowMkInfo?.selectedMetadata && typeof rowMkInfo.selectedMetadata === 'object'
? rowMkInfo.selectedMetadata
: {});
return normalizePositiveInteger(rowSelectedMetadata?.discNumber);
};
if (!parentContainerJobId) { if (!parentContainerJobId) {
const existingContainer = await historyService.findSeriesContainerJob(effectiveTmdbId, effectiveSeasonNumber); const existingContainer = await historyService.findSeriesContainerJob(effectiveTmdbId, effectiveSeasonNumber);
if (existingContainer) { if (existingContainer) {
@@ -13212,6 +13225,38 @@ class PipelineService extends EventEmitter {
parentContainerJobId = Number(containerJob?.id || 0) || null; parentContainerJobId = Number(containerJob?.id || 0) || null;
} }
} }
if (parentContainerJobId && effectiveDiscNumber) {
const containerChildren = await historyService.listChildJobs(parentContainerJobId);
const conflictingDiscChild = (Array.isArray(containerChildren) ? containerChildren : []).find((childRow) => {
const childId = Number(childRow?.id || 0);
if (!childId || childId === Number(jobId)) {
return false;
}
const childDiscNumber = resolveDiscNumberFromJobRow(childRow);
return childDiscNumber === effectiveDiscNumber;
});
if (conflictingDiscChild) {
const existingChildJobId = Number(conflictingDiscChild?.id || 0) || null;
const error = new Error(
`Disk ${effectiveDiscNumber} ist fuer diese Serie/Staffel bereits vorhanden (Job #${existingChildJobId || '?'}). Bitte andere Disk-Nummer waehlen.`
);
error.statusCode = 409;
error.details = [
{
code: 'SERIES_DISC_ALREADY_EXISTS',
containerJobId: Number(parentContainerJobId),
existingJobId: existingChildJobId,
discNumber: Number(effectiveDiscNumber),
tmdbId: Number(effectiveTmdbId || 0) || null,
seasonNumber: Number(effectiveSeasonNumber || 0) || null
}
];
throw error;
}
}
if (parentContainerJobId) { if (parentContainerJobId) {
const containerMkInfo = this.withAnalyzeContextMediaProfile({ const containerMkInfo = this.withAnalyzeContextMediaProfile({
analyzeContext: { analyzeContext: {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.0-1", "version": "0.13.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.0-1", "version": "0.13.1",
"dependencies": { "dependencies": {
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.2", "primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.0-1", "version": "0.13.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+48 -3
View File
@@ -739,6 +739,47 @@ function resolveSeriesDiscNumber(job) {
); );
} }
function isSeriesBatchEpisodeChildJob(job) {
const plan = job?.encodePlan && typeof job.encodePlan === 'object'
? job.encodePlan
: null;
if (!plan) {
return false;
}
if (Boolean(plan?.seriesBatchChild) || Boolean(plan?.seriesBatchVirtualEpisode)) {
return true;
}
const hasParent = normalizePositiveInteger(plan?.seriesBatchParentJobId) !== null;
const hasEpisodeMarker = normalizePositiveInteger(plan?.seriesBatchTitleId) !== null
|| normalizePositiveInteger(plan?.seriesBatchChildIndex) !== null
|| normalizePositiveInteger(plan?.seriesBatchChildCount) !== null;
return hasParent && hasEpisodeMarker;
}
function buildSeriesContainerDiskChildren(children = []) {
const rows = Array.isArray(children) ? children : [];
const diskCandidates = rows.filter((child) => !isSeriesBatchEpisodeChildJob(child));
const byDisc = new Map();
const withoutDisc = [];
for (const child of [...diskCandidates].sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0))) {
const discNumber = resolveSeriesDiscNumber(child);
if (discNumber !== null) {
if (!byDisc.has(discNumber)) {
byDisc.set(discNumber, child);
}
continue;
}
withoutDisc.push(child);
}
const withDisc = Array.from(byDisc.entries())
.sort((left, right) => left[0] - right[0])
.map(([, child]) => child);
const withoutDiscSorted = withoutDisc.sort(compareSeriesChildJobsByDisc);
return [...withDisc, ...withoutDiscSorted];
}
function compareSeriesChildJobsByDisc(leftJob, rightJob) { function compareSeriesChildJobsByDisc(leftJob, rightJob) {
const leftDisc = resolveSeriesDiscNumber(leftJob); const leftDisc = resolveSeriesDiscNumber(leftJob);
const rightDisc = resolveSeriesDiscNumber(rightJob); const rightDisc = resolveSeriesDiscNumber(rightJob);
@@ -1128,10 +1169,14 @@ export default function JobDetailDialog({
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo); const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null; const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt'; const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
const childJobs = Array.isArray(job?.children)
? [...job.children].sort(compareSeriesChildJobsByDisc)
: [];
const isSeriesContainer = isDvdSeries && String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'; const isSeriesContainer = isDvdSeries && String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
const childJobs = Array.isArray(job?.children)
? (
isSeriesContainer
? buildSeriesContainerDiskChildren(job.children)
: [...job.children].sort(compareSeriesChildJobsByDisc)
)
: [];
const seriesChildSummary = job?.seriesChildSummary || null; const seriesChildSummary = job?.seriesChildSummary || null;
const seriesBackupSummary = isSeriesContainer ? seriesChildSummary?.backup : null; const seriesBackupSummary = isSeriesContainer ? seriesChildSummary?.backup : null;
const seriesEncodeSummary = isSeriesContainer ? seriesChildSummary?.encode : null; const seriesEncodeSummary = isSeriesContainer ? seriesChildSummary?.encode : null;
@@ -33,6 +33,7 @@ export default function MetadataSelectionDialog({
const [extraResults, setExtraResults] = useState([]); const [extraResults, setExtraResults] = useState([]);
const [searchBusy, setSearchBusy] = useState(false); const [searchBusy, setSearchBusy] = useState(false);
const [searchError, setSearchError] = useState(''); const [searchError, setSearchError] = useState('');
const [submitError, setSubmitError] = useState('');
const [seasonFilter, setSeasonFilter] = useState(''); const [seasonFilter, setSeasonFilter] = useState('');
const [discValidationError, setDiscValidationError] = useState(''); const [discValidationError, setDiscValidationError] = useState('');
const metadataProvider = String(context?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb'; const metadataProvider = String(context?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
@@ -56,20 +57,17 @@ export default function MetadataSelectionDialog({
const defaultTitle = selectedMetadata.title || context?.detectedTitle || ''; const defaultTitle = selectedMetadata.title || context?.detectedTitle || '';
const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : ''; const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : '';
const defaultImdb = selectedMetadata.imdbId || ''; const defaultImdb = selectedMetadata.imdbId || '';
const hintedDiscNumber = selectedMetadata.discNumber || context?.seriesLookupHint?.discNumber || null;
const defaultDiscNumber = Number(hintedDiscNumber || 0) > 0
? String(Math.trunc(Number(hintedDiscNumber || 0)))
: '';
setSelected(null); setSelected(null);
setQuery(defaultTitle); setQuery(defaultTitle);
setManualTitle(defaultTitle); setManualTitle(defaultTitle);
setManualYear(defaultYear); setManualYear(defaultYear);
setManualImdb(defaultImdb); setManualImdb(defaultImdb);
setManualDiscNumber(defaultDiscNumber); setManualDiscNumber('');
setExtraResults([]); setExtraResults([]);
setSearchBusy(false); setSearchBusy(false);
setSearchError(''); setSearchError('');
setSubmitError('');
setSeasonFilter(''); setSeasonFilter('');
setDiscValidationError(''); setDiscValidationError('');
}, [visible, context]); }, [visible, context]);
@@ -83,6 +81,12 @@ export default function MetadataSelectionDialog({
} }
}, [discValidationError, requiresDiscNumber, hasValidDiscNumber]); }, [discValidationError, requiresDiscNumber, hasValidDiscNumber]);
useEffect(() => {
if (submitError) {
setSubmitError('');
}
}, [manualDiscNumber, seasonFilter, selected]);
const rows = useMemo(() => { const rows = useMemo(() => {
const base = Array.isArray(context?.metadataCandidates) const base = Array.isArray(context?.metadataCandidates)
? context.metadataCandidates ? context.metadataCandidates
@@ -166,6 +170,8 @@ export default function MetadataSelectionDialog({
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
setDiscValidationError('');
setSubmitError('');
if (requiresDiscNumber && !hasValidDiscNumber) { if (requiresDiscNumber && !hasValidDiscNumber) {
setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.'); setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.');
return; return;
@@ -201,7 +207,18 @@ export default function MetadataSelectionDialog({
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}) ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
}; };
try {
await onSubmit(payload); await onSubmit(payload);
} catch (error) {
const details = Array.isArray(error?.details) ? error.details : [];
const hasDuplicateDiscError = details.some((detail) => String(detail?.code || '').trim().toUpperCase() === 'SERIES_DISC_ALREADY_EXISTS');
const message = String(error?.message || 'Metadaten konnten nicht uebernommen werden.').trim() || 'Metadaten konnten nicht uebernommen werden.';
if (hasDuplicateDiscError) {
setSubmitError(message);
return;
}
setSubmitError(message);
}
}; };
const handleHideRequest = () => { const handleHideRequest = () => {
@@ -262,6 +279,7 @@ export default function MetadataSelectionDialog({
</div> </div>
{searchError ? <small className="error-text">{searchError}</small> : null} {searchError ? <small className="error-text">{searchError}</small> : null}
{discValidationError ? <small className="error-text">{discValidationError}</small> : null} {discValidationError ? <small className="error-text">{discValidationError}</small> : null}
{submitError ? <small className="error-text">{submitError}</small> : null}
<div className="table-scroll-wrap table-scroll-medium"> <div className="table-scroll-wrap table-scroll-medium">
<DataTable <DataTable
+178 -2
View File
@@ -485,6 +485,77 @@ function normalizePositiveInt(value) {
return Math.trunc(parsed); return Math.trunc(parsed);
} }
function normalizeEpisodeReference(entry = {}) {
const source = entry && typeof entry === 'object' ? entry : {};
const episodeId = normalizePositiveInt(source?.episodeId ?? source?.id ?? null);
const episodeNumber = normalizePositiveInt(source?.episodeNumber ?? source?.number ?? null);
const seasonNumber = normalizePositiveInt(source?.seasonNumber ?? source?.season ?? null);
if (!episodeId && !episodeNumber) {
return null;
}
return {
episodeId,
episodeNumber,
seasonNumber
};
}
function buildEpisodeReferenceKey(entry = {}) {
const normalized = normalizeEpisodeReference(entry);
if (!normalized) {
return null;
}
if (normalized.episodeId) {
return `id:${normalized.episodeId}`;
}
if (normalized.seasonNumber && normalized.episodeNumber) {
return `se:${normalized.seasonNumber}:${normalized.episodeNumber}`;
}
return normalized.episodeNumber ? `ep:${normalized.episodeNumber}` : null;
}
function isSeriesBatchEpisodePlan(plan = null) {
const source = plan && typeof plan === 'object' ? plan : {};
if (Boolean(source?.seriesBatchChild) || Boolean(source?.seriesBatchVirtualEpisode)) {
return true;
}
const parentJobId = normalizePositiveInt(source?.seriesBatchParentJobId);
const childIndex = normalizePositiveInt(source?.seriesBatchChildIndex);
const childCount = normalizePositiveInt(source?.seriesBatchChildCount);
const titleId = normalizeTitleId(source?.seriesBatchTitleId);
return Boolean(parentJobId && (childIndex || childCount || titleId));
}
function collectEpisodeReferencesFromPlan(plan = null) {
const source = plan && typeof plan === 'object' ? plan : {};
const refs = [];
const assignments = source?.episodeAssignments && typeof source.episodeAssignments === 'object'
? Object.values(source.episodeAssignments)
: [];
for (const assignment of assignments) {
const normalized = normalizeEpisodeReference(assignment);
if (normalized) {
refs.push(normalized);
}
}
const titles = Array.isArray(source?.titles) ? source.titles : [];
for (const title of titles) {
if (!Boolean(title?.selectedForEncode) && !Boolean(title?.encodeInput)) {
continue;
}
const normalized = normalizeEpisodeReference({
episodeId: title?.episodeId,
episodeNumber: title?.episodeNumber,
seasonNumber: title?.seasonNumber
});
if (normalized) {
refs.push(normalized);
}
}
return refs;
}
function normalizeSeriesLanguage(value) { function normalizeSeriesLanguage(value) {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw) { if (!raw) {
@@ -730,6 +801,7 @@ export default function PipelineStatusCard({
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({}); const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
const [episodeAssignmentsByTitle, setEpisodeAssignmentsByTitle] = useState({}); const [episodeAssignmentsByTitle, setEpisodeAssignmentsByTitle] = useState({});
const [selectedEpisodeFillStart, setSelectedEpisodeFillStart] = useState(null); const [selectedEpisodeFillStart, setSelectedEpisodeFillStart] = useState(null);
const [containerUsedEpisodeKeys, setContainerUsedEpisodeKeys] = useState([]);
const [settingsMap, setSettingsMap] = useState({}); const [settingsMap, setSettingsMap] = useState({});
const [presetDisplayMap, setPresetDisplayMap] = useState({}); const [presetDisplayMap, setPresetDisplayMap] = useState({});
const [scriptCatalog, setScriptCatalog] = useState([]); const [scriptCatalog, setScriptCatalog] = useState([]);
@@ -748,7 +820,7 @@ export default function PipelineStatusCard({
const converterConfigSaveTimeoutRef = useRef(null); const converterConfigSaveTimeoutRef = useRef(null);
const selectedMetadataEpisodes = useMemo(() => { const selectedMetadataEpisodes = useMemo(() => {
const rows = Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes : []; const rows = Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes : [];
return rows const normalizedRows = rows
.map((item) => { .map((item) => {
const episodeId = Number(item?.id || 0); const episodeId = Number(item?.id || 0);
const episodeNumber = Number(item?.number ?? item?.episodeNumber ?? 0); const episodeNumber = Number(item?.number ?? item?.episodeNumber ?? 0);
@@ -777,7 +849,15 @@ export default function PipelineStatusCard({
const bId = Number.isFinite(b.episodeId) ? b.episodeId : Number.MAX_SAFE_INTEGER; const bId = Number.isFinite(b.episodeId) ? b.episodeId : Number.MAX_SAFE_INTEGER;
return aId - bId; return aId - bId;
}); });
}, [selectedMetadata?.episodes]); if (!Array.isArray(containerUsedEpisodeKeys) || containerUsedEpisodeKeys.length === 0) {
return normalizedRows;
}
const usedKeySet = new Set(containerUsedEpisodeKeys);
return normalizedRows.filter((episode) => {
const key = buildEpisodeReferenceKey(episode);
return !key || !usedKeySet.has(key);
});
}, [selectedMetadata?.episodes, containerUsedEpisodeKeys]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -1333,6 +1413,102 @@ export default function PipelineStatusCard({
const metadataProvider = String(selectedMetadata?.metadataProvider || '').trim().toLowerCase(); const metadataProvider = String(selectedMetadata?.metadataProvider || '').trim().toLowerCase();
return hasSeasonNumber || hasEpisodeList || metadataProvider === 'tmdb' || metadataProvider === 'themoviedb'; return hasSeasonNumber || hasEpisodeList || metadataProvider === 'tmdb' || metadataProvider === 'themoviedb';
}, [jobMediaProfile, selectedMetadata?.seasonNumber, selectedMetadata?.episodes, selectedMetadata?.metadataProvider]); }, [jobMediaProfile, selectedMetadata?.seasonNumber, selectedMetadata?.episodes, selectedMetadata?.metadataProvider]);
useEffect(() => {
let cancelled = false;
const loadContainerUsedEpisodes = async () => {
if (!isSeriesDvdReview || !retryJobId) {
if (!cancelled) {
setContainerUsedEpisodeKeys([]);
}
return;
}
try {
const currentResponse = await api.getJob(retryJobId, { lite: true, forceRefresh: true });
if (cancelled) {
return;
}
const currentJob = currentResponse?.job && typeof currentResponse.job === 'object'
? currentResponse.job
: null;
const currentJobId = normalizeJobId(currentJob?.id || retryJobId);
const normalizedCurrentKind = String(currentJob?.job_kind || '').trim().toLowerCase();
const containerJobId = normalizedCurrentKind === 'dvd_series_container'
? normalizeJobId(currentJob?.id)
: normalizeJobId(currentJob?.parent_job_id);
if (!containerJobId) {
if (!cancelled) {
setContainerUsedEpisodeKeys([]);
}
return;
}
const containerResponse = await api.getJob(containerJobId, { lite: true, forceRefresh: true });
if (cancelled) {
return;
}
const containerJob = containerResponse?.job && typeof containerResponse.job === 'object'
? containerResponse.job
: null;
const children = Array.isArray(containerJob?.children) ? containerJob.children : [];
const usedKeys = new Set();
for (const child of children) {
const childId = normalizeJobId(child?.id);
if (currentJobId && childId && currentJobId === childId) {
continue;
}
if (isSeriesBatchEpisodePlan(child?.encodePlan)) {
continue;
}
const refs = collectEpisodeReferencesFromPlan(child?.encodePlan);
for (const ref of refs) {
const key = buildEpisodeReferenceKey(ref);
if (key) {
usedKeys.add(key);
}
}
const batchEpisodes = Array.isArray(child?.handbrakeInfo?.seriesBatch?.episodes)
? child.handbrakeInfo.seriesBatch.episodes
: [];
for (const episode of batchEpisodes) {
const key = buildEpisodeReferenceKey({
episodeId: episode?.episodeId,
episodeNumber: episode?.episodeNumber,
seasonNumber: episode?.seasonNumber
});
if (key) {
usedKeys.add(key);
}
}
}
if (!cancelled) {
setContainerUsedEpisodeKeys(Array.from(usedKeys));
}
} catch (_error) {
if (!cancelled) {
setContainerUsedEpisodeKeys([]);
}
}
};
void loadContainerUsedEpisodes();
return () => {
cancelled = true;
};
}, [
isSeriesDvdReview,
retryJobId,
mediaInfoReview?.generatedAt,
mediaInfoReview?.reviewConfirmedAt,
selectedMetadata?.tmdbId,
selectedMetadata?.seasonNumber
]);
const seriesBatchContext = useMemo(() => { const seriesBatchContext = useMemo(() => {
const raw = pipeline?.context?.seriesBatch; const raw = pipeline?.context?.seriesBatch;
if (!raw || typeof raw !== 'object') { if (!raw || typeof raw !== 'object') {
+20 -6
View File
@@ -1344,8 +1344,8 @@ export default function RipperPage({
imdbId: job?.imdb_id || null, imdbId: job?.imdb_id || null,
poster: job?.poster_url || null, poster: job?.poster_url || null,
metadataProvider: context?.metadataProvider || 'omdb', metadataProvider: context?.metadataProvider || 'omdb',
seasonNumber: context?.seriesLookupHint?.seasonNumber || null, seasonNumber: null,
discNumber: context?.seriesLookupHint?.discNumber || null discNumber: null
}; };
return { return {
...context, ...context,
@@ -1379,8 +1379,8 @@ export default function RipperPage({
imdbId: null, imdbId: null,
poster: null, poster: null,
metadataProvider: currentContext?.metadataProvider || 'omdb', metadataProvider: currentContext?.metadataProvider || 'omdb',
seasonNumber: currentContext?.seriesLookupHint?.seasonNumber || null, seasonNumber: null,
discNumber: currentContext?.seriesLookupHint?.discNumber || null discNumber: null
}, },
metadataProvider: currentContext?.metadataProvider || currentContext?.selectedMetadata?.metadataProvider || 'omdb', metadataProvider: currentContext?.metadataProvider || currentContext?.selectedMetadata?.metadataProvider || 'omdb',
metadataCandidates: Array.isArray(currentContext?.metadataCandidates) ? currentContext.metadataCandidates : [], metadataCandidates: Array.isArray(currentContext?.metadataCandidates) ? currentContext.metadataCandidates : [],
@@ -2202,7 +2202,8 @@ export default function RipperPage({
return response.results || []; return response.results || [];
}; };
const doSelectMetadata = async (payload) => { const doSelectMetadata = async (payload, options = {}) => {
const suppressErrorToast = Boolean(options?.suppressErrorToast);
setBusy(true); setBusy(true);
try { try {
if (metadataDialogReassignMode) { if (metadataDialogReassignMode) {
@@ -2216,6 +2217,9 @@ export default function RipperPage({
setMetadataDialogContext(null); setMetadataDialogContext(null);
setMetadataDialogReassignMode(false); setMetadataDialogReassignMode(false);
} catch (error) { } catch (error) {
if (suppressErrorToast) {
throw error;
}
showError(error); showError(error);
} finally { } finally {
setBusy(false); setBusy(false);
@@ -2223,17 +2227,27 @@ export default function RipperPage({
}; };
const handleMetadataSubmit = async (payload) => { const handleMetadataSubmit = async (payload) => {
const payloadProvider = String(payload?.metadataProvider || '').trim().toLowerCase();
const currentJobMediaProfile = String(effectiveMetadataDialogContext?.mediaProfile || '').trim().toLowerCase();
const isSeriesDvdPayload = payloadProvider === 'tmdb' && currentJobMediaProfile === 'dvd';
if (metadataDialogReassignMode) { if (metadataDialogReassignMode) {
await doSelectMetadata(payload); await doSelectMetadata(payload);
return; return;
} }
// Serien-DVDs laufen ohne Film-Duplikatdialog weiter; die Disk-Pruefung
// erfolgt serverseitig gegen den vorhandenen Staffel-Container.
if (isSeriesDvdPayload) {
await doSelectMetadata(payload, { suppressErrorToast: true });
return;
}
// Duplikatprüfung: nur bei OMDB-Auswahl mit imdbId sinnvoll // Duplikatprüfung: nur bei OMDB-Auswahl mit imdbId sinnvoll
const searchTitle = payload.title || ''; const searchTitle = payload.title || '';
const searchImdbId = payload.imdbId || null; const searchImdbId = payload.imdbId || null;
if (searchTitle) { if (searchTitle) {
try { try {
const currentJobMediaProfile = String(effectiveMetadataDialogContext?.mediaProfile || '').trim().toLowerCase();
const historyResponse = await api.getJobs({ search: searchTitle, limit: 50, lite: true }); const historyResponse = await api.getJobs({ search: searchTitle, limit: 50, lite: true });
const historyJobs = Array.isArray(historyResponse?.jobs) ? historyResponse.jobs : []; const historyJobs = Array.isArray(historyResponse?.jobs) ? historyResponse.jobs : [];
const duplicate = historyJobs.find((job) => { const duplicate = historyJobs.find((job) => {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster", "name": "ripster",
"version": "0.13.0-1", "version": "0.13.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster", "name": "ripster",
"version": "0.13.0-1", "version": "0.13.1",
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ripster", "name": "ripster",
"private": true, "private": true,
"version": "0.13.0-1", "version": "0.13.1",
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend", "dev:backend": "npm run dev --prefix backend",