0.13.1-1 Episode Detection
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user