diff options
| author | Fuwn <[email protected]> | 2026-03-01 19:56:11 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-03-01 19:56:18 -0800 |
| commit | 469dfeeac3acffe10b0f7cf374fb7f0d11ecd90f (patch) | |
| tree | bd7e5963a2eec25088f0135cedb68104bd4fae59 /src | |
| parent | fix(ci): stabilize quality graphql generation and trigger workflow paths (diff) | |
| download | due.moe-469dfeeac3acffe10b0f7cf374fb7f0d11ecd90f.tar.xz due.moe-469dfeeac3acffe10b0f7cf374fb7f0d11ecd90f.zip | |
fix(match): prevent cached airing injection mutation regressions
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/List/Anime/DueAnimeList.svelte | 16 | ||||
| -rw-r--r-- | src/lib/List/Anime/UpcomingAnimeList.svelte | 21 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/Subtitled/match.ts | 53 | ||||
| -rw-r--r-- | src/lib/Media/Anime/Airing/classify.test.ts | 41 |
4 files changed, 101 insertions, 30 deletions
diff --git a/src/lib/List/Anime/DueAnimeList.svelte b/src/lib/List/Anime/DueAnimeList.svelte index 4da6836f..e170a81e 100644 --- a/src/lib/List/Anime/DueAnimeList.svelte +++ b/src/lib/List/Anime/DueAnimeList.svelte @@ -93,11 +93,17 @@ const cleanMedia = ( // Outdated media hasDueEpisodes(media), ) - .map((media: Media) => { - if (hasNoAiredEpisodes(media)) media.nextAiringEpisode = { episode: -1 }; - - return media; - }); + .map((media: Media) => + hasNoAiredEpisodes(media) + ? { + ...media, + nextAiringEpisode: { + ...(media.nextAiringEpisode || { episode: 0 }), + episode: -1, + }, + } + : media, + ); if (!displayUnresolved) dueAnime = dueAnime.filter( diff --git a/src/lib/List/Anime/UpcomingAnimeList.svelte b/src/lib/List/Anime/UpcomingAnimeList.svelte index 2dd69a68..4bd7a287 100644 --- a/src/lib/List/Anime/UpcomingAnimeList.svelte +++ b/src/lib/List/Anime/UpcomingAnimeList.svelte @@ -60,16 +60,19 @@ const cleanMedia = ( ? media.mediaListEntry?.status === "PLANNING" : false) || !hasDueEpisodes(media), ) - .map((media: Media) => { + .map((media: Media) => // Adjust for planned anime - if ( - ($settings.displayPlannedAnime ? media.episodes !== 1 : true) && - hasNoAiredEpisodes(media) - ) - media.nextAiringEpisode = { episode: -1 }; - - return media; - }); + ($settings.displayPlannedAnime ? media.episodes !== 1 : true) && + hasNoAiredEpisodes(media) + ? { + ...media, + nextAiringEpisode: { + ...(media.nextAiringEpisode || { episode: 0 }), + episode: -1, + }, + } + : media, + ); let upcomingAnime = filterAnime( plannedOnly ? "NOT_YET_RELEASED" : "RELEASING", ); diff --git a/src/lib/Media/Anime/Airing/Subtitled/match.ts b/src/lib/Media/Anime/Airing/Subtitled/match.ts index d3950948..0c91f3ac 100644 --- a/src/lib/Media/Anime/Airing/Subtitled/match.ts +++ b/src/lib/Media/Anime/Airing/Subtitled/match.ts @@ -171,6 +171,17 @@ const scheduleIndexCache = new WeakMap<SubsPlease, ScheduleIndex>(); const closestMatchCache = new Map<string, Time | null>(); const injectAiringTimeCache = new Map<string, Media>(); +const hashString = (input: string): string => { + let hash = 2166136261; + + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + + return (hash >>> 0).toString(36); +}; + const setBoundedCacheValue = <T>( cache: Map<string, T>, key: string, @@ -188,6 +199,12 @@ const animeTitleFingerprint = (anime: Media) => .map(preprocessTitle) .join("|"); +const localTimeZone = () => + Intl.DateTimeFormat().resolvedOptions().timeZone || "local"; + +const airingDayOf = (airingAt: number | undefined) => + new Date((airingAt || 0) * 1000).toLocaleString("en-US", { weekday: "long" }); + const fallbackClosestMatch = ( dayIndex: DayScheduleIndex, searchTitles: string[], @@ -230,14 +247,8 @@ const buildScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => { 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 || ""}`, - ); + versionParts.push(day); const dayIndex: DayScheduleIndex = { entries: [], @@ -251,6 +262,9 @@ const buildScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => { time: scheduleTime.time, day, }; + + versionParts.push(`${day}\u001f${time.title}\u001f${time.time}`); + const normalizedTitle = preprocessTitle(time.title); const tokens = normalizedTitle.split(" ").filter(isMeaningfulToken); const entryIndex = dayIndex.entries.length; @@ -271,7 +285,7 @@ const buildScheduleIndex = (subsPlease: SubsPlease): ScheduleIndex => { return { byDay, - version: `${subsPlease.tz}:${versionParts.join("|")}`, + version: hashString(`${subsPlease.tz}\u001e${versionParts.join("\u001d")}`), }; }; @@ -290,16 +304,13 @@ export const findClosestMatch = ( return null; } + const airingDay = airingDayOf(anime.nextAiringEpisode?.airingAt); const cacheKey = `${anime.id}:${anime.nextAiringEpisode?.airingAt || 0}:${animeTitleFingerprint( anime, - )}:${scheduleIndex.version}`; + )}:${airingDay}:${localTimeZone()}:${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) { @@ -526,6 +537,14 @@ const buildInjectAiringTimeCacheKey = ( animeTitleFingerprint(anime), ].join(":"); +const cloneInjectedMedia = (media: Media): Media => + ({ + ...media, + nextAiringEpisode: media.nextAiringEpisode + ? { ...media.nextAiringEpisode } + : undefined, + }) as Media; + export const injectAiringTime = ( anime: Media, subsPlease: SubsPlease | null, @@ -543,7 +562,7 @@ export const injectAiringTime = ( ); const cached = injectAiringTimeCache.get(cacheKey); - if (cached) return cached; + if (cached) return cloneInjectedMedia(cached); const airingAt = anime.nextAiringEpisode?.airingAt; const now = new Date(); @@ -604,12 +623,14 @@ export const injectAiringTime = ( }, } as Media; + const cachedValue = cloneInjectedMedia(injected); + setBoundedCacheValue( injectAiringTimeCache, cacheKey, - injected, + cachedValue, MAX_INJECT_CACHE_ENTRIES, ); - return injected; + return cloneInjectedMedia(cachedValue); }; diff --git a/src/lib/Media/Anime/Airing/classify.test.ts b/src/lib/Media/Anime/Airing/classify.test.ts index 6af36b77..fb7f2c8f 100644 --- a/src/lib/Media/Anime/Airing/classify.test.ts +++ b/src/lib/Media/Anime/Airing/classify.test.ts @@ -156,3 +156,44 @@ describe("native countdown toggle parity", () => { }); } }); + +describe("injectAiringTime cache safety", () => { + it("does not let caller mutation poison cached injected media", () => { + const media = baseMedia(444444); + 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", false); + + const first = injectAiringTime(media, subsPlease); + + first.nextAiringEpisode = { episode: -1 }; + + const second = injectAiringTime(media, subsPlease); + + expect(second.nextAiringEpisode?.episode).not.toBe(-1); + expect(typeof second.nextAiringEpisode?.airingAt).toBe("number"); + expect(typeof second.nextAiringEpisode?.nativeAiringAt).toBe("number"); + }); +}); |