feat(core): remember where and until when a cover is hosted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-15 11:30:27 +02:00
1 parent 7569c0bb29
commit 414a272d40
4 files changed
+71 -2

No files matched your search

+12
View File
@@ -4,6 +4,18 @@ export type HostId = "server" | "litterbox" | "catbox" | "direct";
export type LitterboxTime = "1h" | "12h" | "24h" | "72h";
export const LITTERBOX_TIMES: readonly LitterboxTime[] = ["1h", "12h", "24h", "72h"];
const HOUR_MS = 3_600_000;
const LITTERBOX_HOURS: Readonly<Record<LitterboxTime, number>> = { "1h": 1, "12h": 12, "24h": 24, "72h": 72 };
/** Our server sweeps stored covers after this many days (spec 001 §4.6). */
export const SERVER_KEEP_DAYS = 30;
/** When a link made now by `host` stops working; null = it doesn't expire (spec 002 §3.3). */
export function linkExpiry(host: HostId, options: HostOptions, now: number): number | null {
if (host === "litterbox") return now + LITTERBOX_HOURS[options.litterboxTime ?? "72h"] * HOUR_MS;
if (host === "server") return now + SERVER_KEEP_DAYS * 24 * HOUR_MS;
return null;
}
export interface HostOptions {
litterboxTime?: LitterboxTime;
catboxUserhash?: string;
+16 -2
View File
@@ -1,8 +1,8 @@
import { coverNote } from "./covers";
import { downloadBundle } from "./download";
import { HostError, type HostId, type HostImage, type HostOptions } from "./hosting/host";
import { HostError, linkExpiry, type HostId, type HostImage, type HostOptions } from "./hosting/host";
import { HOSTS } from "./hosting/registry";
import { rehostAll, type RehostResult } from "./hosting/rehost";
import { cacheKey, rehostAll, type RehostResult } from "./hosting/rehost";
import { MangaBakaClient, MangaBakaError, coverFormUrl } from "./mangabaka/client";
import type { PreviewResult, SaveResult, SubmissionImage } from "./mangabaka/types";
import type { ContentRating, ImageType, InspectedCover } from "./model";
@@ -41,6 +41,8 @@ export interface ReviewCard {
note: string;
contentRating: ContentRating;
hostedUrl: string | null;
/** Who hosts `hostedUrl`, its rehost cache key, and when it expires (epoch ms; null = never). */
hosting?: { by: HostId; key: string | null; until: number | null };
error: string | null;
}
@@ -106,6 +108,16 @@ export class Session {
}));
}
/** A saved search, back as it was left (spec 002 §3.3). Its hosted links refill the rehost cache. */
restore(result: RunResult, cards: ReviewCard[], makeDefault: boolean, input: RunInput | null): void {
this.result = result;
this.cards = cards;
this.makeDefault = makeDefault && result.suggestedDefault !== null;
this.input = input;
this.conflicts = 0;
for (const c of cards) if (c.hostedUrl && c.hosting?.key) this.hostedCache.set(c.hosting.key, c.hostedUrl);
}
update(sha256: string, patch: Partial<Pick<ReviewCard, "selected" | "type" | "note" | "contentRating">>): void {
const card = this.cards.find((c) => c.cover.sha256 === sha256);
if (!card) throw new Error(`No cover ${sha256}`);
@@ -159,8 +171,10 @@ export class Session {
this.hostedCache,
onProgress,
);
const until = linkExpiry(hostId, options, Date.now());
for (const c of cards) {
c.hostedUrl = res.hosted.get(c.cover.sha256) ?? null;
c.hosting = c.hostedUrl ? { by: hostId, key: cacheKey(hostId, options, c.cover.sha256), until } : undefined;
c.error = res.failures.find((f) => f.sha256 === c.cover.sha256)?.message ?? null;
}
if (this.deps.persistHostedUrls) saveHosted(this.deps.store, this.hostedCache);
+13
View File
@@ -104,3 +104,16 @@ describe("rehostAll", () => {
expect(res.allFailed).toBe(true);
});
});
describe("link expiry", () => {
const now = Date.UTC(2026, 9, 15);
const hour = 3_600_000;
test("litterbox after its chosen time, our server after 30 days, catbox and direct never", async () => {
const { linkExpiry } = await import("../src/core/hosting/host");
expect(linkExpiry("litterbox", { litterboxTime: "1h" }, now)).toBe(now + hour);
expect(linkExpiry("litterbox", {}, now)).toBe(now + 72 * hour);
expect(linkExpiry("server", {}, now)).toBe(now + 30 * 24 * hour);
expect(linkExpiry("catbox", {}, now)).toBeNull();
expect(linkExpiry("direct", {}, now)).toBeNull();
});
});
+30
View File
@@ -156,3 +156,33 @@ describe("download", () => {
expect(new TextDecoder().decode(s.bundle().files[2]!.bytes)).toContain("01_poster_en_480x623.jpg\n type: volume · language: en · rating: safe · MAIN COVER");
});
});
describe("hosting is remembered on the cards", () => {
test("rehost records the host, the cache key and when the link expires", async () => {
const fake = new FakeTransport().on(LITTERBOX, () => text("https://litter.catbox.moe/a.jpg"));
const s = new Session(deps(fake));
s.load(result());
const before = Date.now();
await s.rehost("litterbox", { litterboxTime: "1h" });
const card = s.cards[0]!;
expect(card.hosting?.by).toBe("litterbox");
expect(card.hosting?.key).toBe(`litterbox:1h:${card.cover.sha256}`);
expect(card.hosting!.until!).toBeGreaterThanOrEqual(before + 3_600_000);
});
test("restore puts hosted links back in the rehost cache: no second upload", async () => {
const fake = new FakeTransport().on(LITTERBOX, () => text("https://litter.catbox.moe/a.jpg"));
const first = new Session(deps(fake));
const r = result();
first.load(r);
await first.rehost("litterbox", { litterboxTime: "1h" });
const uploads = fake.calls.length;
const second = new Session(deps(fake));
second.restore(r, first.cards.map((c) => ({ ...c })), false, null);
expect(second.makeDefault).toBe(false);
await second.rehost("litterbox", { litterboxTime: "1h" });
expect(fake.calls.length).toBe(uploads);
expect(second.cards[0]!.hostedUrl).toBe("https://litter.catbox.moe/a.jpg");
});
});