From 062feb2b194ebfe448a74e5cd4808317642b7f74 Mon Sep 17 00:00:00 2001 From: Ash Date: Thu, 3 Sep 2026 16:32:35 +0100 Subject: [PATCH 1/2] fix: drop the play button on Games/Books, wire the logo to home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Games/Books results are installers, archives, and documents — there's no video file for play to land on, so streamCandidates fell back to "every file" and streamed whatever came first. Both front ends now hide/no-op play for those two tabs (add/debrid-add stay as the primary actions). Also makes the web header's "torlnk" wordmark a real button that navigates to the search/home view, instead of a plain non-interactive

. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018VAPhuniJAv5uumnT4akak --- src/ui/components/Results.test.tsx | 35 ++++++++++++++++++++++++++++++ src/ui/components/Results.tsx | 8 +++++-- src/web/static/app.ts | 35 ++++++++++++++++++------------ src/web/static/index.html | 26 +++++++++++++--------- src/web/static/searchModel.test.ts | 17 +++++++++++++++ src/web/static/searchModel.ts | 12 ++++++++++ src/web/static/styles.css | 19 ++++++++++++++-- 7 files changed, 124 insertions(+), 28 deletions(-) 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..32a0ded6 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,24 @@ 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. + if (playApplies(searchView.group)) { + 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 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 @@