diff options
Diffstat (limited to 'apps/proxy/src')
| -rw-r--r-- | apps/proxy/src/index.js | 390 | ||||
| -rw-r--r-- | apps/proxy/src/mangadex.js | 392 | ||||
| -rw-r--r-- | apps/proxy/src/supabase.js | 217 |
3 files changed, 910 insertions, 89 deletions
diff --git a/apps/proxy/src/index.js b/apps/proxy/src/index.js index f2a37111..f90adfa7 100644 --- a/apps/proxy/src/index.js +++ b/apps/proxy/src/index.js @@ -1,113 +1,325 @@ -const handleRequest = async (request) => { +import { bootstrapManga, syncMangadexIndex } from "./mangadex.js"; +import { + deleteMangadexFailureRows, + getMangadexFailureRowsByAniListIds, + getMangadexRowsByAniListIds, + hasSupabaseConfig, + upsertMangadexRows, + upsertMangadexFailureRows, +} from "./supabase.js"; + +const DEFAULT_ALLOWED_ORIGIN = "https://due.moe"; +const DEFAULT_BOOTSTRAP_RETRY_MINUTES = 360; +const DEFAULT_PENDING_RETRY_MS = 750; +const bootstrapInFlight = new Map(); + +const isPrivateHostname = (hostname) => + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname.endsWith(".local") || + /^10\./.test(hostname) || + /^192\.168\./.test(hostname) || + /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname); + +const accessControlOrigin = (request) => { + const origin = request.headers.get("Origin"); + + if (!origin) return DEFAULT_ALLOWED_ORIGIN; + try { - const url = new URL(request.url); - let query; - let dropHeaders = false; + const url = new URL(origin); - if (url.search.includes('&dh')) { - url.search = url.search.replace('&dh', ''); - dropHeaders = true; - } + if ( + url.hostname === "due.moe" || + url.hostname.endsWith(".due.moe") || + isPrivateHostname(url.hostname) + ) + return origin; + } catch {} - if (url.search.includes('?q=')) { - query = url.search.split('?q=')[1]; - } else if (url.search.includes('?d=')) { - query = atob(url.search.split('?d=')[1]); - } else if (url.search.includes('?d2=')) { - const fullEncodedURL = url.search.split('?d2=')[1]; - const key = parseInt(fullEncodedURL.slice(-2)); - - query = atob(fullEncodedURL.slice(0, -2)) - .split(':') - .map((char) => String.fromCharCode(char - key)) - .join(''); - } else { - return new Response(null, { - status: 400, - statusText: 'Bad Request', - }); - } + return DEFAULT_ALLOWED_ORIGIN; +}; - request = new Request(query, request); +const appendCorsHeaders = (request, headers = new Headers()) => { + headers.set("Access-Control-Allow-Origin", accessControlOrigin(request)); + headers.set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS"); + headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type"); + headers.append("Vary", "Origin"); - request.headers.set('Host', new URL(query).origin); - request.headers.set('Referrer', new URL(query)); - request.headers.set('Origin', new URL(query)); - request.headers.set('Access-Control-Allow-Credentials', 'true'); - request.headers.delete('X-Content-Type-Options'); + return headers; +}; - let response = await fetch(request); +const jsonResponse = (request, body, init = {}) => { + const headers = appendCorsHeaders(request, new Headers(init.headers)); - response = new Response(response.body, response); + headers.set("Content-Type", "application/json"); - if (dropHeaders) response.headers.forEach((_, key) => response.headers.delete(key)); + return new Response(JSON.stringify(body), { + ...init, + headers, + }); +}; - response.headers.set('Access-Control-Allow-Origin', 'https://due.moe'); - response.headers.append('Vary', 'Origin'); - response.headers.set('Cache-Control', 'max-age=300'); +const textResponse = (request, body, init = {}) => + new Response(body, { + ...init, + headers: appendCorsHeaders(request, new Headers(init.headers)), + }); - return response; - } catch { - return new Response(null, { - status: 400, - statusText: 'Bad Request', - }); +const decodeProxyTarget = (url) => { + if (url.search.includes("?q=")) return url.search.split("?q=")[1]; + if (url.search.includes("?d=")) return atob(url.search.split("?d=")[1]); + if (url.search.includes("?d2=")) { + const fullEncodedUrl = url.search.split("?d2=")[1]; + const key = Number.parseInt(fullEncodedUrl.slice(-2), 10); + + return atob(fullEncodedUrl.slice(0, -2)) + .split(":") + .map((char) => String.fromCharCode(Number(char) - key)) + .join(""); } + + return null; }; -const handleOptions = async (request) => { - if ( - request.headers.get('Origin') !== null && - request.headers.get('Access-Control-Request-Method') !== null && - request.headers.get('Access-Control-Request-Headers') !== null - ) { - return new Response(null, { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS', - 'Access-Control-Allow-Headers': '*', - }, +const forwardProxyRequest = async (request) => { + const url = new URL(request.url); + const dropHeaders = url.search.includes("&dh"); + + if (dropHeaders) url.search = url.search.replace("&dh", ""); + + const target = decodeProxyTarget(url); + + if (!target) + return textResponse(request, null, { + status: 400, + statusText: "Bad Request", }); - } else { - return new Response(null, { - headers: { - Allow: 'GET, HEAD, POST, OPTIONS', + + const targetUrl = new URL(target); + const proxiedRequest = new Request(target, request); + + proxiedRequest.headers.set("Host", targetUrl.hostname); + proxiedRequest.headers.set("Referrer", targetUrl.toString()); + proxiedRequest.headers.set("Origin", targetUrl.origin); + proxiedRequest.headers.delete("X-Content-Type-Options"); + + let response = await fetch(proxiedRequest); + + response = new Response(response.body, response); + + if (dropHeaders) + for (const key of [...response.headers.keys()]) + response.headers.delete(key); + + appendCorsHeaders(request, response.headers); + response.headers.set("Cache-Control", "max-age=300"); + + return response; +}; + +const handleOptions = (request) => + new Response(null, { + headers: appendCorsHeaders(request), + }); + +const isMangadexIdConstraintConflict = (error) => + error instanceof Error && + error.message.includes("mangadex_manga_index_mangadex_id_key"); + +const parseMangaPayload = async (request) => { + const body = await request.json().catch(() => null); + const manga = Array.isArray(body?.manga) ? body.manga : []; + + return manga + .map((entry) => ({ + anilistId: Number(entry?.anilistId), + status: String(entry?.status || ""), + startYear: entry?.startYear ? Number(entry.startYear) : null, + nativeTitle: entry?.nativeTitle || null, + englishTitle: entry?.englishTitle || null, + romajiTitle: entry?.romajiTitle || null, + })) + .filter((entry) => Number.isFinite(entry.anilistId)); +}; + +const bootstrapRetryMinutes = (env) => { + const minutes = Number.parseInt(env.MANGADEX_BOOTSTRAP_RETRY_MINUTES || "", 10); + + return Number.isFinite(minutes) && minutes > 0 + ? minutes + : DEFAULT_BOOTSTRAP_RETRY_MINUTES; +}; + +const pendingRetryMs = (env) => { + const milliseconds = Number.parseInt(env.MANGA_CHAPTER_COUNTS_RETRY_MS || "", 10); + + return Number.isFinite(milliseconds) && milliseconds > 0 + ? milliseconds + : DEFAULT_PENDING_RETRY_MS; +}; + +const isRecentFailure = (row, retryMinutes) => + Date.now() - new Date(row.last_attempted_at || row.updated_at).getTime() < + retryMinutes * 60 * 1000; + +const queueBootstrap = (env, manga) => + manga + .map((entry) => { + const existing = bootstrapInFlight.get(entry.anilistId); + + if (existing) return existing; + + const promise = (async () => { + const row = await bootstrapManga(env, entry); + + if (row) { + try { + await upsertMangadexRows(env, [row]); + } catch (error) { + if (!isMangadexIdConstraintConflict(error)) throw error; + } + + await deleteMangadexFailureRows(env, [row.anilist_id]); + + return; + } + + await upsertMangadexFailureRows(env, [entry.anilistId]); + })().finally(() => { + bootstrapInFlight.delete(entry.anilistId); + }); + + bootstrapInFlight.set(entry.anilistId, promise); + + return promise; + }) + .filter(Boolean); + +const handleMangaChapterCounts = async (request, env, ctx) => { + if (!hasSupabaseConfig(env)) + return jsonResponse( + request, + { error: "Supabase is not configured for the proxy worker." }, + { status: 500 }, + ); + + const manga = await parseMangaPayload(request); + + if (!manga.length) return jsonResponse(request, { data: {} }); + + const anilistIds = manga.map((entry) => entry.anilistId); + const [existingRows, failureRows] = await Promise.all([ + getMangadexRowsByAniListIds(env, anilistIds), + getMangadexFailureRowsByAniListIds(env, anilistIds), + ]); + const existingIds = new Set(existingRows.map((row) => row.anilist_id)); + const recentFailures = new Set( + failureRows + .filter((row) => isRecentFailure(row, bootstrapRetryMinutes(env))) + .map((row) => row.anilist_id), + ); + const missingRows = manga.filter( + (entry) => + !existingIds.has(entry.anilistId) && !recentFailures.has(entry.anilistId), + ); + const pendingRows = missingRows.filter((entry) => + bootstrapInFlight.has(entry.anilistId), + ); + const queueableRows = missingRows.filter( + (entry) => !bootstrapInFlight.has(entry.anilistId), + ); + + if (queueableRows.length) + ctx.waitUntil( + Promise.all(queueBootstrap(env, queueableRows)).catch((error) => { + if (!isMangadexIdConstraintConflict(error)) throw error; + }), + ); + + const data = Object.fromEntries( + existingRows.map((row) => [ + String(row.anilist_id), + { + chapter: row.latest_en_chapter_number, + ...(row.latest_en_volume_text === null + ? {} + : { volumeText: row.latest_en_volume_text }), }, - }); - } + ]), + ); + const pending = [...new Set([...pendingRows, ...queueableRows].map((entry) => entry.anilistId))]; + + return jsonResponse(request, { + data, + ...(pending.length + ? { + pending, + retryAfterMs: pendingRetryMs(env), + } + : {}), + }); }; -addEventListener('fetch', (event) => { - const request = event.request; +const isAuthorisedSyncRequest = (request, env) => { + const token = env.MANGADEX_SYNC_TOKEN; - try { - switch (request.method) { - case 'OPTIONS': - event.respondWith(handleOptions(request)); + if (!token) return isPrivateHostname(new URL(request.url).hostname); + + return request.headers.get("Authorization") === `Bearer ${token}`; +}; + +const handleMangaSync = async (request, env) => { + if (!hasSupabaseConfig(env)) + return jsonResponse( + request, + { error: "Supabase is not configured for the proxy worker." }, + { status: 500 }, + ); + + if (!isAuthorisedSyncRequest(request, env)) + return jsonResponse(request, { error: "Forbidden" }, { status: 403 }); + + const result = await syncMangadexIndex(env); + + return jsonResponse(request, { data: result }); +}; + +export default { + async fetch(request, env, ctx) { + try { + const url = new URL(request.url); - break; + if (request.method === "OPTIONS") return handleOptions(request); - case 'GET': - case 'HEAD': - case 'POST': - event.respondWith(handleRequest(request)); + if ( + url.pathname === "/manga/chapter-counts" && + request.method === "POST" + ) + return handleMangaChapterCounts(request, env, ctx); - break; + if (url.pathname === "/manga/sync" && request.method === "POST") + return handleMangaSync(request, env); - default: - event.respondWith(async () => { - return new Response(null, { - status: 405, - statusText: 'Method Not Allowed', - }); - }); + if (["GET", "HEAD", "POST"].includes(request.method)) + return forwardProxyRequest(request); - break; + return textResponse(request, null, { + status: 405, + statusText: "Method Not Allowed", + }); + } catch (error) { + return jsonResponse( + request, + { error: error instanceof Error ? error.message : "Bad Request" }, + { status: 400 }, + ); } - } catch { - return new Response(null, { - status: 400, - statusText: 'Bad Request', - }); - } -}); + }, + + async scheduled(_controller, env, ctx) { + if (!hasSupabaseConfig(env)) return; + + ctx.waitUntil(syncMangadexIndex(env)); + }, +}; diff --git a/apps/proxy/src/mangadex.js b/apps/proxy/src/mangadex.js new file mode 100644 index 00000000..d2a42154 --- /dev/null +++ b/apps/proxy/src/mangadex.js @@ -0,0 +1,392 @@ +import { + getMangadexRowsByMangadexIds, + getSyncState, + upsertMangadexRows, + upsertSyncState, +} from "./supabase.js"; + +const MANGADEX_API_ORIGIN = "https://api.mangadex.org"; +const SYNC_STATE_NAME = "chapter_en"; +const ALL_CONTENT_RATINGS = ["safe", "suggestive", "erotica", "pornographic"]; + +const sleep = (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const appendQueryValues = (url, query) => { + for (const [key, rawValue] of Object.entries(query)) { + if (rawValue === undefined || rawValue === null) continue; + + if (Array.isArray(rawValue)) { + for (const value of rawValue) url.searchParams.append(key, String(value)); + + continue; + } + + url.searchParams.set(key, String(rawValue)); + } +}; + +const mangadexUserAgent = (env) => + env?.MANGADEX_USER_AGENT || + "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"; + +const mangadexRequest = async ( + env, + path, + { query = {}, retries = 1, retryDelay = 1500 } = {}, +) => { + const url = new URL(path, MANGADEX_API_ORIGIN); + + appendQueryValues(url, query); + + const response = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": mangadexUserAgent(env), + }, + }); + + if (response.status === 429 && retries > 0) { + const retryAfterHeader = + response.headers.get("Retry-After") || + response.headers.get("X-RateLimit-Retry-After"); + const retryAfterSeconds = Number.parseInt(retryAfterHeader || "", 10); + const retryAt = + Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 10_000 + ? retryAfterSeconds * 1000 - Date.now() + : retryAfterSeconds * 1000; + + await sleep( + Number.isFinite(retryAt) && retryAt > 0 + ? Math.min(retryAt, 30_000) + : retryDelay, + ); + + return mangadexRequest(env, path, { + query, + retries: retries - 1, + retryDelay: retryDelay * 2, + }); + } + + if (!response.ok) { + const message = await response.text(); + + throw new Error(`MangaDex request failed (${response.status}): ${message}`); + } + + return response.json(); +}; + +const statusMap = { + FINISHED: "completed", + RELEASING: "ongoing", + HIATUS: "hiatus", + CANCELLED: "cancelled", +}; + +const normaliseTitle = (value) => + String(value || "") + .trim() + .toLowerCase(); + +const parseOptionalNumber = (value) => { + if (value === null || value === undefined || value === "") return null; + + const number = Number.parseFloat(String(value)); + + return Number.isFinite(number) ? number : null; +}; + +const mangadexDateTime = (isoString) => { + const date = new Date(isoString); + + if (Number.isNaN(date.getTime())) return "1970-01-01T00:00:00"; + + return date.toISOString().slice(0, 19); +}; + +const scoreSearchResult = (manga, title) => { + const attributes = manga.attributes || {}; + const titleMap = attributes.title || {}; + const altTitles = (attributes.altTitles || []).flatMap((item) => + Object.values(item || {}), + ); + const titles = [...Object.values(titleMap || {}), ...altTitles] + .map(normaliseTitle) + .filter(Boolean); + const candidate = normaliseTitle(title); + + if (!candidate.length) return Number.MAX_SAFE_INTEGER; + if (titles.includes(candidate)) return 0; + if (titles.some((item) => item.includes(candidate))) return 1; + return 2; +}; + +const pickBestSearchResult = (results, title) => + [...results].sort( + (left, right) => + scoreSearchResult(left, title) - scoreSearchResult(right, title), + )[0]; + +const titleCandidates = (manga) => + [ + manga.nativeTitle, + manga.englishTitle, + manga.romajiTitle, + manga.nativeTitle === "null" ? null : manga.nativeTitle, + manga.englishTitle === "null" ? null : manga.englishTitle, + manga.romajiTitle === "null" ? null : manga.romajiTitle, + ] + .filter(Boolean) + .map((title) => String(title).trim()) + .filter((title, index, array) => array.indexOf(title) === index); + +const searchQueries = (manga, title) => { + const status = statusMap[manga.status]; + + return [ + { + title, + ...(manga.startYear ? { year: manga.startYear } : {}), + ...(status ? { "status[]": [status] } : {}), + limit: 5, + }, + { + title, + ...(status ? { "status[]": [status] } : {}), + limit: 5, + }, + { + title, + ...(manga.startYear ? { year: manga.startYear } : {}), + limit: 5, + }, + { + title, + limit: 5, + }, + ]; +}; + +const chooseLatestAggregateChapter = (volumes) => { + let latest = null; + + for (const [volumeKey, volumeEntry] of Object.entries(volumes || {})) { + const chapters = volumeEntry?.chapters || {}; + + for (const chapterEntry of Object.values(chapters)) { + const chapterText = chapterEntry?.chapter || null; + const chapterNumber = parseOptionalNumber(chapterText); + const volumeText = + chapterEntry?.volume || volumeEntry?.volume || volumeKey || null; + const volumeNumber = parseOptionalNumber(volumeText); + const candidate = { + chapterText, + chapterNumber, + volumeText, + volumeNumber, + chapterId: chapterEntry?.id || null, + }; + + if (!latest) { + latest = candidate; + + continue; + } + + if ( + candidate.chapterNumber !== null && + (latest.chapterNumber === null || + candidate.chapterNumber > latest.chapterNumber || + (candidate.chapterNumber === latest.chapterNumber && + (candidate.volumeNumber || -1) > (latest.volumeNumber || -1))) + ) { + latest = candidate; + + continue; + } + + if ( + candidate.chapterNumber === null && + latest.chapterNumber === null && + String(candidate.chapterText || "").localeCompare( + String(latest.chapterText || ""), + undefined, + { numeric: true }, + ) > 0 + ) + latest = candidate; + } + } + + return latest; +}; + +const buildIndexedRow = (manga, mangadexId, aggregateResponse) => { + const now = new Date().toISOString(); + const latest = chooseLatestAggregateChapter(aggregateResponse?.volumes); + + return { + anilist_id: manga.anilistId, + mangadex_id: mangadexId, + latest_en_chapter_text: latest?.chapterText || null, + latest_en_chapter_number: latest?.chapterNumber || null, + latest_en_volume_text: latest?.volumeText || null, + latest_en_chapter_id: latest?.chapterId || null, + latest_chapter_updated_at: now, + last_seen_at: now, + last_indexed_at: now, + is_releasing: manga.status === "RELEASING", + created_at: manga.createdAt || now, + updated_at: now, + }; +}; + +export const resolveMangadexId = async (env, manga) => { + const candidates = titleCandidates(manga); + + for (const title of candidates) { + for (const query of searchQueries(manga, title)) { + const payload = await mangadexRequest(env, "/manga", { query }); + const data = payload?.data || []; + + if (!data.length) continue; + + return pickBestSearchResult(data, title)?.id || data[0]?.id || null; + } + } + + return null; +}; + +export const bootstrapManga = async (env, manga) => { + const mangadexId = await resolveMangadexId(env, manga); + + if (!mangadexId) return null; + + const aggregateResponse = await mangadexRequest( + env, + `/manga/${mangadexId}/aggregate`, + { + query: { + "translatedLanguage[]": ["en"], + }, + }, + ); + + return buildIndexedRow(manga, mangadexId, aggregateResponse); +}; + +const extractMangaIdFromChapter = (chapter) => + chapter?.relationships?.find((relationship) => relationship.type === "manga") + ?.id || null; + +const currentCursor = () => new Date().toISOString(); + +const rewindCursor = (isoString, seconds = 30) => + new Date( + Math.max(new Date(isoString).getTime() - seconds * 1000, 0), + ).toISOString(); + +export const syncMangadexIndex = async ( + env, + { maxPages = 5, aggregateDelayMs = 250 } = {}, +) => { + const syncState = (await getSyncState(env, SYNC_STATE_NAME)) || { + cursor_updated_at: currentCursor(), + }; + const trackedMangadexIds = new Set(); + const cursor = syncState.cursor_updated_at; + let maxUpdatedAt = cursor; + let offset = 0; + let pageCount = 0; + let scannedChapterCount = 0; + + while (pageCount < maxPages) { + const payload = await mangadexRequest(env, "/chapter", { + query: { + "translatedLanguage[]": ["en"], + "contentRating[]": ALL_CONTENT_RATINGS, + includeFutureUpdates: 0, + includeFuturePublishAt: 0, + updatedAtSince: mangadexDateTime(cursor), + "order[updatedAt]": "asc", + limit: 100, + offset, + }, + }); + const chapters = payload?.data || []; + + if (!chapters.length) break; + + for (const chapter of chapters) { + scannedChapterCount += 1; + + const mangaId = extractMangaIdFromChapter(chapter); + const updatedAt = chapter?.attributes?.updatedAt; + + if (mangaId) trackedMangadexIds.add(mangaId); + if (updatedAt && updatedAt > maxUpdatedAt) maxUpdatedAt = updatedAt; + } + + pageCount += 1; + + if (chapters.length < 100) break; + + offset += chapters.length; + } + + if (!trackedMangadexIds.size) { + await upsertSyncState(env, SYNC_STATE_NAME, maxUpdatedAt); + + return { + cursor: maxUpdatedAt, + pageCount, + scannedChapterCount, + refreshedCount: 0, + }; + } + + const trackedRows = await getMangadexRowsByMangadexIds(env, [ + ...trackedMangadexIds, + ]); + const refreshedRows = []; + + for (const row of trackedRows) { + const aggregateResponse = await mangadexRequest( + env, + `/manga/${row.mangadex_id}/aggregate`, + { + query: { + "translatedLanguage[]": ["en"], + }, + }, + ); + + refreshedRows.push( + buildIndexedRow( + { + anilistId: row.anilist_id, + status: row.is_releasing ? "RELEASING" : "FINISHED", + createdAt: row.created_at, + }, + row.mangadex_id, + aggregateResponse, + ), + ); + + if (aggregateDelayMs > 0) await sleep(aggregateDelayMs); + } + + if (refreshedRows.length) await upsertMangadexRows(env, refreshedRows); + + await upsertSyncState(env, SYNC_STATE_NAME, rewindCursor(maxUpdatedAt)); + + return { + cursor: rewindCursor(maxUpdatedAt), + pageCount, + scannedChapterCount, + refreshedCount: refreshedRows.length, + }; +}; diff --git a/apps/proxy/src/supabase.js b/apps/proxy/src/supabase.js new file mode 100644 index 00000000..314caf59 --- /dev/null +++ b/apps/proxy/src/supabase.js @@ -0,0 +1,217 @@ +const INDEX_COLUMNS = [ + "anilist_id", + "mangadex_id", + "latest_en_chapter_text", + "latest_en_chapter_number", + "latest_en_volume_text", + "latest_en_chapter_id", + "latest_chapter_updated_at", + "last_seen_at", + "last_indexed_at", + "is_releasing", + "created_at", + "updated_at", +].join(","); + +const chunk = (items, size) => { + const chunks = []; + + for (let index = 0; index < items.length; index += size) + chunks.push(items.slice(index, index + size)); + + return chunks; +}; + +const supabaseUrl = (env, path) => + `${String(env.SUPABASE_URL || "").replace(/\/$/, "")}/rest/v1/${path}`; + +const supabaseHeaders = (env, headers = {}) => ({ + apikey: env.SUPABASE_SERVICE_ROLE_KEY, + Authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`, + ...headers, +}); + +const supabaseRequest = async ( + env, + path, + { + method = "GET", + query = {}, + body, + headers = {}, + prefer = [], + returnNullOn404 = false, + } = {}, +) => { + const url = new URL(supabaseUrl(env, path)); + + for (const [key, value] of Object.entries(query)) + if (value !== undefined && value !== null) url.searchParams.set(key, value); + + const response = await fetch(url, { + method, + headers: supabaseHeaders(env, { + ...(body ? { "Content-Type": "application/json" } : {}), + ...(prefer.length ? { Prefer: prefer.join(",") } : {}), + ...headers, + }), + body: body ? JSON.stringify(body) : undefined, + }); + + if (returnNullOn404 && response.status === 404) return null; + + if (!response.ok) { + const message = await response.text(); + + throw new Error(`Supabase request failed (${response.status}): ${message}`); + } + + if (response.status === 204) return null; + + const text = await response.text(); + + if (!text.length) return null; + + return JSON.parse(text); +}; + +const buildInFilter = (values) => `in.(${values.join(",")})`; + +export const hasSupabaseConfig = (env) => + Boolean(env.SUPABASE_URL && env.SUPABASE_SERVICE_ROLE_KEY); + +export const getMangadexRowsByAniListIds = async (env, anilistIds) => { + const rows = []; + + for (const ids of chunk([...new Set(anilistIds)], 100)) + rows.push( + ...((await supabaseRequest(env, "mangadex_manga_index", { + query: { + select: INDEX_COLUMNS, + anilist_id: buildInFilter(ids), + }, + })) || []), + ); + + return rows; +}; + +export const getMangadexRowsByMangadexIds = async (env, mangadexIds) => { + const rows = []; + + for (const ids of chunk([...new Set(mangadexIds)], 100)) + rows.push( + ...((await supabaseRequest(env, "mangadex_manga_index", { + query: { + select: INDEX_COLUMNS, + mangadex_id: buildInFilter(ids), + }, + })) || []), + ); + + return rows; +}; + +export const getMangadexFailureRowsByAniListIds = async (env, anilistIds) => { + const rows = []; + + for (const ids of chunk([...new Set(anilistIds)], 100)) + rows.push( + ...((await supabaseRequest(env, "mangadex_resolution_failures", { + query: { + select: "anilist_id,last_attempted_at,updated_at", + anilist_id: buildInFilter(ids), + }, + returnNullOn404: true, + })) || []), + ); + + return rows; +}; + +export const upsertMangadexRows = async (env, rows) => { + if (!rows.length) return []; + + return supabaseRequest(env, "mangadex_manga_index", { + method: "POST", + query: { + on_conflict: "anilist_id", + }, + body: rows, + prefer: ["resolution=merge-duplicates", "return=representation"], + }); +}; + +export const touchMangadexRows = async (env, rows) => { + if (!rows.length) return []; + + return upsertMangadexRows( + env, + rows.map((row) => ({ + anilist_id: row.anilist_id, + mangadex_id: row.mangadex_id, + is_releasing: row.is_releasing, + last_seen_at: row.last_seen_at, + updated_at: row.updated_at, + })), + ); +}; + +export const upsertMangadexFailureRows = async (env, anilistIds) => { + if (!anilistIds.length) return null; + + return supabaseRequest(env, "mangadex_resolution_failures", { + method: "POST", + query: { + on_conflict: "anilist_id", + }, + body: [...new Set(anilistIds)].map((anilistId) => ({ + anilist_id: anilistId, + last_attempted_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + })), + prefer: ["resolution=merge-duplicates", "return=minimal"], + returnNullOn404: true, + }); +}; + +export const deleteMangadexFailureRows = async (env, anilistIds) => { + if (!anilistIds.length) return null; + + return supabaseRequest(env, "mangadex_resolution_failures", { + method: "DELETE", + query: { + anilist_id: buildInFilter([...new Set(anilistIds)]), + }, + prefer: ["return=minimal"], + returnNullOn404: true, + }); +}; + +export const getSyncState = async (env, name) => { + const rows = await supabaseRequest(env, "mangadex_sync_state", { + query: { + select: "name,cursor_updated_at,updated_at", + name: `eq.${name}`, + limit: "1", + }, + }); + + return rows?.[0] || null; +}; + +export const upsertSyncState = async (env, name, cursorUpdatedAt) => + supabaseRequest(env, "mangadex_sync_state", { + method: "POST", + query: { + on_conflict: "name", + }, + body: [ + { + name, + cursor_updated_at: cursorUpdatedAt, + updated_at: new Date().toISOString(), + }, + ], + prefer: ["resolution=merge-duplicates", "return=minimal"], + }); |