aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-03-01 19:56:11 -0800
committerFuwn <[email protected]>2026-03-01 19:56:18 -0800
commit469dfeeac3acffe10b0f7cf374fb7f0d11ecd90f (patch)
treebd7e5963a2eec25088f0135cedb68104bd4fae59 /src
parentfix(ci): stabilize quality graphql generation and trigger workflow paths (diff)
downloaddue.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.svelte16
-rw-r--r--src/lib/List/Anime/UpcomingAnimeList.svelte21
-rw-r--r--src/lib/Media/Anime/Airing/Subtitled/match.ts53
-rw-r--r--src/lib/Media/Anime/Airing/classify.test.ts41
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");
+ });
+});