diff options
| author | Fuwn <[email protected]> | 2026-03-01 14:39:20 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-03-01 15:24:05 -0800 |
| commit | 60110fe8f23c53c837aff82d77a21ad8af4b5bb2 (patch) | |
| tree | 4fa6f42cc1f352c3a55165cc2a7735d0b897c841 /src/lib/Media/Anime/Airing/Subtitled | |
| parent | perf: optimise list hot paths and shared timers (diff) | |
| download | due.moe-60110fe8f23c53c837aff82d77a21ad8af4b5bb2.tar.xz due.moe-60110fe8f23c53c837aff82d77a21ad8af4b5bb2.zip | |
fix(anime): unify due classification and harden subtitle matching
Diffstat (limited to 'src/lib/Media/Anime/Airing/Subtitled')
| -rw-r--r-- | src/lib/Media/Anime/Airing/Subtitled/match.ts | 159 |
1 files changed, 131 insertions, 28 deletions
diff --git a/src/lib/Media/Anime/Airing/Subtitled/match.ts b/src/lib/Media/Anime/Airing/Subtitled/match.ts index eb9c9b70..e83c30b6 100644 --- a/src/lib/Media/Anime/Airing/Subtitled/match.ts +++ b/src/lib/Media/Anime/Airing/Subtitled/match.ts @@ -85,6 +85,12 @@ const isMeaningfulToken = (token: string): boolean => const MIN_MATCH_SCORE = 0.3; const MIN_TOKEN_OVERLAP = 2; const MIN_MATCH_MARGIN = 0.08; +const FALLBACK_MIN_SCORE = 0.82; +const FALLBACK_MIN_MARGIN = 0.08; +const MAX_MATCH_CACHE_ENTRIES = 10_000; +const MAX_INJECT_CACHE_ENTRIES = 10_000; +const STALE_AIRING_GRACE_SECONDS = 5 * 60; +const MAX_EPISODE_SHIFT_WINDOW_SECONDS = 36 * 60 * 60; interface SimilarityAnalysis { score: number; @@ -107,6 +113,8 @@ const calculateWeightedSimilarity = (title1: string, title2: string): Similarity let score = 0; let tokenOverlap = 0; let numericTokenOverlap = 0; + const numericTokens1 = tokens1.filter((token) => /^\d+$/.test(token)); + const numericTokens2 = tokens2.filter((token) => /^\d+$/.test(token)); tokens1.forEach((token) => { if (set2.has(token)) { @@ -118,8 +126,15 @@ const calculateWeightedSimilarity = (title1: string, title2: string): Similarity } }); + let finalScore = + (score / ((Math.max(tokens1.length, tokens2.length) || 1) * 2)) * 0.7 + + stringSimilarity.compareTwoStrings(title1, title2) * 0.3; + + if (numericTokens1.length > 0 && numericTokens2.length > 0 && numericTokenOverlap === 0) + finalScore *= 0.5; + return { - score: score / ((Math.max(tokens1.length, tokens2.length) || 1) * 2), + score: finalScore, tokenOverlap, numericTokenOverlap }; @@ -134,6 +149,56 @@ const indexPush = (index: Map<string, number[]>, key: string, entryIndex: number const scheduleIndexCache = new WeakMap<SubsPlease, ScheduleIndex>(); const closestMatchCache = new Map<string, Time | null>(); +const injectAiringTimeCache = new Map<string, Media>(); + +const setBoundedCacheValue = <T>( + cache: Map<string, T>, + key: string, + value: T, + maxEntries: number +) => { + if (cache.size >= maxEntries) cache.clear(); + + cache.set(key, value); +}; + +const animeTitleFingerprint = (anime: Media) => + [anime.title.romaji, anime.title.english, ...anime.synonyms] + .filter(Boolean) + .map(preprocessTitle) + .join('|'); + +const fallbackClosestMatch = (dayIndex: DayScheduleIndex, searchTitles: string[]): Time | null => { + let bestMatch: Time | null = null; + let bestScore = 0; + let secondBestScore = 0; + + for (const searchTitle of searchTitles) { + if (searchTitle.includes('OVA') || searchTitle.includes('Special')) continue; + + const normalizedSearchTitle = preprocessTitle(searchTitle); + + for (const candidateEntry of dayIndex.entries) { + const score = stringSimilarity.compareTwoStrings( + normalizedSearchTitle, + candidateEntry.normalizedTitle + ); + + if (score > bestScore) { + secondBestScore = bestScore; + bestScore = score; + bestMatch = candidateEntry.time; + } else if (score > secondBestScore) { + secondBestScore = score; + } + } + } + + if (bestScore < FALLBACK_MIN_SCORE) return null; + if (bestScore - secondBestScore < FALLBACK_MIN_MARGIN) return null; + + return bestMatch; +}; const buildScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => { const byDay = new Map<string, DayScheduleIndex>(); @@ -187,12 +252,14 @@ const buildScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => { export const findClosestMatch = (scheduleIndex: ScheduleIndex, anime: Media): Time | null => { if (excludeMatch.includes(anime.id)) { - closestMatchCache.set(`${anime.id}:excluded`, null); - + setBoundedCacheValue(closestMatchCache, `${anime.id}:excluded`, null, MAX_MATCH_CACHE_ENTRIES); + return null; } - const cacheKey = `${anime.id}:${anime.nextAiringEpisode?.airingAt || 0}:${scheduleIndex.version}`; + const cacheKey = `${anime.id}:${anime.nextAiringEpisode?.airingAt || 0}:${animeTitleFingerprint( + anime + )}:${scheduleIndex.version}`; const cached = closestMatchCache.get(cacheKey); if (cached !== undefined) return cached; @@ -204,8 +271,8 @@ export const findClosestMatch = (scheduleIndex: ScheduleIndex, anime: Media): Ti const dayIndex = scheduleIndex.byDay.get(airingDay); if (!dayIndex || dayIndex.entries.length === 0) { - closestMatchCache.set(cacheKey, null); - + setBoundedCacheValue(closestMatchCache, cacheKey, null, MAX_MATCH_CACHE_ENTRIES); + return null; } @@ -226,7 +293,8 @@ export const findClosestMatch = (scheduleIndex: ScheduleIndex, anime: Media): Ti const exactMatch = dayIndex.entries[exactMatchIndexes[0]]; if (exactMatch) { - closestMatchCache.set(cacheKey, exactMatch.time); + setBoundedCacheValue(closestMatchCache, cacheKey, exactMatch.time, MAX_MATCH_CACHE_ENTRIES); + return exactMatch.time; } } @@ -264,25 +332,31 @@ export const findClosestMatch = (scheduleIndex: ScheduleIndex, anime: Media): Ti } if (bestScore < MIN_MATCH_SCORE) { - closestMatchCache.set(cacheKey, null); - - return null; + const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles); + + setBoundedCacheValue(closestMatchCache, cacheKey, fallbackMatch, MAX_MATCH_CACHE_ENTRIES); + + return fallbackMatch; } if (bestScore - secondBestScore < MIN_MATCH_MARGIN) { - closestMatchCache.set(cacheKey, null); - - return null; + const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles); + + setBoundedCacheValue(closestMatchCache, cacheKey, fallbackMatch, MAX_MATCH_CACHE_ENTRIES); + + return fallbackMatch; } if (bestNumericTokenOverlap === 0 && bestTokenOverlap < MIN_TOKEN_OVERLAP) { - closestMatchCache.set(cacheKey, null); - - return null; + const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles); + + setBoundedCacheValue(closestMatchCache, cacheKey, fallbackMatch, MAX_MATCH_CACHE_ENTRIES); + + return fallbackMatch; } - closestMatchCache.set(cacheKey, bestMatch); - + setBoundedCacheValue(closestMatchCache, cacheKey, bestMatch, MAX_MATCH_CACHE_ENTRIES); + return bestMatch; }; @@ -352,15 +426,37 @@ const getScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => { if (cached) return cached; const built = buildScheduleIndex(subsPlease); - + scheduleIndexCache.set(subsPlease, built); return built; }; +const buildInjectAiringTimeCacheKey = ( + anime: Media, + scheduleVersion: string, + displayNativeCountdown: boolean +) => + [ + anime.id, + anime.status, + anime.nextAiringEpisode?.episode || 0, + anime.nextAiringEpisode?.airingAt || 0, + displayNativeCountdown ? 1 : 0, + scheduleVersion, + animeTitleFingerprint(anime) + ].join(':'); + export const injectAiringTime = (anime: Media, subsPlease: SubsPlease | null) => { if (season() !== anime.season) return anime; + const displayNativeCountdown = get(settings).displayNativeCountdown; + const scheduleVersion = subsPlease ? getScheduleIndex(subsPlease).version : 'native-only'; + const cacheKey = buildInjectAiringTimeCacheKey(anime, scheduleVersion, displayNativeCountdown); + const cached = injectAiringTimeCache.get(cacheKey); + + if (cached) return cached; + const airingAt = anime.nextAiringEpisode?.airingAt; const now = new Date(); // const nativeUntilAiring = airingAt @@ -371,12 +467,7 @@ export const injectAiringTime = (anime: Media, subsPlease: SubsPlease | null) => let time = new Date(airingAt ? airingAt * 1000 : 0); let nextEpisode = anime.nextAiringEpisode?.episode || 0; - if ( - !( - (get(settings).displayNativeCountdown || !subsPlease) - // || !(nativeUntilAiring !== undefined && nativeUntilAiring < 24 * 60 * 60) - ) - ) { + if (!(displayNativeCountdown || !subsPlease)) { const scheduleIndex = getScheduleIndex(subsPlease); if ((anime.nextAiringEpisode?.episode || 0) > 1) { @@ -391,7 +482,14 @@ export const injectAiringTime = (anime: Media, subsPlease: SubsPlease | null) => const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; - if (nativeTime > time) { + const nowEpochSeconds = Date.now() / 1000; + const nativeAheadSeconds = nativeTime.getTime() / 1000 - time.getTime() / 1000; + + if ( + nativeAheadSeconds > 0 && + nativeAheadSeconds <= MAX_EPISODE_SHIFT_WINDOW_SECONDS && + nativeTime.getTime() / 1000 > nowEpochSeconds + STALE_AIRING_GRACE_SECONDS + ) { nextEpisode -= 1; } @@ -404,12 +502,17 @@ export const injectAiringTime = (anime: Media, subsPlease: SubsPlease | null) => time.setMinutes(beforeTime.getMinutes()); } - return { + const injected = { ...anime, nextAiringEpisode: { episode: nextEpisode, airingAt: time.getTime() / 1000, - nativeAiringAt: nativeTime.getTime() / 1000 + nativeAiringAt: nativeTime.getTime() / 1000, + nativeEpisode: anime.nextAiringEpisode?.episode || 0 } } as Media; + + setBoundedCacheValue(injectAiringTimeCache, cacheKey, injected, MAX_INJECT_CACHE_ENTRIES); + + return injected; }; |