/** * Kavita (PersonalReader's light novels and books): reading progress for * Library items linked to a Kavita series. Daily. Read only against Kavita. * * A Library item is linked only explicitly, never by title: * - `kavitaSeriesId` on the note names the Kavita series; or * - a book, light novel or webnovel note's `anilistId` equals the AniList id * in a Kavita series' metadata (an anilist.co address in its web links). * * Auth: the API key is exchanged for a token at * `POST /api/Plugin/authenticate?apiKey=…&pluginName=…`, sent as * `Authorization: Bearer`. Series come from `POST /api/Series/all-v2` (only * when an AniList match is needed) or `GET /api/Series/`, metadata from * `GET /api/Series/metadata?seriesId=`, the Want to Read list from * `POST /api/want-to-read/v2`. * * State: fully read is finished (or caught up while the series is still * ongoing), partly read is now, unread and on Want to Read is up next; * anything else leaves the note alone. Cached per note: `bookProgress` * (title, percent read, when). Nothing that links into Kavita. * * Written from Kavita's API documentation; not checked against a live server * from here (see docs/content-service.md). */ import type { StoredNote } from "../../store.ts"; import type { SyncContext, SyncJob, SyncResult } from "../types.ts"; import { errorText, isoTime, MEMO_KEY, StateProposer, targetState, type LibraryState } from "./reader.ts"; export interface KavitaOptions { url: string; apiKey: string; everyMinutes?: number; healthcheckUrl?: string; delayMs?: number; } /** Media that may be linked to Kavita by AniList id. */ export const KAVITA_MEDIA = ["book", "light novel", "webnovel"]; /** Kavita's PublicationStatus: OnGoing 0, Hiatus 1, Completed 2, Cancelled 3, Ended 4. */ const DONE_STATUSES = new Set([2, 4]); export interface KavitaSeries { id: number; name: string; pages: number; pagesRead: number; latestReadDate?: string; } export interface KavitaMetadata { anilistId?: number; publicationStatus?: number; } export function parseSeries(raw: unknown): KavitaSeries | null { const s = raw as { id?: number; name?: string; localizedName?: string; pages?: number; pagesRead?: number; latestReadDate?: string }; if (typeof s?.id !== "number" || !s.name) return null; const out: KavitaSeries = { id: s.id, name: s.localizedName || s.name, pages: Math.max(0, Number(s.pages) || 0), pagesRead: Math.max(0, Number(s.pagesRead) || 0) }; const at = isoTime(s.latestReadDate); if (at) out.latestReadDate = at; return out; } const ANILIST_LINK = /anilist\.co\/(?:manga|anime)\/(\d+)/i; /** AniList id from the series' web links (comma separated addresses), and its publication status. */ export function parseMetadata(raw: unknown): KavitaMetadata { const m = raw as { webLinks?: string; publicationStatus?: number }; const out: KavitaMetadata = {}; const id = ANILIST_LINK.exec(m?.webLinks ?? "")?.[1]; if (id) out.anilistId = Number(id); if (typeof m?.publicationStatus === "number") out.publicationStatus = m.publicationStatus; return out; } /** The Library state Kavita's progress means, or null to leave the note alone. */ export function kavitaState(s: KavitaSeries, meta: KavitaMetadata, wantToRead: boolean): LibraryState | null { if (s.pages > 0 && s.pagesRead >= s.pages) return DONE_STATUSES.has(meta.publicationStatus ?? -1) ? "finished" : "caught up"; if (s.pagesRead > 0) return "now"; return wantToRead ? "up next" : null; } export const percentRead = (s: KavitaSeries) => (s.pages > 0 ? Math.min(100, Math.floor((s.pagesRead / s.pages) * 100)) : 0); // ---------------------------------------------------------------- requests const FILTER = { statements: [], combination: 1, limitTo: 0, sortOptions: { sortField: 1, isAscending: true } }; class Kavita { private headers: Record = {}; constructor(private readonly ctx: SyncContext, private readonly o: KavitaOptions) {} private url = (path: string) => `${this.o.url.replace(/\/$/, "")}${path}`; async login() { const p = new URLSearchParams({ apiKey: this.o.apiKey, pluginName: "GregKilmer.com" }); const res = await this.ctx.fetch(this.url(`/api/Plugin/authenticate?${p}`), { method: "POST", headers: { accept: "application/json" } }); if (res.status === 401 || res.status === 403) throw new Error(`Kavita refused the API key (${res.status})`); if (!res.ok) throw new Error(`Kavita login answered ${res.status}`); const token = ((await res.json()) as { token?: string })?.token; if (!token) throw new Error("Kavita login gave no token"); this.headers = { authorization: `Bearer ${token}` }; } async call(path: string, body?: unknown): Promise { const res = await this.ctx.fetch(this.url(path), { method: body === undefined ? "GET" : "POST", headers: { accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }), ...this.headers }, body: body === undefined ? undefined : JSON.stringify(body), }); if (res.status === 404) return null; if (!res.ok) throw new Error(`Kavita answered ${res.status} for ${path.split("?")[0]}`); return res.json(); } async allSeries(): Promise { const out: KavitaSeries[] = []; for (let page = 1; page <= 50; page++) { const list = (await this.call(`/api/Series/all-v2?PageNumber=${page}&PageSize=200`, FILTER)) as unknown[] | null; if (!Array.isArray(list) || !list.length) break; for (const r of list) { const s = parseSeries(r); if (s) out.push(s); } if (list.length < 200) break; } return out; } async series(id: number) { return parseSeries(await this.call(`/api/Series/${id}`)); } async metadata(id: number) { return parseMetadata(await this.call(`/api/Series/metadata?seriesId=${id}`)); } async wantToRead(): Promise> { try { const list = (await this.call("/api/want-to-read/v2?PageNumber=1&PageSize=1000", FILTER)) as { id?: number }[] | null; return new Set((list ?? []).map((s) => s.id).filter((id): id is number => typeof id === "number")); } catch (err) { if (this.ctx.signal.aborted) throw this.ctx.signal.reason; this.ctx.log(`Want to Read list not read: ${errorText(err)}`); return new Set(); } } } // ---------------------------------------------------------------- the job export function kavitaJob(o: KavitaOptions): SyncJob { const delay = o.delayMs ?? 250; return { name: "kavita", everyMinutes: o.everyMinutes ?? 24 * 60, healthcheckUrl: o.healthcheckUrl, timeoutMs: 30 * 60_000, async run(ctx): Promise { const items = ctx.notes("library-item"); const byKavita = items.filter((n) => typeof n.data.kavitaSeriesId === "number"); const byAniList = items.filter( (n) => typeof n.data.kavitaSeriesId !== "number" && typeof n.data.anilistId === "number" && KAVITA_MEDIA.includes(String(n.data.medium)), ); if (!byKavita.length && !byAniList.length) { ctx.retain([MEMO_KEY]); return { message: "No Library item is linked to Kavita", counts: { linked: 0 } }; } const k = new Kavita(ctx, o); await k.login(); // Pairs of (note, Kavita series id), with the metadata already read. const pairs: { note: StoredNote; id: number }[] = byKavita.map((note) => ({ note, id: note.data.kavitaSeriesId as number })); const meta = new Map(); const known = new Map(); if (byAniList.length) { const wanted = new Set(byAniList.map((n) => n.data.anilistId as number)); for (const [i, s] of (await k.allSeries()).entries()) { if (i > 0) await ctx.sleep(delay); if (ctx.signal.aborted) throw ctx.signal.reason; const m = await k.metadata(s.id); if (!m.anilistId || !wanted.has(m.anilistId)) continue; meta.set(s.id, m); known.set(s.id, s); for (const note of byAniList.filter((n) => n.data.anilistId === m.anilistId)) pairs.push({ note, id: s.id }); } } const wantToRead = await k.wantToRead(); const proposer = new StateProposer(ctx); const kept: string[] = [MEMO_KEY]; let synced = 0; let missing = 0; let failed = 0; const errors: string[] = []; for (const { note, id } of pairs) { if (ctx.signal.aborted) throw ctx.signal.reason; kept.push(note.path); try { let s = known.get(id) ?? null; if (!s) { await ctx.sleep(delay); s = await k.series(id); } if (!s) { missing++; ctx.log(`${note.path}: no Kavita series ${id}`); continue; } const m = meta.get(id) ?? (await k.metadata(id)); const progress: Record = { title: s.name, percent: percentRead(s) }; if (s.latestReadDate) progress.readAt = s.latestReadDate; ctx.set(note.path, { bookProgress: progress }); synced++; proposer.propose(note, targetState(String(note.data.state), kavitaState(s, m, wantToRead.has(id)))); } catch (err) { if (ctx.signal.aborted) throw ctx.signal.reason; failed++; errors.push(`${note.title}: ${errorText(err)}`); } } proposer.save(kept); ctx.retain(kept); const c = proposer.counts; const problems = [...errors, ...proposer.errors]; return { ok: problems.length === 0, message: problems.length ? `${problems.length} problems: ${problems.slice(0, 3).join("; ")}` : `${synced} series synced: ${c.accepted} updated, ${c.held} held for review`, counts: { linked: pairs.length, synced, missing, failed, accepted: c.accepted, held: c.held, waiting: c.waiting, rejected: c.rejected }, }; }, }; }