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:
1 parent
7569c0bb29
commit
414a272d40
4 files changed
+71
-2
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user