aboutsummaryrefslogtreecommitdiff
path: root/src/lib/Media/Manga/chapters.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/Media/Manga/chapters.ts')
-rw-r--r--src/lib/Media/Manga/chapters.ts407
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;
};