From 5e680b5a6a18d562ba6503a0c0f5c460fb36c68e Mon Sep 17 00:00:00 2001 From: Ash Date: Tue, 1 Sep 2026 15:10:14 +0100 Subject: [PATCH 1/2] feat: wire torlink for reccd season/episode artwork Adds client-side plumbing for per-season and per-episode poster art, served through a new GET /artwork endpoint on reccd (spec'd separately, not yet implemented there). OMDb has no season/episode artwork fields at all, so this is additive: a season or episode row now asks reccd for its own artwork and prefers it over the series poster OMDb already gives fetchMeta, falling back to that series poster whenever reccd has no answer, isn't configured, or predates the endpoint. - src/recc/client.ts: fetchArtwork() client, mirroring fetchTitleSuggestions - src/core/posterCache.ts: allow image.tmdb.org in the poster CDN allowlist - src/web/routes.ts + wire.ts: GET /api/artwork, proxying reccd and enforcing the CDN allowlist on both posterUrl and stillUrl - src/web/static/resultPosters.ts + app.ts: season/episode-aware poster lookup, wired from renderGroupRow's season/group rows Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011QTsg7ie21bP9JpEPFMkyP --- src/core/posterCache.test.ts | 3 + src/core/posterCache.ts | 3 + src/recc/client.test.ts | 84 +++++++++++++++- src/recc/client.ts | 70 +++++++++++++ src/web/routes.test.ts | 144 +++++++++++++++++++++++++++ src/web/routes.ts | 88 ++++++++++++++++ src/web/static/app.ts | 61 ++++++++++-- src/web/static/resultPosters.test.ts | 76 ++++++++++++++ src/web/static/resultPosters.ts | 56 +++++++++-- src/web/wire.ts | 13 +++ 10 files changed, 583 insertions(+), 15 deletions(-) diff --git a/src/core/posterCache.test.ts b/src/core/posterCache.test.ts index b61cf219..a52a34bc 100644 --- a/src/core/posterCache.test.ts +++ b/src/core/posterCache.test.ts @@ -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); + }); }); diff --git a/src/core/posterCache.ts b/src/core/posterCache.ts index 7f0fa9ff..048244d5 100644 --- a/src/core/posterCache.ts +++ b/src/core/posterCache.ts @@ -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 diff --git a/src/recc/client.test.ts b/src/recc/client.test.ts index cb3bc67e..f24a163c 100644 --- a/src/recc/client.test.ts +++ b/src/recc/client.test.ts @@ -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 = {}) { @@ -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 }]; + 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" }; diff --git a/src/recc/client.ts b/src/recc/client.ts index 62449a73..20746203 100644 --- a/src/recc/client.ts +++ b/src/recc/client.ts @@ -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; + 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 { + 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 } | { diff --git a/src/web/routes.test.ts b/src/web/routes.test.ts index e0aea11e..a76a1ab8 100644 --- a/src/web/routes.test.ts +++ b/src/web/routes.test.ts @@ -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 { + 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 { + 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", ""); diff --git a/src/web/routes.ts b/src/web/routes.ts index c0d444a9..83958302 100644 --- a/src/web/routes.ts +++ b/src/web/routes.ts @@ -44,9 +44,12 @@ import { import { resolveProfileId, isOwnerProfile } from "../core/profile"; import { ensureReccAccount } from "../recc/provision"; import { + fetchArtwork, fetchRecommendations, fetchTitleSuggestions, postEvent, + type ArtworkQuery, + type FetchArtworkResult, type FetchRecommendationsResult, type FetchTitleSuggestionsResult, type ReccClientConfig, @@ -112,6 +115,7 @@ import type { PublicStreamFile, PublicStreamHistoryItem, PublicReccEventAck, + PublicArtwork, PublicReccEventType, PublicRecommendations, PublicStreamSession, @@ -241,6 +245,8 @@ export interface WebDeps { config: ReccClientConfig, query: { q: string; limit?: number }, ) => Promise; + /** reccd's season/episode artwork, for `/api/artwork`. Injected to keep tests off the network. */ + fetchArtworkImpl?: (config: ReccClientConfig, query: ArtworkQuery) => Promise; /** * How `/api/recc-event` reaches reccd. Injected for the same reason, and * typed as returning void rather than a promise the route waits on — see the @@ -1925,6 +1931,84 @@ async function titleSearch( return { status: 200, json: out }; } +/** + * `season` alone: that season's poster. `season` and `episode` together: + * additionally that episode's still. Neither: the route has nothing to ask + * reccd that OMDb doesn't already answer, so this is a 400 rather than a + * silent "ok, nothing" — a caller reaching this route with neither is a bug, + * not an ordinary miss the way an unmatched imdbId is. + */ +function seasonEpisodeFromQuery( + query: URLSearchParams, +): { season?: number; episode?: number; error?: undefined } | { error: string } { + const rawSeason = (query.get("season") ?? "").trim(); + const rawEpisode = (query.get("episode") ?? "").trim(); + if (!rawSeason) { + if (rawEpisode) return { error: "episode given without season" }; + return { error: "missing season" }; + } + const season = Number(rawSeason); + if (!Number.isInteger(season) || season < 0) return { error: "invalid season" }; + if (!rawEpisode) return { season }; + const episode = Number(rawEpisode); + if (!Number.isInteger(episode) || episode < 0) return { error: "invalid episode" }; + return { season, episode }; +} + +/** + * `GET /api/artwork?imdbId=&type=&season=&episode=` — reccd's season/episode + * artwork, proxied for the same reason `/api/title-search` is: the browser + * must never see `reccToken`. + * + * `loadConfig()` per request, not a boot snapshot, for the same reason + * `titleSearch` does it: a reccd URL can be pasted into the Accounts pane at + * any moment, and `serve --web` is a separate process from any running TUI. + */ +async function artwork(deps: WebDeps, query: URLSearchParams, accessEmail?: string): Promise { + const imdbId = (query.get("imdbId") ?? "").trim(); + // Anchored, matching parseTitleLookup's own `imdb` check above: this is + // interpolated into reccd's own query string, and an id shape is cheap to + // insist on before it gets there. + if (!/^tt\d{7,}$/.test(imdbId)) return { status: 400, json: { error: "invalid or missing imdbId" } }; + const type = query.get("type"); + if (type !== "movie" && type !== "series") { + return { status: 400, json: { error: "type must be movie or series" } }; + } + const se = seasonEpisodeFromQuery(query); + if (se.error !== undefined) return { status: 400, json: { error: se.error } }; + + const config = await (deps.loadConfigImpl ?? loadConfig)(); + const reccConfig = resolveReccConfig(config, resolveProfileId(accessEmail, resolveOwnerEmail(config))); + if (!reccConfig.reccUrl) { + const out: PublicArtwork = { status: "not-configured" }; + return { status: 200, json: out }; + } + + // `fetchArtwork` never throws and bounds itself with its own timeout, so a + // reccd that is down, hanging, or simply older than this endpoint costs + // this request that timeout and nothing else. + const result = await (deps.fetchArtworkImpl ?? fetchArtwork)(reccConfig, { + imdbId, + type, + ...(se.season !== undefined ? { season: se.season } : {}), + ...(se.episode !== undefined ? { episode: se.episode } : {}), + }); + if (!result.ok) { + const out: PublicArtwork = { status: "error", error: result.error }; + return { status: 200, json: out }; + } + // Same reasoning as /api/title's own posterUrl: downstream enforcement + // alone is not enough for a URL that is about to cross into the browser as + // an -bound fetch target, so it is checked against POSTER_HOSTS here + // too, not just wherever the fetch itself eventually happens. + const out: PublicArtwork = { + status: "ok", + posterUrl: allowedPosterUrl(result.posterUrl), + stillUrl: allowedPosterUrl(result.stillUrl), + }; + return { status: 200, json: out }; +} + /** The event types this route will forward, as a set for the body check. */ const RECC_EVENTS: ReadonlySet = new Set([ "watched", @@ -2374,6 +2458,10 @@ export async function handleWebApi( return titleSearch(deps, query, accessEmail); } + if (method === "GET" && urlPath === "/api/artwork") { + return artwork(deps, query, accessEmail); + } + if (method === "POST" && urlPath === "/api/recc-event") { return reccEvent(deps, bodyText, accessEmail); } diff --git a/src/web/static/app.ts b/src/web/static/app.ts index dd64ae6a..a488fb37 100644 --- a/src/web/static/app.ts +++ b/src/web/static/app.ts @@ -109,6 +109,7 @@ import { createPosterCache, postersApply, searchHint, + type ArtworkOutcome, type PosterOutcome, } from "./resultPosters"; import { @@ -198,7 +199,13 @@ import { prefsFromWire, type PickState, } from "./pickModel"; -import type { DownloadedResponse, PreferencesResponse, PublicQualityPrefs, PublicTitleSuggestions } from "../wire"; +import type { + DownloadedResponse, + PreferencesResponse, + PublicArtwork, + PublicQualityPrefs, + PublicTitleSuggestions, +} from "../wire"; import { emptyListState, isOpen as suggestOpen, @@ -2213,6 +2220,21 @@ const resultPosters = createPosterCache({ } }, revoke: (url) => URL.revokeObjectURL(url), + async fetchArtwork(imdbId, season, episode): Promise { + const params = new URLSearchParams({ imdbId, type: "series", season: String(season) }); + if (episode !== undefined) params.set("episode", String(episode)); + try { + const res = await fetch(`/api/artwork?${params.toString()}`, { headers: authHeaders() }); + if (!res.ok) return null; + const body = (await res.json()) as unknown; + if (!body || typeof body !== "object") return null; + const artwork = body as PublicArtwork; + if (artwork.status !== "ok") return null; + return { posterUrl: artwork.posterUrl, stillUrl: artwork.stillUrl }; + } catch { + return null; + } + }, }); /** The page's single "no OMDb key" line. The decision lives in resultPosters.ts's `searchHint`. */ @@ -2276,7 +2298,14 @@ const posterObserver: IntersectionObserver | null = posterObserver?.unobserve(entry.target); const pending = posterTargets.get(entry.target); if (pending) { - startPoster(pending.release, entry.target as HTMLElement, pending.compact, pending.group); + startPoster( + pending.release, + entry.target as HTMLElement, + pending.compact, + pending.group, + pending.season, + pending.episode, + ); } } }) @@ -2287,15 +2316,22 @@ const posterObserver: IntersectionObserver | null = // design is avoiding, just spelled differently. const posterTargets = new WeakMap< Element, - { release: string; compact: boolean; group?: string } + { release: string; compact: boolean; group?: string; season?: number; episode?: number } >(); -function startPoster(release: string, host: HTMLElement, compact: boolean, group?: string): void { +function startPoster( + release: string, + host: HTMLElement, + compact: boolean, + group?: string, + season?: number, + episode?: number, +): void { // `group` is what OMDb is asked to look the title up as. It defaults to the // search tab because that is where posters started — but the Continue-watching // rows are not part of a search, and asking for a show under whatever tab the // user last clicked would look it up as a film. - const outcome = resultPosters.want(release, group ?? searchView.group); + const outcome = resultPosters.want(release, group ?? searchView.group, season, episode); if (!(outcome instanceof Promise)) { paintPoster(host, outcome, compact); return; @@ -2327,6 +2363,8 @@ function mountResultPoster( host: HTMLElement, compact: boolean, group?: string, + season?: number, + episode?: number, ): void { const known = resultPosters.peek(release); if (known !== undefined) { @@ -2342,10 +2380,10 @@ function mountResultPoster( // hole, and the row does not resize when the image lands. host.replaceChildren(); if (posterObserver === null) { - startPoster(release, host, compact, group); + startPoster(release, host, compact, group, season, episode); return; } - posterTargets.set(host, { release, compact, group }); + posterTargets.set(host, { release, compact, group, season, episode }); posterObserver.observe(host); } @@ -2924,7 +2962,14 @@ function renderGroupRow( // The BEST MEMBER's name, not the group title: resultPosters caches by release // name, so sharing the member's key means the heading and its expanded rows // resolve to one lookup instead of two. - mountResultPoster(best.name, frame, true); + // + // `row.season`/`row.episode`, not `best`'s own: `best` is a release, and a + // release parses no season/episode of its own worth trusting here — the row + // is the thing that knows what tier of the tree it stands for, which is + // exactly what reccd's artwork lookup needs (a season heading asks for the + // season poster; an episode group asks for the episode still). + const episode = row.kind === "group" ? row.episode : undefined; + mountResultPoster(best.name, frame, true, undefined, row.season, episode); return li; } diff --git a/src/web/static/resultPosters.test.ts b/src/web/static/resultPosters.test.ts index 9baf02e3..ed1de86d 100644 --- a/src/web/static/resultPosters.test.ts +++ b/src/web/static/resultPosters.test.ts @@ -431,4 +431,80 @@ describe("createPosterCache", () => { await cache.want("Harrowgate.S03", "TV"); expect(groups).toEqual(["TV"]); }); + + it("prefers reccd's season artwork over OMDb's series poster, when a season is asked for", async () => { + const artworkCalls: Array<[string, number, number | undefined]> = []; + const { cache, blobCalls } = harness({ + fetchArtwork: async (imdbId, season, episode) => { + artworkCalls.push([imdbId, season, episode]); + return { posterUrl: "https://image.tmdb.org/season3.jpg", stillUrl: null }; + }, + }); + const outcome = await cache.want("Harrowgate.S03.1080p.WEB-DL", "TV", 3); + expect(outcome).toEqual({ kind: "poster", url: "blob:1" }); + expect(artworkCalls).toEqual([["tt3", 3, undefined]]); + expect(blobCalls).toEqual(["https://image.tmdb.org/season3.jpg"]); + }); + + it("prefers reccd's episode still over both the season poster and the series poster", async () => { + const { cache, blobCalls } = harness({ + fetchArtwork: async () => ({ + posterUrl: "https://image.tmdb.org/season3.jpg", + stillUrl: "https://image.tmdb.org/s03e04.jpg", + }), + }); + const outcome = await cache.want("Kepler.S02E04.1080p.WEB-DL", "TV", 2, 4); + expect(outcome).toEqual({ kind: "poster", url: "blob:1" }); + expect(blobCalls).toEqual(["https://image.tmdb.org/s03e04.jpg"]); + }); + + it("falls back to the series poster when reccd has no artwork for this season", async () => { + const { cache, blobCalls } = harness({ + fetchArtwork: async () => ({ posterUrl: null, stillUrl: null }), + }); + const outcome = await cache.want("Harrowgate.S03.1080p.WEB-DL", "TV", 3); + expect(outcome).toEqual({ kind: "poster", url: "blob:1" }); + expect(blobCalls).toEqual(["https://m.media-amazon.com/tinrivers.jpg"]); + }); + + it("falls back to the series poster when reccd's artwork lookup itself fails", async () => { + const { cache, blobCalls } = harness({ + fetchArtwork: async () => null, + }); + const outcome = await cache.want("Harrowgate.S03.1080p.WEB-DL", "TV", 3); + expect(outcome).toEqual({ kind: "poster", url: "blob:1" }); + expect(blobCalls).toEqual(["https://m.media-amazon.com/tinrivers.jpg"]); + }); + + it("never asks reccd for artwork when no season is given — a plain film or a show's own heading", async () => { + let asked = false; + const { cache } = harness({ + fetchArtwork: async () => { + asked = true; + return null; + }, + }); + await cache.want("Kestrel.2010.1080p.BluRay.x264", "Movies"); + expect(asked).toBe(false); + }); + + it("never asks reccd for artwork when the deps do not provide it — a build without reccd wiring", async () => { + const { cache, blobCalls } = harness(); + const outcome = await cache.want("Harrowgate.S03.1080p.WEB-DL", "TV", 3); + expect(outcome).toEqual({ kind: "poster", url: "blob:1" }); + expect(blobCalls).toEqual(["https://m.media-amazon.com/tinrivers.jpg"]); + }); + + it("never asks reccd for artwork when OMDb found no imdbId to ask about", async () => { + let asked = false; + const { cache } = harness({ + fetchMeta: async () => ({ status: "ok", imdbId: null, plot: null, posterUrl: "https://m.media-amazon.com/x.jpg" }), + fetchArtwork: async () => { + asked = true; + return null; + }, + }); + await cache.want("Harrowgate.S03.1080p.WEB-DL", "TV", 3); + expect(asked).toBe(false); + }); }); diff --git a/src/web/static/resultPosters.ts b/src/web/static/resultPosters.ts index 5c1f6e81..e4770782 100644 --- a/src/web/static/resultPosters.ts +++ b/src/web/static/resultPosters.ts @@ -34,6 +34,12 @@ export type PosterOutcome = | { kind: "no-key" } | { kind: "none" }; +/** reccd's season/episode artwork, when there is any. Never both fields absent AND kind "poster" — see want()'s season/episode handling. */ +export interface ArtworkOutcome { + posterUrl: string | null; + stillUrl: string | null; +} + /** The two round trips and the cleanup, injected so this is testable without a DOM. */ export interface PosterDeps { /** `GET /api/title?release=&group=`. Null for any failure. */ @@ -42,6 +48,16 @@ export interface PosterDeps { fetchBlob(posterUrl: string): Promise; /** `URL.revokeObjectURL`. */ revoke(url: string): void; + /** + * `GET /api/artwork?imdbId=&type=series&season=&episode=` — reccd's season + * poster / episode still, which OMDb (fetchMeta's provider) has no field + * for at all. OPTIONAL: a torlink build without reccd wiring, or one where + * reccd predates this endpoint, simply never has a result to prefer, and + * the series poster fetchMeta already returned stands as the answer — + * exactly as it did before this existed. Null for any failure, matching + * fetchMeta's own contract. + */ + fetchArtwork?(imdbId: string, season: number, episode?: number): Promise; } export interface PosterCache { @@ -49,8 +65,20 @@ export interface PosterCache { * The outcome for a release: synchronously when it is already known, a promise * when it has to be looked up. Concurrent asks for one release share a single * lookup. + * + * `season`/`episode` are for a SEASON or episode-GROUP row specifically — a + * plain film, a show's own top-level heading, and any row without a season + * to name should omit them, and get exactly today's OMDb-series-poster + * behaviour. `release` is still what keys the cache: a season's own pack + * release and an episode's own release each already have a distinct name, + * so nothing here needs season/episode folded into the cache key too. */ - want(release: string, group: string): PosterOutcome | Promise; + want( + release: string, + group: string, + season?: number, + episode?: number, + ): PosterOutcome | Promise; /** The settled outcome for a release, or undefined. No fetching. */ peek(release: string): PosterOutcome | undefined; /** Drop everything and revoke every blob. Called when a new search starts. */ @@ -199,13 +227,29 @@ export function createPosterCache(deps: PosterDeps): PosterCache { return posterFetch; } - async function lookup(release: string, group: string, forGeneration: number): Promise { + async function lookup( + release: string, + group: string, + forGeneration: number, + season?: number, + episode?: number, + ): Promise { const meta = await deps.fetchMeta(release, group); if (!meta) return { kind: "none" }; if (meta.status === "no-key") return { kind: "no-key" }; - if (meta.status !== "ok" || !meta.posterUrl) return { kind: "none" }; + if (meta.status !== "ok") return { kind: "none" }; + + let posterUrl = meta.posterUrl; + // reccd's own season poster / episode still, preferred over OMDb's + // series-wide poster when there is one. `meta.imdbId` gates this: without + // it there is nothing to ask reccd for artwork about. + if (season !== undefined && meta.imdbId && deps.fetchArtwork) { + const art = await deps.fetchArtwork(meta.imdbId, season, episode); + if (art?.posterUrl) posterUrl = art.posterUrl; + if (episode !== undefined && art?.stillUrl) posterUrl = art.stillUrl; + } + if (!posterUrl) return { kind: "none" }; - const posterUrl = meta.posterUrl; const existing = blobs.get(posterUrl); // Only valid if it belongs to this generation — clear() emptied the map, so // a hit here is necessarily current. @@ -217,14 +261,14 @@ export function createPosterCache(deps: PosterDeps): PosterCache { } return { - want(release, group) { + want(release, group, season, episode) { const hit = settled.get(release); if (hit !== undefined) return hit; const inflight = pending.get(release); if (inflight !== undefined) return inflight; const forGeneration = generation; - const promise = lookup(release, group, forGeneration) + const promise = lookup(release, group, forGeneration, season, episode) // Every failure path ends at a labelled frame. A throw here would leave // the frame saying "Loading" for the life of the page. .catch((): PosterOutcome => ({ kind: "none" })) diff --git a/src/web/wire.ts b/src/web/wire.ts index 97dc61e3..4368c546 100644 --- a/src/web/wire.ts +++ b/src/web/wire.ts @@ -825,6 +825,19 @@ export type PublicTitleSuggestions = | { status: "not-configured" } | { status: "error"; error: string }; +/** + * The body of `GET /api/artwork?imdbId=&type=&season=&episode=`. + * + * Same three-way shape as `PublicTitleSuggestions`, for the same reason: "no + * reccd" and "reccd doesn't have this endpoint yet" both have to be things the + * poster cache can tell apart from "checked, and there is nothing here" — + * `"ok"` with both fields null IS the last one, and is not an error. + */ +export type PublicArtwork = + | { status: "ok"; posterUrl: string | null; stillUrl: string | null } + | { status: "not-configured" } + | { status: "error"; error: string }; + /** * The event types `POST /api/recc-event` will forward. * From 92c9ac1e4d4fc76e4e44783044b18232cd24669d Mon Sep 17 00:00:00 2001 From: Ash Date: Tue, 1 Sep 2026 15:13:39 +0100 Subject: [PATCH 2/2] fix: handle the show row kind added by the merged grouping PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto main after #105 merged, which added a "show" GroupRow kind sitting above season rows. It has no season/episode of its own, so the artwork season lookup must skip it rather than reading a field that doesn't exist on it — it keeps the series poster fetchMeta already gives. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011QTsg7ie21bP9JpEPFMkyP --- src/web/static/app.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/web/static/app.ts b/src/web/static/app.ts index a488fb37..c5a783a0 100644 --- a/src/web/static/app.ts +++ b/src/web/static/app.ts @@ -2967,9 +2967,12 @@ function renderGroupRow( // release parses no season/episode of its own worth trusting here — the row // is the thing that knows what tier of the tree it stands for, which is // exactly what reccd's artwork lookup needs (a season heading asks for the - // season poster; an episode group asks for the episode still). + // season poster; an episode group asks for the episode still). A show row + // spans every season, so it has neither — reccd has no single artwork to + // return for that, and it keeps the series poster fetchMeta already gives it. + const season = row.kind === "show" ? undefined : row.season; const episode = row.kind === "group" ? row.episode : undefined; - mountResultPoster(best.name, frame, true, undefined, row.season, episode); + mountResultPoster(best.name, frame, true, undefined, season, episode); return li; }