diff --git a/src/ui/components/Results.test.tsx b/src/ui/components/Results.test.tsx
index cc9e2513..255d4643 100644
--- a/src/ui/components/Results.test.tsx
+++ b/src/ui/components/Results.test.tsx
@@ -521,6 +521,41 @@ const GROUPABLE = [
t("a1", "Ashfall.1999.1080p"),
];
+describe("Results play gating", () => {
+ // Games and Books releases are installers/archives/documents, not video —
+ // `v` has nothing sensible to stream, so it's a no-op there. See playSection
+ // in Results.tsx and playApplies in the web UI's searchModel.ts.
+ it("does not stream on 'v' for a Games result", async () => {
+ const streamResult = vi.fn();
+ const gamesList: TorrentResult[] = [{ ...LIST[0]!, source: "fitgirl" }];
+ searchState.current = settled(gamesList);
+ ui = renderUI(
+
+
+ ,
+ );
+ const u = ui;
+ await vi.waitFor(() => expect(u.frame()).toContain(`Results (${gamesList.length})`));
+ u.press("v");
+ await new Promise((r) => yieldToLoop(() => r()));
+ expect(streamResult).not.toHaveBeenCalled();
+ });
+
+ it("still streams on 'v' outside Games/Books", async () => {
+ const streamResult = vi.fn();
+ searchState.current = settled(LIST);
+ ui = renderUI(
+
+
+ ,
+ );
+ const u = ui;
+ await vi.waitFor(() => expect(u.frame()).toContain(`Results (${LIST.length})`));
+ u.press("v");
+ await vi.waitFor(() => expect(streamResult).toHaveBeenCalled());
+ });
+});
+
describe("Results grouping", () => {
// At a WIDE content width throughout. At 80 columns the list has ~61 and
// "Kestrel (2010)" renders as "Kestrel (…", so every assertion here would be
diff --git a/src/ui/components/Results.tsx b/src/ui/components/Results.tsx
index c5290a54..e0943ed0 100644
--- a/src/ui/components/Results.tsx
+++ b/src/ui/components/Results.tsx
@@ -540,6 +540,10 @@ export function Results({ reccConfig, fetchImpl }: ResultsProps) {
const previewSection = !previewGroup || previewGroup === "Movies" || previewGroup === "TV" || previewGroup === "Anime";
// Anime previews from AniList, so it needs no OMDb key.
const sectionIsAnime = section === "anime";
+ // Games and Books results are installers, archives, and documents — there is
+ // no video file for `v` to land on, so it's a no-op there. See the web UI's
+ // `playApplies` (searchModel.ts) for the same gate on the other front end.
+ const playSection = previewGroup !== "Games" && previewGroup !== "Books";
// Local pane: the adult group has no OMDb metadata, so it is built from the
// release name — no key, no lookup.
const adultSection = previewGroup === "Porn";
@@ -769,7 +773,7 @@ export function Results({ reccConfig, fetchImpl }: ResultsProps) {
} else if (input === "r") {
const r = resultAt(clamped);
if (r) openDebrid(r);
- } else if (input === "v") {
+ } else if (input === "v" && playSection) {
const row = rows[clamped];
if (row?.kind === "season" || row?.kind === "show") {
const plan = seasonPlayPlan(
@@ -812,7 +816,7 @@ export function Results({ reccConfig, fetchImpl }: ResultsProps) {
} else if (input === "d" && detail) openDownload(detail);
else if (input === "D" && detail) openDownloadTo(detail);
else if (input === "r" && detail) openDebrid(detail);
- else if (input === "v" && detail) openStream(detail);
+ else if (input === "v" && detail && playSection) openStream(detail);
else if (input === "y" && detail) copyResultMagnet(detail);
else if (input === "i" && detail) openImdbFor(detail.name);
else if (input === "b" && detail && canFavourite(detail)) toggleFavourite(favInput(detail));
diff --git a/src/web/static/app.ts b/src/web/static/app.ts
index 667b3a56..5d2444d1 100644
--- a/src/web/static/app.ts
+++ b/src/web/static/app.ts
@@ -64,6 +64,7 @@ import {
parseGrouping,
parseLayout,
parseSort,
+ playApplies,
previewApplies,
adultPreviewApplies,
reportsHealthLookup,
@@ -272,6 +273,7 @@ const settingsSourcesBox = el("settings-sources");
const settingsAccountsBox = el("settings-accounts");
const settingsAccountsHint = el("settings-accounts-hint");
+const logoHome = el("logo-home");
const viewsNav = el("views");
const viewSearchTab = el("view-search");
const viewReccTab = el("view-recc");
@@ -1644,6 +1646,7 @@ function showView(next: ViewName): void {
syncUrl();
}
+logoHome.addEventListener("click", () => showView("search"));
viewSearchTab.addEventListener("click", () => showView("search"));
viewReccTab.addEventListener("click", () => showView("recc"));
viewSavedTab.addEventListener("click", () => showView("saved"));
@@ -2408,20 +2411,27 @@ function resultActions(
const actions = document.createElement("div");
actions.className = "row-actions";
- const playButton = document.createElement("button");
- playButton.type = "button";
- playButton.className = "play";
- playButton.textContent = "play";
- tagControl(playButton, rowKey, "play");
- // `result.infoHash`, NOT `rowKey`: rowKey is the group key (this row may nest
- // several releases of one title), while play() is handed rowForPlay(result),
- // whose id is the hash. See tagPlayKey for why the two identities are separate.
- tagPlayKey(playButton, result.infoHash, "play");
- playButton.addEventListener("click", () => {
- if (onPlay) onPlay();
- else void play(rowForPlay(result));
- });
- actions.append(playButton);
+ // Games and Books results have no video file for `play` to land on — see
+ // `playApplies`. Add / debrid-add remain as the primary actions on those
+ // tabs, and one of them takes over play's accent styling below so the row
+ // still has one obvious next step.
+ const showPlay = playApplies(searchView.group);
+ if (showPlay) {
+ const playButton = document.createElement("button");
+ playButton.type = "button";
+ playButton.className = "play";
+ playButton.textContent = "play";
+ tagControl(playButton, rowKey, "play");
+ // `result.infoHash`, NOT `rowKey`: rowKey is the group key (this row may nest
+ // several releases of one title), while play() is handed rowForPlay(result),
+ // whose id is the hash. See tagPlayKey for why the two identities are separate.
+ tagPlayKey(playButton, result.infoHash, "play");
+ playButton.addEventListener("click", () => {
+ if (onPlay) onPlay();
+ else void play(rowForPlay(result));
+ });
+ actions.append(playButton);
+ }
// A labelled debrid add button replaces the plain (P2P) "add" whenever a
// provider is configured — the server forces debrid in that case (it never
@@ -2432,6 +2442,9 @@ function resultActions(
if (!debridAddAvailable) {
const addButton = document.createElement("button");
addButton.type = "button";
+ // Only the primary action where play is not shown at all — with play
+ // shown, plain P2P add is a secondary option next to it.
+ if (!showPlay) addButton.className = "primary";
addButton.textContent = "add";
tagControl(addButton, rowKey, "add");
addButton.addEventListener("click", () => void addResult(result, "p2p"));
@@ -2463,6 +2476,9 @@ function resultActions(
if (debridAddAvailable && sources?.debridProvider) {
const debridButton = document.createElement("button");
debridButton.type = "button";
+ // Only the primary action where play is not shown at all — with play
+ // shown, debrid-add is a secondary option next to it.
+ if (!showPlay) debridButton.className = "primary";
debridButton.textContent = debridAddLabel(sources.debridProvider);
tagControl(debridButton, rowKey, "debrid");
debridButton.addEventListener("click", () => void addResult(result, "debrid"));
diff --git a/src/web/static/index.html b/src/web/static/index.html
index cbfb6262..3963531f 100644
--- a/src/web/static/index.html
+++ b/src/web/static/index.html
@@ -12,16 +12,22 @@