diff --git a/README.md b/README.md
index c608f88e..ea5c08e1 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,12 @@ Type what you're looking for and press Enter. Results stream in from every sourc
+Press `i` for a closer look at whatever's highlighted — poster, rating, cast and the rest, right beside the list. `→` opens it up to read and scroll through, `←` sends you back to browsing.
+
+
+
+
+
## Your downloads
Active downloads sit up top with their progress, speed, and time left; when one finishes it drops into Recently downloaded just below, so the list stays tidy. Everything's still there when you come back, and anything interrupted picks up where it left off.
diff --git a/package-lock.json b/package-lock.json
index dd13f04f..ffaad950 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"create-torrent": "^6.1.2",
"env-paths": "^4.0.0",
"ink": "^7.0.5",
+ "jpeg-js": "^0.4.4",
"parse-torrent": "^11.0.21",
"react": "^19.2.7",
"uint8-util": "2.2.6",
@@ -3132,6 +3133,11 @@
"node": ">=10"
}
},
+ "node_modules/jpeg-js": {
+ "version": "0.4.4",
+ "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
+ "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="
+ },
"node_modules/junk": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/junk/-/junk-4.0.1.tgz",
diff --git a/package.json b/package.json
index 48980cf4..e76e14be 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,7 @@
"create-torrent": "^6.1.2",
"env-paths": "^4.0.0",
"ink": "^7.0.5",
+ "jpeg-js": "^0.4.4",
"parse-torrent": "^11.0.21",
"react": "^19.2.7",
"uint8-util": "2.2.6",
diff --git a/preview/browse.svg b/preview/browse.svg
index 951a1c82..0e7b8b08 100644
--- a/preview/browse.svg
+++ b/preview/browse.svg
@@ -117,9 +117,9 @@
Sort/Search
- tab
- Switch
- ?
- Keys
+ f
+ Filter
+ tab
+ Switch …
\ No newline at end of file
diff --git a/preview/downloads.svg b/preview/downloads.svg
index 55d62483..9601b3b4 100644
--- a/preview/downloads.svg
+++ b/preview/downloads.svg
@@ -48,8 +48,8 @@
❯↓Dune: Part Two (2024) [2160p BluRay]
- 7.82 GB
- YTS
+ 7.82 GB
+ YTSMovies
diff --git a/preview/info.svg b/preview/info.svg
new file mode 100644
index 00000000..daf0de2f
--- /dev/null
+++ b/preview/info.svg
@@ -0,0 +1,515 @@
+
+
\ No newline at end of file
diff --git a/scripts/ansi-to-svg.ts b/scripts/ansi-to-svg.ts
index 0a2e6cf6..f0961e0b 100644
--- a/scripts/ansi-to-svg.ts
+++ b/scripts/ansi-to-svg.ts
@@ -229,10 +229,21 @@ export function ansiToSvg(frame: string, opts: AnsiToSvgOptions): string {
const top = baseline - FONT_SIZE;
const half = LINE_H / 2;
const dim = st.dim ? ` fill-opacity="${DIM_OPACITY}"` : "";
+ // Same convention as the general path below: a half-block glyph only paints one half of
+ // its cell, so the other half has to come from the background colour, exactly as a real
+ // terminal composites it (bg fills the whole cell, the glyph's fg sits on top of half of
+ // it). Without this a run with a distinct background — a two-tone poster row, not a
+ // single-colour progress-bar fill — silently loses whichever half the glyph doesn't cover.
+ const boxFill = st.inverse ? fg : st.bg;
const bcells = Array.from(text);
for (let k = 0; k < n; k++) {
const ch = bcells[k]!;
const cellLeft = PAD + (col + k) * CHAR_W;
+ if (boxFill) {
+ out.push(
+ ` `,
+ );
+ }
let rx = cellLeft;
let ry = top;
let rw = CHAR_W;
diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx
index 39aa8d1d..0e627142 100644
--- a/scripts/render-previews-impl.tsx
+++ b/scripts/render-previews-impl.tsx
@@ -5,22 +5,37 @@ import React from "react";
import { render } from "ink-testing-library";
import { Box, Text } from "ink";
import { StoreContext, type Store } from "../src/ui/store";
-import { COLOR, ICON, SOURCE_STYLE } from "../src/ui/theme";
+import { COLOR, ICON, RULE, SOURCE_STYLE, lerpHex } from "../src/ui/theme";
import { Logo } from "../src/ui/components/Logo";
import { Rule } from "../src/ui/components/Rule";
import { Footer } from "../src/ui/components/Footer";
import { Sidebar, RAIL_WIDTH } from "../src/ui/components/Sidebar";
import { SearchBar } from "../src/ui/components/SearchBar";
import { Panel } from "../src/ui/components/Panel";
+import { Poster } from "../src/ui/components/Poster";
import { Downloads } from "../src/ui/components/Downloads";
import { footerHints } from "../src/ui/keymap";
+import { planPaneLines } from "../src/ui/paneCard";
+import {
+ COLUMN_GAP,
+ MAX_TEXT_COLS,
+ PANE_GAP,
+ POSTER_H,
+ POSTER_W,
+ posterBudget,
+ previewLayout,
+ splitTextCols,
+} from "../src/ui/previewLayout";
import { sourcesByGroup } from "../src/sources/registry";
import { cleanText, formatBytes, formatRelative } from "../src/util/format";
import { ansiToSvg, type AnsiToSvgOptions } from "./ansi-to-svg";
+import { fitCells } from "../src/meta/image";
import type { Config } from "../src/config/config";
import type { DownloadQueue } from "../src/download/queue";
import type { QueueItem, SeedItem } from "../src/download/types";
import type { HistoryItem } from "../src/download/history";
+import type { Meta } from "../src/meta/types";
+import type { PosterCells } from "../src/meta/image";
import type { TorrentResult } from "../src/sources/types";
const COLS = 80;
@@ -98,6 +113,8 @@ function makeStore(
setSeedFocus: noop,
resultFocus: null,
setResultFocus: noop,
+ previewOpen: false,
+ setPreviewOpen: noop,
startDownload: noop,
requestDownloadTo: noop,
copyMagnet: noop,
@@ -266,3 +283,198 @@ save(
,
{ shimmer: true },
);
+
+// previewLayout only gives the results list a pane once contentWidth reaches 73, and only draws
+// poster art in it from 86 — the 80-column COLS every other scenario uses never gets there. This
+// one runs wider, on purpose, so the info pane has something to show.
+const META_COLS = 120;
+const META_CONTENT_WIDTH = Math.max(24, META_COLS - RAIL_WIDTH - 3);
+const META_RULE_WIDTH = Math.max(10, META_COLS - 2);
+const META_PANEL_H = 18;
+const metaInnerRows = Math.max(0, META_PANEL_H - 1);
+
+// Focused (`region: "preview"`, the state the → key puts the pane in), not the browsing state the
+// list keeps by default: reading the pane side by side with a full-size poster is what focusing it
+// is *for*. It is also the state most likely to actually show a plot — unfocused, this fixture's
+// director plus a three-name cast alone can saturate the browsing tier's fixed text budget before
+// the plot is ever reached, while focused the budget is infinite and the whole synopsis is built.
+const metaPane = previewLayout(META_CONTENT_WIDTH, true, metaInnerRows);
+if (metaPane === null) {
+ throw new Error(`preview: "info" scenario's ${META_COLS} columns are too narrow for the pane`);
+}
+
+const META: Meta = {
+ imdbId: "tt15398776",
+ kind: "movie",
+ title: "Oppenheimer",
+ year: "2023",
+ rating: "8.3",
+ runtime: "180 min",
+ genres: ["Biography", "Drama", "History"],
+ director: ["Christopher Nolan"],
+ cast: ["Cillian Murphy", "Emily Blunt", "Matt Damon"],
+ plot:
+ "The story of J. Robert Oppenheimer's role in the development of the atomic bomb during World War II.",
+};
+
+// Panel's frame (border 2 + paddingX 2), same arithmetic MetaPane does for its own inner width.
+const metaInner = Math.max(1, metaPane.pane - 4);
+const metaBudget = posterBudget(metaPane.pane, metaInnerRows, true);
+if (metaBudget === null) {
+ throw new Error(`preview: "info" scenario's panel is too short to budget poster rows`);
+}
+
+// The pane's own natural art size — the same question MetaPane asks usePoster (and, under it,
+// fitCells) once a real poster decodes. The preview has no JPEG to decode, so this mirrors the one
+// rendition previewLayout.ts itself reasons the split from (POSTER_W / POSTER_H, imported rather
+// than copied) rather than assuming the budget's cell grid is the picture's actual shape.
+const metaArt = fitCells(POSTER_W, POSTER_H, metaBudget.cols, metaBudget.rows);
+
+const metaCardCols = splitTextCols(metaInner, metaArt.cols);
+if (metaCardCols === null) {
+ throw new Error(`preview: "info" scenario's ${META_COLS} columns can't seat the card beside the poster`);
+}
+const metaTextWidth = Math.min(metaCardCols, MAX_TEXT_COLS);
+
+/**
+ * A gradient standing in for a decoded poster — the previews never touch the network, so there is
+ * no JPEG to decode. Built with the same lerpHex the shimmer sheen uses, staying inside the app's
+ * own palette: the bottom is COLOR.bright as-is, the top is COLOR.accent darkened toward RULE, so
+ * every colour this ramp touches is already exported from theme.ts.
+ */
+function posterMock(cols: number, rows: number): PosterCells {
+ const top = lerpHex(COLOR.accent, RULE, 0.75);
+ const bottom = COLOR.bright;
+ const lines = Array.from({ length: rows }, (_, row) => {
+ const t = rows <= 1 ? 0 : row / (rows - 1);
+ const fg = lerpHex(top, bottom, t);
+ const bg = lerpHex(top, bottom, Math.min(1, t + 1 / (rows * 2)));
+ return [{ fg, bg, n: cols }];
+ });
+ return { cols, rows, lines };
+}
+
+// The app's own card planner, flattened to one entry per terminal row exactly as MetaPane
+// flattens it. The preview is a screenshot of the pane, so it has to be laid out by the thing
+// that lays the pane out — a second copy of the card here would drift the moment either changes.
+// Focused, the budget is infinite (MetaPane.tsx's own textBudget branch): the window that would
+// normally cut it is the scroll offset, and this scenario is sized so nothing is off screen.
+const metaTextRows = planPaneLines(META, metaTextWidth, Number.POSITIVE_INFINITY).flatMap((l) =>
+ l.text.split("\n").map((text, i) => ({ key: `${l.key}:${i}`, text, tone: l.tone })),
+);
+
+// The guarantee this scenario exists to show: the whole card fits beside the poster with nothing
+// left to scroll to. If a future fixture change ever pushes it past the pane's height, that is the
+// bug the earlier attempt at this task caught by hand — catch it here instead of shipping a
+// screenshot with a hidden "↓ more".
+const metaTotalRows = Math.max(metaArt.rows, metaTextRows.length);
+if (metaTotalRows > metaInnerRows) {
+ throw new Error(
+ `preview: "info" scenario's card no longer fits without scrolling (${metaTotalRows} rows > ${metaInnerRows})`,
+ );
+}
+
+save(
+ "info",
+ makeStore({
+ section: "all",
+ contentWidth: META_CONTENT_WIDTH,
+ listRows: 14,
+ cols: META_COLS,
+ rows: 24,
+ region: "preview",
+ }),
+
+
+
+
+
+ {/* +5: SearchBar's own Panel (a label row + a 2-row box) plus the 1-row gap above this
+ Panel's own label row — everything this column holds above the results Panel, which a
+ fixed outer height has to leave room for. Too little and Yoga shrinks the Panel instead
+ of just cropping blank space, and a shrunk fixed-height Panel drops its own overflowing
+ content rather than showing a clean edge. */}
+
+
+
+ {}} />
+
+
+ newest across all sources
+
+
+
+ #
+ Name
+ Size
+ Seed:Lch
+ Src
+
+ {browseResults.map((r, i) => {
+ const here = i === 0;
+ const ss = SOURCE_STYLE[r.source];
+ return (
+
+
+ {here ? ICON.pointer : ""}
+
+
+ {i + 1}
+
+
+
+ {cleanText(r.name)}
+
+
+ {showStats ? (
+ <>
+
+ {r.sizeBytes > 0 ? formatBytes(r.sizeBytes) : "-"}
+
+
+ 0 ? COLOR.good : undefined} dimColor={r.seeders === 0}>
+ {r.seeders || r.leechers ? `${r.seeders}:${r.leechers}` : "-"}
+
+
+ >
+ ) : (
+
+ {formatRelative(r.added) || "-"}
+
+ )}
+
+
+ {ss.tag}
+
+
+
+ );
+ })}
+
+
+
+
+ {/* Side by side, exactly as MetaPane's own `split` branch draws it: the art keeps its
+ natural width and the card runs the full height beside it, rather than the
+ budget's whole row allowance stretching a picture no decoder actually returned. */}
+
+
+
+
+
+ {metaTextRows.map((l) => (
+
+ {l.text}
+
+ ))}
+
+
+
+
+
+
+
+
+ ,
+ { cols: META_COLS },
+);
diff --git a/src/meta/cinemeta.test.ts b/src/meta/cinemeta.test.ts
new file mode 100644
index 00000000..0582463f
--- /dev/null
+++ b/src/meta/cinemeta.test.ts
@@ -0,0 +1,474 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+ CINEMETA,
+ fetchMeta,
+ mapCatalog,
+ mapMeta,
+ metaUrl,
+ pickEpisode,
+ posterUrlFor,
+ searchCatalog,
+ searchUrl,
+} from "./cinemeta";
+import { fetchResilient } from "../util/net";
+
+vi.mock("../util/net", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, fetchResilient: vi.fn() };
+});
+
+const mockFetch = vi.mocked(fetchResilient);
+
+// Trimmed captures of real Cinemeta responses. Long lists are cut down; field names and value
+// shapes are verbatim, including `director: null` on series and the poster host.
+const MATRIX_POSTER =
+ "https://m.media-amazon.com/images/M/MV5BN2NmN2VhMTQtMDNiOS00NDlhLTliMjgtODE2ZTY0ODQyNDRhXkEyXkFqcGc@._V1_SX300.jpg";
+
+const catalogBody = {
+ metas: [
+ {
+ id: "tt0133093",
+ imdb_id: "tt0133093",
+ type: "movie",
+ name: "The Matrix",
+ releaseInfo: "1999",
+ poster: MATRIX_POSTER,
+ },
+ {
+ id: "tt10838180",
+ imdb_id: "tt10838180",
+ type: "movie",
+ name: "The Matrix Resurrections",
+ releaseInfo: "2021",
+ },
+ ],
+};
+
+const movieBody = {
+ meta: {
+ id: "tt0133093",
+ imdb_id: "tt0133093",
+ type: "movie",
+ name: "The Matrix",
+ releaseInfo: "1999",
+ imdbRating: "8.7",
+ runtime: "136 min",
+ genres: ["Action", "Sci-Fi"],
+ cast: ["Keanu Reeves", "Laurence Fishburne", "Carrie-Anne Moss"],
+ director: ["Lana Wachowski", "Lilly Wachowski"],
+ description: "A computer hacker learns from mysterious rebels about the true nature of his reality.",
+ poster: MATRIX_POSTER,
+ },
+};
+
+const seriesBody = {
+ meta: {
+ id: "tt0903747",
+ imdb_id: "tt0903747",
+ type: "series",
+ name: "Breaking Bad",
+ releaseInfo: "2008–2013",
+ imdbRating: "9.5",
+ runtime: "49 min",
+ genres: ["Crime", "Drama", "Thriller"],
+ cast: ["Bryan Cranston", "Aaron Paul", "Anna Gunn"],
+ director: null,
+ description: "A chemistry teacher diagnosed with cancer turns to manufacturing methamphetamine.",
+ poster: "https://images.metahub.space/poster/medium/tt0903747/img",
+ videos: [
+ {
+ id: "tt0903747:5:13",
+ season: 5,
+ number: 13,
+ episode: 13,
+ title: "To'hajiilee",
+ overview: "Jesse's plan to hit Walt where he really lives is a success.",
+ released: "2013-09-08T00:00:00.000Z",
+ },
+ {
+ id: "tt0903747:5:14",
+ season: 5,
+ number: 14,
+ episode: 14,
+ title: "Ozymandias",
+ overview: "Walt goes on the run.",
+ released: "2013-09-15T00:00:00.000Z",
+ },
+ ],
+ },
+};
+
+// Cinemeta answers HTTP 200 with this for an id it does not know.
+const stubBody = {
+ meta: {
+ id: "tt99999999",
+ type: "movie",
+ behaviorHints: { defaultVideoId: null, hasScheduledVideos: false },
+ },
+};
+
+const ok = (body: unknown, headers: Record = {}): Response =>
+ ({
+ ok: true,
+ status: 200,
+ headers: new Headers(headers),
+ json: vi.fn(async () => body),
+ }) as unknown as Response;
+
+beforeEach(() => {
+ mockFetch.mockReset();
+});
+
+describe("searchUrl", () => {
+ it("percent-encodes path separators and fragments in the query", () => {
+ expect(searchUrl("movie", "a/b#c")).toBe(`${CINEMETA}/catalog/movie/top/search=a%2Fb%23c.json`);
+ });
+
+ it("keeps a dot verbatim, which cannot break out of the segment once slashes are encoded", () => {
+ // encodeURIComponent leaves "." alone by design. That is safe here because a traversal needs
+ // a separator, and every "/" is escaped — "../.." stays one opaque segment.
+ expect(searchUrl("movie", "Mr. Robot")).toBe(`${CINEMETA}/catalog/movie/top/search=Mr.%20Robot.json`);
+ expect(searchUrl("series", "../../etc/passwd")).toBe(
+ `${CINEMETA}/catalog/series/top/search=..%2F..%2Fetc%2Fpasswd.json`,
+ );
+ });
+
+ it("routes by kind", () => {
+ expect(searchUrl("series", "breaking bad")).toContain("/catalog/series/top/");
+ });
+
+ // A "\ud800" escape in a tracker payload survives JSON.parse and cleanText as a lone surrogate,
+ // and encodeURIComponent throws URIError on one. These helpers are exported and called directly,
+ // so the guarantee has to hold here, not only inside the callers' try/catch.
+ it("drops a lone surrogate instead of throwing", () => {
+ expect(searchUrl("movie", "\uD800")).toBe(`${CINEMETA}/catalog/movie/top/search=.json`);
+ expect(searchUrl("movie", "\uDFFF")).toBe(`${CINEMETA}/catalog/movie/top/search=.json`);
+ expect(searchUrl("movie", "a\uD800b")).toBe(`${CINEMETA}/catalog/movie/top/search=ab.json`);
+ expect(searchUrl("movie", "a\uDC00b")).toBe(`${CINEMETA}/catalog/movie/top/search=ab.json`);
+ // Reversed pair: each half is unpaired in context, so both go.
+ expect(searchUrl("movie", "a\uDC00\uD800b")).toBe(`${CINEMETA}/catalog/movie/top/search=ab.json`);
+ });
+
+ it("keeps a well-formed astral character", () => {
+ expect(searchUrl("movie", "Dune \u{1F600}")).toBe(
+ `${CINEMETA}/catalog/movie/top/search=Dune%20%F0%9F%98%80.json`,
+ );
+ });
+});
+
+describe("metaUrl", () => {
+ it("builds the meta document url for a kind and id", () => {
+ expect(metaUrl("movie", "tt0133093")).toBe(`${CINEMETA}/meta/movie/tt0133093.json`);
+ expect(metaUrl("series", "tt0903747")).toBe(`${CINEMETA}/meta/series/tt0903747.json`);
+ });
+
+ it("drops a lone surrogate instead of throwing", () => {
+ expect(metaUrl("movie", "\uD800")).toBe(`${CINEMETA}/meta/movie/.json`);
+ expect(metaUrl("movie", "\uDFFF")).toBe(`${CINEMETA}/meta/movie/.json`);
+ expect(metaUrl("movie", "tt013\uD8003093")).toBe(`${CINEMETA}/meta/movie/tt0133093.json`);
+ expect(metaUrl("series", "tt090\uDC003747")).toBe(`${CINEMETA}/meta/series/tt0903747.json`);
+ });
+
+ it("keeps a well-formed astral character", () => {
+ expect(metaUrl("movie", "tt0133093\u{1F600}")).toBe(
+ `${CINEMETA}/meta/movie/tt0133093%F0%9F%98%80.json`,
+ );
+ });
+});
+
+describe("posterUrlFor", () => {
+ it("rewrites a known Amazon rendition down to a thumbnail", () => {
+ const raw = "https://m.media-amazon.com/images/M/MV5BabcXkFqcGc@._V1_SX250.jpg";
+ expect(posterUrlFor("tt0133093", raw)).toBe(
+ "https://m.media-amazon.com/images/M/MV5BabcXkFqcGc@._V1_SX120.jpg",
+ );
+ });
+
+ it("falls back to metahub for any other poster, never echoing the raw url", () => {
+ const metahub = "https://images.metahub.space/poster/small/tt0133093/img?format=jpeg";
+ expect(posterUrlFor("tt0133093")).toBe(metahub);
+ expect(posterUrlFor("tt0133093", "https://example.invalid/p.webp")).toBe(metahub);
+ // Right host, wrong shape: an unanchored match would let this through.
+ expect(posterUrlFor("tt0133093", "https://m.media-amazon.com/images/M/x._V1_SX250.jpg?q=1")).toBe(
+ metahub,
+ );
+ expect(posterUrlFor("tt0133093", "http://m.media-amazon.com/images/M/x._V1_SX250.jpg")).toBe(metahub);
+ });
+
+ it("returns nothing when the id would not be safe in a url path", () => {
+ expect(posterUrlFor("../../etc")).toBeUndefined();
+ });
+});
+
+describe("mapCatalog", () => {
+ it("maps catalog rows to hits", () => {
+ expect(mapCatalog(catalogBody, "movie")).toEqual([
+ { imdbId: "tt0133093", name: "The Matrix", releaseInfo: "1999", kind: "movie" },
+ { imdbId: "tt10838180", name: "The Matrix Resurrections", releaseInfo: "2021", kind: "movie" },
+ ]);
+ });
+
+ it("drops rows with no usable id or name instead of defaulting them", () => {
+ const hits = mapCatalog(
+ { metas: [{ id: "kitsu:42", name: "Some Anime" }, { id: "tt0133093" }, null, "x"] },
+ "movie",
+ );
+ expect(hits).toEqual([]);
+ });
+
+ it("returns [] for a body that is not a catalog", () => {
+ expect(mapCatalog(undefined, "movie")).toEqual([]);
+ expect(mapCatalog({}, "movie")).toEqual([]);
+ expect(mapCatalog({ metas: "nope" }, "movie")).toEqual([]);
+ expect(mapCatalog([], "movie")).toEqual([]);
+ });
+});
+
+describe("mapMeta", () => {
+ it("maps a movie", () => {
+ const meta = mapMeta(movieBody, "movie");
+ expect(meta).toMatchObject({
+ imdbId: "tt0133093",
+ kind: "movie",
+ title: "The Matrix",
+ year: "1999",
+ rating: "8.7",
+ runtime: "136 min",
+ genres: ["Action", "Sci-Fi"],
+ director: ["Lana Wachowski", "Lilly Wachowski"],
+ });
+ expect(meta?.plot).toContain("computer hacker");
+ // Never the raw poster: it is a 300px-wide rendition on a host we only trust in one shape.
+ expect(meta?.posterUrl).toBe(
+ "https://m.media-amazon.com/images/M/MV5BN2NmN2VhMTQtMDNiOS00NDlhLTliMjgtODE2ZTY0ODQyNDRhXkEyXkFqcGc@._V1_SX120.jpg",
+ );
+ });
+
+ it("returns null for the http-200 stub Cinemeta sends for an unknown id", () => {
+ expect(mapMeta(stubBody, "movie")).toBeNull();
+ });
+
+ it("returns null for anything that is not a meta document", () => {
+ expect(mapMeta(null, "movie")).toBeNull();
+ expect(mapMeta({}, "movie")).toBeNull();
+ expect(mapMeta({ meta: "x" }, "movie")).toBeNull();
+ expect(mapMeta({ meta: { name: "No Id Here" } }, "movie")).toBeNull();
+ });
+
+ it("survives director: null, which is what series always send", () => {
+ const meta = mapMeta(seriesBody, "series");
+ expect(meta?.director).toEqual([]);
+ expect(meta?.year).toBe("2008–2013");
+ expect(meta?.kind).toBe("series");
+ // The series poster is on metahub but in the wrong size and format, so it is rebuilt.
+ expect(meta?.posterUrl).toBe("https://images.metahub.space/poster/small/tt0903747/img?format=jpeg");
+ });
+
+ it("caps lists and plot length so one payload cannot flood the pane", () => {
+ const meta = mapMeta(
+ {
+ meta: {
+ id: "tt0133093",
+ name: "Capped",
+ genres: Array.from({ length: 20 }, (_, i) => `g${i}`),
+ cast: Array.from({ length: 40 }, (_, i) => `c${i}`),
+ director: Array.from({ length: 9 }, (_, i) => `d${i}`),
+ description: "x".repeat(2000),
+ },
+ },
+ "movie",
+ );
+ expect(meta?.genres).toHaveLength(6);
+ expect(meta?.cast).toHaveLength(12);
+ expect(meta?.director).toHaveLength(3);
+ expect(meta?.plot).toHaveLength(800);
+ });
+
+ // The list caps above bound how many entries survive, not how long one entry may be, and
+ // MAX_META_BYTES lets a 3 MB body through — so without a cap inside text() a single field is
+ // free to be the whole body. Both tests below are about the cap being at the funnel: they assert
+ // the length that leaves mapMeta, and that producing it did not cost a walk over the input.
+ describe("caps the length of a single remote string", () => {
+ // 2.9 MB, the size of a body that passes withinSizeCap. cleanText is linear, so cleaning this
+ // before slicing to MAX_PLOT costs ~295 ms against ~0.25 ms for slicing first; the assertion
+ // is deliberately an order of magnitude looser than that gap so it cannot flake on slow CI.
+ const HUGE = "lorem ipsum dolor sit amet ".repeat(112_000);
+
+ it("truncates a description without cleaning the whole thing first", () => {
+ const started = performance.now();
+ const meta = mapMeta({ meta: { id: "tt0133093", name: "Huge", description: HUGE } }, "movie");
+ const elapsed = performance.now() - started;
+
+ expect(meta?.plot).toHaveLength(800);
+ expect(meta?.plot?.startsWith("lorem ipsum")).toBe(true);
+ expect(elapsed).toBeLessThan(50);
+ });
+
+ it("caps a pathological cast entry before it can reach the pane's word wrapper", () => {
+ // One 1 MB token. Nothing overflows the pane — planPaneLines admits a block all or nothing,
+ // so the row is dropped — but wordWrapLines still walks it on every single render, which is
+ // ~176 ms per frame. Capping here is what keeps that off the render path entirely.
+ const meta = mapMeta(
+ {
+ meta: {
+ id: "tt0133093",
+ name: "Huge",
+ cast: ["a".repeat(1_000_000), "Keanu Reeves"],
+ genres: ["b".repeat(1_000_000)],
+ },
+ },
+ "movie",
+ );
+
+ for (const s of [...(meta?.cast ?? []), ...(meta?.genres ?? [])]) {
+ expect(s.length).toBeLessThanOrEqual(1500);
+ }
+ // The cap trims the offender, it does not drop it or the entries after it.
+ expect(meta?.cast?.[1]).toBe("Keanu Reeves");
+ });
+
+ it("leaves every legitimate field untouched", () => {
+ // Nothing Cinemeta really sends comes near the cap: the plot is cut to 800 right after, and
+ // the rest are names, years and runtimes.
+ const meta = mapMeta(movieBody, "movie");
+ expect(meta?.title).toBe("The Matrix");
+ expect(meta?.cast).toEqual(["Keanu Reeves", "Laurence Fishburne", "Carrie-Anne Moss"]);
+ expect(meta?.plot).toBe(movieBody.meta.description);
+ });
+
+ it("answers undefined for a field that is only whitespace up to the cap", () => {
+ // The blank check reads the capped string, so this cannot come back as "Untitled".
+ const meta = mapMeta(
+ { meta: { id: "tt0133093", name: "Padded", description: `${" ".repeat(2000)}real plot` } },
+ "movie",
+ );
+ expect(meta?.plot).toBeUndefined();
+ });
+ });
+
+ it("cleans terminal-hostile characters out of every string it keeps", () => {
+ const meta = mapMeta(
+ {
+ meta: {
+ id: "tt0133093",
+ // A hijacked provider could ship an OSC/CSI sequence in any of these fields.
+ name: "The \u001b[31mMatrix\u001b[0m",
+ genres: [" Action\u0007 ", "", " ", "Sci-Fi"],
+ description: "two\nlines\u200b here",
+ },
+ },
+ "movie",
+ );
+ expect(meta?.title).toBe("The [31mMatrix[0m");
+ // Blank entries are dropped rather than turning into cleanText's "Untitled" placeholder.
+ expect(meta?.genres).toEqual(["Action", "Sci-Fi"]);
+ // Junk code points are deleted, not replaced by a space, so the newline leaves no gap.
+ expect(meta?.plot).toBe("twolines here");
+ });
+});
+
+describe("pickEpisode", () => {
+ it("finds S05E14 in a videos array", () => {
+ expect(pickEpisode(seriesBody, 5, 14)).toEqual({
+ season: 5,
+ number: 14,
+ title: "Ozymandias",
+ overview: "Walt goes on the run.",
+ });
+ });
+
+ it("accepts a bare meta object as well as a whole response body", () => {
+ expect(pickEpisode(seriesBody.meta, 5, 13)?.title).toBe("To'hajiilee");
+ });
+
+ it("returns undefined when the episode is absent or the shape is wrong", () => {
+ expect(pickEpisode(seriesBody, 5, 99)).toBeUndefined();
+ expect(pickEpisode(seriesBody, 1, 14)).toBeUndefined();
+ expect(pickEpisode(movieBody, 1, 1)).toBeUndefined();
+ expect(pickEpisode(null, 1, 1)).toBeUndefined();
+ expect(pickEpisode({ videos: "nope" }, 1, 1)).toBeUndefined();
+ });
+});
+
+describe("searchCatalog", () => {
+ it("requests the search catalog and maps the hits", async () => {
+ mockFetch.mockResolvedValueOnce(ok(catalogBody));
+ const hits = await searchCatalog("movie", "The Matrix");
+ expect(mockFetch.mock.calls[0]?.[0]).toBe(`${CINEMETA}/catalog/movie/top/search=The%20Matrix.json`);
+ expect(hits.map((h) => h.imdbId)).toEqual(["tt0133093", "tt10838180"]);
+ });
+
+ it("does not call out for an empty query", async () => {
+ expect(await searchCatalog("movie", " ")).toEqual([]);
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it("returns [] instead of throwing when the request fails", async () => {
+ mockFetch.mockRejectedValueOnce(new Error("ENOTFOUND"));
+ expect(await searchCatalog("movie", "The Matrix")).toEqual([]);
+ });
+
+ it("returns [] on a non-ok response", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 503,
+ headers: new Headers(),
+ json: async () => ({}),
+ } as unknown as Response);
+ expect(await searchCatalog("movie", "The Matrix")).toEqual([]);
+ });
+});
+
+describe("fetchMeta", () => {
+ it("fetches and maps a movie", async () => {
+ mockFetch.mockResolvedValueOnce(ok(movieBody));
+ const meta = await fetchMeta("movie", "tt0133093");
+ expect(mockFetch.mock.calls[0]?.[0]).toBe(`${CINEMETA}/meta/movie/tt0133093.json`);
+ expect(meta?.title).toBe("The Matrix");
+ });
+
+ it("attaches the requested episode to a series", async () => {
+ mockFetch.mockResolvedValueOnce(ok(seriesBody));
+ const meta = await fetchMeta("series", "tt0903747", { season: 5, episode: 14 });
+ expect(meta?.episode).toEqual({
+ season: 5,
+ number: 14,
+ title: "Ozymandias",
+ overview: "Walt goes on the run.",
+ });
+ });
+
+ it("keeps the series meta when the episode is not in the document", async () => {
+ mockFetch.mockResolvedValueOnce(ok(seriesBody));
+ const meta = await fetchMeta("series", "tt0903747", { season: 9, episode: 9 });
+ expect(meta?.title).toBe("Breaking Bad");
+ expect(meta?.episode).toBeUndefined();
+ });
+
+ it("returns null for the unknown-id stub even though the status is 200", async () => {
+ mockFetch.mockResolvedValueOnce(ok(stubBody));
+ expect(await fetchMeta("movie", "tt99999999")).toBeNull();
+ });
+
+ it("rejects an oversized body without parsing it", async () => {
+ const res = ok(movieBody, { "content-length": "5000000" });
+ mockFetch.mockResolvedValueOnce(res);
+ expect(await fetchMeta("movie", "tt0133093")).toBeNull();
+ expect(res.json).not.toHaveBeenCalled();
+ });
+
+ it("parses a body whose declared size is under the cap", async () => {
+ mockFetch.mockResolvedValueOnce(ok(movieBody, { "content-length": "42000" }));
+ expect((await fetchMeta("movie", "tt0133093"))?.title).toBe("The Matrix");
+ });
+
+ it("never puts an unvalidated id in the url", async () => {
+ expect(await fetchMeta("movie", "../../admin")).toBeNull();
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it("returns null instead of throwing when the request fails", async () => {
+ mockFetch.mockRejectedValueOnce(new Error("timed out"));
+ expect(await fetchMeta("movie", "tt0133093")).toBeNull();
+ });
+});
diff --git a/src/meta/cinemeta.ts b/src/meta/cinemeta.ts
new file mode 100644
index 00000000..0160fdf2
--- /dev/null
+++ b/src/meta/cinemeta.ts
@@ -0,0 +1,312 @@
+import { fetchResilient, USER_AGENT } from "../util/net";
+import { cleanText } from "../util/format";
+import { normalizeImdbId } from "./imdbId";
+import type { CatalogHit, EpisodeMeta, Meta, MetaKind } from "./types";
+
+// Cinemeta is Stremio's public metadata addon: keyless, CORS-open, IMDb-keyed. torlink uses it
+// because a search row needs a title, a year and a poster with no account, no API key and no
+// per-user rate limit to explain to the user.
+//
+// Everything here fails soft. These calls are made from a React render path, so a dead provider,
+// a hostile payload or a slow network must degrade to "no metadata", never to a thrown exception
+// that unmounts the TUI. That is why every mapper is total and every network function returns
+// null/[] instead of rejecting.
+export const CINEMETA = "https://v3-cinemeta.strem.io";
+
+// One request per row of interest, so the budget is short: a stale poster is worthless once the
+// user has already scrolled past the row that wanted it.
+const TIMEOUT_MS = 6000;
+
+// A long-running series carries every episode in its meta document — One Piece is 1.35 MB — but
+// nothing legitimate approaches this. The cap exists so a hostile or broken upstream cannot make
+// us buffer an unbounded body into a terminal app's heap.
+const MAX_META_BYTES = 3_000_000;
+
+// Metahub is Stremio's own poster CDN, keyed by IMDb id, and it is the only host we will point
+// an image loader at besides Amazon's image server.
+const METAHUB = "https://images.metahub.space/poster/small";
+
+// Amazon's image server encodes the rendition in the filename. Anchored end to end so nothing
+// but a plain path under that host can match: this string ends up in an outbound request.
+const AMAZON_POSTER = /^https:\/\/m\.media-amazon\.com\/images\/M\/[\w@.-]+\._V1_SX\d+\.jpg$/;
+
+// The terminal renders posters as a few dozen character cells, so ask Amazon for the smallest
+// sane rendition rather than the ~200 KB original the API links to.
+const POSTER_WIDTH = "SX120";
+
+const MAX_GENRES = 6;
+const MAX_CAST = 12;
+const MAX_DIRECTORS = 3;
+const MAX_PLOT = 800;
+
+// Ceiling on any single remote string, applied before it is cleaned. MAX_META_BYTES lets a 3 MB
+// body through and the list caps below bound the *count* of entries, not the length of one — so
+// without this a single field is free to be the whole body. That costs twice, and both costs are
+// real rather than theoretical:
+//
+// - cleanText walks a string code point by code point, so running it over a 2.9 MB description
+// and only then slicing to MAX_PLOT takes ~295 ms, against ~0.25 ms for slicing first. For
+// scale, JSON.parse of that same body is ~2 ms.
+// - a single oversized cast entry or genre survives into Meta and reaches wordWrapLines in
+// MetaPane's planPaneLines, which re-runs on *every* render: ~176 ms per frame for a 1 MB
+// token, i.e. a pane that re-wraps a megabyte on each keystroke. The row is ultimately
+// dropped (planPaneLines admits a block all-or-nothing) so nothing overflows — the cost is
+// paid in full for output that is thrown away.
+//
+// One cap here rather than one per call site: text() is the single funnel every remote string
+// passes through, so capping it is what makes "no unbounded string leaves this module" a property
+// instead of a checklist. 1500 cannot truncate anything legitimate — the longest field, the plot,
+// is cut to 800 immediately after, and every other string is a name, a year, a runtime or an
+// episode title.
+const MAX_FIELD = 1500;
+
+// An unpaired surrogate — half of an astral character, with no other half — is the one input that
+// makes encodeURIComponent throw URIError. It is remotely reachable: JSON.parse turns a "\ud800"
+// escape in a tracker payload into one, and cleanText() does not strip it. Half a character
+// carries no meaning, so it is dropped rather than substituted.
+const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?= cap) break;
+ }
+ return out;
+}
+
+function toInt(raw: unknown): number | undefined {
+ if (typeof raw === "number") return Number.isFinite(raw) ? Math.trunc(raw) : undefined;
+ if (typeof raw !== "string") return undefined;
+ const n = Number.parseInt(raw, 10);
+ return Number.isFinite(n) ? n : undefined;
+}
+
+function asRecord(value: unknown): Record | undefined {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : undefined;
+}
+
+/** Map a catalog response to hits. Rows without a usable id or name are dropped, not defaulted. */
+export function mapCatalog(json: unknown, kind: MetaKind): CatalogHit[] {
+ const metas = asRecord(json)?.["metas"];
+ if (!Array.isArray(metas)) return [];
+ const out: CatalogHit[] = [];
+ for (const entry of metas) {
+ const row = asRecord(entry);
+ if (row === undefined) continue;
+ const imdbId = normalizeImdbId(row["imdb_id"]) ?? normalizeImdbId(row["id"]);
+ const name = text(row["name"]);
+ // A hit with no name cannot be scored against a release title, so it is noise.
+ if (imdbId === undefined || name === undefined) continue;
+ const releaseInfo = text(row["releaseInfo"]);
+ out.push({
+ imdbId,
+ name,
+ kind,
+ ...(releaseInfo !== undefined ? { releaseInfo } : {}),
+ });
+ }
+ return out;
+}
+
+/**
+ * Find one episode in a series meta document. Cinemeta numbers episodes in `number` and repeats
+ * it in `episode`; specials live in season 0, so an exact season match matters.
+ */
+export function pickEpisode(json: unknown, season: number, episode: number): EpisodeMeta | undefined {
+ const root = asRecord(json);
+ // Accepts either a whole response body or the inner meta object, because fetchMeta has the
+ // former and a caller re-reading a cached meta has the latter.
+ const videos = root?.["videos"] ?? asRecord(root?.["meta"])?.["videos"];
+ if (!Array.isArray(videos)) return undefined;
+ for (const entry of videos) {
+ const v = asRecord(entry);
+ if (v === undefined) continue;
+ const s = toInt(v["season"]);
+ const n = toInt(v["number"]) ?? toInt(v["episode"]);
+ if (s !== season || n !== episode) continue;
+ const title = text(v["title"] ?? v["name"]);
+ const overview = text(v["overview"] ?? v["description"]);
+ return {
+ season,
+ number: episode,
+ ...(title !== undefined ? { title } : {}),
+ ...(overview !== undefined ? { overview } : {}),
+ };
+ }
+ return undefined;
+}
+
+/**
+ * Map a meta response, or return null if it is not a real hit.
+ *
+ * Cinemeta answers HTTP 200 for ids it has never heard of, with a stub body carrying only
+ * `{id, type, behaviorHints}`. The status code therefore proves nothing: the presence of
+ * `meta.name` is the only signal that separates a real record from that stub.
+ */
+export function mapMeta(json: unknown, kind: MetaKind): Meta | null {
+ const meta = asRecord(asRecord(json)?.["meta"]);
+ if (meta === undefined) return null;
+
+ const title = text(meta["name"]);
+ if (title === undefined) return null;
+
+ const imdbId = normalizeImdbId(meta["imdb_id"]) ?? normalizeImdbId(meta["id"]);
+ if (imdbId === undefined) return null;
+
+ const year = text(meta["releaseInfo"]);
+ const rating = text(meta["imdbRating"]);
+ const runtime = text(meta["runtime"]);
+ const plotRaw = text(meta["description"] ?? meta["plot"]);
+ // A synopsis is decoration next to a torrent row; anything past this is scroll, not information.
+ const plot = plotRaw === undefined ? undefined : plotRaw.slice(0, MAX_PLOT);
+ const rawPoster = typeof meta["poster"] === "string" ? meta["poster"] : undefined;
+ const posterUrl = posterUrlFor(imdbId, rawPoster);
+
+ return {
+ imdbId,
+ kind,
+ title,
+ genres: stringList(meta["genres"], MAX_GENRES),
+ cast: stringList(meta["cast"], MAX_CAST),
+ director: stringList(meta["director"], MAX_DIRECTORS),
+ ...(year !== undefined ? { year } : {}),
+ ...(rating !== undefined ? { rating } : {}),
+ ...(runtime !== undefined ? { runtime } : {}),
+ ...(plot !== undefined ? { plot } : {}),
+ ...(posterUrl !== undefined ? { posterUrl } : {}),
+ };
+}
+
+// A caller's cancellation and our own deadline are both reasons to stop, and the request should
+// honour whichever fires first.
+function deadline(signal?: AbortSignal): AbortSignal {
+ const timeout = AbortSignal.timeout(TIMEOUT_MS);
+ return signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
+}
+
+// Refuse an oversized body before reading it. content-length is advisory, but a truthful cap is
+// still worth having: it costs nothing and stops the common case of a genuinely huge document.
+function withinSizeCap(res: Response): boolean {
+ const declared = Number(res.headers.get("content-length"));
+ return !Number.isFinite(declared) || declared <= MAX_META_BYTES;
+}
+
+/**
+ * Search a Cinemeta catalog. Returns [] on any failure — a search row that cannot be enriched is
+ * a cosmetic loss, so nothing here is worth propagating to the caller.
+ */
+export async function searchCatalog(
+ kind: MetaKind,
+ query: string,
+ opts: { signal?: AbortSignal } = {},
+): Promise {
+ const q = query.trim();
+ if (q === "") return [];
+ try {
+ const res = await fetchResilient(searchUrl(kind, q), {
+ // One shot. This runs while the user is looking at the list; a backoff would deliver the
+ // answer long after the row it belonged to stopped being interesting.
+ retries: 0,
+ headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
+ signal: deadline(opts.signal),
+ });
+ if (!res.ok || !withinSizeCap(res)) return [];
+ return mapCatalog(await res.json(), kind);
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Fetch one title's metadata, optionally narrowed to a single episode. Returns null on failure,
+ * on an unknown id (the HTTP-200 stub) and on an oversized body.
+ */
+export async function fetchMeta(
+ kind: MetaKind,
+ imdbId: string,
+ opts: { signal?: AbortSignal; season?: number; episode?: number } = {},
+): Promise {
+ const id = normalizeImdbId(imdbId);
+ if (id === undefined) return null;
+ try {
+ const res = await fetchResilient(metaUrl(kind, id), {
+ retries: 0,
+ headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
+ signal: deadline(opts.signal),
+ });
+ if (!res.ok || !withinSizeCap(res)) return null;
+ const json: unknown = await res.json();
+ const meta = mapMeta(json, kind);
+ if (meta === null) return null;
+ const { season, episode } = opts;
+ if (season === undefined || episode === undefined) return meta;
+ const found = pickEpisode(json, season, episode);
+ // No matching video is normal (an unaired episode, a mis-parsed number): keep the series
+ // metadata rather than discarding a good hit over a missing row.
+ return found === undefined ? meta : { ...meta, episode: found };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/meta/image.test.ts b/src/meta/image.test.ts
new file mode 100644
index 00000000..b5d059fa
--- /dev/null
+++ b/src/meta/image.test.ts
@@ -0,0 +1,246 @@
+import { Buffer } from "node:buffer";
+import { describe, expect, it } from "vitest";
+import { decodePoster, fitCells, sampleGrid, toHalfBlockLines } from "./image";
+import type { Bitmap } from "./image";
+
+// Two real JPEGs, inlined rather than committed as fixtures — the repo carries no binary files and
+// a 400-byte constant is easier to reason about than one. They exist because the two poster hosts
+// encode differently: m.media-amazon.com serves baseline, images.metahub.space serves progressive
+// even when asked for `?format=jpeg`, and a decoder that only handles the first would silently
+// lose every title that never went through the catalog.
+//
+// BASELINE_JPEG: 2x2, solid red, baseline.
+const BASELINE_JPEG =
+ "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgs" +
+ "LEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB" +
+ "QUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAACAAIDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACP/EABQQA" +
+ "QAAAAAAAAAAAAAAAAAAAAD/xAAVAQEBAAAAAAAAAAAAAAAAAAAHCf/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEA" +
+ "AhEDEQA/ADoDFU3/2Q==";
+
+// PROGRESSIVE_JPEG: 4x4, top two rows red and bottom two blue, progressive (multi-scan SOF2).
+const PROGRESSIVE_JPEG =
+ "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgs" +
+ "LEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB" +
+ "QUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wgARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAAB//EABUBA" +
+ "QEAAAAAAAAAAAAAAAAAAAUH/9oADAMBAAIQAxAAAAEKuCv/xAAWEAADAAAAAAAAAAAAAAAAAAAAAxT/2gAIAQEAAQUC" +
+ "vef/xAAaEQAABwAAAAAAAAAAAAAAAAAAAgUWU6LS/9oACAEDAQE/AXetz1Jkf//EABoRAAAHAAAAAAAAAAAAAAAAAAA" +
+ "CBRZUotL/2gAIAQIBAT8BZKBHsfQ//8QAFxAAAwEAAAAAAAAAAAAAAAAAAAEyof/aAAgBAQAGPwK8R//EABYQAAMAAA" +
+ "AAAAAAAAAAAAAAAADR8P/aAAgBAQABPyGKj//aAAwDAQACAAMAAAAQ/wD/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oAC" +
+ "AEDAQE/EHH/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/EH3/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEB" +
+ "AAE/ECH/2Q==";
+
+const bytes = (b64: string): Uint8Array => new Uint8Array(Buffer.from(b64, "base64"));
+
+/** `#rrggbb` back to channels, so a decode can be asserted within a tolerance rather than exactly. */
+function rgb(hex: string): [number, number, number] {
+ const n = Number.parseInt(hex.slice(1), 16);
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
+}
+
+// JPEG is lossy and the exact channel values depend on the decoder's IDCT, so colours are asserted
+// as "unmistakably this hue", not as literals a jpeg-js patch release could shift by one.
+function expectNear(hex: string, target: readonly [number, number, number], tol = 12): void {
+ const got = rgb(hex);
+ for (let i = 0; i < 3; i++) {
+ expect(Math.abs((got[i] ?? -1) - (target[i] ?? 0)), `${hex} channel ${i}`).toBeLessThanOrEqual(
+ tol,
+ );
+ }
+}
+
+/** RGBA bitmap from a list of RGB triples, row-major. */
+function bitmap(width: number, height: number, px: readonly (readonly number[])[]): Bitmap {
+ const data = new Uint8Array(width * height * 4);
+ px.forEach((p, i) => {
+ data[i * 4] = p[0] ?? 0;
+ data[i * 4 + 1] = p[1] ?? 0;
+ data[i * 4 + 2] = p[2] ?? 0;
+ data[i * 4 + 3] = 255;
+ });
+ return { width, height, data };
+}
+
+describe("fitCells", () => {
+ it("gives a 2:3 poster three quarters as many rows as columns", () => {
+ // A half-block cell is 1px wide and 2px tall, and a terminal cell is about 1:2, so the pixels
+ // come out square exactly here. 24 columns of a 120x180 poster is 18 rows.
+ expect(fitCells(120, 180, 24, 20)).toEqual({ cols: 24, rows: 18 });
+ expect(fitCells(1000, 1500, 24, 20)).toEqual({ cols: 24, rows: 18 });
+ });
+
+ it("takes the full width when the height budget allows it", () => {
+ expect(fitCells(100, 100, 24, 20)).toEqual({ cols: 24, rows: 12 });
+ expect(fitCells(400, 100, 24, 20)).toEqual({ cols: 24, rows: 3 });
+ });
+
+ it("falls back to the height budget and narrows the width to keep aspect", () => {
+ // 24 columns would want 18 rows; a 13-row pane gets a 17-column poster instead of a squashed
+ // 24-column one, because a stretched poster reads as a rendering bug.
+ expect(fitCells(120, 180, 24, 13)).toEqual({ cols: 17, rows: 13 });
+ expect(fitCells(120, 180, 24, 6)).toEqual({ cols: 8, rows: 6 });
+ });
+
+ it("never returns a budget larger than the one it was given", () => {
+ for (const [w, h] of [
+ [120, 180],
+ [180, 120],
+ [1, 400],
+ [400, 1],
+ ] as const) {
+ const fit = fitCells(w, h, 24, 13);
+ expect(fit.cols).toBeLessThanOrEqual(24);
+ expect(fit.rows).toBeLessThanOrEqual(13);
+ expect(fit.cols).toBeGreaterThanOrEqual(1);
+ expect(fit.rows).toBeGreaterThanOrEqual(1);
+ }
+ });
+
+ it("returns a zero budget rather than throwing on degenerate input", () => {
+ expect(fitCells(0, 180, 24, 13)).toEqual({ cols: 0, rows: 0 });
+ expect(fitCells(120, 0, 24, 13)).toEqual({ cols: 0, rows: 0 });
+ expect(fitCells(120, 180, 0, 13)).toEqual({ cols: 0, rows: 0 });
+ expect(fitCells(120, 180, 24, 0)).toEqual({ cols: 0, rows: 0 });
+ expect(fitCells(Number.NaN, 180, 24, 13)).toEqual({ cols: 0, rows: 0 });
+ });
+});
+
+describe("sampleGrid", () => {
+ const K = [0, 0, 0];
+ const W = [255, 255, 255];
+ // 4x4 checkerboard of 2x2 blocks: black, white / white, black.
+ const CHECKER = bitmap(4, 4, [K, K, W, W, K, K, W, W, W, W, K, K, W, W, K, K]);
+
+ it("box-averages each cell over the pixels it covers", () => {
+ // 2x2 output: every cell covers one whole 2x2 block, so the averages are the block colours.
+ expect(Array.from(sampleGrid(CHECKER, 2, 2))).toEqual([
+ 0, 0, 0, 255, 255, 255, 255, 255, 255, 0, 0, 0,
+ ]);
+ });
+
+ it("averages across block boundaries when a cell straddles them", () => {
+ // 1x2: each row of the grid covers all four columns of two source rows — two black and two
+ // white pixels per band, so both come out mid-grey rather than picking a side.
+ expect(Array.from(sampleGrid(CHECKER, 1, 2))).toEqual([128, 128, 128, 128, 128, 128]);
+ // 1x1 collapses the whole image: eight black, eight white.
+ expect(Array.from(sampleGrid(CHECKER, 1, 1))).toEqual([128, 128, 128]);
+ });
+
+ it("repeats source pixels rather than sampling nothing when upscaling", () => {
+ const solid = bitmap(1, 1, [[10, 20, 30]]);
+ expect(Array.from(sampleGrid(solid, 3, 2))).toEqual([
+ 10, 20, 30, 10, 20, 30, 10, 20, 30, 10, 20, 30, 10, 20, 30, 10, 20, 30,
+ ]);
+ });
+
+ it("returns an empty grid for a degenerate request", () => {
+ expect(sampleGrid(CHECKER, 0, 4)).toHaveLength(0);
+ expect(sampleGrid(CHECKER, 4, 0)).toHaveLength(0);
+ expect(Array.from(sampleGrid(bitmap(0, 0, []), 2, 2))).toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
+ });
+});
+
+describe("toHalfBlockLines", () => {
+ it("folds two pixel rows into one cell row, upper as fg and lower as bg", () => {
+ // 2 cols x 2 pixel rows: top row red then green, bottom row blue then black.
+ const grid = new Uint8Array([255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0]);
+ expect(toHalfBlockLines(grid, 2, 1)).toEqual([
+ [
+ { fg: "#ff0000", bg: "#0000ff", n: 1 },
+ { fg: "#00ff00", bg: "#000000", n: 1 },
+ ],
+ ]);
+ });
+
+ it("merges consecutive cells that share both colours", () => {
+ // 4 cols x 2 pixel rows: red/blue, red/blue, red/blue, green/blue.
+ const grid = new Uint8Array([
+ 255, 0, 0, 255, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255, 0, 0, 255, 0, 0, 255,
+ ]);
+ expect(toHalfBlockLines(grid, 4, 1)).toEqual([
+ [
+ { fg: "#ff0000", bg: "#0000ff", n: 3 },
+ { fg: "#00ff00", bg: "#0000ff", n: 1 },
+ ],
+ ]);
+ });
+
+ it("emits one line per cell row and reads the right pixel rows for each", () => {
+ // 1 col x 4 pixel rows: red, green, blue, white.
+ const grid = new Uint8Array([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]);
+ expect(toHalfBlockLines(grid, 1, 2)).toEqual([
+ [{ fg: "#ff0000", bg: "#00ff00", n: 1 }],
+ [{ fg: "#0000ff", bg: "#ffffff", n: 1 }],
+ ]);
+ });
+
+ it("repeats the upper pixel when a cell has no lower half", () => {
+ // A grid one pixel row short: the bottom half would read past the end, and a black bar under
+ // the image is a more visible bug than a solid last row.
+ const grid = new Uint8Array([255, 0, 0]);
+ expect(toHalfBlockLines(grid, 1, 1)).toEqual([[{ fg: "#ff0000", bg: "#ff0000", n: 1 }]]);
+ });
+
+ it("returns no lines for a degenerate budget", () => {
+ expect(toHalfBlockLines(new Uint8Array(12), 0, 2)).toEqual([]);
+ expect(toHalfBlockLines(new Uint8Array(12), 2, 0)).toEqual([]);
+ });
+});
+
+describe("decodePoster", () => {
+ it("decodes a baseline JPEG — the rendition m.media-amazon.com serves", () => {
+ const cells = decodePoster(bytes(BASELINE_JPEG), 2, 1);
+ expect(cells).not.toBeNull();
+ expect(cells?.cols).toBe(2);
+ expect(cells?.rows).toBe(1);
+ const line = cells?.lines[0];
+ // Solid red end to end, so the whole row merges into a single run.
+ expect(line).toHaveLength(1);
+ expect(line?.[0]?.n).toBe(2);
+ expectNear(line?.[0]?.fg ?? "", [255, 0, 0]);
+ expectNear(line?.[0]?.bg ?? "", [255, 0, 0]);
+ });
+
+ it("decodes a progressive JPEG — the rendition images.metahub.space serves", () => {
+ const cells = decodePoster(bytes(PROGRESSIVE_JPEG), 4, 2);
+ expect(cells).not.toBeNull();
+ expect(cells?.cols).toBe(4);
+ expect(cells?.rows).toBe(2);
+ const [top, bottom] = cells?.lines ?? [];
+ // The source is red over blue, one colour per half of the image; both halves of both cell rows
+ // therefore land on their own colour, and each row merges to one run.
+ expect(top).toHaveLength(1);
+ expect(bottom).toHaveLength(1);
+ expect(top?.[0]?.n).toBe(4);
+ expectNear(top?.[0]?.fg ?? "", [255, 0, 0]);
+ expectNear(top?.[0]?.bg ?? "", [255, 0, 0]);
+ expectNear(bottom?.[0]?.fg ?? "", [0, 0, 255]);
+ expectNear(bottom?.[0]?.bg ?? "", [0, 0, 255]);
+ });
+
+ it("fills the whole budget it is given", () => {
+ const cells = decodePoster(bytes(PROGRESSIVE_JPEG), 24, 18);
+ expect(cells?.cols).toBe(24);
+ expect(cells?.rows).toBe(12); // square source, so half as many rows as columns
+ expect(cells?.lines).toHaveLength(12);
+ for (const line of cells?.lines ?? []) {
+ expect(line.reduce((n, run) => n + run.n, 0)).toBe(24);
+ }
+ });
+
+ it("returns null for bytes that are not a JPEG", () => {
+ expect(decodePoster(new Uint8Array([1, 2, 3]), 24, 18)).toBeNull();
+ expect(decodePoster(new Uint8Array(0), 24, 18)).toBeNull();
+ });
+
+ it("returns null for a truncated JPEG rather than throwing", () => {
+ const full = bytes(PROGRESSIVE_JPEG);
+ // Header intact, scan cut in half: the failure mode of an aborted download, and the one that
+ // would reach a React render path as an exception if the decode were not guarded.
+ expect(decodePoster(full.slice(0, Math.floor(full.length / 2)), 24, 18)).toBeNull();
+ });
+
+ it("returns null when there is no room to draw", () => {
+ expect(decodePoster(bytes(BASELINE_JPEG), 0, 18)).toBeNull();
+ expect(decodePoster(bytes(BASELINE_JPEG), 24, 0)).toBeNull();
+ });
+});
diff --git a/src/meta/image.ts b/src/meta/image.ts
new file mode 100644
index 00000000..ecaa5628
--- /dev/null
+++ b/src/meta/image.ts
@@ -0,0 +1,187 @@
+import jpeg from "jpeg-js";
+
+// Turning a JPEG into something a terminal can draw, with no terminal graphics protocol involved.
+// Ink owns the screen and repaints whole frames through its own renderer, so a sixel or kitty
+// escape written into a cell would be overwritten, mispositioned, or measured as text on the next
+// repaint. Half-block characters are just text: they survive the renderer, they survive a resize,
+// and they degrade on a 16-colour terminal without any code here knowing about it.
+//
+// Everything in this module is total. It runs from a React render path, so a truncated body, a
+// WebP served with a .jpg name or a decoder that simply gives up must all come back as null.
+
+/** Decoded pixels: RGBA, four bytes per pixel, row-major. The only image shape below the decoder. */
+export interface Bitmap {
+ readonly width: number;
+ readonly height: number;
+ readonly data: Uint8Array;
+}
+
+/** `n` consecutive cells sharing one upper (`fg`) and lower (`bg`) colour, as `#rrggbb`. */
+export interface PosterRun {
+ readonly fg: string;
+ readonly bg: string;
+ readonly n: number;
+}
+
+export interface PosterCells {
+ readonly cols: number;
+ readonly rows: number;
+ readonly lines: readonly (readonly PosterRun[])[];
+}
+
+// jpeg-js allocates the whole decoded frame before anything here gets to downsample it, so the
+// only useful ceiling sits on the decoder. 32 MB is far above any poster rendition either host
+// serves (120x180 decodes to 86 KB) and far below what a header claiming a 20000x20000 frame
+// would want, which is the case this number exists for.
+const MAX_DECODE_MB = 32;
+
+/**
+ * Cell dimensions for an image inside a `maxCols` x `maxRows` budget, preserving aspect.
+ *
+ * A half-block cell carries one pixel column and two pixel rows, and a terminal cell is itself
+ * roughly twice as tall as it is wide, so those two factors cancel: the pixels come out square
+ * when the cell grid is half as tall as the pixel grid. That makes a 2:3 poster `0.75 * cols`
+ * rows — 24 columns of art is 18 rows, which is what the pane budgets for.
+ *
+ * Returns a zero budget rather than throwing for a degenerate image or a budget with no room in
+ * it; `decodePoster` reads that as "no art", which is a rendering outcome, not an error.
+ */
+export function fitCells(
+ imgW: number,
+ imgH: number,
+ maxCols: number,
+ maxRows: number,
+): { cols: number; rows: number } {
+ const capCols = Math.floor(maxCols);
+ const capRows = Math.floor(maxRows);
+ if (!(imgW > 0) || !(imgH > 0) || capCols < 1 || capRows < 1) return { cols: 0, rows: 0 };
+
+ // Width-first, because the pane pins the poster's width and has rows to spare; only a wide
+ // image (or a short pane) ever trips the height cap below.
+ let cols = capCols;
+ let rows = Math.max(1, Math.round((cols * imgH) / (imgW * 2)));
+ if (rows > capRows) {
+ rows = capRows;
+ cols = Math.min(capCols, Math.max(1, Math.round((rows * 2 * imgW) / imgH)));
+ }
+ return { cols, rows };
+}
+
+/**
+ * Box-average `bmp` down to a `cols` x `pxRows` grid of RGB triples.
+ *
+ * A box average, not a nearest-neighbour pick: at these ratios (a 120x180 poster into 24x36
+ * pixels) every output pixel covers ~25 input pixels, and sampling one of them turns film grain
+ * and subtitle text into speckle. Averaging is also what makes the result stable — the same
+ * poster at the same budget always produces the same bytes, which is what lets the hook cache it.
+ */
+export function sampleGrid(bmp: Bitmap, cols: number, pxRows: number): Uint8Array {
+ const out = new Uint8Array(Math.max(0, cols * pxRows * 3));
+ if (cols < 1 || pxRows < 1 || bmp.width < 1 || bmp.height < 1) return out;
+
+ for (let cy = 0; cy < pxRows; cy++) {
+ const y0 = Math.floor((cy * bmp.height) / pxRows);
+ // Upsampling (an image smaller than the grid asked for) gives a zero-width box, so every band
+ // claims at least one source row. Without this a 2x2 poster stretched to 24 cells averages
+ // nothing and comes out black.
+ const y1 = Math.min(bmp.height, Math.max(y0 + 1, Math.floor(((cy + 1) * bmp.height) / pxRows)));
+ for (let cx = 0; cx < cols; cx++) {
+ const x0 = Math.floor((cx * bmp.width) / cols);
+ const x1 = Math.min(bmp.width, Math.max(x0 + 1, Math.floor(((cx + 1) * bmp.width) / cols)));
+
+ let r = 0;
+ let g = 0;
+ let b = 0;
+ let n = 0;
+ for (let y = y0; y < y1; y++) {
+ const row = y * bmp.width;
+ for (let x = x0; x < x1; x++) {
+ const i = (row + x) * 4;
+ // A short data array (a decoder that returned fewer bytes than its own header claims)
+ // reads as black rather than NaN, so one malformed frame cannot poison a hex string.
+ r += bmp.data[i] ?? 0;
+ g += bmp.data[i + 1] ?? 0;
+ b += bmp.data[i + 2] ?? 0;
+ n++;
+ }
+ }
+
+ const o = (cy * cols + cx) * 3;
+ if (n > 0) {
+ out[o] = Math.round(r / n);
+ out[o + 1] = Math.round(g / n);
+ out[o + 2] = Math.round(b / n);
+ }
+ }
+ }
+ return out;
+}
+
+function hex2(v: number): string {
+ return v.toString(16).padStart(2, "0");
+}
+
+function colorAt(grid: Uint8Array, offset: number): string {
+ return `#${hex2(grid[offset] ?? 0)}${hex2(grid[offset + 1] ?? 0)}${hex2(grid[offset + 2] ?? 0)}`;
+}
+
+/**
+ * Fold `2 * rows` pixel rows into `rows` cell rows of `▀` runs: the upper pixel becomes the
+ * foreground colour, the lower one the background.
+ *
+ * Runs are merged because the cost of this art is escape sequences, not characters. A poster's
+ * letterbox bars, flat sky and dark background are long stretches of one colour pair, and emitting
+ * them as one `` instead of twenty-four turns roughly 800 SGR switches per frame into well
+ * under a hundred — the difference between a pane that repaints invisibly and one that tears while
+ * the user holds an arrow key down.
+ */
+export function toHalfBlockLines(grid: Uint8Array, cols: number, rows: number): PosterRun[][] {
+ const lines: PosterRun[][] = [];
+ if (cols < 1 || rows < 1) return lines;
+
+ for (let row = 0; row < rows; row++) {
+ const upper = row * 2 * cols * 3;
+ const lower = (row * 2 + 1) * cols * 3;
+ const runs: PosterRun[] = [];
+
+ for (let x = 0; x < cols; x++) {
+ const fg = colorAt(grid, upper + x * 3);
+ const lowOffset = lower + x * 3;
+ // An odd pixel-row count leaves the last cell with no bottom half. Repeating the top pixel
+ // renders it as a solid block; reading past the grid would paint a black bar under the image.
+ const bg = lowOffset + 2 < grid.length ? colorAt(grid, lowOffset) : fg;
+
+ const prev = runs[runs.length - 1];
+ if (prev !== undefined && prev.fg === fg && prev.bg === bg) {
+ runs[runs.length - 1] = { fg, bg, n: prev.n + 1 };
+ } else {
+ runs.push({ fg, bg, n: 1 });
+ }
+ }
+ lines.push(runs);
+ }
+ return lines;
+}
+
+/**
+ * JPEG bytes to drawable cells, or null for anything that is not a JPEG we can render.
+ *
+ * jpeg-js throws for a missing SOI, an unknown marker, a truncated scan and an over-budget frame
+ * alike, and all of those mean the same thing here: draw the text card without art. Nothing is
+ * rethrown, because the only caller is a React effect feeding a render.
+ */
+export function decodePoster(
+ bytes: Uint8Array,
+ maxCols: number,
+ maxRows: number,
+): PosterCells | null {
+ try {
+ const img = jpeg.decode(bytes, { useTArray: true, maxMemoryUsageInMB: MAX_DECODE_MB });
+ const { cols, rows } = fitCells(img.width, img.height, maxCols, maxRows);
+ if (cols < 1 || rows < 1) return null;
+ const grid = sampleGrid({ width: img.width, height: img.height, data: img.data }, cols, rows * 2);
+ return { cols, rows, lines: toHalfBlockLines(grid, cols, rows) };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/meta/imdbId.test.ts b/src/meta/imdbId.test.ts
new file mode 100644
index 00000000..db7a89e7
--- /dev/null
+++ b/src/meta/imdbId.test.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect } from "vitest";
+import { imdbFromNumeric, normalizeImdbId } from "./imdbId";
+
+describe("normalizeImdbId", () => {
+ it("accepts a well-formed id", () => {
+ expect(normalizeImdbId("tt0133093")).toBe("tt0133093");
+ expect(normalizeImdbId("TT0133093")).toBe("tt0133093");
+ expect(normalizeImdbId(" tt0133093 ")).toBe("tt0133093");
+ expect(normalizeImdbId("tt1234567890")).toBe("tt1234567890");
+ });
+
+ it("takes the series id out of an episode id", () => {
+ expect(normalizeImdbId("tt0903747:5:14")).toBe("tt0903747");
+ });
+
+ it("rejects anything that would not survive being put in a url path", () => {
+ expect(normalizeImdbId("../etc")).toBeUndefined();
+ expect(normalizeImdbId("tt")).toBeUndefined();
+ expect(normalizeImdbId("")).toBeUndefined();
+ expect(normalizeImdbId("12")).toBeUndefined();
+ expect(normalizeImdbId("tt0133093/../../admin")).toBeUndefined();
+ expect(normalizeImdbId("tt0133093?x=1")).toBeUndefined();
+ expect(normalizeImdbId("tt12345678901")).toBeUndefined();
+ expect(normalizeImdbId("tt01330 93")).toBeUndefined();
+ expect(normalizeImdbId(undefined)).toBeUndefined();
+ expect(normalizeImdbId(133093)).toBeUndefined();
+ expect(normalizeImdbId({ id: "tt0133093" })).toBeUndefined();
+ });
+});
+
+describe("imdbFromNumeric", () => {
+ it("prefixes and validates a numeric id", () => {
+ expect(imdbFromNumeric("32308214")).toBe("tt32308214");
+ expect(imdbFromNumeric(133093)).toBe("tt0133093");
+ expect(imdbFromNumeric("0133093")).toBe("tt0133093");
+ });
+
+ it("rejects non-numeric input rather than forging an id", () => {
+ expect(imdbFromNumeric("tt0133093")).toBeUndefined();
+ expect(imdbFromNumeric("12x")).toBeUndefined();
+ expect(imdbFromNumeric("")).toBeUndefined();
+ expect(imdbFromNumeric("12345678901")).toBeUndefined();
+ expect(imdbFromNumeric(null)).toBeUndefined();
+ });
+});
diff --git a/src/meta/imdbId.ts b/src/meta/imdbId.ts
new file mode 100644
index 00000000..06a66275
--- /dev/null
+++ b/src/meta/imdbId.ts
@@ -0,0 +1,35 @@
+// IMDb id validation, with no dependency on any particular metadata provider.
+//
+// This lives apart from cinemeta.ts because the torrent sources need it too: YTS, EZTV and The
+// Pirate Bay all carry an IMDb id on their rows, and validating it is their business whether or
+// not a metadata client ever runs. Keeping these two functions here means the lower sources/
+// layer never has to import the Cinemeta client to sanitise a field of its own payload.
+
+// An IMDb id is interpolated into a URL *path*, so it is attacker-controlled routing input the
+// moment it comes back from a remote catalog. Same class of rule as stripControl() in
+// util/format: validate the value you are about to hand to another system, not the value you
+// received. Anchored, digits only — "tt0133093/../../admin" and "tt0133093?x=1" both fail.
+const IMDB_ID = /^tt\d{7,10}$/;
+
+/**
+ * Accept a remote value as an IMDb id only if it still looks like one after cleaning. Returns
+ * undefined rather than throwing so callers can treat "no id" and "bad id" identically.
+ */
+export function normalizeImdbId(raw: unknown): string | undefined {
+ if (typeof raw !== "string") return undefined;
+ const trimmed = raw.trim().toLowerCase();
+ // Series episodes arrive as "tt0944947:5:14"; the series id is the part before the first colon.
+ const base = trimmed.split(":", 1)[0] ?? "";
+ return IMDB_ID.test(base) ? base : undefined;
+}
+
+/**
+ * Some sources (YTS, torrent indexes) carry the bare numeric IMDb id. Zero-pad to IMDb's minimum
+ * width of seven, then run the same result-side validation — a caller could hand us anything.
+ */
+export function imdbFromNumeric(raw: unknown): string | undefined {
+ const s = typeof raw === "number" ? String(raw) : typeof raw === "string" ? raw.trim() : "";
+ if (!/^\d+$/.test(s)) return undefined;
+ const candidate = `tt${s.padStart(7, "0")}`;
+ return IMDB_ID.test(candidate) ? candidate : undefined;
+}
diff --git a/src/meta/lookup.test.ts b/src/meta/lookup.test.ts
new file mode 100644
index 00000000..6040eb3b
--- /dev/null
+++ b/src/meta/lookup.test.ts
@@ -0,0 +1,379 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { isSearchableTitle, lookupMeta, metaCacheKey, metaKindFor, peekMeta } from "./lookup";
+import { fetchMeta, searchCatalog } from "./cinemeta";
+import type { CatalogHit, Meta } from "./types";
+import type { SourceId, TorrentResult } from "../sources/types";
+
+// The whole provider is mocked: no test here is allowed near a socket, and the point of these
+// tests is the orchestration around the provider, not the provider itself.
+vi.mock("./cinemeta", () => ({
+ searchCatalog: vi.fn(),
+ fetchMeta: vi.fn(),
+}));
+
+const mockSearch = vi.mocked(searchCatalog);
+const mockMeta = vi.mocked(fetchMeta);
+
+// The module-level cache has no reset hook — deliberately, mirroring sources/cache.ts — so every
+// test invents its own title and infoHash and nothing leaks between them.
+function row(over: Partial & { name: string; source?: SourceId }): TorrentResult {
+ return {
+ infoHash: over.name.toLowerCase().replace(/\W+/g, ""),
+ sizeBytes: 2.1e9,
+ seeders: 40,
+ leechers: 6,
+ source: "yts",
+ magnet: "magnet:?xt=urn:btih:deadbeef",
+ ...over,
+ };
+}
+
+function meta(imdbId: string, title: string): Meta {
+ return { imdbId, kind: "movie", title, genres: [], cast: [], director: [] };
+}
+
+function hit(imdbId: string, name: string, releaseInfo?: string): CatalogHit {
+ return { imdbId, name, kind: "movie", ...(releaseInfo !== undefined ? { releaseInfo } : {}) };
+}
+
+beforeEach(() => {
+ mockSearch.mockReset();
+ mockMeta.mockReset();
+ mockSearch.mockResolvedValue([]);
+ mockMeta.mockResolvedValue(null);
+});
+
+describe("isSearchableTitle", () => {
+ it("accepts titles that are legitimately two letters long", () => {
+ expect(isSearchableTitle("Up")).toBe(true);
+ expect(isSearchableTitle("It")).toBe(true);
+ expect(isSearchableTitle("Her")).toBe(true);
+ });
+
+ it("accepts an all-digit title", () => {
+ expect(isSearchableTitle("300")).toBe(true);
+ expect(isSearchableTitle("1917")).toBe(true);
+ expect(isSearchableTitle("2012")).toBe(true);
+ });
+
+ it("accepts accented and mixed-script titles on their Latin content", () => {
+ expect(isSearchableTitle("Amélie")).toBe(true);
+ expect(isSearchableTitle("Attack on Titan 進撃の巨人")).toBe(true);
+ });
+
+ it("rejects what a CJK-only fansub name parses down to", () => {
+ // Verbatim output of parseRelease("【喵萌奶茶屋】★07月新番★[花織同學][04][1080p][繁體]"):
+ // long, non-empty, and completely unsearchable. A length check waves it through.
+ expect(isSearchableTitle("★07月新番★[花織同學][04] [繁體]")).toBe(false);
+ expect(isSearchableTitle("【4月新番】【地。—关于地球的运动—】【01】")).toBe(false);
+ });
+
+ it("rejects a bare episode number and other non-Latin scripts", () => {
+ expect(isSearchableTitle("04")).toBe(false);
+ expect(isSearchableTitle("Брат")).toBe(false);
+ expect(isSearchableTitle("")).toBe(false);
+ expect(isSearchableTitle(" ")).toBe(false);
+ });
+});
+
+describe("metaKindFor", () => {
+ it("refuses a Games-only source outright", () => {
+ expect(metaKindFor(row({ name: "Ravenmoor Deluxe Edition", source: "fitgirl" }))).toBeNull();
+ });
+
+ it("lets a single-category source overrule the release name", () => {
+ // "S3" reads as a season marker to the parser, but YTS only ever publishes films.
+ expect(metaKindFor(row({ name: "Nightgale S3 2019 1080p", source: "yts" }))).toBe("movie");
+ expect(metaKindFor(row({ name: "Harbourlight 2019 1080p", source: "eztv" }))).toBe("series");
+ expect(metaKindFor(row({ name: "Ashfall 2021 1080p", source: "nyaa" }))).toBe("series");
+ });
+
+ it("falls back to the release name for a multi-category source", () => {
+ expect(metaKindFor(row({ name: "Coldwater 2018 1080p", source: "bittorrented" }))).toBe("movie");
+ expect(metaKindFor(row({ name: "Coldwater S02E04 1080p", source: "bittorrented" }))).toBe("series");
+ });
+
+ it("does not let the unknown-source fallback classify a row as a game", () => {
+ // getSource() answers DEFAULT_SOURCE (FitGirl, Games-only) for an id it does not know, which
+ // would turn every unrecognised source into "never query".
+ const unknown = row({ name: "Saltmarsh 2020 1080p", source: "not-a-source" as SourceId });
+ expect(metaKindFor(unknown)).toBe("movie");
+ });
+});
+
+describe("metaCacheKey", () => {
+ it("collapses one film's quality rows onto a single key", () => {
+ const a = row({ infoHash: "q1", name: "Tidewater (2018) [1080p BluRay]", imdbId: "tt3311111" });
+ const b = row({ infoHash: "q2", name: "Tidewater (2018) [2160p WEB]", imdbId: "tt3311111" });
+ expect(metaCacheKey(a)).toBe(metaCacheKey(b));
+ });
+
+ it("keeps two episodes of one series apart despite a shared series id", () => {
+ // EZTV publishes the series id on every episode row, so the id alone is not a unique key.
+ const e14 = row({ infoHash: "e14", name: "Foghorn S05E14 1080p", source: "eztv", imdbId: "tt3322222" });
+ const e15 = row({ infoHash: "e15", name: "Foghorn S05E15 1080p", source: "eztv", imdbId: "tt3322222" });
+ expect(metaCacheKey(e14)).not.toBe(metaCacheKey(e15));
+ });
+
+ it("collapses the same film arriving from different trackers", () => {
+ const tpb = row({ infoHash: "t1", name: "Saltbreak.2019.1080p.BluRay.x264-GRP", source: "tpb-movies" });
+ const x = row({ infoHash: "x1", name: "Saltbreak (2019) 1080p WEB-DL", source: "x1337-movies" });
+ expect(metaCacheKey(tpb)).toBe(metaCacheKey(x));
+ });
+
+ it("keeps a shared id apart when the two rows resolve to different kinds", () => {
+ // Same imdbId, but a movies feed and a TV feed disagree on what it is. They need different
+ // Cinemeta URLs (/meta/movie/... vs /meta/series/...), and Cinemeta's HTTP-200 stub for a
+ // wrong-type id would otherwise poison one feed's entry with the other's negative result.
+ const movie = row({
+ infoHash: "m1",
+ name: "Driftglass (2020) 1080p",
+ source: "tpb-movies",
+ imdbId: "tt3355555",
+ });
+ const series = row({
+ infoHash: "s1",
+ name: "Driftglass S01E01 1080p",
+ source: "tpb-tv",
+ imdbId: "tt3355555",
+ });
+ expect(metaKindFor(movie)).toBe("movie");
+ expect(metaKindFor(series)).toBe("series");
+ expect(metaCacheKey(movie)).not.toBe(metaCacheKey(series));
+ });
+});
+
+describe("lookupMeta", () => {
+ it("uses a source-provided id and never searches", async () => {
+ const found = meta("tt4400001", "Emberfall");
+ mockMeta.mockResolvedValue(found);
+
+ const r = row({ infoHash: "f1", name: "Emberfall (2021) [1080p BluRay]", imdbId: "tt4400001" });
+ await expect(lookupMeta(r)).resolves.toEqual(found);
+
+ expect(mockSearch).not.toHaveBeenCalled();
+ expect(mockMeta).toHaveBeenCalledTimes(1);
+ expect(mockMeta).toHaveBeenCalledWith("movie", "tt4400001", expect.anything());
+ });
+
+ it("passes the episode coordinates through the id fast path", async () => {
+ mockMeta.mockResolvedValue(meta("tt4400002", "Nightpost"));
+ const r = row({ infoHash: "f2", name: "Nightpost S02E07 1080p", source: "eztv", imdbId: "tt4400002" });
+ await lookupMeta(r);
+ expect(mockMeta).toHaveBeenCalledWith(
+ "series",
+ "tt4400002",
+ expect.objectContaining({ season: 2, episode: 7 }),
+ );
+ });
+
+ it("fetches once for three quality rows of the same film", async () => {
+ mockMeta.mockResolvedValue(meta("tt4400003", "Glasshour"));
+ const rows = ["720p", "1080p", "2160p"].map((q, i) =>
+ row({ infoHash: `g${i}`, name: `Glasshour (2020) [${q} BluRay]`, imdbId: "tt4400003" }),
+ );
+
+ // Concurrently, so the in-flight map is what does the collapsing rather than the cache.
+ const settled = await Promise.all(rows.map((r) => lookupMeta(r)));
+
+ expect(mockMeta).toHaveBeenCalledTimes(1);
+ expect(settled.every((m) => m?.title === "Glasshour")).toBe(true);
+ // And once more after everything has settled, now served by the cache.
+ await lookupMeta(row({ infoHash: "g3", name: "Glasshour (2020) [480p BluRay]", imdbId: "tt4400003" }));
+ expect(mockMeta).toHaveBeenCalledTimes(1);
+ });
+
+ it("searches, matches and fetches when no id is supplied", async () => {
+ mockSearch.mockResolvedValue([hit("tt4400004", "Winterlark", "2017")]);
+ mockMeta.mockResolvedValue(meta("tt4400004", "Winterlark"));
+
+ const r = row({ infoHash: "s1", name: "Winterlark.2017.1080p.BluRay.x264-GRP", source: "tpb-movies" });
+ await expect(lookupMeta(r)).resolves.toEqual(meta("tt4400004", "Winterlark"));
+
+ expect(mockSearch).toHaveBeenCalledWith("movie", "Winterlark", expect.anything());
+ expect(mockMeta).toHaveBeenCalledWith("movie", "tt4400004", expect.anything());
+ });
+
+ it("negatively caches an unmatched row", async () => {
+ // The catalog answers with a different film, so pickBestHit abstains.
+ mockSearch.mockResolvedValue([hit("tt4400005", "Something Else Entirely", "1994")]);
+
+ const r = row({ infoHash: "n1", name: "Duskmarch.2016.1080p.WEB-DL", source: "tpb-movies" });
+ await expect(lookupMeta(r)).resolves.toBeNull();
+ expect(mockSearch).toHaveBeenCalledTimes(1);
+
+ await expect(lookupMeta(r)).resolves.toBeNull();
+ expect(mockSearch).toHaveBeenCalledTimes(1);
+ expect(mockMeta).not.toHaveBeenCalled();
+ // And the miss is visible synchronously, so the hook shows no spinner on a revisit.
+ expect(peekMeta(r)).toBeNull();
+ });
+
+ it("never touches the network for a games row", async () => {
+ const r = row({ infoHash: "fg1", name: "Ravenmoor Deluxe Edition v1.2", source: "fitgirl" });
+ await expect(lookupMeta(r)).resolves.toBeNull();
+ expect(peekMeta(r)).toBeNull();
+ expect(mockSearch).not.toHaveBeenCalled();
+ expect(mockMeta).not.toHaveBeenCalled();
+ });
+
+ it("never touches the network for a name with no searchable title", async () => {
+ const r = row({
+ infoHash: "cjk1",
+ name: "【喵萌奶茶屋】★07月新番★[花織同學][04][1080p][繁體]",
+ source: "nyaa",
+ });
+ await expect(lookupMeta(r)).resolves.toBeNull();
+ expect(peekMeta(r)).toBeNull();
+ expect(mockSearch).not.toHaveBeenCalled();
+ });
+
+ it("returns null on abort and leaves the cache untouched", async () => {
+ mockSearch.mockResolvedValue([hit("tt4400006", "Stormglass", "2015")]);
+ mockMeta.mockResolvedValue(meta("tt4400006", "Stormglass"));
+
+ const ctrl = new AbortController();
+ const r = row({ infoHash: "ab1", name: "Stormglass.2015.1080p.BluRay", source: "tpb-movies" });
+ const pending = lookupMeta(r, { signal: ctrl.signal });
+ ctrl.abort();
+ await expect(pending).resolves.toBeNull();
+
+ // Nothing was written, so the next visit is free to try again rather than being told "no
+ // metadata" for the rest of the TTL.
+ expect(peekMeta(r)).toBeUndefined();
+ await expect(lookupMeta(r)).resolves.toEqual(meta("tt4400006", "Stormglass"));
+ expect(peekMeta(r)).toEqual(meta("tt4400006", "Stormglass"));
+ });
+
+ it("survives a provider that rejects", async () => {
+ // The real searchCatalog never rejects — it swallows everything and returns []. This pins the
+ // belt-and-braces catch in lookupMeta: if that contract is ever broken, the render path still
+ // gets null rather than an unhandled rejection, and nothing is recorded for a failure that
+ // never produced an answer.
+ mockSearch.mockRejectedValue(new Error("boom"));
+ const r = row({ infoHash: "br1", name: "Ashenvale.2022.1080p.WEB-DL", source: "tpb-movies" });
+ await expect(lookupMeta(r)).resolves.toBeNull();
+ expect(peekMeta(r)).toBeUndefined();
+ });
+});
+
+describe("shared requests", () => {
+ it("keeps a request alive for the callers that still want it", async () => {
+ // The Task 5 shape: a detail view and a pane both mounted on one row, then the detail closes.
+ let settle: (m: Meta | null) => void = () => {};
+ mockMeta.mockReturnValue(
+ new Promise((res) => {
+ settle = res;
+ }),
+ );
+ const found = meta("tt4400008", "Palewind");
+ const r = row({ infoHash: "sh1", name: "Palewind (2020) [1080p]", imdbId: "tt4400008" });
+
+ const leaving = new AbortController();
+ const staying = new AbortController();
+ const first = lookupMeta(r, { signal: leaving.signal });
+ const second = lookupMeta(r, { signal: staying.signal });
+
+ leaving.abort();
+ await expect(first).resolves.toBeNull();
+
+ // One caller walking away must not cancel the request, answer null for the other, or stop the
+ // answer reaching the cache — the joiner has no way to notice and retry.
+ settle(found);
+ await expect(second).resolves.toEqual(found);
+ expect(peekMeta(r)).toEqual(found);
+ expect(mockMeta).toHaveBeenCalledTimes(1);
+ });
+
+ it("cancels the request once the last caller has gone", async () => {
+ mockMeta.mockReturnValue(new Promise(() => {}));
+ const r = row({ infoHash: "sh2", name: "Duskvane (2020) [1080p]", imdbId: "tt4400009" });
+
+ const a = new AbortController();
+ const b = new AbortController();
+ const first = lookupMeta(r, { signal: a.signal });
+ const second = lookupMeta(r, { signal: b.signal });
+
+ a.abort();
+ b.abort();
+ await expect(Promise.all([first, second])).resolves.toEqual([null, null]);
+
+ const passed = mockMeta.mock.calls[0]?.[2]?.signal;
+ expect(passed?.aborted).toBe(true);
+ });
+
+ it("starts a fresh request rather than joining a cancelled one", async () => {
+ mockMeta.mockReturnValueOnce(new Promise(() => {}));
+ const found = meta("tt4400010", "Marrowfen");
+ const r = row({ infoHash: "sh3", name: "Marrowfen (2020) [1080p]", imdbId: "tt4400010" });
+
+ const gone = new AbortController();
+ const abandoned = lookupMeta(r, { signal: gone.signal });
+ gone.abort();
+ await expect(abandoned).resolves.toBeNull();
+
+ // The dead flight is still in the map at this point; joining it would relay its null forever.
+ mockMeta.mockResolvedValue(found);
+ await expect(lookupMeta(r)).resolves.toEqual(found);
+ expect(mockMeta).toHaveBeenCalledTimes(2);
+ });
+
+ it("answers a caller that gave up before it asked, without touching the cache", async () => {
+ const found = meta("tt4400011", "Thornhollow");
+ mockMeta.mockResolvedValue(found);
+ const r = row({ infoHash: "sh4", name: "Thornhollow (2020) [1080p]", imdbId: "tt4400011" });
+ await lookupMeta(r);
+ expect(peekMeta(r)).toEqual(found);
+
+ // Even with the answer sitting in the cache, an abandoned caller is told nothing.
+ const dead = AbortSignal.abort();
+ await expect(lookupMeta(r, { signal: dead })).resolves.toBeNull();
+
+ const untouched = row({ infoHash: "sh5", name: "Ravenglass (2020) [1080p]", imdbId: "tt4400012" });
+ mockMeta.mockClear();
+ await expect(lookupMeta(untouched, { signal: dead })).resolves.toBeNull();
+ expect(mockMeta).not.toHaveBeenCalled();
+ });
+});
+
+describe("negative caching", () => {
+ it("forgets a miss long before it forgets a hit", async () => {
+ mockSearch.mockResolvedValue([]);
+ const missed = row({ infoHash: "tt1", name: "Fernmoor.2016.1080p.WEB-DL", source: "tpb-movies" });
+ const found = meta("tt4400013", "Larkspur");
+ mockMeta.mockResolvedValue(found);
+ const hitRow = row({ infoHash: "tt2", name: "Larkspur (2018) [1080p]", imdbId: "tt4400013" });
+
+ await lookupMeta(missed);
+ await lookupMeta(hitRow);
+ expect(peekMeta(missed)).toBeNull();
+ expect(peekMeta(hitRow)).toEqual(found);
+
+ // Five minutes on: a dead network at launch must not have killed metadata for the session, so
+ // the miss is retried while the answer we actually got is still good.
+ const clock = vi.spyOn(Date, "now").mockReturnValue(Date.now() + 5 * 60 * 1000);
+ try {
+ expect(peekMeta(missed)).toBeUndefined();
+ expect(peekMeta(hitRow)).toEqual(found);
+
+ await lookupMeta(missed);
+ expect(mockSearch).toHaveBeenCalledTimes(2);
+ } finally {
+ clock.mockRestore();
+ }
+ });
+});
+
+describe("peekMeta", () => {
+ it("reports an unresolved row as unknown, not as a miss", () => {
+ expect(peekMeta(row({ infoHash: "pk1", name: "Hollowmere (2019) 1080p" }))).toBeUndefined();
+ });
+
+ it("serves a resolved row synchronously", async () => {
+ const found = meta("tt4400007", "Brightwater");
+ mockMeta.mockResolvedValue(found);
+ const r = row({ infoHash: "pk2", name: "Brightwater (2018) [1080p]", imdbId: "tt4400007" });
+ await lookupMeta(r);
+ expect(peekMeta(r)).toEqual(found);
+ });
+});
diff --git a/src/meta/lookup.ts b/src/meta/lookup.ts
new file mode 100644
index 00000000..53542ec2
--- /dev/null
+++ b/src/meta/lookup.ts
@@ -0,0 +1,305 @@
+import { SOURCES } from "../sources/registry";
+import { fetchMeta, searchCatalog } from "./cinemeta";
+import { normalizeTitle, pickBestHit } from "./match";
+import { parseRelease } from "./release";
+import type { ParsedRelease } from "./release";
+import type { SourceGroup, TorrentResult } from "../sources/types";
+import type { Meta, MetaKind } from "./types";
+
+// The orchestrator between a torrent row and Cinemeta: decide whether a row is worth a lookup at
+// all, turn it into a stable cache key, and make sure the same title is only ever fetched once.
+//
+// Shape is deliberately identical to sources/cache.ts — a module-level Map, a TTL constant, a key
+// helper, no eviction and no persistence. A process-lifetime map is right here for the same reason
+// it is right there: torlink is a short-lived terminal session, the working set is the handful of
+// rows the user actually scrolled past, and a Meta is a few hundred bytes.
+//
+// Everything fails soft. This is reached from a React render path, so a dead provider or a hostile
+// payload must degrade to "no metadata", never to an exception that unmounts the TUI.
+
+// Metadata is far more stable than a search result set (which uses 5 minutes): a film's plot and
+// poster do not change inside a session, so an answer we did get is worth keeping for most of one.
+const TTL_MS = 30 * 60 * 1000;
+
+// "No metadata" gets a much shorter life, because at this layer it is ambiguous. cinemeta.ts
+// collapses a dead network, a 502 and a title Cinemeta genuinely does not carry into the same
+// []/null, so a session started while DNS or a VPN is still settling would otherwise record a hard
+// "nothing here" for every row the user scrolled past and keep serving it for half an hour after
+// the network came back. Two minutes still absorbs the case this cache exists for — scrolling up
+// and down a result page takes seconds, and no row is re-queried during it — while capping a
+// transient outage at one stale pass instead of a dead session.
+const NEGATIVE_TTL_MS = 2 * 60 * 1000;
+
+// Matches cinemeta's own per-request budget. It exists here too because the lookup can chain two
+// requests (search, then meta) and the *pair* needs a ceiling, not just each half.
+const TIMEOUT_MS = 6000;
+
+interface Entry {
+ at: number;
+ meta: Meta | null;
+}
+
+const cache = new Map();
+
+/**
+ * One request that more than one caller may be waiting on.
+ *
+ * The request owns its own AbortController rather than borrowing the first caller's signal, and
+ * `refs` counts the callers still interested. Only the last one to walk away cancels it: a caller
+ * losing interest is not the same event as a cancelled request, and conflating the two let one
+ * caller's abort hand every other caller a null they would then never retry.
+ */
+interface Flight {
+ readonly promise: Promise;
+ readonly ctrl: AbortController;
+ /** The composite actually passed to the provider: `ctrl` plus the timeout. */
+ readonly signal: AbortSignal;
+ refs: number;
+}
+
+// A second map so a fast scroll that lands on the same title twice — four YTS quality rows of one
+// film, or a re-select after a re-sort — issues one request instead of one per landing.
+const inflight = new Map();
+
+// Cinemeta is keyed by IMDb primary titles, which are Latin script. A release name that reduces to
+// CJK, Cyrillic or a bare episode number carries nothing to search with, so querying it spends a
+// request per row for a guaranteed miss. Nyaa's Chinese fansub names hit this on every single row:
+// "【喵萌奶茶屋】★07月新番★[花織同學][04][1080p][繁體]" parses to a long, non-empty, entirely
+// unsearchable title, which a plain length check waves straight through.
+//
+// Two Latin letters is the floor because real titles do get that short ("Up", "It", "Her"). The
+// all-digit escape hatch keeps "300", "1917" and "2012" queryable while still rejecting the bare
+// "04" that a fully-bracketed fansub name leaves behind. Mixed titles pass on their Latin half,
+// which is the right call: "Attack on Titan 進撃の巨人" is searchable.
+const LATIN_LETTER = /\p{Script=Latin}/gu;
+const ALL_DIGITS = /^\d{3,4}$/;
+
+export function isSearchableTitle(title: string): boolean {
+ if (typeof title !== "string") return false;
+ const t = title.trim();
+ if (t === "") return false;
+ if (ALL_DIGITS.test(t)) return true;
+ return (t.match(LATIN_LETTER) ?? []).length >= 2;
+}
+
+function groupsFor(id: TorrentResult["source"]): readonly SourceGroup[] | undefined {
+ // Deliberately not getSource(): it falls back to DEFAULT_SOURCE for an unknown id, and that
+ // default is FitGirl — Games-only — so a source id we do not recognise would silently classify
+ // as "never query" instead of falling through to the release name.
+ return SOURCES.find((s) => s.id === id)?.groups;
+}
+
+/**
+ * The source's own category, when it is unambiguous enough to overrule the release name. A YTS row
+ * is a film even when its name carries something the parser reads as a season marker, and a
+ * FitGirl row is a game no matter what the repack is called — which is what keeps games entirely
+ * off the network. Anime is best-effort "series"; Cinemeta files most of it there.
+ *
+ * `every` rather than an index read: it states "only this group" directly and needs no length
+ * check to satisfy noUncheckedIndexedAccess. Hence the emptiness guard, since every([]) is true.
+ */
+function kindFromGroups(groups: readonly SourceGroup[] | undefined, fallback: MetaKind): MetaKind | null {
+ // No groups, or a source that feeds several (BitTorrented is Movies + TV): the name is all the
+ // evidence there is.
+ if (groups === undefined || groups.length === 0) return fallback;
+ if (groups.every((g) => g === "Games")) return null;
+ if (groups.every((g) => g === "Movies")) return "movie";
+ if (groups.every((g) => g === "TV" || g === "Anime")) return "series";
+ return fallback;
+}
+
+/** Which Cinemeta catalog a row belongs in, or null when it must never be queried. */
+export function metaKindFor(r: TorrentResult): MetaKind | null {
+ return plan(r).kind;
+}
+
+interface Plan {
+ readonly kind: MetaKind | null;
+ readonly parsed: ParsedRelease;
+ readonly key: string;
+}
+
+/**
+ * Everything derivable from a row without touching the network, computed once. parseRelease is
+ * pure and cheap but not free, and metaKindFor, metaCacheKey and lookupMeta all want its output.
+ */
+function plan(r: TorrentResult): Plan {
+ const parsed = parseRelease(r.name);
+ const kind = kindFromGroups(groupsFor(r.source), parsed.kind);
+ return { kind, parsed, key: cacheKey(r, kind ?? parsed.kind, parsed) };
+}
+
+/**
+ * The key two rows share when they would produce the same metadata.
+ *
+ * With an id it is the id plus the episode coordinates, because Cinemeta answers a series id with
+ * the whole show and we narrow it to one episode: EZTV hands us the *series* id on every row, so
+ * without the coordinates S05E14 and S05E15 would collide on one entry and the second row would be
+ * served the first one's episode title. Movies carry no coordinates, so YTS's four quality rows of
+ * one film still collapse onto a single entry. `kind` is in the key too: the same IMDb id can arrive
+ * from both a movies feed and a TV feed misclassifying it, and those need different Cinemeta URLs
+ * (`/meta/movie/…` vs `/meta/series/…`). Cinemeta answers HTTP 200 with the "unknown id" stub for a
+ * wrong-type lookup rather than 404ing, so without `kind` here one feed's stub would poison the
+ * other's entry for the whole negative TTL.
+ *
+ * Without an id it is the normalized title plus everything that could distinguish two works with
+ * it, which is what collapses the same film arriving from TPB, 1337x and BitTorrented.
+ */
+function cacheKey(r: TorrentResult, kind: MetaKind, parsed: ParsedRelease): string {
+ const season = parsed.season ?? "";
+ const episode = parsed.episode ?? "";
+ if (r.imdbId !== undefined) return `imdb:${kind}:${r.imdbId}:${season}:${episode}`;
+ return `guess:${kind}:${normalizeTitle(parsed.title)}:${parsed.year ?? ""}:${season}:${episode}`;
+}
+
+export function metaCacheKey(r: TorrentResult): string {
+ return plan(r).key;
+}
+
+function read(key: string): Meta | null | undefined {
+ const hit = cache.get(key);
+ if (hit === undefined) return undefined;
+ // A miss is far less trustworthy than a hit, so it is remembered for far less time.
+ const ttl = hit.meta === null ? NEGATIVE_TTL_MS : TTL_MS;
+ return Date.now() - hit.at < ttl ? hit.meta : undefined;
+}
+
+/**
+ * Synchronous cache read: `undefined` means "not known yet, ask the network", `null` means "there
+ * is no metadata for this row" and a Meta is the answer. The hook calls this first so revisiting a
+ * row it already resolved renders instantly instead of flashing a spinner.
+ *
+ * A row that can never be queried — a game, or a name with no searchable title — answers null
+ * rather than undefined: the answer is already final, and making the caller wait on a request that
+ * will not happen would show that same spinner forever.
+ */
+export function peekMeta(r: TorrentResult): Meta | null | undefined {
+ const p = plan(r);
+ if (p.kind === null) return null;
+ if (r.imdbId === undefined && !isSearchableTitle(p.parsed.title)) return null;
+ return read(p.key);
+}
+
+/**
+ * Commit a result, unless the request that produced it was cancelled.
+ *
+ * This guard is load-bearing. searchCatalog and fetchMeta return []/null for an aborted request
+ * exactly as they do for a dead provider, so an abort is indistinguishable from a genuine miss at
+ * this point — and caching it would pin "no metadata" on the row for the full TTL just because the
+ * user scrolled past it before the answer arrived.
+ */
+function commit(key: string, meta: Meta | null, signal: AbortSignal): Meta | null {
+ if (signal.aborted) return null;
+ cache.set(key, { at: Date.now(), meta });
+ return meta;
+}
+
+async function resolve(r: TorrentResult, p: Plan, kind: MetaKind, signal: AbortSignal): Promise {
+ // Only meaningful for a series, and fetchMeta wants both halves or neither.
+ const episodeOpts =
+ kind === "series" && p.parsed.season !== undefined && p.parsed.episode !== undefined
+ ? { season: p.parsed.season, episode: p.parsed.episode }
+ : {};
+
+ // Fast path: the source already told us what this is, so skip the guessing round trip entirely.
+ if (r.imdbId !== undefined) {
+ return commit(p.key, await fetchMeta(kind, r.imdbId, { signal, ...episodeOpts }), signal);
+ }
+
+ const hits = await searchCatalog(kind, p.parsed.title, { signal });
+ const best = pickBestHit(p.parsed, hits);
+ // A confident abstention is worth remembering: scrolling repeatedly past an unmatched row should
+ // cost nothing after the first pass.
+ if (best === null) return commit(p.key, null, signal);
+ return commit(p.key, await fetchMeta(kind, best.imdbId, { signal, ...episodeOpts }), signal);
+}
+
+/** Begin a request nobody is waiting on yet, and register it so the next caller can join it. */
+function start(r: TorrentResult, p: Plan, kind: MetaKind): Flight {
+ const ctrl = new AbortController();
+ // The request's own deadline plus its own cancel handle. No caller signal is folded in here:
+ // cancellation is driven by the refcount below, so the request outlives any single caller.
+ const signal = AbortSignal.any([ctrl.signal, AbortSignal.timeout(TIMEOUT_MS)]);
+ const flight: Flight = {
+ ctrl,
+ signal,
+ refs: 0,
+ // resolve() only calls functions that already swallow their own failures, but this is a render
+ // path: one unforeseen throw here would surface as an unhandled rejection in the TUI.
+ promise: resolve(r, p, kind, signal).catch((): Meta | null => null),
+ };
+ inflight.set(p.key, flight);
+ // Retire the entry once it settles — but only if it is still the current one, so a replacement
+ // started after a cancellation is never evicted by its predecessor.
+ void flight.promise.finally(() => {
+ if (inflight.get(p.key) === flight) inflight.delete(p.key);
+ });
+ return flight;
+}
+
+/**
+ * Wait on a shared request as one of possibly several callers.
+ *
+ * A caller gets its own answer the moment its own signal aborts, without disturbing anyone else's.
+ * The request itself is only cancelled when the count of interested callers reaches zero, which is
+ * what keeps the two `useResultMeta` instances Task 5 mounts on one row from poisoning each other:
+ * closing the detail view must not tell the still-open pane there is no metadata.
+ */
+async function join(flight: Flight, signal: AbortSignal | undefined): Promise {
+ flight.refs += 1;
+ // Detaches the abort listener below; a caller's signal can easily outlive this join.
+ const detach = new AbortController();
+ let released = false;
+ const release = (): void => {
+ if (released) return;
+ released = true;
+ detach.abort();
+ flight.refs -= 1;
+ if (flight.refs <= 0) flight.ctrl.abort();
+ };
+
+ try {
+ if (signal === undefined) return await flight.promise;
+ const cancelled = new Promise((settle) => {
+ // Released from inside the listener rather than from the finally below, so the last caller's
+ // abort reaches the provider synchronously — before any in-flight answer can be committed.
+ signal.addEventListener(
+ "abort",
+ () => {
+ release();
+ settle(null);
+ },
+ { once: true, signal: detach.signal },
+ );
+ });
+ return await Promise.race([flight.promise, cancelled]);
+ } finally {
+ release();
+ }
+}
+
+/**
+ * Metadata for one row, or null when there is none to be had. Never throws and never rejects.
+ */
+export async function lookupMeta(
+ r: TorrentResult,
+ opts: { signal?: AbortSignal } = {},
+): Promise {
+ const p = plan(r);
+ const kind = p.kind;
+ if (kind === null) return null;
+ if (r.imdbId === undefined && !isSearchableTitle(p.parsed.title)) return null;
+ // A caller that has already given up gets nothing and starts nothing — including on the cache
+ // path, where answering an abandoned request would be pointless work either way.
+ if (opts.signal?.aborted === true) return null;
+
+ const cached = read(p.key);
+ if (cached !== undefined) return cached;
+
+ const existing = inflight.get(p.key);
+ // A flight whose own signal has already fired — the last caller left, or the deadline passed —
+ // will never commit anything, so joining it would just relay its null. Start over instead.
+ const flight =
+ existing !== undefined && !existing.signal.aborted ? existing : start(r, p, kind);
+ return join(flight, opts.signal);
+}
diff --git a/src/meta/match.test.ts b/src/meta/match.test.ts
new file mode 100644
index 00000000..342d8426
--- /dev/null
+++ b/src/meta/match.test.ts
@@ -0,0 +1,168 @@
+import { describe, it, expect } from "vitest";
+import { normalizeTitle, pickBestHit, scoreHit } from "./match";
+import { parseRelease, type ParsedRelease } from "./release";
+import type { CatalogHit } from "./types";
+
+const hit = (name: string, releaseInfo?: string, imdbId = "tt0000001"): CatalogHit => ({
+ imdbId,
+ name,
+ kind: "movie",
+ ...(releaseInfo !== undefined ? { releaseInfo } : {}),
+});
+
+const release = (title: string, year?: number): ParsedRelease => ({
+ title,
+ kind: "movie",
+ ...(year !== undefined ? { year } : {}),
+});
+
+const MATRIX = hit("The Matrix", "1999", "tt0133093");
+const RESURRECTIONS = hit("The Matrix Resurrections", "2021", "tt10838180");
+const RELOADED = hit("The Matrix Reloaded", "2003", "tt0234215");
+
+describe("normalizeTitle", () => {
+ it("lowercases, drops punctuation and collapses whitespace", () => {
+ expect(normalizeTitle("The Lord of the Rings: The Two Towers")).toBe("lord of rings two towers");
+ expect(normalizeTitle("Spider-Man - No Way Home!")).toBe("spider man no way home");
+ expect(normalizeTitle("WALL·E")).toBe("wall e");
+ });
+
+ it("folds accents so a catalog spelling and a scene spelling meet", () => {
+ expect(normalizeTitle("Amélie")).toBe(normalizeTitle("Amelie"));
+ expect(normalizeTitle("Léon: The Professional")).toBe("leon professional");
+ });
+
+ it("drops articles wherever they appear, since providers disagree about them", () => {
+ expect(normalizeTitle("The Office")).toBe("office");
+ expect(normalizeTitle("Office, The")).toBe("office");
+ expect(normalizeTitle("A Quiet Place")).toBe("quiet place");
+ });
+
+ it("keeps digits, which are often the whole title", () => {
+ expect(normalizeTitle("2012")).toBe("2012");
+ expect(normalizeTitle("Blade Runner 2049")).toBe("blade runner 2049");
+ });
+
+ it("returns an empty string for input with nothing to compare", () => {
+ expect(normalizeTitle("")).toBe("");
+ expect(normalizeTitle("---")).toBe("");
+ expect(normalizeTitle("The")).toBe("");
+ });
+});
+
+describe("scoreHit", () => {
+ it("scores an exact title with an agreeing year highest", () => {
+ // exact 100 + prefix 60 + tokens 30 + year 40
+ expect(scoreHit(release("The Matrix", 1999), MATRIX)).toBe(230);
+ });
+
+ it("allows a year to be one out, because catalogs and releases date films differently", () => {
+ expect(scoreHit(release("The Matrix", 2000), MATRIX)).toBe(230);
+ expect(scoreHit(release("The Matrix", 1998), MATRIX)).toBe(230);
+ });
+
+ it("penalises a contradicted year", () => {
+ // exact 100 + prefix 60 + tokens 30 − year 40
+ expect(scoreHit(release("The Matrix", 1997), MATRIX)).toBe(150);
+ });
+
+ it("scores a prefix match without an exact match", () => {
+ // prefix 60 + tokens 30, no year on either side
+ expect(scoreHit(release("The Matrix"), RESURRECTIONS)).toBe(90);
+ });
+
+ it("does not treat a longer word as a prefix match", () => {
+ expect(scoreHit(release("Matrix"), hit("Matrixxx"))).toBe(0);
+ });
+
+ it("scores token containment when word order or padding differs", () => {
+ // tokens 30 only: "the professional" is not a prefix of "leon professional"
+ expect(scoreHit(release("The Professional"), hit("Léon: The Professional"))).toBe(30);
+ });
+
+ it("scores nothing when either side normalizes away", () => {
+ expect(scoreHit(release(""), MATRIX)).toBe(0);
+ expect(scoreHit(release("The Matrix"), hit(""))).toBe(0);
+ });
+
+ it("ignores a year the hit does not carry", () => {
+ expect(scoreHit(release("The Matrix", 1999), hit("The Matrix"))).toBe(190);
+ });
+
+ it("reads the leading year of a series release span", () => {
+ const bb = { imdbId: "tt0903747", name: "Breaking Bad", releaseInfo: "2008–2013", kind: "series" } as const;
+ const parsed: ParsedRelease = { title: "Breaking Bad", kind: "series", year: 2008, season: 5, episode: 14 };
+ expect(scoreHit(parsed, bb)).toBe(230);
+ // The span's later years are not the leading year, so they contradict.
+ expect(scoreHit({ ...parsed, year: 2013 }, bb)).toBe(150);
+ expect(scoreHit({ ...parsed, year: 2008 }, { ...bb, releaseInfo: "2016–" })).toBe(150);
+ });
+});
+
+describe("pickBestHit", () => {
+ it("uses the year to separate a film from its sequels", () => {
+ expect(pickBestHit(release("The Matrix", 1999), [RESURRECTIONS, RELOADED, MATRIX])?.imdbId).toBe(
+ "tt0133093",
+ );
+ expect(pickBestHit(release("The Matrix Resurrections", 2021), [MATRIX, RESURRECTIONS])?.imdbId).toBe(
+ "tt10838180",
+ );
+ });
+
+ it("lets an exact title outrank a sequel whose year happens to agree", () => {
+ // "The Matrix" + 2021 is 150 on the original (exact, wrong year) against 130 on Resurrections
+ // (prefix, right year). The title is the stronger claim: a release that meant the sequel would
+ // have carried the sequel's name.
+ expect(pickBestHit(release("The Matrix", 2021), [MATRIX, RESURRECTIONS])?.imdbId).toBe("tt0133093");
+ });
+
+ it("prefers the exact title when no year is available at all", () => {
+ expect(pickBestHit(release("The Matrix"), [RESURRECTIONS, MATRIX])?.imdbId).toBe("tt0133093");
+ });
+
+ it("returns null for an empty hit list", () => {
+ expect(pickBestHit(release("The Matrix", 1999), [])).toBeNull();
+ });
+
+ it("returns null for a plausible-but-wrong title rather than showing the wrong poster", () => {
+ // prefix 60 + tokens 30 − year 40 = 50, just under the threshold: the year says this is a
+ // different film, and a wrong poster is worse than no poster.
+ expect(scoreHit(release("The Matrix", 1999), RESURRECTIONS)).toBe(50);
+ expect(pickBestHit(release("The Matrix", 1999), [RESURRECTIONS])).toBeNull();
+ });
+
+ it("returns null when nothing shares enough words", () => {
+ expect(pickBestHit(release("Arrival", 2016), [hit("Arrested Development", "2003")])).toBeNull();
+ expect(pickBestHit(release("Dune", 2021), [hit("Dune: Part Two", "2024")])).toBeNull();
+ });
+
+ it("accepts a hit that clears the threshold exactly", () => {
+ // tokens 30 + year 40 = 70; token containment plus an agreeing year is enough on its own.
+ expect(scoreHit(release("The Professional", 1994), hit("Léon: The Professional", "1994"))).toBe(70);
+ expect(pickBestHit(release("The Professional", 1994), [hit("Léon: The Professional", "1994")])).not.toBeNull();
+ });
+
+ it("rejects a hit one point short of the threshold", () => {
+ // A bare prefix match with no other evidence is 60 and is kept; take the tokens away and the
+ // same hit falls to 0. There is no partial credit between them by design.
+ expect(scoreHit(release("The Matrix"), RELOADED)).toBe(90);
+ expect(scoreHit(release("Matrix Revolutions"), RELOADED)).toBe(0);
+ expect(pickBestHit(release("Matrix Revolutions"), [RELOADED])).toBeNull();
+ });
+
+ it("keeps the earlier hit when two score the same", () => {
+ const a = hit("The Matrix", "1999", "tt0133093");
+ const b = hit("The Matrix", "1999", "tt9999999");
+ expect(pickBestHit(release("The Matrix", 1999), [a, b])?.imdbId).toBe("tt0133093");
+ });
+
+ it("matches a real release name end to end", () => {
+ const parsed = parseRelease("The.Matrix.1999.1080p.BluRay.x264-GROUP");
+ expect(pickBestHit(parsed, [RESURRECTIONS, MATRIX, RELOADED])?.imdbId).toBe("tt0133093");
+ });
+
+ it("declines a release name that parsed to junk", () => {
+ const parsed = parseRelease("1080p x264");
+ expect(pickBestHit(parsed, [MATRIX])).toBeNull();
+ });
+});
diff --git a/src/meta/match.ts b/src/meta/match.ts
new file mode 100644
index 00000000..fff8cf25
--- /dev/null
+++ b/src/meta/match.ts
@@ -0,0 +1,100 @@
+import type { ParsedRelease } from "./release";
+import type { CatalogHit } from "./types";
+
+// A provider's search endpoint answers a fuzzy query with a ranked-by-popularity list, and
+// popularity is not relevance: querying "The Matrix" returns the sequels and the documentaries
+// too. This module decides which hit — if any — the release name actually meant.
+//
+// The bias is deliberate and one-sided: showing the wrong film's poster and plot next to a
+// torrent is worse than showing nothing, because the user cannot tell it is wrong. Every rule
+// here is therefore built to abstain rather than guess.
+
+// Articles carry no identity and providers disagree about them ("The Office" vs "Office, The"),
+// so they are dropped rather than compared.
+const ARTICLES: ReadonlySet = new Set(["the", "a", "an"]);
+
+// Below this, we show nothing. This is the single quality knob in the feature: the point scale
+// below is arranged so that a title-prefix match with a contradicted year (60 + 30 − 40 = 50)
+// lands under it, while the same prefix match with no year claim at all (60 + 30) clears it.
+const THRESHOLD = 60;
+
+const EXACT_TITLE = 100;
+const PREFIX_TITLE = 60;
+const ALL_TOKENS = 30;
+const YEAR_AGREES = 40;
+const YEAR_CONTRADICTS = -40;
+
+// A release name and a catalog name for the same work differ in punctuation, case and accents far
+// more often than in words. Fold all three away so the comparison is about the words.
+const DIACRITICS = /[\u0300-\u036f]/g;
+const NON_ALPHANUMERIC = /[^\p{L}\p{N}]+/gu;
+
+export function normalizeTitle(s: string): string {
+ if (typeof s !== "string") return "";
+ const folded = s.toLowerCase().normalize("NFKD").replace(DIACRITICS, "");
+ return folded
+ .replace(NON_ALPHANUMERIC, " ")
+ .split(" ")
+ .filter((t) => t !== "" && !ARTICLES.has(t))
+ .join(" ");
+}
+
+/**
+ * The first four-digit run of `releaseInfo`. A movie sends "1999"; a series sends a span,
+ * "2008–2013" or open-ended "2016–", and its first year is the one a release name would carry.
+ */
+function leadingYear(releaseInfo: string | undefined): number | undefined {
+ if (releaseInfo === undefined) return undefined;
+ const m = releaseInfo.match(/\d{4}/);
+ if (m === null) return undefined;
+ const n = Number.parseInt(m[0], 10);
+ return Number.isFinite(n) ? n : undefined;
+}
+
+/**
+ * Additive score for one candidate. Higher is a better match; the caller decides what is good
+ * enough. Total by construction — an unparseable title scores 0, it does not throw.
+ */
+export function scoreHit(parsed: ParsedRelease, hit: CatalogHit): number {
+ const want = normalizeTitle(parsed.title);
+ const got = normalizeTitle(hit.name);
+ if (want === "" || got === "") return 0;
+
+ let score = 0;
+ if (want === got) score += EXACT_TITLE;
+ // Prefix on a word boundary, not on characters: "matrix" must not claim "matrixxx", but it
+ // should still recognise "matrix reloaded" as a near miss worth scoring.
+ if (got === want || got.startsWith(`${want} `)) score += PREFIX_TITLE;
+
+ const gotTokens = new Set(got.split(" "));
+ const wantTokens = want.split(" ");
+ // Rewards a reordered or padded title ("Léon: The Professional" vs "The Professional") that the
+ // prefix rule cannot see.
+ if (wantTokens.every((t) => gotTokens.has(t))) score += ALL_TOKENS;
+
+ const hitYear = leadingYear(hit.releaseInfo);
+ if (parsed.year !== undefined && hitYear !== undefined) {
+ // ±1 because release names disagree with catalogs about festival, limited and regional
+ // release dates all the time. A wider window would stop separating a film from its remake.
+ score += Math.abs(parsed.year - hitYear) <= 1 ? YEAR_AGREES : YEAR_CONTRADICTS;
+ }
+
+ return score;
+}
+
+/**
+ * The best-scoring hit, or null when nothing is convincing enough. Ties keep the earlier hit:
+ * providers return their list popularity-first, which is the right tiebreak for equal evidence.
+ */
+export function pickBestHit(parsed: ParsedRelease, hits: readonly CatalogHit[]): CatalogHit | null {
+ let best: CatalogHit | null = null;
+ let bestScore = 0;
+ for (const hit of hits) {
+ const score = scoreHit(parsed, hit);
+ if (score > bestScore) {
+ bestScore = score;
+ best = hit;
+ }
+ }
+ return bestScore >= THRESHOLD ? best : null;
+}
diff --git a/src/meta/poster.test.ts b/src/meta/poster.test.ts
new file mode 100644
index 00000000..93619c36
--- /dev/null
+++ b/src/meta/poster.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { POSTER_HOSTS, fetchPosterBytes, isAllowedPosterUrl, isJpeg } from "./poster";
+import { fetchResilient } from "../util/net";
+
+vi.mock("../util/net", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, fetchResilient: vi.fn() };
+});
+
+const mockFetch = vi.mocked(fetchResilient);
+
+const AMAZON = "https://m.media-amazon.com/images/M/MV5BN2Nm._V1_SX120.jpg";
+const METAHUB = "https://images.metahub.space/poster/small/tt0133093/img?format=jpeg";
+
+const jpegBody = (extra = 0): Uint8Array =>
+ new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...new Array(extra).fill(0)]);
+
+/** WebP: the body metahub sometimes returns regardless of `?format=jpeg`. */
+const WEBP_BODY = new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]);
+
+function respond(body: Uint8Array, init: { status?: number; length?: string } = {}): void {
+ const headers = new Headers();
+ headers.set("content-length", init.length ?? String(body.byteLength));
+ mockFetch.mockResolvedValue(new Response(body, { status: init.status ?? 200, headers }));
+}
+
+beforeEach(() => {
+ mockFetch.mockReset();
+});
+
+describe("isAllowedPosterUrl", () => {
+ it("accepts https on each allowlisted host", () => {
+ for (const host of POSTER_HOSTS) {
+ expect(isAllowedPosterUrl(`https://${host}/poster.jpg`)).toBe(true);
+ }
+ });
+
+ it("rejects any scheme but https", () => {
+ expect(isAllowedPosterUrl("http://images.metahub.space/poster.jpg")).toBe(false);
+ expect(isAllowedPosterUrl("file:///etc/passwd")).toBe(false);
+ expect(isAllowedPosterUrl("data:image/jpeg;base64,/9j/")).toBe(false);
+ });
+
+ it("rejects hosts that merely look allowlisted", () => {
+ // The suffix and prefix tricks a substring match would wave through.
+ expect(isAllowedPosterUrl("https://images.metahub.space.evil.test/p.jpg")).toBe(false);
+ expect(isAllowedPosterUrl("https://evil.test/images.metahub.space/p.jpg")).toBe(false);
+ expect(isAllowedPosterUrl("https://notimages.metahub.space/p.jpg")).toBe(false);
+ });
+
+ it("rejects a port or credentials smuggled into the authority", () => {
+ expect(isAllowedPosterUrl("https://images.metahub.space:8443/p.jpg")).toBe(false);
+ expect(isAllowedPosterUrl("https://user:pw@images.metahub.space/p.jpg")).toBe(false);
+ });
+
+ it("rejects anything that is not a URL at all", () => {
+ expect(isAllowedPosterUrl("")).toBe(false);
+ expect(isAllowedPosterUrl("images.metahub.space/p.jpg")).toBe(false);
+ expect(isAllowedPosterUrl("not a url")).toBe(false);
+ });
+});
+
+describe("isJpeg", () => {
+ it("accepts a start-of-image marker and rejects everything else", () => {
+ expect(isJpeg(jpegBody())).toBe(true);
+ expect(isJpeg(WEBP_BODY)).toBe(false);
+ expect(isJpeg(new Uint8Array([0x89, 0x50, 0x4e, 0x47]))).toBe(false);
+ expect(isJpeg(new Uint8Array([0xff, 0xd8]))).toBe(false);
+ expect(isJpeg(new Uint8Array(0))).toBe(false);
+ });
+});
+
+describe("fetchPosterBytes", () => {
+ it("returns the bytes for an allowlisted JPEG", async () => {
+ respond(jpegBody(120));
+ await expect(fetchPosterBytes(AMAZON)).resolves.toEqual(jpegBody(120));
+ });
+
+ it("retries once — enough for a transient blip, not enough to outlive the cursor", async () => {
+ respond(jpegBody());
+ await fetchPosterBytes(METAHUB);
+ expect(mockFetch).toHaveBeenCalledWith(METAHUB, expect.objectContaining({ retries: 1 }));
+ });
+
+ it("never leaves the allowlist, and does not even open a connection to try", async () => {
+ await expect(fetchPosterBytes("https://evil.test/p.jpg")).resolves.toBeNull();
+ await expect(fetchPosterBytes("http://m.media-amazon.com/p.jpg")).resolves.toBeNull();
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it("returns null for a non-OK response", async () => {
+ respond(jpegBody(), { status: 404 });
+ await expect(fetchPosterBytes(METAHUB)).resolves.toBeNull();
+ });
+
+ it("refuses an oversized body from its declared length, before reading it", async () => {
+ respond(jpegBody(), { length: String(2 * 1024 * 1024) });
+ await expect(fetchPosterBytes(METAHUB)).resolves.toBeNull();
+ });
+
+ it("refuses an oversized body that declared nothing", async () => {
+ // The chunked case: content-length is absent, so only the post-read cap can catch it.
+ const huge = jpegBody(2 * 1024 * 1024);
+ mockFetch.mockResolvedValue(new Response(huge, { status: 200 }));
+ await expect(fetchPosterBytes(METAHUB)).resolves.toBeNull();
+ });
+
+ it("rejects a WebP body rather than handing the decoder garbage", async () => {
+ respond(WEBP_BODY);
+ await expect(fetchPosterBytes(METAHUB)).resolves.toBeNull();
+ });
+
+ it("returns null when the request throws", async () => {
+ mockFetch.mockRejectedValue(new Error("ENOTFOUND"));
+ await expect(fetchPosterBytes(METAHUB)).resolves.toBeNull();
+ });
+});
diff --git a/src/meta/poster.ts b/src/meta/poster.ts
new file mode 100644
index 00000000..95d63523
--- /dev/null
+++ b/src/meta/poster.ts
@@ -0,0 +1,93 @@
+import { fetchResilient, USER_AGENT } from "../util/net";
+
+// The only outbound image requests torlink makes. Poster URLs come back from a remote catalog, so
+// the host list is enforced here as well as at the mapping boundary in cinemeta.ts: this module is
+// exported and a future caller could hand it a URL that never went through mapMeta.
+export const POSTER_HOSTS = [
+ "images.metahub.space",
+ "live.metahub.space",
+ "m.media-amazon.com",
+] as const;
+
+const HOSTS: ReadonlySet = new Set(POSTER_HOSTS);
+
+// Matches cinemeta's per-request budget. A poster is decoration on a row the cursor is sitting on;
+// once the user has moved, the bytes are worthless however cheap they were.
+const TIMEOUT_MS = 6000;
+
+// The renditions we ask for are 8-40 KB. A megabyte is generous enough that no legitimate poster
+// approaches it and tight enough that a hostile or misconfigured host cannot make a terminal app
+// buffer an unbounded body.
+const MAX_POSTER_BYTES = 1_048_576;
+
+/** Pure: https, an allowlisted host, and no port or credentials smuggled into the authority. */
+export function isAllowedPosterUrl(url: string): boolean {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return false;
+ }
+ // hostname, not host: `new URL` has already lowercased and punycoded it, and comparing the whole
+ // authority would let "images.metahub.space:8443" or a userinfo prefix past a naive match.
+ return (
+ parsed.protocol === "https:" &&
+ parsed.port === "" &&
+ parsed.username === "" &&
+ parsed.password === "" &&
+ HOSTS.has(parsed.hostname)
+ );
+}
+
+/**
+ * True only for a JFIF/EXIF start-of-image marker.
+ *
+ * Metahub answers WebP for some ids regardless of `?format=jpeg`, and a WebP body starts `RIFF`.
+ * Sniffing rejects it here — jpeg-js would otherwise be handed bytes it will throw on, and a
+ * thrown decode is a slower, noisier way to reach the same "no art" answer.
+ */
+export function isJpeg(bytes: Uint8Array): boolean {
+ return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
+}
+
+// A caller's cancellation and our own deadline are both reasons to stop; the request honours
+// whichever fires first. Same shape as cinemeta's, kept local because it is three lines and this
+// module has no other reason to depend on the metadata client.
+function deadline(signal?: AbortSignal): AbortSignal {
+ const timeout = AbortSignal.timeout(TIMEOUT_MS);
+ return signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
+}
+
+/**
+ * Fetch poster bytes, or null for every failure there is.
+ *
+ * One retry, not cinemeta's zero and not the default five: the poster request is fired after the
+ * metadata that named it already landed, so the row has demonstrably held the cursor long enough
+ * to be worth a second attempt at a transient 502 — but not a third, by which time the user has
+ * scrolled on.
+ */
+export async function fetchPosterBytes(
+ url: string,
+ opts: { signal?: AbortSignal } = {},
+): Promise {
+ if (!isAllowedPosterUrl(url)) return null;
+ try {
+ const res = await fetchResilient(url, {
+ retries: 1,
+ headers: { "User-Agent": USER_AGENT, Accept: "image/jpeg" },
+ signal: deadline(opts.signal),
+ });
+ if (!res.ok) return null;
+
+ // Refuse an oversized body before reading it. content-length is advisory and a chunked
+ // response omits it entirely, so the real cap is the one after the read.
+ const declared = Number(res.headers.get("content-length"));
+ if (Number.isFinite(declared) && declared > MAX_POSTER_BYTES) return null;
+
+ const bytes = new Uint8Array(await res.arrayBuffer());
+ if (bytes.byteLength > MAX_POSTER_BYTES) return null;
+ return isJpeg(bytes) ? bytes : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/meta/release.test.ts b/src/meta/release.test.ts
new file mode 100644
index 00000000..ee22f25d
--- /dev/null
+++ b/src/meta/release.test.ts
@@ -0,0 +1,184 @@
+import { describe, it, expect } from "vitest";
+import {
+ parseRelease,
+ normalizeSeparators,
+ findEpisodeMarker,
+ findYear,
+ firstJunkIndex,
+} from "./release";
+import type { ParsedRelease } from "./release";
+
+type Row = readonly [name: string, expected: Partial];
+
+// Every row below the first three is a real name sampled from a live source adapter.
+const TABLE: readonly Row[] = [
+ [
+ "The.Matrix.1999.1080p.BluRay.x264-GROUP",
+ { title: "The Matrix", year: 1999, kind: "movie", group: "GROUP" },
+ ],
+ ["Show.S01E05.720p.WEB", { title: "Show", season: 1, episode: 5, kind: "series" }],
+ [
+ "[SubsPlease] Show - 01 (1080p) [ABCD1234].mkv",
+ { title: "Show", episode: 1, kind: "series", group: "SubsPlease" },
+ ],
+ ["The.Odyssey.2026.1080p.TELESYNC.HEVC.AAC2.0-SPLiCE", { title: "The Odyssey", year: 2026 }],
+ ["Disclosure Day (2026) [1080p] [WEBRip] [5.1]", { title: "Disclosure Day", year: 2026 }],
+ [
+ "Marvel Studios Iron Man 2008 1080p MA WEB-DL DDP5 1 H 264-SARVO",
+ { title: "Marvel Studios Iron Man", year: 2008 },
+ ],
+ [
+ "Rick and Morty S09E10 Field of Dreams 1080p AMZN WEB-DL DDP5 1 H 264-FLUX",
+ { title: "Rick and Morty", season: 9, episode: 10 },
+ ],
+ [
+ "House of the Dragon S03E07 1080p WEB H264-CAKES",
+ { title: "House of the Dragon", season: 3, episode: 7 },
+ ],
+ [
+ "Spider-Man: Brand New Day 2026.1080p.HQ Pre.Multi.AAC 2.0.x264",
+ { title: "Spider-Man: Brand New Day", year: 2026 },
+ ],
+ [
+ "Avatar.The.Legend.of.Aang.The.Last.Airbender.2026.1080p.PMNTP.WEBRip.AAC2.0.H264-[LEAK].mp4",
+ { title: "Avatar The Legend of Aang The Last Airbender", year: 2026 },
+ ],
+ ["Oppenheimer (2023) [1080p bluray]", { title: "Oppenheimer", year: 2023 }],
+ ["Breaking Bad S05E14 1080p WEB-DL", { title: "Breaking Bad", season: 5, episode: 14 }],
+ ["Frieren - 28 [1080p]", { title: "Frieren", episode: 28, kind: "series" }],
+ [
+ "Tensei Shitara Slime Datta Ken S4 - 17 [1080p]",
+ { title: "Tensei Shitara Slime Datta Ken", season: 4, episode: 17 },
+ ],
+ ["Hell Mode S2 - 06v2 [1080p]", { title: "Hell Mode", season: 2, episode: 6 }],
+ [
+ "[Erai-raws] Jujutsu Kaisen S2 - 23 [1080p]",
+ { title: "Jujutsu Kaisen", season: 2, episode: 23, group: "Erai-raws" },
+ ],
+ ["[WZF]Bleach_-_100[X264-AAC][784x576][Sub_Esp][MP4]", { title: "Bleach", episode: 100 }],
+ [
+ "Zillow Gone Wild S03E14 Enchanted Forest 480p WEB-DL x264-RMTeam EZTV",
+ { title: "Zillow Gone Wild", season: 3, episode: 14 },
+ ],
+ [
+ "Elden Ring: Shadow of the Erdtree Edition",
+ { title: "Elden Ring: Shadow of the Erdtree Edition", kind: "movie" },
+ ],
+];
+
+describe("parseRelease", () => {
+ for (const [name, expected] of TABLE) {
+ it(`parses ${name}`, () => {
+ expect(parseRelease(name)).toMatchObject(expected);
+ });
+ }
+
+ // A bare hyphenated title has the same shape as a scene "-GROUP" suffix. Trackers really do
+ // post names this minimal, and mistaking "Man" for a group would send "Spider" to the lookup.
+ // ("Mad-Max" is deliberately not in this list: "max" is source vocabulary, so it would pass on
+ // the junk guard alone and prove nothing about the corroboration rule.)
+ for (const bare of ["Spider-Man", "Ant-Man", "X-Men", "Kill-Bill"]) {
+ it(`keeps the hyphenated title ${bare} intact and extracts no group`, () => {
+ const parsed = parseRelease(bare);
+ expect(parsed.title).toBe(bare);
+ expect(parsed.group).toBeUndefined();
+ });
+ }
+
+ it("still takes a trailing group when scene context corroborates it", () => {
+ expect(parseRelease("Ant-Man.2015.1080p.BluRay.x264-GROUP")).toMatchObject({
+ title: "Ant-Man",
+ year: 2015,
+ group: "GROUP",
+ });
+ });
+
+ it("survives full-width brackets and CJK without throwing", () => {
+ const parsed = parseRelease("【喵萌奶茶屋】★07月新番★[花織同學][04][1080p][繁體]");
+ expect(typeof parsed.title).toBe("string");
+ });
+
+ it("parses a bare parenthesised year", () => {
+ expect(parseRelease("Old School (2003)")).toMatchObject({ title: "Old School", year: 2003 });
+ });
+
+ for (const degenerate of ["", ".", "[]", "2012"]) {
+ it(`returns a string title for the degenerate input ${JSON.stringify(degenerate)}`, () => {
+ const parsed = parseRelease(degenerate);
+ expect(typeof parsed.title).toBe("string");
+ expect(parsed.title === "" || degenerate.includes(parsed.title)).toBe(true);
+ });
+ }
+
+ it("never throws and always yields a string title over a junk corpus", () => {
+ const alphabet = ["", ".", "-", "_", "[", "]", "(", ")", "S01E01", "1080p", "2020", "x", "喵"];
+ for (let i = 0; i < 3000; i++) {
+ let name = "";
+ // Deterministic pseudo-random walk: a seeded corpus keeps failures reproducible.
+ let seed = i * 2654435761;
+ for (let j = 0; j < 8; j++) {
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
+ name += alphabet[seed % alphabet.length];
+ }
+ const parsed = parseRelease(name);
+ expect(typeof parsed.title).toBe("string");
+ expect(parsed.kind === "movie" || parsed.kind === "series").toBe(true);
+ }
+ });
+});
+
+describe("normalizeSeparators", () => {
+ it("always turns underscores into spaces", () => {
+ expect(normalizeSeparators("Some_Show_Name")).toBe("Some Show Name");
+ });
+
+ it("turns dots into spaces when dots outnumber spaces", () => {
+ expect(normalizeSeparators("The.Matrix.1999.1080p")).toBe("The Matrix 1999 1080p");
+ });
+
+ it("keeps dots when spaces already dominate, so decimals survive", () => {
+ expect(normalizeSeparators("Some Long Show Name AAC 2.0")).toBe("Some Long Show Name AAC 2.0");
+ });
+});
+
+describe("findEpisodeMarker", () => {
+ it("finds SxxExx", () => {
+ expect(findEpisodeMarker("Breaking Bad S05E14 1080p")).toMatchObject({ season: 5, episode: 14 });
+ });
+
+ it("finds the 1x02 form", () => {
+ expect(findEpisodeMarker("Some Show 3x07 720p")).toMatchObject({ season: 3, episode: 7 });
+ });
+
+ it("finds a bare season", () => {
+ expect(findEpisodeMarker("Hell Mode S2")).toMatchObject({ season: 2 });
+ });
+
+ it("returns null when there is no marker at all", () => {
+ expect(findEpisodeMarker("The Matrix 1999 1080p BluRay")).toBeNull();
+ });
+});
+
+describe("findYear", () => {
+ it("prefers a parenthesised year", () => {
+ expect(findYear("Old School (2003)")).toMatchObject({ year: 2003 });
+ });
+
+ it("takes a standalone year followed by junk", () => {
+ expect(findYear("The Matrix 1999 1080p BluRay")).toMatchObject({ year: 1999 });
+ });
+
+ it("refuses a year that is the only remaining token", () => {
+ expect(findYear("2012")).toBeNull();
+ });
+});
+
+describe("firstJunkIndex", () => {
+ it("reports the first junk token position", () => {
+ expect(firstJunkIndex(["The", "Matrix", "1080p", "BluRay"])).toBe(2);
+ });
+
+ it("reports -1 when nothing is junk", () => {
+ expect(firstJunkIndex(["Elden", "Ring"])).toBe(-1);
+ });
+});
diff --git a/src/meta/release.ts b/src/meta/release.ts
new file mode 100644
index 00000000..05c03041
--- /dev/null
+++ b/src/meta/release.ts
@@ -0,0 +1,296 @@
+// Release names are the only metadata a tracker reliably gives us, and every scene/fansub group
+// spells them differently. This module reduces one to a searchable title plus whatever season,
+// episode and year fell out on the way. It is deliberately import-free and total: it runs on
+// every visible row during a search, so it must never throw and never reach the network.
+// Sanitizing the result for a terminal is the caller's job at the render boundary.
+
+export type ReleaseKind = "movie" | "series";
+
+export interface ParsedRelease {
+ readonly title: string;
+ readonly kind: ReleaseKind;
+ readonly year?: number;
+ readonly season?: number;
+ readonly episode?: number;
+ /** Release group / fansub tag. Informational only — never part of the search title. */
+ readonly group?: string;
+}
+
+const CONTAINER_EXT = /\.(mkv|mp4|avi|ts|m2ts|iso|rar|mov|webm)$/i;
+
+// A leading tag is a group; every other bracket is a candidate for removal. Full-width brackets
+// are here because nyaa carries Chinese fansub names that use them.
+const BRACKET_ANY = /[[(【]([^[\])】]*)[\])】]/g;
+const BRACKET_LEADING = /^\s*[[(【]([^[\])】]*)[\])】]/;
+
+const YEAR_ONLY = /^(19\d{2}|20\d{2})$/;
+const YEAR_PAREN = /\((19\d{2}|20\d{2})\)/;
+const YEAR_ANY = /\b(19\d{2}|20\d{2})\b/g;
+
+// Fansub CRC stamps, e.g. "[ABCD1234]" — pure noise, but they look like a title to a naive split.
+const CRC32 = /^[0-9A-F]{8}$/;
+
+const RESOLUTION = /^\d{3,4}p$/i;
+const DIMENSIONS = /^\d{3,4}x\d{3,4}$/;
+
+// One frozen vocabulary rather than a regex alternation: membership is O(1) and the list stays
+// readable when the next streaming-service tag has to be added.
+const JUNK: ReadonlySet = new Set([
+ // quality
+ "4k", "uhd", "8k", "hd", "sd", "hdr", "hdr10", "dv", "sdr",
+ // source
+ "bluray", "blu-ray", "bdrip", "bdremux", "brrip", "remux", "webrip", "web-dl", "webdl", "web",
+ "hdtv", "pdtv", "dvdrip", "dvdscr", "dvd", "hdrip", "cam", "camrip", "ts", "telesync", "tc",
+ "telecine", "hdcam", "screener", "scr", "r5", "vodrip", "amzn", "nf", "hulu", "dsnp", "atvp",
+ "ma", "max", "hmax", "pmntp", "itunes",
+ // codec
+ "x264", "x265", "h264", "h265", "h.264", "h.265", "hevc", "avc", "xvid", "divx", "av1", "vp9",
+ "10bit", "8bit", "10-bit", "hi10p",
+ // audio
+ "aac", "ac3", "eac3", "dts", "truehd", "ddp", "dd", "atmos", "flac", "mp3", "opus", "dual-audio",
+ "5.1", "7.1", "2.0", "aac2.0", "ddp5.1", "dd5.1",
+ // misc
+ "multi", "multi-audio", "multi-subs", "subbed", "dubbed", "sub", "subs", "vostfr", "vf",
+ "french", "truefrench", "ita", "eng", "esp", "repack", "proper", "extended", "uncut", "unrated",
+ "remastered", "imax", "limited", "internal", "complete", "batch", "leak", "hq", "pre",
+]);
+
+// Trim only leading/trailing non-alphanumerics: interior punctuation is significant ("web-dl",
+// "aac2.0", "h.264"). Unicode classes keep CJK titles intact instead of erasing them to "".
+const EDGE_PUNCTUATION = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu;
+
+function isJunk(token: string): boolean {
+ const t = token.toLowerCase().replace(EDGE_PUNCTUATION, "");
+ if (t === "") return false;
+ return JUNK.has(t) || RESOLUTION.test(t) || DIMENSIONS.test(t);
+}
+
+/** True when every dash/underscore/space-separated part of a bracket body is junk. */
+function isJunkOnly(inner: string): boolean {
+ if (isJunk(inner)) return true;
+ const parts = inner.split(/[\s_-]+/).filter((p) => p !== "");
+ return parts.length > 0 && parts.every(isJunk);
+}
+
+interface Token {
+ readonly text: string;
+ readonly index: number;
+}
+
+function tokenize(s: string): readonly Token[] {
+ const out: Token[] = [];
+ for (const m of s.matchAll(/\S+/g)) {
+ if (m.index !== undefined) out.push({ text: m[0], index: m.index });
+ }
+ return out;
+}
+
+/**
+ * `_` is never anything but a separator. `.` is ambiguous — it separates scene names but also
+ * carries decimals ("AAC 2.0") — so only convert it when dots are pulling their weight as the
+ * dominant separator. Ties go to dots: a name with as many dots as spaces is a scene name whose
+ * title happens to contain spaces, and leaving the dots in would fuse title and junk tokens.
+ */
+export function normalizeSeparators(s: string): string {
+ const underscored = s.replace(/_/g, " ");
+ const dots = (underscored.match(/\./g) ?? []).length;
+ const spaces = (underscored.match(/ /g) ?? []).length;
+ return dots > 0 && dots >= spaces ? underscored.replace(/\./g, " ") : underscored;
+}
+
+const EP_SxxExx = /S(\d{1,2})[ ._-]?E(\d{1,3})/i;
+const EP_NxNN = /\b(\d{1,2})x(\d{2})\b/i;
+const EP_SEASON_WORD = /\bSeason[ ._-]?(\d{1,2})(?:[ ._-]?Episode[ ._-]?(\d{1,3}))?/i;
+// SubsPlease / Erai-raws style "Show - 01", optionally version-stamped ("- 06v2").
+const EP_DASH = /[ ._-]-[ ._-](\d{1,4})(?:v\d)?\b/;
+const EP_BARE_SEASON = /\bS(\d{1,2})\b/i;
+
+function toInt(s: string | undefined): number | undefined {
+ if (s === undefined) return undefined;
+ const n = Number.parseInt(s, 10);
+ return Number.isFinite(n) ? n : undefined;
+}
+
+/**
+ * First marker wins, strongest form first. The bare "- 01" form is last-resort and only trusted
+ * past the first third of the name, because a hyphen that early is far more likely to be part of
+ * the title ("Spider-Man - the ...") than an episode number.
+ */
+export function findEpisodeMarker(
+ s: string,
+): { index: number; season?: number; episode?: number } | null {
+ const sxxexx = s.match(EP_SxxExx);
+ if (sxxexx?.index !== undefined) {
+ return { index: sxxexx.index, season: toInt(sxxexx[1]), episode: toInt(sxxexx[2]) };
+ }
+
+ const nxnn = s.match(EP_NxNN);
+ if (nxnn?.index !== undefined) {
+ return { index: nxnn.index, season: toInt(nxnn[1]), episode: toInt(nxnn[2]) };
+ }
+
+ const worded = s.match(EP_SEASON_WORD);
+ if (worded?.index !== undefined) {
+ return { index: worded.index, season: toInt(worded[1]), episode: toInt(worded[2]) };
+ }
+
+ const dash = s.match(EP_DASH);
+ if (dash?.index !== undefined && dash.index >= s.length / 3) {
+ // The dash form carries no season, but fansubs park one just before it ("… S4 - 17"), so
+ // backfill from the text we are about to discard and cut at whichever marker comes first.
+ const before = s.slice(0, dash.index);
+ const season = before.match(EP_BARE_SEASON);
+ if (season?.index !== undefined) {
+ return { index: season.index, season: toInt(season[1]), episode: toInt(dash[1]) };
+ }
+ return { index: dash.index, episode: toInt(dash[1]) };
+ }
+
+ const bare = s.match(EP_BARE_SEASON);
+ if (bare?.index !== undefined) return { index: bare.index, season: toInt(bare[1]) };
+
+ return null;
+}
+
+/**
+ * A parenthesised year is an explicit claim and always wins. A naked four-digit run is not — it
+ * could be part of the title ("2012", "Blade Runner 2049") — so it only counts when junk follows
+ * it, which is the shape of a real scene name, and never when it is all we have left.
+ */
+export function findYear(s: string): { index: number; year: number } | null {
+ const paren = s.match(YEAR_PAREN);
+ if (paren?.index !== undefined) {
+ const year = toInt(paren[1]);
+ if (year !== undefined) return { index: paren.index, year };
+ }
+
+ if (YEAR_ONLY.test(s.trim())) return null;
+
+ const matches = [...s.matchAll(YEAR_ANY)];
+ for (let i = matches.length - 1; i >= 0; i--) {
+ const m = matches[i];
+ if (m?.index === undefined) continue;
+ const year = toInt(m[1]);
+ if (year === undefined) continue;
+ const trailing = tokenize(s.slice(m.index + m[0].length));
+ if (trailing.some((t) => isJunk(t.text))) return { index: m.index, year };
+ }
+ return null;
+}
+
+/** Index of the first junk token, or -1. Token index, not character offset. */
+export function firstJunkIndex(tokens: readonly string[]): number {
+ for (let i = 0; i < tokens.length; i++) {
+ const t = tokens[i];
+ if (t !== undefined && isJunk(t)) return i;
+ }
+ return -1;
+}
+
+function stripLeadingGroup(s: string): { text: string; group?: string } {
+ const lead = s.match(BRACKET_LEADING);
+ const inner = lead?.[1]?.trim();
+ if (lead === null || lead === undefined || inner === undefined || inner === "") return { text: s };
+ // A leading "[1080p]" or "(2023)" is metadata, not a group — leave it for the generic pass.
+ if (YEAR_ONLY.test(inner) || isJunkOnly(inner) || CRC32.test(inner)) return { text: s };
+ return { text: s.slice(lead[0].length), group: inner };
+}
+
+function stripJunkBrackets(s: string): string {
+ return s.replace(BRACKET_ANY, (whole: string, inner: string) => {
+ const v = inner.trim();
+ if (YEAR_ONLY.test(v)) return whole; // the year is the one bracket body worth keeping
+ if (v === "" || isJunkOnly(v) || CRC32.test(v)) return " ";
+ return whole;
+ });
+}
+
+// Scene names end in "-GROUP" with no space before the dash — but so does an ordinary hyphenated
+// title ("Spider-Man", "X-Men"), and so does "WEB-DL". Shape alone cannot tell them apart.
+const TRAILING_GROUP = /-([A-Za-z][A-Za-z0-9]+)$/;
+
+/**
+ * A trailing "-GROUP" is only believable when the rest of the name reads like a scene release:
+ * some junk token, a year, or an episode marker ahead of it. Without that corroboration the
+ * hyphen belongs to the title, and taking it would search for "Spider" instead of "Spider-Man".
+ * Rejecting a vocabulary candidate ("WEB-DL") stays as a second, independent guard.
+ */
+function stripTrailingGroup(s: string): { text: string; group?: string } {
+ const m = s.match(TRAILING_GROUP);
+ const candidate = m?.[1];
+ if (m?.index === undefined || candidate === undefined) return { text: s };
+ const lastToken = s.slice(s.lastIndexOf(" ") + 1);
+ if (isJunk(lastToken) || isJunk(candidate)) return { text: s };
+
+ const before = s.slice(0, m.index);
+ const corroborated =
+ firstJunkIndex(tokenize(before).map((t) => t.text)) >= 0 ||
+ findYear(before) !== null ||
+ findEpisodeMarker(before) !== null;
+ if (!corroborated) return { text: s };
+
+ return { text: s.slice(0, m.index), group: candidate };
+}
+
+const TRAILING_EZTV = /[\s._-]*EZTV$/i;
+const TRAILING_PUNCTUATION = /[-:–,]+$/;
+
+function tidy(s: string): string {
+ let t = s.replace(/\s+/g, " ").trim();
+ // EZTV staples its own name onto every row it publishes; it is never part of the title.
+ t = t.replace(TRAILING_EZTV, "").trim();
+ let previous = "";
+ while (t !== previous) {
+ previous = t;
+ t = t.replace(TRAILING_PUNCTUATION, "").trim();
+ }
+ return t;
+}
+
+/**
+ * Reduce a torrent release name to something worth querying a metadata provider with. Total by
+ * construction: unparseable input yields an empty title rather than an exception, and the caller
+ * decides whether an empty or junk-looking title is worth a lookup.
+ */
+export function parseRelease(name: string): ParsedRelease {
+ if (typeof name !== "string" || name === "") return { title: "", kind: "movie" };
+
+ const withoutExt = name.replace(CONTAINER_EXT, "");
+
+ const lead = stripLeadingGroup(withoutExt);
+ const debracketed = stripJunkBrackets(lead.text).replace(/\s+/g, " ").trim();
+
+ // Separators are normalized before the group strip: the corroboration check reads the name as
+ // tokens, and a dotted scene name is a single token until the dots become spaces.
+ const normalized = normalizeSeparators(debracketed).replace(/\s+/g, " ").trim();
+ const trailing = stripTrailingGroup(normalized);
+ const s = trailing.text.trim();
+
+ const marker = findEpisodeMarker(s);
+ const year = findYear(s);
+ const tokens = tokenize(s);
+ const junkToken = firstJunkIndex(tokens.map((t) => t.text));
+
+ const cuts: number[] = [];
+ if (marker !== null) cuts.push(marker.index);
+ if (year !== null) cuts.push(year.index);
+ if (junkToken >= 0) {
+ const t = tokens[junkToken];
+ if (t !== undefined) cuts.push(t.index);
+ }
+ const cut = cuts.length > 0 ? Math.min(...cuts) : s.length;
+
+ const title = tidy(s.slice(0, cut));
+ const season = marker?.season;
+ const episode = marker?.episode;
+ const group = lead.group ?? trailing.group;
+
+ return {
+ title,
+ kind: season !== undefined || episode !== undefined ? "series" : "movie",
+ ...(year !== null ? { year: year.year } : {}),
+ ...(season !== undefined ? { season } : {}),
+ ...(episode !== undefined ? { episode } : {}),
+ ...(group !== undefined ? { group } : {}),
+ };
+}
diff --git a/src/meta/types.ts b/src/meta/types.ts
new file mode 100644
index 00000000..9e4b28fa
--- /dev/null
+++ b/src/meta/types.ts
@@ -0,0 +1,41 @@
+// The shape of the metadata torlink shows beside a search result, independent of which provider
+// produced it. Everything here has already crossed the trust boundary: strings are cleaned, lists
+// are capped and the IMDb id is validated, so a render path can print any of it verbatim.
+// Fields are readonly because these values are cached and shared across rows — a consumer that
+// mutated one would silently poison every other row holding the same object.
+
+export type MetaKind = "movie" | "series";
+
+export interface EpisodeMeta {
+ readonly season: number;
+ readonly number: number;
+ readonly title?: string;
+ readonly overview?: string;
+}
+
+export interface Meta {
+ readonly imdbId: string;
+ readonly kind: MetaKind;
+ readonly title: string;
+ /** `releaseInfo` verbatim: a movie's "1999", a series' "2008–2013" or open-ended "2016–". */
+ readonly year?: string;
+ /** IMDb rating as sent, e.g. "8.7" — kept a string so a missing value is absent, not 0. */
+ readonly rating?: string;
+ readonly runtime?: string;
+ readonly genres: readonly string[];
+ readonly cast: readonly string[];
+ /** Cinemeta sends `null` here for series, so this is routinely empty. */
+ readonly director: readonly string[];
+ readonly plot?: string;
+ /** https only, host-allowlisted at the mapping boundary. */
+ readonly posterUrl?: string;
+ readonly episode?: EpisodeMeta;
+}
+
+/** One row of a provider's search catalog — just enough to match against a parsed release name. */
+export interface CatalogHit {
+ readonly imdbId: string;
+ readonly name: string;
+ readonly releaseInfo?: string;
+ readonly kind: MetaKind;
+}
diff --git a/src/sources/eztv.test.ts b/src/sources/eztv.test.ts
index 6bbbe520..21ed4cf4 100644
--- a/src/sources/eztv.test.ts
+++ b/src/sources/eztv.test.ts
@@ -1,6 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
+import { toResult } from "./eztv";
-const mockFetch = vi.fn();
+// Hoisted above the vi.mock factory below, which runs before this file's own top-level
+// statements: toResult is imported statically, so ./eztv -- and with it the mocked
+// ../util/net -- loads at import time rather than inside a test.
+const mockFetch = vi.hoisted(() => vi.fn());
vi.mock("../util/net", async (importOriginal) => {
const actual = await importOriginal();
@@ -126,3 +130,40 @@ describe("eztv search", () => {
expect(mockFetch.mock.calls.length - afterFirst).toBe(1);
});
});
+
+// Field names and value shapes are verbatim from an EZTV get-torrents response.
+const API_ROW = {
+ title: "Show.Name.S01E02.1080p.WEB.h264-GROUP",
+ filename: "Show.Name.S01E02.1080p.WEB.h264-GROUP.mkv",
+ imdb_id: "399664",
+ hash: "8C4ADBF9EBDC4C6D1D0F1B0F0E0D0C0B0A090807",
+ magnet_url: "magnet:?xt=urn:btih:8c4adbf9ebdc4c6d1d0f1b0f0e0d0c0b0a090807",
+ seeds: 88,
+ peers: 5,
+ size_bytes: "734003200",
+ date_released_unix: 1600000000,
+} as const;
+
+describe("toResult", () => {
+ it("zero-pads EZTV's bare numeric series id into an IMDb id", () => {
+ expect(toResult({ ...API_ROW })).toMatchObject({
+ infoHash: "8c4adbf9ebdc4c6d1d0f1b0f0e0d0c0b0a090807",
+ source: "eztv",
+ imdbId: "tt0399664",
+ });
+ });
+
+ it("leaves the id absent when EZTV's value is empty or already prefixed", () => {
+ expect(toResult({ ...API_ROW, imdb_id: "" })?.imdbId).toBeUndefined();
+ expect(toResult({ ...API_ROW, imdb_id: "tt0399664" })?.imdbId).toBeUndefined();
+ });
+
+ it("leaves the id absent when EZTV omits the field entirely", () => {
+ const { imdb_id: _imdbId, ...withoutImdb } = API_ROW;
+ expect(toResult(withoutImdb)?.imdbId).toBeUndefined();
+ });
+
+ it("still drops rows with no usable hash or magnet", () => {
+ expect(toResult({ ...API_ROW, hash: "", magnet_url: "" })).toBeNull();
+ });
+});
diff --git a/src/sources/eztv.ts b/src/sources/eztv.ts
index 7389cf76..7e6daf7c 100644
--- a/src/sources/eztv.ts
+++ b/src/sources/eztv.ts
@@ -1,4 +1,5 @@
import { fetchResilient, HttpError, USER_AGENT } from "../util/net";
+import { imdbFromNumeric } from "../meta/imdbId";
import { buildMagnet } from "./magnet";
import type { SearchOptions, Source, TorrentResult } from "./types";
@@ -81,7 +82,8 @@ function matches(t: EztvTorrent, tokens: string[]): boolean {
return tokens.every((token) => name.includes(token));
}
-function toResult(t: EztvTorrent): TorrentResult | null {
+/** Exported for tests: the mapping is where every EZTV quirk is absorbed. */
+export function toResult(t: EztvTorrent): TorrentResult | null {
const hash = (t.hash ?? "").toLowerCase();
const name = t.title || t.filename || hash;
const magnet = t.magnet_url || (hash ? buildMagnet(hash, name) : "");
@@ -95,6 +97,9 @@ function toResult(t: EztvTorrent): TorrentResult | null {
source: "eztv",
magnet,
added: t.date_released_unix,
+ // EZTV publishes the *series* id, bare digits and unpadded ("399664"), so it needs both the
+ // tt-prefix and the same result-side validation every other remote id gets.
+ imdbId: imdbFromNumeric(t.imdb_id),
};
}
diff --git a/src/sources/piratebay.test.ts b/src/sources/piratebay.test.ts
index 7b0585c2..d1441f1c 100644
--- a/src/sources/piratebay.test.ts
+++ b/src/sources/piratebay.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { tpbMovies } from "./piratebay";
+import { toResult, tpbMovies } from "./piratebay";
import { fetchResilient } from "../util/net";
vi.mock("../util/net", async (importOriginal) => {
@@ -73,3 +73,45 @@ describe("apibay sentinel retry", () => {
expect(askedUrl(0)).toContain("/precompiled/");
});
});
+
+// Field names and value shapes are verbatim from an apibay q.php response.
+const ROW = {
+ id: "10944926",
+ name: "The Matrix 1999 1080p BluRay x264",
+ info_hash: "8C4ADBF9EBDC4C6D1D0F1B0F0E0D0C0B0A090807",
+ seeders: "412",
+ leechers: "17",
+ num_files: "3",
+ size: "2147483648",
+ added: "1600000000",
+ category: "207",
+ imdb: "tt0133093",
+} as const;
+
+describe("toResult", () => {
+ it("carries the IMDb id apibay supplies", () => {
+ expect(toResult({ ...ROW }, "tpb-movies")).toMatchObject({
+ infoHash: "8c4adbf9ebdc4c6d1d0f1b0f0e0d0c0b0a090807",
+ source: "tpb-movies",
+ imdbId: "tt0133093",
+ });
+ });
+
+ it('rejects the "0" apibay sends for a row with no id', () => {
+ // Not a sentinel check: normalizeImdbId refuses it on shape, the same way it refuses any other
+ // remote string that is not tt + 7-10 digits.
+ expect(toResult({ ...ROW, imdb: "0" }, "tpb-movies")?.imdbId).toBeUndefined();
+ expect(toResult({ ...ROW, imdb: "" }, "tpb-movies")?.imdbId).toBeUndefined();
+ expect(toResult({ ...ROW, imdb: "tt0133093/../admin" }, "tpb-tv")?.imdbId).toBeUndefined();
+ });
+
+ it("leaves the id absent when apibay omits the field entirely", () => {
+ const { imdb: _imdb, ...withoutImdb } = ROW;
+ expect(toResult(withoutImdb, "tpb-tv")?.imdbId).toBeUndefined();
+ });
+
+ it("still drops the rows it always dropped", () => {
+ expect(toResult({ ...ROW, info_hash: "0".repeat(40) }, "tpb-movies")).toBeNull();
+ expect(toResult({ ...ROW, id: "0" }, "tpb-movies")).toBeNull();
+ });
+});
diff --git a/src/sources/piratebay.ts b/src/sources/piratebay.ts
index a391aefe..cb318772 100644
--- a/src/sources/piratebay.ts
+++ b/src/sources/piratebay.ts
@@ -1,4 +1,5 @@
import { fetchResilient, HttpError, USER_AGENT } from "../util/net";
+import { normalizeImdbId } from "../meta/imdbId";
import { buildMagnet } from "./magnet";
import type { SearchOptions, Source, SourceId, TorrentResult } from "./types";
@@ -20,11 +21,13 @@ interface ApibayItem {
size?: string;
added?: string;
category?: string;
+ imdb?: string;
}
const ZERO_HASH = "0000000000000000000000000000000000000000";
-function toResult(it: ApibayItem, source: SourceId): TorrentResult | null {
+/** Exported for tests: the mapping is where every apibay quirk is absorbed. */
+export function toResult(it: ApibayItem, source: SourceId): TorrentResult | null {
const infoHash = (it.info_hash ?? "").toLowerCase();
if (!infoHash || infoHash === ZERO_HASH || it.id === "0") return null;
const name = it.name || "Unknown";
@@ -39,6 +42,9 @@ function toResult(it: ApibayItem, source: SourceId): TorrentResult | null {
source,
magnet: buildMagnet(infoHash, name),
added: Number(it.added) || undefined,
+ // apibay carries the id on both category feeds, and sends the string "0" on rows that have
+ // none. normalizeImdbId rejects that on shape alone, so no separate sentinel check is needed.
+ imdbId: normalizeImdbId(it.imdb),
};
}
diff --git a/src/sources/types.ts b/src/sources/types.ts
index 39bf7be7..e013a805 100644
--- a/src/sources/types.ts
+++ b/src/sources/types.ts
@@ -22,6 +22,12 @@ export interface TorrentResult {
source: SourceId;
magnet: string;
added?: number;
+ /**
+ * IMDb id (`tt` + 7-10 digits) when the source's API hands one over. Lets the
+ * metadata lookup skip title guessing. Never trusted verbatim: it is remote
+ * text interpolated into a URL path, so producers validate the shape first.
+ */
+ imdbId?: string;
}
export interface SearchOptions {
diff --git a/src/sources/yts.test.ts b/src/sources/yts.test.ts
new file mode 100644
index 00000000..7b05d01a
--- /dev/null
+++ b/src/sources/yts.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import { toResult } from "./yts";
+
+// Field names and value shapes are verbatim from a YTS list_movies.json response.
+const MOVIE = {
+ title_long: "The Matrix (1999)",
+ title: "The Matrix",
+ imdb_code: "tt0133093",
+ date_uploaded_unix: 1600000000,
+} as const;
+
+const TORRENT = {
+ hash: "8C4ADBF9EBDC4C6D1D0F1B0F0E0D0C0B0A090807",
+ quality: "1080p",
+ type: "bluray",
+ size_bytes: 2147483648,
+ seeds: 412,
+ peers: 17,
+} as const;
+
+describe("toResult", () => {
+ it("carries the IMDb id YTS supplies", () => {
+ expect(toResult({ ...MOVIE }, { ...TORRENT })).toMatchObject({
+ infoHash: "8c4adbf9ebdc4c6d1d0f1b0f0e0d0c0b0a090807",
+ source: "yts",
+ imdbId: "tt0133093",
+ });
+ });
+
+ it("leaves the id absent when YTS's value doesn't look like an IMDb id", () => {
+ expect(toResult({ ...MOVIE, imdb_code: "133093" }, { ...TORRENT })?.imdbId).toBeUndefined();
+ expect(toResult({ ...MOVIE, imdb_code: "" }, { ...TORRENT })?.imdbId).toBeUndefined();
+ });
+
+ it("leaves the id absent when YTS omits the field entirely", () => {
+ const { imdb_code: _imdbCode, ...withoutImdb } = MOVIE;
+ expect(toResult(withoutImdb, { ...TORRENT })?.imdbId).toBeUndefined();
+ });
+
+ it("still drops rows with no usable hash", () => {
+ expect(toResult({ ...MOVIE }, { ...TORRENT, hash: undefined })).toBeNull();
+ });
+});
diff --git a/src/sources/yts.ts b/src/sources/yts.ts
index 37e7708b..afd1fa39 100644
--- a/src/sources/yts.ts
+++ b/src/sources/yts.ts
@@ -1,4 +1,5 @@
import { fetchResilient, HttpError, USER_AGENT } from "../util/net";
+import { normalizeImdbId } from "../meta/imdbId";
import { buildMagnet } from "./magnet";
import type { SearchOptions, Source, TorrentResult } from "./types";
@@ -15,6 +16,7 @@ interface YtsTorrent {
interface YtsMovie {
title_long?: string;
title?: string;
+ imdb_code?: string;
date_uploaded_unix?: number;
torrents?: YtsTorrent[];
}
@@ -41,6 +43,28 @@ async function fetchMovies(params: URLSearchParams, opts: SearchOptions): Promis
throw lastError instanceof Error ? lastError : new HttpError(0, "YTS unreachable");
}
+/** Exported for tests: the mapping is where every YTS quirk is absorbed. */
+export function toResult(movie: YtsMovie, t: YtsTorrent): TorrentResult | null {
+ if (!t.hash) return null;
+ const infoHash = t.hash.toLowerCase();
+ const base = movie.title_long || movie.title || "Unknown";
+ const tag = [t.quality, t.type].filter(Boolean).join(" ");
+ const name = tag ? `${base} [${tag}]` : base;
+ return {
+ infoHash,
+ name,
+ sizeBytes: t.size_bytes ?? 0,
+ seeders: t.seeds ?? 0,
+ leechers: t.peers ?? 0,
+ source: "yts",
+ magnet: buildMagnet(infoHash, name),
+ added: movie.date_uploaded_unix,
+ // One id per film, shared by its quality rows: validated here, at the trust boundary, not at
+ // the point of use.
+ imdbId: normalizeImdbId(movie.imdb_code),
+ };
+}
+
async function search(query: string, opts: SearchOptions = {}): Promise {
const q = query.trim();
const params = new URLSearchParams({ limit: "50" });
@@ -50,22 +74,9 @@ async function search(query: string, opts: SearchOptions = {}): Promise ({ useMouseWheel: (): void => {} }));
+
+vi.mock("../config/config", () => ({
+ loadConfig: async (): Promise => ({ downloadDir: "/tmp/torlink-tests", trackers: [] }),
+ saveConfig: async (): Promise => {},
+}));
+
+vi.mock("../download/queue", () => {
+ // Enough of the queue for the views that read it: Sidebar's badges and the three store hooks.
+ class FakeQueue {
+ activeCount = 0;
+ seedingCount = 0;
+ setTrackers(): void {}
+ restore(): void {}
+ restoreHistory(): void {}
+ restoreSeeds(): void {}
+ suspend(): void {}
+ persistSync(): void {}
+ add(): void {}
+ getItems(): unknown[] {
+ return [];
+ }
+ getHistory(): unknown[] {
+ return [];
+ }
+ getSeeds(): unknown[] {
+ return [];
+ }
+ getSeed(): undefined {
+ return undefined;
+ }
+ on(): this {
+ return this;
+ }
+ off(): this {
+ return this;
+ }
+ }
+ return { DownloadQueue: FakeQueue };
+});
+
+vi.mock("../download/persist", () => ({
+ loadQueue: async (): Promise => [],
+ loadSeeds: async (): Promise => [],
+}));
+vi.mock("../download/history", () => ({ loadHistory: async (): Promise => [] }));
+vi.mock("../download/reconcile", () => ({ reconcileQueue: (items: unknown): unknown => items }));
+vi.mock("../download/bootguard", () => ({
+ BOOT_SETTLE_MS: 0,
+ armBootMarker: (): void => {},
+ disarmBootMarker: (): void => {},
+ wasBootInterrupted: (): boolean => false,
+}));
+vi.mock("../update/version", () => ({
+ fetchLatestVersion: async (): Promise => null,
+ isNewer: (): boolean => false,
+}));
+
+const searchState = vi.hoisted(() => ({ current: null as unknown }));
+const metaState = vi.hoisted(() => ({ current: null as unknown }));
+
+vi.mock("./hooks/useConcurrentSearch", () => ({
+ useConcurrentSearch: () => searchState.current,
+}));
+vi.mock("./hooks/useResultMeta", () => ({ useResultMeta: () => metaState.current }));
+vi.mock("./hooks/usePoster", () => ({ usePoster: () => ({ loading: false, cells: null }) }));
+
+const t = (infoHash: string, name: string): TorrentResult => ({
+ infoHash,
+ name,
+ source: "yts",
+ sizeBytes: 2.1e9,
+ seeders: 40,
+ leechers: 6,
+ magnet: `magnet:?xt=urn:btih:${infoHash}`,
+ added: 1_760_000_000,
+});
+
+const LIST = [
+ t("a1", "ubuntu 24.04 desktop amd64 iso"),
+ t("b2", "ubuntu server 24.04 arm64 iso"),
+ t("c3", "debian 12 netinst iso"),
+];
+
+const META: Meta = {
+ imdbId: "tt0111161",
+ kind: "movie",
+ title: "The Shawshank Redemption",
+ year: "1994",
+ rating: "9.3",
+ runtime: "142 min",
+ genres: ["Drama"],
+ cast: ["Tim Robbins", "Morgan Freeman"],
+ director: ["Frank Darabont"],
+};
+
+function settled(results: TorrentResult[]): ConcurrentSearchState {
+ const perSource = Object.fromEntries(
+ SOURCES.map((s) => [s.id, { loading: false, error: null, code: null, count: 0 }]),
+ ) as ConcurrentSearchState["perSource"];
+ return { results, perSource, loading: false, done: SOURCES.length, total: SOURCES.length };
+}
+
+let ui: RenderedUI | null = null;
+afterEach(() => {
+ ui?.unmount();
+ ui = null;
+});
+
+const WIDE = 120;
+const contentWidthFor = (cols: number): number => Math.max(24, cols - RAIL_WIDTH - 3);
+
+/** Boots the app and searches, which is the only way into the results view a user has. */
+async function boot(cols = WIDE, rows = 30): Promise {
+ searchState.current = settled(LIST);
+ metaState.current = { loading: false, meta: META };
+ ui = renderUI( {}} />, { cols, rows });
+ const u = ui;
+ await vi.waitFor(() => expect(u.frame()).toContain("terminal-native"));
+ u.press("linux iso");
+ // The field commits what it has rendered, so the query has to be on screen before Enter — a
+ // same-tick burst would otherwise submit the empty string the splash opened with.
+ await vi.waitFor(() => expect(u.frame()).toContain("linux iso"));
+ u.press(KEY.enter);
+ await vi.waitFor(() => expect(u.frame()).toContain("Results (3)"));
+ return u;
+}
+
+// Which region owns the keyboard, read off the footer, because that row is rendered from the same
+// `region` the key handler is walking — a claim about focus that the user can also see.
+const inPane = (u: RenderedUI): boolean => u.frame().includes("↑↓ Scroll");
+const inList = (u: RenderedUI): boolean => u.frame().includes("d Download");
+const inSidebar = (u: RenderedUI): boolean => u.frame().includes("q Quit");
+
+// Where the pane's border sits on the shared top-border row, measured from the list panel's own
+// left edge so the sidebar rail's width never enters into it. This is the layout's answer to "is
+// the pane focused", independent of the footer above.
+const paneStartsAt = (u: RenderedUI): number => {
+ const line = u.frame().split("\n").find((l) => l.includes("╭─ Results")) ?? "";
+ return line.indexOf("╭─ Info") - line.indexOf("╭─ Results");
+};
+const paneStartFor = (cols: number, focused: boolean): number => {
+ const pl = previewLayout(contentWidthFor(cols), focused);
+ return pl === null ? -1 : pl.list + 1;
+};
+
+describe("App region walk", () => {
+ it("steps → from the results list into the info pane, and widens it", async () => {
+ const u = await boot();
+ expect(inList(u)).toBe(true);
+ expect(paneStartsAt(u)).toBe(paneStartFor(WIDE, false));
+
+ u.press(KEY.right);
+ await vi.waitFor(() => expect(inPane(u)).toBe(true));
+ // Not just a footer swap: the split moved to the focused widths under it.
+ expect(paneStartsAt(u)).toBe(paneStartFor(WIDE, true));
+ expect(inList(u)).toBe(false);
+ });
+
+ it("accepts l for the same step, as every other horizontal key in this app does", async () => {
+ const u = await boot();
+ u.press("l");
+ await vi.waitFor(() => expect(inPane(u)).toBe(true));
+ });
+
+ it("steps ← back to the list, never past it to the sidebar", async () => {
+ const u = await boot();
+ u.press(KEY.right);
+ await vi.waitFor(() => expect(inPane(u)).toBe(true));
+
+ u.press(KEY.left);
+ await vi.waitFor(() => expect(inList(u)).toBe(true));
+ // The whole point of a three-column model: one key, one column.
+ expect(inSidebar(u)).toBe(false);
+ expect(paneStartsAt(u)).toBe(paneStartFor(WIDE, false));
+
+ // And the step after it lands where ← always landed.
+ u.press(KEY.left);
+ await vi.waitFor(() => expect(inSidebar(u)).toBe(true));
+ });
+
+ it("steps esc left exactly as ← does, all the way out to the splash", async () => {
+ const u = await boot();
+ u.press(KEY.right);
+ await vi.waitFor(() => expect(inPane(u)).toBe(true));
+
+ u.press(KEY.esc);
+ await vi.waitFor(() => expect(inList(u)).toBe(true));
+ expect(inSidebar(u)).toBe(false);
+
+ u.press(KEY.esc);
+ await vi.waitFor(() => expect(inSidebar(u)).toBe(true));
+
+ u.press(KEY.esc);
+ await vi.waitFor(() => expect(u.frame()).toContain("terminal-native"));
+ });
+
+ it("leaves tab the two-way toggle it has always been", async () => {
+ const u = await boot();
+ u.press(KEY.right);
+ await vi.waitFor(() => expect(inPane(u)).toBe(true));
+
+ u.press("\t");
+ await vi.waitFor(() => expect(inSidebar(u)).toBe(true));
+ });
+
+ it("keeps → a no-op where the pane cannot exist", async () => {
+ const u = await boot(80);
+ expect(u.frame()).not.toContain("╭─ Info");
+
+ u.press(KEY.right);
+ await new Promise((r) => setTimeout(r, 30));
+ expect(inPane(u)).toBe(false);
+ expect(inList(u)).toBe(true);
+ });
+
+ it("keeps → a no-op once the pane is toggled off, and honours it again after i", async () => {
+ const u = await boot();
+ u.press("i");
+ await vi.waitFor(() => expect(u.frame()).not.toContain("╭─ Info"));
+
+ u.press(KEY.right);
+ await new Promise((r) => setTimeout(r, 30));
+ expect(inPane(u)).toBe(false);
+
+ u.press("i");
+ await vi.waitFor(() => expect(u.frame()).toContain("╭─ Info"));
+ u.press(KEY.right);
+ await vi.waitFor(() => expect(inPane(u)).toBe(true));
+ });
+});
+
+describe("App footer for the info pane", () => {
+ it("advertises → while the pane is open and i once it is closed", async () => {
+ const u = await boot();
+ expect(u.frame()).toContain("→ Info");
+ expect(u.frame()).not.toContain("i Info");
+
+ u.press("i");
+ await vi.waitFor(() => expect(u.frame()).toContain("i Info"));
+ expect(u.frame()).not.toContain("→ Info");
+ });
+});
+
+describe("App modals over a focused pane", () => {
+ it("returns the keyboard to the pane after the ? sheet closes", async () => {
+ const u = await boot();
+ u.press(KEY.right);
+ await vi.waitFor(() => expect(inPane(u)).toBe(true));
+
+ u.press("?");
+ await vi.waitFor(() => expect(u.frame()).toContain("Keyboard"));
+ // The body is hidden while the overlay owns the screen, so nothing behind it is holding keys.
+ expect(u.frame()).not.toContain("╭─ Results");
+ expect(inPane(u)).toBe(false);
+
+ u.press("?");
+ await vi.waitFor(() => expect(inPane(u)).toBe(true));
+ expect(paneStartsAt(u)).toBe(paneStartFor(WIDE, true));
+ });
+
+ it("returns it after the folder prompt is cancelled", async () => {
+ const u = await boot();
+ u.press(KEY.right);
+ await vi.waitFor(() => expect(inPane(u)).toBe(true));
+
+ u.press("o");
+ await vi.waitFor(() => expect(u.frame()).toContain("Default download folder"));
+ expect(u.frame()).not.toContain("╭─ Results");
+
+ u.press(KEY.esc);
+ await vi.waitFor(() => expect(inPane(u)).toBe(true));
+ });
+
+ it("returns it after the trackers prompt is cancelled", async () => {
+ const u = await boot();
+ u.press(KEY.right);
+ await vi.waitFor(() => expect(inPane(u)).toBe(true));
+
+ u.press("t");
+ await vi.waitFor(() => expect(u.frame()).toContain("Extra trackers"));
+
+ u.press(KEY.esc);
+ await vi.waitFor(() => expect(inPane(u)).toBe(true));
+ });
+});
+
+describe("App rescues focus from a pane that disappears", () => {
+ it("hands the keyboard back to the list when a resize takes the pane away", async () => {
+ const u = await boot();
+ u.press(KEY.right);
+ await vi.waitFor(() => expect(inPane(u)).toBe(true));
+
+ // 80 columns is below the width the split exists at: the pane goes, and the keyboard cannot
+ // be left holding it — every key would be feeding a column that is no longer drawn.
+ u.resize(80, 30);
+ await vi.waitFor(() => expect(u.frame()).not.toContain("╭─ Info"));
+ await vi.waitFor(() => expect(inList(u)).toBe(true));
+ expect(inPane(u)).toBe(false);
+
+ // And the rescue moved the state, not just the frame: widening again leaves focus where the
+ // user last had it rather than teleporting it back into the pane.
+ u.resize(WIDE, 30);
+ await vi.waitFor(() => expect(u.frame()).toContain("╭─ Info"));
+ expect(inList(u)).toBe(true);
+ expect(paneStartsAt(u)).toBe(paneStartFor(WIDE, false));
+ });
+
+ it("leaves nothing stranded when the results view itself goes away", async () => {
+ const u = await boot();
+ u.press(KEY.right);
+ await vi.waitFor(() => expect(inPane(u)).toBe(true));
+
+ // Out to the sidebar and up into Downloads, which has no pane at any width. One key per
+ // frame: a burst arrives as a single chunk, and ink hands the whole chunk over as one `input`.
+ u.press("\t");
+ await vi.waitFor(() => expect(inSidebar(u)).toBe(true));
+ u.press("k");
+ await vi.waitFor(() => expect(u.frame()).toContain("╭─ Seeding"));
+ u.press("k");
+ await vi.waitFor(() => expect(u.frame()).toContain("╭─ Downloads"));
+ u.press(KEY.enter);
+ await vi.waitFor(() => expect(u.frame()).toContain("p Pause"));
+
+ // → here must find no third column: the results view reported its pane gone on the way out.
+ u.press(KEY.right);
+ await new Promise((r) => setTimeout(r, 30));
+ expect(inPane(u)).toBe(false);
+ expect(u.frame()).toContain("p Pause");
+ });
+});
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 441dd469..7bca20ad 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -45,6 +45,8 @@ import { Splash } from "./views/Splash";
import { FolderPrompt } from "./components/FolderPrompt";
import { TrackersPrompt } from "./components/TrackersPrompt";
import { footerHints } from "./keymap";
+import { stepRegion } from "./move";
+import { previewLayout } from "./previewLayout";
import { COLOR, ICON } from "./theme";
import { useMouseWheel } from "./hooks/useMouseWheel";
import { VERSION } from "../version";
@@ -95,6 +97,9 @@ export function App({
const [downloadFocus, setDownloadFocus] = useState(null);
const [seedFocus, setSeedFocus] = useState(null);
const [resultFocus, setResultFocus] = useState(null);
+ // Reported by the results view; see Store.previewOpen. Kept here rather than derived because
+ // only that view knows all three of the width split, the `i` toggle and the section.
+ const [previewOpen, setPreviewOpen] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [editingFolder, setEditingFolder] = useState(false);
const [editingTrackers, setEditingTrackers] = useState(false);
@@ -461,6 +466,14 @@ export function App({
return () => clearTimeout(t);
}, [notice]);
+ // Focus must never be left holding a pane that is no longer on screen: a resize below the
+ // split's width, the `i` toggle and leaving the results view for Downloads all take the pane
+ // away from under it. stepRegion already answers "content" once previewOpen is false, so this
+ // rescue is the same rule the arrow keys walk rather than a second one that could disagree.
+ useEffect(() => {
+ if (region === "preview" && !previewOpen) setRegion(stepRegion(region, -1, previewOpen));
+ }, [region, previewOpen]);
+
const compact = rows < 18;
const showTopRule = !compact;
const showFooter = rows >= 12;
@@ -496,6 +509,8 @@ export function App({
setSeedFocus,
resultFocus,
setResultFocus,
+ previewOpen,
+ setPreviewOpen,
startDownload,
requestDownloadTo,
copyMagnet,
@@ -527,6 +542,7 @@ export function App({
downloadFocus,
seedFocus,
resultFocus,
+ previewOpen,
startDownload,
requestDownloadTo,
copyMagnet,
@@ -577,18 +593,24 @@ export function App({
setRegion(region === "sidebar" ? "content" : "sidebar");
return;
}
+ // One horizontal model, three columns: sidebar, results list, info pane. The pane end of it
+ // only exists while the pane is on screen, so on a narrow terminal, a section without
+ // metadata or with the pane toggled off, → in the list stays the no-op it has always been.
if (key.rightArrow || input === "l") {
- if (region === "sidebar") setRegion("content");
+ setRegion(stepRegion(region, 1, previewOpen));
return;
}
if (key.leftArrow || input === "h") {
- if (region === "content") setRegion("sidebar");
+ setRegion(stepRegion(region, -1, previewOpen));
return;
}
if (key.escape) {
if (captureMode === "esc") return;
- if (region === "content") {
- setRegion("sidebar");
+ // esc steps left exactly as ← does, and only falls through to leaving the browser once
+ // there is nothing left of the row to step back through.
+ const back = stepRegion(region, -1, previewOpen);
+ if (back !== region) {
+ setRegion(back);
return;
}
setView("splash");
@@ -705,7 +727,20 @@ export function App({
{showFooter ? (
-
+
) : null}
diff --git a/src/ui/components/MetaPane.test.tsx b/src/ui/components/MetaPane.test.tsx
new file mode 100644
index 00000000..b5ee9526
--- /dev/null
+++ b/src/ui/components/MetaPane.test.tsx
@@ -0,0 +1,570 @@
+import { useEffect, useState } from "react";
+import { describe, expect, it, beforeEach, vi } from "vitest";
+import { MetaPane } from "./MetaPane";
+import { KEY, renderUI } from "../testHarness";
+import { displayWidth } from "../textWidth";
+import { MIN_FOCUSED_TEXT_ROWS, posterBudget } from "../previewLayout";
+import { ICON } from "../theme";
+import { usePoster } from "../hooks/usePoster";
+import { useResultMeta } from "../hooks/useResultMeta";
+import { fitCells } from "../../meta/image";
+import type { PosterCells } from "../../meta/image";
+import type { Meta } from "../../meta/types";
+import type { TorrentResult } from "../../sources/types";
+
+// Both hooks are mocked: this file is about the pane's layout arithmetic — how many rows the art
+// claims and how many are left for the text — not about fetching, which image.test.ts and
+// poster.test.ts already cover. No test in this repo may touch the network, and a mocked hook is
+// the only way to render the loaded state at all.
+vi.mock("../hooks/useResultMeta", () => ({ useResultMeta: vi.fn() }));
+vi.mock("../hooks/usePoster", () => ({ usePoster: vi.fn() }));
+
+const mockMeta = vi.mocked(useResultMeta);
+const mockPoster = vi.mocked(usePoster);
+
+// The exhaustive sweep at the bottom of this file mounts the pane a few thousand times, which
+// costs seconds even on an idle machine and rather more on a busy one. Vitest's five-second
+// default is a number nobody here chose; this one is chosen, with room for a contributor's laptop
+// running a build in the next window. A sweep that overruns it is a machine under load, not a
+// slow test hiding a problem — so raise this rather than thinning the sweep, which is what caught
+// the off-by-one layout bugs the cases below pin.
+const SWEEP_MS = 20_000;
+
+// The widest tier: pane 34 columns, and the panel height the results view hands it.
+const PANE_W = 34;
+const PANE_H = 20;
+const INNER_ROWS = PANE_H - 1;
+const BUDGET = posterBudget(PANE_W, INNER_ROWS);
+
+const ROW: TorrentResult = {
+ infoHash: "h-alpha",
+ name: "The.Matrix.1999.1080p.BluRay.x264",
+ source: "yts",
+ sizeBytes: 2.1e9,
+ seeders: 40,
+ leechers: 6,
+ magnet: "magnet:?xt=urn:btih:h-alpha",
+};
+
+const META: Meta = {
+ imdbId: "tt0133093",
+ kind: "movie",
+ title: "The Matrix",
+ year: "1999",
+ rating: "8.7",
+ runtime: "136 min",
+ genres: ["Action", "Sci-Fi"],
+ cast: ["Keanu Reeves", "Laurence Fishburne", "Carrie-Anne Moss", "Hugo Weaving"],
+ director: ["Lana Wachowski", "Lilly Wachowski"],
+ posterUrl: "https://images.metahub.space/poster/small/tt0133093/img?format=jpeg",
+};
+
+// A separate fixture rather than a plot on META: every other test in this file measures the facts
+// card's rows, and a synopsis under it would change what all of them are asserting.
+const META_PLOT: Meta = {
+ ...META,
+ plot:
+ "A computer hacker learns from mysterious rebels about the true nature of his reality and " +
+ "his role in the war against its controllers, who farm sleeping humanity for power.",
+};
+
+function art(cols: number, rows: number): PosterCells {
+ return {
+ cols,
+ rows,
+ lines: Array.from({ length: rows }, () => [{ fg: "#ff0000", bg: "#0000ff", n: cols }]),
+ };
+}
+
+// Panel's focused frame colour, as chalk emits COLOR.accent (#a78bfa) in truecolour.
+const ACCENT = "\u001b[38;2;167;139;250m";
+
+// The CSI sequences ink's input parser turns into key.downArrow, key.pageUp and key.pageDown.
+const DOWN = `${KEY.esc}[B`;
+const PAGE_UP = `${KEY.esc}[5~`;
+const PAGE_DOWN = `${KEY.esc}[6~`;
+
+function frameLines(frame: string): string[] {
+ // Trailing blank lines are the harness's, not the panel's; the panel's own bottom border is the
+ // last line that carries anything.
+ const all = frame.split("\n");
+ let end = all.length;
+ while (end > 0 && (all[end - 1] ?? "").trim() === "") end--;
+ return all.slice(0, end);
+}
+
+beforeEach(() => {
+ mockMeta.mockReset();
+ mockPoster.mockReset();
+ mockMeta.mockReturnValue({ loading: false, meta: META });
+ mockPoster.mockReturnValue({ loading: false, cells: null });
+});
+
+describe("MetaPane poster slot", () => {
+ it("asks for exactly the cell budget previewLayout sized for this pane", () => {
+ renderUI().unmount();
+ expect(BUDGET).not.toBeNull();
+ expect(mockPoster).toHaveBeenCalledWith(META.posterUrl, BUDGET?.cols, BUDGET?.rows, true);
+ });
+
+ it("does not fetch art at a tier that does not draw it", () => {
+ renderUI().unmount();
+ // Still called — hooks cannot be conditional — but disabled and with no budget to spend.
+ expect(mockPoster).toHaveBeenCalledWith(META.posterUrl, 0, 0, false);
+ });
+
+ it("keeps every row of art, the spacer and the text inside the panel frame", () => {
+ const rows = BUDGET?.rows ?? 0;
+ const cols = BUDGET?.cols ?? 0;
+ mockPoster.mockReturnValue({ loading: false, cells: art(cols, rows) });
+
+ const ui = renderUI(, {
+ cols: 60,
+ });
+ const out = frameLines(ui.frame());
+
+ // Title bar + the bordered content box, which draws its own bottom border inside `height`.
+ expect(out).toHaveLength(1 + PANE_H);
+ expect(out[0]).toContain("Info");
+ expect(out.at(-1)?.startsWith("╰")).toBe(true);
+ // Frame integrity beats a width assertion: Yoga answers overflow by fusing rows, so the proof
+ // that nothing overflowed is that all of them are still here at the pane's exact width.
+ for (const line of out) expect(displayWidth(line)).toBe(PANE_W);
+
+ // Content rows: `rows` of art, one blank spacer, then the text card.
+ const content = out.slice(1, -1);
+ expect(content).toHaveLength(INNER_ROWS);
+ for (let i = 0; i < rows; i++) expect(content[i]).toContain("▀".repeat(cols));
+ expect(content[rows]?.replace(/│/g, "").trim()).toBe("");
+ expect(content.slice(rows + 1).join("\n")).toContain("The Matrix");
+ ui.unmount();
+ });
+
+ it("refuses to draw a grid that outgrew the pane it was decoded for", () => {
+ // What a resize looks like for one frame: the hook re-keys inside an effect, so the render
+ // that first sees the narrower pane is still holding the grid decoded for the wider one.
+ mockPoster.mockReturnValue({
+ loading: false,
+ cells: art((BUDGET?.cols ?? 0) + 4, (BUDGET?.rows ?? 0) + 4),
+ });
+ const ui = renderUI(, {
+ cols: 60,
+ });
+ const out = frameLines(ui.frame());
+ expect(out.join("")).not.toContain("▀");
+ expect(out).toHaveLength(1 + PANE_H);
+ for (const line of out) expect(displayWidth(line)).toBe(PANE_W);
+ ui.unmount();
+ });
+
+ it("keeps the pane unfocused unless it is told otherwise", () => {
+ // Panel paints its frame in the accent only for the focused region, and the pane beside the
+ // list is not it until the user steps in. Task 5's default behaviour, pinned as a default.
+ const ui = renderUI();
+ expect(ui.rawFrame()).not.toContain(ACCENT);
+ ui.unmount();
+ });
+
+ it("gives the text back the rows the art would have taken when there is no art", () => {
+ // beforeEach leaves the poster hook answering null, which is what a WebP body, a 404, a
+ // truncated download and a decode failure all look like from here.
+ const bare = renderUI();
+ const textOnly = bare.frame();
+ bare.unmount();
+
+ mockPoster.mockReturnValue({ loading: false, cells: art(BUDGET?.cols ?? 0, BUDGET?.rows ?? 0) });
+ const drawn = renderUI();
+ const withArt = drawn.frame();
+ drawn.unmount();
+
+ // A poster that never arrives leaves the card exactly as it renders today, cast line and all;
+ // one that does arrive spends those rows on the art instead.
+ expect(textOnly).toContain("Cast Keanu Reeves");
+ expect(withArt).not.toContain("Cast Keanu Reeves");
+ expect(withArt).toContain("The Matrix");
+ });
+});
+
+describe("MetaPane plot", () => {
+ it("spends the rows the facts card left over on the synopsis", () => {
+ // Unfocused with art on screen there is exactly one row of slack under the credits, and the
+ // plot is what claims it — the blank area under the cast line was the gap this closes.
+ mockMeta.mockReturnValue({ loading: false, meta: META_PLOT });
+ mockPoster.mockReturnValue({ loading: false, cells: art(BUDGET?.cols ?? 0, BUDGET?.rows ?? 0) });
+ const ui = renderUI(, {
+ cols: 60,
+ });
+ const out = frameLines(ui.frame());
+
+ expect(ui.frame()).toContain("A computer hacker");
+ // The row is a cut fragment of a longer plot, and says so rather than reading as the whole of
+ // a very short one.
+ expect(ui.frame()).toContain("…");
+ // Nothing overflowed to buy it: same frame, same width, art still whole.
+ expect(out).toHaveLength(1 + PANE_H);
+ for (const line of out) expect(displayWidth(line)).toBe(PANE_W);
+ ui.unmount();
+ });
+
+ it("builds the whole synopsis for a focused pane and scrolls to the end of it", async () => {
+ // Focused the planner is handed an infinite budget, so the plot arrives whole and the window
+ // — not the card — decides what is on screen. No art, so the overflow is the text alone.
+ mockMeta.mockReturnValue({ loading: false, meta: META_PLOT });
+ const ui = renderUI(, {
+ cols: 60,
+ });
+ expect(ui.frame()).toContain("The Matrix");
+ expect(ui.frame()).toContain(`${ICON.down} more`);
+
+ ui.press(PAGE_DOWN);
+ // The last words of the plot, which only exist on screen because nothing truncated it.
+ await vi.waitFor(() => expect(ui.frame()).toContain("power."));
+ const out = frameLines(ui.frame());
+ expect(out).toHaveLength(1 + 12);
+ for (const line of out) expect(displayWidth(line)).toBe(PANE_W);
+ ui.unmount();
+ });
+});
+
+// A focused pane too narrow to seat the card beside the picture: the art gives up rows instead of
+// columns, the card sits under it, and one window scrolls the two. Every assertion here is about
+// rows — which ones are on screen and how many — because that is what scrolling can get wrong, and
+// because a pane that overflows its frame shows up as fused rows rather than as a wide line.
+describe("MetaPane focused, stacked", () => {
+ // 40 columns is 36 inside Panel's frame, which leaves 7 beside a card at MIN_TEXT_COLS — under
+ // MIN_POSTER_COLS, so nothing can sit there and the pane stacks. 41 is the first width that can.
+ const WIDE_W = 40;
+ const FOCUSED_BUDGET = posterBudget(WIDE_W, INNER_ROWS, true);
+ // What fitCells answers for a 2:3 poster in that budget (36x9): 12x9, narrowed by the rows the
+ // card's guarantee kept back rather than by the pane's width.
+ const FULL_ART = art(12, 9);
+
+ const paneLines = (frame: string): string[] => frameLines(frame).slice(1, -1);
+ const artRowCount = (frame: string): number =>
+ paneLines(frame).filter((l) => l.includes("▀")).length;
+ // Rows carrying card text: not the borders, not the art, not the blank spacer, and not the
+ // scroll affordance, which is chrome the guarantee does not count.
+ const cardRowCount = (frame: string): number =>
+ paneLines(frame).filter(
+ (l) =>
+ !l.includes(ICON.up) &&
+ !l.includes(ICON.down) &&
+ l.replace(/[│▀\s]/g, "") !== "",
+ ).length;
+
+ // Swapping the row under a mounted pane, which a second render cannot express: the reset is an
+ // effect keyed on the row, so the tree has to stay alive across the change. Mirrors the
+ // setter-through-a-ref pattern Results.test.tsx uses for the query.
+ let swapRow: ((r: TorrentResult) => void) | null = null;
+ function Swappable() {
+ const [row, setRow] = useState(ROW);
+ useEffect(() => {
+ swapRow = setRow;
+ return () => {
+ swapRow = null;
+ };
+ }, []);
+ return ;
+ }
+
+ it("asks for art sized to leave the card the rows it is guaranteed", () => {
+ renderUI(
+ ,
+ ).unmount();
+ expect(FOCUSED_BUDGET).not.toBeNull();
+ // Stacked, the art still gets every column inside Panel's frame — it is height it gives up.
+ expect(FOCUSED_BUDGET?.cols).toBe(WIDE_W - 4);
+ // The eight rows the card is promised, plus the spacer and the scroll affordance, neither of
+ // which is card. A picture that filled the pane would bury the description the user focused
+ // the pane to read, and the rows past what fits are the ones scrolling gives back anyway.
+ expect(FOCUSED_BUDGET?.rows).toBe(INNER_ROWS - MIN_FOCUSED_TEXT_ROWS - 2);
+ expect(mockPoster).toHaveBeenCalledWith(
+ META.posterUrl,
+ FOCUSED_BUDGET?.cols,
+ FOCUSED_BUDGET?.rows,
+ true,
+ );
+ });
+
+ it("wears the focus the results panel gives up", () => {
+ mockPoster.mockReturnValue({ loading: false, cells: FULL_ART });
+ const ui = renderUI();
+ expect(ui.rawFrame()).toContain(ACCENT);
+ ui.unmount();
+ });
+
+ it("keeps the description on screen under the art instead of a row of title", () => {
+ // The whole point of the guarantee: a poster the user has to scroll past before reaching the
+ // synopsis is not what they focused the pane for. Eight rows of card, art and all.
+ mockMeta.mockReturnValue({ loading: false, meta: META_PLOT });
+ mockPoster.mockReturnValue({ loading: false, cells: FULL_ART });
+ const ui = renderUI(, {
+ cols: 60,
+ });
+ const out = frameLines(ui.frame());
+
+ expect(artRowCount(ui.frame())).toBe(FULL_ART.rows);
+ expect(cardRowCount(ui.frame())).toBeGreaterThanOrEqual(MIN_FOCUSED_TEXT_ROWS);
+ expect(ui.frame()).toContain("The Matrix");
+ expect(ui.frame()).toContain("A computer hacker");
+ expect(ui.frame()).toContain(`${ICON.down} more`);
+ expect(ui.frame()).not.toContain(ICON.up);
+ expect(out).toHaveLength(1 + PANE_H);
+ for (const line of out) expect(displayWidth(line)).toBe(WIDE_W);
+ ui.unmount();
+ });
+
+ it("scrolls the card under the window and clamps at the bottom", async () => {
+ mockMeta.mockReturnValue({ loading: false, meta: META_PLOT });
+ mockPoster.mockReturnValue({ loading: false, cells: FULL_ART });
+ const ui = renderUI(, {
+ cols: 60,
+ });
+
+ ui.press("j");
+ await vi.waitFor(() => expect(ui.frame()).toContain(`${ICON.up}${ICON.down} more`));
+ // One row of art gone from the top, and the card one row further along at the bottom.
+ expect(artRowCount(ui.frame())).toBe(FULL_ART.rows - 1);
+
+ // Ten presses for an overflow of four, so the last six are keys that do nothing rather than a
+ // card that keeps sliding.
+ for (let i = 0; i < 10; i++) ui.press(DOWN);
+ await vi.waitFor(() => expect(ui.frame()).toContain(`${ICON.up} more`));
+
+ const out = frameLines(ui.frame());
+ expect(out).toHaveLength(1 + PANE_H);
+ for (const line of out) expect(displayWidth(line)).toBe(WIDE_W);
+ // The bottom of the card: the last words of the plot, which only exist on screen because the
+ // focused planner was handed an infinite budget and never truncated it.
+ expect(ui.frame()).toContain("power.");
+ expect(ui.frame()).not.toContain(`${ICON.down} more`);
+
+ ui.press("k");
+ await vi.waitFor(() => expect(ui.frame()).toContain(`${ICON.up}${ICON.down} more`));
+ ui.unmount();
+ });
+
+ it("pages by a window at a time and stops at the top", async () => {
+ mockMeta.mockReturnValue({ loading: false, meta: META_PLOT });
+ mockPoster.mockReturnValue({ loading: false, cells: FULL_ART });
+ const ui = renderUI(, {
+ cols: 60,
+ });
+
+ ui.press(PAGE_DOWN);
+ await vi.waitFor(() => expect(ui.frame()).toContain("power."));
+ ui.press(PAGE_UP);
+ await vi.waitFor(() => expect(ui.frame()).toContain(`${ICON.down} more`));
+ // Clamped, not wrapped: the first row of the card is back and nothing sits above it, so
+ // every row of the art is on screen again.
+ expect(ui.frame()).not.toContain(ICON.up);
+ expect(artRowCount(ui.frame())).toBe(FULL_ART.rows);
+ ui.unmount();
+ });
+
+ it("ignores movement keys while it does not hold the keyboard", async () => {
+ mockPoster.mockReturnValue({ loading: false, cells: FULL_ART });
+ // Unfocused the same pane draws the small art the row budget allows, and j belongs to the
+ // list — a pane that scrolled from here would be stealing the cursor's key.
+ const ui = renderUI(, {
+ cols: 60,
+ });
+ const before = ui.frame();
+ ui.press("j");
+ ui.press(DOWN);
+ await new Promise((r) => setTimeout(r, 20));
+ expect(ui.frame()).toBe(before);
+ ui.unmount();
+ });
+
+ it("opens a new row at the top of its card", async () => {
+ mockMeta.mockReturnValue({ loading: false, meta: META_PLOT });
+ mockPoster.mockReturnValue({ loading: false, cells: FULL_ART });
+ const ui = renderUI(, { cols: 60 });
+
+ ui.press(PAGE_DOWN);
+ await vi.waitFor(() => expect(ui.frame()).toContain(`${ICON.up} more`));
+ // A different release is a different card, and arriving at it halfway down would be reading
+ // the middle of something the user has not seen the top of.
+ swapRow?.({ ...ROW, infoHash: "h-beta" });
+ await vi.waitFor(() => expect(ui.frame()).toContain(`${ICON.down} more`));
+ expect(ui.frame()).not.toContain(ICON.up);
+ ui.unmount();
+ });
+
+ it("leaves a card that already fits alone", () => {
+ // No plot on this fixture: nine rows of art, the spacer and six of card in nineteen rows.
+ // Nothing overflows, so there is no affordance and no row spent on one.
+ mockPoster.mockReturnValue({ loading: false, cells: FULL_ART });
+ const ui = renderUI(, {
+ cols: 60,
+ });
+ expect(ui.frame()).toContain("Cast Keanu Reeves");
+ expect(ui.frame()).not.toContain("more");
+ const out = frameLines(ui.frame());
+ expect(out).toHaveLength(1 + PANE_H);
+ for (const line of out) expect(displayWidth(line)).toBe(WIDE_W);
+ ui.unmount();
+ });
+
+ it("holds its frame on a pane too short for art at all", () => {
+ // Five inner rows: posterBudget refuses art rather than break the card's guarantee, so this is
+ // the text card alone with the whole pane to itself — which is the guarantee holding, not
+ // failing.
+ mockPoster.mockReturnValue({ loading: false, cells: FULL_ART });
+ const ui = renderUI(, {
+ cols: 60,
+ });
+ const out = frameLines(ui.frame());
+ expect(out).toHaveLength(1 + 6);
+ for (const line of out) expect(displayWidth(line)).toBe(WIDE_W);
+ expect(ui.frame()).not.toContain("▀");
+ expect(ui.frame()).toContain("The Matrix");
+ ui.unmount();
+ });
+});
+
+// The focused pane's second layout: poster in the left column, card in the right one. It exists
+// because a poster fitCells had to cap by rows comes back narrower than the pane, and stacking
+// spends the pane's whole height on the picture while leaving those freed columns as dead gutter
+// beside it. Every assertion here is about which rows carry both things at once.
+describe("MetaPane side by side", () => {
+ // 60 columns is 56 inside Panel's frame, and 18 rows leave 17 inner ones. The art is handed
+ // 56 - COLUMN_GAP - MIN_TEXT_COLS = 27 columns and all 17 rows, and a 2:3 poster fills 23x17 of
+ // that box, leaving the card 32.
+ const SPLIT_W = 60;
+ const SPLIT_H = 18;
+ const ART_COLS = 23;
+ const ART_ROWS = 17;
+ // The narrowest pane that splits at all: 41 is 37 inside the frame, which is MIN_POSTER_COLS +
+ // COLUMN_GAP + MIN_TEXT_COLS exactly. 40 is one column short and stacks.
+ const EDGE_W = 41;
+
+ // The decoder's own answer for a 2:3 poster in whatever budget the pane asks for, rather than a
+ // grid pinned to one size: the split is decided from the width fitCells narrows a height-capped
+ // poster to, so a fixture that ignored that narrowing would only ever exercise one layout.
+ const fitted = (): void => {
+ mockPoster.mockImplementation((_url, cols, rows, enabled) => {
+ if (!enabled || cols < 1 || rows < 1) return { loading: false, cells: null };
+ const f = fitCells(120, 180, cols, rows);
+ if (f.cols < 1 || f.rows < 1) return { loading: false, cells: null };
+ return { loading: false, cells: art(f.cols, f.rows) };
+ });
+ };
+
+ const paneLines = (frame: string): string[] => frameLines(frame).slice(1, -1);
+ const artRowCount = (frame: string): number =>
+ paneLines(frame).filter((l) => l.includes("▀")).length;
+ // A row carrying art and text at once is the whole claim of this layout, and the one thing the
+ // stacked layout can never produce.
+ const beside = (frame: string, text: string): boolean =>
+ paneLines(frame).some((l) => l.includes("▀") && l.includes(text));
+
+ const intact = (ui: { frame: () => string }, w: number, h: number): void => {
+ const out = frameLines(ui.frame());
+ expect(out).toHaveLength(1 + h);
+ for (const line of out) expect(displayWidth(line)).toBe(w);
+ };
+
+ it("puts the card beside the poster instead of under it", () => {
+ fitted();
+ const ui = renderUI(
+ ,
+ { cols: 100 },
+ );
+ // The art keeps the pane's whole height — beside the card it never had to give rows up — and
+ // the title sits on its first row rather than seventeen rows below it.
+ expect(artRowCount(ui.frame())).toBe(ART_ROWS);
+ expect(beside(ui.frame(), "The Matrix")).toBe(true);
+ expect(beside(ui.frame(), "Cast Keanu Reeves")).toBe(true);
+ expect(paneLines(ui.frame())[0]).toContain("\u2580".repeat(ART_COLS));
+ intact(ui, SPLIT_W, SPLIT_H);
+ ui.unmount();
+ });
+
+ it("stacks one column below the width a picture needs beside the card, with no off-by-one", () => {
+ fitted();
+ // 37 inner columns: MIN_POSTER_COLS beside the gap and MIN_TEXT_COLS, so the pane splits on
+ // the narrowest picture it is willing to draw.
+ const wide = renderUI(
+ ,
+ { cols: 100 },
+ );
+ expect(beside(wide.frame(), "The Matrix")).toBe(true);
+ intact(wide, EDGE_W, SPLIT_H);
+ wide.unmount();
+
+ // 36: one column short, and the answer is the stacked layout rather than a seven-column smear
+ // beside the card. The art gives up rows instead, and nothing sits next to it.
+ const narrow = renderUI(
+ ,
+ { cols: 100 },
+ );
+ expect(narrow.frame()).toContain("\u2580");
+ expect(beside(narrow.frame(), "The Matrix")).toBe(false);
+ intact(narrow, EDGE_W - 1, SPLIT_H);
+ narrow.unmount();
+ });
+
+ it("leaves an unfocused pane stacked however wide it is", () => {
+ // Browsing, the pane is pinned at its tier's width and the card is cut to the rows the art
+ // left — splitting there would hand both halves something too narrow to be either.
+ fitted();
+ const ui = renderUI(, {
+ cols: 100,
+ });
+ expect(ui.frame()).toContain("\u2580");
+ expect(beside(ui.frame(), "The Matrix")).toBe(false);
+ intact(ui, SPLIT_W, SPLIT_H);
+ ui.unmount();
+ });
+
+ it("scrolls both columns as one list, not two", async () => {
+ // The narrowest split, where the card is at MIN_TEXT_COLS and a full synopsis genuinely runs
+ // past the window — so there is something below the fold in both columns at once.
+ mockMeta.mockReturnValue({ loading: false, meta: META_PLOT });
+ fitted();
+ const ui = renderUI(, {
+ cols: 100,
+ });
+ expect(beside(ui.frame(), "The Matrix")).toBe(true);
+ expect(ui.frame()).toContain(`${ICON.down} more`);
+ const before = artRowCount(ui.frame());
+ // Where the card's own column starts, which must not move: a column that resized mid-scroll
+ // would rewrap text planPaneLines had already wrapped to a width the window was sized for.
+ const dirColumn = (frame: string): number =>
+ paneLines(frame).find((l) => l.includes("Dir Lana"))?.indexOf("Dir Lana") ?? -1;
+ const column = dirColumn(ui.frame());
+ // Past the art, not merely somewhere: this pane is narrower, so its poster is narrower than
+ // SPLIT_W's and the card starts wherever fitCells left off rather than at a fixed column.
+ const artWidth = (paneLines(ui.frame())[0] ?? "").match(/\u2580+/)?.[0].length ?? 0;
+ expect(artWidth).toBeGreaterThan(0);
+ expect(column).toBeGreaterThan(artWidth);
+
+ ui.press(DOWN);
+ // One row off the top of *both* columns: the title has gone with the first row of art, which
+ // is the property a second, independent scroller would break.
+ await vi.waitFor(() => expect(ui.frame()).toContain(`${ICON.up}${ICON.down} more`));
+ expect(artRowCount(ui.frame())).toBe(before - 1);
+ expect(ui.frame()).not.toContain("The Matrix");
+ expect(dirColumn(ui.frame())).toBe(column);
+ intact(ui, EDGE_W, 14);
+ ui.unmount();
+ });
+
+ // The sweep SWEEP_MS was chosen for: every width the pane can be given, against every height.
+ it("holds its frame across the widths and heights either layout can land on", () => {
+ fitted();
+ for (let w = 34; w <= 90; w += 2) {
+ for (let h = 6; h <= 22; h++) {
+ const ui = renderUI(, {
+ cols: 100,
+ });
+ const out = frameLines(ui.frame());
+ expect(out, `${w}x${h}`).toHaveLength(1 + h);
+ for (const line of out) expect(displayWidth(line), `${w}x${h} "${line}"`).toBe(w);
+ ui.unmount();
+ }
+ }
+ }, SWEEP_MS);
+});
diff --git a/src/ui/components/MetaPane.tsx b/src/ui/components/MetaPane.tsx
new file mode 100644
index 00000000..82b9cac5
--- /dev/null
+++ b/src/ui/components/MetaPane.tsx
@@ -0,0 +1,235 @@
+import { useEffect, useMemo, useState } from "react";
+import { Box, Text, useInput } from "ink";
+import { Panel } from "./Panel";
+import { Poster } from "./Poster";
+import { Spinner } from "./Spinner";
+import { usePoster } from "../hooks/usePoster";
+import { useResultMeta } from "../hooks/useResultMeta";
+import { scrollStart } from "../move";
+import { planPaneLines } from "../paneCard";
+import { COLUMN_GAP, MAX_TEXT_COLS, posterBudget, splitTextCols } from "../previewLayout";
+import { COLOR, ICON } from "../theme";
+import type { PosterCells } from "../../meta/image";
+import type { TorrentResult } from "../../sources/types";
+
+/**
+ * The live card beside the results list: what the row under the cursor actually is, without the
+ * user opening anything.
+ *
+ * It owns its own lookup rather than being handed one, so the pane is the only thing that decides
+ * when a row is worth a request — mounting it starts one, closing it (the `i` key, or a terminal
+ * too narrow for the split) stops it. The lookup keeps the hook's default debounce: holding an
+ * arrow key down sweeps past rows the user never asked about, and that delay is what makes those
+ * rows free.
+ *
+ * `poster` is the tier's answer from previewLayout, not a preference: the narrowest split has the
+ * columns for art but not enough of them for it to read as a picture, and that call belongs with
+ * the widths it was made from.
+ *
+ * `focused` is the pane holding the keyboard (region "preview"). It is one prop rather than a read
+ * of the store because the pane is rendered standalone in its own tests, and because the pane
+ * itself has no opinion on what focus means — it is told, and answers with a wider card, a
+ * full-size poster, rows that scroll instead of rows that were cut to fit, and — where the columns
+ * are there for it — the poster and the card side by side instead of stacked.
+ */
+export function MetaPane({
+ result,
+ width,
+ height,
+ poster,
+ focused = false,
+}: {
+ result: TorrentResult | null;
+ width: number;
+ height: number;
+ poster: boolean;
+ focused?: boolean;
+}) {
+ const { loading, meta } = useResultMeta(result, true);
+
+ // Panel draws its bottom border inside `height` (its title bar is the separate row above), and
+ // pads one column each side inside a 1-column border.
+ const innerWidth = Math.max(1, width - 4);
+ const innerRows = Math.max(0, height - 1);
+
+ // Two independent vetoes: the tier says whether art belongs at this width at all, posterBudget
+ // says whether this pane has the rows for it. Both have to agree before a single byte goes over
+ // the wire.
+ const budget = poster ? posterBudget(width, innerRows, focused) : null;
+ const { cells } = usePoster(
+ meta?.posterUrl,
+ budget?.cols ?? 0,
+ budget?.rows ?? 0,
+ budget !== null,
+ );
+
+ // Art is drawn only once it demonstrably fits the budget it is being drawn into. Cells outgrow
+ // their pane for exactly one frame on a resize — the hook re-keys in an effect, so the render
+ // that first sees the new width still holds the old grid — and one frame of a too-tall poster is
+ // a fused row through Yoga's shrink math, in the pane *and* in the list beside it.
+ const art =
+ cells !== null && budget !== null && cells.cols <= budget.cols && cells.rows <= budget.rows
+ ? cells
+ : null;
+
+ // Scroll offset in content rows, owned here because clamping needs the row count only this
+ // component knows. A new row is a new card, so it opens at the top — anything else would leave
+ // the user reading the middle of a release they just arrived at.
+ const rowKey = result?.infoHash ?? null;
+ const [scroll, setScroll] = useState(0);
+ useEffect(() => {
+ setScroll(0);
+ }, [rowKey]);
+
+ // The card is laid out around the art that actually rendered, never around art that is merely
+ // expected — including which of the two layouts below it gets. A poster still in flight, refused
+ // by the host sniff or rejected by the decoder therefore leaves the text with the whole pane to
+ // itself: no reserved hole, no gap, and no column held open for a picture that may never come.
+ // The cost is one settle when art does land, and only then.
+ const artRows = art === null ? 0 : art.rows;
+ const artCols = art === null ? 0 : art.cols;
+
+ // A poster fitCells had to cap by rows comes back narrower than the pane that asked for it, and
+ // stacking leaves those freed columns as dead gutter beside the art while the card below them
+ // has one row to say anything in. Focused, the pane spends them on the card instead: the art
+ // takes the left column and the text flows down the right. splitTextCols answers null when the
+ // card would land under a readable measure, and null is the stacked layout the pane has always
+ // drawn — unfocused it is the only layout, because a 34-column tier split two ways is neither.
+ const cardCols = focused ? splitTextCols(innerWidth, artCols) : null;
+ const split = cardCols !== null;
+ // Clamped in both layouts, because the pane is no longer only ever a card: it is granted width
+ // for a poster *and* a measure, so a poster that arrives narrower than the box it was budgeted
+ // into — a squarer rendition, or one refused entirely — would otherwise leave the card wrapping
+ // prose across the whole grant. MAX_TEXT_COLS is the measure either way; any surplus stays blank.
+ const textWidth = Math.min(cardCols ?? innerWidth, MAX_TEXT_COLS);
+
+ // Rows the art claims off the top of the card before the text starts. Stacked that is the whole
+ // picture plus the blank spacer under it; side by side it is none of them — row i is art row i
+ // *beside* card row i, so both columns share one offset and one window slices them together.
+ const head = split || art === null ? 0 : artRows + 1;
+ // Unfocused the card is cut to what is left; focused it is built whole and the window below
+ // decides what shows, which is the entire point of being able to focus it.
+ const textBudget = focused ? Number.POSITIVE_INFINITY : Math.max(0, innerRows - head);
+ // Memoised because the pane re-renders on every search tick and every cursor move, while the
+ // word wrapper is linear in the plot — the one field long enough for that to be worth a cache.
+ const lines = useMemo(
+ () => (meta === null ? [] : planPaneLines(meta, textWidth, textBudget)),
+ [meta, textWidth, textBudget],
+ );
+ // One entry per terminal row, so the window can cut inside a wrapped credit.
+ const textRows = lines.flatMap((l) =>
+ l.text.split("\n").map((text, i) => ({ key: `${l.key}:${i}`, text, tone: l.tone })),
+ );
+
+ // Side by side the two columns are the same rows, not consecutive ones, so the block is as tall
+ // as the taller of them rather than as tall as both.
+ const total = split ? Math.max(artRows, textRows.length) : head + textRows.length;
+ // The affordance costs a row, and it only exists when there is something off screen to point
+ // at, so the two are resolved together: overflow against the full height, then the window
+ // against what the affordance leaves. Unfocused there is nothing to resolve — planPaneLines
+ // already fitted the card — and the whole block renders as it always did.
+ const overflow = focused && total > innerRows;
+ const viewRows = Math.max(1, overflow ? innerRows - 1 : innerRows);
+ const start = focused ? scrollStart(scroll, total, viewRows) : 0;
+ const end = focused ? start + viewRows : total;
+
+ const page = Math.max(1, viewRows - 1);
+ const scrollBy = (delta: number): void =>
+ setScroll((prev) => {
+ // `prev` can outrun the content it was clamped against — a poster landing, a resize, a
+ // shorter card on the next row — so it is re-clamped before the step rather than after, or
+ // the first key press after a shrink is spent walking back into range.
+ const from = scrollStart(prev, total, viewRows);
+ return scrollStart(from + delta, total, viewRows);
+ });
+
+ // Movement keys only: everything else the results view binds stays with the results view, so
+ // stepping into the pane never quietly changes what d, y or / do.
+ useInput(
+ (input, key) => {
+ if (key.upArrow || input === "k") scrollBy(-1);
+ else if (key.downArrow || input === "j") scrollBy(1);
+ else if (key.pageUp) scrollBy(-page);
+ else if (key.pageDown) scrollBy(page);
+ },
+ { isActive: focused },
+ );
+
+ // Sliced rather than re-decoded, and identity-preserved in the common case where the whole
+ // poster is on screen: Poster is memoised, and a fresh object every render would defeat that
+ // for the one thing in this pane expensive enough to reconcile.
+ const artWindow = useMemo(() => {
+ if (art === null) return null;
+ const from = Math.min(start, art.rows);
+ const to = Math.min(end, art.rows);
+ if (to <= from) return null;
+ if (from === 0 && to === art.rows) return art;
+ return { cols: art.cols, rows: to - from, lines: art.lines.slice(from, to) };
+ }, [art, start, end]);
+
+ // The spacer row only exists in the stacked layout, and only while the window is over it.
+ const gapVisible = !split && art !== null && start <= artRows && artRows < end;
+ const shownText = textRows.slice(Math.max(0, start - head), Math.max(0, end - head));
+ const more = `${start > 0 ? ICON.up : ""}${end < total ? ICON.down : ""} more`;
+
+ const card = shownText.map((l) => (
+
+ {l.text}
+
+ ));
+
+ return (
+
+ {loading ? (
+
+ ) : meta === null ? (
+ // One answer for a Games row, an unmatched release and a dead network alike. The pane is
+ // a bonus beside the list, and a red error string for a lookup nobody asked for would
+ // make a working search look broken.
+ No metadata
+ ) : (
+
+ {split ? (
+ // Both columns are pinned, and together they never exceed innerWidth — usually they
+ // fill it, and where the card hit MAX_TEXT_COLS the remainder simply stays blank. What
+ // matters is that Yoga is never asked to shrink either one: a shrunk column would
+ // rewrap text planPaneLines already wrapped, which is how a card ends up a row taller
+ // than the window that was sized for it. The art's column keeps its width even once
+ // the window has scrolled past the last row of the picture, so the card never slides
+ // left mid-scroll and never rewraps under the reader.
+
+
+ {artWindow !== null && }
+
+
+ {card}
+
+
+ ) : (
+ <>
+ {artWindow !== null && }
+ {gapVisible && }
+ {card}
+ >
+ )}
+ {overflow && (
+ // The calm theme's answer to a scrollbar: one dim line saying which way there is more
+ // of the card, in the same voice as every other hint in the app.
+ {more}
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/ui/components/Poster.test.tsx b/src/ui/components/Poster.test.tsx
new file mode 100644
index 00000000..97630b79
--- /dev/null
+++ b/src/ui/components/Poster.test.tsx
@@ -0,0 +1,85 @@
+import { Box } from "ink";
+import { describe, expect, it } from "vitest";
+import { Poster } from "./Poster";
+import { renderUI } from "../testHarness";
+import { displayWidth } from "../textWidth";
+import type { PosterCells } from "../../meta/image";
+
+// The invariant this file exists for: a poster line must measure exactly `cols` columns by the
+// same accounting the panes use to decide what fits. U+2580 is in Block Elements, which
+// textWidth.ts classifies as width 1 — if that ever drifted to 2, Yoga would clip every poster row
+// and the corruption would land in the results list beside the pane, not in the poster.
+
+function cells(cols: number, rows: number, fg = "#ff0000", bg = "#0000ff"): PosterCells {
+ return {
+ cols,
+ rows,
+ lines: Array.from({ length: rows }, () => [{ fg, bg, n: cols }]),
+ };
+}
+
+/** Frame lines with the harness's trailing blanks dropped. */
+function lines(frame: string): string[] {
+ return frame.split("\n").filter((l) => l.trim() !== "");
+}
+
+describe("Poster", () => {
+ it("draws one row per cell line, each exactly `cols` columns wide", () => {
+ const ui = renderUI();
+ const out = lines(ui.frame());
+ expect(out).toHaveLength(3);
+ for (const line of out) {
+ expect(line).toBe("▀".repeat(6));
+ expect(displayWidth(line)).toBe(6);
+ }
+ ui.unmount();
+ });
+
+ it("emits one styled span per run and keeps the row's total width", () => {
+ const ui = renderUI(
+ ,
+ );
+ expect(lines(ui.frame())).toEqual(["▀".repeat(5)]);
+ // Ink routes color/backgroundColor through chalk, so a truecolour terminal gets 38;2 and 48;2
+ // pairs. Asserting on them is what proves the background half of the cell is actually painted:
+ // without it a poster renders as a monochrome silhouette and still passes a width check.
+ const raw = ui.rawFrame();
+ expect(raw).toContain("\u001b[38;2;255;0;0m");
+ expect(raw).toContain("\u001b[48;2;0;0;255m");
+ expect(raw).toContain("\u001b[38;2;0;255;0m");
+ expect(raw).toContain("\u001b[48;2;0;0;0m");
+ ui.unmount();
+ });
+
+ it("keeps its rows intact inside a box exactly its own width", () => {
+ // A pane is a fixed-width box with overflow hidden. Yoga answers an overflowing child by
+ // squeezing rows — lines get dropped and fused rather than truncated — so the assertion that
+ // matters is that all of them are still there at their full width.
+ const ui = renderUI(
+
+
+ ,
+ );
+ const out = lines(ui.frame());
+ expect(out).toHaveLength(12);
+ for (const line of out) expect(displayWidth(line)).toBe(24);
+ ui.unmount();
+ });
+
+ it("renders nothing at all for an empty grid", () => {
+ const ui = renderUI();
+ expect(lines(ui.frame())).toEqual([]);
+ ui.unmount();
+ });
+});
diff --git a/src/ui/components/Poster.tsx b/src/ui/components/Poster.tsx
new file mode 100644
index 00000000..cafce937
--- /dev/null
+++ b/src/ui/components/Poster.tsx
@@ -0,0 +1,36 @@
+import { memo } from "react";
+import { Box, Text } from "ink";
+import type { PosterCells } from "../../meta/image";
+
+// U+2580 UPPER HALF BLOCK. The foreground colour paints the top half of the cell and the
+// background colour the bottom half, which is how two pixel rows fit in one terminal row.
+// textWidth.ts classifies the whole Block Elements range as one column, so a line of these
+// measures exactly `cols` — the property the pane's frame and the list beside it depend on.
+const HALF_BLOCK = "▀";
+
+/**
+ * A decoded poster as rows of coloured half-blocks.
+ *
+ * Per-run `` rather than per-cell, the same idiom as Logo.tsx but with the runs already
+ * merged upstream. Ink's `backgroundColor` goes through chalk, which downgrades a hex colour to
+ * the 256- or 16-colour palette the terminal actually advertises, so there is no quantizer here
+ * and no capability sniffing: the art is emitted in truecolour and chalk decides what survives.
+ *
+ * Memoised because the pane re-renders on every search tick and every cursor move, while the art
+ * only changes when the selected row does — and a 24x18 poster is ~430 elements to reconcile.
+ */
+export const Poster = memo(function Poster({ cells }: { cells: PosterCells }) {
+ return (
+
+ {cells.lines.map((runs, row) => (
+
+ {runs.map((run, i) => (
+
+ {HALF_BLOCK.repeat(run.n)}
+
+ ))}
+
+ ))}
+
+ );
+});
diff --git a/src/ui/components/Results.test.tsx b/src/ui/components/Results.test.tsx
index 7cfa2515..ba2d1391 100644
--- a/src/ui/components/Results.test.tsx
+++ b/src/ui/components/Results.test.tsx
@@ -1,15 +1,25 @@
+import { useEffect, useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SOURCES } from "../../sources/registry";
-import { StoreContext } from "../store";
+import { StoreContext, type Store } from "../store";
import {
KEY,
makeTestStore,
renderUI,
+ TEST_COLS,
TEST_CONTENT_WIDTH,
type RenderedUI,
} from "../testHarness";
import { Results } from "./Results";
+import { RAIL_WIDTH } from "./Sidebar";
+import { resultsPanelOuter } from "../move";
+import { MIN_FOCUSED_TEXT_ROWS, previewLayout } from "../previewLayout";
+import { displayWidth } from "../textWidth";
+import { ICON } from "../theme";
+import { fitCells } from "../../meta/image";
import type { ConcurrentSearchState } from "../hooks/useConcurrentSearch";
+import type { MetaState } from "../hooks/useResultMeta";
+import type { Meta } from "../../meta/types";
import type { TorrentResult } from "../../sources/types";
const searchState = vi.hoisted(() => ({ current: null as unknown }));
@@ -18,6 +28,41 @@ vi.mock("../hooks/useConcurrentSearch", () => ({
useConcurrentSearch: () => searchState.current,
}));
+const IDLE_META: MetaState = { loading: false, meta: null };
+const metaState = vi.hoisted(() => ({ current: null as unknown }));
+
+vi.mock("../hooks/useResultMeta", () => ({
+ useResultMeta: () => metaState.current,
+}));
+
+// Off for every test above — none of their fixtures carries a posterUrl, so the real hook would
+// answer null anyway — and switched on by the frame sweep at the bottom, which needs art in the
+// pane without a byte going over the wire.
+const posterOn = vi.hoisted(() => ({ current: false }));
+
+vi.mock("../hooks/usePoster", () => ({
+ usePoster: (_url: string | undefined, cols: number, rows: number, enabled: boolean) => {
+ if (!posterOn.current || !enabled || cols < 1 || rows < 1) return { loading: false, cells: null };
+ // fitCells itself, on a 2:3 poster, rather than a hand-rolled approximation of it: the real
+ // decoder narrows a grid it had to cap by rows to preserve aspect, and that narrowing is what
+ // the focused pane's side-by-side layout is decided from. A mock that kept the full width
+ // would hand every case a poster as wide as the pane and quietly test only one of the two
+ // layouts.
+ const fit = fitCells(120, 180, cols, rows);
+ if (fit.cols < 1 || fit.rows < 1) return { loading: false, cells: null };
+ return {
+ loading: false,
+ cells: {
+ cols: fit.cols,
+ rows: fit.rows,
+ lines: Array.from({ length: fit.rows }, () => [
+ { fg: "#ff0000", bg: "#0000ff", n: fit.cols },
+ ]),
+ },
+ };
+ },
+}));
+
const t = (infoHash: string, name: string): TorrentResult => ({
infoHash,
name,
@@ -55,21 +100,67 @@ afterEach(() => {
ui = null;
});
-async function mount(results: TorrentResult[] = LIST): Promise {
+// App.tsx's own width math, so a test asking for a wider terminal gets the content width the real
+// app would hand Results at that size rather than a hand-copied number that can drift from it.
+const contentWidthFor = (cols: number): number => Math.max(24, cols - RAIL_WIDTH - 3);
+
+async function mount(
+ results: TorrentResult[] = LIST,
+ storeOverrides: Partial = {},
+ meta: MetaState = IDLE_META,
+ cols: number = TEST_COLS,
+): Promise {
searchState.current = settled(results);
+ metaState.current = meta;
ui = renderUI(
-
+ ,
+ { cols },
);
const u = ui;
- await vi.waitFor(() => expect(u.frame()).toContain(`Results (${results.length})`));
+ // An empty list has no count in the panel title, so it is settled by its status line instead.
+ const settledMark = results.length > 0 ? `Results (${results.length})` : "No results for";
+ await vi.waitFor(() => expect(u.frame()).toContain(settledMark));
return u;
}
+// The detail panel's height comes from listRows, same as the list view. The default test
+// listRows (14) leaves ~5 content rows after the search bar and panel chrome — enough for the
+// pre-existing rows but not for five more metadata rows on top, so detail-view tests ask for a
+// tall enough panel to actually see what they're asserting on instead of silently clipping it.
+async function openDetail(u: RenderedUI): Promise {
+ u.press(KEY.enter);
+ // "Magnet" is an unconditional detail-view row (present regardless of metadata state), so
+ // waiting on it — rather than the result's own name, which the list view already shows —
+ // actually proves the mode switch happened instead of matching the still-open list frame.
+ await vi.waitFor(() => expect(u.frame()).toContain("Magnet"));
+}
+
const lines = (u: RenderedUI): string[] => u.frame().split("\n");
const lineIndex = (u: RenderedUI, needle: string): number =>
lines(u).findIndex((l) => l.includes(needle));
+// A plain `.toContain("esc back")` still passes when the hint row is fused with stray content
+// from an overflowing row above it (`esc back田`, `esc backLibby`) — the exact corruption this
+// feature has produced before. This instead requires "esc back" to be followed by nothing but
+// padding and the panel's own right border, which only a clean, unfused hint row satisfies.
+const hintRowIntact = (u: RenderedUI): boolean =>
+ lines(u).some((l) => /esc back\s*│$/.test(l));
+// `.length` undercounts a CJK/emoji line (one JS unit, two terminal columns) by roughly half, so
+// it stays "within budget" even when the real rendered line is corrupted or overflowing — the
+// exact blind spot that let a display-width bug through review with every existing test green.
+// This uses the component's own `displayWidth`, not a second implementation that could quietly
+// drift from it and give false confidence again.
+const widthFits = (u: RenderedUI): void => {
+ for (const l of lines(u)) expect(displayWidth(l)).toBeLessThanOrEqual(TEST_CONTENT_WIDTH);
+};
// The TextField cursor renders as SGR inverse; nothing else in this view does.
const editing = (u: RenderedUI): boolean => u.rawFrame().includes(`${KEY.esc}[7m`);
@@ -78,6 +169,28 @@ async function openFilter(u: RenderedUI): Promise {
await vi.waitFor(() => expect(editing(u)).toBe(true));
}
+// Lets a test change the query the way submitQuery does — on the mounted tree, so the effects
+// keyed on it actually run — which a fresh render cannot express. Mirrors the setter-through-a-ref
+// pattern useResultMeta.test.tsx uses for the same reason.
+let setQuery: ((q: string) => void) | null = null;
+
+function Queried({ cols }: { cols: number }) {
+ const [q, setQ] = useState("linux iso");
+ useEffect(() => {
+ setQuery = setQ;
+ return () => {
+ setQuery = null;
+ };
+ }, []);
+ return (
+
+
+
+ );
+}
+
async function type(u: RenderedUI, text: string, expectCount: number): Promise {
u.press(text);
await vi.waitFor(() => expect(u.frame()).toContain(`(${expectCount})`));
@@ -185,3 +298,913 @@ describe("Results filter UI", () => {
expect(u.frame()).toContain("Results (8)");
});
});
+
+const MOVIE_META: Meta = {
+ imdbId: "tt0111161",
+ kind: "movie",
+ title: "The Shawshank Redemption",
+ year: "1994",
+ rating: "9.3",
+ runtime: "142 min",
+ genres: ["Drama"],
+ cast: ["Tim Robbins", "Morgan Freeman"],
+ director: ["Frank Darabont"],
+ plot: "A banker convicted of murdering his wife forms a friendship over a number of years.",
+};
+
+// Cinemeta sends null director for series; an empty array is the routine shape here, not an
+// edge case, which is exactly why the "no Director row" guard below matters.
+const SERIES_META: Meta = {
+ imdbId: "tt0944947",
+ kind: "series",
+ title: "Game of Thrones",
+ year: "2011–2019",
+ rating: "9.2",
+ genres: ["Drama", "Fantasy"],
+ cast: ["Emilia Clarke", "Kit Harington"],
+ director: [],
+ plot: "Nine noble families fight for control of the mythical land of Westeros.",
+};
+
+// Tall enough that the detail panel's fixed height (derived from listRows) never clips a row
+// these tests assert on — the default test listRows only fits the pre-existing rows.
+const TALL_DETAIL_STORE = { listRows: 40 };
+
+describe("Results detail metadata", () => {
+ it("renders rating, genres, director, cast and plot when metadata is present", async () => {
+ const u = await mount(LIST, TALL_DETAIL_STORE, { loading: false, meta: MOVIE_META });
+ await openDetail(u);
+
+ const frame = u.frame();
+ expect(frame).toContain("Rating");
+ expect(frame).toContain("9.3 / 10");
+ expect(frame).toContain("Genres");
+ expect(frame).toContain("Drama");
+ expect(frame).toContain("Director");
+ expect(frame).toContain("Frank Darabont");
+ expect(frame).toContain("Cast");
+ expect(frame).toContain("Tim Robbins, Morgan Freeman");
+ expect(frame).toContain("Plot");
+ expect(frame).toContain("A banker convicted of murdering his wife");
+
+ widthFits(u);
+ });
+
+ it("renders none of the metadata rows when meta is null", async () => {
+ const u = await mount(LIST, TALL_DETAIL_STORE, IDLE_META);
+ await openDetail(u);
+
+ // The existing rows still render exactly as before...
+ expect(u.frame()).toContain("Magnet");
+ // ...but nothing new appears: no error row, no placeholder, no partial label.
+ expect(u.frame()).not.toContain("Rating");
+ expect(u.frame()).not.toContain("Genres");
+ expect(u.frame()).not.toContain("Director");
+ expect(u.frame()).not.toContain("Cast");
+ expect(u.frame()).not.toContain("Plot");
+
+ widthFits(u);
+ });
+
+ it("omits the Director row for a series with no director", async () => {
+ const u = await mount(LIST, TALL_DETAIL_STORE, { loading: false, meta: SERIES_META });
+ await openDetail(u);
+
+ const frame = u.frame();
+ // Metadata that does exist still renders...
+ expect(frame).toContain("Cast");
+ expect(frame).toContain("Emilia Clarke");
+ // ...but an empty director list produces no row at all, not a blank one.
+ expect(frame).not.toContain("Director");
+
+ widthFits(u);
+ });
+});
+
+// The maximum shape Meta's own caps allow: 6 genres, 12 cast, 3 directors, an 800-char plot.
+// Exercises the metadata layout budget at its worst case, not just a comfortable one.
+const MAX_META: Meta = {
+ imdbId: "tt0111161",
+ kind: "movie",
+ title: "The Shawshank Redemption",
+ year: "1994",
+ rating: "9.3",
+ runtime: "142 min",
+ genres: ["Drama", "Crime", "Prison", "Redemption", "Friendship", "Hope"],
+ cast: [
+ "Tim Robbins",
+ "Morgan Freeman",
+ "Bob Gunton",
+ "William Sadler",
+ "Clancy Brown",
+ "Gil Bellows",
+ "Mark Rolston",
+ "James Whitmore",
+ "Jeffrey DeMunn",
+ "Larry Brandenburg",
+ "Neil Giuntoli",
+ "Brian Libby",
+ ],
+ director: ["Frank Darabont", "Second Director", "Third Director"],
+ plot: "A".repeat(800),
+};
+
+describe("Results detail metadata at a realistic terminal height", () => {
+ // App.tsx's own listRows formula gives 17 for a standard 24-row terminal — the height that
+ // matters for real usage, as opposed to TALL_DETAIL_STORE's 40, which exists purely to pin the
+ // full, unclipped rendering path above. At this height the detail panel's inner content area
+ // (Panel's height minus its own bottom border) is exactly 11 rows, and the six torrent-fact
+ // rows below already use all 11 once Files and Added both apply — leaving no room for any
+ // metadata row at all, which the first test asserts holds up even under MAX_META.
+ const REALISTIC_STORE = { listRows: 17 };
+
+ it("keeps every torrent fact and the action hint intact, even with MAX_META and no room to spare", async () => {
+ const withFiles = [{ ...t("a1", "ubuntu 24.04 desktop amd64 iso"), numFiles: 3 }];
+ const u = await mount(withFiles, REALISTIC_STORE, { loading: false, meta: MAX_META });
+ await openDetail(u);
+
+ const frame = u.frame();
+ // The row order and content that existed before this feature must survive completely
+ // unchanged: nothing dropped from the top, nothing fused, nothing renamed.
+ expect(frame).toContain("ubuntu 24.04 desktop");
+ expect(frame).toContain("Size");
+ expect(frame).toContain("Health");
+ expect(frame).toContain("Files");
+ expect(frame).toContain("Added");
+ expect(frame).toContain("Hash");
+ expect(frame).toContain("Magnet");
+ expect(frame).toContain("d Download");
+ expect(hintRowIntact(u)).toBe(true);
+
+ widthFits(u);
+ });
+
+ it("drops genres, director and cast as one unit once genres fails to fit, while rating and plot survive on either side of the gap", async () => {
+ // No numFiles or Added on this result, so the facts block is two rows shorter than the test
+ // above — budget 2 rather than 0. Rating (1 line) is admitted first, leaving 1. Genres needs 2
+ // lines for MAX_META's six genres and does not fit, which cuts off every row after it in that
+ // all-or-nothing group — including Director, whose own three names *would* fit in the 1 line
+ // Genres left behind (1 <= 1) if admitted independently. Showing Director there anyway is
+ // exactly the bug a prior, unreviewed version of this fix had: it admitted each of
+ // genres/director/cast independently instead of sharing one cutoff, so Director rendered while
+ // Genres, ranked above it, did not — a row missing from the middle of that group. Plot is a
+ // deliberate exception to the cutoff (see planMetaRows' doc comment) and still claims the 1
+ // line Genres left unclaimed, so the real, intended result has a gap — Genres/Director/Cast
+ // all missing — between Rating and Plot, not "no gap at all".
+ const minimal = { ...t("a1", "ubuntu 24.04 desktop amd64 iso"), numFiles: undefined, added: undefined };
+ const u = await mount([minimal], REALISTIC_STORE, { loading: false, meta: MAX_META });
+ await openDetail(u);
+
+ const frame = u.frame();
+ // The title row is the loudest signature of this class of corruption — it's the first thing
+ // Yoga's shrink math squeezes away when the panel overflows.
+ expect(frame).toContain("ubuntu 24.04 desktop");
+ expect(frame).toContain("Rating");
+ expect(frame).toContain("9.3 / 10");
+ expect(frame).not.toContain("Genres");
+ expect(frame).not.toContain("Director");
+ expect(frame).not.toContain("Cast");
+ expect(frame).toContain("Plot");
+ // The facts and hint rows are unaffected by how much metadata budget is left.
+ expect(frame).toContain("Magnet");
+ expect(frame).toContain("d Download");
+ expect(hintRowIntact(u)).toBe(true);
+
+ widthFits(u);
+ });
+
+ it("hard-breaks a single unbreakable token so it cannot undercount its way past the budget", async () => {
+ // No spaces at all — nothing for the greedy word-wrapper to break on except its hard
+ // per-character fallback. Without that fallback this "word" is undercounted as one line when
+ // it actually needs many, which is exactly what let a single unbroken run of text blow through
+ // a budget that looked, on paper, like it had room to spare (the original Critical bug, and
+ // the plot's 800-char stress case is unbroken text for the same reason). A tight budget is
+ // required to make the miscount visible: at a generous budget the same miscount just leaves
+ // unused slack, so this reuses REALISTIC_STORE with a fact row dropped rather than
+ // TALL_DETAIL_STORE.
+ const unbreakable = "X".repeat(600);
+ const meta: Meta = { ...MAX_META, genres: [], cast: [], director: [unbreakable] };
+ const minimal = { ...t("a1", "ubuntu 24.04 desktop amd64 iso"), numFiles: undefined, added: undefined };
+ const u = await mount([minimal], REALISTIC_STORE, { loading: false, meta });
+ await openDetail(u);
+
+ const frame = u.frame();
+ expect(frame).toContain("ubuntu 24.04 desktop");
+ expect(frame).toContain("Magnet");
+ expect(frame).toContain("d Download");
+ expect(hintRowIntact(u)).toBe(true);
+
+ widthFits(u);
+ });
+});
+
+// Nyaa (one of torlink's own sources) is an anime index, and Cinemeta routinely returns CJK cast
+// and plot text for Japanese/Korean/Chinese titles — this is a routine path, not an edge case.
+// Each CJK character is one JS string unit but two terminal columns, which a length-based layout
+// budget silently undercounts by half; these pin the fix at the reviewer's own repro shapes.
+describe("Results detail metadata with CJK text", () => {
+ const REALISTIC_STORE = { listRows: 17 };
+
+ const CJK_CAST = [
+ "田中誠",
+ "鈴木一郎",
+ "佐藤健二",
+ "高橋美咲",
+ "伊藤大輔",
+ "渡辺直樹",
+ "山本花子",
+ "中村和也",
+ "小林優子",
+ "加藤誠一",
+ "吉田真央",
+ "山田太郎",
+ ];
+
+ const cjkCastMeta: Meta = { ...MAX_META, cast: CJK_CAST, genres: ["Drama"], director: [] };
+ // Genres/director cleared and plot dropped so Cast is the only thing competing for budget —
+ // needed for the "admitted and rendered" tests below, where the point is to actually exercise
+ // the wide-char measurement on a multi-line wrapped value, not just an admit/reject decision.
+ const cjkCastOnlyMeta: Meta = { ...MAX_META, cast: CJK_CAST, genres: [], director: [], plot: undefined };
+ const cjkPlotMeta: Meta = {
+ ...MAX_META,
+ cast: [],
+ genres: [],
+ director: [],
+ plot: "本作は刑務所を舞台にした友情と希望の物語である。".repeat(20),
+ };
+
+ it("renders a CJK cast without corruption at listRows=17, budget 2 (reviewer's exact repro)", async () => {
+ // No numFiles or Added — the same budget-2 shape as the mutant-killing test above. Budget 2
+ // cannot admit a 3-line CJK cast list regardless of how it's measured (Rating alone already
+ // takes 1), so this specifically pins "still renders cleanly when correctly rejected" — it is
+ // not the test that exercises the wide-char table on admitted content; see the two tests below
+ // for that.
+ const minimal = { ...t("a1", "ubuntu 24.04 desktop amd64 iso"), numFiles: undefined, added: undefined };
+ const u = await mount([minimal], REALISTIC_STORE, { loading: false, meta: cjkCastMeta });
+ await openDetail(u);
+
+ const frame = u.frame();
+ expect(frame).toContain("ubuntu 24.04 desktop");
+ expect(frame).toContain("Size");
+ expect(frame).toContain("Health");
+ expect(frame).toContain("Hash");
+ expect(frame).toContain("Magnet");
+ expect(frame).toContain("d Download");
+ expect(hintRowIntact(u)).toBe(true);
+
+ widthFits(u);
+ });
+
+ it("admits and renders a CJK cast at listRows=20 with just enough budget to fit it", async () => {
+ // Minimal facts (no numFiles/Added) plus an otherwise-empty meta gives budget 5 at
+ // listRows=20: Rating (1) + the CJK cast's real 3-line wrap = 4, with 1 line to spare — tight
+ // enough that a wrong (undercounted) line-count prediction changes the outcome, unlike a
+ // generous budget where the same miscount just wastes slack invisibly.
+ const minimal = { ...t("a1", "ubuntu 24.04 desktop amd64 iso"), numFiles: undefined, added: undefined };
+ const u = await mount([minimal], { listRows: 20 }, { loading: false, meta: cjkCastOnlyMeta });
+ await openDetail(u);
+
+ const frame = u.frame();
+ expect(frame).toContain("ubuntu 24.04 desktop");
+ expect(frame).toContain("Size");
+ expect(frame).toContain("Health");
+ expect(frame).toContain("Hash");
+ expect(frame).toContain("Magnet");
+ expect(frame).toContain("Cast");
+ for (const name of CJK_CAST) expect(frame).toContain(name);
+ expect(frame).toContain("d Download");
+ expect(hintRowIntact(u)).toBe(true);
+
+ widthFits(u);
+ });
+
+ it("renders a CJK plot without corruption at listRows=20", async () => {
+ const withFiles = [{ ...t("a1", "ubuntu 24.04 desktop amd64 iso"), numFiles: 3 }];
+ const u = await mount(withFiles, { listRows: 20 }, { loading: false, meta: cjkPlotMeta });
+ await openDetail(u);
+
+ const frame = u.frame();
+ expect(frame).toContain("ubuntu 24.04 desktop");
+ expect(frame).toContain("Size");
+ expect(frame).toContain("Health");
+ expect(frame).toContain("Files");
+ expect(frame).toContain("Added");
+ expect(frame).toContain("Hash");
+ expect(frame).toContain("Magnet");
+ expect(frame).toContain("d Download");
+ expect(hintRowIntact(u)).toBe(true);
+
+ widthFits(u);
+ });
+
+ it("renders a full CJK cast list unclipped at a tall terminal", async () => {
+ const u = await mount(LIST, TALL_DETAIL_STORE, { loading: false, meta: cjkCastMeta });
+ await openDetail(u);
+
+ const frame = u.frame();
+ expect(frame).toContain("ubuntu 24.04 desktop");
+ expect(frame).toContain("Cast");
+ // All twelve names present somewhere in the wrapped, multi-line Cast value.
+ for (const name of CJK_CAST) expect(frame).toContain(name);
+ expect(frame).toContain("d Download");
+ expect(hintRowIntact(u)).toBe(true);
+
+ widthFits(u);
+ });
+});
+
+// Astral code points (emoji outside the BMP, CJK Extension B+ ideographs) get no free ride from
+// `.length` the way a BMP character never did either — `for...of` yields one code point per
+// surrogate pair, so an unlisted astral range undercounts exactly like an unlisted BMP one. A
+// prior version of this file's width table left every astral range out on the theory that
+// `.length` already "handled" them, which reproduced the original Critical bug on emoji plots.
+describe("Results detail metadata with astral and BMP emoji", () => {
+ const clapperMeta: Meta = {
+ imdbId: "tt3", kind: "movie", title: "t", rating: "8.0",
+ genres: [], cast: [], director: [], plot: "🎬".repeat(200),
+ };
+ const starMeta: Meta = {
+ imdbId: "tt4", kind: "movie", title: "t", rating: "8.0",
+ genres: [], cast: [], director: [], plot: "⭐".repeat(200),
+ };
+
+ it("renders an astral emoji (clapper board) plot without corruption at listRows=20", async () => {
+ const withFiles = [{ ...t("a1", "ubuntu 24.04 desktop amd64 iso"), numFiles: 3 }];
+ const u = await mount(withFiles, { listRows: 20 }, { loading: false, meta: clapperMeta });
+ await openDetail(u);
+
+ const frame = u.frame();
+ expect(frame).toContain("ubuntu 24.04 desktop");
+ expect(frame).toContain("Size");
+ expect(frame).toContain("Health");
+ expect(frame).toContain("Files");
+ expect(frame).toContain("Added");
+ expect(frame).toContain("Hash");
+ expect(frame).toContain("Magnet");
+ expect(frame).toContain("d Download");
+ expect(hintRowIntact(u)).toBe(true);
+
+ widthFits(u);
+ });
+
+ it("renders a BMP emoji (star) plot without corruption at listRows=20", async () => {
+ const withFiles = [{ ...t("a1", "ubuntu 24.04 desktop amd64 iso"), numFiles: 3 }];
+ const u = await mount(withFiles, { listRows: 20 }, { loading: false, meta: starMeta });
+ await openDetail(u);
+
+ const frame = u.frame();
+ expect(frame).toContain("ubuntu 24.04 desktop");
+ expect(frame).toContain("Size");
+ expect(frame).toContain("Health");
+ expect(frame).toContain("Files");
+ expect(frame).toContain("Added");
+ expect(frame).toContain("Hash");
+ expect(frame).toContain("Magnet");
+ expect(frame).toContain("d Download");
+ expect(hintRowIntact(u)).toBe(true);
+
+ widthFits(u);
+ });
+});
+
+// The pane lives or dies by frame integrity, not by line length: Ink clips a too-wide row to the
+// panel it is in, so a pane that overflows never shows up as an over-wide line — it shows up as
+// rows dropped or fused somewhere in the block. Every assertion below is therefore about what is
+// present and where, with width checked on top rather than instead.
+describe("Results info pane", () => {
+ // 120 columns is the first tier that carries a poster in Task 6 and the width the pane was
+ // designed against; the layout numbers come from previewLayout so they cannot drift from it.
+ const WIDE_COLS = 120;
+ const WIDE_CONTENT = contentWidthFor(WIDE_COLS);
+ const PL = previewLayout(WIDE_CONTENT);
+
+ // The results panel's own top border row — the search bar draws one of these too, above it.
+ const topBorder = (u: RenderedUI): string => lines(u)[lineIndex(u, "╭─ Results")] ?? "";
+ const paneOpen = (u: RenderedUI): boolean => u.frame().includes("╭─ Info");
+
+ it("splits the top border between an intact results panel and the pane", async () => {
+ const u = await mount(LIST, {}, { loading: false, meta: MOVIE_META }, WIDE_COLS);
+
+ expect(PL).not.toBeNull();
+ if (PL === null) return;
+ const top = topBorder(u);
+ // The list panel's own border is byte-for-byte what it always was, just narrower...
+ expect(top.slice(0, PL.list)).toMatch(/^╭─ Results \(\d+\) ─+╮$/);
+ // ...and the pane's sits beside it, one gap column over, with nothing fused in between.
+ expect(top.slice(PL.list + 1)).toMatch(/^╭─ Info ─+╮$/);
+ expect(displayWidth(top)).toBe(WIDE_CONTENT);
+ });
+
+ it("renders the row's metadata as a card, not just a title", async () => {
+ const u = await mount(LIST, {}, { loading: false, meta: MOVIE_META }, WIDE_COLS);
+
+ const frame = u.frame();
+ expect(frame).toContain("The Shawshank Redemption");
+ expect(frame).toContain(`1994 ${ICON.dot} 9.3 ${ICON.dot} 142 min`);
+ expect(frame).toContain("Drama");
+ expect(frame).toContain("Dir Frank Darabont");
+ expect(frame).toContain("Cast Tim Robbins");
+ // The list is still the point of the view: nothing it used to show has moved out of frame.
+ expect(frame).toContain("ubuntu 24.04 desktop");
+ expect(frame).toContain("Size");
+ for (const l of lines(u)) expect(displayWidth(l)).toBeLessThanOrEqual(WIDE_CONTENT);
+ });
+
+ it("names a matched episode with its series title", async () => {
+ const episodeMeta: Meta = {
+ ...SERIES_META,
+ episode: { season: 3, number: 7, title: "The Bear and the Maiden Fair" },
+ };
+ const u = await mount(LIST, {}, { loading: false, meta: episodeMeta }, WIDE_COLS);
+
+ const frame = u.frame();
+ expect(frame).toContain("Game of Thrones");
+ expect(frame).toContain(`S03E07 ${ICON.dot} The Bear`);
+ });
+
+ it("says No metadata — never an error — when there is nothing to show", async () => {
+ const u = await mount(LIST, {}, IDLE_META, WIDE_COLS);
+
+ expect(u.frame()).toContain("No metadata");
+ // A Games row, an unmatched release and a dead network all land here, and none of them is a
+ // failure of the search the user actually ran.
+ for (const shout of ["rror", "ailed", "nable", "Couldn't"]) {
+ expect(u.frame()).not.toContain(shout);
+ }
+ // Dim, and only dim: chalk emits SGR 2 and no colour of its own around it.
+ expect(u.rawFrame()).toContain(`${KEY.esc}[2mNo metadata`);
+ });
+
+ it("shows a spinner rather than a verdict while the lookup is still out", async () => {
+ const u = await mount(LIST, {}, { loading: true, meta: null }, WIDE_COLS);
+
+ expect(paneOpen(u)).toBe(true);
+ expect(u.frame()).not.toContain("No metadata");
+ });
+
+ it("i closes the pane, gives the list its columns back, and reopens it", async () => {
+ const u = await mount(LIST, {}, { loading: false, meta: MOVIE_META }, WIDE_COLS);
+ expect(paneOpen(u)).toBe(true);
+
+ u.press("i");
+ await vi.waitFor(() => expect(paneOpen(u)).toBe(false));
+ // Closed means the list is whole again, not merely that the card is hidden.
+ expect(topBorder(u)).toMatch(/^╭─ Results \(\d+\) ─+╮$/);
+ expect(displayWidth(topBorder(u))).toBe(WIDE_CONTENT);
+ expect(u.frame()).not.toContain("The Shawshank Redemption");
+
+ u.press("i");
+ await vi.waitFor(() => expect(paneOpen(u)).toBe(true));
+ expect(u.frame()).toContain("The Shawshank Redemption");
+ });
+
+ it("toggles on an empty list, where the user is most likely to want the columns back", async () => {
+ // The binding sits above the `results.length === 0` early return for exactly this; moved
+ // below it, the key would go dead on the one view whose emptiness invites the question.
+ const u = await mount([], {}, IDLE_META, WIDE_COLS);
+ expect(paneOpen(u)).toBe(true);
+
+ u.press("i");
+ await vi.waitFor(() => expect(paneOpen(u)).toBe(false));
+ });
+
+ it("survives a new query", async () => {
+ // The pane is a preference, not view state. It shares an effect's neighbourhood with
+ // textFilter, which the query change deliberately clears — this pins that the toggle is not
+ // swept up with it.
+ searchState.current = settled(LIST);
+ metaState.current = { loading: false, meta: MOVIE_META };
+ ui = renderUI(, { cols: WIDE_COLS });
+ const u = ui;
+ await vi.waitFor(() => expect(u.frame()).toContain("Results (8)"));
+
+ u.press("i");
+ await vi.waitFor(() => expect(paneOpen(u)).toBe(false));
+
+ setQuery?.("arch iso");
+ await vi.waitFor(() => expect(u.frame()).toContain("arch iso"));
+ expect(paneOpen(u)).toBe(false);
+ });
+
+ it("keeps j and k moving the cursor with the pane open", async () => {
+ const u = await mount(LIST, {}, { loading: false, meta: MOVIE_META }, WIDE_COLS);
+
+ u.press("j");
+ await vi.waitFor(() => {
+ expect(lines(u).find((l) => l.includes("ubuntu server"))).toContain(ICON.pointer);
+ });
+ expect(paneOpen(u)).toBe(true);
+
+ u.press("k");
+ await vi.waitFor(() => {
+ expect(lines(u).find((l) => l.includes("ubuntu 24.04 desktop"))).toContain(ICON.pointer);
+ });
+ // The split survived the movement: no row of the list fused into the pane's border.
+ expect(topBorder(u).slice(0, PL?.list ?? 0)).toMatch(/^╭─ Results \(\d+\) ─+╮$/);
+ });
+
+ it("holds together on the narrowest tier that renders it", async () => {
+ // 92 columns is the first width the pane exists at: 20 wide, 16 usable inside Panel's frame.
+ // Everything here is wrapping against roughly half the width the card was designed at, which
+ // is where a line-count miscount turns into a fused row rather than an unused one.
+ const NARROW_COLS = 92;
+ const narrowContent = contentWidthFor(NARROW_COLS);
+ const nl = previewLayout(narrowContent);
+ const u = await mount(LIST, {}, { loading: false, meta: MOVIE_META }, NARROW_COLS);
+
+ expect(nl).not.toBeNull();
+ if (nl === null) return;
+ expect(nl.pane).toBe(20);
+ const top = topBorder(u);
+ expect(top.slice(0, nl.list)).toMatch(/^╭─ Results \(\d+\) ─+╮$/);
+ expect(top.slice(nl.list + 1)).toMatch(/^╭─ Info ─+╮$/);
+ // The card sheds its lower rows rather than overflowing: the title survives whole, wrapped.
+ expect(u.frame()).toContain("The Shawshank");
+ expect(u.frame()).toContain("Redemption");
+ // Every row of the list is still a row of the list — nothing fused across the gap. The name
+ // column truncates at this width, so the row is identified by the prefix that survives it.
+ expect(lines(u).filter((l) => l.includes("ubuntu 24.04 des"))).toHaveLength(1);
+ expect(lines(u).filter((l) => l.includes("mint cinnamon"))).toHaveLength(0);
+ for (const l of lines(u)) expect(displayWidth(l)).toBeLessThanOrEqual(narrowContent);
+ });
+
+ it("is absent at 80 columns, where the list needs every column it has", async () => {
+ const u = await mount(LIST, {}, { loading: false, meta: MOVIE_META });
+
+ expect(paneOpen(u)).toBe(false);
+ expect(u.frame()).not.toContain("The Shawshank Redemption");
+ expect(u.frame()).not.toContain("No metadata");
+ widthFits(u);
+ });
+
+ it("still opens the detail view over a narrowed list", async () => {
+ const u = await mount(LIST, { listRows: 40 }, { loading: false, meta: MOVIE_META }, WIDE_COLS);
+ await openDetail(u);
+
+ const frame = u.frame();
+ expect(frame).toContain("Magnet");
+ expect(frame).toContain("Rating");
+ // Both mounted at once on the same row — the case Task 3's refcounted dedupe exists for.
+ expect(paneOpen(u)).toBe(true);
+ // The detail view's hint row no longer runs to the frame's edge, so intactness is asked of
+ // the columns the list panel actually owns: still one clean row ending at its own border.
+ const hintRow = lines(u).find((l) => l.includes("esc back")) ?? "";
+ expect(hintRow.slice(0, PL?.list ?? 0)).toMatch(/esc back\s*│$/);
+ for (const l of lines(u)) expect(displayWidth(l)).toBeLessThanOrEqual(WIDE_CONTENT);
+ });
+});
+
+// The pane with the keyboard in it. Region "preview" is the whole input to this view — App owns
+// the key that produces it (move.test.ts pins that walk) and Results owns what it looks like.
+describe("Results info pane focused", () => {
+ const WIDE_COLS = 120;
+ const WIDE_CONTENT = contentWidthFor(WIDE_COLS);
+ const IDLE = previewLayout(WIDE_CONTENT);
+ // The pane's own content height at the harness default listRows, which the focused width now
+ // depends on: how wide a poster comes out decides how wide a pane has to be to seat a card
+ // next to it.
+ const READING_ROWS = resultsPanelOuter(14, 3) - 1;
+ const READING = previewLayout(WIDE_CONTENT, true, READING_ROWS);
+
+ const topBorder = (u: RenderedUI): string => lines(u)[lineIndex(u, "╭─ Results")] ?? "";
+ const paneOpen = (u: RenderedUI): boolean => u.frame().includes("╭─ Info");
+ // Panel's two frame colours as chalk emits them: COLOR.accent for the focused panel, RULE for
+ // every other one.
+ const ACCENT = "[38;2;167;139;250m";
+ const DIM = "[38;2;107;101;119m";
+ // Both panels draw their top border on the same line, so "is this label accented" is a question
+ // about which colour was opened last before it — not about the frame containing the code at all.
+ const accented = (raw: string, label: string): boolean => {
+ const head = raw.slice(0, raw.indexOf(label));
+ return head.lastIndexOf(ACCENT) > head.lastIndexOf(DIM);
+ };
+
+ it("hands the pane every column the list can spare", async () => {
+ const u = await mount(LIST, { region: "preview" }, { loading: false, meta: MOVIE_META }, WIDE_COLS);
+
+ expect(READING).not.toBeNull();
+ if (READING === null || IDLE === null) return;
+ expect(READING.pane).toBeGreaterThan(IDLE.pane);
+ const top = topBorder(u);
+ // The same two-panel border as unfocused, at the focused split's widths: the list narrower,
+ // the pane wider, and the gap column still between them.
+ expect(top.slice(0, READING.list)).toMatch(/^╭─ Results \(\d+\) ─+╮$/);
+ expect(top.slice(READING.list + 1)).toMatch(/^╭─ Info ─+╮$/);
+ expect(displayWidth(top)).toBe(WIDE_CONTENT);
+ // The list is still a list: its rows are all still rows, none fused into the pane. At
+ // MIN_LIST_WIDTH the name column truncates, so each row is identified by what survives it.
+ expect(lines(u).filter((l) => l.includes("ubuntu 24"))).toHaveLength(1);
+ expect(lines(u).filter((l) => l.includes("ubuntu se"))).toHaveLength(1);
+ for (const l of lines(u)) expect(displayWidth(l)).toBeLessThanOrEqual(WIDE_CONTENT);
+ });
+
+ it("moves the accent from the list's frame to the pane's", async () => {
+ const browsing = await mount(LIST, {}, { loading: false, meta: MOVIE_META }, WIDE_COLS);
+ expect(accented(browsing.rawFrame(), "Results")).toBe(true);
+ expect(accented(browsing.rawFrame(), "Info")).toBe(false);
+ browsing.unmount();
+
+ const reading = await mount(
+ LIST,
+ { region: "preview" },
+ { loading: false, meta: MOVIE_META },
+ WIDE_COLS,
+ );
+ // One highlight idiom, moved — not a second one added.
+ expect(accented(reading.rawFrame(), "Results")).toBe(false);
+ expect(accented(reading.rawFrame(), "Info")).toBe(true);
+ });
+
+ it("keeps the list pointing at the row the pane is describing, and stops taking its keys", async () => {
+ const u = await mount(LIST, { region: "preview" }, { loading: false, meta: MOVIE_META }, WIDE_COLS);
+
+ // The name column truncates at MIN_LIST_WIDTH, so rows are identified by their prefixes.
+ const marked = (name: string): boolean =>
+ (lines(u).find((l) => l.includes(name)) ?? "").includes(ICON.pointer);
+ // Losing the marker would leave the card describing a row nothing on screen identifies.
+ expect(marked("ubuntu 24")).toBe(true);
+
+ u.press("j");
+ await new Promise((r) => setTimeout(r, 20));
+ // j belongs to the pane now: the cursor has not moved to the second row.
+ expect(marked("ubuntu 24")).toBe(true);
+ expect(marked("ubuntu se")).toBe(false);
+ });
+
+ it("tells App the pane is there, so → has somewhere to go", async () => {
+ const setPreviewOpen = vi.fn();
+ await mount(LIST, { setPreviewOpen }, { loading: false, meta: MOVIE_META }, WIDE_COLS);
+ expect(setPreviewOpen).toHaveBeenLastCalledWith(true);
+ });
+
+ it("tells App there is nothing to step into at 80 columns", async () => {
+ const setPreviewOpen = vi.fn();
+ const u = await mount(LIST, { setPreviewOpen }, { loading: false, meta: MOVIE_META });
+ expect(setPreviewOpen).toHaveBeenLastCalledWith(false);
+ expect(paneOpen(u)).toBe(false);
+ });
+
+ it("renders the list alone, full width, if focus somehow points at a pane that is not there", async () => {
+ // Unreachable through the keys — stepRegion refuses it and App's rescue effect undoes it —
+ // but a region and a width that disagree must degrade to the frame that has always been
+ // correct at 80 columns rather than to a pane with nowhere to draw.
+ const u = await mount(LIST, { region: "preview" }, { loading: false, meta: MOVIE_META });
+ expect(paneOpen(u)).toBe(false);
+ expect(topBorder(u)).toMatch(/^╭─ Results \(\d+\) ─+╮$/);
+ expect(displayWidth(topBorder(u))).toBe(TEST_CONTENT_WIDTH);
+ widthFits(u);
+ });
+});
+
+describe("Results info pane on Games", () => {
+ const WIDE_COLS = 120;
+ const WIDE_CONTENT = contentWidthFor(WIDE_COLS);
+ // A row from a Games-only source, so the tab has results to show while having no metadata
+ // provider behind them.
+ const GAMES = [{ ...t("g1", "some.repack-FitGirl"), source: "fitgirl" as const }];
+
+ it("hides the pane and gives the list back its columns", async () => {
+ const setPreviewOpen = vi.fn();
+ const u = await mount(
+ GAMES,
+ { section: "games", setPreviewOpen },
+ { loading: false, meta: MOVIE_META },
+ WIDE_COLS,
+ );
+
+ // Nothing looks up a game, so the pane would be a column of "No metadata" — and with it gone,
+ // → has nothing to step into either.
+ expect(u.frame()).not.toContain("╭─ Info");
+ expect(u.frame()).not.toContain("No metadata");
+ expect(setPreviewOpen).toHaveBeenLastCalledWith(false);
+ const top = lines(u)[lineIndex(u, "╭─ Results")] ?? "";
+ expect(top).toMatch(/^╭─ Results \(1\) ─+╮$/);
+ expect(displayWidth(top)).toBe(WIDE_CONTENT);
+ });
+
+ it("keeps the pane on every tab that does have metadata", async () => {
+ // `all` carries video and stays in, which is the other half of the rule: its Games rows
+ // already answer "No metadata" one row at a time, unlike a tab that can never answer more.
+ // Each tab is given a row from one of its own sources, since the tab filters the list.
+ const rows = {
+ all: LIST,
+ movies: LIST,
+ tv: [{ ...t("t1", "some.series.s01e01"), source: "eztv" as const }],
+ anime: [{ ...t("n1", "some anime 01"), source: "nyaa" as const }],
+ };
+ for (const section of ["all", "movies", "tv", "anime"] as const) {
+ const u = await mount(rows[section], { section }, { loading: false, meta: MOVIE_META }, WIDE_COLS);
+ expect(u.frame(), section).toContain("╭─ Info");
+ u.unmount();
+ }
+ });
+});
+
+// Ink answers an overflowing box by squeezing rows through Yoga's shrink math, which drops and
+// fuses lines anywhere in the block rather than cutting the one that overflowed — so the proof
+// that a two-panel split holds is that every row is still a row, at its own panel's exact width,
+// with the gap column between them still blank. This sweeps the sizes and content shapes that
+// each broke a different assumption while this feature was built.
+describe("Results frame integrity", () => {
+ // The sweep below mounts both panels at every size, focus and metadata shape in the matrix,
+ // which costs seconds even on an idle machine and rather more on a busy one. Vitest's
+ // five-second default is a number nobody here chose; this one is chosen, with room for a
+ // contributor's laptop running a build in the next window. A sweep that overruns it is a
+ // machine under load, not a slow test hiding a problem — so raise this rather than thinning
+ // the matrix, which is what caught the frames that only broke at one width.
+ const SWEEP_MS = 20_000;
+
+ const CJK_META: Meta = {
+ imdbId: "tt0245429",
+ kind: "movie",
+ title: "千と千尋の神隠し | 센과 치히로의 행방불명",
+ year: "2001",
+ rating: "8.6",
+ runtime: "125 min",
+ genres: ["アニメ", "冒険", "ファンタジー"],
+ cast: ["柊瑠美", "入野自由", "夏木マリ", "内藤剛志"],
+ director: ["宮崎駿"],
+ posterUrl: "https://example.invalid/poster.jpg",
+ };
+ const WITH_POSTER: Meta = { ...MOVIE_META, posterUrl: "https://example.invalid/poster.jpg" };
+ // A synopsis at the length Cinemeta actually sends for a feature. MOVIE_META's one-liner fits
+ // any pane that has a column of its own, and a card that fits is a card no window ever cuts —
+ // which is the state the side-by-side scroll case has to avoid to be testing anything.
+ const LONG_PLOT: Meta = {
+ ...WITH_POSTER,
+ plot:
+ "Over the course of several years, two convicts form a friendship, seeking consolation " +
+ "and, eventually, redemption through basic compassion. Chronicles the experiences of a " +
+ "man sentenced to life in Shawshank State Penitentiary for a crime he did not commit.",
+ };
+
+ // App's own row arithmetic, so a case asking for a 17-row terminal gets the listRows the real
+ // app would hand Results there.
+ const listRowsFor = (rows: number): number => {
+ const compact = rows < 18;
+ const chrome = 3 + (compact ? 0 : 2) + (rows >= 12 ? 1 : 0);
+ return Math.max(4, Math.max(6, rows - 1 - chrome));
+ };
+
+ const CASES: { name: string; meta: MetaState; art: boolean; guaranteed: boolean }[] = [
+ { name: "poster", meta: { loading: false, meta: WITH_POSTER }, art: true, guaranteed: false },
+ { name: "cjk", meta: { loading: false, meta: CJK_META }, art: true, guaranteed: false },
+ { name: "no metadata", meta: IDLE_META, art: false, guaranteed: false },
+ // The only case with enough card to ask the guarantee of at every width in the sweep: a
+ // one-line synopsis wraps to fewer than eight rows in a wide pane, and a card that is short
+ // cannot be evidence of one being cut.
+ { name: "long plot", meta: { loading: false, meta: LONG_PLOT }, art: true, guaranteed: true },
+ { name: "long plot, no art", meta: { loading: false, meta: LONG_PLOT }, art: false, guaranteed: true },
+ ];
+
+ // Rows of the pane carrying card text: not its borders, not the poster, not the blank spacer,
+ // and not the scroll affordance, which is chrome rather than card.
+ const cardRows = (block: readonly string[], list: number): number =>
+ block.slice(1, -1).filter((l) => {
+ const right = l.slice(list + 1);
+ return (
+ !right.includes(ICON.up) &&
+ !right.includes(ICON.down) &&
+ right.replace(/[│▀\s]/g, "") !== ""
+ );
+ }).length;
+
+ // Both panels' rows, from the shared top border down to the shared bottom one.
+ const panelBlock = (u: RenderedUI, listRows: number): string[] => {
+ const all = lines(u);
+ const top = all.findIndex((l) => l.includes("╭─ Results"));
+ expect(top).toBeGreaterThanOrEqual(0);
+ return all.slice(top, top + resultsPanelOuter(listRows, 3) + 1);
+ };
+
+ it("holds both panels' frames across widths, heights, focus and metadata shapes", async () => {
+ for (const cols of [80, 92, 100, 110, 120, 160]) {
+ for (const rows of [17, 26, 30]) {
+ for (const focused of [false, true]) {
+ for (const c of CASES) {
+ posterOn.current = c.art;
+ const contentWidth = contentWidthFor(cols);
+ const listRows = listRowsFor(rows);
+ const pl = previewLayout(contentWidth, focused, resultsPanelOuter(listRows, 3) - 1);
+ const u = await mount(
+ LIST,
+ { rows, listRows, contentWidth, region: focused && pl !== null ? "preview" : "content" },
+ c.meta,
+ cols,
+ );
+ const where = `${cols}x${rows} focused=${focused} ${c.name}`;
+ const block = panelBlock(u, listRows);
+ for (const l of lines(u)) expect(displayWidth(l), `${where} "${l}"`).toBeLessThanOrEqual(contentWidth);
+
+ if (pl === null) {
+ // The 80-column floor: one panel, full width, exactly as it renders without any of
+ // this — including when the region says the pane has focus.
+ expect(u.frame(), where).not.toContain("╭─ Info");
+ expect(block[0], where).toMatch(/^╭─ Results \(8\) ─+╮$/);
+ expect(block.at(-1), where).toMatch(/^╰─+╯$/);
+ for (const l of block.slice(1, -1)) {
+ expect(displayWidth(l), `${where} row`).toBe(contentWidth);
+ }
+ } else {
+ for (const [i, l] of block.entries()) {
+ const left = l.slice(0, pl.list);
+ const right = l.slice(pl.list + 1);
+ expect(displayWidth(left), `${where} left#${i} "${l}"`).toBe(pl.list);
+ expect(l.slice(pl.list, pl.list + 1), `${where} gap#${i}`).toBe(" ");
+ expect(displayWidth(right), `${where} right#${i} "${right}"`).toBe(pl.pane);
+ if (i === 0) {
+ expect(left, where).toMatch(/^╭─ Results \(8\) ─+╮$/);
+ expect(right, where).toMatch(/^╭─ Info ─+╮$/);
+ } else if (i === block.length - 1) {
+ expect(left, where).toMatch(/^╰─+╯$/);
+ expect(right, where).toMatch(/^╰─+╯$/);
+ } else {
+ expect(left.startsWith("│") && left.endsWith("│"), `${where} left "${left}"`).toBe(true);
+ expect(right.startsWith("│") && right.endsWith("│"), `${where} right "${right}"`).toBe(true);
+ }
+ }
+ expect(u.frame(), where).toContain("ubuntu 24");
+ // The contract focusing exists to keep: the user stepped in to read the
+ // description, so the card is on screen without scrolling — MIN_FOCUSED_TEXT_ROWS
+ // of it, or the whole window where the pane is shorter than that.
+ if (focused && c.guaranteed) {
+ const paneInnerRows = resultsPanelOuter(listRows, 3) - 1;
+ const scrolls = u.frame().includes(`${ICON.down} more`);
+ const window = paneInnerRows - (scrolls ? 1 : 0);
+ expect(cardRows(block, pl.list), `${where} card rows`).toBeGreaterThanOrEqual(
+ Math.min(MIN_FOCUSED_TEXT_ROWS, window),
+ );
+ }
+ }
+ u.unmount();
+ }
+ }
+ }
+ }
+ posterOn.current = false;
+ }, SWEEP_MS);
+
+ // Mid-scroll is where the two panels are easiest to break, because the pane's contents change
+ // shape under a fixed frame while the list beside it does not move at all. Both of the focused
+ // pane's layouts get a pass, since they compose their rows differently and only share the
+ // window that slices them.
+ const midScroll = async (
+ cols: number,
+ listRows: number,
+ meta: Meta,
+ ): Promise<{ u: RenderedUI; pl: NonNullable> }> => {
+ posterOn.current = true;
+ const contentWidth = contentWidthFor(cols);
+ const pl = previewLayout(contentWidth, true, resultsPanelOuter(listRows, 3) - 1);
+ const u = await mount(LIST, { listRows, contentWidth, region: "preview" }, { loading: false, meta }, cols);
+ expect(pl).not.toBeNull();
+ if (pl === null) throw new Error(`no pane at ${cols} columns`);
+ expect(u.frame()).toContain(`${ICON.down} more`);
+ u.press(KEY.down);
+ // Proof the pane actually moved under the window, not just that it survived a keypress: the
+ // affordance now points both ways, which is the state where the art is cut at top and bottom.
+ await vi.waitFor(() => expect(u.frame()).toContain(`${ICON.up}${ICON.down} more`));
+ return { u, pl };
+ };
+
+ const rowsIntact = (u: RenderedUI, listRows: number, pl: { list: number; pane: number }): void => {
+ for (const [i, l] of panelBlock(u, listRows).entries()) {
+ expect(displayWidth(l.slice(0, pl.list)), `left#${i} "${l}"`).toBe(pl.list);
+ expect(l.slice(pl.list, pl.list + 1), `gap#${i}`).toBe(" ");
+ expect(displayWidth(l.slice(pl.list + 1)), `right#${i}`).toBe(pl.pane);
+ }
+ expect(u.frame()).toContain("ubuntu 24");
+ };
+
+ // Whether any row of the pane carries art and text at once, which is the one thing the stacked
+ // layout can never produce and the only thing the side-by-side one is for.
+ const anyRowSplit = (u: RenderedUI, listRows: number, list: number): boolean =>
+ panelBlock(u, listRows).some((l) => {
+ const right = l.slice(list + 1);
+ return right.includes("▀") && /[A-Za-z]/.test(right);
+ });
+
+ it("holds the same frame mid-scroll where the pane stacked, and the window cuts the art", async () => {
+ // 110 terminal columns is the last tier that still carries a poster while leaving the focused
+ // pane only 38 — 34 inside the frame, under the width a picture needs beside a card at
+ // MIN_TEXT_COLS. So this is the stacked fallback: the art gives up rows instead of columns,
+ // the card sits under it, and the window cuts through the picture itself. 23 rows are what
+ // let the pane keep any art at all under that reserve.
+ const listRows = 23;
+ const { u, pl } = await midScroll(110, listRows, LONG_PLOT);
+ expect(anyRowSplit(u, listRows, pl.list)).toBe(false);
+ rowsIntact(u, listRows, pl);
+ posterOn.current = false;
+ });
+
+ it("holds the same frame mid-scroll where the pane split, with both columns cut at once", async () => {
+ // 120 terminal columns leaves the pane 48 — 44 inside the frame, enough to seat a 15-column
+ // poster beside a card at MIN_TEXT_COLS. One window then slices both columns, so the art loses
+ // its top row on the same keypress the title does.
+ const listRows = 19;
+ const { u, pl } = await midScroll(120, listRows, LONG_PLOT);
+ expect(anyRowSplit(u, listRows, pl.list)).toBe(true);
+ rowsIntact(u, listRows, pl);
+ posterOn.current = false;
+ });
+});
diff --git a/src/ui/components/Results.tsx b/src/ui/components/Results.tsx
index c28623e6..6c885c3d 100644
--- a/src/ui/components/Results.tsx
+++ b/src/ui/components/Results.tsx
@@ -6,11 +6,16 @@ import { SearchBar } from "./SearchBar";
import { TextField } from "./TextField";
import { Panel } from "./Panel";
import { Rule } from "./Rule";
+import { MetaPane } from "./MetaPane";
import { useConcurrentSearch } from "../hooks/useConcurrentSearch";
+import { useResultMeta } from "../hooks/useResultMeta";
import { getSource, SOURCES } from "../../sources/registry";
import { stickCursor, wrapStep, windowStart, resultsPanelOuter } from "../move";
import { sortResults, nextSort, sortLabel, sortArrow, type Sort, type SortField } from "../sort";
import { filterResults } from "../filter";
+import { planMetaRows } from "../metaPlan";
+import { PANE_GAP, previewLayout } from "../previewLayout";
+import { LABEL_W } from "../textWidth";
import { COLOR, GUTTER, ICON, sourceStyle } from "../theme";
import { cleanText, formatBytes, formatCount, formatRelative, stripControl, truncate } from "../../util/format";
import type { Source, TorrentResult } from "../../sources/types";
@@ -22,7 +27,7 @@ const PLACEHOLDER = "Search or paste a magnet link…";
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
return (
-
+ {label}{value}
@@ -30,9 +35,27 @@ function DetailRow({ label, value }: { label: string; value: ReactNode }) {
);
}
-function Detail({ r, width }: { r: TorrentResult; width: number }) {
+function Detail({ r, width, panelHeight }: { r: TorrentResult; width: number; panelHeight: number }) {
const ss = sourceStyle(r.source);
const date = formatRelative(r.added);
+ // No debounce: Enter is an explicit commit (unlike the list cursor sweeping past rows), and
+ // the cache behind this hook usually resolves the same title's earlier lookup instantly.
+ const { meta } = useResultMeta(r, true, 0);
+
+ // Same column math DetailRow itself uses (LABEL_W plus whatever remains), needed here because
+ // planMetaRows must know a value's wrapped line count before render.
+ const valueWidth = Math.max(1, width - LABEL_W);
+ // The four unconditional facts rows plus whichever of Files/Added this result actually has.
+ const factsRows = 4 + (r.numFiles ? 1 : 0) + (date ? 1 : 0);
+ // Fixed chrome that is never negotiable: title, rule, the blank line above the facts block,
+ // the blank line above the hint row, and the hint row itself.
+ const CHROME_ROWS = 5;
+ // Panel renders its own bottom border inside `panelHeight` (its top border is the separate
+ // title-bar row above it), so one row of that budget is never available to Detail's content.
+ const innerRows = Math.max(0, panelHeight - 1);
+ const metaBudget = Math.max(0, innerRows - CHROME_ROWS - factsRows);
+ const plan = planMetaRows(meta, valueWidth, metaBudget);
+
const health =
r.seeders || r.leechers ? (
@@ -91,6 +114,24 @@ function Detail({ r, width }: { r: TorrentResult; width: number }) {
}
/>
+ {plan.rating !== null ? (
+ {plan.rating}} />
+ ) : null}
+ {plan.genres !== null ? (
+ {plan.genres}} />
+ ) : null}
+ {/* Cinemeta sends no director for most series — an empty row here would be a label with
+ nothing after it, so absence of data means absence of the row (planMetaRows never
+ admits an empty list in the first place). */}
+ {plan.director !== null ? (
+ {plan.director}} />
+ ) : null}
+ {plan.cast !== null ? (
+ {plan.cast}} />
+ ) : null}
+ {plan.plot !== null ? (
+ {plan.plot}} />
+ ) : null}
@@ -128,6 +169,7 @@ export function Results() {
copyMagnet,
fetchAndExportTorrent,
setResultFocus,
+ setPreviewOpen,
contentWidth,
listRows,
} = useStore();
@@ -136,6 +178,9 @@ export function Results() {
const [sort, setSort] = useState("none");
const [hideDead, setHideDead] = useState(false);
+ // On by default: the pane answers the question the list cannot ("what *is* this release"), and
+ // it costs nothing on a terminal too narrow to hold it, where previewLayout hides it anyway.
+ const [showInfo, setShowInfo] = useState(true);
const [textFilter, setTextFilter] = useState("");
const results = useMemo(() => {
const cat = CATEGORIES.find((c) => c.key === section);
@@ -146,6 +191,9 @@ export function Results() {
}, [search.results, section, sort, hideDead, textFilter]);
const focused = region === "content";
+ // The pane holding the keyboard is still the list's own selection being read, so the list keeps
+ // its pointer on the row the pane describes — it just stops answering keys.
+ const paneFocused = region === "preview";
const [mode, setMode] = useState("list");
const [cursor, setCursor] = useState(0);
// The row the user navigated to, by infohash; null until they move. Keeps
@@ -187,6 +235,35 @@ export function Results() {
const listHeight = Math.max(3, panelOuter - 4);
const pageJump = Math.max(1, listHeight - 1);
+ // One value settles both the pane's existence and the list's width, so the two can never
+ // disagree and render a pane overlapping the list's own right border. Null — toggled off, a
+ // terminal too narrow to split, or a section with no metadata behind it — means the list keeps
+ // the full content width it always had.
+ //
+ // Games is out because nothing looks it up: every row would read "No metadata", which is a
+ // column of nothing where the list could have had 34 more of them. `all` stays in — it carries
+ // real video, and the Games rows inside it already answer "No metadata" one row at a time,
+ // which is a different thing from a tab that can never answer anything else.
+ // panelOuter less the bottom border Panel draws inside it: the pane's own content height, which
+ // a focused split needs because how wide the poster comes out is a question about height.
+ const pane =
+ showInfo && section !== "games"
+ ? previewLayout(contentWidth, paneFocused, panelOuter - 1)
+ : null;
+ const listWidth = pane ? pane.list : contentWidth;
+ // What App needs to know to decide whether → has a third column to step into. Reported rather
+ // than re-derived there: the `i` toggle lives here, and a second copy of this rule would be one
+ // resize away from disagreeing with the pane it is describing.
+ const paneOpen = pane !== null;
+ useEffect(() => {
+ setPreviewOpen(paneOpen);
+ return () => setPreviewOpen(false);
+ }, [paneOpen, setPreviewOpen]);
+ // The row the pane describes. `clamped`, not `detail`: the pane follows the cursor even while
+ // the detail view is open over the list, so closing that view leaves the pane already on the
+ // right row instead of starting a fresh lookup.
+ const selected = results[clamped] ?? null;
+
const openDownload = (r: TorrentResult): void =>
startDownload({
id: r.infoHash,
@@ -230,6 +307,10 @@ export function Results() {
setHideDead((on) => !on);
} else if (input === "f") {
setMode("filter");
+ } else if (input === "i") {
+ // Above the empty-list return: an empty list is exactly when a user wonders whether the
+ // pane is what is eating their columns, so the toggle has to answer there too.
+ setShowInfo((on) => !on);
} else if (results.length === 0) {
return;
} else if (key.downArrow || input === "j") {
@@ -401,13 +482,13 @@ export function Results() {
{mode === "detail" && detail ? (
-
+
) : (
<>
{status()}
@@ -442,7 +523,7 @@ export function Results() {
) : null}
{visible.map((r, i) => {
const index = start + i;
- const here = index === clamped && focused && mode === "list";
+ const here = index === clamped && (focused || paneFocused) && mode === "list";
const ss = sourceStyle(r.source);
return (
@@ -506,9 +587,20 @@ export function Results() {
>
)}
+ {pane ? (
+
+
+
+ ) : null}
{(mode === "filter" || textFilter.trim()) && (
-
+ {`Filter ${ICON.pointer} `}
@@ -516,7 +608,7 @@ export function Results() {
{mode === "filter" ? (
{
expect(new Set(tr).size).toBe(tr.length);
});
- it("backfills numFiles and added only where the winner has none", () => {
+ it("backfills numFiles, added and imdbId only where the winner has none", () => {
const out = dedupeResults([
row({ source: "yts", seeders: 90, added: 1_700_000_000 }),
- row({ source: "eztv", seeders: 3, numFiles: 7, added: 1_600_000_000 }),
+ row({ source: "eztv", seeders: 3, numFiles: 7, added: 1_600_000_000, imdbId: "tt0133093" }),
]);
expect(out[0]!.numFiles).toBe(7);
expect(out[0]!.added).toBe(1_700_000_000);
+ // The healthiest row is routinely one from a source that carries no id at all, so the
+ // winner losing the loser's imdbId is the common case, not the corner.
+ expect(out[0]!.imdbId).toBe("tt0133093");
});
it("keeps the first row when seeders tie", () => {
diff --git a/src/ui/dedupe.ts b/src/ui/dedupe.ts
index 91904429..27eb317c 100644
--- a/src/ui/dedupe.ts
+++ b/src/ui/dedupe.ts
@@ -11,8 +11,10 @@ import type { TorrentResult } from "../sources/types";
// defaults, so keeping the higher-seeder row alone can trade a working
// announce list for a generic one. Same loss #146 fixed for a .torrent
// file's own trackers.
-// - numFiles and added, but only where the winner has none. A field the
-// winner never reported is a gap, not a decision.
+// - numFiles, added and imdbId, but only where the winner has none. A field
+// the winner never reported is a gap, not a decision — and an imdbId is
+// worth more than a blank column, since losing it sends the metadata
+// lookup back to guessing a title from the release name.
//
// Everything else stays the winner's, including its magnet URI byte for byte.
function merge(a: TorrentResult, b: TorrentResult): TorrentResult {
@@ -22,6 +24,7 @@ function merge(a: TorrentResult, b: TorrentResult): TorrentResult {
magnet: mergeMagnetTrackers(win.magnet, [lost.magnet]),
numFiles: win.numFiles ?? lost.numFiles,
added: win.added ?? lost.added,
+ imdbId: win.imdbId ?? lost.imdbId,
};
}
diff --git a/src/ui/helpLayout.test.ts b/src/ui/helpLayout.test.ts
index 45dc6b70..130d339d 100644
--- a/src/ui/helpLayout.test.ts
+++ b/src/ui/helpLayout.test.ts
@@ -4,7 +4,12 @@ import { MEASURED, pickLayout } from "./helpLayout";
describe("help layout measurement", () => {
it("derives packing widths and grid heights from HELP_GROUPS", () => {
expect(MEASURED.map((m) => m.width)).toEqual([134, 108, 77, 41]);
- expect(MEASURED.map((m) => m.gridH)).toEqual([10, 15, 19, 32]);
+ // Heights track HELP_GROUPS directly: the Search group is the tallest in every packing, so
+ // the pane's three rows — i to toggle it, → to focus it, ← to leave — added two more on top
+ // of the toggle Task 5 brought. The widths did not move with any of them: a single arrow
+ // glyph is as wide as every other Search key, and both new labels are shorter than that
+ // group's longest ("Download (shift+d: folder)").
+ expect(MEASURED.map((m) => m.gridH)).toEqual([13, 18, 22, 35]);
});
it("picks the widest packing that fits inside cols - 2", () => {
diff --git a/src/ui/hooks/usePoster.test.tsx b/src/ui/hooks/usePoster.test.tsx
new file mode 100644
index 00000000..2e135b1b
--- /dev/null
+++ b/src/ui/hooks/usePoster.test.tsx
@@ -0,0 +1,296 @@
+import { Text } from "ink";
+import { useEffect, useState } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { decodePoster } from "../../meta/image";
+import { fetchPosterBytes } from "../../meta/poster";
+import { renderUI } from "../testHarness";
+import { usePoster } from "./usePoster";
+import type { ReactElement } from "react";
+import type { PosterCells } from "../../meta/image";
+
+// Both halves of the pipeline are mocked so these tests are about the hook's own contract — what
+// it asks for, when it asks again, and whose answer it is allowed to render. The fetch and the
+// decode have their own tests, and no test in this repo may touch the network.
+vi.mock("../../meta/poster", () => ({ fetchPosterBytes: vi.fn() }));
+vi.mock("../../meta/image", () => ({ decodePoster: vi.fn() }));
+
+const mockFetch = vi.mocked(fetchPosterBytes);
+const mockDecode = vi.mocked(decodePoster);
+
+const BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
+
+interface Budget {
+ cols: number;
+ rows: number;
+}
+
+const WIDE: Budget = { cols: 24, rows: 18 };
+const NARROW: Budget = { cols: 18, rows: 13 };
+
+function cells(cols: number, rows: number): PosterCells {
+ return { cols, rows, lines: Array.from({ length: rows }, () => [{ fg: "#000", bg: "#000", n: cols }]) };
+}
+
+// The module-level cache outlives every test in this file, exactly as it outlives a row selection
+// in the app. Each test therefore owns a distinct URL, so one test's cached grid can never be the
+// reason another one passes.
+let urlSeq = 0;
+const nextUrl = (): string => `https://images.metahub.space/poster/small/tt${++urlSeq}/img?format=jpeg`;
+
+// The probe exposes its budget setter so a test can resize the pane the way a terminal resize
+// does, without needing a rerender handle the shared harness does not provide.
+let resize: ((b: Budget) => void) | null = null;
+
+function Probe({
+ url,
+ first,
+ enabled = true,
+}: {
+ url?: string;
+ first: Budget;
+ enabled?: boolean;
+}): ReactElement {
+ const [b, setB] = useState(first);
+ useEffect(() => {
+ resize = setB;
+ return () => {
+ resize = null;
+ };
+ }, []);
+ const { loading, cells: got } = usePoster(url, b.cols, b.rows, enabled);
+ return {loading ? "LOADING" : got === null ? "NONE" : `ART ${got.cols}x${got.rows}`};
+}
+
+/** Let Ink flush a render and any pending microtask. */
+function tick(ms = 0): Promise {
+ return new Promise((res) => setTimeout(res, ms));
+}
+
+function deferred(): { promise: Promise; settle: (b: Uint8Array | null) => void } {
+ let settle: (b: Uint8Array | null) => void = () => {};
+ const promise = new Promise((res) => {
+ settle = res;
+ });
+ return { promise, settle };
+}
+
+beforeEach(() => {
+ resize = null;
+ mockFetch.mockReset();
+ mockDecode.mockReset();
+ mockFetch.mockResolvedValue(BYTES);
+ // Answer with a grid that matches whatever budget it was asked for, so a frame reading
+ // "ART 24x18" is proof of which budget produced it.
+ mockDecode.mockImplementation((_bytes, cols, rows) => cells(cols, rows));
+});
+
+describe("usePoster", () => {
+ it("re-fetches when the pane narrows, and never shows the wider grid again", async () => {
+ // The cache key carries the cell budget because the same poster at a different pane size is a
+ // different picture. Keyed on the URL alone, this resize would serve the 24x18 grid into an
+ // 18-column pane — which is the one direction MetaPane's own guard does catch, by refusing to
+ // draw it at all, so the pane would silently lose its art instead of resizing it.
+ const url = nextUrl();
+ const ui = renderUI();
+ try {
+ await vi.waitFor(() => expect(ui.frame()).toContain("ART 24x18"));
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+
+ // Held in flight so the frame between the resize and the new art is observable.
+ const slow = deferred();
+ mockFetch.mockReturnValueOnce(slow.promise);
+
+ resize?.(NARROW);
+ // Wait on the two positive facts that pin the intermediate state, then read the negative
+ // off the same frame: the probe only ever renders one of LOADING/NONE/"TIER WxH", so a
+ // frame that just satisfied "contains LOADING" cannot also contain "ART 24x18".
+ await vi.waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+ expect(ui.frame()).toContain("LOADING");
+ });
+ expect(ui.frame()).not.toContain("ART 24x18");
+
+ slow.settle(BYTES);
+ await vi.waitFor(() => expect(ui.frame()).toContain("ART 18x13"));
+ } finally {
+ ui.unmount();
+ }
+ });
+
+ it("re-fetches when the pane widens, rather than serving an undersized grid", async () => {
+ // The direction nothing downstream can catch. MetaPane refuses a grid *larger* than its
+ // budget; a grid that is too small fits, draws, and simply leaves a column of dead space
+ // beside a poster that no longer matches the pane. Only the key prevents it.
+ const url = nextUrl();
+ const ui = renderUI();
+ try {
+ await vi.waitFor(() => expect(ui.frame()).toContain("ART 18x13"));
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+
+ resize?.(WIDE);
+ await vi.waitFor(() => expect(ui.frame()).toContain("ART 24x18"));
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+ expect(ui.frame()).not.toContain("ART 18x13");
+ expect(mockDecode).toHaveBeenLastCalledWith(BYTES, 24, 18);
+ } finally {
+ ui.unmount();
+ }
+ });
+
+ it("serves a remount at the same budget from cache, with no second fetch or decode", async () => {
+ const url = nextUrl();
+ const first = renderUI();
+ await vi.waitFor(() => expect(first.frame()).toContain("ART 24x18"));
+ first.unmount();
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ expect(mockDecode).toHaveBeenCalledTimes(1);
+
+ const second = renderUI();
+ try {
+ await vi.waitFor(() => expect(second.frame()).toContain("ART 24x18"));
+ // The whole point of caching the decoded cells rather than the bytes: scrolling back onto a
+ // row costs neither the request nor the synchronous decode.
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ expect(mockDecode).toHaveBeenCalledTimes(1);
+ expect(second.frame()).not.toContain("LOADING");
+ } finally {
+ second.unmount();
+ }
+ });
+
+ it("negative-caches a poster that could not be fetched", async () => {
+ const url = nextUrl();
+ // Held in flight, the way the pane-narrows test holds its resize: the mount frame and the
+ // settled frame both read "NONE", so only a LOADING frame between them tells "the fetch has
+ // not started" apart from "the fetch finished with nothing".
+ const miss = deferred();
+ mockFetch.mockReturnValue(miss.promise);
+
+ const first = renderUI();
+ await vi.waitFor(() => expect(first.frame()).toContain("LOADING"));
+
+ miss.settle(null);
+ await vi.waitFor(() => expect(first.frame()).toContain("NONE"));
+ // A null body never reaches the decoder — there is nothing to decode.
+ expect(mockDecode).not.toHaveBeenCalled();
+ first.unmount();
+
+ // What a remount that missed the negative cache would get: a request that never answers. It
+ // would sit on LOADING, so the frame below fails on a broken cache as directly as the count.
+ mockFetch.mockReturnValue(deferred().promise);
+ const second = renderUI();
+ try {
+ // A cache hit is served synchronously inside the effect, so there is no intermediate state
+ // to wait on here — a real sleep instead, the same precedent as the unmount test below,
+ // giving a miss the chance to render the LOADING frame that would disprove the cache.
+ await tick(5);
+ // A row whose poster is a 404 or a WebP must not re-ask on every revisit, and must render
+ // its final answer without flashing a spinner first.
+ expect(second.frame()).toContain("NONE");
+ expect(second.frame()).not.toContain("LOADING");
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ } finally {
+ second.unmount();
+ }
+ });
+
+ it("caches a decoded null so a broken image is not re-decoded either", async () => {
+ const url = nextUrl();
+ mockDecode.mockReturnValue(null);
+ // Held in flight for the reason the negative-cache test holds its own: a decoded null renders
+ // the same "NONE" the mount frame already shows, so LOADING is the only proof of the fetch.
+ const slow = deferred();
+ mockFetch.mockReturnValue(slow.promise);
+
+ const first = renderUI();
+ await vi.waitFor(() => expect(first.frame()).toContain("LOADING"));
+
+ slow.settle(BYTES);
+ await vi.waitFor(() => expect(first.frame()).toContain("NONE"));
+ first.unmount();
+
+ // Again: a remount that re-fetched would park on LOADING rather than answer.
+ mockFetch.mockReturnValue(deferred().promise);
+ const second = renderUI();
+ try {
+ // Cache hit, so nothing asynchronous to wait on — a real sleep, as above.
+ await tick(5);
+ expect(second.frame()).toContain("NONE");
+ expect(second.frame()).not.toContain("LOADING");
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ expect(mockDecode).toHaveBeenCalledTimes(1);
+ } finally {
+ second.unmount();
+ }
+ });
+
+ it("asks for nothing while disabled, with no url, or with no room to draw", async () => {
+ // Every shape below takes the effect's early return, which sets IDLE synchronously — the same
+ // state, and the same "NONE" frame, the probe already renders on mount. There is no
+ // intermediate state here and no asynchronous fact to wait on, so a waitFor could only ever
+ // pass on its first attempt. A real sleep instead, the same precedent as the unmount test
+ // below: it gives a hook that wrongly asked the chance to answer, and the default mock answers
+ // with art, which would replace every "NONE" below as well as tripping the counts at the end.
+ const off = renderUI();
+ await tick(5);
+ expect(off.frame()).toContain("NONE");
+ off.unmount();
+
+ const noUrl = renderUI();
+ await tick(5);
+ expect(noUrl.frame()).toContain("NONE");
+ noUrl.unmount();
+
+ // What posterBudget returning null looks like by the time it reaches the hook: a zero budget,
+ // which must be treated as "no art" and not as a request for a 0x0 image.
+ for (const budget of [
+ { cols: 0, rows: 18 },
+ { cols: 24, rows: 0 },
+ { cols: 0, rows: 0 },
+ ]) {
+ const none = renderUI();
+ await tick(5);
+ expect(none.frame()).toContain("NONE");
+ none.unmount();
+ }
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ expect(mockDecode).not.toHaveBeenCalled();
+ });
+
+ it("drops a response for an unmounted row before paying for the decode", async () => {
+ // The decode is synchronous and blocks the event loop, so a late response has to be discarded
+ // *before* it, not after. Nothing downstream would notice the wasted work — this is the only
+ // place it can be asserted.
+ const slow = deferred();
+ mockFetch.mockReturnValue(slow.promise);
+
+ const ui = renderUI();
+ await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
+ ui.unmount();
+
+ slow.settle(BYTES);
+ // Purely negative with no positive fact to pin it — the point is that nothing happens, so a
+ // waitFor here would pass on its first attempt and prove nothing. Same precedent as
+ // MetaPane.test.tsx:374: a real sleep gives the (mocked, synchronous) decode a chance to run
+ // if the unmounted row's late response were wrongly accepted.
+ await tick(5);
+ expect(mockDecode).not.toHaveBeenCalled();
+ });
+
+ it("aborts the in-flight request when the budget changes under it", async () => {
+ mockFetch.mockReturnValue(deferred().promise);
+ const ui = renderUI();
+ try {
+ await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
+ const signal = mockFetch.mock.calls[0]?.[1]?.signal;
+ expect(signal?.aborted).toBe(false);
+
+ resize?.(NARROW);
+ await vi.waitFor(() => expect(signal?.aborted).toBe(true));
+ } finally {
+ ui.unmount();
+ }
+ });
+});
diff --git a/src/ui/hooks/usePoster.ts b/src/ui/hooks/usePoster.ts
new file mode 100644
index 00000000..524715f3
--- /dev/null
+++ b/src/ui/hooks/usePoster.ts
@@ -0,0 +1,103 @@
+import { useEffect, useState } from "react";
+import { decodePoster } from "../../meta/image";
+import { fetchPosterBytes } from "../../meta/poster";
+import type { PosterCells } from "../../meta/image";
+
+export interface PosterState {
+ loading: boolean;
+ cells: PosterCells | null;
+}
+
+// Same shape as sources/cache.ts and meta/lookup.ts: a module-level Map, a TTL constant, a key
+// helper, no eviction and no persistence. What is cached is the *decoded* cell grid, not the JPEG
+// bytes — the decode is the expensive half and it is synchronous, so re-selecting a row the user
+// already looked at must not pay for it twice.
+//
+// The key carries the cell budget as well as the URL because the same poster at a different pane
+// size is a different picture; a resize retires the old entry by simply never asking for it again.
+const TTL_MS = 30 * 60 * 1000;
+
+// A miss here is as ambiguous as it is in lookup.ts — a dead network, a CDN 404 and a WebP body
+// all arrive as the same null — so it is parked for minutes rather than for the session, and a
+// poster that was merely unlucky comes back once the network does.
+const NEGATIVE_TTL_MS = 2 * 60 * 1000;
+
+interface Entry {
+ at: number;
+ cells: PosterCells | null;
+}
+
+const cache = new Map();
+
+function key(url: string, cols: number, rows: number): string {
+ return `${url}::${cols}x${rows}`;
+}
+
+function peek(k: string): PosterCells | null | undefined {
+ const hit = cache.get(k);
+ if (hit === undefined) return undefined;
+ const ttl = hit.cells === null ? NEGATIVE_TTL_MS : TTL_MS;
+ return Date.now() - hit.at < ttl ? hit.cells : undefined;
+}
+
+const IDLE: PosterState = { loading: false, cells: null };
+
+/**
+ * Poster art for a URL at a given cell budget, fetched lazily and dropped the moment the row stops
+ * being interesting.
+ *
+ * No debounce of its own: `useResultMeta` has already waited out the user's scrolling before it
+ * produced the metadata that carries this URL, so by the time there is anything to fetch the
+ * cursor has settled. `enabled` mirrors that hook so the caller can keep it mounted (hooks cannot
+ * be called conditionally) while the pane has no room for art.
+ */
+export function usePoster(
+ url: string | undefined,
+ cols: number,
+ rows: number,
+ enabled: boolean,
+): PosterState {
+ const [state, setState] = useState(IDLE);
+
+ useEffect(() => {
+ if (!enabled || url === undefined || cols < 1 || rows < 1) {
+ setState(IDLE);
+ return;
+ }
+
+ const k = key(url, cols, rows);
+ // Synchronous read first, so scrolling back onto a row redraws its art in the same frame the
+ // text card returns instead of blinking through a second empty pass.
+ const cached = peek(k);
+ if (cached !== undefined) {
+ setState({ loading: false, cells: cached });
+ return;
+ }
+
+ setState({ loading: true, cells: null });
+
+ let alive = true;
+ const ctrl = new AbortController();
+ void fetchPosterBytes(url, { signal: ctrl.signal })
+ .then((bytes) => {
+ // Decoding blocks the event loop, so a response for a row the cursor has already left is
+ // dropped before the decode rather than after it.
+ if (!alive) return;
+ const cells = bytes === null ? null : decodePoster(bytes, cols, rows);
+ cache.set(k, { at: Date.now(), cells });
+ setState({ loading: false, cells });
+ })
+ .catch(() => {
+ // fetchPosterBytes and decodePoster both swallow their own failures. Belt and braces: an
+ // unhandled rejection out of a render path is a crashed TUI.
+ if (alive) setState(IDLE);
+ });
+
+ return () => {
+ alive = false;
+ ctrl.abort();
+ };
+ }, [url, cols, rows, enabled]);
+
+ return state;
+}
diff --git a/src/ui/hooks/useResultMeta.dualMount.test.tsx b/src/ui/hooks/useResultMeta.dualMount.test.tsx
new file mode 100644
index 00000000..6429ce40
--- /dev/null
+++ b/src/ui/hooks/useResultMeta.dualMount.test.tsx
@@ -0,0 +1,127 @@
+import { Box, Text } from "ink";
+import { useEffect, useState } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { fetchMeta, searchCatalog } from "../../meta/cinemeta";
+import { renderUI } from "../testHarness";
+import { useResultMeta } from "./useResultMeta";
+import type { ReactElement } from "react";
+import type { Meta } from "../../meta/types";
+import type { TorrentResult } from "../../sources/types";
+
+// Only the provider is mocked here — unlike useResultMeta.test.tsx, which mocks the orchestrator
+// to test the hook alone. The whole point of this file is the seam *between* them: two live hooks
+// on one row, sharing one real in-flight request through meta/lookup's refcount.
+vi.mock("../../meta/cinemeta", () => ({
+ searchCatalog: vi.fn(),
+ fetchMeta: vi.fn(),
+}));
+
+const mockSearch = vi.mocked(searchCatalog);
+const mockFetch = vi.mocked(fetchMeta);
+
+// An id-carrying row, so the lookup takes the fast path and fetchMeta is the only call to control.
+const ROW: TorrentResult = {
+ infoHash: "dual-mount-1",
+ name: "Palewind (2020) [1080p]",
+ imdbId: "tt7700001",
+ source: "yts",
+ sizeBytes: 2.1e9,
+ seeders: 40,
+ leechers: 6,
+ magnet: "magnet:?xt=urn:btih:dualmount1",
+};
+
+const FOUND: Meta = {
+ imdbId: "tt7700001",
+ kind: "movie",
+ title: "Palewind",
+ genres: [],
+ cast: [],
+ director: [],
+};
+
+/**
+ * Let Ink flush a render and any timer shorter than `ms`.
+ *
+ * Only for the wait that has to outlive the pane's debounce, which is a wall-clock fact by
+ * construction. Everything positive waits on the fact itself with `vi.waitFor`, because a fixed
+ * sleep is a guess about how long a loaded machine takes to settle a promise.
+ */
+const tick = (ms = 0): Promise => new Promise((res) => setTimeout(res, ms));
+
+let closeDetail: (() => void) | null = null;
+
+function Probe({ tag, debounceMs }: { tag: string; debounceMs: number }): ReactElement {
+ const { loading, meta } = useResultMeta(ROW, true, debounceMs);
+ return {`${tag}=${loading ? "LOADING" : (meta?.title ?? "NONE")}`};
+}
+
+/**
+ * The exact shape Results renders: the pane always mounted on the cursor row, the detail view
+ * mounted over it on the same row and closing independently.
+ */
+function Pair(): ReactElement {
+ const [open, setOpen] = useState(true);
+ useEffect(() => {
+ closeDetail = () => setOpen(false);
+ return () => {
+ closeDetail = null;
+ };
+ }, []);
+ return (
+
+ {/* Detail commits with no debounce; the pane waits, because the cursor sweeps past rows. */}
+ {open ? : null}
+
+
+ );
+}
+
+beforeEach(() => {
+ closeDetail = null;
+ mockSearch.mockReset();
+ mockFetch.mockReset();
+ mockSearch.mockResolvedValue([]);
+});
+
+describe("detail view and info pane on one row", () => {
+ it("leaves the pane resolving after the detail view closes over it", async () => {
+ // Held open so the detail view is still waiting on it when the pane joins, and both are still
+ // waiting when the detail view closes — the ordering that broke before Task 3's refcount.
+ let settle: (m: Meta | null) => void = () => {};
+ mockFetch.mockReturnValue(
+ new Promise((res) => {
+ settle = res;
+ }),
+ );
+
+ const ui = renderUI();
+ try {
+ // This one stays a real sleep. The fact being waited for is that the pane's 20 ms debounce
+ // elapsed and it joined the flight, and nothing observable tells that apart from "the pane
+ // has not asked yet" — the request count is 1 either way, and "pane=LOADING" is already on
+ // the mount frame. Waiting on either would resolve before the pane had done anything. A
+ // sleep can only overshoot 20 ms on a loaded machine, which is the harmless direction.
+ await tick(60);
+ expect(ui.frame()).toContain("pane=LOADING");
+ // One request for both mounts: the pane joined the detail view's flight rather than opening
+ // a second socket for the same title.
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+
+ closeDetail?.();
+ // React schedules the unmount rather than running it inside the setter, so the frame still
+ // carries "detail=" on the first attempt: this waits for the close, it does not assume it.
+ await vi.waitFor(() => expect(ui.frame()).not.toContain("detail="));
+
+ settle(FOUND);
+ // The pane must land on the answer. Sticking on "No metadata" here is the regression this
+ // exists to catch: the leaving caller's abort reaching the shared request would hand the
+ // pane a null it has no way to notice or retry. The frame reads "pane=LOADING" until the
+ // settled promise walks back through the refcount, so this waits rather than assumes.
+ await vi.waitFor(() => expect(ui.frame()).toContain("pane=Palewind"));
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ } finally {
+ ui.unmount();
+ }
+ });
+});
diff --git a/src/ui/hooks/useResultMeta.test.tsx b/src/ui/hooks/useResultMeta.test.tsx
new file mode 100644
index 00000000..6c6ed325
--- /dev/null
+++ b/src/ui/hooks/useResultMeta.test.tsx
@@ -0,0 +1,238 @@
+import { Text } from "ink";
+import { useEffect, useState } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { lookupMeta, peekMeta } from "../../meta/lookup";
+import { renderUI } from "../testHarness";
+import { useResultMeta } from "./useResultMeta";
+import type { ReactElement } from "react";
+import type { Meta } from "../../meta/types";
+import type { TorrentResult } from "../../sources/types";
+
+// The orchestrator is mocked so these tests are about the hook's own contract — which row it asks
+// for, when it asks, and whose answer it is allowed to render.
+vi.mock("../../meta/lookup", () => ({ peekMeta: vi.fn(), lookupMeta: vi.fn() }));
+
+const mockPeek = vi.mocked(peekMeta);
+const mockLookup = vi.mocked(lookupMeta);
+
+function row(infoHash: string, name: string): TorrentResult {
+ return {
+ infoHash,
+ name,
+ sizeBytes: 2.1e9,
+ seeders: 40,
+ leechers: 6,
+ source: "yts",
+ magnet: `magnet:?xt=urn:btih:${infoHash}`,
+ };
+}
+
+function meta(title: string): Meta {
+ return { imdbId: "tt1234567", kind: "movie", title, genres: [], cast: [], director: [] };
+}
+
+const ALPHA = row("h-alpha", "Alpha (2019) [1080p]");
+const BRAVO = row("h-bravo", "Bravo (2020) [1080p]");
+
+// The probe exposes its row setter so a test can change the selection the way the results list
+// does, without needing a rerender handle the shared harness does not provide.
+let swap: ((r: TorrentResult | null) => void) | null = null;
+
+function Probe({
+ first,
+ debounceMs,
+ enabled = true,
+}: {
+ first: TorrentResult | null;
+ debounceMs: number;
+ enabled?: boolean;
+}): ReactElement {
+ const [r, setR] = useState(first);
+ useEffect(() => {
+ swap = setR;
+ return () => {
+ swap = null;
+ };
+ }, []);
+ const { loading, meta: found } = useResultMeta(r, enabled, debounceMs);
+ return {loading ? "LOADING" : (found?.title ?? "NONE")};
+}
+
+/**
+ * Let Ink flush a render and any timer shorter than `ms`.
+ *
+ * Only for the waits that are *purely negative* — "nothing happened", "it did not ask again" —
+ * and for the debounces, which are wall-clock facts by construction. Anything positive waits on
+ * the fact itself with `vi.waitFor`, because a fixed sleep is a guess about how long a loaded
+ * machine takes to settle a promise, and a wrong guess is a test that fails for no reason.
+ */
+function tick(ms = 0): Promise {
+ return new Promise((res) => setTimeout(res, ms));
+}
+
+function deferred(): { promise: Promise; settle: (m: Meta | null) => void } {
+ let settle: (m: Meta | null) => void = () => {};
+ const promise = new Promise((res) => {
+ settle = res;
+ });
+ return { promise, settle };
+}
+
+beforeEach(() => {
+ swap = null;
+ mockPeek.mockReset();
+ mockLookup.mockReset();
+ mockPeek.mockReturnValue(undefined);
+ mockLookup.mockResolvedValue(null);
+});
+
+describe("useResultMeta", () => {
+ it("never renders one row's metadata against another row", async () => {
+ // The `alive` flag is the only thing enforcing this. Cache keys make the cache correct; they
+ // do nothing to stop a late resolution landing in the state a different row is rendering.
+ const slow = deferred();
+ mockLookup.mockReturnValueOnce(slow.promise).mockResolvedValue(meta("Bravo"));
+
+ const ui = renderUI();
+ try {
+ // The lookup is fired from a timer inside the effect, so it cannot have happened by the
+ // time render() returns — this waits on a fact the mount frame does not already show.
+ await vi.waitFor(() => expect(mockLookup).toHaveBeenCalledTimes(1));
+ expect(mockLookup.mock.calls[0]?.[0]).toBe(ALPHA);
+
+ swap?.(BRAVO);
+ // Alpha is still pending, so the frame reads LOADING until Bravo's own lookup settles.
+ await vi.waitFor(() => expect(ui.frame()).toContain("Bravo"));
+
+ // Alpha's request finally comes back, long after the user moved on.
+ slow.settle(meta("Alpha"));
+ // Purely negative, with no positive fact to pin it: the point is that the late resolution
+ // changes nothing, so a waitFor would pass on its first attempt and prove nothing. A real
+ // sleep instead — the same precedent as MetaPane's unfocused-keys test — giving the
+ // resolution every chance to land in the state Bravo is rendering.
+ await tick(5);
+ expect(ui.frame()).toContain("Bravo");
+ expect(ui.frame()).not.toContain("Alpha");
+ } finally {
+ ui.unmount();
+ }
+ });
+
+ it("aborts the in-flight lookup when the row changes", async () => {
+ mockLookup.mockReturnValue(deferred().promise);
+ const ui = renderUI();
+ try {
+ await vi.waitFor(() => expect(mockLookup).toHaveBeenCalledTimes(1));
+ const signal = mockLookup.mock.calls[0]?.[1]?.signal;
+ expect(signal?.aborted).toBe(false);
+
+ swap?.(BRAVO);
+ // The abort comes from the effect cleanup, which React runs on the render the swap
+ // schedules rather than synchronously inside it — so this still has something to wait for.
+ await vi.waitFor(() => expect(signal?.aborted).toBe(true));
+ } finally {
+ ui.unmount();
+ }
+ });
+
+ it("renders a cached row with no timer and no request", async () => {
+ mockPeek.mockReturnValue(meta("Charlie"));
+
+ // A debounce long enough that anything reaching the network would still be waiting.
+ const ui = renderUI();
+ try {
+ // A cache hit is served synchronously inside the effect, so there is no intermediate state
+ // to wait on and every assertion below is a negative. A real sleep instead: it gives a hook
+ // that wrongly armed the timer or the request the chance to show it.
+ await tick(5);
+ expect(ui.frame()).toContain("Charlie");
+ expect(ui.frame()).not.toContain("LOADING");
+ expect(mockLookup).not.toHaveBeenCalled();
+ } finally {
+ ui.unmount();
+ }
+ });
+
+ it("renders a cached miss as a final answer, not as loading", async () => {
+ // peekMeta answers null for a row that can never be queried at all, so the spinner would
+ // otherwise never end.
+ mockPeek.mockReturnValue(null);
+ const ui = renderUI();
+ try {
+ // "NONE" is what the mount frame already shows, so waiting for it would resolve on the
+ // first attempt and prove nothing. A real sleep, as in the cached-hit test above: a hook
+ // that treated the cached null as unanswered would replace it with LOADING in that window.
+ await tick(5);
+ expect(ui.frame()).toContain("NONE");
+ expect(mockLookup).not.toHaveBeenCalled();
+ } finally {
+ ui.unmount();
+ }
+ });
+
+ it("issues no lookup when the row is left before the debounce elapses", async () => {
+ const ui = renderUI();
+ await tick(5);
+ expect(mockLookup).not.toHaveBeenCalled();
+
+ ui.unmount();
+ // Both waits are negative, and both are wall-clock by construction: the second has to outlive
+ // the 60 ms the cancelled timer would have fired at. A sleep can only ever overshoot on a
+ // loaded machine, which is the harmless direction for "still not called".
+ await tick(120);
+ expect(mockLookup).not.toHaveBeenCalled();
+ });
+
+ it("waits out the debounce before asking, then asks once", async () => {
+ const ui = renderUI();
+ try {
+ // The one wait in this file that a loaded machine can overshoot into failing: 5 ms is a
+ // fraction of the 40 ms debounce, but only the clock says so. There is nothing else to
+ // wait on — "has not asked yet" is exactly a negative — and fake timers would take the
+ // debounce out of the test entirely.
+ await tick(5);
+ expect(mockLookup).not.toHaveBeenCalled();
+ await vi.waitFor(() => expect(mockLookup).toHaveBeenCalledTimes(1));
+ } finally {
+ ui.unmount();
+ }
+ });
+
+ it("keys on the infoHash, so a re-sorted list does not retrigger", async () => {
+ const ui = renderUI();
+ try {
+ await vi.waitFor(() => expect(mockLookup).toHaveBeenCalledTimes(1));
+
+ // What a streaming re-sort produces: an equal row with a fresh object identity.
+ swap?.({ ...ALPHA });
+ // Negative — "it did not ask again" — so a real sleep, long enough that a retrigger would
+ // have run its zero-length debounce and bumped the count this line reads.
+ await tick(5);
+ expect(mockLookup).toHaveBeenCalledTimes(1);
+
+ // And the counter is not simply stuck — a genuinely different row does retrigger.
+ swap?.(BRAVO);
+ await vi.waitFor(() => expect(mockLookup).toHaveBeenCalledTimes(2));
+ } finally {
+ ui.unmount();
+ }
+ });
+
+ it("asks for nothing while disabled or with no row", async () => {
+ // Both shapes take the effect's early return, which sets IDLE synchronously — the same state,
+ // and the same "NONE" frame, the probe already renders on mount. Nothing here is asynchronous
+ // and every assertion is a negative, so a waitFor could only pass on its first attempt. Real
+ // sleeps instead: a hook that wrongly asked has a whole debounce-free window to prove it.
+ const off = renderUI();
+ await tick(5);
+ expect(off.frame()).toContain("NONE");
+ expect(mockPeek).not.toHaveBeenCalled();
+ expect(mockLookup).not.toHaveBeenCalled();
+ off.unmount();
+
+ const empty = renderUI();
+ await tick(5);
+ expect(mockLookup).not.toHaveBeenCalled();
+ empty.unmount();
+ });
+});
diff --git a/src/ui/hooks/useResultMeta.ts b/src/ui/hooks/useResultMeta.ts
new file mode 100644
index 00000000..cf61c189
--- /dev/null
+++ b/src/ui/hooks/useResultMeta.ts
@@ -0,0 +1,82 @@
+import { useEffect, useRef, useState } from "react";
+import { lookupMeta, peekMeta } from "../../meta/lookup";
+import type { Meta } from "../../meta/types";
+import type { TorrentResult } from "../../sources/types";
+
+export interface MetaState {
+ loading: boolean;
+ meta: Meta | null;
+}
+
+// The user is scrolling a list; every row they pass through is a candidate lookup they did not
+// ask for. Waiting this long before firing means holding an arrow key down costs zero requests,
+// and a deliberate stop on a row still feels immediate.
+const DEBOUNCE_MS = 250;
+
+const IDLE: MetaState = { loading: false, meta: null };
+
+/**
+ * Metadata for the currently interesting row, fetched lazily and cancelled the moment it stops
+ * being interesting.
+ *
+ * `enabled` exists so a caller can keep the hook mounted (hooks cannot be called conditionally)
+ * while the pane that wants the data is closed.
+ */
+export function useResultMeta(
+ result: TorrentResult | null,
+ enabled: boolean,
+ debounceMs?: number,
+): MetaState {
+ const [state, setState] = useState(IDLE);
+
+ // The effect keys on the infoHash, not the row object, so the streaming re-sorts in Results
+ // cannot restart a lookup that is already in flight. It still needs the row itself to run the
+ // lookup, and reading it through a ref keeps that out of the dependency list — two objects with
+ // the same infoHash are the same torrent, so the latest one is always safe to use.
+ const latest = useRef(result);
+ latest.current = result;
+
+ const infoHash = result?.infoHash;
+
+ useEffect(() => {
+ const row = latest.current;
+ if (!enabled || row === null || row === undefined) {
+ setState(IDLE);
+ return;
+ }
+
+ // Synchronous cache read first: a row we already resolved (or already know we never will)
+ // renders its answer immediately instead of flashing a spinner on every revisit.
+ const cached = peekMeta(row);
+ if (cached !== undefined) {
+ setState({ loading: false, meta: cached });
+ return;
+ }
+
+ setState({ loading: true, meta: null });
+
+ let alive = true;
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => {
+ void lookupMeta(row, { signal: ctrl.signal })
+ .then((meta) => {
+ // The row changed under us while the request was in flight; its state belongs to
+ // whichever effect is current, not to this one.
+ if (alive) setState({ loading: false, meta });
+ })
+ .catch(() => {
+ // lookupMeta already swallows its own failures. Belt and braces: an unhandled rejection
+ // from a render path is a crashed TUI.
+ if (alive) setState(IDLE);
+ });
+ }, debounceMs ?? DEBOUNCE_MS);
+
+ return () => {
+ alive = false;
+ ctrl.abort();
+ clearTimeout(timer);
+ };
+ }, [infoHash, enabled, debounceMs]);
+
+ return state;
+}
diff --git a/src/ui/keymap.test.ts b/src/ui/keymap.test.ts
index ad6d2c1e..ecf77e7b 100644
--- a/src/ui/keymap.test.ts
+++ b/src/ui/keymap.test.ts
@@ -6,6 +6,10 @@ import { footerHints, HELP_GROUPS, type Hint } from "./keymap";
const rowWidth = (hints: Hint[]): number =>
hints.reduce((n, h) => n + h.keys.length + 1 + h.label.length, 0) + (hints.length - 1) * 3;
+// The same row as a string, so a test can ask what survives Footer's `wrap="truncate-end"` at a
+// given terminal width instead of only asking how wide the untruncated row would have been.
+const rowText = (hints: Hint[]): string => hints.map((h) => `${h.keys} ${h.label}`).join(" ");
+
describe("downloads/seeding key vocabulary", () => {
it("folds clear-all into shift+c on the c row and drops x", () => {
const downloads = HELP_GROUPS.find((g) => g.title === "Downloads")!;
@@ -39,3 +43,93 @@ describe("downloads/seeding key vocabulary", () => {
for (const row of rows) expect(rowWidth(row)).toBeLessThanOrEqual(78);
});
});
+
+describe("info pane key", () => {
+ it("carries the full label in the ? sheet", () => {
+ const search = HELP_GROUPS.find((g) => g.title === "Search");
+ expect(search?.hints.find((h) => h.keys === "i")?.label).toBe("Toggle info pane");
+ // Both halves of the trip are documented, or the pane is a room with no marked exit.
+ expect(search?.hints.find((h) => h.keys === "→")?.label).toContain("info pane");
+ expect(search?.hints.find((h) => h.keys === "←")?.label).toContain("results list");
+ });
+
+ it("advertises i in the footer only where the pane can exist", () => {
+ expect(footerHints("content", "all", null, null, null).some((h) => h.keys === "i")).toBe(false);
+ expect(footerHints("content", "all", null, null, null, true).find((h) => h.keys === "i")?.label).toBe(
+ "Info",
+ );
+ // The pane belongs to the results view alone; no other row grows, whatever it is passed.
+ const elsewhere = [
+ footerHints("sidebar", "all", null, null, null, true),
+ footerHints("content", "downloads", "downloading", null, null, true),
+ footerHints("content", "seeding", null, "seeding", null, true),
+ ];
+ for (const row of elsewhere) expect(row.some((h) => h.keys === "i")).toBe(false);
+ });
+
+ // Footer truncates from the end, so the hint's position decides what a narrow terminal loses.
+ // The pane appears from 92 cols (contentWidth 73), where the footer's budget is cols - 2 = 90
+ // and this row measures 93 — the hint cannot fit there, and the point of putting it last is
+ // that what does not fit is the hint itself and never the `? Keys` anchor.
+ it("never pushes ? Keys off the row at any width where the hint appears", () => {
+ const row = footerHints("content", "all", null, null, null, true);
+ expect(rowWidth(row)).toBe(93);
+ for (const cols of [92, 93, 94, 95, 120]) {
+ expect(rowText(row).slice(0, cols - 2), `at ${cols} cols`).toContain("? Keys");
+ }
+ // And the row is unchanged, to the column, wherever the pane cannot render.
+ expect(rowWidth(footerHints("content", "all", null, null, null))).toBe(84);
+ });
+
+ it("spends its one slot on the key that does something right now", () => {
+ // Pane on screen: → steps into it. Pane toggled off: i is what brings it back. Two hints for
+ // one pane would cost the row columns it does not have, and one arrow glyph is exactly as
+ // wide as the "i" it replaces, so the 93-column boundary above never moves.
+ const open = footerHints("content", "movies", null, null, null, true, true);
+ expect(open.find((h) => h.label === "Info")?.keys).toBe("→");
+ expect(rowWidth(open)).toBe(93);
+ const closed = footerHints("content", "movies", null, null, null, true, false);
+ expect(closed.find((h) => h.label === "Info")?.keys).toBe("i");
+ expect(rowWidth(closed)).toBe(93);
+ });
+
+ it("says nothing about a pane the Games tab never shows", () => {
+ // No provider answers for games, so the pane is hidden there and an "Info" hint would be an
+ // invitation to open a column that cannot exist.
+ for (const open of [true, false]) {
+ const games = footerHints("content", "games", null, null, null, true, open);
+ expect(games.some((h) => h.label === "Info")).toBe(false);
+ }
+ expect(footerHints("content", "anime", null, null, null, true).some((h) => h.label === "Info"))
+ .toBe(true);
+ });
+});
+
+describe("focused info pane footer", () => {
+ it("swaps the list's vocabulary for the pane's, and keeps the ? anchor last", () => {
+ const row = footerHints("preview", "all", null, null, null, true, true);
+ expect(row.map((h) => h.keys)).toEqual(["↑↓", "←", "tab", "?"]);
+ expect(row.at(-1)?.label).toBe("Keys");
+ // The list's own keys are gone, because in this region they are not what the keyboard does.
+ for (const gone of ["d", "y", "/", "f", "s"]) {
+ expect(row.some((h) => h.keys === gone), gone).toBe(false);
+ }
+ });
+
+ it("fits the 80-column budget, even though it only ever appears above 92", () => {
+ expect(rowWidth(footerHints("preview", "all", null, null, null, true, true))).toBeLessThanOrEqual(78);
+ });
+
+ it("answers the region before the section, since the pane is only ever the results view's", () => {
+ // Downloads and Seeding never report this region; if one somehow did, the pane's row is still
+ // the correct answer for a keyboard that is inside the pane.
+ for (const section of ["all", "movies", "downloads", "seeding"] as const) {
+ expect(footerHints("preview", section, "recent", "seeding").map((h) => h.keys)).toEqual([
+ "↑↓",
+ "←",
+ "tab",
+ "?",
+ ]);
+ }
+ });
+});
diff --git a/src/ui/keymap.ts b/src/ui/keymap.ts
index 48b56e75..20a3f796 100644
--- a/src/ui/keymap.ts
+++ b/src/ui/keymap.ts
@@ -31,6 +31,11 @@ export const HELP_GROUPS: HelpGroup[] = [
{ keys: "d", label: "Download (shift+d: folder)" },
{ keys: "s", label: "Sort results" },
{ keys: "z", label: "Hide dead torrents" },
+ { keys: "i", label: "Toggle info pane" },
+ // The pane's own two keys sit with the toggle that summons it, not up in Navigate: the
+ // arrows there already describe walking panes, and these say what the third column is for.
+ { keys: "→", label: "Focus info pane to scroll" },
+ { keys: "←", label: "Back to the results list" },
{ keys: "y", label: "Copy magnet" },
{ keys: "↵", label: "Open details" },
{ keys: "e", label: "Export as .torrent" },
@@ -73,12 +78,28 @@ const TORRENT: Hint = { keys: "s", label: "Export" };
const EXPORT: Hint = { keys: "e", label: "Export" };
+/**
+ * `previewAvailable` gates the info-pane hint on the pane being able to exist at all, and
+ * `previewOpen` decides which of its keys the one hint slot spends itself on.
+ *
+ * The results row is already 84 columns against a 78-column budget at 80 cols (a known overflow
+ * this test suite exempts), and Footer truncates from the end — so an unconditional hint would
+ * spend its columns advertising a pane the terminal is too narrow to ever show, on exactly the
+ * rows that can least afford them. Gated, the hint only appears from 92 cols, which is where the
+ * pane itself starts existing. That is also why the pane gets one slot and not two: `→` and `i`
+ * are the same single column of hint, showing whichever one does something right now — with the
+ * pane on screen that is stepping into it, with it toggled off that is bringing it back. The `?`
+ * sheet carries all three keys unabbreviated. The defaults keep every existing caller —
+ * scripts/render-previews-impl.tsx included — compiling and rendering unchanged.
+ */
export function footerHints(
region: Region,
section: Section,
downloadFocus?: DownloadFocus | null,
seedFocus?: SeedFocus | null,
resultFocus?: ResultFocus | null,
+ previewAvailable = false,
+ previewOpen = false,
): Hint[] {
if (region === "sidebar") {
return [
@@ -89,6 +110,11 @@ export function footerHints(
{ keys: "q", label: "Quit" },
];
}
+ // The third column reached from the results list, and the only place the list's own keys are
+ // not what the user needs told: the pane scrolls, and the way back out is the way in reversed.
+ if (region === "preview") {
+ return [{ keys: "↑↓", label: "Scroll" }, { keys: "←", label: "Back" }, SWITCH, ALWAYS];
+ }
if (section === "seeding") {
const label =
seedFocus === "seeding" ? "Pause" : seedFocus === "missing" ? "Retry" : "Resume";
@@ -126,5 +152,17 @@ export function footerHints(
{ keys: "f", label: "Filter" },
SWITCH,
ALWAYS,
+ // Last on purpose, behind the `?` anchor rather than in front of it. Footer truncates from the
+ // end, and this row is 84 columns before the hint and 93 after it, so between 92 (where the
+ // pane first exists) and 94 columns something has to go — and it must not be `? Keys`, which
+ // is how every binding that does not fit here is discoverable at all. Both key glyphs are one
+ // column wide, so which one is showing never moves that boundary.
+ //
+ // Games is excluded here rather than at the call site because it is the same rule the pane
+ // itself follows: no provider answers for games, so the pane would be a column of "No
+ // metadata" and the hint an invitation to open it.
+ ...(previewAvailable && section !== "games"
+ ? [{ keys: previewOpen ? "→" : "i", label: "Info" }]
+ : []),
];
}
diff --git a/src/ui/metaPlan.test.ts b/src/ui/metaPlan.test.ts
new file mode 100644
index 00000000..8f4db498
--- /dev/null
+++ b/src/ui/metaPlan.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from "vitest";
+import { NO_META_PLAN, planMetaRows } from "./metaPlan";
+import { displayWidth } from "./textWidth";
+import type { Meta } from "../meta/types";
+
+// The detail panel's row-budget arithmetic without a render: Results.test.tsx proves the panel
+// draws what it is given, and these prove what it is given. A wrapped credit that costs two rows
+// instead of one is a fused row in the panel, which is the hardest failure to read off a rendered
+// snapshot.
+const META: Meta = {
+ imdbId: "tt0111161",
+ kind: "movie",
+ title: "The Shawshank Redemption",
+ year: "1994",
+ rating: "9.3",
+ runtime: "142 min",
+ genres: ["Drama"],
+ cast: ["Neo"],
+ director: ["Frank Darabont"],
+ plot: "A banker convicted of murdering his wife forms a friendship over a number of years.",
+};
+
+const WIDTH = 30;
+const INFINITE = Number.POSITIVE_INFINITY;
+
+describe("planMetaRows", () => {
+ it("answers every field null when there is no metadata to plan", () => {
+ expect(planMetaRows(null, WIDTH, INFINITE)).toEqual(NO_META_PLAN);
+ });
+
+ it("builds every row when nothing is competing for rows", () => {
+ const plan = planMetaRows(META, WIDTH, INFINITE);
+ expect(plan.rating).toBe("9.3 / 10");
+ expect(plan.genres).toBe("Drama");
+ expect(plan.director).toBe("Frank Darabont");
+ expect(plan.cast).toBe("Neo");
+ // Wrapped to this width, so compare against the wrap-normalized text rather than the raw
+ // single-line source.
+ expect(plan.plot?.replace(/\n/g, " ")).toBe(META.plot);
+ });
+
+ it("answers an empty plan for a budget with no rows to give", () => {
+ expect(planMetaRows(META, WIDTH, 0)).toEqual(NO_META_PLAN);
+ expect(planMetaRows(META, WIDTH, -3)).toEqual(NO_META_PLAN);
+ });
+
+ it("spends the budget in priority order — rating, then genres, then director, then cast", () => {
+ // Each of these four rows costs exactly one line at this fixture and width.
+ expect(planMetaRows(META, WIDTH, 1)).toMatchObject({
+ rating: "9.3 / 10",
+ genres: null,
+ director: null,
+ cast: null,
+ });
+ expect(planMetaRows(META, WIDTH, 2)).toMatchObject({
+ rating: "9.3 / 10",
+ genres: "Drama",
+ director: null,
+ cast: null,
+ });
+ expect(planMetaRows(META, WIDTH, 3)).toMatchObject({
+ rating: "9.3 / 10",
+ genres: "Drama",
+ director: "Frank Darabont",
+ cast: null,
+ });
+ });
+
+ it("treats a field the result never carried as absent, not as a fit failure", () => {
+ const noRating: Meta = { ...META, rating: undefined };
+ const plan = planMetaRows(noRating, WIDTH, 3);
+ // Genres, director and cast all fit in the three rows a missing rating never claimed.
+ expect(plan).toMatchObject({ rating: null, genres: "Drama", director: "Frank Darabont", cast: "Neo" });
+ });
+
+ it("holds the cutoff once a present row does not fit, even for a shorter row right after it", () => {
+ // Two directors wrap to two lines at this width; the cast credit right after it would fit
+ // in the one row left over on its own, but the cutoff a two-row overflow triggers drops it
+ // too rather than leaving a hole where director should have been.
+ const wideDirector: Meta = { ...META, director: ["Christopher Alexander Nolan", "Peter Jackson"] };
+ const plan = planMetaRows(wideDirector, WIDTH, 3);
+ expect(plan.rating).toBe("9.3 / 10");
+ expect(plan.genres).toBe("Drama");
+ expect(plan.director).toBeNull();
+ expect(plan.cast).toBeNull();
+ });
+
+ it("still gives the plot the row a dropped credit left unclaimed", () => {
+ // Same fixture as the cutoff test above: director's failed attempt never spent the one row
+ // left after rating and genres, so plot — exempt from the cutoff — gets to spend it.
+ const wideDirector: Meta = { ...META, director: ["Christopher Alexander Nolan", "Peter Jackson"] };
+ const plan = planMetaRows(wideDirector, WIDTH, 3);
+ expect(plan.plot).not.toBeNull();
+ expect(displayWidth(plan.plot ?? "")).toBeLessThanOrEqual(WIDTH);
+ });
+
+ it("omits the plot row entirely when the rows above it spent everything", () => {
+ expect(planMetaRows(META, WIDTH, 4).plot).toBeNull();
+ });
+
+ it("ellipsizes the plot's last line rather than dropping it whole", () => {
+ const longPlot: Meta = {
+ ...META,
+ plot:
+ "A computer hacker learns from mysterious rebels about the true nature of his reality and " +
+ "his role in the war against its controllers, who farm humanity in a simulated world.",
+ };
+ const plan = planMetaRows(longPlot, WIDTH, 6);
+ expect(plan.plot).toContain("…");
+ for (const line of plan.plot?.split("\n") ?? []) {
+ expect(displayWidth(line)).toBeLessThanOrEqual(WIDTH);
+ }
+ });
+
+ it("never spends more rows than it was given", () => {
+ const rowsOf = (plan: ReturnType): number =>
+ [plan.rating, plan.genres, plan.director, plan.cast, plan.plot]
+ .filter((v): v is string => v !== null)
+ .reduce((n, v) => n + v.split("\n").length, 0);
+ for (let budget = 0; budget <= 8; budget++) {
+ expect(rowsOf(planMetaRows(META, WIDTH, budget)), `budget ${budget}`).toBeLessThanOrEqual(budget);
+ }
+ });
+});
diff --git a/src/ui/metaPlan.ts b/src/ui/metaPlan.ts
new file mode 100644
index 00000000..6b8865a7
--- /dev/null
+++ b/src/ui/metaPlan.ts
@@ -0,0 +1,102 @@
+/**
+ * The detail panel's metadata rows: which of the five fields fit in a fixed row budget.
+ *
+ * A pure module the component and its tests both read from, mirroring paneCard.ts — the same
+ * budgeted-degradation problem, one level down. paneCard.ts plans the *pane's* card; this plans
+ * the *detail panel's* rows.
+ */
+
+import { ellipsizeToWidth, wordWrapLines } from "./textWidth";
+import type { Meta } from "../meta/types";
+
+export interface MetaPlan {
+ readonly rating: string | null;
+ readonly genres: string | null;
+ readonly director: string | null;
+ readonly cast: string | null;
+ readonly plot: string | null;
+}
+
+export const NO_META_PLAN: MetaPlan = {
+ rating: null,
+ genres: null,
+ director: null,
+ cast: null,
+ plot: null,
+};
+
+/**
+ * Decides which of the five metadata rows fit in `budget` terminal rows, in priority order —
+ * rating, genres, director, cast, plot — so a tight panel sheds value from the metadata block
+ * outward and never touches the torrent facts above it or the action hint below.
+ *
+ * Rating/genres/director/cast share one cutoff: the moment a *present* row among them fails to
+ * fit, every one of them considered afterward is dropped too, even ones that would individually
+ * have fit on their own — otherwise a wide-but-short row (say, three-name Director) could land
+ * after a taller one it just displaced (six-genre Genres), which reads as a row missing from the
+ * middle of that group rather than a clean cut at its end. A field that is simply absent from this
+ * title's metadata (no director credited, most series) is not a fit failure and never triggers
+ * that cutoff — only a row that exists but does not fit does. Among themselves, genres/director/
+ * cast are also all-or-nothing: a cast list cut off mid-name reads as a bug, not a feature, so
+ * each is only admitted if every one of its wrapped lines fits.
+ *
+ * Plot is deliberately exempt from that cutoff and always evaluated last, spending whatever
+ * budget the rows above it left unclaimed — whether they used it in full or gave up on it early —
+ * and ellipsizing its last visible line when the full text still does not fit. This is the one
+ * place a real gap can appear: rating can end up directly above plot with genres, director and
+ * cast all missing between them. That is an accepted tradeoff, not an oversight — capping the
+ * plot, rather than truncating whichever all-or-nothing row happens to sit at the budget boundary,
+ * is the chosen primary lever for a tight panel, and plot is the only row built to show less than
+ * it has.
+ */
+export function planMetaRows(meta: Meta | null, valueWidth: number, budget: number): MetaPlan {
+ if (meta === null) return NO_META_PLAN;
+ let remaining = budget;
+ // Set the first time a present row does not fit. Every row considered afterward — regardless
+ // of whether it would individually fit in what is left — is dropped, which is what keeps
+ // degradation a clean prefix cut instead of a hole partway through the block.
+ let cutoff = false;
+
+ const admit = (rows: number): boolean => {
+ if (cutoff || rows > remaining) {
+ cutoff = true;
+ return false;
+ }
+ remaining -= rows;
+ return true;
+ };
+
+ const rating = meta.rating && admit(1) ? `${meta.rating} / 10` : null;
+
+ const admitJoined = (values: readonly string[]): string | null => {
+ if (values.length === 0) return null; // absent, not a fit failure — does not trip the cutoff
+ const lines = wordWrapLines(values.join(", "), valueWidth);
+ return admit(lines.length) ? lines.join("\n") : null;
+ };
+
+ const genres = admitJoined(meta.genres);
+ const director = admitJoined(meta.director);
+ const cast = admitJoined(meta.cast);
+
+ // Not gated on `cutoff` — see the doc comment above for why plot is the deliberate exception.
+ let plot: string | null = null;
+ if (meta.plot && remaining > 0) {
+ const lines = wordWrapLines(meta.plot, valueWidth);
+ if (lines.length <= remaining) {
+ plot = lines.join("\n");
+ } else {
+ const kept = lines.slice(0, remaining);
+ const lastIndex = kept.length - 1;
+ const last = kept[lastIndex];
+ // A hard-wrapped chunk is already sized to fit exactly, so it never looks "cut" on its own
+ // even though real text still follows it — the ellipsis has to be forced on here, or a
+ // capped plot reads as the whole plot rather than a fragment of one.
+ if (last !== undefined) {
+ kept[lastIndex] = ellipsizeToWidth(last, valueWidth);
+ }
+ plot = kept.join("\n");
+ }
+ }
+
+ return { rating, genres, director, cast, plot };
+}
diff --git a/src/ui/move.test.ts b/src/ui/move.test.ts
index 3da80d2b..c4b2b592 100644
--- a/src/ui/move.test.ts
+++ b/src/ui/move.test.ts
@@ -1,5 +1,12 @@
import { describe, it, expect } from "vitest";
-import { stickCursor, wrapStep, windowStart, resultsPanelOuter } from "./move";
+import {
+ stickCursor,
+ wrapStep,
+ windowStart,
+ resultsPanelOuter,
+ scrollStart,
+ stepRegion,
+} from "./move";
describe("stickCursor", () => {
const rows = (...hashes: string[]) => hashes.map((infoHash) => ({ infoHash }));
@@ -37,6 +44,63 @@ describe("windowStart", () => {
});
});
+describe("scrollStart", () => {
+ it("clamps at both ends instead of wrapping", () => {
+ expect(scrollStart(-3, 20, 5)).toBe(0);
+ expect(scrollStart(0, 20, 5)).toBe(0);
+ expect(scrollStart(7, 20, 5)).toBe(7);
+ // The last window shows the final row and nothing past it.
+ expect(scrollStart(15, 20, 5)).toBe(15);
+ expect(scrollStart(16, 20, 5)).toBe(15);
+ expect(scrollStart(999, 20, 5)).toBe(15);
+ });
+
+ it("pins to the top whenever the content fits", () => {
+ expect(scrollStart(4, 5, 5)).toBe(0);
+ expect(scrollStart(4, 2, 5)).toBe(0);
+ expect(scrollStart(0, 0, 5)).toBe(0);
+ });
+
+ it("does not centre the way windowStart does", () => {
+ // The two are not interchangeable, which is the reason both exist: a scrolled pane has no
+ // cursor to centre, so row 7 of a 20-row card sits at the top of the window and not in it.
+ expect(scrollStart(7, 20, 5)).toBe(7);
+ expect(windowStart(7, 20, 5)).toBe(5);
+ });
+});
+
+describe("stepRegion", () => {
+ it("walks the three columns in both directions", () => {
+ expect(stepRegion("sidebar", 1, true)).toBe("content");
+ expect(stepRegion("content", 1, true)).toBe("preview");
+ expect(stepRegion("preview", -1, true)).toBe("content");
+ expect(stepRegion("content", -1, true)).toBe("sidebar");
+ });
+
+ it("stops at both ends rather than wrapping around the screen", () => {
+ expect(stepRegion("sidebar", -1, true)).toBe("sidebar");
+ expect(stepRegion("preview", 1, true)).toBe("preview");
+ });
+
+ it("keeps → a no-op in the list when the pane is not on screen", () => {
+ // Exactly today's behaviour at 80 columns, on the Games tab, and with the pane toggled off.
+ expect(stepRegion("content", 1, false)).toBe("content");
+ expect(stepRegion("sidebar", 1, false)).toBe("content");
+ });
+
+ it("rescues focus that was inside the pane when the pane disappeared", () => {
+ // A resize or the `i` toggle can take the pane away while it holds the keyboard; every key
+ // that moves horizontally has to lead back out rather than into a column that is not drawn.
+ expect(stepRegion("preview", -1, false)).toBe("content");
+ expect(stepRegion("preview", 1, false)).toBe("content");
+ });
+
+ it("leaves the modal flag alone — it is a state, not a column", () => {
+ expect(stepRegion("help", 1, true)).toBe("help");
+ expect(stepRegion("help", -1, true)).toBe("help");
+ });
+});
+
describe("resultsPanelOuter", () => {
// The results view is: search bar (searchH rows) + a 1-row gap + the panel.
const searchH = 3;
diff --git a/src/ui/move.ts b/src/ui/move.ts
index b1c88194..3d320c1d 100644
--- a/src/ui/move.ts
+++ b/src/ui/move.ts
@@ -1,3 +1,5 @@
+import type { Region } from "./store";
+
/**
* Where the cursor lands after the list identity changes under it (a source
* streaming in mid-search, a sort cycle, the z filter). Follows the row the
@@ -26,6 +28,40 @@ export function windowStart(cursor: number, total: number, height: number): numb
return Math.max(0, Math.min(cursor - half, total - height));
}
+/**
+ * First visible row of a block the user scrolls directly, clamped to both ends.
+ *
+ * Not windowStart: that one centres a cursor, and a scrolled pane has no cursor — the offset *is*
+ * the state the keys move, so it must stay put when the content around it grows (a poster landing)
+ * or shrinks (a resize) rather than re-centring on something. Clamping here rather than at the
+ * keypress is what keeps a held-down arrow key from banking scroll it cannot spend.
+ */
+export function scrollStart(start: number, total: number, height: number): number {
+ if (total <= height) return 0;
+ return Math.max(0, Math.min(start, total - height));
+}
+
+/**
+ * The columns the arrow keys walk, left to right. "help" is deliberately absent: it is a modal
+ * flag, not a place, and stepping out of a modal is the modal's own job.
+ */
+const COLUMNS: readonly Region[] = ["sidebar", "content", "preview"];
+
+/**
+ * The region one step left (-1) or right (+1) of `region`, clamped at both ends — no wrap, so the
+ * ends of the row are dead keys rather than a jump across the screen.
+ *
+ * `previewOpen` is what keeps focus off a pane that is not on screen: with it false the walk stops
+ * at "content" in both directions, which also rescues focus that was already inside the pane when
+ * it disappeared (a resize below the split's width, the `i` toggle, a section without metadata).
+ */
+export function stepRegion(region: Region, step: -1 | 1, previewOpen: boolean): Region {
+ const at = COLUMNS.indexOf(region);
+ if (at < 0) return region;
+ const last = previewOpen ? COLUMNS.length - 1 : COLUMNS.indexOf("content");
+ return COLUMNS[Math.min(last, Math.max(0, at + step))] ?? region;
+}
+
/**
* Outer height of the results panel given the body's row budget.
*
diff --git a/src/ui/paneCard.test.ts b/src/ui/paneCard.test.ts
new file mode 100644
index 00000000..7e40db89
--- /dev/null
+++ b/src/ui/paneCard.test.ts
@@ -0,0 +1,186 @@
+import { describe, expect, it } from "vitest";
+import { planPaneLines } from "./paneCard";
+import { displayWidth } from "./textWidth";
+import { ICON } from "./theme";
+import type { Meta } from "../meta/types";
+
+// The card's row arithmetic without a render: MetaPane.test.tsx proves the pane draws what it is
+// given, and these prove what it is given. A wrapped credit that costs three rows instead of two
+// is a fused row in the frame, which is the hardest failure to read off a rendered snapshot.
+const META: Meta = {
+ imdbId: "tt0133093",
+ kind: "movie",
+ title: "The Matrix",
+ year: "1999",
+ rating: "8.7",
+ runtime: "136 min",
+ genres: ["Action", "Sci-Fi"],
+ cast: ["Keanu Reeves", "Laurence Fishburne", "Carrie-Anne Moss", "Hugo Weaving"],
+ director: ["Lana Wachowski", "Lilly Wachowski"],
+};
+
+// Kept separate from META so every assertion above about the facts card still measures the facts
+// card. Length is roughly what Cinemeta sends after the client's 800-column cap.
+const WITH_PLOT: Meta = {
+ ...META,
+ plot:
+ "A computer hacker learns from mysterious rebels about the true nature of his reality and " +
+ "his role in the war against its controllers, who farm humanity in a simulated world while " +
+ "harvesting the sleeping bodies for power in cell after cell.",
+};
+
+const WIDTH = 30;
+const INFINITE = Number.POSITIVE_INFINITY;
+
+const keys = (meta: Meta, width: number, budget: number): string[] =>
+ planPaneLines(meta, width, budget).map((l) => l.key);
+
+/** One entry per terminal row, the way MetaPane flattens the card before windowing it. */
+const rows = (meta: Meta, width: number, budget: number): string[] =>
+ planPaneLines(meta, width, budget).flatMap((l) => l.text.split("\n"));
+
+describe("planPaneLines", () => {
+ it("builds the whole card when nothing is competing for rows", () => {
+ // The focused pane's budget: it scrolls, so the window below decides what shows and the
+ // planner's only job is to lay out every line the row has.
+ expect(keys(META, WIDTH, INFINITE)).toEqual(["title", "facts", "genres", "director", "cast"]);
+ const text = rows(META, WIDTH, INFINITE);
+ expect(text[0]).toBe("The Matrix");
+ expect(text[1]).toBe(`1999 ${ICON.dot} 8.7 ${ICON.dot} 136 min`);
+ for (const line of text) expect(displayWidth(line)).toBeLessThanOrEqual(WIDTH);
+ });
+
+ it("spends a one-row pane on the title rather than dropping it", () => {
+ // The title is the line that says *which* work this is; a card with no room for it says
+ // nothing at all.
+ const lines = planPaneLines(META, WIDTH, 1);
+ expect(lines.map((l) => l.key)).toEqual(["title"]);
+ expect(lines[0]?.tone).toBe("title");
+ });
+
+ it("forces the ellipsis onto a capped title, which otherwise reads as the whole one", () => {
+ // A wrapped line fills its width exactly, so the cut is invisible without it.
+ const long = { ...META, title: "The Lord of the Rings: The Fellowship of the Ring" };
+ const lines = planPaneLines(long, 20, 1);
+ expect(lines).toHaveLength(1);
+ expect(lines[0]?.text).toContain("…");
+ expect(displayWidth(lines[0]?.text ?? "")).toBeLessThanOrEqual(20);
+ });
+
+ it("cuts the card once at the bottom rather than leaving a hole in the middle", () => {
+ // 5 rows: title, facts, genres and the two the director credit wraps to. The cast credit
+ // wraps to three at this width and there is nothing left to spend on it.
+ expect(keys(META, WIDTH, 5)).toEqual(["title", "facts", "genres", "director"]);
+ expect(rows(META, WIDTH, 5)).toHaveLength(5);
+ });
+
+ it("holds the cutoff once something has been cut, even for a line that would have fitted", () => {
+ // The alternative is a hole in the middle of the card: a two-row director credit dropped and
+ // the one-row cast credit under it kept, which reads as a pane that lost a field rather than
+ // as one that ran out of room.
+ const short = { ...META, cast: ["Neo"] };
+ expect(keys(short, WIDTH, INFINITE)).toContain("cast");
+ expect(keys(short, WIDTH, 4)).toEqual(["title", "facts", "genres"]);
+ });
+
+ it("never spends more rows than it was given", () => {
+ for (let budget = 0; budget <= 12; budget++) {
+ expect(rows(META, WIDTH, budget).length, `budget ${budget}`).toBeLessThanOrEqual(budget);
+ }
+ });
+
+ it("treats a field the provider never sent as absent, not as a fit failure", () => {
+ // Cinemeta sends no director for most series and no runtime for plenty of titles. An empty
+ // field costs nothing and must not end the card the way an overlong one does.
+ const series: Meta = {
+ ...META,
+ kind: "series",
+ runtime: undefined,
+ director: [],
+ episode: { season: 3, number: 7, title: "Winter Is Coming" },
+ };
+ expect(keys(series, WIDTH, INFINITE)).toEqual(["title", "facts", "episode", "genres", "cast"]);
+ // The cast still lands with the director's rows never having been claimed by anything.
+ expect(keys(series, WIDTH, 7)).toContain("cast");
+ });
+
+ it("answers an empty card for a pane with no rows to give", () => {
+ expect(planPaneLines(META, WIDTH, 0)).toEqual([]);
+ expect(planPaneLines(META, WIDTH, -3)).toEqual([]);
+ });
+
+ it("leaves a card with no plot exactly as it was", () => {
+ expect(keys(META, WIDTH, INFINITE)).not.toContain("plot");
+ expect(keys({ ...META, plot: "" }, WIDTH, INFINITE)).toEqual(keys(META, WIDTH, INFINITE));
+ });
+
+ it("gives the focused pane every line of the plot, none of them cut", () => {
+ const lines = planPaneLines(WITH_PLOT, WIDTH, INFINITE);
+ expect(lines.at(-1)?.key).toBe("plot");
+ const plot = lines.at(-1)?.text.split("\n") ?? [];
+ expect(plot.length).toBeGreaterThan(3);
+ for (const line of plot) {
+ expect(displayWidth(line)).toBeLessThanOrEqual(WIDTH);
+ expect(line).not.toContain("…");
+ }
+ expect(plot.join(" ")).toContain("cell");
+ });
+
+ it("spends the leftovers on the plot even when the cutoff dropped the credits", () => {
+ // The plot is the one field with no natural length, so it is the one that fills a gap rather
+ // than being refused whole: a card that lost its cast credit to a two-row overflow still has
+ // that row to say what the film is about.
+ const lines = planPaneLines(WITH_PLOT, WIDTH, 4);
+ expect(lines.map((l) => l.key)).toEqual(["title", "facts", "genres", "plot"]);
+ expect(rows(WITH_PLOT, WIDTH, 4)).toHaveLength(4);
+ });
+
+ it("forces the ellipsis onto the last kept plot line, which fills its width exactly", () => {
+ const plot = planPaneLines(WITH_PLOT, WIDTH, 6).find((l) => l.key === "plot");
+ const kept = plot?.text.split("\n") ?? [];
+ expect(kept.length).toBeGreaterThan(0);
+ expect(kept.at(-1)).toContain("…");
+ for (const line of kept) expect(displayWidth(line)).toBeLessThanOrEqual(WIDTH);
+ });
+
+ it("omits the plot row entirely when the facts spent everything", () => {
+ // No row at all rather than an empty one: a blank line at the bottom of the pane reads as a
+ // field that failed to load.
+ expect(keys(WITH_PLOT, WIDTH, 3)).toEqual(["title", "facts", "genres"]);
+ });
+
+ it("wraps a CJK plot by display column, not by string length", () => {
+ // Cinemeta returns CJK synopses for the anime titles nyaa and subsplease index, and each of
+ // those characters is one string unit but two terminal columns — the miscount that fused rows
+ // in the detail panel before wordWrapLines measured width instead of length.
+ const cjk = {
+ ...META,
+ plot: "本作は刑務所を舞台にした友情と希望の物語である。".repeat(8),
+ };
+ const plot = planPaneLines(cjk, WIDTH, INFINITE).find((l) => l.key === "plot");
+ for (const line of plot?.text.split("\n") ?? []) {
+ expect(displayWidth(line)).toBeLessThanOrEqual(WIDTH);
+ }
+ });
+
+ it("wraps an astral-plane emoji plot without splitting a surrogate pair", () => {
+ const emoji = { ...META, plot: "🎬 A film 🍿 about 👨👩👧 a family ".repeat(6) };
+ const plot = planPaneLines(emoji, WIDTH, INFINITE).find((l) => l.key === "plot");
+ const lines = plot?.text.split("\n") ?? [];
+ expect(lines.length).toBeGreaterThan(1);
+ for (const line of lines) {
+ expect(displayWidth(line)).toBeLessThanOrEqual(WIDTH);
+ // A pair cut down the middle surfaces as the replacement character, not as an exception.
+ expect(line).not.toContain("�");
+ }
+ });
+
+ it("keeps only the first four cast names, tagged so the two name lists are told apart", () => {
+ const crowded = { ...META, cast: ["A Aa", "B Bb", "C Cc", "D Dd", "E Ee", "F Ff"] };
+ const cast = planPaneLines(crowded, WIDTH, INFINITE).find((l) => l.key === "cast");
+ expect(cast?.text).toBe("Cast A Aa, B Bb, C Cc, D Dd");
+ expect(planPaneLines(crowded, WIDTH, INFINITE).find((l) => l.key === "director")?.text).toBe(
+ "Dir Lana Wachowski, Lilly\nWachowski",
+ );
+ });
+});
diff --git a/src/ui/paneCard.ts b/src/ui/paneCard.ts
new file mode 100644
index 00000000..00f262e0
--- /dev/null
+++ b/src/ui/paneCard.ts
@@ -0,0 +1,142 @@
+/**
+ * The info pane's text card: which lines a row's metadata wants and which of them fit.
+ *
+ * A pure module the component and its tests both read from, mirroring previewLayout.ts and
+ * helpLayout.ts — the row arithmetic is the part most worth pinning without a render, and the
+ * previews script needs the same card the app draws rather than a hand-rolled second copy of it.
+ */
+
+import { ellipsizeToWidth, wordWrapLines } from "./textWidth";
+import { ICON } from "./theme";
+import type { Meta } from "../meta/types";
+
+/** Cast credits worth the rows in a pane this narrow; past the fourth name nobody is reading. */
+export const CAST_SHOWN = 4;
+
+/**
+ * A row's text card: which lines it wants, in the order it wants them, already wrapped.
+ *
+ * `title` is the one line rendered in full colour; everything under it is dim, so the pane reads
+ * as one quiet block the eye can skip rather than a second thing competing with the list.
+ *
+ * `text` is one block, newlines and all, until the pane flattens it: scrolling counts rows, and a
+ * three-line cast credit is three rows to a window that has to cut between them.
+ */
+export interface PaneLine {
+ readonly key: string;
+ readonly text: string;
+ readonly tone: "title" | "dim";
+}
+
+const pad2 = (n: number): string => String(n).padStart(2, "0");
+
+/** `1994 · 8.7 · 142 min`, with whatever Cinemeta actually sent. */
+function factsLine(meta: Meta): string {
+ return [meta.year, meta.rating, meta.runtime]
+ .filter((v): v is string => v !== undefined && v !== "")
+ .join(` ${ICON.dot} `);
+}
+
+/** `S03E07 · Winter Is Coming` — only when the release named an episode we resolved. */
+function episodeLine(meta: Meta): string {
+ const ep = meta.episode;
+ if (ep === undefined) return "";
+ const code = `S${pad2(ep.season)}E${pad2(ep.number)}`;
+ return ep.title ? `${code} ${ICON.dot} ${ep.title}` : code;
+}
+
+/**
+ * Two lists of names in a row are indistinguishable without a word saying which is which, and the
+ * pane has no room for a label column like the detail view's. The tag rides inside the wrapped
+ * text so it is measured with it, not added to a line already sized to fit.
+ */
+function tagged(tag: string, values: readonly string[]): string {
+ return values.length === 0 ? "" : `${tag} ${values.join(", ")}`;
+}
+
+/**
+ * Which lines of the card fit in `budget` terminal rows, wrapped to `width` display columns.
+ *
+ * Same contract as the detail panel's planMetaRows, for the same reason: the pane has a fixed
+ * height and Ink clips an overflowing box by squeezing rows through Yoga's shrink math, which
+ * drops and fuses lines anywhere in the block rather than cutting the one that overflowed. Every
+ * line is therefore admitted only once its wrapped height is known.
+ *
+ * The title is capped rather than dropped — it is the line that says *which* work this is, so a
+ * pane with room for one row spends it there. Everything after it shares one cutoff: the first
+ * present line that does not fit ends the card, so a short pane degrades as a clean cut at the
+ * bottom instead of a hole in the middle. A field Cinemeta simply did not send (no director for
+ * most series, no runtime for plenty of titles) is absent, not a fit failure, and never triggers
+ * that cutoff.
+ *
+ * The plot is evaluated last and outside that cutoff, because it is the one field with no natural
+ * length: it exists to fill whatever the facts above it left, so it is measured against `remaining`
+ * on its own terms and truncated rather than admitted or refused whole.
+ *
+ * A focused pane passes an infinite budget: it scrolls, so nothing is competing for rows and the
+ * whole card is built, with the window — not this function — deciding what is on screen.
+ */
+export function planPaneLines(meta: Meta, width: number, budget: number): PaneLine[] {
+ const out: PaneLine[] = [];
+ if (budget <= 0) return out;
+ let remaining = budget;
+ let cutoff = false;
+
+ const titleLines = wordWrapLines(meta.title, width);
+ if (titleLines.length > 0) {
+ const kept = titleLines.slice(0, remaining);
+ if (titleLines.length > remaining) {
+ const lastIndex = kept.length - 1;
+ const last = kept[lastIndex];
+ // A wrapped line fills its width exactly, so a capped title reads as the whole title
+ // unless the ellipsis is forced onto it.
+ if (last !== undefined) kept[lastIndex] = ellipsizeToWidth(last, width);
+ cutoff = true;
+ }
+ remaining -= kept.length;
+ out.push({ key: "title", text: kept.join("\n"), tone: "title" });
+ }
+
+ const admit = (key: string, text: string): void => {
+ if (cutoff || text === "") return;
+ const lines = wordWrapLines(text, width);
+ if (lines.length === 0) return;
+ if (lines.length > remaining) {
+ cutoff = true;
+ return;
+ }
+ remaining -= lines.length;
+ out.push({ key, text: lines.join("\n"), tone: "dim" });
+ };
+
+ admit("facts", factsLine(meta));
+ // Directly under the title, not at the end of the card: on a series row the episode is half of
+ // what identifies the release, and the shared cutoff means whatever sits last is the first
+ // thing a short pane gives up.
+ admit("episode", episodeLine(meta));
+ admit("genres", meta.genres.join(", "));
+ admit("director", tagged("Dir", meta.director));
+ admit("cast", tagged("Cast", meta.cast.slice(0, CAST_SHOWN)));
+
+ // The plot is the deliberate exception to the cutoff, exactly as the detail panel's planMetaRows
+ // treats it: it is the only field with no natural length, so it takes whatever the fixed-height
+ // facts above it underspent — down to nothing, which is a legitimate answer on a short pane —
+ // rather than being dropped whole because a two-row credit above it did not fit. A focused pane
+ // passes an infinite budget, so this is the branch that hands the window the whole synopsis.
+ if (meta.plot !== undefined && meta.plot !== "" && remaining > 0) {
+ const lines = wordWrapLines(meta.plot, width);
+ if (lines.length > 0) {
+ const kept = lines.slice(0, remaining);
+ if (lines.length > remaining) {
+ const lastIndex = kept.length - 1;
+ const last = kept[lastIndex];
+ // A hard-wrapped chunk is already sized to fit exactly, so it never looks "cut" on its own
+ // even though real text still follows it — the ellipsis has to be forced on here, or a
+ // capped plot reads as the whole plot rather than a fragment of one.
+ if (last !== undefined) kept[lastIndex] = ellipsizeToWidth(last, width);
+ }
+ out.push({ key: "plot", text: kept.join("\n"), tone: "dim" });
+ }
+ }
+ return out;
+}
diff --git a/src/ui/previewLayout.test.ts b/src/ui/previewLayout.test.ts
new file mode 100644
index 00000000..d72d040a
--- /dev/null
+++ b/src/ui/previewLayout.test.ts
@@ -0,0 +1,298 @@
+import { describe, expect, it } from "vitest";
+import {
+ COLUMN_GAP,
+ MAX_TEXT_COLS,
+ MIN_LIST_WIDTH,
+ MIN_TEXT_COLS,
+ PANE_GAP,
+ posterBudget,
+ previewLayout,
+ splitTextCols,
+} from "./previewLayout";
+import { fitCells } from "../meta/image";
+import { TEST_CONTENT_WIDTH } from "./testHarness";
+
+describe("preview layout tiers", () => {
+ it("hands out the documented pane and list widths at each tier boundary", () => {
+ expect(previewLayout(100)).toEqual({ pane: 34, list: 65, poster: true });
+ expect(previewLayout(86)).toEqual({ pane: 28, list: 57, poster: true });
+ expect(previewLayout(73)).toEqual({ pane: 20, list: 52, poster: false });
+ });
+
+ it("steps down one tier at a time, one column below each boundary", () => {
+ expect(previewLayout(99)?.pane).toBe(28);
+ expect(previewLayout(85)?.pane).toBe(20);
+ expect(previewLayout(72)).toBeNull();
+ });
+
+ it("drops the poster only on the narrowest tier", () => {
+ expect(previewLayout(99)?.poster).toBe(true);
+ expect(previewLayout(85)?.poster).toBe(false);
+ });
+
+ it("gives every extra column to the list, never to the pane", () => {
+ // The pane is pinned per tier so its card never rewraps on resize; the list absorbs the rest
+ // through the name column's flexGrow, exactly as it already does without a pane.
+ const wide = previewLayout(160);
+ expect(wide?.pane).toBe(34);
+ expect(wide?.list).toBe(160 - 34 - PANE_GAP);
+ });
+
+ it("never starves the list below MIN_LIST_WIDTH, at any width the pane exists", () => {
+ for (let w = 73; w <= 300; w += 1) {
+ const pl = previewLayout(w);
+ expect(pl, `contentWidth ${w} should have a layout`).not.toBeNull();
+ if (pl === null) continue;
+ expect(pl.list, `list at contentWidth ${w}`).toBeGreaterThanOrEqual(MIN_LIST_WIDTH);
+ // The three widths have to add up: a rounding slip here is a pane that overlaps the list's
+ // right border, which Ink renders as a fused row rather than an error.
+ expect(pl.list + PANE_GAP + pl.pane).toBe(w);
+ }
+ });
+
+ it("hides itself entirely below the narrowest tier", () => {
+ for (const w of [72, 61, 40, 24, 0, -5]) expect(previewLayout(w)).toBeNull();
+ });
+
+ // The 80-column terminal the whole test suite renders at. The pane must not exist there, or
+ // every existing frame assertion in Results.test.tsx is measuring a different layout.
+ it("is absent at the harness's own 80-column content width", () => {
+ expect(previewLayout(TEST_CONTENT_WIDTH)).toBeNull();
+ });
+});
+
+describe("preview layout, focused", () => {
+ it("gives the pane every column the list can spare, up to what it can use", () => {
+ // The reverse of the unfocused rule: the user has said the pane is what they are reading, so
+ // the list falls back to the narrowest width it is allowed to have and the pane takes the rest.
+ for (const w of [100, 110, 113]) {
+ const pl = previewLayout(w, true);
+ expect(pl, `contentWidth ${w}`).not.toBeNull();
+ if (pl === null) continue;
+ expect(pl.list, `list at ${w}`).toBe(MIN_LIST_WIDTH);
+ expect(pl.pane, `pane at ${w}`).toBe(w - PANE_GAP - MIN_LIST_WIDTH);
+ }
+ });
+
+ it("stops widening the pane once it has more columns than it can spend", () => {
+ // With no height given there is no poster to seat a card beside, so the pane is only ever a
+ // card and MAX_TEXT_COLS + Panel's frame is the whole of it: a text measure past the
+ // mid-fifties stops helping, because the eye loses the line it is returning to. The list,
+ // whose name column truncates at MIN_LIST_WIDTH, has an obvious use for every column past
+ // that. (Handed a height, the pane can spend more — see "focused pane width, by height".)
+ expect(previewLayout(160, true)).toEqual({ pane: 60, list: 99, poster: true });
+ expect(previewLayout(200, true)).toEqual({ pane: 60, list: 139, poster: true });
+ // 113 is the last width where the list can still be the one at its minimum; one column later
+ // the pane is at the cap and the list starts growing again.
+ expect(previewLayout(113, true)?.pane).toBe(60);
+ expect(previewLayout(114, true)).toEqual({ pane: 60, list: 53, poster: true });
+ });
+
+ it("holds the unfocused widths where there is nothing to give", () => {
+ // 73 is the one width at which the list is already at MIN_LIST_WIDTH, so focusing there is a
+ // no-op rather than a pane that grows by starving the list.
+ expect(previewLayout(73, true)).toEqual(previewLayout(73, false));
+ // Everywhere else the list is holding columns above its minimum, and focus is what hands them
+ // over: 86 has 5 to give, and one column above a tier boundary the pane takes that one too.
+ expect(previewLayout(86, true)?.pane).toBe(33);
+ expect(previewLayout(101, true)?.pane).toBe(48);
+ expect(previewLayout(101, false)?.pane).toBe(34);
+ });
+
+ it("never shrinks the pane or the list below what browsing already guaranteed", () => {
+ for (let w = 73; w <= 300; w += 1) {
+ const idle = previewLayout(w);
+ const read = previewLayout(w, true);
+ expect(read, `contentWidth ${w}`).not.toBeNull();
+ if (idle === null || read === null) continue;
+ expect(read.pane, `pane at ${w}`).toBeGreaterThanOrEqual(idle.pane);
+ expect(read.list, `list at ${w}`).toBeGreaterThanOrEqual(MIN_LIST_WIDTH);
+ // The same arithmetic the unfocused split is pinned on: a column lost between the two
+ // panels is a fused row, not an error.
+ expect(read.list + PANE_GAP + read.pane).toBe(w);
+ expect(read.poster).toBe(idle.poster);
+ }
+ });
+
+ it("cannot conjure a pane at a width that has none", () => {
+ // Focus is not a way in: previewLayout answering null is what makes → a no-op at 80 columns.
+ for (const w of [72, 40, 0]) expect(previewLayout(w, true)).toBeNull();
+ expect(previewLayout(TEST_CONTENT_WIDTH, true)).toBeNull();
+ });
+});
+
+describe("poster budget", () => {
+ it("reserves the text card's rows and spends every column the pane has on the art", () => {
+ // 34-wide pane: 30 columns inside Panel's frame. 20 inner rows less the 7 the text card
+ // always claims leaves 13.
+ expect(posterBudget(34, 20)).toEqual({ cols: 30, rows: 13 });
+ // 28-wide pane: 24 inside the frame, and the same rows — width is the tier's answer, height
+ // is the pane's.
+ expect(posterBudget(28, 20)).toEqual({ cols: 24, rows: 13 });
+ });
+
+ it("drops the art rather than the facts on a short pane", () => {
+ // 13 inner rows: 6 left after the text card, the last height that still earns a poster.
+ expect(posterBudget(34, 13)).toEqual({ cols: 30, rows: 6 });
+ expect(posterBudget(34, 12)).toBeNull();
+ expect(posterBudget(34, 7)).toBeNull();
+ expect(posterBudget(34, 0)).toBeNull();
+ });
+
+ it("answers null rather than a negative cell budget for a pane with no room inside its frame", () => {
+ expect(posterBudget(4, 40)).toBeNull();
+ expect(posterBudget(0, 40)).toBeNull();
+ });
+
+ it("makes a focused poster give up columns, not rows, once the card can sit beside it", () => {
+ // 48-wide pane: 44 inside the frame, and the art hands back exactly COLUMN_GAP + MIN_TEXT_COLS
+ // of that so the card has a column of its own. What it keeps is the pane's entire height —
+ // capping the art by height instead is what let a tall terminal grow the poster until nothing
+ // could fit next to it, which is how a 120-column terminal ended up stacked with one row of
+ // text.
+ expect(posterBudget(48, 13, true)).toEqual({ cols: 15, rows: 13 });
+ expect(posterBudget(48, 17, true)).toEqual({ cols: 15, rows: 17 });
+ // A wider pane spends the difference on the picture, not on a wider card: MAX_TEXT_COLS is
+ // what previewLayout stopped widening the pane at, so the card is already at its measure.
+ expect(posterBudget(60, 17, true)).toEqual({ cols: 27, rows: 17 });
+ expect(posterBudget(84, 17, true)).toEqual({ cols: 51, rows: 17 });
+ });
+
+ it("falls back to giving up rows where nothing can sit beside the art", () => {
+ // 34-wide pane: 30 inside the frame, which leaves 1 column beside a card at MIN_TEXT_COLS —
+ // not a picture. So the card takes its rows from the bottom instead, and what it takes is
+ // FOCUSED_TEXT_RESERVE: the eight rows it is guaranteed plus the spacer and the scroll
+ // affordance, neither of which is card.
+ expect(posterBudget(34, 20, true)).toEqual({ cols: 30, rows: 10 });
+ expect(posterBudget(34, 17, true)).toEqual({ cols: 30, rows: 7 });
+ // One column either side of the boundary: 41 seats an 8-column poster beside the card (the
+ // narrowest picture MIN_POSTER_COLS allows), 40 cannot and stacks.
+ expect(posterBudget(41, 17, true)).toEqual({ cols: 8, rows: 17 });
+ expect(posterBudget(40, 17, true)).toEqual({ cols: 36, rows: 7 });
+ });
+
+ it("still refuses art a focused pane has no room to read around", () => {
+ // Stacked, the floor is MIN_POSTER_ROWS on top of the whole reserve: 16 inner rows, six of
+ // picture with the ten the card and its chrome are guaranteed. Below it the pane drops the art
+ // and spends every row on the card, which is the trade the guarantee exists to make.
+ expect(posterBudget(34, 16, true)).toEqual({ cols: 30, rows: 6 });
+ expect(posterBudget(34, 15, true)).toBeNull();
+ expect(posterBudget(34, 8, true)).toBeNull();
+ // Beside the card the floor is MIN_POSTER_ROWS alone, since the art is giving up columns
+ // rather than rows: a six-row pane still seats a six-row picture next to a full-height card.
+ expect(posterBudget(48, 6, true)).toEqual({ cols: 15, rows: 6 });
+ expect(posterBudget(48, 5, true)).toBeNull();
+ expect(posterBudget(4, 40, true)).toBeNull();
+ });
+});
+
+// Change 1 of the review: the pane's width is bounded by the card's *measure*, not by the pane.
+// Stacked those are the same number and nothing moves; side by side the pane must also carry a
+// poster, and how wide that poster comes out is a question about the pane's height.
+describe("focused pane width, by height", () => {
+ it("grows the pane to seat the card beside the poster, and not one column further", () => {
+ // A 30-row terminal leaves the pane 17 inner rows, where a 2:3 poster is 23 columns wide.
+ // 23 + COLUMN_GAP + MAX_TEXT_COLS + Panel's frame is 84, and the list keeps everything past it.
+ expect(previewLayout(141, true, 17)).toEqual({ pane: 84, list: 56, poster: true });
+ expect(previewLayout(181, true, 17)).toEqual({ pane: 84, list: 96, poster: true });
+ // A shorter pane wants a narrower poster and therefore a narrower pane.
+ expect(previewLayout(141, true, 11)).toEqual({ pane: 76, list: 64, poster: true });
+ // And without a height there is no poster to seat anything beside, so the pane is a card and
+ // stops at MAX_TEXT_COLS inside its frame — the width every caller got before.
+ expect(previewLayout(141, true)).toEqual({ pane: 60, list: 80, poster: true });
+ });
+
+ it("keeps the list at its minimum rather than granting a width it cannot afford", () => {
+ // 120 terminal columns is contentWidth 101, and the list needs 52 of them: the pane genuinely
+ // cannot exceed 48 whatever it would like. The terminal is the constraint there, not the cap.
+ expect(previewLayout(101, true, 17)).toEqual({ pane: 48, list: 52, poster: true });
+ for (let w = 73; w <= 300; w += 1) {
+ for (const rows of [0, 6, 11, 17, 27]) {
+ const pl = previewLayout(w, true, rows);
+ expect(pl, `contentWidth ${w}`).not.toBeNull();
+ if (pl === null) continue;
+ expect(pl.list, `list at ${w}/${rows}`).toBeGreaterThanOrEqual(MIN_LIST_WIDTH);
+ expect(pl.list + PANE_GAP + pl.pane, `sum at ${w}/${rows}`).toBe(w);
+ const idle = previewLayout(w);
+ expect(pl.pane, `pane at ${w}/${rows}`).toBeGreaterThanOrEqual(idle?.pane ?? 0);
+ }
+ }
+ });
+
+ it("refuses the wider grant where the extra columns would not become card", () => {
+ // contentWidth 94 leaves 41 for the pane — 37 inside the frame, exactly MIN_POSTER_COLS plus
+ // the gap plus MIN_TEXT_COLS — so the grant buys a real split. One column less and it would
+ // only buy a wider stacked pane with dead columns beside a card already at its measure, so
+ // the list keeps it. This is the off-by-one at the bottom of the split.
+ expect(previewLayout(94, true, 17)?.pane).toBe(41);
+ expect(previewLayout(93, true, 17)?.pane).toBe(40);
+ expect(splitTextCols(41 - 4, 8)).toBe(MIN_TEXT_COLS);
+ expect(splitTextCols(40 - 4, 8)).toBeNull();
+ });
+});
+
+// The focused pane's own split: poster on the left, card on the right, with the arithmetic kept
+// here rather than in the component so the boundary can be pinned without a render.
+describe("side-by-side split", () => {
+ it("gives the card every column the art and the gap did not take", () => {
+ expect(splitTextCols(56, 20)).toBe(56 - 20 - COLUMN_GAP);
+ expect(splitTextCols(49, 20)).toBe(MIN_TEXT_COLS);
+ });
+
+ it("falls back to stacking one column below the readable measure", () => {
+ // The off-by-one that would live exactly here: 49 inner columns beside a 20-column poster
+ // leaves the card its floor and splits; 48 leaves it one short and must stack instead of
+ // shaving a column off the plot.
+ expect(splitTextCols(49, 20)).toBe(MIN_TEXT_COLS);
+ expect(splitTextCols(48, 20)).toBeNull();
+ // Same boundary walked from the art's side rather than the pane's.
+ expect(splitTextCols(49, 21)).toBeNull();
+ });
+
+ it("has nothing to sit beside when no art rendered", () => {
+ // A poster still in flight, refused by the host sniff or rejected by the decoder: the card
+ // keeps the whole pane, which is the layout it has always had without art.
+ expect(splitTextCols(56, 0)).toBeNull();
+ expect(splitTextCols(56, -1)).toBeNull();
+ });
+
+ it("never splits a pane so narrow the card would be shorter than the gap", () => {
+ // The narrowest tier's 16 inner columns, and the widest browsing tier's 30: both stack, which
+ // is why the split is a focused-only answer.
+ expect(splitTextCols(16, 12)).toBeNull();
+ expect(splitTextCols(30, 20)).toBeNull();
+ });
+
+ it("always leaves the card its measure once the budget seated it beside the art", () => {
+ // The budget's whole job is to make this unconditional: whatever fitCells does with the box —
+ // narrow it for aspect, shrink it for a small rendition — the art can only ever come back at
+ // or under the width it was handed, so what is left is at or above MIN_TEXT_COLS. The split
+ // decision downstream cannot be surprised.
+ for (const pane of [41, 48, 53, 60, 76, 84]) {
+ for (const rows of [6, 11, 13, 17, 27]) {
+ const budget = posterBudget(pane, rows, true);
+ expect(budget, `${pane}x${rows}`).not.toBeNull();
+ if (budget === null) continue;
+ const fit = fitCells(120, 180, budget.cols, budget.rows);
+ expect(splitTextCols(pane - 4, fit.cols), `${pane}x${rows}`).toBeGreaterThanOrEqual(
+ MIN_TEXT_COLS,
+ );
+ }
+ }
+ });
+
+ it("reclaims the gutter a height-capped poster leaves, at the pane the app actually grants", () => {
+ // The measurement the layout exists for, re-derived after the width cap moved: a 30-row
+ // terminal at 160 columns is contentWidth 141, and the pane it grants is 84 — 80 inside the
+ // frame. A 2:3 poster keeps its full 17 rows there and comes back 23 wide, leaving the card
+ // exactly MAX_TEXT_COLS. Under the old 60-column cap the same pane held 56 columns and the
+ // card would have had 32.
+ const pane = previewLayout(141, true, 17);
+ expect(pane?.pane).toBe(84);
+ const budget = posterBudget(84, 17, true);
+ expect(budget).toEqual({ cols: 51, rows: 17 });
+ const fit = fitCells(120, 180, budget?.cols ?? 0, budget?.rows ?? 0);
+ expect(fit).toEqual({ cols: 23, rows: 17 });
+ expect(splitTextCols(80, fit.cols)).toBe(MAX_TEXT_COLS);
+ });
+});
diff --git a/src/ui/previewLayout.ts b/src/ui/previewLayout.ts
new file mode 100644
index 00000000..fc2ba2ef
--- /dev/null
+++ b/src/ui/previewLayout.ts
@@ -0,0 +1,278 @@
+/**
+ * How the results view splits its content width between the list and the info pane beside it.
+ *
+ * The pane is a bonus, never a cost: the list keeps a usable width at every tier and the pane
+ * simply stops existing below the width where it would start eating into that. Mirrors
+ * helpLayout.ts — a pure module that measures a layout so both the component and its tests read
+ * the same numbers from one place.
+ */
+
+import { fitCells } from "../meta/image";
+
+/**
+ * The narrowest list worth rendering, derived from the columns the list already has: gutter 2 +
+ * num 2 + 1 + name (min 18) + 1 + size 10 + 1 + seed 9 + 1 + src 4 = 49, plus Panel's own frame
+ * of 4. Below this the name column — the only one that flexes — starts truncating release names
+ * to the point where the list stops answering the question the user is scrolling it to answer.
+ */
+export const MIN_LIST_WIDTH = 52;
+
+/** One blank column between the list panel and the pane, so their borders never touch. */
+export const PANE_GAP = 1;
+
+/** Panel's own frame around whatever it holds: a 1-column border and 1 column of padding a side. */
+const PANE_FRAME = 4;
+
+/**
+ * Widest the card is ever wrapped to, in terminal columns.
+ *
+ * This bounds the *text measure*, not the pane. Prose past the mid-fifties stops being comfortable
+ * — the eye loses the line it is returning to on the wrap — so 56 columns is where the card stops
+ * getting wider however many columns the terminal has. It is still nearly twice the 30 the widest
+ * browsing tier gives it.
+ *
+ * The distinction matters because the pane is not always just a card. Stacked, the pane *is* the
+ * text column and 56 + PANE_FRAME = 60 is the whole pane, which is what this number used to say
+ * outright. Side by side the text is only part of the width, so a 60-column cap on the pane caps
+ * the card at roughly `60 - artCols - COLUMN_GAP` — 15 columns or fewer at a 120-column terminal,
+ * which forced the stacked layout at exactly the sizes the split was built for. Bounding the
+ * measure instead lets the pane grow to `artCols + COLUMN_GAP + this`, and not one column past it:
+ * every column beyond goes back to the list, which does have a use for them.
+ */
+export const MAX_TEXT_COLS = 56;
+
+export interface PreviewLayout {
+ pane: number;
+ list: number;
+ poster: boolean;
+}
+
+/**
+ * Fixed pane widths, widest tier first. The pane is what is pinned and the list takes whatever is
+ * left, rather than the other way round: a card whose width drifts with the terminal would rewrap
+ * its title on every resize, while a list is built to absorb width through the name column's
+ * `flexGrow`. Each tier's minimum leaves the list at exactly MIN_LIST_WIDTH or better — 34+1+65,
+ * 28+1+57, 20+1+52 — which is the property previewLayout.test.ts pins.
+ *
+ * The 20-column tier drops the poster: 16 usable columns of art is a smear, but the same 16
+ * columns still carry a title, a year and a genre list, which is the whole point of the pane.
+ */
+const TIERS: readonly { readonly min: number; readonly pane: number; readonly poster: boolean }[] = [
+ { min: 100, pane: 34, poster: true },
+ { min: 86, pane: 28, poster: true },
+ { min: 73, pane: 20, poster: false },
+];
+
+/**
+ * The split for a given content width, or null when the terminal is too narrow to hold both — in
+ * which case the caller renders the list alone at full width. Auto-hiding rather than shrinking
+ * is deliberate: an 80-column terminal is the floor torlink targets, and at that size the list is
+ * already spending every column it has.
+ *
+ * `focused` is the pane holding the keyboard, which reverses who gets the spare columns: browsing,
+ * the list absorbs them and the pane stays pinned so its card never rewraps under the cursor;
+ * reading, the user has said the pane is what they are looking at, so the list gives back
+ * everything above MIN_LIST_WIDTH — up to what the pane can actually spend, past which it has
+ * nothing left to do with a column and the list keeps it. Both answers come out of the same tier
+ * table, and both keep `list + PANE_GAP + pane === contentWidth`. At contentWidth 73 — the bottom
+ * of the narrowest tier, where the list is already at MIN_LIST_WIDTH — there is nothing to give and
+ * focusing changes no widths at all, which is the honest outcome rather than a pane that grows by
+ * starving the list.
+ *
+ * "What the pane can spend" is where `paneInnerRows` comes in, and why a width function takes a
+ * height at all: a focused pane that will seat its card *beside* the poster needs room for both,
+ * and how wide the poster comes out is a question about the pane's height, not its width — a
+ * height-capped poster is narrowed by fitCells to keep its aspect. Callers that have no pane yet
+ * (App only asks whether one exists) can leave it out; the answer is then the stacked width, which
+ * is the width a pane with no art gets anyway.
+ */
+export function previewLayout(
+ contentWidth: number,
+ focused = false,
+ paneInnerRows = 0,
+): PreviewLayout | null {
+ const tier = TIERS.find((t) => contentWidth >= t.min);
+ if (tier === undefined) return null;
+ const laid = (pane: number): PreviewLayout => ({
+ pane,
+ list: contentWidth - pane - PANE_GAP,
+ poster: tier.poster,
+ });
+ if (!focused) return laid(tier.pane);
+
+ const spare = contentWidth - PANE_GAP - MIN_LIST_WIDTH;
+ // The outer max is a floor, not a second opinion: focusing may only ever widen the pane, so no
+ // focused width is worse than the browsing width it replaced, whatever the cap says.
+ const grant = (cap: number): number => Math.max(tier.pane, Math.min(cap, spare));
+
+ // What a poster of the usual shape comes out at when it has this pane's whole height and no
+ // width worth mentioning to fight over — the number the side-by-side layout has to seat a card
+ // next to. Width is deliberately unbounded here (contentWidth is only a sane ceiling): the point
+ // is to ask the height what it wants before deciding how wide the pane must be to grant it.
+ const art = tier.poster ? fitCells(POSTER_W, POSTER_H, contentWidth, paneInnerRows) : { cols: 0 };
+ const wide = grant(art.cols + COLUMN_GAP + MAX_TEXT_COLS + PANE_FRAME);
+ // Granted only if the extra columns actually become card beside the picture. Where the list
+ // cannot spare that many, a wider pane would be a wider *stacked* pane — the card capped at
+ // MAX_TEXT_COLS with dead columns beside it — so the stacked width is the honest answer and the
+ // list keeps the difference.
+ return laid(
+ art.cols > 0 && seatsCardBeside(wide - PANE_FRAME, paneInnerRows)
+ ? wide
+ : grant(MAX_TEXT_COLS + PANE_FRAME),
+ );
+}
+
+/**
+ * Rows of card a focused pane guarantees on screen without scrolling.
+ *
+ * This is the contract the whole focused mode exists to keep: the user stepped into the pane to
+ * *read the description*, so a poster they have to scroll past before reaching one is not serving
+ * them. Six of these rows are the card's identity block — title, year·rating·runtime, genres,
+ * director, and a cast credit that wraps to two lines at every width the pane is ever drawn at.
+ * The remaining two are plot, which at 28 to 56 columns is 60 to 110 characters: a whole sentence
+ * of synopsis, which is the difference between knowing a description exists and being able to read
+ * one. Anything less and the guarantee buys only the title the pane already showed unfocused.
+ *
+ * It replaces an earlier two-row peek, which predated the pane carrying a plot at all and bought
+ * exactly the title and nothing under it.
+ */
+export const MIN_FOCUSED_TEXT_ROWS = 8;
+
+/**
+ * What the art actually gives up in the stacked layout: the guaranteed card rows plus the two rows
+ * of chrome around them that are not card — the blank spacer between the picture and the text, and
+ * the one-line scroll affordance a pane with anything off screen spends a row on.
+ */
+const FOCUSED_TEXT_RESERVE = MIN_FOCUSED_TEXT_ROWS + 2;
+
+/**
+ * Rows the art gives up to the text below it: the facts card at its usual height — title,
+ * year·rating·runtime, genres, director, two cast lines — plus the blank spacer between the art
+ * and the text.
+ *
+ * Not an enumeration of every field the card can hold. The plot has no natural length and is
+ * planned last against whatever the facts underspent, so it claims what is left of these seven
+ * rather than asking for rows of its own; a pane whose credits wrap short simply shows more of it.
+ */
+const TEXT_ROWS = 7;
+
+/**
+ * Below this the art is more artifact than image, and the rows are worth more to the facts.
+ */
+const MIN_POSTER_ROWS = 6;
+
+/**
+ * The same floor read off the other axis. A 2:3 poster eight cells wide is six cells tall, so this
+ * and MIN_POSTER_ROWS describe one smallest acceptable picture rather than two independent limits —
+ * which is what lets the focused budget cap the art by width without needing a second opinion on
+ * whether what is left is still worth drawing.
+ */
+const MIN_POSTER_COLS = 8;
+
+/**
+ * The rendition the Amazon host serves, and the shape every width below is reasoned in. Every
+ * rendition shares its 2:3 ratio, so `fitCells` doesn't care which one actually decoded.
+ */
+export const POSTER_W = 120;
+export const POSTER_H = 180;
+
+/**
+ * Whether a focused pane of these dimensions seats its card beside the poster rather than under it.
+ *
+ * The decision is made here, once, from dimensions alone — before any pixel is fetched — because
+ * two things downstream have to agree on it and cannot ask each other: previewLayout has to know
+ * how wide to make the pane, and posterBudget has to know which axis the art gives up. Deciding it
+ * from the art that came back instead would make the pane's width depend on a decode that depends
+ * on the pane's width.
+ */
+function seatsCardBeside(inner: number, paneInnerRows: number): boolean {
+ return paneInnerRows >= MIN_POSTER_ROWS && inner - COLUMN_GAP - MIN_TEXT_COLS >= MIN_POSTER_COLS;
+}
+
+/**
+ * The cell budget for poster art, or null when the pane has no room worth spending on it.
+ * Returning null drops the art and keeps the facts, which is the right trade on a short terminal —
+ * the pane exists to say what a release is, and it can do that in text alone.
+ *
+ * Unfocused, the art gets the pane's full inner width and bids against the text for a fixed set of
+ * rows, winning the slack: it takes everything the facts card does not claim, so a taller pane
+ * grows the picture first.
+ *
+ * Focused, the card's share is guaranteed rather than left over, and the only question is which
+ * axis the art surrenders it on:
+ *
+ * - Wide enough to seat the card beside the picture, and the art gives up *columns*: it keeps the
+ * pane's entire height and hands back exactly the gutter MIN_TEXT_COLS needs. This is what makes
+ * the side-by-side layout affordable at every width that can hold it at all — capping the art by
+ * height instead would let a tall terminal grow the poster until nothing could fit next to it,
+ * which is precisely how a 120-column terminal ended up stacked with one row of text.
+ * - Otherwise the art gives up *rows*, FOCUSED_TEXT_RESERVE of them, and the card sits underneath.
+ *
+ * Either way the guarantee holds: beside the art the card's column runs the pane's full height, and
+ * under it the reserve is what the art was not allowed to take.
+ *
+ * MIN_POSTER_ROWS and MIN_POSTER_COLS veto the rest: below them the art is more artifact than
+ * image and the cells are worth more to the facts.
+ *
+ * fitCells does the aspect arbitration from here; these are only the outer bounds it fits into, so
+ * a poster narrower or shorter than the box simply comes back that way.
+ *
+ * `paneInnerRows` is the pane's content height (Panel's height less the border row it draws
+ * inside it), not its outer height. The widths handed back are already inside Panel's frame.
+ */
+export function posterBudget(
+ paneWidth: number,
+ paneInnerRows: number,
+ focused = false,
+): { cols: number; rows: number } | null {
+ const inner = paneWidth - PANE_FRAME;
+ // Unreachable through previewLayout (its narrowest tier is 20 wide and carries no poster
+ // anyway), but this is exported and a negative cell budget is not an answer.
+ if (inner < 1) return null;
+ if (focused && seatsCardBeside(inner, paneInnerRows)) {
+ return { cols: inner - COLUMN_GAP - MIN_TEXT_COLS, rows: paneInnerRows };
+ }
+ const rows = paneInnerRows - (focused ? FOCUSED_TEXT_RESERVE : TEXT_ROWS);
+ if (rows < MIN_POSTER_ROWS) return null;
+ return { cols: inner, rows };
+}
+
+/**
+ * One blank column between the poster and the card beside it.
+ *
+ * Same value and the same reason as PANE_GAP between the two panels: the art's last cell is a
+ * saturated background colour, and a glyph sitting directly against it reads as being *on* the
+ * picture. One column is enough to break that, and at these widths a second is a column the plot
+ * wants more than the gutter does.
+ */
+export const COLUMN_GAP = 1;
+
+/**
+ * The narrowest card column worth splitting the focused pane into.
+ *
+ * The plot sets this floor: it is the widest thing the card holds and the only field with no
+ * natural length, so it is what a bad measure ruins. The widest browsing tier — a 34-column pane —
+ * already wraps that same plot at 30 columns inside Panel's frame, and that is the measure the
+ * card was written against. 28 sits two columns under it: close enough that focusing never hands
+ * the synopsis a worse line than browsing already did, and low enough that the split still engages
+ * on a terminal someone plausibly has rather than being a feature nobody's screen can reach.
+ * Under it a wrapped synopsis averages fewer than five words a line and the eye starts losing its
+ * place on the return — which is the exact failure this layout exists to fix, so a pane that
+ * cannot clear the bar stacks instead of splitting badly.
+ */
+export const MIN_TEXT_COLS = 28;
+
+/**
+ * Card columns for a focused pane laying the poster and the text side by side, or null when it
+ * cannot afford to and should stack them the way it always has.
+ *
+ * `artCols` is the art's *natural* width at this pane's height, read off the grid that actually
+ * decoded rather than off the budget it was decoded into: fitCells narrows a poster it had to cap
+ * by rows to keep its aspect, and the columns that narrowing frees are precisely the empty gutter
+ * this layout reclaims. A pane with no art on screen has nothing to sit beside and gets null.
+ */
+export function splitTextCols(innerWidth: number, artCols: number): number | null {
+ if (artCols < 1) return null;
+ const textCols = innerWidth - artCols - COLUMN_GAP;
+ return textCols >= MIN_TEXT_COLS ? textCols : null;
+}
diff --git a/src/ui/store.ts b/src/ui/store.ts
index b96eeaf5..79d91178 100644
--- a/src/ui/store.ts
+++ b/src/ui/store.ts
@@ -19,7 +19,12 @@ export const CATEGORIES: { key: Category; label: string; group?: SourceGroup }[]
{ key: "anime", label: "Anime", group: "Anime" },
];
-export type Region = "sidebar" | "content" | "help";
+/**
+ * Which column owns the keyboard. `sidebar → content → preview` is one horizontal model the arrow
+ * keys walk in both directions; "help" is not a column at all but the flag a modal raises so every
+ * region-aware component reads as unfocused while the overlay owns the screen.
+ */
+export type Region = "sidebar" | "content" | "preview" | "help";
export type CaptureMode = "none" | "text" | "esc";
@@ -52,6 +57,12 @@ export interface Store {
setSeedFocus: (f: SeedFocus | null) => void;
resultFocus: ResultFocus | null;
setResultFocus: (f: ResultFocus | null) => void;
+ // Whether the info pane is actually on screen. Only the results view can answer that — it owns
+ // the `i` toggle, the section it is showing and the width split — so it reports up rather than
+ // App re-deriving two of the three and getting the third wrong. App uses it for one decision:
+ // whether → has a third column to step into.
+ previewOpen: boolean;
+ setPreviewOpen: (open: boolean) => void;
startDownload: (input: {
id: string;
diff --git a/src/ui/testHarness.ts b/src/ui/testHarness.ts
index 362947ba..e63dc4c1 100644
--- a/src/ui/testHarness.ts
+++ b/src/ui/testHarness.ts
@@ -23,6 +23,12 @@ export const KEY = {
enter: "\r",
esc: "\u001b",
ctrlU: "\u0015",
+ // CSI cursor keys. Ink parses a raw ESC + "[" + letter as the corresponding arrow, matching
+ // what a real terminal emits for the unmodified key.
+ right: "\u001b[C",
+ left: "\u001b[D",
+ up: "\u001b[A",
+ down: "\u001b[B",
} as const;
// SGR/CSI sequences only. util/format's stripControl drops the ESC byte but
@@ -38,6 +44,12 @@ export interface RenderedUI {
rawFrame: () => string;
/** Feed raw bytes to the app as if typed. */
press: (bytes: string) => void;
+ /**
+ * Resizes the fake terminal the way a real one does — new size on the stream, then the event —
+ * because that is exactly what App's resize effect listens for (App.tsx reads stdout.columns and
+ * stdout.rows inside its "resize" handler, not from the event).
+ */
+ resize: (cols: number, rows: number) => void;
unmount: () => void;
}
@@ -97,6 +109,11 @@ export function renderUI(node: ReactNode, opts: { cols?: number; rows?: number }
},
rawFrame: () => writes.at(-1) ?? "",
press: (bytes: string) => void stdin.write(bytes),
+ resize: (cols: number, rows: number) => {
+ stdout.columns = cols;
+ stdout.rows = rows;
+ stdout.emit("resize");
+ },
unmount: () => instance.unmount(),
};
}
@@ -159,6 +176,8 @@ export function makeTestStore(overrides: Partial = {}): Store {
setSeedFocus: noop,
resultFocus: null,
setResultFocus: noop,
+ previewOpen: false,
+ setPreviewOpen: noop,
startDownload: noop,
requestDownloadTo: noop,
copyMagnet: noop,
diff --git a/src/ui/textWidth.test.ts b/src/ui/textWidth.test.ts
new file mode 100644
index 00000000..51bcd5b9
--- /dev/null
+++ b/src/ui/textWidth.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+import { codePointWidth, displayWidth, ellipsizeToWidth, wordWrapLines } from "./textWidth";
+
+// Astral code points (emoji outside the BMP, CJK Extension B+ ideographs) get no free ride from
+// `.length` the way a BMP character never did either — `for...of` yields one code point per
+// surrogate pair, so an unlisted astral range undercounts exactly like an unlisted BMP one. A
+// prior version of this file's width table left every astral range out on the theory that
+// `.length` already "handled" them, which reproduced the original Critical bug on emoji plots.
+describe("terminal column width", () => {
+ it("measures the full reviewer-named spread of wide and narrow code points correctly", () => {
+ // Wide: astral emoji/pictographs, BMP dingbat emoji scattered through U+231A..U+2B55, a rare
+ // astral CJK ideograph (Extension B), a Vertical Forms code point, plus one representative
+ // each of Hangul Jamo, CJK unified ideographs and Hangul syllables.
+ const wide = [
+ 0x1f3ac, // 🎬 clapper board (astral emoji)
+ 0x2b50, // ⭐ star (BMP emoji)
+ 0x2705, // ✅ check mark button
+ 0x2757, // ❗ heavy exclamation mark
+ 0x2b1b, // ⬛ black large square
+ 0x231a, // ⌚ watch
+ 0x20000, // 𠀀 CJK unified ideographs extension B
+ 0xfe10, // ︐ presentation form for vertical comma
+ 0x4e00, // 一 CJK unified ideographs
+ 0xac00, // 가 Hangul syllable
+ ];
+ // Narrow: this app's own border/pointer characters (the regression a too-broad wide range
+ // caused in an earlier draft of this table), halfwidth katakana, and a spread of non-CJK
+ // scripts that must never be measured as wide.
+ const narrow = [
+ 0x2500, // ─ box drawings light horizontal (this app's own panel border)
+ 0x2502, // │ box drawings light vertical
+ 0x276f, // ❯ heavy right-pointing angle quotation mark ornament (list cursor)
+ 0x61, // a
+ 0xff71, // ア halfwidth katakana A
+ 0xff61, // 。 halfwidth ideographic full stop
+ 0x439, // й Cyrillic
+ 0x3b1, // α Greek
+ 0xe9, // é Latin-1
+ 0x5d0, // א Hebrew
+ 0x627, // ا Arabic
+ 0xe01, // ก Thai
+ ];
+ for (const cp of wide) {
+ expect(codePointWidth(String.fromCodePoint(cp)), `U+${cp.toString(16)} should be wide`).toBe(2);
+ }
+ for (const cp of narrow) {
+ expect(codePointWidth(String.fromCodePoint(cp)), `U+${cp.toString(16)} should be narrow`).toBe(1);
+ }
+ });
+});
+
+// The info pane wraps into 16 columns on its narrowest tier — less than half the width the detail
+// panel ever asked of these helpers — so the properties below are pinned directly rather than
+// only through a rendered frame.
+describe("wrapping to a narrow column budget", () => {
+ const fits = (lines: string[], width: number): void => {
+ for (const l of lines) expect(displayWidth(l), `"${l}"`).toBeLessThanOrEqual(width);
+ };
+
+ it("never emits a line wider than the budget, whatever the script", () => {
+ const cases = [
+ "The Shawshank Redemption",
+ "Cast Tim Robbins, Morgan Freeman, Bob Gunton, William Sadler",
+ "本作は刑務所を舞台にした友情と希望の物語である",
+ "🎬🎬🎬🎬🎬🎬🎬🎬🎬🎬🎬🎬",
+ "Antidisestablishmentarianism",
+ ];
+ for (const width of [16, 24, 30]) {
+ for (const text of cases) fits(wordWrapLines(text, width), width);
+ }
+ });
+
+ it("keeps every word, in order, when it hard-breaks an oversized one", () => {
+ const lines = wordWrapLines("Dir Wolfeschlegelsteinhausenbergerdorff", 16);
+ fits(lines, 16);
+ expect(lines.join("").replace(/\s/g, "")).toBe("DirWolfeschlegelsteinhausenbergerdorff");
+ });
+
+ it("counts a wide code point as two columns wherever it lands", () => {
+ expect(displayWidth("田中誠")).toBe(6);
+ expect(displayWidth("Dir 田中誠")).toBe(10);
+ expect(displayWidth("")).toBe(0);
+ });
+
+ it("ellipsizes on a code point boundary, never inside one", () => {
+ expect(ellipsizeToWidth("Redemption", 6)).toBe("Redem…");
+ expect(ellipsizeToWidth("Redemption", 1)).toBe("…");
+ // A wide character cannot half-fit: it is dropped rather than counted as one column.
+ expect(displayWidth(ellipsizeToWidth("田中誠一郎", 6))).toBeLessThanOrEqual(6);
+ expect(ellipsizeToWidth("🎬🎬🎬", 5)).toBe("🎬🎬…");
+ });
+});
diff --git a/src/ui/textWidth.ts b/src/ui/textWidth.ts
new file mode 100644
index 00000000..aa2d157e
--- /dev/null
+++ b/src/ui/textWidth.ts
@@ -0,0 +1,199 @@
+// Terminal column accounting, shared by every view that has to know how much of a fixed-size
+// panel a string will actually occupy *before* Ink lays it out.
+//
+// This lives in its own module rather than inside the component that first needed it because two
+// views now depend on the same answers — the detail panel and the info pane beside the results
+// list — and a second copy of this table is a second thing to get wrong. Ink/Yoga clips an
+// overflowing panel by squeezing rows, not by cutting the offending line, so a miscount here shows
+// up as dropped and fused rows somewhere else entirely.
+
+// DetailRow's fixed label column width. Hoisted so Detail's own layout-budget math (which needs
+// to know exactly how many columns are left for a value) can never independently drift from what
+// DetailRow actually renders — two copies of this number is exactly the kind of silent mismatch
+// that reintroduces the panel-overflow bug behind an otherwise green test suite.
+export const LABEL_W = 9;
+
+// [start, end] (inclusive) code point ranges rendered at 2 terminal columns: East Asian
+// Wide/Fullwidth blocks, plus the specific BMP and astral emoji/symbol ranges terminals render
+// double-width.
+//
+// This has to cover the full 0–0x10FFFF space, astral planes included. There is no free ride for
+// an emoji or a CJK Extension B+ ideograph the way there was for `.length`, which counted 2
+// UTF-16 units per astral code point regardless of what the character actually was — `for...of`
+// (below, and throughout this file) yields one code point per surrogate pair, so an astral
+// character gets exactly the same "look it up or default to 1" treatment as a BMP one. A prior
+// version of this table left every astral range out entirely on the theory that `.length` already
+// handled them; that was the same 2x undercount as the bug this whole mechanism exists to
+// prevent, just relocated to emoji and rare CJK ideographs instead of common ones.
+//
+// Solid CJK/Hangul/fullwidth territory is merged into single generous spans — a handful of narrow
+// or unassigned code points inside one of those costs at most one wasted row. U+2000–U+2BFF is
+// the one region kept surgical rather than swept: it also holds Box Drawing (this app's own
+// panel borders — ─│╭╮╰╯ all live at U+2500+) and Geometric Shapes, both mostly narrow, so a wide
+// net here would double-count the UI's own chrome, not just waste a line.
+const WIDE_RANGES: readonly (readonly [number, number])[] = [
+ [0x1100, 0x115f], // Hangul Jamo
+ [0x231a, 0x231b], // watch, hourglass
+ [0x2329, 0x232a], // angle brackets
+ [0x23e9, 0x23ec], // fast-forward/rewind/next/last track
+ [0x23f0, 0x23f0], // alarm clock
+ [0x23f3, 0x23f3], // hourglass (flowing)
+ [0x25fd, 0x25fe], // small squares
+ [0x2614, 0x2615], // umbrella, hot beverage
+ [0x2648, 0x2653], // zodiac signs
+ [0x267f, 0x267f], // wheelchair symbol
+ [0x2693, 0x2693], // anchor
+ [0x26a1, 0x26a1], // high voltage
+ [0x26aa, 0x26ab], // circles
+ [0x26bd, 0x26be], // soccer ball, baseball
+ [0x26c4, 0x26c5], // snowman, sun behind cloud
+ [0x26ce, 0x26ce], // ophiuchus
+ [0x26d4, 0x26d4], // no entry
+ [0x26ea, 0x26ea], // church
+ [0x26f2, 0x26f3], // fountain, flag in hole
+ [0x26f5, 0x26f5], // sailboat
+ [0x26fa, 0x26fa], // tent
+ [0x26fd, 0x26fd], // fuel pump
+ [0x2705, 0x2705], // check mark button
+ [0x270a, 0x270b], // raised fist, raised hand
+ [0x2728, 0x2728], // sparkles
+ [0x274c, 0x274c], // cross mark
+ [0x274e, 0x274e], // negative squared cross mark
+ [0x2753, 0x2755], // question/exclamation marks
+ [0x2757, 0x2757], // heavy exclamation mark
+ [0x2795, 0x2797], // plus/minus/division
+ [0x27b0, 0x27b0], // curly loop
+ [0x27bf, 0x27bf], // double curly loop
+ [0x2b1b, 0x2b1c], // large squares
+ [0x2b50, 0x2b50], // star
+ [0x2b55, 0x2b55], // heavy large circle
+ [0x2e80, 0x9fff], // CJK radicals through CJK unified ideographs — Hiragana, Katakana, Hangul
+ // compatibility Jamo, Yijing hexagrams and everything else in this span included
+ [0xa000, 0xa4cf], // Yi syllables and radicals
+ [0xa960, 0xa97f], // Hangul Jamo Extended-A
+ [0xac00, 0xd7a3], // Hangul syllables
+ [0xd7b0, 0xd7ff], // Hangul Jamo Extended-B
+ [0xf900, 0xfaff], // CJK compatibility ideographs
+ [0xfe10, 0xfe19], // vertical forms
+ [0xfe30, 0xfe6b], // CJK compatibility forms, small form variants
+ [0xff00, 0xff60], // fullwidth forms (halfwidth katakana/Hangul at 0xff61+ excluded on purpose)
+ [0xffe0, 0xffe6], // fullwidth signs
+ [0x1f000, 0x1ffff], // mahjong/domino/cards, enclosed CJK, emoji, transport, chess, pictographs
+ [0x20000, 0x3fffd], // CJK unified ideographs extension B and every plane beyond it
+];
+
+function isWideCodePoint(cp: number): boolean {
+ for (const [start, end] of WIDE_RANGES) {
+ if (cp >= start && cp <= end) return true;
+ }
+ return false;
+}
+
+// A `for...of` over a string yields whole code points (surrogate pairs included), so this reads
+// one on-screen glyph at a time rather than one UTF-16 unit — the distinction that matters for a
+// CJK character (one unit, two columns) exactly as much as for an astral one (an emoji, a rare
+// CJK ideograph): both are a single code point handed to `isWideCodePoint`, no special-casing
+// either way. Exported so a test can measure a rendered frame by the identical column accounting
+// the components use to decide what fits, rather than trusting a second, potentially-diverging
+// implementation to agree with it.
+export function codePointWidth(ch: string): number {
+ const cp = ch.codePointAt(0);
+ return cp !== undefined && isWideCodePoint(cp) ? 2 : 1;
+}
+
+export function displayWidth(text: string): number {
+ let w = 0;
+ for (const ch of text) w += codePointWidth(ch);
+ return w;
+}
+
+// Breaks a single run of text with no whitespace into chunks that each fit within `width` display
+// columns, splitting only between code points, never inside one.
+function breakToWidth(text: string, width: number): string[] {
+ const chunks: string[] = [];
+ let chunk = "";
+ let col = 0;
+ for (const ch of text) {
+ const w = codePointWidth(ch);
+ if (col + w > width && chunk !== "") {
+ chunks.push(chunk);
+ chunk = "";
+ col = 0;
+ }
+ chunk += ch;
+ col += w;
+ }
+ if (chunk !== "") chunks.push(chunk);
+ return chunks;
+}
+
+// Trims to at most `width` display columns before the ellipsis, using the same column accounting
+// as wordWrapLines below. A plain `.slice()` counts UTF-16 units, which can land inside a CJK
+// character (one unit, two columns) or split a surrogate-pair emoji in half; this never does.
+// Every call site clamps its width with `Math.max(1, …)` before calling in, so there is no
+// `width <= 0` case to guard here, same reasoning as wordWrapLines below.
+export function ellipsizeToWidth(text: string, width: number): string {
+ if (width === 1) return "…";
+ let out = "";
+ let col = 0;
+ for (const ch of text) {
+ const w = codePointWidth(ch);
+ if (col + w > width - 1) break;
+ out += ch;
+ col += w;
+ }
+ return `${out}…`;
+}
+
+// Greedy word wrap, used only to learn — and pin — exactly how many terminal rows a value will
+// occupy before Ink ever lays it out. Both panels that use it have a fixed height (Panel's
+// `height` prop, clipped with `overflow: hidden`), and handing Ink's own `wrap="wrap"` more text
+// than that budget allows does not clip cleanly: Yoga's flexbox shrink squeezes whichever rows
+// land on the losing side of its shrink math, dropping or fusing rows anywhere in the block,
+// including ones above the actual overflow. Every metadata row is only rendered once it is known
+// to fit, which means knowing its line count first.
+//
+// Wraps by *display* column, not UTF-16 code unit: a CJK character is one JS string unit but two
+// terminal columns, and Nyaa (an anime index, one of torlink's own sources) plus Cinemeta's
+// Japanese/Korean/Chinese titles make CJK cast and plot text a routine path here, not an edge
+// case. A word wider than `width` (a Cinemeta plot has no guaranteed word-length cap, and neither
+// does a single-token cast credit) is hard-broken into `width`-wide chunks rather than left to
+// overflow its own line — Ink's real wrap does the same, and undercounting here is exactly what
+// let a single unbroken run of text blow through a budget that looked, on paper, like it had room
+// to spare.
+export function wordWrapLines(text: string, width: number): string[] {
+ const words = text.split(/\s+/).filter(Boolean);
+ if (words.length === 0) return [];
+ const lines: string[] = [];
+ let line = "";
+ let lineWidth = 0;
+ for (const word of words) {
+ const wordWidth = displayWidth(word);
+ if (wordWidth > width) {
+ if (line !== "") {
+ lines.push(line);
+ line = "";
+ lineWidth = 0;
+ }
+ const chunks = breakToWidth(word, width);
+ const last = chunks.pop() ?? "";
+ for (const chunk of chunks) lines.push(chunk);
+ line = last;
+ lineWidth = displayWidth(last);
+ continue;
+ }
+ if (line === "") {
+ line = word;
+ lineWidth = wordWidth;
+ } else if (lineWidth + 1 + wordWidth <= width) {
+ line += ` ${word}`;
+ lineWidth += 1 + wordWidth;
+ } else {
+ lines.push(line);
+ line = word;
+ lineWidth = wordWidth;
+ }
+ }
+ if (line !== "") lines.push(line);
+ return lines;
+}