mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
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
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user