diff options
Diffstat (limited to 'src/lib/Media/Anime')
| -rw-r--r-- | src/lib/Media/Anime/Airing/AiringTime.svelte | 238 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/Subtitled/match.ts | 768 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts | 16 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/classify.test.ts | 291 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/classify.ts | 64 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/time.ts | 239 | ||||
| -rw-r--r-- | src/lib/Media/Anime/cache.ts | 65 | ||||
| -rw-r--r-- | src/lib/Media/Anime/episodes.ts | 6 | ||||
| -rw-r--r-- | src/lib/Media/Anime/season.ts | 18 |
9 files changed, 1251 insertions, 454 deletions
diff --git a/src/lib/Media/Anime/Airing/AiringTime.svelte b/src/lib/Media/Anime/Airing/AiringTime.svelte index 8cf1cacc..be5f40d9 100644 --- a/src/lib/Media/Anime/Airing/AiringTime.svelte +++ b/src/lib/Media/Anime/Airing/AiringTime.svelte @@ -1,124 +1,122 @@ <script lang="ts"> - import type { Media } from '$lib/Data/AniList/media'; - import settings from '$stores/settings'; - import { onDestroy, onMount } from 'svelte'; - import type { MediaPrequel } from '$lib/Data/AniList/prequels'; - import tooltip from '$lib/Tooltip/tooltip'; - import locale from '$stores/locale'; - - export let originalAnime: Media; - export let upcoming = false; - - const anime = originalAnime; - let opacity = 100; - let timeFrame = ''; - let time = ''; - let nextEpisode = anime.nextAiringEpisode?.episode || 0; - let few = true; - let dateString = ''; - let updateInterval: ReturnType<typeof setInterval>; - - onMount(() => { - const setAiringTime = () => { - time = ''; - timeFrame = ''; - dateString = ''; - - const airingAt = anime.nextAiringEpisode?.airingAt; - const untilAiring = airingAt - ? Math.round((airingAt - Date.now() / 1000) * 100) / 100 - : undefined; - let hours = null; - const shortenCountdown = $settings.displayShortCountdown; - - time = new Date(airingAt ? airingAt * 1000 : 0).toLocaleTimeString([], { - hour12: !$settings.display24HourTime, - hour: 'numeric', - minute: '2-digit' - }); - - if ( - (anime as unknown as MediaPrequel).startDate && - new Date( - anime.startDate.year, - (anime as unknown as MediaPrequel).startDate.month, - (anime as unknown as MediaPrequel).startDate.day - ) < new Date() - ) - return `<span class="opaque">on ${new Date( - anime.startDate.year, - (anime as unknown as MediaPrequel).startDate.month, - (anime as unknown as MediaPrequel).startDate.day - ).toLocaleDateString()}</span>`; - - if (untilAiring !== undefined) { - let minutes = Math.round(untilAiring / 60); - - few = true; - - if (minutes > 60) { - hours = minutes / 60; - - if (hours > 24) { - const days = Math.floor(hours / 24); - const weeks = Math.floor(days / 7); - - few = false; - - if (weeks >= 1.5) { - timeFrame = `${weeks}${shortenCountdown ? 'w' : ' week'}${ - weeks === 1 || shortenCountdown ? '' : 's' - }`; - - const residualDays = days % 7; - - if (residualDays > 0) - timeFrame += `${shortenCountdown ? '' : ' '}${residualDays}${ - shortenCountdown ? 'd' : ' day' - }${residualDays === 1 || shortenCountdown ? '' : 's'}`; - } else { - timeFrame += `${days}${shortenCountdown ? 'd' : ' day'}${ - days === 1 || shortenCountdown ? '' : 's' - }`; - } - - const residualHours = Math.floor(hours - days * 24); - - if (residualHours > 0) - timeFrame += `${shortenCountdown ? '' : ' '}${residualHours}${ - shortenCountdown ? 'h' : ' hour' - }${residualHours === 1 || shortenCountdown ? '' : 's'}`; - } else { - const residualMinutes = Math.round(minutes - Math.floor(hours) * 60); - - timeFrame += `${Math.floor(hours).toFixed(0)}${shortenCountdown ? 'h' : ' hour'}${ - Math.floor(hours) === 1 || shortenCountdown ? '' : 's' - }`; - - if (residualMinutes > 0) - timeFrame += `${shortenCountdown ? '' : ' '}${residualMinutes}${ - shortenCountdown ? 'm' : ' minute' - }${residualMinutes === 1 || shortenCountdown ? '' : 's'}`; - } - } else { - minutes = Math.round(minutes); - - timeFrame += `${minutes}${shortenCountdown ? 'm' : ' minute'}${ - minutes === 1 || shortenCountdown ? '' : 's' - }`; - } - - opacity = Math.max(50, 100 - (untilAiring / 60 / 60 / 24 / 7) * 50); - dateString = $locale().dateFormatter(new Date(airingAt ? airingAt * 1000 : 0)); - } - }; - - setAiringTime(); - - updateInterval = setInterval(setAiringTime, 30000); - }); - - onDestroy(() => clearInterval(updateInterval)); +import type { Media } from "$lib/Data/AniList/media"; +import settings from "$stores/settings"; +import type { MediaPrequel } from "$lib/Data/AniList/prequels"; +import tooltip from "$lib/Tooltip/tooltip"; +import locale from "$stores/locale"; +import airingNow from "$stores/airingNow"; + +export let originalAnime: Media; +export let upcoming = false; + +const anime = originalAnime; +let opacity = 100; +let timeFrame = ""; +let time = ""; +let nextEpisode = anime.nextAiringEpisode?.episode || 0; +let few = true; +let dateString = ""; +const setAiringTime = () => { + time = ""; + timeFrame = ""; + dateString = ""; + + const airingAt = anime.nextAiringEpisode?.airingAt; + const untilAiring = airingAt + ? Math.round((airingAt - $airingNow / 1000) * 100) / 100 + : undefined; + let hours = null; + const shortenCountdown = $settings.displayShortCountdown; + + time = new Date(airingAt ? airingAt * 1000 : 0).toLocaleTimeString([], { + hour12: !$settings.display24HourTime, + hour: "numeric", + minute: "2-digit", + }); + + if ( + (anime as unknown as MediaPrequel).startDate && + new Date( + anime.startDate.year, + (anime as unknown as MediaPrequel).startDate.month, + (anime as unknown as MediaPrequel).startDate.day, + ) < new Date() + ) + return `<span class="opaque">on ${new Date( + anime.startDate.year, + (anime as unknown as MediaPrequel).startDate.month, + (anime as unknown as MediaPrequel).startDate.day, + ).toLocaleDateString()}</span>`; + + if (untilAiring !== undefined) { + let minutes = Math.round(untilAiring / 60); + + few = true; + + if (minutes > 60) { + hours = minutes / 60; + + if (hours > 24) { + const days = Math.floor(hours / 24); + const weeks = Math.floor(days / 7); + + few = false; + + if (weeks >= 1.5) { + timeFrame = `${weeks}${shortenCountdown ? "w" : " week"}${ + weeks === 1 || shortenCountdown ? "" : "s" + }`; + + const residualDays = days % 7; + + if (residualDays > 0) + timeFrame += `${shortenCountdown ? "" : " "}${residualDays}${ + shortenCountdown ? "d" : " day" + }${residualDays === 1 || shortenCountdown ? "" : "s"}`; + } else { + timeFrame += `${days}${shortenCountdown ? "d" : " day"}${ + days === 1 || shortenCountdown ? "" : "s" + }`; + } + + const residualHours = Math.floor(hours - days * 24); + + if (residualHours > 0) + timeFrame += `${shortenCountdown ? "" : " "}${residualHours}${ + shortenCountdown ? "h" : " hour" + }${residualHours === 1 || shortenCountdown ? "" : "s"}`; + } else { + const residualMinutes = Math.round(minutes - Math.floor(hours) * 60); + + timeFrame += `${Math.floor(hours).toFixed(0)}${shortenCountdown ? "h" : " hour"}${ + Math.floor(hours) === 1 || shortenCountdown ? "" : "s" + }`; + + if (residualMinutes > 0) + timeFrame += `${shortenCountdown ? "" : " "}${residualMinutes}${ + shortenCountdown ? "m" : " minute" + }${residualMinutes === 1 || shortenCountdown ? "" : "s"}`; + } + } else { + minutes = Math.round(minutes); + + timeFrame += `${minutes}${shortenCountdown ? "m" : " minute"}${ + minutes === 1 || shortenCountdown ? "" : "s" + }`; + } + + opacity = Math.max(50, 100 - (untilAiring / 60 / 60 / 24 / 7) * 50); + dateString = $locale().dateFormatter( + new Date(airingAt ? airingAt * 1000 : 0), + ); + } +}; + +$: { + $airingNow; + + setAiringTime(); +} </script> {#if upcoming} diff --git a/src/lib/Media/Anime/Airing/Subtitled/match.ts b/src/lib/Media/Anime/Airing/Subtitled/match.ts index fb494539..40dda4b8 100644 --- a/src/lib/Media/Anime/Airing/Subtitled/match.ts +++ b/src/lib/Media/Anime/Airing/Subtitled/match.ts @@ -1,223 +1,649 @@ -import { get } from 'svelte/store'; -import type { Media } from '../../../../Data/AniList/media'; -import settings from '$stores/settings'; -import type { SubsPlease } from '$lib/Media/Anime/Airing/Subtitled/subsPlease'; -import stringSimilarity from 'string-similarity'; -import excludeMatch from '$lib/Data/Static/matchExclude.json'; -import { season } from '../../season'; +import { get } from "svelte/store"; +import type { Media } from "../../../../Data/AniList/media"; +import settings from "$stores/settings"; +import type { SubsPlease } from "$lib/Media/Anime/Airing/Subtitled/subsPlease"; +import stringSimilarity from "string-similarity"; +import excludeMatch from "$lib/Data/Static/matchExclude.json"; +import { season } from "../../season"; export interface Time { - title: string; - time: string; - day: string; + 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(); + 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; +}; - if (dayDifference < 0) dayDifference += 7; +const preprocessTitle = (title: string): string => { + return title + .toLowerCase() + .replace(/\b(season|s|part|cour)\b/g, " ") + .replace(/[^a-z0-9\s]/gi, "") + .trim() + .split(/\s+/) + .join(" "); +}; - const targetDate = new Date(now); +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 = 36 * 60 * 60; + +interface SimilarityAnalysis { + score: number; + tokenOverlap: number; + numericTokenOverlap: number; +} - targetDate.setDate(now.getDate() + dayDifference); - targetDate.setHours(targetHour, targetMinute, 0, 0); +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 secondsDifference = (Number(targetDate) - Number(now)) / 1000; +const indexPush = ( + index: Map<string, number[]>, + key: string, + entryIndex: number, +) => { + const existing = index.get(key); - return secondsDifference > 0 ? secondsDifference : secondsDifference + 7 * 24 * 60 * 60; + if (existing) existing.push(entryIndex); + else index.set(key, [entryIndex]); }; -const preprocessTitle = (title: string): string => { - return title - .toLowerCase() - .replace(/\b(season|s|part|cour)\b/g, ' ') - .replace(/[^a-z0-9\s]/gi, '') - .trim() - .split(/\s+/) - .join(' '); +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 calculateWeightedSimilarity = (title1: string, title2: string): number => { - const tokens1 = title1.split(' '); - const tokens2 = title2.split(' '); - const set2 = new Set(tokens2); - let score = 0; +const setBoundedCacheValue = <T>( + cache: Map<string, T>, + key: string, + value: T, + maxEntries: number, +) => { + if (cache.size >= maxEntries) cache.clear(); - tokens1.forEach((token) => { - if (set2.has(token)) { - score += /^\d+$/.test(token) ? 2 : 1; - } - }); + cache.set(key, value); +}; - return score / (Math.max(tokens1.length, tokens2.length) * 2); +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; }; -export const findClosestMatch = (times: Time[], anime: Media): Time | null => { - if (excludeMatch.includes(anime.id)) return null; +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 airingDay = new Date((anime.nextAiringEpisode?.airingAt || 0) * 1000).toLocaleString( - 'en-US', - { weekday: 'long' } - ); - const dayTimes = times.filter((time) => time.day === airingDay); + const dayIndex: DayScheduleIndex = { + entries: [], + exactTitleIndex: new Map<string, number[]>(), + tokenIndex: new Map<string, number[]>(), + }; - if (dayTimes.length === 0) return null; + for (const scheduleTime of flattenedValue) { + const time = { + title: scheduleTime.title, + time: scheduleTime.time, + day, + }; - const preprocessedTimes = dayTimes.map((time) => ({ - time, - normalized: preprocessTitle(time.title) - })); - let bestMatch: Time | null = null; - let bestScore = 0; - const searchTitles = [anime.title.romaji, anime.title.english, ...anime.synonyms].filter(Boolean); + versionParts.push(`${day}\u001f${time.title}\u001f${time.time}`); - for (const searchTitle of searchTitles) { - if (searchTitle.includes('OVA') || searchTitle.includes('Special')) continue; + const normalizedTitle = preprocessTitle(time.title); + const tokens = normalizedTitle.split(" ").filter(isMeaningfulToken); + const entryIndex = dayIndex.entries.length; - const normalizedSearchTitle = preprocessTitle(searchTitle); + dayIndex.entries.push({ + time, + normalizedTitle, + tokens, + }); + indexPush(dayIndex.exactTitleIndex, normalizedTitle, entryIndex); - for (const { time, normalized } of preprocessedTimes) { - const similarityScore = calculateWeightedSimilarity(normalizedSearchTitle, normalized); + for (const token of tokens) + indexPush(dayIndex.tokenIndex, token, entryIndex); + } - if (similarityScore > bestScore) { - bestScore = similarityScore; - bestMatch = time; - } - } - } + byDay.set(day, dayIndex); + } - return bestMatch; + 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(); + (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; + if (!matchFor) return null; - const cached = findClosestMediaCache.get(matchFor); + const cached = findClosestMediaCache.get(matchFor); - if (cached !== undefined) return cached; + if (cached !== undefined) return cached; - const normalisedMatchFor = normalizeTitle(matchFor); - const matchForWords = normalisedMatchFor.split(' '); - let bestFitMedia: Media | null = null; - let bestDistance = -Infinity; + 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); + 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; + if ( + titles.some( + (title) => + title.toLowerCase().includes("special") || + title.toLowerCase().includes("ova"), + ) + ) + continue; - const normalisedTitles = titles.map(normalizeTitle); + const normalisedTitles = titles.map(normalizeTitle); - for (const normalisedTitle of normalisedTitles) { - const distance = stringSimilarity.compareTwoStrings(normalisedMatchFor, normalisedTitle); + for (const normalisedTitle of normalisedTitles) { + const distance = stringSimilarity.compareTwoStrings( + normalisedMatchFor, + normalisedTitle, + ); - if (distance <= bestDistance) continue; + if (distance <= bestDistance) continue; - const wordMatch = - matchForWords.every((word) => normalisedTitles.some((t) => t.includes(word))) || - normalisedTitles.some((t) => t.includes(normalisedMatchFor)); + const wordMatch = + matchForWords.every((word) => + normalisedTitles.some((t) => t.includes(word)), + ) || normalisedTitles.some((t) => t.includes(normalisedMatchFor)); - if (wordMatch) { - bestDistance = distance; - bestFitMedia = m; + if (wordMatch) { + bestDistance = distance; + bestFitMedia = m; - if (distance === 1) break; - } - } + if (distance === 1) break; + } + } - if (bestDistance === 1) break; - } + if (bestDistance === 1) break; + } - findClosestMediaCache.set(matchFor, bestFitMedia); + findClosestMediaCache.set(matchFor, bestFitMedia); - return bestFitMedia as Media | null; + return bestFitMedia as Media | null; }; export const clearClosestMediaCache = () => findClosestMediaCache.clear(); -export const injectAiringTime = (anime: Media, subsPlease: SubsPlease | null) => { - if (season() !== anime.season) return anime; - - 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; - 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) - ) - ) { - const times: Time[] = []; - - for (const [key, value] of Object.entries(subsPlease.schedule)) { - const flattenedValue = Array.isArray(value) ? value.flat() : []; - - for (const time of flattenedValue) { - times.push({ - title: time.title, - time: time.time, - day: key - }); - } - } - - if ((anime.nextAiringEpisode?.episode || 0) > 1) { - const foundTime: Time | null = findClosestMatch(times, 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; - - if (nativeTime > time) { - nextEpisode -= 1; - } - - if (nativeTime.getTime() - now.getTime() > SEVEN_DAYS) { - const beforeTime = time; - - time = nativeTime; - - time.setHours(beforeTime.getHours()); - time.setMinutes(beforeTime.getMinutes()); - } - - return { - ...anime, - nextAiringEpisode: { - episode: nextEpisode, - airingAt: time.getTime() / 1000, - nativeAiringAt: nativeTime.getTime() / 1000 - } - } as Media; +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; + + 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; + } + + 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: anime.nextAiringEpisode?.episode || 0, + }, + } 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 index 69f56286..3815259d 100644 --- a/src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts +++ b/src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts @@ -1,13 +1,13 @@ export interface SubsPlease { - tz: string; - schedule: { - [key in string]: SubsPleaseEpisode; - }[]; + tz: string; + schedule: { + [key in string]: SubsPleaseEpisode; + }[]; } export interface SubsPleaseEpisode { - title: string; - page: string; - image_url: string; - time: string; + title: string; + page: string; + image_url: string; + time: string; } 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..b05be83b --- /dev/null +++ b/src/lib/Media/Anime/Airing/classify.test.ts @@ -0,0 +1,291 @@ +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 { + clearInjectAiringTimeCache, + 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); + }); + } +}); + +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); + + first.nextAiringEpisode = { episode: -1 }; + + const second = injectAiringTime(media, subsPlease); + + expect(second.nextAiringEpisode?.episode).not.toBe(-1); + expect(typeof second.nextAiringEpisode?.airingAt).toBe("number"); + expect(typeof second.nextAiringEpisode?.nativeAiringAt).toBe("number"); + }); + + it("does not reuse stale progress across cache hits for media 194028", () => { + clearInjectAiringTimeCache(); + + 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 caughtUp = { + ...media, + mediaListEntry: { + ...(media.mediaListEntry || {}), + progress: 9, + }, + } as Media; + const behind = { + ...media, + mediaListEntry: { + ...(media.mediaListEntry || {}), + progress: 8, + }, + } as Media; + const cachedCaughtUp = injectAiringTime(caughtUp, subsPlease); + const updatedBehind = injectAiringTime(behind, subsPlease); + + expect(hasDueEpisodes(cachedCaughtUp)).toBe(false); + expect(hasDueEpisodes(updatedBehind)).toBe(true); + }); + + it("does not let caller mutate cached mediaListEntry progress", () => { + clearInjectAiringTimeCache(); + + 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 originalProgress = media.mediaListEntry?.progress || 0; + const first = injectAiringTime(media, subsPlease); + + if (first.mediaListEntry) first.mediaListEntry.progress = 999; + + const second = injectAiringTime(media, subsPlease); + + expect(media.mediaListEntry?.progress).toBe(originalProgress); + expect(second.mediaListEntry?.progress).toBe(originalProgress); + }); +}); diff --git a/src/lib/Media/Anime/Airing/classify.ts b/src/lib/Media/Anime/Airing/classify.ts new file mode 100644 index 00000000..9585fae9 --- /dev/null +++ b/src/lib/Media/Anime/Airing/classify.ts @@ -0,0 +1,64 @@ +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; +}; diff --git a/src/lib/Media/Anime/Airing/time.ts b/src/lib/Media/Anime/Airing/time.ts index 76d51668..dadcd6f1 100644 --- a/src/lib/Media/Anime/Airing/time.ts +++ b/src/lib/Media/Anime/Airing/time.ts @@ -1,122 +1,125 @@ -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 settings from '$stores/settings'; -import { injectAiringTime } from './Subtitled/match'; -import { totalEpisodes } from '../episodes'; -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 settings from "$stores/settings"; +import { injectAiringTime } from "./Subtitled/match"; +import { totalEpisodes } from "../episodes"; +import { get } from "svelte/store"; export const airingTime = ( - originalAnime: Media, - subsPlease: SubsPlease | null, - upcoming = false, - forceDays = false + originalAnime: Media, + subsPlease: SubsPlease | null, + upcoming = false, + forceDays = false, ) => { - const anime = injectAiringTime(originalAnime, subsPlease); - const airingAt = anime.nextAiringEpisode?.airingAt; - const untilAiring = airingAt ? Math.round((airingAt - Date.now() / 1000) * 100) / 100 : undefined; - const time = new Date(airingAt ? airingAt * 1000 : 0).toLocaleTimeString([], { - hour12: !settings.get().display24HourTime, - hour: 'numeric', - minute: '2-digit' - }); - let timeFrame = ''; - let hours = null; - const shortenCountdown = get(settings).displayShortCountdown; - - if ( - (anime as unknown as MediaPrequel).startDate && - new Date( - anime.startDate.year, - (anime as unknown as MediaPrequel).startDate.month, - (anime as unknown as MediaPrequel).startDate.day - ) < new Date() - ) - return `<span class="opaque">on ${new Date( - anime.startDate.year, - (anime as unknown as MediaPrequel).startDate.month, - (anime as unknown as MediaPrequel).startDate.day - ).toLocaleDateString()}</span>`; - - if (untilAiring !== undefined) { - let minutes = untilAiring / 60; - let few = true; - - if (minutes > 60) { - hours = minutes / 60; - - if (hours > 24) { - let weeks = Math.floor(hours / 24) / 7; - - few = false; - - if (weeks >= 1.5 && !forceDays) { - weeks = Math.round(weeks); - - timeFrame = `${weeks}${shortenCountdown ? 'w' : ' week'}${ - weeks === 1 || shortenCountdown ? '' : 's' - }`; - } else { - const days = Math.round(Math.floor(hours / 24)); - const residualHours = Math.floor(hours - days * 24); - - timeFrame += `${days.toFixed(0)}${shortenCountdown ? 'd' : ' day'}${ - days === 1 || shortenCountdown ? '' : 's' - }`; - - if (residualHours > 0) - timeFrame += `${shortenCountdown ? '' : ' '}${residualHours}${ - shortenCountdown ? 'h' : ' hour' - }${residualHours === 1 || shortenCountdown ? '' : 's'}`; - } - } else { - const residualMinutes = Math.round(minutes - Math.floor(hours) * 60); - - timeFrame += `${hours.toFixed(0)}${shortenCountdown ? 'h' : ' hour'}${ - hours === 1 || shortenCountdown ? '' : 's' - }`; - - if (residualMinutes > 0) - timeFrame += `${shortenCountdown ? '' : ' '}${residualMinutes}${ - shortenCountdown ? 'm' : ' minute' - }${residualMinutes === 1 || shortenCountdown ? '' : 's'}`; - } - } else { - minutes = Math.round(minutes); - - timeFrame += `${minutes}${shortenCountdown ? 'm' : ' minute'}${ - minutes === 1 || shortenCountdown ? '' : 's' - }`; - } - - const opacity = Math.max(50, 100 - (untilAiring / 60 / 60 / 24 / 7) * 50); - const nextEpisode = - anime.nextAiringEpisode?.nativeAiringAt && - !upcoming && - anime.nextAiringEpisode.nativeAiringAt < Date.now() / 1000 + 1 * 24 * 60 * 60 - ? anime.nextAiringEpisode.episode - 1 - : anime.nextAiringEpisode?.episode || 0; - const dateString = - new Date(airingAt ? airingAt * 1000 : 0).toLocaleDateString([], { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric' - }) + - ' ' + - time; - - if (upcoming) - return `<span title="${dateString}" style="opacity: ${opacity}%;">${nextEpisode}${totalEpisodes( - anime - )} in ${timeFrame} <span class="opaque">${ - few && get(settings).displayCoverModeAnime ? '<br>' : '' - }${few ? `(${time})` : ''}</span></span>`; - else - return `<span title="${dateString}" style="opacity: ${opacity}%;">${nextEpisode} in ${ - few && get(settings).displayCoverModeAnime ? '<br>' : '' - }${few ? '<b>' : ''}${timeFrame}${few ? '</b>' : ''} ${few ? `(${time})` : ''}</span>`; - } - - return ''; + const anime = injectAiringTime(originalAnime, subsPlease); + const airingAt = anime.nextAiringEpisode?.airingAt; + const untilAiring = airingAt + ? Math.round((airingAt - Date.now() / 1000) * 100) / 100 + : undefined; + const time = new Date(airingAt ? airingAt * 1000 : 0).toLocaleTimeString([], { + hour12: !settings.get().display24HourTime, + hour: "numeric", + minute: "2-digit", + }); + let timeFrame = ""; + let hours = null; + const shortenCountdown = get(settings).displayShortCountdown; + + if ( + (anime as unknown as MediaPrequel).startDate && + new Date( + anime.startDate.year, + (anime as unknown as MediaPrequel).startDate.month, + (anime as unknown as MediaPrequel).startDate.day, + ) < new Date() + ) + return `<span class="opaque">on ${new Date( + anime.startDate.year, + (anime as unknown as MediaPrequel).startDate.month, + (anime as unknown as MediaPrequel).startDate.day, + ).toLocaleDateString()}</span>`; + + if (untilAiring !== undefined) { + let minutes = untilAiring / 60; + let few = true; + + if (minutes > 60) { + hours = minutes / 60; + + if (hours > 24) { + let weeks = Math.floor(hours / 24) / 7; + + few = false; + + if (weeks >= 1.5 && !forceDays) { + weeks = Math.round(weeks); + + timeFrame = `${weeks}${shortenCountdown ? "w" : " week"}${ + weeks === 1 || shortenCountdown ? "" : "s" + }`; + } else { + const days = Math.round(Math.floor(hours / 24)); + const residualHours = Math.floor(hours - days * 24); + + timeFrame += `${days.toFixed(0)}${shortenCountdown ? "d" : " day"}${ + days === 1 || shortenCountdown ? "" : "s" + }`; + + if (residualHours > 0) + timeFrame += `${shortenCountdown ? "" : " "}${residualHours}${ + shortenCountdown ? "h" : " hour" + }${residualHours === 1 || shortenCountdown ? "" : "s"}`; + } + } else { + const residualMinutes = Math.round(minutes - Math.floor(hours) * 60); + + timeFrame += `${hours.toFixed(0)}${shortenCountdown ? "h" : " hour"}${ + hours === 1 || shortenCountdown ? "" : "s" + }`; + + if (residualMinutes > 0) + timeFrame += `${shortenCountdown ? "" : " "}${residualMinutes}${ + shortenCountdown ? "m" : " minute" + }${residualMinutes === 1 || shortenCountdown ? "" : "s"}`; + } + } else { + minutes = Math.round(minutes); + + timeFrame += `${minutes}${shortenCountdown ? "m" : " minute"}${ + minutes === 1 || shortenCountdown ? "" : "s" + }`; + } + + const opacity = Math.max(50, 100 - (untilAiring / 60 / 60 / 24 / 7) * 50); + const nextEpisode = + anime.nextAiringEpisode?.nativeAiringAt && + !upcoming && + anime.nextAiringEpisode.nativeAiringAt < + Date.now() / 1000 + 1 * 24 * 60 * 60 + ? anime.nextAiringEpisode.episode - 1 + : anime.nextAiringEpisode?.episode || 0; + const dateString = + new Date(airingAt ? airingAt * 1000 : 0).toLocaleDateString([], { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }) + + " " + + time; + + if (upcoming) + return `<span title="${dateString}" style="opacity: ${opacity}%;">${nextEpisode}${totalEpisodes( + anime, + )} in ${timeFrame} <span class="opaque">${ + few && get(settings).displayCoverModeAnime ? "<br>" : "" + }${few ? `(${time})` : ""}</span></span>`; + else + return `<span title="${dateString}" style="opacity: ${opacity}%;">${nextEpisode} in ${ + few && get(settings).displayCoverModeAnime ? "<br>" : "" + }${few ? "<b>" : ""}${timeFrame}${few ? "</b>" : ""} ${few ? `(${time})` : ""}</span>`; + } + + return ""; }; diff --git a/src/lib/Media/Anime/cache.ts b/src/lib/Media/Anime/cache.ts index 9aabb2ab..e988f255 100644 --- a/src/lib/Media/Anime/cache.ts +++ b/src/lib/Media/Anime/cache.ts @@ -1,31 +1,44 @@ -import { get } from 'svelte/store'; -import anime from '$stores/anime'; -import { mediaListCollection, Type } from '../../Data/AniList/media'; -import lastPruneTimes from '$stores/lastPruneTimes'; -import type { AniListAuthorisation, UserIdentity } from '../../Data/AniList/identity'; +import { get } from "svelte/store"; +import anime from "$stores/anime"; +import { mediaListCollection, Type } from "../../Data/AniList/media"; +import lastPruneTimes from "$stores/lastPruneTimes"; +import type { + AniListAuthorisation, + UserIdentity, +} from "../../Data/AniList/identity"; -export const cleanCache = (user: AniListAuthorisation, identity: UserIdentity) => - mediaListCollection(user, identity, Type.Anime, get(anime), get(lastPruneTimes).anime, { - forcePrune: true - }); +export const cleanCache = ( + user: AniListAuthorisation, + identity: UserIdentity, +) => + mediaListCollection( + user, + identity, + Type.Anime, + get(anime), + get(lastPruneTimes).anime, + { + forcePrune: true, + }, + ); export const incrementMediaProgress = ( - id: number, - progress: number | undefined, - user: AniListAuthorisation, - callback: () => void + id: number, + progress: number | undefined, + user: AniListAuthorisation, + callback: () => void, ) => { - fetch('https://graphql.anilist.co', { - method: 'POST', - headers: { - Authorization: `${user.tokenType} ${user.accessToken}`, - 'Content-Type': 'application/json', - Accept: 'application/json' - }, - body: JSON.stringify({ - query: `mutation { SaveMediaListEntry(mediaId: ${id}, progress: ${ - (progress || 0) + 1 - }) { id } }` - }) - }).then(callback); + fetch("https://graphql.anilist.co", { + method: "POST", + headers: { + Authorization: `${user.tokenType} ${user.accessToken}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + query: `mutation { SaveMediaListEntry(mediaId: ${id}, progress: ${ + (progress || 0) + 1 + }) { id } }`, + }), + }).then(callback); }; diff --git a/src/lib/Media/Anime/episodes.ts b/src/lib/Media/Anime/episodes.ts index f4994f83..75281c1e 100644 --- a/src/lib/Media/Anime/episodes.ts +++ b/src/lib/Media/Anime/episodes.ts @@ -1,4 +1,6 @@ -import type { Media } from '$lib/Data/AniList/media'; +import type { Media } from "$lib/Data/AniList/media"; export const totalEpisodes = (anime: Media) => - anime.episodes === null ? '' : `<span class="opaque">/${anime.episodes}</span>`; + anime.episodes === null + ? "" + : `<span class="opaque">/${anime.episodes}</span>`; diff --git a/src/lib/Media/Anime/season.ts b/src/lib/Media/Anime/season.ts index d0cd6c25..cfd96856 100644 --- a/src/lib/Media/Anime/season.ts +++ b/src/lib/Media/Anime/season.ts @@ -1,11 +1,11 @@ export const season = () => { - if (new Date().getMonth() >= 0 && new Date().getMonth() <= 2) - return 'WINTER' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; - else if (new Date().getMonth() >= 3 && new Date().getMonth() <= 5) - return 'SPRING' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; - else if (new Date().getMonth() >= 6 && new Date().getMonth() <= 8) - return 'SUMMER' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; - else if (new Date().getMonth() >= 9 && new Date().getMonth() <= 11) - return 'FALL' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; - else return 'WINTER' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; + if (new Date().getMonth() >= 0 && new Date().getMonth() <= 2) + return "WINTER" as "WINTER" | "SPRING" | "SUMMER" | "FALL"; + else if (new Date().getMonth() >= 3 && new Date().getMonth() <= 5) + return "SPRING" as "WINTER" | "SPRING" | "SUMMER" | "FALL"; + else if (new Date().getMonth() >= 6 && new Date().getMonth() <= 8) + return "SUMMER" as "WINTER" | "SPRING" | "SUMMER" | "FALL"; + else if (new Date().getMonth() >= 9 && new Date().getMonth() <= 11) + return "FALL" as "WINTER" | "SPRING" | "SUMMER" | "FALL"; + else return "WINTER" as "WINTER" | "SPRING" | "SUMMER" | "FALL"; }; |