diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index 8ae9849d..fb6e7d6c 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -6,6 +6,8 @@ import { qk } from "../lib/queryKeys"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api"; import { formatRuntime } from "../lib/format"; import { notifyActionError, notifyCollectionEditError } from "../lib/actionErrors"; +import { notify } from "../lib/notifications"; +import { showEpisodeKeys } from "../lib/plexQueue"; import { useConfirm } from "./ConfirmProvider"; import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi"; import { PlexPosterCard } from "./plexCards"; @@ -87,7 +89,7 @@ export default function PlexCollectionView({ queryKey: qk.plexShow(c.id), queryFn: () => api.plexShow(c.id), }); - return detail.seasons.flatMap((s) => s.episodes.map((e) => e.id)); + return showEpisodeKeys(detail); }), ); return perMember.flat(); @@ -98,7 +100,10 @@ export default function PlexCollectionView({ try { let queue = await buildQueue(); if (shuffle) queue = shuffled(queue); + // canPlay gates on "has a show member", but a show can expand to zero episodes (unmatched / + // empty). An enabled Play-all that silently no-ops is a dishonest state, so say so plainly. if (queue.length > 0) onPlay(queue[0]!, queue); + else notify({ level: "info", message: t("plex.collection.playEmpty") }); } catch (e) { notifyActionError(e, t("plex.collection.playFailed")); } finally { diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 7f9b0d1d..2aa2c5d4 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -8,6 +8,7 @@ import { plexLang } from "../i18n"; import { pickRatingBadge } from "../lib/plexRating"; import { notifyActionError } from "../lib/actionErrors"; import { toggleWatchState } from "../lib/plexWatch"; +import { showEpisodeKeys } from "../lib/plexQueue"; import { ActionBtn, BackBtn, @@ -306,7 +307,7 @@ export function PlexShowView({ const show = d?.show; const showLib = show?.library ?? undefined; - const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id)); + const allKeys = showEpisodeKeys(d); const watched = show?.status === "watched"; const { artBg, @@ -568,7 +569,7 @@ export function PlexSeasonView({ // Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls // straight into the next season's first — the same cross-season order the show page already uses. // seasonKeys stays only for the season-scoped "add to playlist" action. - const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id)); + const allKeys = showEpisodeKeys(d); const watched = se?.status === "watched"; const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade } = useDetailPrefs(); useCrossfadeBackdrop( diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index a8251d79..e46f7523 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -24,6 +24,7 @@ "deleteConfirm": "Delete the collection \"{{title}}\"? This removes it from Plex too.", "editFailed": "Collection edit failed", "playFailed": "Couldn't build the play queue — try again.", + "playEmpty": "Nothing to play in this collection yet.", "newCollection": "New collection", "namePlaceholder": "Collection name", "nameTaken": "A collection with this name already exists.", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 587c8db0..135848c0 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -24,6 +24,7 @@ "deleteConfirm": "Törlöd a(z) „{{title}}” kollekciót? Ez a Plexből is eltávolítja.", "editFailed": "A kollekció szerkesztése sikertelen", "playFailed": "Nem sikerült összeállítani a lejátszási sort — próbáld újra.", + "playEmpty": "Ebben a kollekcióban még nincs mit lejátszani.", "newCollection": "Új kollekció", "namePlaceholder": "Kollekció neve", "nameTaken": "Már létezik ilyen nevű kollekció.", diff --git a/frontend/src/lib/plexQueue.test.ts b/frontend/src/lib/plexQueue.test.ts new file mode 100644 index 00000000..b809b45f --- /dev/null +++ b/frontend/src/lib/plexQueue.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { showEpisodeKeys } from "./plexQueue"; +import type { PlexCard, PlexSeasonDetail, PlexShowDetail } from "./api"; + +const ep = (id: string): PlexCard => ({ id }) as PlexCard; +const season = (episodes: PlexCard[]): PlexSeasonDetail => + ({ episodes }) as unknown as PlexSeasonDetail; +const show = (seasons: PlexSeasonDetail[]): PlexShowDetail => + ({ seasons }) as unknown as PlexShowDetail; + +describe("showEpisodeKeys", () => { + it("flattens seasons in order, episodes in order, concatenated across seasons", () => { + const detail = show([ + season([ep("s1e1"), ep("s1e2")]), + season([ep("s2e1"), ep("s2e2"), ep("s2e3")]), + ]); + expect(showEpisodeKeys(detail)).toEqual(["s1e1", "s1e2", "s2e1", "s2e2", "s2e3"]); + }); + + it("returns [] for a show with no seasons", () => { + expect(showEpisodeKeys(show([]))).toEqual([]); + }); + + it("returns [] for a season with no episodes (the enabled-but-empty Play-all case)", () => { + expect(showEpisodeKeys(show([season([])]))).toEqual([]); + }); + + it("returns [] for null/undefined so an in-flight query's data passes straight through", () => { + expect(showEpisodeKeys(null)).toEqual([]); + expect(showEpisodeKeys(undefined)).toEqual([]); + }); +}); diff --git a/frontend/src/lib/plexQueue.ts b/frontend/src/lib/plexQueue.ts new file mode 100644 index 00000000..052d7c35 --- /dev/null +++ b/frontend/src/lib/plexQueue.ts @@ -0,0 +1,10 @@ +import type { PlexShowDetail } from "./api"; + +// The ordered episode rating-keys of a show: season order, then episode order within each season — +// the play-queue order the show/season pages and the collection Play-all all rely on. Kept in ONE +// place so that invariant (and any future explicit sort, e.g. pushing season 0 "Specials" last) +// lives at a single site rather than the three hand-rolled `seasons.flatMap(...)` copies it grew +// into. `detail` is nullable so callers can pass an in-flight query's `data` straight through. +export function showEpisodeKeys(detail: PlexShowDetail | null | undefined): string[] { + return (detail?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id)); +}