From 4c7f73af9d89683c76a52022167f04577b184634 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 9 Oct 2026 00:06:18 +0200 Subject: [PATCH] feat(plex): a title opens its page, the picture plays; an episode links up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card title lit like a link but played the item. Now the picture plays and the title opens the page of what it names: a movie's or an episode's info page, a show's page, and for an episode standing as its show (Recently watched) the show. Every poster and episode card carries it (grid, rails, person, collection, season, related, Home), a card with no page keeps a plain title, and the season page gains the menu's Details row too. The episode page had no way up: the detail now serves the show's and the season's rating keys, and on the page the show title opens the show and "S1·E25" the season (Back returns to where the page was). --- backend/app/routes/plex/detail.py | 4 ++ backend/tests/test_plex_detail.py | 14 +++++- frontend/src/components/Home.dom.test.tsx | 17 ++++++- frontend/src/components/Home.tsx | 1 + frontend/src/components/PlexBrowse.tsx | 20 ++++++++ .../src/components/PlexCollectionView.tsx | 1 + frontend/src/components/PlexDiscover.tsx | 1 + frontend/src/components/PlexInfo.dom.test.tsx | 49 +++++++++++++++++++ frontend/src/components/PlexInfo.tsx | 42 +++++++++++++++- .../src/components/plexCards.dom.test.tsx | 41 +++++++++++++++- frontend/src/components/plexCards.tsx | 24 +++++++-- .../components/plexViews.restart.dom.test.tsx | 10 +++- frontend/src/components/plexViews.tsx | 15 ++++++ frontend/src/lib/api/plex.ts | 2 + 14 files changed, 227 insertions(+), 14 deletions(-) diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index 99de5974..ada2cf9b 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -504,6 +504,7 @@ def item_detail( prev_id, next_id = _episode_nav(db, it) show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None + season = db.get(PlexSeason, it.season_id) if show and it.season_id else None return { "id": it.rating_key, "kind": it.kind, @@ -537,6 +538,9 @@ def item_detail( "status": st.status if st else "new", "position_seconds": st.position_seconds if st else 0, "show_title": ((show.title_hu or show.title if hu else show.title) if show else None), + # The episode page links up to its show and its season (their rating keys = the view keys). + "show_id": show.rating_key if show else None, + "season_id": season.rating_key if season else None, "season_number": it.season_number, "episode_number": it.episode_number, "prev_id": prev_id, diff --git a/backend/tests/test_plex_detail.py b/backend/tests/test_plex_detail.py index b9bbf62b..572da9b1 100644 --- a/backend/tests/test_plex_detail.py +++ b/backend/tests/test_plex_detail.py @@ -19,7 +19,7 @@ import app.state as state from app.auth import current_user from app.db import get_db from app.main import app -from app.models import PlexItem, PlexLibrary, PlexShow, User +from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, User from app.plex import detail from app.plex.sync import _apply_facet_fields, _enrich_people_batch @@ -276,8 +276,16 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep show = PlexShow(rating_key="s1", library_id=lib.id, title="A Show") db.add(show) db.flush() + season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1) + db.add(season) + db.flush() ep = PlexItem( - rating_key="e1", library_id=lib.id, kind="episode", title="Pilot", show_id=show.id + rating_key="e1", + library_id=lib.id, + kind="episode", + title="Pilot", + show_id=show.id, + season_id=season.id, ) db.add(ep) db.commit() @@ -289,6 +297,8 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep assert resp.status_code == 200, resp.text body = resp.json() assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row + # The page's links up to the show and the season. + assert (body["show_id"], body["season_id"]) == ("s1", "se1") assert [c["name"] for c in body["cast"]] == ["Marlon Brando", "Al Pacino"] db.refresh(ep) assert ep.full_cast is None # episodes are not persisted/enriched diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx index b69e3c76..1f94ae45 100644 --- a/frontend/src/components/Home.dom.test.tsx +++ b/frontend/src/components/Home.dom.test.tsx @@ -663,7 +663,7 @@ describe("Home", () => { comfort: [], }); mount(); - fireEvent.click(await screen.findByText("A Show")); + fireEvent.click(await screen.findByRole("button", { name: "A Show" })); expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ id: "ep-7", type: "play" })); }); @@ -678,11 +678,24 @@ describe("Home", () => { comfort: [], }); mount(); - fireEvent.click(await screen.findByText("A Show")); + fireEvent.click(await screen.findByRole("button", { name: "A Show" })); expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ replace: true })); expect(setPage).toHaveBeenCalledWith("plex"); }); + it("a show tile's title opens the show page; its picture plays on", async () => { + discover.mockResolvedValue({ + budget: "any", + hero: null, + continue_watching: [showCard()], + comfort: [], + }); + mount(); + fireEvent.click(await screen.findByText("A Show"), { detail: 1 }); + expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })); + expect(setJumpTo).not.toHaveBeenCalledWith(expect.objectContaining({ type: "play" })); + }); + it("sends the ⓘ affordance to the item's page, not to the player", async () => { // `onInfo` and `onPlay` differ by one ternary arm, and confusing them is the defect the play // test above was written for — but only `onPlay` was pinned, so swapping ⓘ's "movie" for "play" diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 2317a576..3f86a066 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -371,6 +371,7 @@ function Rail({ onAdd={plexAdd} next={c.next?.plex} onInfo={() => onInfo(c)} + onPage={() => onInfo(c)} menuProps={menuFor[i]} menuButtonProps={buttonFor[i]} menuOpen={menu.open && menu.target === c} diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index d8f9f0a6..be08e9a0 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -338,8 +338,11 @@ export default function PlexBrowse() { scrollRef.current = scroller()?.scrollTop ?? 0; sub.open({ kind: "player", id: card.id, fromStart: true }); } + // A card's page: a show's own page, a movie's or an episode's info page (the card menu's "Details", + // and a card title's link). function onInfo(card: PlexCard) { scrollRef.current = scroller()?.scrollTop ?? 0; + if (card.type === "show") return void sub.open({ kind: "show", id: card.id }); rememberInfoScroll(0); // fresh info page starts at the top sub.open({ kind: "info", id: card.id }); } @@ -461,6 +464,20 @@ export default function PlexBrowse() { onPlayItem={(id, fromStart) => sub.open({ kind: "player", id, fromStart })} onFilter={applyFilter} onOpenPerson={onOpenPerson} + // Up to the show or season: Back returns to this page where it was (as from a cast row). + onOpenShow={(showId) => { + rememberInfoScroll(scroller()?.scrollTop ?? 0); + sub.open({ kind: "show", id: showId }); + }} + onOpenSeason={(showId, seasonId) => { + rememberInfoScroll(scroller()?.scrollTop ?? 0); + sub.open({ kind: "season", showId, seasonId }); + }} + // A collection strip's title: another info page, from its top. + onOpenInfo={(id) => { + rememberInfoScroll(0); + sub.open({ kind: "info", id }); + }} /> ); } @@ -504,6 +521,7 @@ export default function PlexBrowse() { showId={showId} seasonId={seasonId} onBack={sub.back} + onInfoCard={onInfo} onPlay={(epRk, queue, fromStart) => sub.open({ kind: "player", id: epRk, queue, fromStart }) } @@ -707,6 +725,7 @@ export default function PlexBrowse() { navIndex={i} card={c} onOpen={() => onCard(c)} + onPage={onInfo} onRestart={() => onRestartCard(c)} onToggleWatched={toggleWatched} onAdd={actions.add} @@ -730,6 +749,7 @@ export default function PlexBrowse() { ep={ep} withShowTitle onPlay={() => onCard(ep)} + onPage={() => onInfo(ep)} onRestart={() => onRestartCard(ep)} onToggleWatched={() => toggleWatched(ep)} onAdd={() => epActions.add(ep)} diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index 2c2e4b9e..1358d65a 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -431,6 +431,7 @@ export default function PlexCollectionView({ card={c} navIndex={i} onOpen={() => onOpenCard(c)} + onPage={onInfoCard} onRestart={() => onRestartCard(c)} onToggleWatched={toggleWatched} onAdd={actions.add} diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index d6e93936..a00c2a7e 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -78,6 +78,7 @@ function CardRail({ card={c} navIndex={i} onOpen={() => onOpenCard(c)} + onPage={onInfoCard} onRestart={() => onRestartCard(c)} onToggleWatched={onToggleWatched} onAdd={actions.add} diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx index 556f03cb..1f1cc806 100644 --- a/frontend/src/components/PlexInfo.dom.test.tsx +++ b/frontend/src/components/PlexInfo.dom.test.tsx @@ -173,6 +173,55 @@ describe("PlexInfo enriched detail (page)", () => { }); }); +describe("an episode page links up to its show and season", () => { + const episode: PlexItemDetail = { + ...detail, + id: "e1", + kind: "episode", + title: "Pilot", + show_title: "A Show", + show_id: "s1", + season_id: "se1", + season_number: 1, + episode_number: 25, + tmdb: null, + }; + + it("the show title opens the show, the episode number its season", () => { + const onOpenShow = vi.fn(); + const onOpenSeason = vi.fn(); + wrap( + , + ); + fireEvent.click(screen.getByRole("button", { name: "A Show" })); + expect(onOpenShow).toHaveBeenCalledWith("s1"); + fireEvent.click(screen.getByRole("button", { name: "S1·E25" })); + expect(onOpenSeason).toHaveBeenCalledWith("s1", "se1"); + expect(screen.getByText(/— Pilot/)).toBeTruthy(); + }); + + it("over the running player the header stays plain text", () => { + wrap( + , + ); + expect(screen.queryByRole("button", { name: "A Show" })).toBeNull(); + expect(screen.queryByRole("button", { name: "S1·E25" })).toBeNull(); + expect(screen.getByText("S1·E25 — Pilot")).toBeTruthy(); + }); +}); + // The owner's layout: a main column (collection strips → crew → cast) beside a sidebar of // facts (details, keywords); a column with nothing in it is not rendered and the other takes the width. // The owner's pick: the hero's text stopped at ~55% of the panel. The facts and the key crew are diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 6a9a404d..260a8507 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -11,6 +11,8 @@ import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAsi import { DETAIL_POSTER, episodeLine, + episodeName, + episodeNumbers, tmdbBackdropUrl, useCrossfadeBackdrop, useDetailAside, @@ -186,6 +188,11 @@ type Props = { onPlayItem: (id: string, fromStart?: boolean) => void; // The item's library key — enables the admin "Manage collections" editor (page variant, movies). library?: string; + // Page variant, an episode: its show title opens the show page, its "S1·E25" the season page. + onOpenShow?: (showId: string) => void; + onOpenSeason?: (showId: string, seasonId: string) => void; + // Page variant: a collection strip's title opens that title's info page (its picture plays it). + onOpenInfo?: (id: string) => void; }; export default function PlexInfo({ @@ -198,6 +205,9 @@ export default function PlexInfo({ onOpenPerson, onPlayItem, library, + onOpenShow, + onOpenSeason, + onOpenInfo, }: Props) { const { t, i18n } = useTranslation(); const lang = i18n.language; @@ -299,6 +309,12 @@ export default function PlexInfo({ const asideShown = !overlay && aside.column; const infoTitle = detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title; + // Up from an episode: the show's title and the episode's season are links on the page (not over + // the running player, where leaving would stop it). + const showId = detail.kind === "episode" && !overlay ? detail.show_id : null; + const seasonId = detail.season_id; + const numbers = episodeNumbers(detail); + const upLink = "hover:text-accent hover:underline"; return (
@@ -312,11 +328,32 @@ export default function PlexInfo({ {overlay ? (

{infoTitle}

) : ( - {infoTitle} + + {showId && onOpenShow && detail.show_title ? ( + onOpenShow(showId)}> + {infoTitle} + + ) : ( + infoTitle + )} + )} {detail.kind === "episode" && (

- {episodeLine(detail)} + {showId && seasonId && onOpenSeason && numbers ? ( + <> + onOpenSeason(showId, seasonId)} + > + {episodeName(detail)} + + {` — ${detail.title}`} + + ) : ( + episodeLine(detail) + )}

)}
@@ -576,6 +613,7 @@ export default function PlexInfo({ shelf navIndex={i} onOpen={() => onPlayItem(m.id)} + onPage={!overlay && onOpenInfo ? (c) => onOpenInfo(c.id) : undefined} onRestart={() => onPlayItem(m.id, true)} onToggleWatched={toggleColWatched} onAdd={colActions.add} diff --git a/frontend/src/components/plexCards.dom.test.tsx b/frontend/src/components/plexCards.dom.test.tsx index cd7b6df7..a8e5a6f5 100644 --- a/frontend/src/components/plexCards.dom.test.tsx +++ b/frontend/src/components/plexCards.dom.test.tsx @@ -313,7 +313,7 @@ describe("Plex cards in a keyboard-walkable list", () => { describe("a Plex card's title stays selectable", () => { it("opens from its caption, but not on the click that ends selecting the title", () => { const onOpen = vi.fn(); - render(); + render(); const title = screen.getByText("M"); const range = document.createRange(); range.selectNodeContents(title); @@ -327,10 +327,47 @@ describe("a Plex card's title stays selectable", () => { }); }); +// The picture plays; the title, lit like a link, opens the page of what it names (owner). +describe("a Plex card's title is a link to its page", () => { + it("a movie's title opens its page and never plays it", () => { + const onOpen = vi.fn(); + const onPage = vi.fn(); + render(); + fireEvent.click(screen.getByText("M"), { detail: 1 }); + expect(onPage).toHaveBeenCalledWith(expect.objectContaining({ id: "1", type: "movie" })); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it("an episode standing as its show opens the show", () => { + const onPage = vi.fn(); + const ep: PlexCard = { ...episode(), show_id: "s9", show_title: "The Show" }; + render(); + fireEvent.click(screen.getByText("The Show"), { detail: 1 }); + expect(onPage).toHaveBeenCalledWith(expect.objectContaining({ id: "s9", type: "show" })); + }); + + it("an episode card's title opens the episode's page, its picture plays", () => { + const onPlay = vi.fn(); + const onPage = vi.fn(); + render(); + fireEvent.click(screen.getByText(episode().title), { detail: 1 }); + expect(onPage).toHaveBeenCalledTimes(1); + expect(onPlay).not.toHaveBeenCalled(); + }); + + it("without a page the title is plain text: a click does nothing", () => { + const onOpen = vi.fn(); + render(); + fireEvent.click(screen.getByText("M"), { detail: 1 }); + expect(onOpen).not.toHaveBeenCalled(); + expect(screen.getByText("M").className).not.toContain("cursor-pointer"); + }); +}); + describe("a Plex card's caption and a drag", () => { it("does not open on a press dragged past the slop", () => { const onOpen = vi.fn(); - render(); + render(); const title = screen.getByText("M"); fireEvent.mouseDown(title, { clientX: 10, clientY: 10 }); fireEvent.click(title, { detail: 1, clientX: 60, clientY: 10 }); diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 8fdfaeb5..a3866f13 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -139,9 +139,11 @@ export function PlexHoverBar({ ); } -/** A Plex card's title: it opens the card like its picture does, except for the click that ends - * selecting its words, or a press dragged past the slop (DESIGN "Text stays selectable"); a div's - * text selects on its own, so only the click needs the guard. */ +/** A Plex card's title: a link to the page of what it names (a movie's or an episode's info page, a + * show's page), while the picture plays it — a title lit like a link that started playback surprised + * (owner). Not for the click that ends selecting its words, or a press dragged past the slop (DESIGN + * "Text stays selectable"); a div's text selects on its own, so only the click needs the guard. + * Without a page to open it is plain text. */ function CardTitle({ onClick, shelf = false, @@ -267,9 +269,13 @@ export function PlexPosterCard({ next, onInfo, onRestart, + onPage, }: { card: PlexCard; onOpen: () => void; + /** Open a card's page (a show's, or a movie's or an episode's info page): the title's link. It is + * handed the card the title NAMES — the show, for an episode standing as its show. */ + onPage?: (target: PlexCard) => void; /** Play the item (or the `next` episode) from its start, not its resume point (C5). */ onRestart?: () => void; onToggleWatched?: (c: PlexCard) => void; @@ -306,6 +312,9 @@ export function PlexPosterCard({ const asShow = card.type === "episode" && !wide && !!card.show_id && !!card.show_title; const landscape = wide || (card.type === "episode" && !asShow); const name = asShow ? `${card.show_title} · ${card.title}` : card.title; + const titled: PlexCard = asShow + ? { ...card, type: "show", id: card.show_id!, title: card.show_title! } + : card; const ep = card.type === "show" ? (next ?? null) : null; // movies/episodes play; a show opens its drill-down — unless it plays on (`next`) const isPlayable = card.type !== "show" || !!ep; @@ -385,7 +394,7 @@ export function PlexPosterCard({ } title={ - + onPage(titled))} shelf={shelf}> {asShow ? card.show_title : card.title} } @@ -655,6 +664,8 @@ export function RelatedStrip({ shelf navIndex={i} onOpen={() => onOpen(r)} + // A related title is a show: its page is what both its picture and its title open. + onPage={onOpen} menuProps={menuProps} menuButtonProps={menuButtonProps} menuOpen={openId === r.id} @@ -677,9 +688,12 @@ export function EpisodeCard({ menuButtonProps, menuOpen = false, navIndex, + onPage, }: { ep: PlexCard; onPlay: () => void; + /** The episode's info page: the title's link (the picture plays). */ + onPage?: () => void; /** Play the episode from its start, not its resume point (C5). */ onRestart?: () => void; onAdd?: () => void; @@ -732,7 +746,7 @@ export function EpisodeCard({ } title={ - + {!withShowTitle && ep.episode_number != null && ( {ep.episode_number}. )} diff --git a/frontend/src/components/plexViews.restart.dom.test.tsx b/frontend/src/components/plexViews.restart.dom.test.tsx index 61a86103..45458e93 100644 --- a/frontend/src/components/plexViews.restart.dom.test.tsx +++ b/frontend/src/components/plexViews.restart.dom.test.tsx @@ -108,7 +108,15 @@ const ALL = ["e11", "e12", "e21", "e22"]; describe("the restart button on a show and a season", () => { it("a season's names the season and its first episode, and starts that episode at 0", async () => { const onPlay = vi.fn(); - wrap( {}} onPlay={onPlay} />); + wrap( + {}} + onPlay={onPlay} + onInfoCard={() => {}} + />, + ); const btn = await screen.findByRole("button", { name: /plex\.series\.seasonFromStart/ }); expect(btn.textContent).toContain("S2 · E1"); fireEvent.click(btn); diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 61c40bc3..0500729b 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -78,6 +78,9 @@ export function PlexInfoView({ onPlayItem, onFilter, onOpenPerson, + onOpenShow, + onOpenSeason, + onOpenInfo, }: { id: string; onBack: () => void; @@ -85,6 +88,9 @@ export function PlexInfoView({ onPlayItem: (id: string, fromStart?: boolean) => void; onFilter: (patch: Partial) => void; onOpenPerson: (name: string) => void; + onOpenShow: (showId: string) => void; + onOpenSeason: (showId: string, seasonId: string) => void; + onOpenInfo: (id: string) => void; }) { const { t, i18n } = useTranslation(); const lang = plexLang(i18n.language); @@ -113,6 +119,9 @@ export function PlexInfoView({ onPlayItem={onPlayItem} onFilter={onFilter} onOpenPerson={onOpenPerson} + onOpenShow={onOpenShow} + onOpenSeason={onOpenSeason} + onOpenInfo={onOpenInfo} // No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very // query), so q refetches automatically — a manual refetch here just double-fetched. /> @@ -238,6 +247,7 @@ export function PlexPersonView({ navIndex={i} card={c} onOpen={() => onOpenCard(c)} + onPage={onInfoCard} onToggleWatched={toggle} {...cardMenu(c)} /> @@ -372,6 +382,7 @@ export function PlexPersonView({ card={c} navIndex={i} onOpen={() => onOpenCard(c)} + onPage={onInfoCard} onToggleWatched={toggle} {...cardMenu(c)} /> @@ -765,11 +776,13 @@ export function PlexSeasonView({ seasonId, onBack, onPlay, + onInfoCard, }: { showId: string; seasonId: string; onBack: () => void; onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void; + onInfoCard: (card: PlexCard) => void; }) { const { t, i18n } = useTranslation(); const qc = useQueryClient(); @@ -822,6 +835,7 @@ export function PlexSeasonView({ { onOpen: (ep) => onPlay(ep.id, allKeys), onRestart: (ep) => onPlay(ep.id, allKeys, true), + onInfo: onInfoCard, onToggleWatched: (ep) => void markEpisode(ep), }, epMenu.open, @@ -918,6 +932,7 @@ export function PlexSeasonView({ navIndex={i} ep={ep} onPlay={() => onPlay(ep.id, allKeys)} + onPage={() => onInfoCard(ep)} onRestart={() => onPlay(ep.id, allKeys, true)} onAdd={() => episodes.add(ep)} onToggleWatched={() => markEpisode(ep)} diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index e538ad6a..922eea7c 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -180,6 +180,8 @@ export interface PlexItemDetail { status: string; position_seconds: number; show_title?: string | null; + show_id?: string | null; // an episode's show and season rating keys (its page links up to them) + season_id?: string | null; season_number?: number | null; episode_number?: number | null; prev_id?: string | null;