From 414a272d40d1d6c87b67bf08ff3ca83418008aff Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 15 Oct 2026 11:30:27 +0200 Subject: [PATCH] feat(core): remember where and until when a cover is hosted Co-Authored-By: Claude Opus 5.5 --- src/core/hosting/host.ts | 12 ++++++++++++ src/core/session.ts | 18 ++++++++++++++++-- test/hosting.test.ts | 13 +++++++++++++ test/session.test.ts | 30 ++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/core/hosting/host.ts b/src/core/hosting/host.ts index 86e227c..c84ed16 100644 --- a/src/core/hosting/host.ts +++ b/src/core/hosting/host.ts @@ -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> = { "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; diff --git a/src/core/session.ts b/src/core/session.ts index 50abbae..c64a4bc 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -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>): 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); diff --git a/test/hosting.test.ts b/test/hosting.test.ts index 96a066d..f57039c 100644 --- a/test/hosting.test.ts +++ b/test/hosting.test.ts @@ -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(); + }); +}); diff --git a/test/session.test.ts b/test/session.test.ts index 5e733dd..6981953 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -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"); + }); +});