diff options
| author | Fuwn <[email protected]> | 2026-03-01 16:20:51 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-03-01 16:21:02 -0800 |
| commit | eae5d24d9e79e59a19d4721caaeaa0ca650ecb33 (patch) | |
| tree | 1b685bb248e051dfa26d2bfdebe6689402dd93c5 /src/lib/Media/Anime/Airing/Subtitled | |
| parent | chore(tooling): remove legacy eslint and prettier (diff) | |
| download | due.moe-eae5d24d9e79e59a19d4721caaeaa0ca650ecb33.tar.xz due.moe-eae5d24d9e79e59a19d4721caaeaa0ca650ecb33.zip | |
chore(biome): drop formatter style overrides
Diffstat (limited to 'src/lib/Media/Anime/Airing/Subtitled')
| -rw-r--r-- | src/lib/Media/Anime/Airing/Subtitled/match.ts | 949 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/Subtitled/subsPlease.ts | 16 |
2 files changed, 531 insertions, 434 deletions
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<string, number[]>; - tokenIndex: Map<string, number[]>; + entries: IndexedTime[]; + exactTitleIndex: Map<string, number[]>; + tokenIndex: Map<string, number[]>; } interface ScheduleIndex { - byDay: Map<string, DayScheduleIndex>; - version: string; + byDay: Map<string, DayScheduleIndex>; + version: string; } const secondsUntil = (targetTime: string, targetDay: string) => { - const now = new Date(); - const [targetHour, targetMinute] = targetTime.split(':').map(Number); - let dayDifference = - ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].indexOf( - targetDay - ) - now.getDay(); - - if (dayDifference < 0) dayDifference += 7; - - const targetDate = new Date(now); - - targetDate.setDate(now.getDate() + dayDifference); - targetDate.setHours(targetHour, targetMinute, 0, 0); - - const secondsDifference = (Number(targetDate) - Number(now)) / 1000; - - return secondsDifference > 0 ? secondsDifference : secondsDifference + 7 * 24 * 60 * 60; + const 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<string, number[]>, key: string, entryIndex: number) => { - const existing = index.get(key); +const indexPush = ( + index: Map<string, number[]>, + key: string, + entryIndex: number, +) => { + const existing = index.get(key); - if (existing) existing.push(entryIndex); - else index.set(key, [entryIndex]); + if (existing) existing.push(entryIndex); + else index.set(key, [entryIndex]); }; const scheduleIndexCache = new WeakMap<SubsPlease, ScheduleIndex>(); @@ -152,367 +172,444 @@ const closestMatchCache = new Map<string, Time | null>(); const injectAiringTimeCache = new Map<string, Media>(); const setBoundedCacheValue = <T>( - cache: Map<string, T>, - key: string, - value: T, - maxEntries: number + cache: Map<string, T>, + 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<string, DayScheduleIndex>(); - 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<string, number[]>(), - tokenIndex: new Map<string, number[]>() - }; - - 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<string, DayScheduleIndex>(); + 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<string, number[]>(), + tokenIndex: new Map<string, number[]>(), + }; + + 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<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; +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<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(); 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; } |