Files
com.gregkilmer.www/services/content/src/sync/jobs/kavita.ts
T
Claude e3b0cf60c5 Phase 6: sync jobs, inquiry and order forms
- Sync scheduler in the content service with a per-job cache (sync_cache),
  sync_runs reporting and Healthchecks pings. Jobs: Archidekt, BoardGameGeek,
  cover art (Open Library, TMDB, AniList), Suwayomi and Kavita reading
  progress, Bambuddy filament and Homebox material stock. A note's own value
  always wins over a synced one.
- PersonalReader: updates only Library notes with a matching anilistId,
  changes to Greg's notes are held for review; Reading series with no note
  show as suggestions on the admin Overview.
- /api/inquiries and /api/orders: Turnstile, rate limits, a saved copy in the
  content service (intake token) plus email over SMTP. Orders are re-checked
  against the live notes: accepted only when the category's ordersOpen is
  true and the line has capacity.
- Admin Inbox for saved submissions. Options' inStock is optional so synced
  stock can fill it. All settings from process.env; .env.example per service.
- Sources could not be reached from the build environment; parsers are tested
  against fixtures labelled as not live.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 23:50:06 +00:00

239 lines
9.8 KiB
TypeScript

/**
* 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/<id>`, 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<string, string> = {};
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<unknown> {
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<KavitaSeries[]> {
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<Set<number>> {
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<SyncResult> {
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<number, KavitaMetadata>();
const known = new Map<number, KavitaSeries>();
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<string, unknown> = { 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 },
};
},
};
}