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;