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 | |
| 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')
| -rw-r--r-- | src/lib/Data/AniList/media.ts | 1 | ||||
| -rw-r--r-- | src/lib/List/Anime/DueAnimeList.svelte | 12 | ||||
| -rw-r--r-- | src/lib/List/Anime/UpcomingAnimeList.svelte | 6 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/Subtitled/match.ts | 159 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/classify.test.ts | 153 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/classify.ts | 53 |
6 files changed, 346 insertions, 38 deletions
diff --git a/src/lib/Data/AniList/media.ts b/src/lib/Data/AniList/media.ts index bf43ef1d..68f9fc9b 100644 --- a/src/lib/Data/AniList/media.ts +++ b/src/lib/Data/AniList/media.ts @@ -55,6 +55,7 @@ export interface Media { episode: number; airingAt?: number; nativeAiringAt?: number; + nativeEpisode?: number; }; synonyms: string[]; mediaListEntry?: { diff --git a/src/lib/List/Anime/DueAnimeList.svelte b/src/lib/List/Anime/DueAnimeList.svelte index 0c1e128a..da1f6c48 100644 --- a/src/lib/List/Anime/DueAnimeList.svelte +++ b/src/lib/List/Anime/DueAnimeList.svelte @@ -8,6 +8,7 @@ import AnimeList from './AnimeListTemplate.svelte'; import type { SubsPlease } from '$lib/Media/Anime/Airing/Subtitled/subsPlease'; import { injectAiringTime } from '$lib/Media/Anime/Airing/Subtitled/match'; + import { hasDueEpisodes, hasNoAiredEpisodes } from '$lib/Media/Anime/Airing/classify'; import { addNotification } from '$lib/Notification/store'; import locale from '$stores/locale'; import identity from '$stores/identity'; @@ -78,15 +79,12 @@ ($settings.displayNotStarted === true ? 0 : 1) && (media.mediaListEntry || { status: 'DROPPED' }).status !== 'DROPPED' ) - .filter( - (media: Media) => - // Outdated media - (media.nextAiringEpisode || { episode: 0 }).episode - 1 > - (media.mediaListEntry || { progress: 0 }).progress + .filter((media: Media) => + // Outdated media + hasDueEpisodes(media) ) .map((media: Media) => { - if ((media.nextAiringEpisode || { episode: 0 }).episode - 1 <= 0) - media.nextAiringEpisode = { episode: -1 }; + if (hasNoAiredEpisodes(media)) media.nextAiringEpisode = { episode: -1 }; return media; }); diff --git a/src/lib/List/Anime/UpcomingAnimeList.svelte b/src/lib/List/Anime/UpcomingAnimeList.svelte index 4f285b47..a2cc963d 100644 --- a/src/lib/List/Anime/UpcomingAnimeList.svelte +++ b/src/lib/List/Anime/UpcomingAnimeList.svelte @@ -12,6 +12,7 @@ import locale from '$stores/locale'; import identity from '$stores/identity'; import { injectAiringTime } from '$lib/Media/Anime/Airing/Subtitled/match'; + import { hasDueEpisodes, hasNoAiredEpisodes } from '$lib/Media/Anime/Airing/classify'; import revalidateAnime from '$stores/revalidateAnime'; export let user: AniListAuthorisation; @@ -43,14 +44,13 @@ (media: Media) => // Outdated media ($settings.displayPlannedAnime ? media.mediaListEntry?.status === 'PLANNING' : false) || - (media.nextAiringEpisode || { episode: 0 }).episode - 1 <= - (media.mediaListEntry || { progress: 0 }).progress + !hasDueEpisodes(media) ) .map((media: Media) => { // Adjust for planned anime if ( ($settings.displayPlannedAnime ? media.episodes !== 1 : true) && - (media.nextAiringEpisode || { episode: 0 }).episode - 1 <= 0 + hasNoAiredEpisodes(media) ) media.nextAiringEpisode = { episode: -1 }; 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; }; diff --git a/src/lib/Media/Anime/Airing/classify.test.ts b/src/lib/Media/Anime/Airing/classify.test.ts new file mode 100644 index 00000000..cad08b43 --- /dev/null +++ b/src/lib/Media/Anime/Airing/classify.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import settings from '$stores/settings'; +import type { Media } from '$lib/Data/AniList/media'; +import { season } from '$lib/Media/Anime/season'; +import { hasDueEpisodes, getAnimeEpisodeState } from '$lib/Media/Anime/Airing/classify'; +import { injectAiringTime } from '$lib/Media/Anime/Airing/Subtitled/match'; +import type { SubsPlease } from '$lib/Media/Anime/Airing/Subtitled/subsPlease'; + +const toScheduleTime = (epochSeconds: number) => { + const date = new Date(epochSeconds * 1000); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + + return `${hours}:${minutes}`; +}; + +const baseMedia = (id: number): Media => + ({ + id, + idMal: id, + status: 'RELEASING', + type: 'ANIME', + episodes: 12, + chapters: 0, + volumes: 0, + duration: 24, + format: 'TV', + title: { + romaji: `Fixture Show ${id}`, + english: `Fixture Show ${id}`, + native: `Fixture Show ${id}` + }, + nextAiringEpisode: { + episode: 8, + airingAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60 + }, + synonyms: [], + mediaListEntry: { + progress: 6, + progressVolumes: 0, + status: 'CURRENT', + score: 0, + repeat: 0, + startedAt: { + year: 2025, + month: 1, + day: 1 + }, + completedAt: { + year: 0, + month: 0, + day: 0 + }, + createdAt: 0, + updatedAt: 0, + customLists: {} + }, + startDate: { + year: 2025, + month: 1 + }, + endDate: { + year: 2025, + month: 12 + }, + coverImage: { + extraLarge: 'https://example.com/cover-xl.jpg', + medium: 'https://example.com/cover-md.jpg' + }, + tags: [], + genres: [], + season: season(), + isAdult: false, + relations: { + edges: [] + } + }) as Media; + +const regressionIds = [192507, 189259, 198767]; + +describe('anime episode classification', () => { + it('prefers nativeEpisode for due/upcoming classification', () => { + const media = baseMedia(192507); + + media.nextAiringEpisode = { + episode: 7, + nativeEpisode: 8, + airingAt: Math.floor(Date.now() / 1000) + 6 * 60 * 60, + nativeAiringAt: Math.floor(Date.now() / 1000) + 18 * 60 * 60 + }; + + const state = getAnimeEpisodeState(media); + + expect(state.airedEpisodes).toBe(7); + expect(hasDueEpisodes(media)).toBe(true); + }); + + it('treats stale native release data as aired for due detection', () => { + const media = baseMedia(189259); + + media.nextAiringEpisode = { + episode: 9, + airingAt: Math.floor(Date.now() / 1000) + 72 * 60 * 60, + nativeAiringAt: Math.floor(Date.now() / 1000) - 60 * 60 + }; + + const state = getAnimeEpisodeState(media); + + expect(state.airedEpisodes).toBe(9); + expect(hasDueEpisodes(media)).toBe(true); + }); +}); + +describe('native countdown toggle parity', () => { + for (const id of regressionIds) { + it(`keeps media ${id} due with native countdown on/off`, () => { + const media = baseMedia(id); + const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; + const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60; + const nativeAiringDate = new Date(nativeAiringAt * 1000); + const airingDay = nativeAiringDate.toLocaleString('en-US', { weekday: 'long' }); + const subsPlease = { + tz: 'America/Los_Angeles', + schedule: { + [airingDay]: [ + { + title: media.title.romaji, + page: '', + image_url: '', + time: toScheduleTime(subtitledAiringAt) + } + ] + } + } as unknown as SubsPlease; + + media.nextAiringEpisode = { + episode: 8, + airingAt: nativeAiringAt + }; + + settings.setKey('displayNativeCountdown', true); + + const nativeOnly = injectAiringTime(media, subsPlease); + + settings.setKey('displayNativeCountdown', false); + + const subtitled = injectAiringTime(media, subsPlease); + + expect(hasDueEpisodes(nativeOnly)).toBe(true); + expect(hasDueEpisodes(subtitled)).toBe(true); + }); + } +}); diff --git a/src/lib/Media/Anime/Airing/classify.ts b/src/lib/Media/Anime/Airing/classify.ts new file mode 100644 index 00000000..9b7487d2 --- /dev/null +++ b/src/lib/Media/Anime/Airing/classify.ts @@ -0,0 +1,53 @@ +import type { Media } from '$lib/Data/AniList/media'; + +export interface AnimeEpisodeState { + progress: number; + nextEpisode: number; + airedEpisodes: number; +} + +const hasAired = (airingAt: number | undefined, nowEpochSeconds: number) => + typeof airingAt === 'number' && airingAt <= nowEpochSeconds; + +export const getAnimeEpisodeState = ( + media: Media, + nowEpochSeconds = Date.now() / 1000 +): AnimeEpisodeState => { + const progress = media.mediaListEntry?.progress || 0; + const nextEpisode = + media.nextAiringEpisode?.nativeEpisode || media.nextAiringEpisode?.episode || 0; + + if (nextEpisode <= 0) { + return { + progress, + nextEpisode, + airedEpisodes: 0 + }; + } + + let airedEpisodes = Math.max(0, nextEpisode - 1); + const airingAt = media.nextAiringEpisode?.airingAt; + const nativeAiringAt = media.nextAiringEpisode?.nativeAiringAt; + + // If either source says the "next" episode already aired, treat it as released. + if (hasAired(airingAt, nowEpochSeconds) || hasAired(nativeAiringAt, nowEpochSeconds)) + airedEpisodes = Math.max(airedEpisodes, nextEpisode); + + return { + progress, + nextEpisode, + airedEpisodes + }; +}; + +export const hasDueEpisodes = (media: Media, nowEpochSeconds = Date.now() / 1000) => { + const episodeState = getAnimeEpisodeState(media, nowEpochSeconds); + + return episodeState.airedEpisodes > episodeState.progress; +}; + +export const hasNoAiredEpisodes = (media: Media, nowEpochSeconds = Date.now() / 1000) => { + const episodeState = getAnimeEpisodeState(media, nowEpochSeconds); + + return episodeState.airedEpisodes <= 0; +}; |