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;