diff options
Diffstat (limited to 'src/lib/Media/Anime')
| -rw-r--r-- | src/lib/Media/Anime/Airing/Subtitled/match.ts | 652 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts | 13 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/animeSchedule.ts | 114 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/classify.test.ts | 187 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/match.ts | 381 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/time.ts | 8 |
6 files changed, 593 insertions, 762 deletions
diff --git a/src/lib/Media/Anime/Airing/Subtitled/match.ts b/src/lib/Media/Anime/Airing/Subtitled/match.ts deleted file mode 100644 index a89ac8ac..00000000 --- a/src/lib/Media/Anime/Airing/Subtitled/match.ts +++ /dev/null @@ -1,652 +0,0 @@ -import stringSimilarity from "string-similarity"; -import { get } from "svelte/store"; -import excludeMatch from "$lib/Data/Static/matchExclude.json"; -import type { SubsPlease } from "$lib/Media/Anime/Airing/Subtitled/subsPlease"; -import settings from "$stores/settings"; -import type { Media } from "../../../../Data/AniList/media"; -import { season } from "../../season"; - -export interface Time { - title: string; - time: string; - day: string; -} - -interface IndexedTime { - time: Time; - normalizedTitle: string; - tokens: string[]; -} - -interface DayScheduleIndex { - entries: IndexedTime[]; - exactTitleIndex: Map<string, number[]>; - tokenIndex: Map<string, number[]>; -} - -interface ScheduleIndex { - byDay: Map<string, DayScheduleIndex>; - version: string; -} - -const secondsUntil = (targetTime: string, targetDay: string) => { - const now = new Date(); - const [targetHour, targetMinute] = targetTime.split(":").map(Number); - let dayDifference = - [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - ].indexOf(targetDay) - now.getDay(); - - if (dayDifference < 0) dayDifference += 7; - - const targetDate = new Date(now); - - targetDate.setDate(now.getDate() + dayDifference); - targetDate.setHours(targetHour, targetMinute, 0, 0); - - const secondsDifference = (Number(targetDate) - Number(now)) / 1000; - - return secondsDifference > 0 - ? secondsDifference - : secondsDifference + 7 * 24 * 60 * 60; -}; - -const preprocessTitle = (title: string): string => { - return title - .toLowerCase() - .replace(/\b(season|s|part|cour)(\d+)\b/g, " $2 ") - .replace(/\b(season|s|part|cour)\b/g, " ") - .replace(/[^a-z0-9\s]/gi, "") - .trim() - .split(/\s+/) - .join(" "); -}; - -const NON_DISTINCTIVE_TOKENS = new Set([ - "a", - "and", - "de", - "e", - "for", - "ga", - "in", - "na", - "ni", - "no", - "o", - "of", - "on", - "the", - "to", - "wa", - "wo", -]); - -const isMeaningfulToken = (token: string): boolean => - /^\d+$/.test(token) || - (token.length >= 3 && !NON_DISTINCTIVE_TOKENS.has(token)); - -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 = 8 * 24 * 60 * 60; - -interface SimilarityAnalysis { - score: number; - tokenOverlap: number; - numericTokenOverlap: number; -} - -const calculateWeightedSimilarity = ( - title1: string, - title2: string, -): SimilarityAnalysis => { - const tokens1 = title1.split(" ").filter(isMeaningfulToken); - const tokens2 = title2.split(" ").filter(isMeaningfulToken); - - if (tokens1.length === 0 || tokens2.length === 0) - return { - score: 0, - tokenOverlap: 0, - numericTokenOverlap: 0, - }; - - const set2 = new Set(tokens2); - 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)) { - tokenOverlap += 1; - - if (/^\d+$/.test(token)) numericTokenOverlap += 1; - - score += /^\d+$/.test(token) ? 2 : 1; - } - }); - - 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: finalScore, - tokenOverlap, - numericTokenOverlap, - }; -}; - -const indexPush = ( - index: Map<string, number[]>, - key: string, - entryIndex: number, -) => { - const existing = index.get(key); - - if (existing) existing.push(entryIndex); - else index.set(key, [entryIndex]); -}; - -const scheduleIndexCache = new WeakMap<SubsPlease, ScheduleIndex>(); -const closestMatchCache = new Map<string, Time | null>(); -const injectAiringTimeCache = new Map<string, Media>(); - -const hashString = (input: string): string => { - let hash = 2166136261; - - for (let index = 0; index < input.length; index += 1) { - hash ^= input.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - - return (hash >>> 0).toString(36); -}; - -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 localTimeZone = () => - Intl.DateTimeFormat().resolvedOptions().timeZone || "local"; - -const airingDayOf = (airingAt: number | undefined) => - new Date((airingAt || 0) * 1000).toLocaleString("en-US", { weekday: "long" }); - -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>(); - const versionParts: string[] = []; - - for (const [day, value] of Object.entries(subsPlease.schedule)) { - const flattenedValue = Array.isArray(value) ? value.flat() : []; - - versionParts.push(day); - - const dayIndex: DayScheduleIndex = { - entries: [], - exactTitleIndex: new Map<string, number[]>(), - tokenIndex: new Map<string, number[]>(), - }; - - for (const scheduleTime of flattenedValue) { - const time = { - title: scheduleTime.title, - time: scheduleTime.time, - day, - }; - - versionParts.push(`${day}\u001f${time.title}\u001f${time.time}`); - - const normalizedTitle = preprocessTitle(time.title); - const tokens = normalizedTitle.split(" ").filter(isMeaningfulToken); - const entryIndex = dayIndex.entries.length; - - dayIndex.entries.push({ - time, - normalizedTitle, - tokens, - }); - indexPush(dayIndex.exactTitleIndex, normalizedTitle, entryIndex); - - for (const token of tokens) - indexPush(dayIndex.tokenIndex, token, entryIndex); - } - - byDay.set(day, dayIndex); - } - - return { - byDay, - version: hashString(`${subsPlease.tz}\u001e${versionParts.join("\u001d")}`), - }; -}; - -export const findClosestMatch = ( - scheduleIndex: ScheduleIndex, - anime: Media, -): Time | null => { - if (excludeMatch.includes(anime.id)) { - setBoundedCacheValue( - closestMatchCache, - `${anime.id}:excluded`, - null, - MAX_MATCH_CACHE_ENTRIES, - ); - - return null; - } - - const airingDay = airingDayOf(anime.nextAiringEpisode?.airingAt); - const cacheKey = `${anime.id}:${anime.nextAiringEpisode?.airingAt || 0}:${animeTitleFingerprint( - anime, - )}:${airingDay}:${localTimeZone()}:${scheduleIndex.version}`; - const cached = closestMatchCache.get(cacheKey); - - if (cached !== undefined) return cached; - const dayIndex = scheduleIndex.byDay.get(airingDay); - - if (!dayIndex || dayIndex.entries.length === 0) { - setBoundedCacheValue( - closestMatchCache, - cacheKey, - null, - MAX_MATCH_CACHE_ENTRIES, - ); - - return null; - } - - let bestMatch: Time | null = null; - let bestScore = 0; - let secondBestScore = 0; - let bestTokenOverlap = 0; - let bestNumericTokenOverlap = 0; - const searchTitles = [ - anime.title.romaji, - anime.title.english, - ...anime.synonyms, - ].filter(Boolean); - - for (const searchTitle of searchTitles) { - if (searchTitle.includes("OVA") || searchTitle.includes("Special")) - continue; - - const normalizedSearchTitle = preprocessTitle(searchTitle); - const exactMatchIndexes = dayIndex.exactTitleIndex.get( - normalizedSearchTitle, - ); - - if (exactMatchIndexes && exactMatchIndexes.length > 0) { - const exactMatch = dayIndex.entries[exactMatchIndexes[0]]; - - if (exactMatch) { - setBoundedCacheValue( - closestMatchCache, - cacheKey, - exactMatch.time, - MAX_MATCH_CACHE_ENTRIES, - ); - - return exactMatch.time; - } - } - - const searchTokens = normalizedSearchTitle - .split(" ") - .filter(isMeaningfulToken); - const candidateIndexSet = new Set<number>(); - - for (const token of searchTokens) { - for (const candidateIndex of dayIndex.tokenIndex.get(token) || []) - candidateIndexSet.add(candidateIndex); - } - - const candidateIndexes = - candidateIndexSet.size > 0 - ? [...candidateIndexSet] - : dayIndex.entries.map((_, entryIndex) => entryIndex); - - for (const candidateIndex of candidateIndexes) { - const candidateEntry = dayIndex.entries[candidateIndex]; - const similarity = calculateWeightedSimilarity( - normalizedSearchTitle, - candidateEntry.normalizedTitle, - ); - - if (similarity.score > bestScore) { - secondBestScore = bestScore; - bestScore = similarity.score; - bestTokenOverlap = similarity.tokenOverlap; - bestNumericTokenOverlap = similarity.numericTokenOverlap; - bestMatch = candidateEntry.time; - } else if (similarity.score > secondBestScore) { - secondBestScore = similarity.score; - } - } - } - - if (bestScore < MIN_MATCH_SCORE) { - const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles); - - setBoundedCacheValue( - closestMatchCache, - cacheKey, - fallbackMatch, - MAX_MATCH_CACHE_ENTRIES, - ); - - return fallbackMatch; - } - - if (bestScore - secondBestScore < MIN_MATCH_MARGIN) { - const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles); - - setBoundedCacheValue( - closestMatchCache, - cacheKey, - fallbackMatch, - MAX_MATCH_CACHE_ENTRIES, - ); - - return fallbackMatch; - } - - if (bestNumericTokenOverlap === 0 && bestTokenOverlap < MIN_TOKEN_OVERLAP) { - const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles); - - setBoundedCacheValue( - closestMatchCache, - cacheKey, - fallbackMatch, - MAX_MATCH_CACHE_ENTRIES, - ); - - return fallbackMatch; - } - - setBoundedCacheValue( - closestMatchCache, - cacheKey, - bestMatch, - MAX_MATCH_CACHE_ENTRIES, - ); - - return bestMatch; -}; - -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>(); - -export const findClosestMedia = (media: Media[], matchFor: string) => { - if (!matchFor) return null; - - const cached = findClosestMediaCache.get(matchFor); - - if (cached !== undefined) return cached; - - const normalisedMatchFor = normalizeTitle(matchFor); - const matchForWords = normalisedMatchFor.split(" "); - let bestFitMedia: Media | null = null; - let bestDistance = -Infinity; - - for (const m of media) { - const titles = [m.title.romaji, m.title.english, ...m.synonyms].filter( - Boolean, - ); - - if ( - titles.some( - (title) => - title.toLowerCase().includes("special") || - title.toLowerCase().includes("ova"), - ) - ) - continue; - - const normalisedTitles = titles.map(normalizeTitle); - - for (const normalisedTitle of normalisedTitles) { - const distance = stringSimilarity.compareTwoStrings( - normalisedMatchFor, - normalisedTitle, - ); - - if (distance <= bestDistance) continue; - - const wordMatch = - matchForWords.every((word) => - normalisedTitles.some((t) => t.includes(word)), - ) || normalisedTitles.some((t) => t.includes(normalisedMatchFor)); - - if (wordMatch) { - bestDistance = distance; - bestFitMedia = m; - - if (distance === 1) break; - } - } - - if (bestDistance === 1) break; - } - - findClosestMediaCache.set(matchFor, bestFitMedia); - - return bestFitMedia as Media | null; -}; - -export const clearClosestMediaCache = () => findClosestMediaCache.clear(); - -const getScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => { - const cached = scheduleIndexCache.get(subsPlease); - - 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.mediaListEntry?.status || "", - anime.mediaListEntry?.progress || 0, - anime.mediaListEntry?.updatedAt || 0, - anime.nextAiringEpisode?.episode || 0, - anime.nextAiringEpisode?.airingAt || 0, - displayNativeCountdown ? 1 : 0, - 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; - -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 cloneInjectedMedia(cached); - - const airingAt = anime.nextAiringEpisode?.airingAt; - const now = new Date(); - // const nativeUntilAiring = airingAt - // ? Math.round((airingAt - Date.now() / 1000) * 100) / 100 - // : undefined; - const nativeTime = new Date(airingAt ? airingAt * 1000 : 0); - let untilAiring: number | undefined; - let time = new Date(airingAt ? airingAt * 1000 : 0); - let nextEpisode = anime.nextAiringEpisode?.episode || 0; - let nativeEpisode = nextEpisode; - - if (!(displayNativeCountdown || !subsPlease)) { - const scheduleIndex = getScheduleIndex(subsPlease); - - if ((anime.nextAiringEpisode?.episode || 0) > 1) { - const foundTime: Time | null = findClosestMatch(scheduleIndex, anime); - - if (foundTime) { - untilAiring = secondsUntil( - (foundTime as Time).time, - (foundTime as Time).day, - ); - time = new Date(Date.now() + untilAiring * 1000); - } - } - } - - const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; - - 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) { - const beforeTime = time; - - time = nativeTime; - - 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( - injectAiringTimeCache, - cacheKey, - cachedValue, - MAX_INJECT_CACHE_ENTRIES, - ); - - return cloneInjectedMedia(cachedValue); -}; - -export const clearInjectAiringTimeCache = () => injectAiringTimeCache.clear(); diff --git a/src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts b/src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts deleted file mode 100644 index 3815259d..00000000 --- a/src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface SubsPlease { - tz: string; - schedule: { - [key in string]: SubsPleaseEpisode; - }[]; -} - -export interface SubsPleaseEpisode { - title: string; - page: string; - image_url: string; - time: string; -} diff --git a/src/lib/Media/Anime/Airing/animeSchedule.ts b/src/lib/Media/Anime/Airing/animeSchedule.ts new file mode 100644 index 00000000..f3f6f85d --- /dev/null +++ b/src/lib/Media/Anime/Airing/animeSchedule.ts @@ -0,0 +1,114 @@ +// Data model for AnimeSchedule.net's weekly timetable, the source of truth for +// when subbed and dubbed episodes actually release. Unlike a fansub schedule, +// every release carries an absolute timestamp, an episode number, delay windows, +// and the streaming platforms it lands on. + +export type AirType = "sub" | "dub"; + +export interface Stream { + platform: string; + url: string; + name: string; +} + +// A single scheduled episode release for one show. +export interface AiringEntry { + route: string; + title: string; + romaji: string; + english: string; + native: string; + episodeNumber: number; + airingAt: number; + delayedUntil?: number; + imageUrl: string; + streams: Stream[]; +} + +// The merged sub + dub schedule for the current week. +export interface AiringSchedule { + generatedAt: number; + sub: AiringEntry[]; + dub: AiringEntry[]; +} + +const IMAGE_BASE = + "https://img.animeschedule.net/production/assets/public/img/"; + +const animeScheduleImageUrl = (imageVersionRoute: string): string => + imageVersionRoute ? `${IMAGE_BASE}${imageVersionRoute}` : ""; + +// AnimeSchedule uses 0001-01-01 as its "no date" sentinel. +const isZeroDate = (value: string | undefined): boolean => + !value || value.startsWith("0001-01-01"); + +const toEpochSeconds = (value: string | undefined): number => + isZeroDate(value) + ? 0 + : Math.floor(new Date(value as string).getTime() / 1000); + +// Stream URLs come back without a scheme (e.g. "www.hidive.com/..."). +const withScheme = (url: string): string => + !url ? "" : /^https?:\/\//.test(url) ? url : `https://${url}`; + +interface RawTimetableEntry { + title: string; + route: string; + romaji: string; + english: string; + native: string; + episodeNumber: number; + episodeDate: string; + delayedUntil: string; + imageVersionRoute: string; + streams: { platform: string; url: string; name: string }[]; +} + +export const parseTimetable = (raw: unknown): AiringEntry[] => { + if (!Array.isArray(raw)) return []; + + return (raw as RawTimetableEntry[]).map((entry) => { + const delayedUntil = toEpochSeconds(entry.delayedUntil); + + return { + route: entry.route, + title: entry.english || entry.romaji || entry.title, + romaji: entry.romaji || "", + english: entry.english || "", + native: entry.native || "", + episodeNumber: entry.episodeNumber, + airingAt: toEpochSeconds(entry.episodeDate), + delayedUntil: delayedUntil || undefined, + imageUrl: animeScheduleImageUrl(entry.imageVersionRoute), + streams: (entry.streams || []).map((stream) => ({ + platform: stream.platform, + url: withScheme(stream.url), + name: stream.name, + })), + }; + }); +}; + +const TIMETABLE_ENDPOINT = "https://animeschedule.net/api/v3/timetables"; + +// Fetch and parse the current week's sub and dub timetables in one shot. The +// caller supplies the AnimeSchedule application token. +export const fetchTimetables = async ( + token: string, +): Promise<{ sub: AiringEntry[]; dub: AiringEntry[] }> => { + const fetchOne = async (airType: AirType): Promise<AiringEntry[]> => { + try { + const response = await fetch(`${TIMETABLE_ENDPOINT}?airType=${airType}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + return response.ok ? parseTimetable(await response.json()) : []; + } catch { + return []; + } + }; + + const [sub, dub] = await Promise.all([fetchOne("sub"), fetchOne("dub")]); + + return { sub, dub }; +}; diff --git a/src/lib/Media/Anime/Airing/classify.test.ts b/src/lib/Media/Anime/Airing/classify.test.ts index 161787ba..1019b303 100644 --- a/src/lib/Media/Anime/Airing/classify.test.ts +++ b/src/lib/Media/Anime/Airing/classify.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Media } from "$lib/Data/AniList/media"; +import type { AiringSchedule } from "$lib/Media/Anime/Airing/animeSchedule"; import { getAnimeEpisodeState, hasDueEpisodes, @@ -7,18 +8,29 @@ import { import { clearInjectAiringTimeCache, injectAiringTime, -} from "$lib/Media/Anime/Airing/Subtitled/match"; -import type { SubsPlease } from "$lib/Media/Anime/Airing/Subtitled/subsPlease"; +} from "$lib/Media/Anime/Airing/match"; import { season } from "$lib/Media/Anime/season"; import settings from "$stores/settings"; -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}`; -}; +// A single-show schedule that joins to the given media by title, releasing the +// sub at `airingAt`. +const subScheduleFor = (media: Media, airingAt: number): AiringSchedule => ({ + generatedAt: Math.floor(Date.now() / 1000), + sub: [ + { + route: `fixture-${media.id}`, + title: media.title.romaji, + romaji: media.title.romaji, + english: media.title.english, + native: media.title.native, + episodeNumber: media.nextAiringEpisode?.episode || 0, + airingAt, + imageUrl: "", + streams: [], + }, + ], + dub: [], +}); const baseMedia = (id: number): Media => ({ @@ -117,29 +129,12 @@ describe("anime episode classification", () => { }); }); -describe("native countdown toggle parity", () => { +describe("countdown source parity", () => { for (const id of regressionIds) { - it(`keeps media ${id} due with native countdown on/off`, () => { + it(`keeps media ${id} due across native and sub countdown sources`, () => { 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, @@ -148,13 +143,15 @@ describe("native countdown toggle parity", () => { if (media.mediaListEntry) media.mediaListEntry.progress = 5; - settings.setKey("displayNativeCountdown", true); + const schedule = subScheduleFor(media, subtitledAiringAt); - const nativeOnly = injectAiringTime(media, subsPlease); + settings.setKey("countdownSource", "native"); - settings.setKey("displayNativeCountdown", false); + const nativeOnly = injectAiringTime(media, schedule); - const subtitled = injectAiringTime(media, subsPlease); + settings.setKey("countdownSource", "sub"); + + const subtitled = injectAiringTime(media, schedule); expect(hasDueEpisodes(nativeOnly)).toBe(true); expect(hasDueEpisodes(subtitled)).toBe(true); @@ -162,40 +159,75 @@ describe("native countdown toggle parity", () => { } }); +describe("countdown source fallback", () => { + it("falls back dub → sub when no dub release exists", () => { + clearInjectAiringTimeCache(); + + const media = baseMedia(310001); + const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; + const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60; + + media.nextAiringEpisode = { + episode: 8, + airingAt: nativeAiringAt, + }; + + // Sub-only schedule (the dub feed is empty). + const schedule = subScheduleFor(media, subtitledAiringAt); + + settings.setKey("countdownSource", "dub"); + + const injected = injectAiringTime(media, schedule); + + expect(injected.nextAiringEpisode?.airingAt).toBe(subtitledAiringAt); + expect(injected.nextAiringEpisode?.nativeAiringAt).toBe(nativeAiringAt); + }); + + it("falls back to native when neither dub nor sub exists", () => { + clearInjectAiringTimeCache(); + + const media = baseMedia(310002); + const nativeAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; + + media.nextAiringEpisode = { + episode: 8, + airingAt: nativeAiringAt, + }; + + const schedule: AiringSchedule = { + generatedAt: Math.floor(Date.now() / 1000), + sub: [], + dub: [], + }; + + settings.setKey("countdownSource", "dub"); + + const injected = injectAiringTime(media, schedule); + + expect(injected.nextAiringEpisode?.airingAt).toBe(nativeAiringAt); + }); +}); + describe("injectAiringTime cache safety", () => { it("does not let caller mutation poison cached injected media", () => { const media = baseMedia(444444); 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", false); - const first = injectAiringTime(media, subsPlease); + const schedule = subScheduleFor(media, subtitledAiringAt); + + settings.setKey("countdownSource", "sub"); + + const first = injectAiringTime(media, schedule); first.nextAiringEpisode = { episode: -1 }; - const second = injectAiringTime(media, subsPlease); + const second = injectAiringTime(media, schedule); expect(second.nextAiringEpisode?.episode).not.toBe(-1); expect(typeof second.nextAiringEpisode?.airingAt).toBe("number"); @@ -208,30 +240,15 @@ describe("injectAiringTime cache safety", () => { const media = baseMedia(194028); 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: 10, airingAt: nativeAiringAt, }; - settings.setKey("displayNativeCountdown", false); + const schedule = subScheduleFor(media, subtitledAiringAt); + + settings.setKey("countdownSource", "sub"); const caughtUp = { ...media, @@ -247,8 +264,8 @@ describe("injectAiringTime cache safety", () => { progress: 7, }, } as Media; - const cachedCaughtUp = injectAiringTime(caughtUp, subsPlease); - const updatedBehind = injectAiringTime(behind, subsPlease); + const cachedCaughtUp = injectAiringTime(caughtUp, schedule); + const updatedBehind = injectAiringTime(behind, schedule); expect(hasDueEpisodes(cachedCaughtUp)).toBe(false); expect(hasDueEpisodes(updatedBehind)).toBe(true); @@ -259,33 +276,17 @@ describe("injectAiringTime cache safety", () => { const media = baseMedia(194028); 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; - settings.setKey("displayNativeCountdown", false); + const schedule = subScheduleFor(media, subtitledAiringAt); + + settings.setKey("countdownSource", "sub"); const originalProgress = media.mediaListEntry?.progress || 0; - const first = injectAiringTime(media, subsPlease); + const first = injectAiringTime(media, schedule); if (first.mediaListEntry) first.mediaListEntry.progress = 999; - const second = injectAiringTime(media, subsPlease); + const second = injectAiringTime(media, schedule); expect(media.mediaListEntry?.progress).toBe(originalProgress); expect(second.mediaListEntry?.progress).toBe(originalProgress); 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; +}; diff --git a/src/lib/Media/Anime/Airing/time.ts b/src/lib/Media/Anime/Airing/time.ts index e2111e86..f45a690f 100644 --- a/src/lib/Media/Anime/Airing/time.ts +++ b/src/lib/Media/Anime/Airing/time.ts @@ -1,19 +1,19 @@ import { get } from "svelte/store"; import type { Media } from "$lib/Data/AniList/media"; import type { MediaPrequel } from "$lib/Data/AniList/prequels"; -import type { SubsPlease } from "$lib/Media/Anime/Airing/Subtitled/subsPlease"; +import type { AiringSchedule } from "$lib/Media/Anime/Airing/animeSchedule"; import settings from "$stores/settings"; import { totalEpisodes } from "../episodes"; import { formatCountdown } from "./format"; -import { injectAiringTime } from "./Subtitled/match"; +import { injectAiringTime } from "./match"; export const airingTime = ( originalAnime: Media, - subsPlease: SubsPlease | null, + schedule: AiringSchedule | null, upcoming = false, forceDays = false, ) => { - const anime = injectAiringTime(originalAnime, subsPlease); + const anime = injectAiringTime(originalAnime, schedule); const airingAt = anime.nextAiringEpisode?.airingAt; const untilAiring = airingAt ? Math.round((airingAt - Date.now() / 1000) * 100) / 100 |