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..c5a783a0 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,17 @@ 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). 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, 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. *