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; byLatin: Map; entries: AiringEntry[]; } const indexSet = ( index: Map, key: string, entry: AiringEntry, ) => { if (key && !index.has(key)) index.set(key, entry); }; const buildScheduleIndex = (entries: AiringEntry[]): ScheduleIndex => { const byNative = new Map(); const byLatin = new Map(); 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> >(); 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(); 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(); // 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; };