0.13.1-1 Episode Detection

This commit is contained in:
2026-04-12 19:43:56 +00:00
parent bc193db50a
commit 850f05453c
19 changed files with 2046 additions and 119 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.13.1",
"version": "0.13.1-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.13.1",
"version": "0.13.1-1",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.13.1",
"version": "0.13.1-1",
"private": true,
"type": "module",
"scripts": {
+293 -13
View File
@@ -32,6 +32,137 @@ function normalizeTrackId(value) {
return Math.trunc(parsed);
}
function normalizePositiveInt(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function formatTwoDigit(value) {
const normalized = normalizePositiveInt(value);
if (normalized === null) {
return '??';
}
return String(normalized).padStart(2, '0');
}
function buildEpisodeRangeToken(start, end) {
const startNormalized = normalizePositiveInt(start);
const endNormalized = normalizePositiveInt(end);
if (!startNormalized) {
return '??';
}
if (!endNormalized || endNormalized <= startNormalized) {
return formatTwoDigit(startNormalized);
}
return `${formatTwoDigit(startNormalized)}-${formatTwoDigit(endNormalized)}`;
}
function buildEpisodePartsToken(start, end) {
const startNormalized = normalizePositiveInt(start) || 1;
const endNormalized = normalizePositiveInt(end) || startNormalized;
const span = Math.max(1, endNormalized - startNormalized + 1);
if (span <= 1) {
return '1';
}
if (span === 2) {
return '1+2';
}
return `1-${span}`;
}
function stripEpisodePartSuffix(value) {
const source = String(value || '').replace(/\s+/g, ' ').trim();
if (!source) {
return '';
}
const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)\s*$/i;
let normalized = source;
let changed = false;
for (let i = 0; i < 2; i += 1) {
const next = normalized.replace(suffixPattern, '').replace(/[-–—:.,\s]+$/g, '').trim();
if (!next || next === normalized) {
break;
}
normalized = next;
changed = true;
}
return changed ? (normalized || source) : source;
}
function normalizeEpisodeFillTitle(value, isMulti = false) {
const source = String(value || '').replace(/\s+/g, ' ').trim();
if (!source) {
return '';
}
if (!isMulti) {
return source;
}
return stripEpisodePartSuffix(source) || source;
}
function resolveTitleDurationSecondsForFill(title = null) {
const durationSeconds = Number(title?.durationSeconds || 0);
if (Number.isFinite(durationSeconds) && durationSeconds > 0) {
return durationSeconds;
}
const durationMinutes = Number(title?.durationMinutes || 0);
if (Number.isFinite(durationMinutes) && durationMinutes > 0) {
return durationMinutes * 60;
}
return 0;
}
function medianPositiveNumber(values = []) {
const numbers = (Array.isArray(values) ? values : [])
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
.sort((a, b) => a - b);
if (numbers.length === 0) {
return 0;
}
const middle = Math.floor(numbers.length / 2);
if (numbers.length % 2 === 1) {
return numbers[middle];
}
return (numbers[middle - 1] + numbers[middle]) / 2;
}
function estimateEpisodeSpanForFill(title = null, allTitles = []) {
const titleDuration = resolveTitleDurationSecondsForFill(title);
if (!Number.isFinite(titleDuration) || titleDuration <= 0) {
return 1;
}
const durations = (Array.isArray(allTitles) ? allTitles : [])
.map((item) => resolveTitleDurationSecondsForFill(item))
.filter((value) => Number.isFinite(value) && value > 0);
if (durations.length === 0) {
return 1;
}
const medianDuration = medianPositiveNumber(durations);
if (!medianDuration) {
return 1;
}
const baselinePool = durations.filter((value) => value <= medianDuration * 1.35);
const baselineDuration = medianPositiveNumber(baselinePool.length > 0 ? baselinePool : durations) || medianDuration;
if (!baselineDuration) {
return 1;
}
const ratio = titleDuration / baselineDuration;
const roundedSpan = Math.round(ratio);
if (!Number.isFinite(roundedSpan) || roundedSpan < 2 || roundedSpan > 4) {
return 1;
}
const minRatio = roundedSpan === 2 ? 1.55 : roundedSpan * 0.72;
const maxRatio = roundedSpan * 1.32;
if (ratio < minRatio || ratio > maxRatio) {
return 1;
}
return roundedSpan;
}
function normalizeTrackIdList(values) {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
@@ -429,7 +560,7 @@ function transliterateForFilename(input) {
function sanitizePathSegment(value) {
return transliterateForFilename(String(value || ''))
.replace(/[^a-zA-Z0-9 ().[\]_-]/g, '')
.replace(/[^a-zA-Z0-9 ().[\]_+-]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
@@ -1319,27 +1450,175 @@ export default function MediaInfoReviewPanel({
const normalizedEpisodeRows = (Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : [])
.map((episode) => {
const episodeId = Number(episode?.episodeId ?? episode?.id ?? 0);
const episodeNumber = Number(episode?.episodeNumber ?? episode?.number ?? 0);
const episodeIdStart = Number(episode?.episodeIdStart ?? episode?.idStart ?? episode?.episodeId ?? episode?.id ?? 0);
const episodeIdEnd = Number(episode?.episodeIdEnd ?? episode?.idEnd ?? 0);
const episodeNumberStart = Number(
episode?.episodeNumberStart
?? episode?.numberStart
?? episode?.episodeNoStart
?? episode?.episodeNumber
?? episode?.number
?? 0
);
const episodeNumberEnd = Number(
episode?.episodeNumberEnd
?? episode?.numberEnd
?? episode?.episodeNoEnd
?? 0
);
const episodeSpan = Number(episode?.episodeSpan ?? episode?.episodeCount ?? 0);
const episodeNumber = Number(episode?.episodeNumber ?? episode?.number ?? episodeNumberStart ?? 0);
const seasonNumber = Number(episode?.seasonNumber ?? episode?.season ?? 0);
const episodeTitle = String(episode?.episodeTitle || episode?.name || episode?.title || '').trim();
return {
episodeId: Number.isFinite(episodeId) && episodeId > 0 ? Math.trunc(episodeId) : null,
episodeIdStart: Number.isFinite(episodeIdStart) && episodeIdStart > 0 ? Math.trunc(episodeIdStart) : null,
episodeIdEnd: Number.isFinite(episodeIdEnd) && episodeIdEnd > 0 ? Math.trunc(episodeIdEnd) : null,
episodeNumber: Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null,
episodeNumberStart: Number.isFinite(episodeNumberStart) && episodeNumberStart > 0 ? Math.trunc(episodeNumberStart) : null,
episodeNumberEnd: Number.isFinite(episodeNumberEnd) && episodeNumberEnd > 0 ? Math.trunc(episodeNumberEnd) : null,
episodeSpan: Number.isFinite(episodeSpan) && episodeSpan > 0 ? Math.trunc(episodeSpan) : null,
seasonNumber: Number.isFinite(seasonNumber) && seasonNumber > 0 ? Math.trunc(seasonNumber) : null,
episodeTitle: episodeTitle || null
};
})
.filter((episode) => episode.episodeId || episode.episodeNumber !== null);
const episodeFillOptions = normalizedEpisodeRows.map((episode) => {
const seasonLabel = episode.seasonNumber !== null ? `S${String(episode.seasonNumber).padStart(2, '0')}` : 'S??';
const episodeLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : 'E??';
const titleLabel = episode.episodeTitle || '(ohne Titel)';
const value = String(episode.episodeId || episode.episodeNumber || '');
return {
label: `${seasonLabel}${episodeLabel} - ${titleLabel}`,
value
};
}).filter((option) => option.value);
const selectedTitlesForFill = selectedTitleIds.length > 0
? displayTitles.filter((title) => selectedTitleIdSet.has(String(normalizeTitleId(title?.id))))
: displayTitles.filter((title) => Boolean(title?.selectedForEncode));
const titleAnchorsByEpisodeIndex = (() => {
const anchors = new Map();
const rows = Array.isArray(normalizedEpisodeRows) ? normalizedEpisodeRows : [];
if (rows.length === 0) {
return anchors;
}
let cursor = 0;
for (const title of selectedTitlesForFill) {
if (cursor >= rows.length) {
break;
}
const explicitSpan = normalizePositiveInt(
title?.episodeSpan
?? title?.episodeCount
?? null
);
const estimatedSpan = estimateEpisodeSpanForFill(title, selectedTitlesForFill);
const span = Math.max(1, explicitSpan || 1, estimatedSpan || 1);
const safeSpan = Math.max(1, Math.min(span, rows.length - cursor));
anchors.set(cursor, { span: safeSpan });
cursor += safeSpan;
}
return anchors;
})();
const fallbackEpisodeFillOptions = (() => {
const rows = Array.isArray(normalizedEpisodeRows) ? normalizedEpisodeRows : [];
const options = [];
let index = 0;
while (index < rows.length) {
const startRow = rows[index];
const seasonNumber = normalizePositiveInt(startRow?.seasonNumber ?? null);
const seasonLabel = seasonNumber !== null ? `S${String(seasonNumber).padStart(2, '0')}` : 'S??';
const rangeStart = normalizePositiveInt(
startRow?.episodeNumberStart
?? startRow?.episodeNoStart
?? startRow?.episodeNumber
?? null
);
const explicitRangeEnd = normalizePositiveInt(
startRow?.episodeNumberEnd
?? startRow?.episodeNoEnd
?? null
);
const explicitRangeSpan = normalizePositiveInt(
startRow?.episodeSpan
?? startRow?.episodeCount
?? null
);
let consumedRows = 1;
let rangeEnd = explicitRangeEnd || rangeStart;
if (
rangeStart
&& (
(explicitRangeEnd && explicitRangeEnd > rangeStart)
|| (explicitRangeSpan && explicitRangeSpan > 1)
)
) {
const effectiveSpan = explicitRangeEnd && explicitRangeEnd > rangeStart
? Math.max(1, explicitRangeEnd - rangeStart + 1)
: Math.max(1, explicitRangeSpan || 1);
rangeEnd = rangeStart + effectiveSpan - 1;
consumedRows = 1;
} else {
const anchor = titleAnchorsByEpisodeIndex.get(index) || null;
const anchorSpan = normalizePositiveInt(anchor?.span) || 1;
if (anchorSpan > 1) {
const endIndex = Math.min(rows.length - 1, index + anchorSpan - 1);
const endRow = rows[endIndex];
const endRowStart = normalizePositiveInt(
endRow?.episodeNumberStart
?? endRow?.episodeNoStart
?? endRow?.episodeNumber
?? null
);
rangeEnd = endRowStart || (rangeStart ? (rangeStart + anchorSpan - 1) : null);
consumedRows = anchorSpan;
}
}
if (rangeStart && rangeEnd && rangeEnd < rangeStart) {
rangeEnd = rangeStart;
}
const isMulti = Boolean(rangeStart && rangeEnd && rangeEnd > rangeStart);
const episodeRangeToken = buildEpisodeRangeToken(rangeStart, rangeEnd);
const partsToken = buildEpisodePartsToken(rangeStart, rangeEnd);
const baseTitle = String(startRow?.episodeTitle || '(ohne Titel)').trim() || '(ohne Titel)';
const normalizedTitle = normalizeEpisodeFillTitle(baseTitle, isMulti);
const titleLabel = isMulti
? `${normalizedTitle} (Teil ${partsToken})`
: normalizedTitle;
const episodeLabel = rangeStart ? `E${episodeRangeToken}` : 'E??';
const value = String(
startRow?.episodeIdStart
|| startRow?.episodeId
|| rangeStart
|| ''
);
if (value) {
options.push({
label: `${seasonLabel}${episodeLabel} - ${titleLabel}`,
value,
startRef: value
});
}
index += Math.max(1, consumedRows);
}
return options;
})();
const episodeFillOptions = fallbackEpisodeFillOptions;
const selectedEpisodeFillValue = (() => {
const raw = String(selectedEpisodeFillStart || '').trim();
if (!raw) {
return null;
}
const direct = episodeFillOptions.find((option) => String(option?.value || '').trim() === raw);
if (direct) {
return direct.value;
}
const byStartRef = episodeFillOptions.find((option) => String(option?.startRef || '').trim() === raw);
if (byStartRef) {
return byStartRef.value;
}
const tokenMatch = raw.match(/^fill:([^:]+):title:\d+$/i);
if (tokenMatch && tokenMatch[1]) {
const byTokenRef = episodeFillOptions.find((option) => String(option?.startRef || '').trim() === String(tokenMatch[1]).trim());
if (byTokenRef) {
return byTokenRef.value;
}
}
return null;
})();
const processedFiles = Number(review.processedFiles || displayTitles.length || 0);
const totalFiles = Number(review.totalFiles || displayTitles.length || 0);
const playlistRecommendation = review.playlistRecommendation || null;
@@ -1893,7 +2172,7 @@ export default function MediaInfoReviewPanel({
<div className="episode-fill-field">
<label>Episodentitel befüllen ab Episode</label>
<Dropdown
value={String(selectedEpisodeFillStart || '').trim() || null}
value={selectedEpisodeFillValue}
options={episodeFillOptions}
optionLabel="label"
optionValue="value"
@@ -1906,6 +2185,7 @@ export default function MediaInfoReviewPanel({
/>
<small>
Startet bei der gewählten Episode und befüllt alle ausgewählten DVD-Titel der Reihe nach.
Multi-Episoden werden als Bereich (z.B. E16-17 | Teil 1+2) angezeigt.
</small>
</div>
</div>
File diff suppressed because it is too large Load Diff
+61
View File
@@ -345,6 +345,11 @@ function getQueueActionResult(response) {
return response?.result && typeof response.result === 'object' ? response.result : {};
}
function isPipelineMetadataFlowStatus(value) {
const normalized = String(value || '').trim().toUpperCase();
return ['METADATA_SELECTION', 'READY_TO_START', 'WAITING_FOR_USER_DECISION'].includes(normalized);
}
function normalizeSortText(value) {
return String(value || '').trim().toLocaleLowerCase('de-DE');
}
@@ -1113,6 +1118,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
const context = {
jobId,
status: row?.status || null,
lastState: row?.last_state || null,
submitMode: (
isPipelineMetadataFlowStatus(row?.status)
|| isPipelineMetadataFlowStatus(row?.last_state)
)
? 'pipeline'
: 'assign',
detectedTitle: row?.detected_title || row?.title || '',
selectedMetadata: {
...(selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}),
@@ -1166,8 +1179,53 @@ export default function HistoryPage({ refreshToken = 0 }) {
const handleOmdbSubmit = async (payload) => {
const provider = String(payload?.metadataProvider || metadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
const submitMode = String(metadataDialogContext?.submitMode || 'assign').trim().toLowerCase() || 'assign';
const isPipelineFlow = submitMode === 'pipeline';
setOmdbAssignBusy(true);
try {
if (isPipelineFlow) {
await api.selectMetadata(payload);
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
try {
const startResponse = await api.startJob(payload.jobId);
const startResult = getQueueActionResult(startResponse);
if (startResult.queued) {
toastRef.current?.show({
severity: 'info',
summary: 'Job in Queue',
detail: startResult.queuePosition > 0
? `Metadaten übernommen. Start wurde in Queue-Position ${startResult.queuePosition} eingeplant.`
: 'Metadaten übernommen. Start wurde in der Queue eingeplant.',
life: 3500
});
} else {
toastRef.current?.show({
severity: 'success',
summary: 'Job gestartet',
detail: 'Metadaten übernommen. Die Verarbeitung wurde gestartet.',
life: 3200
});
}
} catch (startError) {
const startMessage = String(startError?.message || '').trim();
const waitingForPlaylist = startMessage.includes('waiting_for_manual_playlist_selection');
toastRef.current?.show({
severity: waitingForPlaylist ? 'warn' : 'error',
summary: waitingForPlaylist ? 'Metadaten übernommen' : 'Start fehlgeschlagen',
detail: waitingForPlaylist
? 'Metadaten wurden übernommen, aber es fehlt noch eine Titel-/Playlist-Auswahl. Bitte im Ripper fortsetzen.'
: (startMessage || 'Job konnte nach der Metadaten-Übernahme nicht gestartet werden.'),
life: waitingForPlaylist ? 4200 : 4500
});
}
await load();
await refreshDetailIfOpen(payload.jobId);
return;
}
await api.assignJobOmdb(payload.jobId, payload);
toastRef.current?.show({
severity: 'success',
@@ -1180,6 +1238,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
await load();
await refreshDetailIfOpen(payload.jobId);
} catch (error) {
if (isPipelineFlow) {
throw error;
}
toastRef.current?.show({
severity: 'error',
summary: provider === 'tmdb' ? 'TMDb-Zuweisung fehlgeschlagen' : 'OMDb-Zuweisung fehlgeschlagen',
+8
View File
@@ -11,6 +11,12 @@ export function confirmModal(options = {}) {
const rejectLabel = String(options?.rejectLabel || 'Abbrechen').trim() || 'Abbrechen';
const icon = String(options?.icon || 'pi pi-exclamation-triangle').trim() || 'pi pi-exclamation-triangle';
const danger = Boolean(options?.danger);
const style = options?.style && typeof options.style === 'object'
? options.style
: null;
const breakpoints = options?.breakpoints && typeof options.breakpoints === 'object'
? options.breakpoints
: null;
return new Promise((resolve) => {
let finished = false;
@@ -32,6 +38,8 @@ export function confirmModal(options = {}) {
dismissableMask: true,
acceptLabel,
rejectLabel,
...(style ? { style } : {}),
...(breakpoints ? { breakpoints } : {}),
...(danger ? { acceptClassName: 'p-button-danger' } : {}),
accept: () => settle(true),
reject: () => settle(false),