diff options
Diffstat (limited to 'src/lib/Media')
| -rw-r--r-- | src/lib/Media/Manga/chapters.ts | 407 |
1 files changed, 216 insertions, 191 deletions
diff --git a/src/lib/Media/Manga/chapters.ts b/src/lib/Media/Manga/chapters.ts index e4ad9429..473a3ed4 100644 --- a/src/lib/Media/Manga/chapters.ts +++ b/src/lib/Media/Manga/chapters.ts @@ -1,237 +1,262 @@ -import { recentMediaActivities, type Media } from "$lib/Data/AniList/media"; +import { env } from "$env/dynamic/public"; +import { type Media, recentMediaActivities } from "$lib/Data/AniList/media"; import { getChapterCount } from "$lib/Data/Manga/raw"; -import proxy from "$lib/Utility/proxy"; +import { proxyRoute } from "$lib/Utility/proxy"; import settings from "$stores/settings"; import type { UserIdentity } from "../../Data/AniList/identity"; import { database } from "../../Database/IDB/chapters"; -const getManga = async ( - statusIn: string, - year: number, - native: string | null, - english: string | null, - romaji: string | null, -) => { - let status = ""; - let error = false; +interface MangaDexChapterCount { + chapter: number | null; + volumeText?: string | null; +} - switch (statusIn) { - case "FINISHED": - { - status = "completed"; - } - break; - case "RELEASING": - { - status = "ongoing"; - } - break; - case "HIATUS": - { - status = "hiatus"; - } - break; - case "CANCELLED": - { - status = "cancelled"; - } - break; - } +interface MangaDexChapterCountsResponse { + data?: Record<string, MangaDexChapterCount>; + pending?: number[]; + retryAfterMs?: number; +} - const nullIfNullString = (s: string | null) => (s === "null" ? null : s); - const get = async (title: string) => { - try { - return await ( - await fetch( - proxy( - `https://api.mangadex.org/manga?title=${encodeURIComponent( - title, - )}&year=${year}&status[]=${status}`, - ), - ) - ).json(); - } catch { - error = true; - } - }; +const chapterMemoryCache = new Map<number, number | null>(); +const MAX_PENDING_RETRIES = 2; +const DEFAULT_PENDING_RETRY_MS = 750; - let mangadexData = await get( - nullIfNullString(native) || - nullIfNullString(english) || - nullIfNullString(romaji) || - "", - ); +const browserChapterCacheDisabled = () => + env.PUBLIC_DISABLE_MANGA_BROWSER_CACHE === "true"; - if (error) return new Response("rate-limited"); +const parseOptionalNumber = (value: string | number | null | undefined) => { + if (value === null || value === undefined || value === "") return null; - if (mangadexData["data"] === undefined || mangadexData["data"].length === 0) { - mangadexData = await get(nullIfNullString(english) || ""); + const number = Number.parseFloat(String(value)); - if ( - mangadexData["data"] === undefined || - mangadexData["data"].length === 0 - ) { - mangadexData = await get(nullIfNullString(romaji) || ""); - } - } + 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; - return Response.json(mangadexData, { - headers: { - "Cache-Control": "max-age=300", - }, + await database.chapters.put({ + id: mangaId, + chapters: chapters === null ? -1 : chapters, + volumes, }); }; -export const chapterCount = async ( +const getActivityChapterCount = async ( identity: UserIdentity, manga: Media, disableGuessing: boolean, - // preferActivity = false -): Promise<number | null> => { - const chapters = await database.chapters.get(manga.id); +) => { + if (disableGuessing) { + await writeCachedChapterCount(manga.id, null); + + return null; + } - if (chapters !== undefined) - return chapters.chapters === -1 ? null : chapters.chapters; + const anilistData = await recentMediaActivities( + identity, + manga, + settings.get().calculateGuessMethod, + ); - // if (preferActivity) { - // return await recentMediaActivities(identity, manga); - // } + await writeCachedChapterCount(manga.id, anilistData); - const tryRecentMediaActivities = async () => { - if (disableGuessing) { - await database.chapters.put({ - id: manga.id, - chapters: -1, - volumes: null, - }); + return anilistData; +}; - return null; - } +const applyResolvedChapterCount = async ( + identity: UserIdentity, + manga: Media, + disableGuessing: boolean, + chapter: number, + volumeText?: string | null, +) => { + let resolvedChapter = chapter; - const anilistData = await recentMediaActivities( + if ( + (manga.mediaListEntry || { progress: 0 }).progress > resolvedChapter && + !disableGuessing + ) { + const activityChapterCount = await recentMediaActivities( identity, manga, settings.get().calculateGuessMethod, ); - await database.chapters.put({ - id: manga.id, - chapters: anilistData ? anilistData : -1, - volumes: null, - }); + if (activityChapterCount !== null && activityChapterCount > resolvedChapter) + resolvedChapter = activityChapterCount; + } + + if (resolvedChapter === 0) resolvedChapter = -1; - return anilistData; - }; + await writeCachedChapterCount( + manga.id, + resolvedChapter === -1 ? null : resolvedChapter, + parseOptionalNumber(volumeText), + ); - if (manga.format === "NOVEL") return await tryRecentMediaActivities(); + return resolvedChapter === -1 ? null : resolvedChapter; +}; - let lastChapter = 0; - let completedVolumes = null; +const fetchMangaChapterCounts = async (manga: Media[]) => { + const data: Record<string, MangaDexChapterCount> = {}; + 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<number>(); + + 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; + } - if (!settings.get().calculatePreferNativeChapterCount) { - const mangadexData = await getManga( - manga.status, - manga.startDate.year, - manga.title.native, - manga.title.english, - manga.title.romaji, - ); + const payload = (await response.json()) as MangaDexChapterCountsResponse; + successfulResponse = true; + retryAfterMs = payload.retryAfterMs || retryAfterMs; - if ((await mangadexData.clone().text()) === "rate-limited") return -22; - - const mangadexDataJson = await mangadexData.json(); - - if ( - mangadexDataJson["data"] === undefined || - mangadexDataJson["data"].length === 0 - ) - return await tryRecentMediaActivities(); - - const mangadexId = mangadexDataJson["data"][0]["id"]; - const lastChapterDataJson = await ( - await fetch( - proxy( - `https://api.mangadex.org/manga/${mangadexId}/feed?order[chapter]=desc&translatedLanguage[]=en&limit=1&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic`, - ), - ) - ).json(); - - if ( - lastChapterDataJson["data"] === undefined || - lastChapterDataJson["data"].length === 0 - ) - return await tryRecentMediaActivities(); - - lastChapter = lastChapterDataJson["data"][0]["attributes"]["chapter"]; - completedVolumes = null; - - if ( - (manga.mediaListEntry || { progress: 0 }).progress > lastChapter && - !disableGuessing - ) { - const anilistData = await recentMediaActivities( - identity, - manga, - settings.get().calculateGuessMethod, + 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 }; +}; + +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 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) { + const nativeCount = (await getChapterCount(entry.title.native)) || 0; + + await writeCachedChapterCount( + entry.id, + nativeCount === 0 ? null : nativeCount, ); - if (anilistData !== null && anilistData > lastChapter) - lastChapter = anilistData; + continue; } - if (!settings.get().calculateDisableOutOfDateVolumeWarning) { - const volumeOfChapterData = await ( - await fetch( - proxy( - `https://api.mangadex.org/chapter?manga=${mangadexId}&chapter=${manga.mediaListEntry?.progress}&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic&limit=1`, - ), - ) - ).json(); - let lastAvailableVolume = - lastChapterDataJson["data"][0]["attributes"]["volume"]; - - if (lastAvailableVolume === null) { - let chapterIndex = 0; - - while ( - chapterIndex < lastChapterDataJson["data"].length && - lastAvailableVolume === null - ) { - if ( - lastChapterDataJson["data"][chapterIndex]["attributes"][ - "volume" - ] !== null - ) - lastAvailableVolume = - lastChapterDataJson["data"][chapterIndex]["attributes"]["volume"]; - - chapterIndex += 1; - } - } + unresolvedManga.push(entry); + } - if ( - volumeOfChapterData["data"] !== undefined && - volumeOfChapterData["data"].length > 0 - ) { - const volumeOfChapter = - volumeOfChapterData["data"][0]["attributes"]["volume"]; + if (!unresolvedManga.length) return { rateLimited: false }; - if (volumeOfChapter !== null) completedVolumes = volumeOfChapter; + const { data, rateLimited } = await fetchMangaChapterCounts(unresolvedManga); - if (completedVolumes === volumeOfChapter) completedVolumes -= 1; - } + 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; } - } else { - lastChapter = (await getChapterCount(manga.title.native)) || 0; + + await getActivityChapterCount(identity, entry, disableGuessing); } - if (lastChapter === 0) lastChapter = -1; + return { rateLimited }; +}; - await database.chapters.put({ - id: manga.id, - chapters: Number(lastChapter), - volumes: completedVolumes, - }); +export const chapterCount = async ( + identity: UserIdentity, + manga: Media, + disableGuessing: boolean, +): Promise<number | null> => { + const cachedChapterCount = await readCachedChapterCount(manga.id); + + if (cachedChapterCount !== undefined) return cachedChapterCount; + + const { rateLimited } = await hydrateChapterCounts( + identity, + [manga], + disableGuessing, + ); + + if (rateLimited) return -22; - return Number(lastChapter); + return (await readCachedChapterCount(manga.id)) ?? null; }; |