diff options
| author | Fuwn <[email protected]> | 2026-06-05 11:10:22 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-06-05 11:10:22 +0000 |
| commit | 4b56194ee6807acb56abf0949394efadabf830d4 (patch) | |
| tree | 5cb2074a8d012bf9b7c900e7e44cbdfd0e15123f /src/lib/Media/Anime/Airing/match.ts | |
| parent | fix(lists): tick count down when media leaves a list (diff) | |
| download | due.moe-4b56194ee6807acb56abf0949394efadabf830d4.tar.xz due.moe-4b56194ee6807acb56abf0949394efadabf830d4.zip | |
feat(airing): replace SubsPlease with AnimeSchedule (sub+dub)
Source both subbed and dubbed episode schedules from AnimeSchedule.net v3 (absolute timestamps, episode numbers, delay windows, streams), keyed to AniList shows by title. Removes SubsPlease and its ~650-line fuzzy matcher. Countdown source is now a setting (native|sub|dub) with a dub->sub->native fallback.
Requires ANIMESCHEDULE_CLIENT_TOKEN.
Diffstat (limited to 'src/lib/Media/Anime/Airing/match.ts')
| -rw-r--r-- | src/lib/Media/Anime/Airing/match.ts | 381 |
1 files changed, 381 insertions, 0 deletions
diff --git a/src/lib/Media/Anime/Airing/match.ts b/src/lib/Media/Anime/Airing/match.ts new file mode 100644 index 00000000..9cfe6102 --- /dev/null +++ b/src/lib/Media/Anime/Airing/match.ts @@ -0,0 +1,381 @@ +import stringSimilarity from "string-similarity"; +import { get } from "svelte/store"; +import type { Media } from "$lib/Data/AniList/media"; +import settings from "$stores/settings"; +import { season } from "../season"; +import type { AiringEntry, AiringSchedule, AirType } from "./animeSchedule"; + +const SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60; +const STALE_AIRING_GRACE_SECONDS = 5 * 60; +const MAX_EPISODE_SHIFT_WINDOW_SECONDS = 8 * 24 * 60 * 60; +const MAX_INJECT_CACHE_ENTRIES = 10_000; +const FUZZY_MIN_SCORE = 0.82; + +// Strip season/part markers and punctuation so canonical romaji/english titles +// from AniList and AnimeSchedule normalise to the same key. NFKC folds fullwidth +// characters down to ASCII first. +const normalizeLatin = (title: string): string => + (title || "") + .normalize("NFKC") + .toLowerCase() + .replace(/\b(season|s|part|cour)\s*(\d+)\b/g, " $2 ") + .replace(/\b(season|s|part|cour)\b/g, " ") + .replace(/[^a-z0-9\s]/g, " ") + .trim() + .split(/\s+/) + .join(" "); + +// Native titles are CJK (Japanese, Chinese, Korean); collapse whitespace but +// keep every glyph. NFKC reconciles fullwidth and halfwidth digits (農家2 vs 農家2). +const normalizeNative = (title: string): string => + (title || "").normalize("NFKC").replace(/\s+/g, "").toLowerCase(); + +interface ScheduleIndex { + byNative: Map<string, AiringEntry>; + byLatin: Map<string, AiringEntry>; + entries: AiringEntry[]; +} + +const indexSet = ( + index: Map<string, AiringEntry>, + key: string, + entry: AiringEntry, +) => { + if (key && !index.has(key)) index.set(key, entry); +}; + +const buildScheduleIndex = (entries: AiringEntry[]): ScheduleIndex => { + const byNative = new Map<string, AiringEntry>(); + const byLatin = new Map<string, AiringEntry>(); + + for (const entry of entries) { + indexSet(byNative, normalizeNative(entry.native), entry); + indexSet(byLatin, normalizeLatin(entry.romaji), entry); + indexSet(byLatin, normalizeLatin(entry.english), entry); + indexSet(byLatin, normalizeLatin(entry.title), entry); + } + + return { byNative, byLatin, entries }; +}; + +const indexCache = new WeakMap< + AiringSchedule, + Partial<Record<AirType, ScheduleIndex>> +>(); + +const getScheduleIndex = ( + schedule: AiringSchedule, + source: AirType, +): ScheduleIndex => { + let perSource = indexCache.get(schedule); + + if (!perSource) { + perSource = {}; + + indexCache.set(schedule, perSource); + } + + const cached = perSource[source]; + + if (cached) return cached; + + const built = buildScheduleIndex(schedule[source]); + + perSource[source] = built; + + return built; +}; + +const fuzzyMatch = ( + index: ScheduleIndex, + searchTitles: string[], +): AiringEntry | null => { + let bestEntry: AiringEntry | null = null; + let bestScore = 0; + + for (const searchTitle of searchTitles) { + const normalized = normalizeLatin(searchTitle); + + if (!normalized) continue; + + for (const entry of index.entries) { + const score = stringSimilarity.compareTwoStrings( + normalized, + normalizeLatin(entry.english || entry.romaji || entry.title), + ); + + if (score > bestScore) { + bestScore = score; + bestEntry = entry; + } + } + } + + return bestScore >= FUZZY_MIN_SCORE ? bestEntry : null; +}; + +// Join an AniList show to its AnimeSchedule release. The native title is a +// near-perfect key; romaji/english cover the rest, with a fuzzy fallback. +const findScheduleEntry = ( + schedule: AiringSchedule, + source: AirType, + anime: Media, +): AiringEntry | null => { + const index = getScheduleIndex(schedule, source); + + const nativeMatch = index.byNative.get(normalizeNative(anime.title.native)); + + if (nativeMatch) return nativeMatch; + + const latinTitles = [ + anime.title.romaji, + anime.title.english, + ...anime.synonyms, + ]; + + for (const title of latinTitles) { + const match = index.byLatin.get(normalizeLatin(title)); + + if (match) return match; + } + + return fuzzyMatch(index, latinTitles.filter(Boolean)); +}; + +// Resolve the next future release time for a matched entry. AnimeSchedule gives +// the current week's episode; a delay window or a weekly cadence rolls a past +// release forward to the next occurrence. +const nextReleaseTime = ( + entry: AiringEntry, + nowEpochSeconds: number, +): number => { + if (entry.delayedUntil && entry.delayedUntil > nowEpochSeconds) + return entry.delayedUntil; + + const base = entry.airingAt; + + if (!base) return 0; + if (base > nowEpochSeconds - STALE_AIRING_GRACE_SECONDS) return base; + + const weeksElapsed = Math.ceil((nowEpochSeconds - base) / SEVEN_DAYS_SECONDS); + + return base + weeksElapsed * SEVEN_DAYS_SECONDS; +}; + +const injectAiringTimeCache = new Map<string, Media>(); + +const setBoundedCacheValue = (key: string, value: Media) => { + if (injectAiringTimeCache.size >= MAX_INJECT_CACHE_ENTRIES) + injectAiringTimeCache.clear(); + + injectAiringTimeCache.set(key, value); +}; + +const animeTitleFingerprint = (anime: Media) => + [ + anime.title.romaji, + anime.title.english, + anime.title.native, + ...anime.synonyms, + ] + .filter(Boolean) + .join("|"); + +const buildInjectAiringTimeCacheKey = ( + anime: Media, + scheduleVersion: string, + source: string, +) => + [ + anime.id, + anime.status, + anime.mediaListEntry?.status || "", + anime.mediaListEntry?.progress || 0, + anime.mediaListEntry?.updatedAt || 0, + anime.nextAiringEpisode?.episode || 0, + anime.nextAiringEpisode?.airingAt || 0, + source, + scheduleVersion, + animeTitleFingerprint(anime), + ].join(":"); + +const cloneInjectedMedia = (media: Media): Media => + ({ + ...media, + mediaListEntry: media.mediaListEntry + ? { + ...media.mediaListEntry, + startedAt: { ...media.mediaListEntry.startedAt }, + completedAt: { ...media.mediaListEntry.completedAt }, + customLists: { ...media.mediaListEntry.customLists }, + } + : undefined, + nextAiringEpisode: media.nextAiringEpisode + ? { ...media.nextAiringEpisode } + : undefined, + }) as Media; + +// Override a show's countdown with its subbed or dubbed release time while +// preserving the native broadcast time and episode for due classification. +export const injectAiringTime = ( + anime: Media, + schedule: AiringSchedule | null, +) => { + if (season() !== anime.season) return anime; + + const source = get(settings).countdownSource; + const useSchedule = source !== "native" && schedule !== null; + const scheduleVersion = useSchedule + ? String((schedule as AiringSchedule).generatedAt) + : "native-only"; + const cacheKey = buildInjectAiringTimeCacheKey( + anime, + scheduleVersion, + source, + ); + const cached = injectAiringTimeCache.get(cacheKey); + + if (cached) return cloneInjectedMedia(cached); + + const airingAt = anime.nextAiringEpisode?.airingAt; + const now = new Date(); + const nativeTime = new Date(airingAt ? airingAt * 1000 : 0); + let time = new Date(airingAt ? airingAt * 1000 : 0); + let nextEpisode = anime.nextAiringEpisode?.episode || 0; + let nativeEpisode = nextEpisode; + + // Prefer the selected track, then fall back: dub → sub → native. Sub never + // falls back to dub. Native is the initial value of `time`. + if (useSchedule && (anime.nextAiringEpisode?.episode || 0) > 1) { + const fallbackOrder: AirType[] = + source === "dub" ? ["dub", "sub"] : ["sub"]; + + for (const candidateSource of fallbackOrder) { + const entry = findScheduleEntry( + schedule as AiringSchedule, + candidateSource, + anime, + ); + + if (!entry) continue; + + const releaseTime = nextReleaseTime(entry, Date.now() / 1000); + + if (releaseTime) { + time = new Date(releaseTime * 1000); + + break; + } + } + } + + 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; + nativeEpisode = nextEpisode; + } + + if (nativeTime.getTime() - now.getTime() > SEVEN_DAYS_SECONDS * 1000) { + const beforeTime = time; + + time = new Date(nativeTime.getTime()); + + time.setHours(beforeTime.getHours()); + time.setMinutes(beforeTime.getMinutes()); + } + + const injected = { + ...anime, + nextAiringEpisode: { + episode: nextEpisode, + airingAt: time.getTime() / 1000, + nativeAiringAt: nativeTime.getTime() / 1000, + nativeEpisode, + }, + } as Media; + + const cachedValue = cloneInjectedMedia(injected); + + setBoundedCacheValue(cacheKey, cachedValue); + + return cloneInjectedMedia(cachedValue); +}; + +export const clearInjectAiringTimeCache = () => injectAiringTimeCache.clear(); + +const normalizeTitle = (title: string | null) => + (title || "") + .toLowerCase() + .replace(/\b(s|season|part|cour)\s*\d+/g, "") + .replace(/[\W_]+/g, " ") + .trim(); + +const findClosestMediaCache = new Map<string, Media | null>(); + +// Reverse lookup used by the schedule page: pick the AniList show that best +// matches a given release title. +export const findClosestMedia = (media: Media[], matchFor: string) => { + if (!matchFor) return null; + + const cached = findClosestMediaCache.get(matchFor); + + if (cached !== undefined) return cached; + + const normalizedMatchFor = normalizeTitle(matchFor); + const matchForWords = normalizedMatchFor.split(" "); + let bestFitMedia: Media | null = null; + let bestDistance = -Infinity; + + for (const mediaItem of media) { + const titles = [ + mediaItem.title.romaji, + mediaItem.title.english, + ...mediaItem.synonyms, + ].filter(Boolean); + + if ( + titles.some( + (title) => + title.toLowerCase().includes("special") || + title.toLowerCase().includes("ova"), + ) + ) + continue; + + const normalizedTitles = titles.map(normalizeTitle); + + for (const normalizedTitle of normalizedTitles) { + const distance = stringSimilarity.compareTwoStrings( + normalizedMatchFor, + normalizedTitle, + ); + + if (distance <= bestDistance) continue; + + const wordMatch = + matchForWords.every((word) => + normalizedTitles.some((t) => t.includes(word)), + ) || normalizedTitles.some((t) => t.includes(normalizedMatchFor)); + + if (wordMatch) { + bestDistance = distance; + bestFitMedia = mediaItem; + + if (distance === 1) break; + } + } + + if (bestDistance === 1) break; + } + + findClosestMediaCache.set(matchFor, bestFitMedia); + + return bestFitMedia as Media | null; +}; |