aboutsummaryrefslogtreecommitdiff
path: root/src/lib/Media/Anime/Airing/Subtitled
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/Media/Anime/Airing/Subtitled')
-rw-r--r--src/lib/Media/Anime/Airing/Subtitled/match.ts159
1 files changed, 131 insertions, 28 deletions
diff --git a/src/lib/Media/Anime/Airing/Subtitled/match.ts b/src/lib/Media/Anime/Airing/Subtitled/match.ts
index eb9c9b70..e83c30b6 100644
--- a/src/lib/Media/Anime/Airing/Subtitled/match.ts
+++ b/src/lib/Media/Anime/Airing/Subtitled/match.ts
@@ -85,6 +85,12 @@ const isMeaningfulToken = (token: string): boolean =>
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;
@@ -107,6 +113,8 @@ const calculateWeightedSimilarity = (title1: string, title2: string): Similarity
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)) {
@@ -118,8 +126,15 @@ const calculateWeightedSimilarity = (title1: string, title2: string): Similarity
}
});
+ 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: score / ((Math.max(tokens1.length, tokens2.length) || 1) * 2),
+ score: finalScore,
tokenOverlap,
numericTokenOverlap
};
@@ -134,6 +149,56 @@ const indexPush = (index: Map<string, number[]>, key: string, entryIndex: number
const scheduleIndexCache = new WeakMap<SubsPlease, ScheduleIndex>();
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
+) => {
+ if (cache.size >= maxEntries) cache.clear();
+
+ cache.set(key, value);
+};
+
+const animeTitleFingerprint = (anime: Media) =>
+ [anime.title.romaji, anime.title.english, ...anime.synonyms]
+ .filter(Boolean)
+ .map(preprocessTitle)
+ .join('|');
+
+const 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>();
@@ -187,12 +252,14 @@ const buildScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => {
export const findClosestMatch = (scheduleIndex: ScheduleIndex, anime: Media): Time | null => {
if (excludeMatch.includes(anime.id)) {
- closestMatchCache.set(`${anime.id}:excluded`, null);
-
+ setBoundedCacheValue(closestMatchCache, `${anime.id}:excluded`, null, MAX_MATCH_CACHE_ENTRIES);
+
return null;
}
- const cacheKey = `${anime.id}:${anime.nextAiringEpisode?.airingAt || 0}:${scheduleIndex.version}`;
+ const cacheKey = `${anime.id}:${anime.nextAiringEpisode?.airingAt || 0}:${animeTitleFingerprint(
+ anime
+ )}:${scheduleIndex.version}`;
const cached = closestMatchCache.get(cacheKey);
if (cached !== undefined) return cached;
@@ -204,8 +271,8 @@ export const findClosestMatch = (scheduleIndex: ScheduleIndex, anime: Media): Ti
const dayIndex = scheduleIndex.byDay.get(airingDay);
if (!dayIndex || dayIndex.entries.length === 0) {
- closestMatchCache.set(cacheKey, null);
-
+ setBoundedCacheValue(closestMatchCache, cacheKey, null, MAX_MATCH_CACHE_ENTRIES);
+
return null;
}
@@ -226,7 +293,8 @@ export const findClosestMatch = (scheduleIndex: ScheduleIndex, anime: Media): Ti
const exactMatch = dayIndex.entries[exactMatchIndexes[0]];
if (exactMatch) {
- closestMatchCache.set(cacheKey, exactMatch.time);
+ setBoundedCacheValue(closestMatchCache, cacheKey, exactMatch.time, MAX_MATCH_CACHE_ENTRIES);
+
return exactMatch.time;
}
}
@@ -264,25 +332,31 @@ export const findClosestMatch = (scheduleIndex: ScheduleIndex, anime: Media): Ti
}
if (bestScore < MIN_MATCH_SCORE) {
- closestMatchCache.set(cacheKey, null);
-
- return null;
+ const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles);
+
+ setBoundedCacheValue(closestMatchCache, cacheKey, fallbackMatch, MAX_MATCH_CACHE_ENTRIES);
+
+ return fallbackMatch;
}
if (bestScore - secondBestScore < MIN_MATCH_MARGIN) {
- closestMatchCache.set(cacheKey, null);
-
- return null;
+ const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles);
+
+ setBoundedCacheValue(closestMatchCache, cacheKey, fallbackMatch, MAX_MATCH_CACHE_ENTRIES);
+
+ return fallbackMatch;
}
if (bestNumericTokenOverlap === 0 && bestTokenOverlap < MIN_TOKEN_OVERLAP) {
- closestMatchCache.set(cacheKey, null);
-
- return null;
+ const fallbackMatch = fallbackClosestMatch(dayIndex, searchTitles);
+
+ setBoundedCacheValue(closestMatchCache, cacheKey, fallbackMatch, MAX_MATCH_CACHE_ENTRIES);
+
+ return fallbackMatch;
}
- closestMatchCache.set(cacheKey, bestMatch);
-
+ setBoundedCacheValue(closestMatchCache, cacheKey, bestMatch, MAX_MATCH_CACHE_ENTRIES);
+
return bestMatch;
};
@@ -352,15 +426,37 @@ const getScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => {
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.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
@@ -371,12 +467,7 @@ export const injectAiringTime = (anime: Media, subsPlease: SubsPlease | null) =>
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)
- )
- ) {
+ if (!(displayNativeCountdown || !subsPlease)) {
const scheduleIndex = getScheduleIndex(subsPlease);
if ((anime.nextAiringEpisode?.episode || 0) > 1) {
@@ -391,7 +482,14 @@ export const injectAiringTime = (anime: Media, subsPlease: SubsPlease | null) =>
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
- if (nativeTime > time) {
+ 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;
}
@@ -404,12 +502,17 @@ export const injectAiringTime = (anime: Media, subsPlease: SubsPlease | null) =>
time.setMinutes(beforeTime.getMinutes());
}
- return {
+ const injected = {
...anime,
nextAiringEpisode: {
episode: nextEpisode,
airingAt: time.getTime() / 1000,
- nativeAiringAt: nativeTime.getTime() / 1000
+ nativeAiringAt: nativeTime.getTime() / 1000,
+ nativeEpisode: anime.nextAiringEpisode?.episode || 0
}
} as Media;
+
+ setBoundedCacheValue(injectAiringTimeCache, cacheKey, injected, MAX_INJECT_CACHE_ENTRIES);
+
+ return injected;
};