From eae5d24d9e79e59a19d4721caaeaa0ca650ecb33 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 1 Mar 2026 16:20:51 -0800 Subject: chore(biome): drop formatter style overrides --- src/lib/Media/Anime/Airing/AiringTime.svelte | 204 ++--- src/lib/Media/Anime/Airing/Subtitled/match.ts | 949 ++++++++++++--------- src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts | 16 +- src/lib/Media/Anime/Airing/classify.test.ts | 273 +++--- src/lib/Media/Anime/Airing/classify.ts | 87 +- src/lib/Media/Anime/Airing/time.ts | 239 +++--- 6 files changed, 944 insertions(+), 824 deletions(-) (limited to 'src/lib/Media/Anime/Airing') diff --git a/src/lib/Media/Anime/Airing/AiringTime.svelte b/src/lib/Media/Anime/Airing/AiringTime.svelte index 0278de1c..be5f40d9 100644 --- a/src/lib/Media/Anime/Airing/AiringTime.svelte +++ b/src/lib/Media/Anime/Airing/AiringTime.svelte @@ -1,117 +1,121 @@ diff --git a/src/lib/Media/Anime/Airing/Subtitled/match.ts b/src/lib/Media/Anime/Airing/Subtitled/match.ts index 0570b7f9..d3950948 100644 --- a/src/lib/Media/Anime/Airing/Subtitled/match.ts +++ b/src/lib/Media/Anime/Airing/Subtitled/match.ts @@ -1,86 +1,95 @@ -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[]; + time: Time; + normalizedTitle: string; + tokens: string[]; } interface DayScheduleIndex { - entries: IndexedTime[]; - exactTitleIndex: Map; - tokenIndex: Map; + entries: IndexedTime[]; + exactTitleIndex: Map; + tokenIndex: Map; } interface ScheduleIndex { - byDay: Map; - version: string; + byDay: Map; + 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 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)\b/g, ' ') - .replace(/[^a-z0-9\s]/gi, '') - .trim() - .split(/\s+/) - .join(' '); + return title + .toLowerCase() + .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' + "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)); + /^\d+$/.test(token) || + (token.length >= 3 && !NON_DISTINCTIVE_TOKENS.has(token)); const MIN_MATCH_SCORE = 0.3; const MIN_TOKEN_OVERLAP = 2; @@ -93,58 +102,69 @@ const STALE_AIRING_GRACE_SECONDS = 5 * 60; const MAX_EPISODE_SHIFT_WINDOW_SECONDS = 36 * 60 * 60; interface SimilarityAnalysis { - score: number; - tokenOverlap: number; - numericTokenOverlap: number; + 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 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, key: string, entryIndex: number) => { - const existing = index.get(key); +const indexPush = ( + index: Map, + key: string, + entryIndex: number, +) => { + const existing = index.get(key); - if (existing) existing.push(entryIndex); - else index.set(key, [entryIndex]); + if (existing) existing.push(entryIndex); + else index.set(key, [entryIndex]); }; const scheduleIndexCache = new WeakMap(); @@ -152,367 +172,444 @@ const closestMatchCache = new Map(); const injectAiringTimeCache = new Map(); const setBoundedCacheValue = ( - cache: Map, - key: string, - value: T, - maxEntries: number + cache: Map, + key: string, + value: T, + maxEntries: number, ) => { - if (cache.size >= maxEntries) cache.clear(); + if (cache.size >= maxEntries) cache.clear(); - cache.set(key, value); + cache.set(key, value); }; const animeTitleFingerprint = (anime: Media) => - [anime.title.romaji, anime.title.english, ...anime.synonyms] - .filter(Boolean) - .map(preprocessTitle) - .join('|'); - -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; + [anime.title.romaji, anime.title.english, ...anime.synonyms] + .filter(Boolean) + .map(preprocessTitle) + .join("|"); + +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(); - const versionParts: string[] = []; - - for (const [day, value] of Object.entries(subsPlease.schedule)) { - const flattenedValue = Array.isArray(value) ? value.flat() : []; - const firstEntry = flattenedValue[0]; - const lastEntry = flattenedValue[flattenedValue.length - 1]; - - versionParts.push( - `${day}:${flattenedValue.length}:${firstEntry?.title || ''}:${firstEntry?.time || ''}:${ - lastEntry?.title || '' - }:${lastEntry?.time || ''}` - ); - - const dayIndex: DayScheduleIndex = { - entries: [], - exactTitleIndex: new Map(), - tokenIndex: new Map() - }; - - for (const scheduleTime of flattenedValue) { - const time = { - title: scheduleTime.title, - time: scheduleTime.time, - day - }; - 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: `${subsPlease.tz}:${versionParts.join('|')}` - }; + const byDay = new Map(); + const versionParts: string[] = []; + + for (const [day, value] of Object.entries(subsPlease.schedule)) { + const flattenedValue = Array.isArray(value) ? value.flat() : []; + const firstEntry = flattenedValue[0]; + const lastEntry = flattenedValue[flattenedValue.length - 1]; + + versionParts.push( + `${day}:${flattenedValue.length}:${firstEntry?.title || ""}:${firstEntry?.time || ""}:${ + lastEntry?.title || "" + }:${lastEntry?.time || ""}`, + ); + + const dayIndex: DayScheduleIndex = { + entries: [], + exactTitleIndex: new Map(), + tokenIndex: new Map(), + }; + + for (const scheduleTime of flattenedValue) { + const time = { + title: scheduleTime.title, + time: scheduleTime.time, + day, + }; + 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: `${subsPlease.tz}:${versionParts.join("|")}`, + }; }; -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 cacheKey = `${anime.id}:${anime.nextAiringEpisode?.airingAt || 0}:${animeTitleFingerprint( - anime - )}:${scheduleIndex.version}`; - const cached = closestMatchCache.get(cacheKey); - - if (cached !== undefined) return cached; - - const airingDay = new Date((anime.nextAiringEpisode?.airingAt || 0) * 1000).toLocaleString( - 'en-US', - { weekday: 'long' } - ); - 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(); - - 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; +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 cacheKey = `${anime.id}:${anime.nextAiringEpisode?.airingAt || 0}:${animeTitleFingerprint( + anime, + )}:${scheduleIndex.version}`; + const cached = closestMatchCache.get(cacheKey); + + if (cached !== undefined) return cached; + + const airingDay = new Date( + (anime.nextAiringEpisode?.airingAt || 0) * 1000, + ).toLocaleString("en-US", { weekday: "long" }); + 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(); + + 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(); 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(); const getScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => { - const cached = scheduleIndexCache.get(subsPlease); + const cached = scheduleIndexCache.get(subsPlease); - if (cached) return cached; + if (cached) return cached; - const built = buildScheduleIndex(subsPlease); + const built = buildScheduleIndex(subsPlease); - scheduleIndexCache.set(subsPlease, built); + scheduleIndexCache.set(subsPlease, built); - return built; + return built; }; const buildInjectAiringTimeCacheKey = ( - anime: Media, - scheduleVersion: string, - displayNativeCountdown: boolean + anime: Media, + scheduleVersion: string, + displayNativeCountdown: boolean, ) => - [ - anime.id, - anime.status, - anime.nextAiringEpisode?.episode || 0, - anime.nextAiringEpisode?.airingAt || 0, - displayNativeCountdown ? 1 : 0, - scheduleVersion, - animeTitleFingerprint(anime) - ].join(':'); - -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 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; - - setBoundedCacheValue(injectAiringTimeCache, cacheKey, injected, MAX_INJECT_CACHE_ENTRIES); - - return injected; + [ + anime.id, + anime.status, + anime.nextAiringEpisode?.episode || 0, + anime.nextAiringEpisode?.airingAt || 0, + displayNativeCountdown ? 1 : 0, + scheduleVersion, + animeTitleFingerprint(anime), + ].join(":"); + +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 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; + + setBoundedCacheValue( + injectAiringTimeCache, + cacheKey, + injected, + MAX_INJECT_CACHE_ENTRIES, + ); + + return injected; }; 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 index cad08b43..6af36b77 100644 --- a/src/lib/Media/Anime/Airing/classify.test.ts +++ b/src/lib/Media/Anime/Airing/classify.test.ts @@ -1,153 +1,158 @@ -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 { injectAiringTime } from '$lib/Media/Anime/Airing/Subtitled/match'; -import type { SubsPlease } from '$lib/Media/Anime/Airing/Subtitled/subsPlease'; +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 { 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'); + 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}`; + 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; + ({ + 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); +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 - }; + 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); + const state = getAnimeEpisodeState(media); - expect(state.airedEpisodes).toBe(7); - expect(hasDueEpisodes(media)).toBe(true); - }); + 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); + 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 - }; + 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); + const state = getAnimeEpisodeState(media); - expect(state.airedEpisodes).toBe(9); - expect(hasDueEpisodes(media)).toBe(true); - }); + 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("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); + }); + } }); diff --git a/src/lib/Media/Anime/Airing/classify.ts b/src/lib/Media/Anime/Airing/classify.ts index 9b7487d2..9585fae9 100644 --- a/src/lib/Media/Anime/Airing/classify.ts +++ b/src/lib/Media/Anime/Airing/classify.ts @@ -1,53 +1,64 @@ -import type { Media } from '$lib/Data/AniList/media'; +import type { Media } from "$lib/Data/AniList/media"; export interface AnimeEpisodeState { - progress: number; - nextEpisode: number; - airedEpisodes: number; + progress: number; + nextEpisode: number; + airedEpisodes: number; } const hasAired = (airingAt: number | undefined, nowEpochSeconds: number) => - typeof airingAt === 'number' && airingAt <= nowEpochSeconds; + typeof airingAt === "number" && airingAt <= nowEpochSeconds; export const getAnimeEpisodeState = ( - media: Media, - nowEpochSeconds = Date.now() / 1000 + 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 - }; + 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); +export const hasDueEpisodes = ( + media: Media, + nowEpochSeconds = Date.now() / 1000, +) => { + const episodeState = getAnimeEpisodeState(media, nowEpochSeconds); - return episodeState.airedEpisodes > episodeState.progress; + return episodeState.airedEpisodes > episodeState.progress; }; -export const hasNoAiredEpisodes = (media: Media, nowEpochSeconds = Date.now() / 1000) => { - const episodeState = getAnimeEpisodeState(media, nowEpochSeconds); +export const hasNoAiredEpisodes = ( + media: Media, + nowEpochSeconds = Date.now() / 1000, +) => { + const episodeState = getAnimeEpisodeState(media, nowEpochSeconds); - return episodeState.airedEpisodes <= 0; + 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 `on ${new Date( - anime.startDate.year, - (anime as unknown as MediaPrequel).startDate.month, - (anime as unknown as MediaPrequel).startDate.day - ).toLocaleDateString()}`; - - 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 `${nextEpisode}${totalEpisodes( - anime - )} in ${timeFrame} ${ - few && get(settings).displayCoverModeAnime ? '
' : '' - }${few ? `(${time})` : ''}
`; - else - return `${nextEpisode} in ${ - few && get(settings).displayCoverModeAnime ? '
' : '' - }${few ? '' : ''}${timeFrame}${few ? '' : ''} ${few ? `(${time})` : ''}
`; - } - - 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 `on ${new Date( + anime.startDate.year, + (anime as unknown as MediaPrequel).startDate.month, + (anime as unknown as MediaPrequel).startDate.day, + ).toLocaleDateString()}`; + + 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 `${nextEpisode}${totalEpisodes( + anime, + )} in ${timeFrame} ${ + few && get(settings).displayCoverModeAnime ? "
" : "" + }${few ? `(${time})` : ""}
`; + else + return `${nextEpisode} in ${ + few && get(settings).displayCoverModeAnime ? "
" : "" + }${few ? "" : ""}${timeFrame}${few ? "" : ""} ${few ? `(${time})` : ""}
`; + } + + return ""; }; -- cgit v1.2.3