import { env } from "$env/dynamic/public"; import { type Media, recentMediaActivities } from "$lib/Data/AniList/media"; import { proxyRoute } from "$lib/Utility/proxy"; import settings from "$stores/settings"; import type { UserIdentity } from "../../Data/AniList/identity"; import { database } from "../../Database/IDB/chapters"; interface MangaDexChapterCount { chapter: number | null; volumeText?: string | null; } interface MangaDexChapterCountsResponse { data?: Record; pending?: number[]; retryAfterMs?: number; } interface NativeChapterCount { chapter: number | null; } interface NativeChapterCountsResponse { data?: Record; } const chapterMemoryCache = new Map(); const MAX_PENDING_RETRIES = 2; const DEFAULT_PENDING_RETRY_MS = 750; const browserChapterCacheDisabled = () => env.PUBLIC_DISABLE_MANGA_BROWSER_CACHE === "true"; const parseOptionalNumber = (value: string | number | null | undefined) => { if (value === null || value === undefined || value === "") return null; const number = Number.parseFloat(String(value)); return Number.isFinite(number) ? number : null; }; const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); const readCachedChapterCount = async (mangaId: number) => { if (chapterMemoryCache.has(mangaId)) return chapterMemoryCache.get(mangaId); if (browserChapterCacheDisabled()) return undefined; const chapter = await database.chapters.get(mangaId); return chapter === undefined ? undefined : chapter.chapters === -1 ? null : chapter.chapters; }; const writeCachedChapterCount = async ( mangaId: number, chapters: number | null, volumes: number | null = null, ) => { chapterMemoryCache.set(mangaId, chapters); if (browserChapterCacheDisabled()) return; await database.chapters.put({ id: mangaId, chapters: chapters === null ? -1 : chapters, volumes, }); }; const getActivityChapterCount = async ( identity: UserIdentity, manga: Media, disableGuessing: boolean, ) => { if (disableGuessing) { await writeCachedChapterCount(manga.id, null); return null; } const anilistData = await recentMediaActivities( identity, manga, settings.get().calculateGuessMethod, ); await writeCachedChapterCount(manga.id, anilistData); return anilistData; }; const applyResolvedChapterCount = async ( identity: UserIdentity, manga: Media, disableGuessing: boolean, chapter: number, volumeText?: string | null, ) => { let resolvedChapter = chapter; if ( (manga.mediaListEntry || { progress: 0 }).progress > resolvedChapter && !disableGuessing ) { const activityChapterCount = await recentMediaActivities( identity, manga, settings.get().calculateGuessMethod, ); if (activityChapterCount !== null && activityChapterCount > resolvedChapter) resolvedChapter = activityChapterCount; } if (resolvedChapter === 0) resolvedChapter = -1; await writeCachedChapterCount( manga.id, resolvedChapter === -1 ? null : resolvedChapter, parseOptionalNumber(volumeText), ); return resolvedChapter === -1 ? null : resolvedChapter; }; const fetchMangaChapterCounts = async (manga: Media[]) => { const data: Record = {}; let rateLimited = false; let successfulResponse = false; const mangaById = new Map(manga.map((entry) => [entry.id, entry])); let pendingManga = manga; let retryAfterMs = DEFAULT_PENDING_RETRY_MS; for (let attempt = 0; attempt <= MAX_PENDING_RETRIES; attempt += 1) { const nextPendingIds = new Set(); for (let index = 0; index < pendingManga.length; index += 100) { const chunk = pendingManga.slice(index, index + 100); const response = await fetch(proxyRoute("/manga/chapter-counts"), { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ manga: chunk.map((entry) => ({ anilistId: entry.id, status: entry.status, startYear: entry.startDate.year, nativeTitle: entry.title.native, englishTitle: entry.title.english, romajiTitle: entry.title.romaji, })), }), }).catch(() => null); if (!response) continue; if (!response.ok) { rateLimited = response.status === 429 || response.status === 503; continue; } const payload = (await response.json()) as MangaDexChapterCountsResponse; successfulResponse = true; retryAfterMs = payload.retryAfterMs || retryAfterMs; Object.assign(data, payload.data || {}); for (const id of payload.pending || []) nextPendingIds.add(id); } if (!nextPendingIds.size || attempt === MAX_PENDING_RETRIES) break; pendingManga = [...nextPendingIds] .map((id) => mangaById.get(id)) .filter((entry): entry is Media => entry !== undefined); if (!pendingManga.length) break; await sleep(retryAfterMs); } return { data, rateLimited: rateLimited && !successfulResponse }; }; const fetchNativeChapterCounts = async (manga: Media[]) => { const data: Record = {}; for (let index = 0; index < manga.length; index += 100) { const chunk = manga.slice(index, index + 100); const response = await fetch(proxyRoute("/manga/native-chapter-counts"), { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ manga: chunk.map((entry) => ({ anilistId: entry.id, nativeTitle: entry.title.native, englishTitle: entry.title.english, romajiTitle: entry.title.romaji, })), }), }).catch(() => null); if (!response?.ok) continue; const payload = (await response.json()) as NativeChapterCountsResponse; Object.assign(data, payload.data || {}); } return data; }; export const hydrateChapterCounts = async ( identity: UserIdentity, manga: Media[], disableGuessing: boolean, ): Promise<{ rateLimited: boolean }> => { const uniqueManga = manga.filter( (entry, index, array) => array.findIndex((candidate) => candidate.id === entry.id) === index, ); const nativeCountManga: Media[] = []; const unresolvedManga: Media[] = []; for (const entry of uniqueManga) { if ((await readCachedChapterCount(entry.id)) !== undefined) continue; if (entry.format === "NOVEL") { await getActivityChapterCount(identity, entry, disableGuessing); continue; } if (settings.get().calculatePreferNativeChapterCount) { nativeCountManga.push(entry); continue; } unresolvedManga.push(entry); } if (nativeCountManga.length) { const nativeCounts = await fetchNativeChapterCounts(nativeCountManga); for (const entry of nativeCountManga) { const nativeCount = nativeCounts[String(entry.id)]?.chapter ?? null; await writeCachedChapterCount(entry.id, nativeCount); } } if (!unresolvedManga.length) return { rateLimited: false }; const { data, rateLimited } = await fetchMangaChapterCounts(unresolvedManga); for (const entry of unresolvedManga) { const resolved = data[String(entry.id)]; if (resolved?.chapter !== undefined && resolved.chapter !== null) { await applyResolvedChapterCount( identity, entry, disableGuessing, resolved.chapter, resolved.volumeText, ); continue; } await getActivityChapterCount(identity, entry, disableGuessing); } return { rateLimited }; }; export const chapterCount = async ( identity: UserIdentity, manga: Media, disableGuessing: boolean, ): Promise => { const cachedChapterCount = await readCachedChapterCount(manga.id); if (cachedChapterCount !== undefined) return cachedChapterCount; const { rateLimited } = await hydrateChapterCounts( identity, [manga], disableGuessing, ); if (rateLimited) return -22; return (await readCachedChapterCount(manga.id)) ?? null; };