diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py
index ac981005..76aa0b02 100644
--- a/backend/app/routes/plex/detail.py
+++ b/backend/app/routes/plex/detail.py
@@ -432,6 +432,7 @@ def item_detail(
"summary": (it.summary_hu or it.summary) if hu else it.summary,
"year": it.year,
"duration_seconds": it.duration_s,
+ "size_bytes": it.size_bytes,
"playable": it.playable,
# Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows.
"transcode_hw": it.transcode_hw,
diff --git a/backend/tests/test_plex_detail.py b/backend/tests/test_plex_detail.py
index b8e2ec3d..19772e9f 100644
--- a/backend/tests/test_plex_detail.py
+++ b/backend/tests/test_plex_detail.py
@@ -219,6 +219,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch,
library_id=lib.id,
kind="movie",
title="The Godfather",
+ size_bytes=15_000_000_000,
genres=["Crime", "Drama"],
directors=["Francis Ford Coppola"],
studio="Paramount",
@@ -238,6 +239,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch,
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1")
assert resp.status_code == 200, resp.text
body = resp.json()
+ assert body["size_bytes"] == 15_000_000_000
assert body["imdb_rating"] == 8.7
assert body["imdb_id"] == "tt0068646"
assert body["imdb_url"] == "https://www.imdb.com/title/tt0068646/"
diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts
index 5ca6e261..7524ccd0 100644
--- a/frontend/src/lib/api/plex.ts
+++ b/frontend/src/lib/api/plex.ts
@@ -154,6 +154,7 @@ export interface PlexItemDetail {
summary?: string | null;
year?: number | null;
duration_seconds: number | null;
+ size_bytes?: number | null; // the file's, null while unknown
playable: string; // direct | remux | transcode
transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a
codec_video?: string | null; // e.g. "hevc" / "h264" (info-chip subtitle)
diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts
index 13a5a166..5f7b10cb 100644
--- a/frontend/src/lib/plexPlayerHooks.ts
+++ b/frontend/src/lib/plexPlayerHooks.ts
@@ -434,6 +434,9 @@ export function usePlayerVolume(opts: {
return { setVol, nudgeVolume, toggleMute, onWheel };
}
+// How long a marker plays before an auto-skip's countdown starts (seconds).
+const AUTO_SKIP_WAIT_S = 3;
+
// Auto-skip intro/credits. Entering a marker whose auto-skip is on runs a countdown (skipProgress
// 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — to the marker end, or
// for the credits that end the item to the next item (binge). Which credits those are, and which the
@@ -538,14 +541,16 @@ export function useAutoSkip(opts: {
const enabled = (m.type === "intro" ? autoSkipIntro : autoSkipCredits) && activeAuto;
if (!enabled || suspended || cancelledMarkerRef.current === key) return setSkipProgress(null);
if (autoSkipDelay <= 0) return doSkip(m); // immediate
- let elapsed = 0;
+ // Below zero is the wait: time to see where the marker has landed and press the button, which
+ // shows from the start, before anything counts down to leaving. A delay of 0 asked for none.
+ let elapsed = -AUTO_SKIP_WAIT_S;
setSkipProgress(0);
const iv = window.setInterval(() => {
// Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a
// deliberately-paused video. It resumes from where it stopped when playback resumes.
if (videoRef.current?.paused) return;
elapsed += 0.1;
- const p = Math.min(1, elapsed / autoSkipDelay);
+ const p = Math.max(0, Math.min(1, elapsed / autoSkipDelay));
setSkipProgress(p);
if (p >= 1) {
window.clearInterval(iv);
diff --git a/frontend/src/modules/channels/TagFilterChips.dom.test.tsx b/frontend/src/modules/channels/TagFilterChips.dom.test.tsx
index 198fe12c..b8e5b9ea 100644
--- a/frontend/src/modules/channels/TagFilterChips.dom.test.tsx
+++ b/frontend/src/modules/channels/TagFilterChips.dom.test.tsx
@@ -100,3 +100,18 @@ describe("TagFilterChips — the overflow menu", () => {
expect(menu()).toBeNull();
});
});
+
+describe("TagFilterChips counts", () => {
+ it("draws a chip's count after its name, and none when it has none", () => {
+ fit.visible = 2;
+ render(
+ ,
+ );
+ const name = (label: string) =>
+ screen.getAllByRole("button").find((b) => b.textContent?.startsWith(label));
+ expect(name("alpha")?.textContent).toBe("alpha42");
+ expect(name("beta")?.textContent).toBe("beta0");
+ });
+});
diff --git a/frontend/src/modules/channels/TagFilterChips.tsx b/frontend/src/modules/channels/TagFilterChips.tsx
index 9eb2ea4a..243883a0 100644
--- a/frontend/src/modules/channels/TagFilterChips.tsx
+++ b/frontend/src/modules/channels/TagFilterChips.tsx
@@ -19,6 +19,8 @@ import { ChipLabelPending } from "../../components/ui/Skeleton";
export interface FilterChip {
key: string;
label: string;
+ /** How many it holds, drawn muted after the name. */
+ count?: number;
/** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
pending?: boolean;
active: boolean;
@@ -28,7 +30,13 @@ export interface FilterChip {
// A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror
// measures what shows (ActiveFilterChips' ChipText, for this row's chips).
function ChipText({ chip }: { chip: FilterChip }) {
- return chip.pending ? : <>{chip.label}>;
+ if (chip.pending) return ;
+ return (
+ <>
+ {chip.label}
+ {chip.count != null && {chip.count}}
+ >
+ );
}
function TagChip({ chip }: { chip: FilterChip }) {
@@ -53,7 +61,7 @@ export default function TagFilterChips({
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
// width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
const key = chips
- .map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
+ .map((c) => `${c.key}:${c.label}:${c.count ?? ""}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
.join("|");
const visible = useChipOverflowFit({
diff --git a/frontend/src/modules/channels/channelColumns.dom.test.tsx b/frontend/src/modules/channels/channelColumns.dom.test.tsx
index 977f10b5..7149d1e0 100644
--- a/frontend/src/modules/channels/channelColumns.dom.test.tsx
+++ b/frontend/src/modules/channels/channelColumns.dom.test.tsx
@@ -186,3 +186,28 @@ describe("buildChannelColumns — Added", () => {
expect(screen.getByText(formatDate("2026-10-12T08:00:00+00:00", "en"))).toBeTruthy();
});
});
+
+describe("tagFilterChips", () => {
+ it("counts a tag's channels, and only a tag's", () => {
+ const tag = {
+ id: 3,
+ name: "Tech",
+ color: null,
+ category: "topic",
+ system: false,
+ channel_count: 17,
+ };
+ const chips = tagFilterChips({
+ userTags: [tag],
+ tags: { isPending: false } as never,
+ picked: ["99"],
+ noTagOn: false,
+ toggle: () => {},
+ toggleNoTag: () => {},
+ t: ((k: string) => k) as never,
+ });
+ expect(chips.find((c) => c.label === "Tech")?.count).toBe(17);
+ expect(chips.find((c) => c.key === "t99")?.count).toBeUndefined();
+ expect(chips[chips.length - 1]?.count).toBeUndefined();
+ });
+});
diff --git a/frontend/src/modules/channels/channelColumns.tsx b/frontend/src/modules/channels/channelColumns.tsx
index 1c8899c0..736c0b51 100644
--- a/frontend/src/modules/channels/channelColumns.tsx
+++ b/frontend/src/modules/channels/channelColumns.tsx
@@ -78,6 +78,7 @@ export function tagFilterChips(o: {
...o.userTags.map((tg) => ({
key: `t${tg.id}`,
label: tg.name,
+ count: tg.channel_count,
active: o.picked.includes(String(tg.id)),
onToggle: () => o.toggle(tg.id),
})),
diff --git a/frontend/src/modules/plex/PlexBrowse.tsx b/frontend/src/modules/plex/PlexBrowse.tsx
index 86f31603..8cbbe24e 100644
--- a/frontend/src/modules/plex/PlexBrowse.tsx
+++ b/frontend/src/modules/plex/PlexBrowse.tsx
@@ -579,7 +579,16 @@ function PlexBrowseView({ sub }: { sub: ReturnType
onRestartCard={onRestartCard}
onInfoCard={onInfo}
onGenre={(g) => applyFilter({ genres: [g] })}
- onSeeAllRecent={() => sub.open({ kind: "grid" })}
+ // Each opens the list its rail is the start of: the watch state goes with it, and
+ // entering in-progress brings the last-watched sort (the effect above).
+ onSeeAllRecent={() => {
+ setShow("all");
+ sub.open({ kind: "grid" });
+ }}
+ onSeeAllContinue={() => {
+ setShow("in_progress");
+ sub.open({ kind: "grid" });
+ }}
/>
);
diff --git a/frontend/src/modules/plex/PlexDiscover.dom.test.tsx b/frontend/src/modules/plex/PlexDiscover.dom.test.tsx
index fdc257cc..b173b5f0 100644
--- a/frontend/src/modules/plex/PlexDiscover.dom.test.tsx
+++ b/frontend/src/modules/plex/PlexDiscover.dom.test.tsx
@@ -105,6 +105,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
+ onSeeAllContinue={noop}
/>,
);
await screen.findByText("plex.discover.continueWatching");
@@ -144,6 +145,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
+ onSeeAllContinue={noop}
/>
,
);
@@ -179,12 +181,34 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={onGenre}
onSeeAllRecent={noop}
+ onSeeAllContinue={noop}
/>,
);
fireEvent.click(await screen.findByText("Drama"));
expect(onGenre).toHaveBeenCalledWith("Drama");
});
+ it("fires onSeeAllContinue from the Continue watching rail's see-all", async () => {
+ const onSeeAllContinue = vi.fn();
+ const onSeeAllRecent = vi.fn();
+ plexDiscover.mockResolvedValue({ ...fullPayload, recently_added: [] });
+ plexSurprise.mockResolvedValue({ scope: "both", items: [] });
+ wrap(
+ {}}
+ onInfoCard={noop}
+ onGenre={noop}
+ onSeeAllRecent={onSeeAllRecent}
+ onSeeAllContinue={onSeeAllContinue}
+ />,
+ );
+ fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" }));
+ expect(onSeeAllContinue).toHaveBeenCalledOnce();
+ expect(onSeeAllRecent).not.toHaveBeenCalled();
+ });
+
it("fires onSeeAllRecent from the recently-added rail's see-all", async () => {
plexDiscover.mockResolvedValue(fullPayload);
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
@@ -197,9 +221,12 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={onSeeAllRecent}
+ onSeeAllContinue={noop}
/>,
);
- fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" }));
+ // The last of them: Continue watching, above it, has its own.
+ const seeAll = await screen.findAllByRole("button", { name: "common.seeAll" });
+ fireEvent.click(seeAll[seeAll.length - 1]!);
expect(onSeeAllRecent).toHaveBeenCalledOnce();
});
@@ -214,6 +241,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
+ onSeeAllContinue={noop}
/>,
);
await screen.findByText("Random Pick");
@@ -250,6 +278,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
+ onSeeAllContinue={noop}
/>
);
@@ -297,6 +326,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
+ onSeeAllContinue={noop}
/>
,
);
@@ -338,6 +368,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
+ onSeeAllContinue={noop}
/>,
);
await screen.findByText("plex.discover.empty");
@@ -363,6 +394,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
+ onSeeAllContinue={noop}
/>,
);
fireEvent.click(await screen.findByLabelText("plex.markWatched"));
@@ -386,6 +418,7 @@ describe("PlexDiscover · keyboard", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
+ onSeeAllContinue={noop}
/>,
);
await screen.findAllByText("Random Pick");
diff --git a/frontend/src/modules/plex/PlexDiscover.tsx b/frontend/src/modules/plex/PlexDiscover.tsx
index 19a8d3d1..0908dfd6 100644
--- a/frontend/src/modules/plex/PlexDiscover.tsx
+++ b/frontend/src/modules/plex/PlexDiscover.tsx
@@ -101,6 +101,7 @@ export default function PlexDiscover({
onInfoCard,
onGenre,
onSeeAllRecent,
+ onSeeAllContinue,
}: {
scope: string;
onOpenCard: (c: PlexCard) => void;
@@ -108,6 +109,7 @@ export default function PlexDiscover({
onInfoCard: (c: PlexCard) => void;
onGenre: (genre: string) => void;
onSeeAllRecent: () => void;
+ onSeeAllContinue: () => void;
}) {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
@@ -233,6 +235,7 @@ export default function PlexDiscover({
diff --git a/frontend/src/modules/plex/PlexInfo.dom.test.tsx b/frontend/src/modules/plex/PlexInfo.dom.test.tsx
index eb2a9f0e..88a2f5b1 100644
--- a/frontend/src/modules/plex/PlexInfo.dom.test.tsx
+++ b/frontend/src/modules/plex/PlexInfo.dom.test.tsx
@@ -426,3 +426,17 @@ describe("a collection strip's card", () => {
await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2"));
});
});
+
+describe("PlexInfo size", () => {
+ const show = (over: Partial) =>
+ wrap();
+ it("names the file's size beside the runtime", () => {
+ show({ size_bytes: 15_000_000_000 });
+ expect(screen.getByText(/^1[45](\.\d)? GB$/)).toBeTruthy();
+ });
+ it("names none when it is not known", () => {
+ show({ size_bytes: null });
+ expect(screen.getByText("Test Movie")).toBeTruthy();
+ expect(screen.queryByText(/ GB$/)).toBeNull();
+ });
+});
diff --git a/frontend/src/modules/plex/PlexInfo.tsx b/frontend/src/modules/plex/PlexInfo.tsx
index ad2f46e1..c1f99b96 100644
--- a/frontend/src/modules/plex/PlexInfo.tsx
+++ b/frontend/src/modules/plex/PlexInfo.tsx
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api";
-import { formatCompact, formatDecimal, formatRuntime } from "../../lib/format";
+import { formatBytes, formatCompact, formatDecimal, formatRuntime } from "../../lib/format";
import { notifyActionError } from "../../lib/actionErrors";
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
@@ -452,6 +452,9 @@ export default function PlexInfo({
{detail.duration_seconds ? (
{formatRuntime(detail.duration_seconds, lang)}
) : null}
+ {detail.size_bytes ? (
+ {formatBytes(detail.size_bytes, lang)}
+ ) : null}
{detail.studio && (
{
expect(screen.getByTestId("clip-panel")).toBeTruthy();
});
+ describe("the wait before an auto-skip's countdown", () => {
+ const withCredits = {
+ ...item("a", 0, "b"),
+ markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }],
+ } as unknown as PlexItemDetail;
+ const advance = (ms: number) =>
+ act(() => {
+ vi.advanceTimersByTime(ms);
+ });
+
+ // Into the marker with the clock already faked, so the countdown's interval is the fake one's.
+ const enterMarker = (paused: boolean) => {
+ vi.useFakeTimers();
+ const v = video();
+ v._paused = paused;
+ v._t = 2910;
+ act(() => void v.dispatchEvent(new Event("timeupdate")));
+ };
+
+ it("holds the countdown for 3 s after the marker starts, then runs it", async () => {
+ prefs({ autoSkipCredits: true, autoSkipDelay: 1 });
+ // Leaving a final marker writes "watched" as it skips, in the same tick: the next item's
+ // session comes only after promises this clock does not run.
+ const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
+ mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
+ await playingAt("/media/a", 2800);
+ enterMarker(false);
+ advance(3500); // past what the delay alone would have waited
+ expect(left).not.toHaveBeenCalled();
+ advance(1000);
+ expect(left).toHaveBeenCalledWith("a", "watched");
+ });
+
+ it("a delay of 0 is still immediate", async () => {
+ prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
+ const { session } = mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
+ await playingAt("/media/a", 2910);
+ await waitFor(() => expect(lastStart(session)?.[0]).toBe("b"));
+ });
+
+ it("waits out the pause: a paused video does not use up the 3 s", async () => {
+ prefs({ autoSkipCredits: true, autoSkipDelay: 1 });
+ const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
+ mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
+ await playingAt("/media/a", 2800);
+ enterMarker(true);
+ advance(10_000);
+ expect(left).not.toHaveBeenCalled();
+ });
+ });
+
it("does not auto-skip while a clip is marked: the skip would carry the player off it", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const withCredits = {