aboutsummaryrefslogtreecommitdiff
path: root/src/lib/Media/Anime/Airing/match.ts
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-06-05 11:10:22 +0000
committerFuwn <[email protected]>2026-06-05 11:10:22 +0000
commit4b56194ee6807acb56abf0949394efadabf830d4 (patch)
tree5cb2074a8d012bf9b7c900e7e44cbdfd0e15123f /src/lib/Media/Anime/Airing/match.ts
parentfix(lists): tick count down when media leaves a list (diff)
downloaddue.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.ts381
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;
+};