Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/core/posterCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,4 +369,7 @@ describe("POSTER_HOSTS allowlist", () => {
it("still accepts the OMDb/Amazon hosts", () => {
expect(POSTER_HOSTS.has("m.media-amazon.com")).toBe(true);
});
it("accepts TMDB's image CDN, via reccd's GET /artwork", () => {
expect(POSTER_HOSTS.has("image.tmdb.org")).toBe(true);
});
});
3 changes: 3 additions & 0 deletions src/core/posterCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export const POSTER_HOSTS = new Set([
"img.omdbapi.com",
// AniList cover art (the Anime group's metadata provider).
"s4.anilist.co",
// TMDB's image CDN, via reccd's GET /artwork — season posters and episode
// stills, which OMDb (the two hosts above) has no equivalent field for.
"image.tmdb.org",
]);

// Cap the cache rather than letting it grow forever. Posters are ~50-200KB, so
Expand Down
84 changes: 83 additions & 1 deletion src/recc/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { postEvent, fetchRecommendations, fetchTitleSuggestions, claimReccAccount } from "./client.js";
import { postEvent, fetchRecommendations, fetchTitleSuggestions, fetchArtwork, claimReccAccount } from "./client.js";
import type { FetchImpl } from "../util/net";

function jsonRes(status: number, body: unknown = {}) {
Expand Down Expand Up @@ -372,6 +372,88 @@ describe("fetchTitleSuggestions", () => {
});
});

describe("fetchArtwork", () => {
it("gets {reccUrl}/artwork with imdbId, type and a bearer token", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonRes(200, { posterUrl: "https://p.jpg", stillUrl: null }));
const res = await fetchArtwork(
{ reccUrl: "http://localhost:4100", reccToken: "dev-token" },
{ imdbId: "tt1190634", type: "series" },
{ fetchImpl },
);
expect(res).toEqual({ ok: true, posterUrl: "https://p.jpg", stillUrl: null });
const [url, init] = fetchImpl.mock.calls[0] as [string, { method: string; headers: Record<string, string> }];
expect(url).toBe("http://localhost:4100/artwork?imdbId=tt1190634&type=series");
expect(init.method).toBe("GET");
expect(init.headers.authorization).toBe("Bearer dev-token");
});

it("adds season and episode to the query when given", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonRes(200, { posterUrl: "https://s5.jpg", stillUrl: "https://e1.jpg" }));
const res = await fetchArtwork(
{ reccUrl: "http://r", reccToken: "t" },
{ imdbId: "tt1190634", type: "series", season: 5, episode: 1 },
{ fetchImpl },
);
expect(res).toEqual({ ok: true, posterUrl: "https://s5.jpg", stillUrl: "https://e1.jpg" });
const [url] = fetchImpl.mock.calls[0] as [string];
expect(url).toBe("http://r/artwork?imdbId=tt1190634&type=series&season=5&episode=1");
});

it("omits episode when only season is given — a season poster with no episode still", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonRes(200, { posterUrl: "https://s5.jpg", stillUrl: null }));
await fetchArtwork({ reccUrl: "http://r", reccToken: "t" }, { imdbId: "tt1", type: "series", season: 5 }, { fetchImpl });
const [url] = fetchImpl.mock.calls[0] as [string];
expect(url).toBe("http://r/artwork?imdbId=tt1&type=series&season=5");
});

// Both fields null, still a 200 — reccd found no TMDB match. Ordinary, not
// an error: the caller's existing OMDb series poster is the answer either way.
it("treats both fields null as an ordinary miss, not an error", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonRes(200, { posterUrl: null, stillUrl: null }));
const res = await fetchArtwork({ reccUrl: "http://r", reccToken: "t" }, { imdbId: "tt9", type: "movie" }, { fetchImpl });
expect(res).toEqual({ ok: true, posterUrl: null, stillUrl: null });
});

it("does not call fetch at all when reccUrl is not configured", async () => {
const fetchImpl = vi.fn();
const res = await fetchArtwork({}, { imdbId: "tt1", type: "movie" }, { fetchImpl });
expect(res.ok).toBe(false);
expect(fetchImpl).not.toHaveBeenCalled();
});

it("reports a rejected token", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonRes(401, { error: "unauthorized" }));
const res = await fetchArtwork({ reccUrl: "http://r", reccToken: "bad" }, { imdbId: "tt1", type: "movie" }, { fetchImpl });
expect(res).toEqual({ ok: false, error: "reccd rejected the token — check reccToken" });
});

// A reccd predating GET /artwork 404s. That is "this feature is unavailable",
// not a fault — the caller's existing series-poster fallback carries on.
it("treats a 404 as an older reccd without the endpoint", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonRes(404, { error: "not found" }));
const res = await fetchArtwork({ reccUrl: "http://r", reccToken: "t" }, { imdbId: "tt1", type: "movie" }, { fetchImpl });
expect(res).toEqual({ ok: false, error: "this reccd has no artwork endpoint" });
});

it("reports any other non-ok status", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonRes(500, {}));
const res = await fetchArtwork({ reccUrl: "http://r", reccToken: "t" }, { imdbId: "tt1", type: "movie" }, { fetchImpl });
expect(res).toEqual({ ok: false, error: "artwork unavailable (HTTP 500)" });
});

it("rejects a body whose fields are the wrong shape", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonRes(200, { posterUrl: 12, stillUrl: null }));
const res = await fetchArtwork({ reccUrl: "http://r", reccToken: "t" }, { imdbId: "tt1", type: "movie" }, { fetchImpl });
expect(res.ok).toBe(false);
});

it("never throws on a network error", async () => {
const fetchImpl = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
const res = await fetchArtwork({ reccUrl: "http://r", reccToken: "t" }, { imdbId: "tt1", type: "movie" }, { fetchImpl });
expect(res.ok).toBe(false);
});
});

describe("claimReccAccount", () => {
const CFG = { reccUrl: "https://reccd.stream", reccToken: "tok" };

Expand Down
70 changes: 70 additions & 0 deletions src/recc/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,76 @@ export async function fetchTitleSuggestions(
}
}

export interface ArtworkQuery {
imdbId: string;
type: "movie" | "series";
/** A season's own poster, or (with `episode`) that episode's still. */
season?: number;
/** Meaningless without `season`. */
episode?: number;
}

export type FetchArtworkResult =
| { ok: true; posterUrl: string | null; stillUrl: string | null }
| { ok: false; error: string };

function isArtworkBody(v: unknown): v is { posterUrl: string | null; stillUrl: string | null } {
if (typeof v !== "object" || v === null) return false;
const r = v as Record<string, unknown>;
return (
(r.posterUrl === null || typeof r.posterUrl === "string") &&
(r.stillUrl === null || typeof r.stillUrl === "string")
);
}

/**
* reccd's `GET /artwork` — a season's own poster, or an episode's still,
* sourced from TMDB (which OMDb, this app's primary poster provider, simply
* has no equivalent field for). `imdbId` is what this app already resolves
* via OMDb for every title; `season`/`episode` are already sitting on
* `GroupRow` (src/util/resultGroup.ts) for a season or episode-group heading,
* so nothing upstream of this call needs new data — only new plumbing.
*
* Same soft-degrade shape as `fetchTitleSuggestions`: a reccd that predates
* this endpoint, or one with no TMDB key configured, answers 404 or with both
* fields null, and the caller's existing series-level OMDb poster stands as
* the answer, exactly as it did before this existed.
*/
export async function fetchArtwork(
config: ReccClientConfig,
query: ArtworkQuery,
opts: { fetchImpl?: FetchImpl; timeoutMs?: number } = {},
): Promise<FetchArtworkResult> {
if (!config.reccUrl) return { ok: false, error: "artwork not configured" };
const fetchImpl = opts.fetchImpl ?? (fetch as FetchImpl);
const params = new URLSearchParams();
params.set("imdbId", query.imdbId);
params.set("type", query.type);
if (query.season !== undefined) params.set("season", String(query.season));
if (query.episode !== undefined) params.set("episode", String(query.episode));
try {
const res = await fetchImpl(`${config.reccUrl}/artwork?${params.toString()}`, {
method: "GET",
headers: { authorization: `Bearer ${config.reccToken ?? ""}` },
signal: AbortSignal.timeout(opts.timeoutMs ?? 8000),
});
if (res.status === 401) return { ok: false, error: "reccd rejected the token — check reccToken" };
// A reccd older than the /artwork endpoint. Not a fault — the feature is
// simply unavailable, and the caller's existing series-poster fallback
// carries on exactly as it did before this endpoint existed.
if (res.status === 404) return { ok: false, error: "this reccd has no artwork endpoint" };
if (!res.ok) return { ok: false, error: `artwork unavailable (HTTP ${res.status})` };
const body: unknown = await res.json();
if (!isArtworkBody(body)) return { ok: false, error: "unexpected response from reccd" };
return { ok: true, posterUrl: body.posterUrl, stillUrl: body.stillUrl };
} catch (err) {
log.debug(
`recc fetchArtwork: failed to reach ${config.reccUrl}/artwork: ${err instanceof Error ? err.message : String(err)}`,
);
return { ok: false, error: "couldn't reach reccd" };
}
}

export type ClaimReccResult =
| { ok: true; name: string }
| {
Expand Down
144 changes: 144 additions & 0 deletions src/web/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3809,6 +3809,150 @@ describe("GET /api/title-search", () => {
});
});

describe("GET /api/artwork", () => {
beforeEach(() => {
// Same reasoning as GET /api/title-search's own beforeEach: without this a
// developer with a real reccd exported would never see the not-configured
// path, and the "configured" tests would talk to their actual service.
vi.stubEnv("TORLINK_RECC_URL", "");
vi.stubEnv("TORLINK_RECC_TOKEN", "");
});

function artworkDeps(over: Partial<WebDeps> = {}): WebDeps {
return deps({
loadConfigImpl: async () => searchConfig({ reccUrl: "http://recc.local", reccToken: "t" }),
fetchArtworkImpl: async () => ({ ok: true, posterUrl: "https://image.tmdb.org/t/p/w500/s5.jpg", stillUrl: null }),
...over,
});
}

function ask(d: WebDeps, qs = "imdbId=tt1190634&type=series&season=5"): Promise<WebResponse> {
return handleWebApi(d, "GET", "/api/artwork", new URLSearchParams(qs), AUTH, "");
}

// Same gate as GET /api/title-search: this route does not delegate to
// handleApi, so nothing else stands between an anonymous caller and reccd.
it("rejects an unauthenticated caller when a token is set", async () => {
const fetchArtworkImpl = vi.fn(async () => ({ ok: true as const, posterUrl: "https://p.jpg", stillUrl: null }));
const res = await handleWebApi(
artworkDeps({ token: "secret", fetchArtworkImpl }),
"GET",
"/api/artwork",
new URLSearchParams("imdbId=tt1190634&type=series&season=5"),
undefined,
"",
);
expect(res.status).toBe(401);
expect(fetchArtworkImpl).not.toHaveBeenCalled();
});

it("returns reccd's artwork", async () => {
const res = await ask(artworkDeps());
expect(res.status).toBe(200);
expect(res.json).toEqual({ status: "ok", posterUrl: "https://image.tmdb.org/t/p/w500/s5.jpg", stillUrl: null });
});

// 200 with its own status, NOT a 500 — matching every other reccd-backed
// route (title-search, recommendations, title). Nothing is broken: the user
// has no reccd, and the caller needs to be able to tell that apart from the
// server falling over.
it("answers not-configured without asking reccd", async () => {
const fetchArtworkImpl = vi.fn(async () => ({ ok: true as const, posterUrl: "https://p.jpg", stillUrl: null }));
const res = await ask(artworkDeps({ loadConfigImpl: async () => searchConfig({}), fetchArtworkImpl }));
expect(res.status).toBe(200);
expect(res.json).toEqual({ status: "not-configured" });
expect(fetchArtworkImpl).not.toHaveBeenCalled();
});

// Covers a reccd predating this endpoint the same way: fetchArtwork turns a
// 404 into an ordinary {ok: false}, and the route must not turn that into a
// 500 either.
it("reports a reccd failure as a status, not a 500", async () => {
const res = await ask(
artworkDeps({ fetchArtworkImpl: async () => ({ ok: false, error: "this reccd has no artwork endpoint" }) }),
);
expect(res.status).toBe(200);
expect(res.json).toEqual({ status: "error", error: "this reccd has no artwork endpoint" });
});

it("leaks neither the reccd token nor its URL", async () => {
const res = await ask(
artworkDeps({
loadConfigImpl: async () =>
searchConfig({ reccUrl: "http://recc.internal:4100", reccToken: "zzq-secret-9317" }),
}),
);
const body = JSON.stringify(res.json);
expect(body).not.toContain("zzq-secret-9317");
expect(body).not.toContain("recc.internal");
});

it("400s a missing or malformed imdbId rather than asking reccd", async () => {
const fetchArtworkImpl = vi.fn(async () => ({ ok: true as const, posterUrl: null, stillUrl: null }));
const res = await ask(artworkDeps({ fetchArtworkImpl }), "type=series&season=5");
expect(res.status).toBe(400);
expect(fetchArtworkImpl).not.toHaveBeenCalled();

const res2 = await ask(artworkDeps({ fetchArtworkImpl }), "imdbId=notreal&type=series&season=5");
expect(res2.status).toBe(400);
expect(fetchArtworkImpl).not.toHaveBeenCalled();
});

it("400s an invalid type rather than asking reccd", async () => {
const fetchArtworkImpl = vi.fn(async () => ({ ok: true as const, posterUrl: null, stillUrl: null }));
const res = await ask(artworkDeps({ fetchArtworkImpl }), "imdbId=tt1190634&type=episode&season=5");
expect(res.status).toBe(400);
expect(fetchArtworkImpl).not.toHaveBeenCalled();
});

it("400s when neither season nor episode is given — nothing OMDb doesn't already answer", async () => {
const fetchArtworkImpl = vi.fn(async () => ({ ok: true as const, posterUrl: null, stillUrl: null }));
const res = await ask(artworkDeps({ fetchArtworkImpl }), "imdbId=tt1190634&type=series");
expect(res.status).toBe(400);
expect(fetchArtworkImpl).not.toHaveBeenCalled();
});

it("400s an episode given without a season", async () => {
const fetchArtworkImpl = vi.fn(async () => ({ ok: true as const, posterUrl: null, stillUrl: null }));
const res = await ask(artworkDeps({ fetchArtworkImpl }), "imdbId=tt1190634&type=series&episode=1");
expect(res.status).toBe(400);
expect(fetchArtworkImpl).not.toHaveBeenCalled();
});

it("forwards imdbId, type, season and episode to reccd", async () => {
const fetchArtworkImpl = vi.fn(async () => ({ ok: true as const, posterUrl: null, stillUrl: null }));
await ask(artworkDeps({ fetchArtworkImpl }), "imdbId=tt1190634&type=series&season=5&episode=1");
expect(fetchArtworkImpl).toHaveBeenCalledWith(expect.objectContaining({ reccUrl: "http://recc.local" }), {
imdbId: "tt1190634",
type: "series",
season: 5,
episode: 1,
});
});

it("omits episode from the forwarded query when only season is given", async () => {
const fetchArtworkImpl = vi.fn(async () => ({ ok: true as const, posterUrl: null, stillUrl: null }));
await ask(artworkDeps({ fetchArtworkImpl }), "imdbId=tt1190634&type=series&season=5");
expect(fetchArtworkImpl).toHaveBeenCalledWith(expect.objectContaining({ reccUrl: "http://recc.local" }), {
imdbId: "tt1190634",
type: "series",
season: 5,
});
});

// POSTER_HOSTS enforcement, same as GET /api/title's own posterUrl — this is
// the route where a URL reccd invented (or a compromised reccd returned)
// would otherwise cross straight into the browser.
it("refuses a poster URL whose host is not on the allowlist", async () => {
const res = await ask(
artworkDeps({
fetchArtworkImpl: async () => ({ ok: true, posterUrl: "https://evil.example/p.jpg", stillUrl: null }),
}),
);
expect(res.json).toEqual({ status: "ok", posterUrl: null, stillUrl: null });
});
});

describe("GET /api/sources reccConfigured", () => {
beforeEach(() => {
vi.stubEnv("TORLINK_RECC_URL", "");
Expand Down
Loading
Loading