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; } const secondsUntil = (targetTime: string, targetDay: string) => { const now = new Date(); const [targetHour, targetMinute] = targetTime.split(':').map(Number); let dayDifference = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].indexOf( targetDay ) - now.getDay(); if (dayDifference < 0) dayDifference += 7; const targetDate = new Date(now); targetDate.setDate(now.getDate() + dayDifference); targetDate.setHours(targetHour, targetMinute, 0, 0); const secondsDifference = (Number(targetDate) - Number(now)) / 1000; return secondsDifference > 0 ? secondsDifference : secondsDifference + 7 * 24 * 60 * 60; }; const preprocessTitle = (title: string): string => { return title .toLowerCase() .replace(/\b(season|s|part|cour)\b/g, ' ') .replace(/[^a-z0-9\s]/gi, '') .trim() .split(/\s+/) .join(' '); }; const NON_DISTINCTIVE_TOKENS = new Set([ 'a', 'and', 'de', 'e', 'for', 'ga', 'in', 'na', 'ni', 'no', 'o', 'of', 'on', 'the', 'to', 'wa', 'wo' ]); const isMeaningfulToken = (token: string): boolean => /^\d+$/.test(token) || (token.length >= 3 && !NON_DISTINCTIVE_TOKENS.has(token)); const MIN_MATCH_SCORE = 0.3; const MIN_TOKEN_OVERLAP = 2; interface SimilarityAnalysis { score: number; tokenOverlap: number; numericTokenOverlap: number; } const calculateWeightedSimilarity = (title1: string, title2: string): SimilarityAnalysis => { const tokens1 = title1.split(' ').filter(isMeaningfulToken); const tokens2 = title2.split(' ').filter(isMeaningfulToken); if (tokens1.length === 0 || tokens2.length === 0) return { score: 0, tokenOverlap: 0, numericTokenOverlap: 0 }; const set2 = new Set(tokens2); let score = 0; let tokenOverlap = 0; let numericTokenOverlap = 0; tokens1.forEach((token) => { if (set2.has(token)) { tokenOverlap += 1; if (/^\d+$/.test(token)) numericTokenOverlap += 1; score += /^\d+$/.test(token) ? 2 : 1; } }); return { score: score / ((Math.max(tokens1.length, tokens2.length) || 1) * 2), tokenOverlap, numericTokenOverlap }; }; export const findClosestMatch = (times: Time[], anime: Media): Time | null => { if (excludeMatch.includes(anime.id)) return null; const airingDay = new Date((anime.nextAiringEpisode?.airingAt || 0) * 1000).toLocaleString( 'en-US', { weekday: 'long' } ); const dayTimes = times.filter((time) => time.day === airingDay); if (dayTimes.length === 0) return null; const preprocessedTimes = dayTimes.map((time) => ({ time, normalized: preprocessTitle(time.title) })); let bestMatch: Time | null = null; let bestScore = 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); for (const { time, normalized } of preprocessedTimes) { const similarity = calculateWeightedSimilarity(normalizedSearchTitle, normalized); if (similarity.score > bestScore) { bestScore = similarity.score; bestTokenOverlap = similarity.tokenOverlap; bestNumericTokenOverlap = similarity.numericTokenOverlap; bestMatch = time; } } } if (bestScore < MIN_MATCH_SCORE) return null; if (bestNumericTokenOverlap === 0 && bestTokenOverlap < MIN_TOKEN_OVERLAP) return null; return bestMatch; }; const normalizeTitle = (title: string | null) => (title || '') .toLowerCase() .replace(/\b(s|season|part|cour)\s*\d+/g, '') .replace(/[\W_]+/g, ' ') .trim(); const findClosestMediaCache = new Map(); export const findClosestMedia = (media: Media[], matchFor: string) => { if (!matchFor) return null; const cached = findClosestMediaCache.get(matchFor); if (cached !== undefined) return cached; const normalisedMatchFor = normalizeTitle(matchFor); const matchForWords = normalisedMatchFor.split(' '); let bestFitMedia: Media | null = null; let bestDistance = -Infinity; for (const m of media) { const titles = [m.title.romaji, m.title.english, ...m.synonyms].filter(Boolean); if ( titles.some( (title) => title.toLowerCase().includes('special') || title.toLowerCase().includes('ova') ) ) continue; const normalisedTitles = titles.map(normalizeTitle); for (const normalisedTitle of normalisedTitles) { const distance = stringSimilarity.compareTwoStrings(normalisedMatchFor, normalisedTitle); if (distance <= bestDistance) continue; const wordMatch = matchForWords.every((word) => normalisedTitles.some((t) => t.includes(word))) || normalisedTitles.some((t) => t.includes(normalisedMatchFor)); if (wordMatch) { bestDistance = distance; bestFitMedia = m; if (distance === 1) break; } } if (bestDistance === 1) break; } findClosestMediaCache.set(matchFor, bestFitMedia); return bestFitMedia as Media | null; }; export const clearClosestMediaCache = () => findClosestMediaCache.clear(); 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; };