aboutsummaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-03-01 14:39:20 -0800
committerFuwn <[email protected]>2026-03-01 15:24:05 -0800
commit60110fe8f23c53c837aff82d77a21ad8af4b5bb2 (patch)
tree4fa6f42cc1f352c3a55165cc2a7735d0b897c841 /src/lib
parentperf: optimise list hot paths and shared timers (diff)
downloaddue.moe-60110fe8f23c53c837aff82d77a21ad8af4b5bb2.tar.xz
due.moe-60110fe8f23c53c837aff82d77a21ad8af4b5bb2.zip
fix(anime): unify due classification and harden subtitle matching
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/Data/AniList/media.ts1
-rw-r--r--src/lib/List/Anime/DueAnimeList.svelte12
-rw-r--r--src/lib/List/Anime/UpcomingAnimeList.svelte6
-rw-r--r--src/lib/Media/Anime/Airing/Subtitled/match.ts159
-rw-r--r--src/lib/Media/Anime/Airing/classify.test.ts153
-rw-r--r--src/lib/Media/Anime/Airing/classify.ts53
6 files changed, 346 insertions, 38 deletions
diff --git a/src/lib/Data/AniList/media.ts b/src/lib/Data/AniList/media.ts
index bf43ef1d..68f9fc9b 100644
--- a/src/lib/Data/AniList/media.ts
+++ b/src/lib/Data/AniList/media.ts
@@ -55,6 +55,7 @@ export interface Media {
episode: number;
airingAt?: number;
nativeAiringAt?: number;
+ nativeEpisode?: number;
};
synonyms: string[];
mediaListEntry?: {
diff --git a/src/lib/List/Anime/DueAnimeList.svelte b/src/lib/List/Anime/DueAnimeList.svelte
index 0c1e128a..da1f6c48 100644
--- a/src/lib/List/Anime/DueAnimeList.svelte
+++ b/src/lib/List/Anime/DueAnimeList.svelte
@@ -8,6 +8,7 @@
import AnimeList from './AnimeListTemplate.svelte';
import type { SubsPlease } from '$lib/Media/Anime/Airing/Subtitled/subsPlease';
import { injectAiringTime } from '$lib/Media/Anime/Airing/Subtitled/match';
+ import { hasDueEpisodes, hasNoAiredEpisodes } from '$lib/Media/Anime/Airing/classify';
import { addNotification } from '$lib/Notification/store';
import locale from '$stores/locale';
import identity from '$stores/identity';
@@ -78,15 +79,12 @@
($settings.displayNotStarted === true ? 0 : 1) &&
(media.mediaListEntry || { status: 'DROPPED' }).status !== 'DROPPED'
)
- .filter(
- (media: Media) =>
- // Outdated media
- (media.nextAiringEpisode || { episode: 0 }).episode - 1 >
- (media.mediaListEntry || { progress: 0 }).progress
+ .filter((media: Media) =>
+ // Outdated media
+ hasDueEpisodes(media)
)
.map((media: Media) => {
- if ((media.nextAiringEpisode || { episode: 0 }).episode - 1 <= 0)
- media.nextAiringEpisode = { episode: -1 };
+ if (hasNoAiredEpisodes(media)) media.nextAiringEpisode = { episode: -1 };
return media;
});
diff --git a/src/lib/List/Anime/UpcomingAnimeList.svelte b/src/lib/List/Anime/UpcomingAnimeList.svelte
index 4f285b47..a2cc963d 100644
--- a/src/lib/List/Anime/UpcomingAnimeList.svelte
+++ b/src/lib/List/Anime/UpcomingAnimeList.svelte
@@ -12,6 +12,7 @@
import locale from '$stores/locale';
import identity from '$stores/identity';
import { injectAiringTime } from '$lib/Media/Anime/Airing/Subtitled/match';
+ import { hasDueEpisodes, hasNoAiredEpisodes } from '$lib/Media/Anime/Airing/classify';
import revalidateAnime from '$stores/revalidateAnime';
export let user: AniListAuthorisation;
@@ -43,14 +44,13 @@
(media: Media) =>
// Outdated media
($settings.displayPlannedAnime ? media.mediaListEntry?.status === 'PLANNING' : false) ||
- (media.nextAiringEpisode || { episode: 0 }).episode - 1 <=
- (media.mediaListEntry || { progress: 0 }).progress
+ !hasDueEpisodes(media)
)
.map((media: Media) => {
// Adjust for planned anime
if (
($settings.displayPlannedAnime ? media.episodes !== 1 : true) &&
- (media.nextAiringEpisode || { episode: 0 }).episode - 1 <= 0
+ hasNoAiredEpisodes(media)
)
media.nextAiringEpisode = { episode: -1 };
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;
};
diff --git a/src/lib/Media/Anime/Airing/classify.test.ts b/src/lib/Media/Anime/Airing/classify.test.ts
new file mode 100644
index 00000000..cad08b43
--- /dev/null
+++ b/src/lib/Media/Anime/Airing/classify.test.ts
@@ -0,0 +1,153 @@
+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');
+
+ return `${hours}:${minutes}`;
+};
+
+const baseMedia = (id: number): Media =>
+ ({
+ id,
+ idMal: id,
+ status: 'RELEASING',
+ type: 'ANIME',
+ episodes: 12,
+ chapters: 0,
+ volumes: 0,
+ duration: 24,
+ format: 'TV',
+ title: {
+ romaji: `Fixture Show ${id}`,
+ english: `Fixture Show ${id}`,
+ native: `Fixture Show ${id}`
+ },
+ nextAiringEpisode: {
+ episode: 8,
+ airingAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60
+ },
+ synonyms: [],
+ mediaListEntry: {
+ progress: 6,
+ progressVolumes: 0,
+ status: 'CURRENT',
+ score: 0,
+ repeat: 0,
+ startedAt: {
+ year: 2025,
+ month: 1,
+ day: 1
+ },
+ completedAt: {
+ year: 0,
+ month: 0,
+ day: 0
+ },
+ createdAt: 0,
+ updatedAt: 0,
+ customLists: {}
+ },
+ startDate: {
+ year: 2025,
+ month: 1
+ },
+ endDate: {
+ year: 2025,
+ month: 12
+ },
+ coverImage: {
+ extraLarge: 'https://example.com/cover-xl.jpg',
+ medium: 'https://example.com/cover-md.jpg'
+ },
+ tags: [],
+ genres: [],
+ season: season(),
+ isAdult: false,
+ relations: {
+ edges: []
+ }
+ }) as Media;
+
+const regressionIds = [192507, 189259, 198767];
+
+describe('anime episode classification', () => {
+ it('prefers nativeEpisode for due/upcoming classification', () => {
+ const media = baseMedia(192507);
+
+ media.nextAiringEpisode = {
+ episode: 7,
+ nativeEpisode: 8,
+ airingAt: Math.floor(Date.now() / 1000) + 6 * 60 * 60,
+ nativeAiringAt: Math.floor(Date.now() / 1000) + 18 * 60 * 60
+ };
+
+ const state = getAnimeEpisodeState(media);
+
+ expect(state.airedEpisodes).toBe(7);
+ expect(hasDueEpisodes(media)).toBe(true);
+ });
+
+ it('treats stale native release data as aired for due detection', () => {
+ const media = baseMedia(189259);
+
+ media.nextAiringEpisode = {
+ episode: 9,
+ airingAt: Math.floor(Date.now() / 1000) + 72 * 60 * 60,
+ nativeAiringAt: Math.floor(Date.now() / 1000) - 60 * 60
+ };
+
+ const state = getAnimeEpisodeState(media);
+
+ expect(state.airedEpisodes).toBe(9);
+ expect(hasDueEpisodes(media)).toBe(true);
+ });
+});
+
+describe('native countdown toggle parity', () => {
+ for (const id of regressionIds) {
+ it(`keeps media ${id} due with native countdown on/off`, () => {
+ const media = baseMedia(id);
+ const subtitledAiringAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60;
+ const nativeAiringAt = subtitledAiringAt + 12 * 60 * 60;
+ const nativeAiringDate = new Date(nativeAiringAt * 1000);
+ const airingDay = nativeAiringDate.toLocaleString('en-US', { weekday: 'long' });
+ const subsPlease = {
+ tz: 'America/Los_Angeles',
+ schedule: {
+ [airingDay]: [
+ {
+ title: media.title.romaji,
+ page: '',
+ image_url: '',
+ time: toScheduleTime(subtitledAiringAt)
+ }
+ ]
+ }
+ } as unknown as SubsPlease;
+
+ media.nextAiringEpisode = {
+ episode: 8,
+ airingAt: nativeAiringAt
+ };
+
+ settings.setKey('displayNativeCountdown', true);
+
+ const nativeOnly = injectAiringTime(media, subsPlease);
+
+ settings.setKey('displayNativeCountdown', false);
+
+ const subtitled = injectAiringTime(media, subsPlease);
+
+ expect(hasDueEpisodes(nativeOnly)).toBe(true);
+ expect(hasDueEpisodes(subtitled)).toBe(true);
+ });
+ }
+});
diff --git a/src/lib/Media/Anime/Airing/classify.ts b/src/lib/Media/Anime/Airing/classify.ts
new file mode 100644
index 00000000..9b7487d2
--- /dev/null
+++ b/src/lib/Media/Anime/Airing/classify.ts
@@ -0,0 +1,53 @@
+import type { Media } from '$lib/Data/AniList/media';
+
+export interface AnimeEpisodeState {
+ progress: number;
+ nextEpisode: number;
+ airedEpisodes: number;
+}
+
+const hasAired = (airingAt: number | undefined, nowEpochSeconds: number) =>
+ typeof airingAt === 'number' && airingAt <= nowEpochSeconds;
+
+export const getAnimeEpisodeState = (
+ media: Media,
+ nowEpochSeconds = Date.now() / 1000
+): AnimeEpisodeState => {
+ const progress = media.mediaListEntry?.progress || 0;
+ const nextEpisode =
+ media.nextAiringEpisode?.nativeEpisode || media.nextAiringEpisode?.episode || 0;
+
+ if (nextEpisode <= 0) {
+ return {
+ progress,
+ nextEpisode,
+ airedEpisodes: 0
+ };
+ }
+
+ let airedEpisodes = Math.max(0, nextEpisode - 1);
+ const airingAt = media.nextAiringEpisode?.airingAt;
+ const nativeAiringAt = media.nextAiringEpisode?.nativeAiringAt;
+
+ // If either source says the "next" episode already aired, treat it as released.
+ if (hasAired(airingAt, nowEpochSeconds) || hasAired(nativeAiringAt, nowEpochSeconds))
+ airedEpisodes = Math.max(airedEpisodes, nextEpisode);
+
+ return {
+ progress,
+ nextEpisode,
+ airedEpisodes
+ };
+};
+
+export const hasDueEpisodes = (media: Media, nowEpochSeconds = Date.now() / 1000) => {
+ const episodeState = getAnimeEpisodeState(media, nowEpochSeconds);
+
+ return episodeState.airedEpisodes > episodeState.progress;
+};
+
+export const hasNoAiredEpisodes = (media: Media, nowEpochSeconds = Date.now() / 1000) => {
+ const episodeState = getAnimeEpisodeState(media, nowEpochSeconds);
+
+ return episodeState.airedEpisodes <= 0;
+};