mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
e3b0cf60c5
- 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
239 lines
9.8 KiB
TypeScript
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 },
|
|
};
|
|
},
|
|
};
|
|
}
|