/** * Cover art for Library items that have no `cover` of their own: * * - books: Open Library search (no key); * - films and TV: TMDB search (needs TMDB_API_KEY; skipped without it); * - anime, manga, manhwa, light novels and webnovels: AniList (no key), * by the note's `anilistId` when it has one, else by title. * * Video games have no source yet. Only the image address is stored; nothing * is downloaded. The job looks every 15 minutes, so a new item gets its * cover soon after it appears, and it re-checks old answers slowly: an item * with no cover found is asked about again after a day, one with a cover * after 30 days, at most `perRun` items a run. This follows the legacy * scripts in legacy/scripts/fetch-{book-covers,tmdb-art,anime-art}.mjs. */ import type { StoredNote } from "../../store.ts"; import type { SyncContext, SyncJob } from "../types.ts"; export const OPEN_LIBRARY_SEARCH = "https://openlibrary.org/search.json"; export const OPEN_LIBRARY_COVERS = "https://covers.openlibrary.org/b"; export const TMDB_API = "https://api.themoviedb.org/3"; export const TMDB_IMAGES = "https://image.tmdb.org/t/p/w500"; export const ANILIST_API = "https://graphql.anilist.co"; const DAY = 24 * 60 * 60_000; const RETRY_FOUND_MS = 30 * DAY; const RETRY_MISSING_MS = DAY; type Source = "openlibrary" | "tmdb" | "anilist"; export function sourceFor(medium: string): Source | null { if (medium === "book") return "openlibrary"; if (medium === "movie" || medium === "TV") return "tmdb"; if (["anime", "manga", "manhwa", "light novel", "webnovel"].includes(medium)) return "anilist"; return null; } class RateLimited extends Error {} // ---------------------------------------------------------------- parsing /** Open Library search.json: the first result with a cover. */ export function parseOpenLibrary(json: unknown): string | null { const docs = ((json as { docs?: { cover_i?: number; cover_edition_key?: string }[] })?.docs ?? []); const withId = docs.find((d) => typeof d.cover_i === "number" && d.cover_i > 0); if (withId) return `${OPEN_LIBRARY_COVERS}/id/${withId.cover_i}-L.jpg`; const withEdition = docs.find((d) => d.cover_edition_key); return withEdition ? `${OPEN_LIBRARY_COVERS}/olid/${withEdition.cover_edition_key}-L.jpg` : null; } /** TMDB search/movie or search/tv: the first result with a poster. */ export function parseTmdb(json: unknown): string | null { const results = (json as { results?: { poster_path?: string | null }[] })?.results ?? []; const hit = results.find((r) => r.poster_path); return hit ? `${TMDB_IMAGES}${hit.poster_path}` : null; } /** AniList Media query: the largest cover. */ export function parseAniList(json: unknown): string | null { const media = (json as { data?: { Media?: { coverImage?: { extraLarge?: string; large?: string } } | null } })?.data?.Media; const url = media?.coverImage?.extraLarge ?? media?.coverImage?.large; return url && url.startsWith("https://") ? url : null; } export const ANILIST_QUERY = `query ($id: Int, $search: String, $type: MediaType, $format: MediaFormat) { Media(id: $id, search: $search, type: $type, format: $format) { id title { english romaji } coverImage { extraLarge large } } }`; // ---------------------------------------------------------------- lookups async function json(ctx: SyncContext, url: string, init: RequestInit = {}) { const res = await ctx.fetch(url, { ...init, headers: { accept: "application/json", ...(init.headers as Record) } }); if (res.status === 429) throw new RateLimited("rate limited (429)"); if (res.status === 404) return null; if (!res.ok) throw new Error(`answered ${res.status}`); return res.json(); } export async function lookup(ctx: SyncContext, note: StoredNote, tmdbKey?: string): Promise { const d = note.data as { medium: string; creator?: string; year?: number; anilistId?: number }; const title = note.title; switch (sourceFor(d.medium)) { case "openlibrary": { const p = new URLSearchParams({ title, limit: "5", fields: "title,cover_i,cover_edition_key" }); if (d.creator) p.set("author", d.creator); return parseOpenLibrary(await json(ctx, `${OPEN_LIBRARY_SEARCH}?${p}`)); } case "tmdb": { if (!tmdbKey) return null; const kind = d.medium === "movie" ? "movie" : "tv"; const p = new URLSearchParams({ query: title, include_adult: "false" }); if (d.year) p.set(kind === "movie" ? "year" : "first_air_date_year", String(d.year)); // A v4 read access token (a JWT) goes in the header; a v3 key in the query. const bearer = tmdbKey.startsWith("eyJ"); if (!bearer) p.set("api_key", tmdbKey); return parseTmdb(await json(ctx, `${TMDB_API}/search/${kind}?${p}`, bearer ? { headers: { authorization: `Bearer ${tmdbKey}` } } : {})); } case "anilist": { const variables: Record = { type: d.medium === "anime" ? "ANIME" : "MANGA" }; if (d.anilistId) variables.id = d.anilistId; else { variables.search = title; if (d.medium === "light novel" || d.medium === "webnovel") variables.format = "NOVEL"; } return parseAniList( await json(ctx, ANILIST_API, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query: ANILIST_QUERY, variables }), }), ); } default: return null; } } // ---------------------------------------------------------------- the job export function coversJob( o: { everyMinutes?: number; healthcheckUrl?: string; tmdbApiKey?: string; perRun?: number; delayMs?: number } = {}, ): SyncJob { return { name: "covers", everyMinutes: o.everyMinutes ?? 15, healthcheckUrl: o.healthcheckUrl, async run(ctx) { const now = ctx.now().getTime(); const items = ctx.notes("library-item").filter((n) => !n.data.cover && sourceFor(String(n.data.medium)) !== null); const usable = items.filter((n) => sourceFor(String(n.data.medium)) !== "tmdb" || o.tmdbApiKey); // New items first, then the oldest answers that are due again. const due = usable .map((n) => ({ n, c: ctx.cached(n.path) })) .filter(({ c }) => !c || now - Date.parse(c.fetchedAt) >= (c.values.cover ? RETRY_FOUND_MS : RETRY_MISSING_MS)) .sort((a, b) => (a.c ? Date.parse(a.c.fetchedAt) : 0) - (b.c ? Date.parse(b.c.fetchedAt) : 0)) .slice(0, o.perRun ?? 20); let found = 0; let missing = 0; let failed = 0; const errors: string[] = []; for (const [i, { n, c }] of due.entries()) { if (i > 0) await ctx.sleep(o.delayMs ?? 1000); if (ctx.signal.aborted) throw ctx.signal.reason; try { const cover = await lookup(ctx, n, o.tmdbApiKey); if (cover) { ctx.set(n.path, { cover }); found++; } else { // Nothing found: keep a cover found earlier, else remember the miss. ctx.set(n.path, c?.values.cover ? c.values : {}); missing++; } } catch (err) { if (ctx.signal.aborted) throw ctx.signal.reason; failed++; errors.push(`${n.title}: ${err instanceof Error ? err.message : String(err)}`); if (err instanceof RateLimited) break; } } ctx.retain(items.map((n) => n.path)); return { ok: failed === 0, message: failed ? `${failed} lookups failed: ${errors.slice(0, 3).join("; ")}` : `${due.length} checked: ${found} covers found, ${missing} not found`, counts: { checked: due.length, found, missing, failed, waiting: usable.length - due.length }, }; }, }; }