From dae0b80f6ad4d1fa1b2943d80acd58bf4d6e335c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 03:09:55 +0200 Subject: [PATCH] feat(plex): add a movie or show to a collection from its card menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Add to collection…" opens a panel beside the card: the title's library's manual collections tick it in or out, and a name + Enter creates a new one with the title in it. It used to take the Collections page, a new collection, its name and a title search, then another search per title. A lean endpoint lists the collections with whether each holds the title. Admin only, as on the title's page. Also: a collection seeded with a show is made as a show collection (Plex type 2). Measured: as a movie one it came out empty and refused the show after. --- backend/app/plex/client.py | 10 +- backend/app/routes/plex/collections.py | 40 ++++- backend/tests/test_plex_collections_filter.py | 20 ++- .../tests/test_plex_collections_for_item.py | 82 ++++++++++ docs/DESIGN.md | 1 + frontend/src/components/Home.tsx | 2 +- frontend/src/components/PlexCollectionAdd.tsx | 148 ++++++++++++++++++ frontend/src/components/PlexMenu.dom.test.tsx | 72 ++++++++- frontend/src/components/PlexMenu.tsx | 2 +- .../src/components/usePlexCardActions.tsx | 41 ++++- frontend/src/i18n/locales/en/plex.json | 6 + frontend/src/i18n/locales/hu/plex.json | 6 + frontend/src/lib/api/plex.ts | 6 + 13 files changed, 421 insertions(+), 15 deletions(-) create mode 100644 backend/tests/test_plex_collections_for_item.py create mode 100644 frontend/src/components/PlexCollectionAdd.tsx diff --git a/backend/app/plex/client.py b/backend/app/plex/client.py index bcdcb605..99ecc24f 100644 --- a/backend/app/plex/client.py +++ b/backend/app/plex/client.py @@ -167,12 +167,16 @@ class PlexClient: keys = ",".join(str(k) for k in rating_keys) return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{keys}" - def create_collection(self, section_key: str, title: str, item_rating_key: str) -> dict: - """Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey).""" + def create_collection( + self, section_key: str, title: str, item_rating_key: str, item_type: int = 1 + ) -> dict: + """Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey). + `item_type` is the Plex metadata type of its members — 1 movie, 2 show. Measured: a collection + made as type 1 in a TV section came out EMPTY with a show seed, and refused the show after.""" uri = quote(self._metadata_uri(item_rating_key), safe="") mc = self._write( "POST", - f"/library/collections?type=1&smart=0§ionId={section_key}" + f"/library/collections?type={item_type}&smart=0§ionId={section_key}" f"&title={quote(title, safe='')}&uri={uri}", ) items = mc.get("Metadata", []) or [] diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py index 0dfdde83..3e791adb 100644 --- a/backend/app/routes/plex/collections.py +++ b/backend/app/routes/plex/collections.py @@ -176,6 +176,40 @@ def collections( } +@router.get("/collections/for/{item_rating_key}") +def collections_for_item( + item_rating_key: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """What a movie or show card's "Add to collection" lists: the editable collections of the title's + library (a collection lives in one library, and only a manual one can take a title — the same + `can_edit` the editor offers), each saying whether the title is in it already, by name.""" + movie = by_rating_key(db, PlexItem, item_rating_key) + title: PlexItem | PlexShow | None = ( + movie if movie is not None and movie.kind == "movie" else None + ) or by_rating_key(db, PlexShow, item_rating_key) + if title is None: + raise not_found("item") + member_of = set(title.collection_keys or []) + rows = ( + db.execute( + select(PlexCollection) + .where(PlexCollection.library_id == title.library_id) + .order_by(collated(PlexCollection.title, lang_for_user(user))) + ) + .scalars() + .all() + ) + return { + "collections": [ + {**_collection_card(c), "contains": c.rating_key in member_of} + for c in rows + if _collection_editable(c) + ] + } + + class Member(NamedTuple): """One collection member flattened to everything the Sort control can order by. The page's two legs are different tables (movies and shows), so there is no single query to ORDER BY — the @@ -407,13 +441,14 @@ def create_collection( seed = body.item_rating_key if not title or not seed: raise refused(400, "collection_fields_required") + seed_show = by_rating_key(db, PlexShow, seed) if library: lib = ( db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first() ) else: # The "New collection" flow only knows the seed title → derive its library. - seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed) + seed_item = by_rating_key(db, PlexItem, seed) or seed_show lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None if lib is None: raise not_found("library") @@ -421,7 +456,8 @@ def create_collection( wrote = False try: with PlexClient(db) as plex: - new = plex.create_collection(lib.plex_key, title, seed) + # A show collection is made as one (Plex type 2): as a movie one it stayed empty. + new = plex.create_collection(lib.plex_key, title, seed, 2 if seed_show else 1) wrote = True col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey"))) col.editable = True diff --git a/backend/tests/test_plex_collections_filter.py b/backend/tests/test_plex_collections_filter.py index c90c2319..60048cbb 100644 --- a/backend/tests/test_plex_collections_filter.py +++ b/backend/tests/test_plex_collections_filter.py @@ -5,6 +5,7 @@ DB lane (`db` fixture) — skipped without Postgres. from datetime import datetime, timezone +import pytest from fastapi.testclient import TestClient from sqlalchemy import select @@ -224,8 +225,9 @@ class _CollStub: if self.fail_on == name: raise PlexError("boom") - def create_collection(self, section_key, title, item_rating_key): + def create_collection(self, section_key, title, item_rating_key, item_type=1): self._maybe("create_collection") + self.created_type = item_type return {"ratingKey": "NEW"} def add_collection_item(self, rk, item): @@ -320,6 +322,22 @@ def test_create_writes_through_to_plex(db, monkeypatch, override_dep): assert body["editable"] is True # create marks the new collection editable +@pytest.mark.parametrize(("seed", "plex_type"), [("s1", 2), ("m1", 1)]) +def test_create_makes_a_show_collection_as_one(db, monkeypatch, override_dep, seed, plex_type): + # Measured on a real Plex: a collection made as a movie one (type 1) in a TV section came out + # empty with a show seed, and refused the show when it was added after. + lib, user = _seed_editable(db) + db.add(PlexShow(rating_key="s1", library_id=lib.id, title="Series")) + db.commit() + stub = _CollStub() + client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_create) + r = client.post( + "/api/plex/collections", json={"library": "", "title": "New", "item_rating_key": seed} + ) + assert r.status_code == 200, r.text + assert stub.created_type == plex_type + + def test_create_resync_failure_is_422(db, monkeypatch, override_dep): # The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with # the honest "reconcile on the next sync" detail, not a false "write failed". diff --git a/backend/tests/test_plex_collections_for_item.py b/backend/tests/test_plex_collections_for_item.py new file mode 100644 index 00000000..c03c78c8 --- /dev/null +++ b/backend/tests/test_plex_collections_for_item.py @@ -0,0 +1,82 @@ +"""A movie or show card's "Add to collection": the collections it can go into, each saying whether the +title is in it already. DB lane (`db` fixture).""" + +from datetime import datetime, timezone + +from fastapi.testclient import TestClient + +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 PlexCollection, PlexItem, PlexLibrary, PlexShow, User + + +def _seed(db): + movies = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + shows = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add_all([movies, shows]) + db.flush() + enriched = datetime(2024, 1, 1, tzinfo=timezone.utc) + db.add_all( + [ + PlexItem( + rating_key="m1", + title="Film", + kind="movie", + library_id=movies.id, + collection_keys=["cA"], + people_enriched_at=enriched, + ), + PlexItem( + rating_key="e1", + title="An episode", + kind="episode", + library_id=shows.id, + people_enriched_at=enriched, + ), + PlexShow(rating_key="s1", title="Series", library_id=shows.id, collection_keys=[]), + # The movie library's: two manual ones it can go into, and three it cannot. + PlexCollection(rating_key="cA", library_id=movies.id, title="Alpha", editable=True), + PlexCollection(rating_key="cB", library_id=movies.id, title="Beta", editable=True), + PlexCollection(rating_key="cP", library_id=movies.id, title="Plain", editable=False), + PlexCollection( + rating_key="cS", library_id=movies.id, title="Smart", editable=True, smart=True + ), + PlexCollection( + rating_key="cI", library_id=movies.id, title="IMDb Top 250", editable=True + ), + # The show library's own. + PlexCollection(rating_key="cT", library_id=shows.id, title="Sci-fi", editable=True), + ] + ) + user = User(email="collfor@example.com") + db.add(user) + db.commit() + return user + + +def _get(db, monkeypatch, override_dep, rk): + user = _seed(db) + monkeypatch.setattr(state, "_configured_cache", True) + override_dep(get_db, db.info["request"]) + override_dep(current_user, lambda: user) + return TestClient(app).get(f"/api/plex/collections/for/{rk}") + + +def test_a_movie_lists_its_librarys_manual_collections_and_which_hold_it( + db, monkeypatch, override_dep +): + resp = _get(db, monkeypatch, override_dep, "m1") + assert resp.status_code == 200 + got = [(c["id"], c["contains"]) for c in resp.json()["collections"]] + assert got == [("cA", True), ("cB", False)] + + +def test_a_show_lists_its_own_librarys(db, monkeypatch, override_dep): + resp = _get(db, monkeypatch, override_dep, "s1") + assert [(c["id"], c["contains"]) for c in resp.json()["collections"]] == [("cT", False)] + + +def test_an_episode_or_an_unknown_key_is_not_a_collection_member(db, monkeypatch, override_dep): + assert _get(db, monkeypatch, override_dep, "e1").status_code == 404 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 486a228e..59c5aa18 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -714,3 +714,4 @@ of a full-width panel and nothing on the right. | 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch | | 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii | | 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine | +| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" | diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index cd73a7ba..2317a576 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -199,7 +199,7 @@ function useHomeCardMenu({ .map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it)) : target.plex ? plexActions - .items(target.plex, { play: false, pin: false }) + .items(target.plex, { play: false, pin: false, anchor: menu.anchor }) .map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it)) : target.kind === "channel" || target.kind === "playlist" ? [{ ...openPage.item(target), separatorBefore: true }] diff --git a/frontend/src/components/PlexCollectionAdd.tsx b/frontend/src/components/PlexCollectionAdd.tsx new file mode 100644 index 00000000..76dd8dc5 --- /dev/null +++ b/frontend/src/components/PlexCollectionAdd.tsx @@ -0,0 +1,148 @@ +import { useLayoutEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus } from "lucide-react"; +import { qk } from "../lib/queryKeys"; +import { api, type PlexCollection } from "../lib/api"; +import { notifyActionError } from "../lib/actionErrors"; +import { useScrollFade } from "../lib/useScrollFade"; +import { MenuCheck } from "./ui/MenuCheck"; +import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; +import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; +import { Input } from "./ui/Input"; +import { SURFACE } from "./ui/surfaces"; + +// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title +// into or out of its library's manual collections, and names a new one — created with this title in +// it, in one step (a collection cannot be empty on Plex). It was a trip to the Collections page, a new +// collection, its name, a search for the title, then another search to add the next one. The panel is +// the video card's "Add to playlist" (AddToPlaylist), so it keeps that file's two conventions: +// typeahead off, and the name field keeping every key but Escape to itself. + +export interface PlexCollectionTarget { + ratingKey: string; + anchor: Element; +} + +export default function PlexCollectionAddAt({ + target, + onClose, +}: { + target: PlexCollectionTarget; + onClose: () => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const pop = useAnchoredPopover({ + open: true, + onOpenChange: (next) => { + if (!next) onClose(); + }, + offset: 6, + hostsInput: true, + }); + const { setPositionReference } = pop.refs; + useLayoutEffect(() => setPositionReference(target.anchor), [target.anchor, setPositionReference]); + const { ref: fadeRef, style: fadeStyle } = useScrollFade(); + const [newName, setNewName] = useState(""); + const [busy, setBusy] = useState(false); + const { ratingKey } = target; + + const list = useQuery({ + queryKey: qk.plexCollections("for", ratingKey), + queryFn: () => api.plexCollectionsFor(ratingKey), + }); + + // The title's own page, the collections' pages and the library grid all show membership. + function refresh() { + void qc.invalidateQueries({ queryKey: qk.plexCollections() }); + void qc.invalidateQueries({ queryKey: qk.plexItem(ratingKey) }); + void qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + } + + async function toggle(c: PlexCollection & { contains: boolean }) { + if (busy) return; + setBusy(true); + try { + if (c.contains) await api.plexCollectionRemoveItem(c.id, ratingKey); + else await api.plexCollectionAddItem(c.id, ratingKey); + } catch (e) { + notifyActionError(e, t("plex.collection.editFailed")); + } + refresh(); + setBusy(false); + } + + async function create() { + const name = newName.trim(); + if (!name || busy) return; + setBusy(true); + try { + // No library: the server takes the seed title's own. + await api.plexCreateCollection("", name, ratingKey); + setNewName(""); + } catch (e) { + notifyActionError(e, t("plex.collection.editFailed")); + } + refresh(); + setBusy(false); + } + + const rows = list.data?.collections ?? []; + return ( + + {/* A portal still bubbles through the React tree: keep a click here off the card that opened it. */} +
e.stopPropagation()}> +
{t("plex.collectionAdd.title")}
+
+ {list.isError ? ( +
+ {t("plex.collectionAdd.loadFailed")} +
+ ) : rows.length === 0 && !list.isLoading ? ( +
{t("plex.collectionAdd.none")}
+ ) : null} + {rows.map((c) => ( + void toggle(c)} + // Not `disabled={busy}`: that blurs the focused row and ends roving focus (see + // AddToPlaylist); toggle() no-ops while busy and the list says aria-busy. + label={c.title} + className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition" + > + + {c.title} + + ))} +
+
+ + setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") return; + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + void create(); + } + }} + placeholder={t("plex.collection.newCollection")} + aria-label={t("plex.collection.namePlaceholder")} + /> +
+
+
+ ); +} diff --git a/frontend/src/components/PlexMenu.dom.test.tsx b/frontend/src/components/PlexMenu.dom.test.tsx index cb79aa1b..1b93587c 100644 --- a/frontend/src/components/PlexMenu.dom.test.tsx +++ b/frontend/src/components/PlexMenu.dom.test.tsx @@ -8,7 +8,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { FloatingTree } from "@floating-ui/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { PlexCard } from "../lib/api"; +import { api, type PlexCard, type PlexCollection } from "../lib/api"; +import { qk } from "../lib/queryKeys"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -101,15 +102,21 @@ function Grid({ ); } -function setup(c: PlexCard, opts: Omit[0], "c" | "handlers"> = {}) { +function setup( + c: PlexCard, + opts: Omit[0], "c" | "handlers"> = {}, + { admin = false }: { admin?: boolean } = {}, +) { const handlers = { onOpen: vi.fn(), onInfo: vi.fn(), onToggleWatched: vi.fn(), onRestart: vi.fn(), }; + const qc = new QueryClient(); + if (admin) qc.setQueryData(qk.me(), { role: "admin" }); render( - + @@ -223,3 +230,62 @@ describe("a Plex title's menu", () => { await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1")); }); }); + +// Adding a title to a collection was a trip to the Collections page, a new collection, its name and a +// search for the title. From the card's menu it is a tick, or a name and Enter. +describe("a Plex title's menu — Add to collection", () => { + const coll = (id: string, title: string, contains: boolean) => + ({ id, title, contains }) as PlexCollection & { contains: boolean }; + + it("an admin gets the row on a movie and a show; an episode and a viewer do not", () => { + setup(card({}), {}, { admin: true }); + rightClick(); + expect(labels()).toContain("plex.collectionAdd.row"); + cleanup(); + setup(card({ type: "show" }), {}, { admin: true }); + rightClick(); + expect(labels()).toContain("plex.collectionAdd.row"); + cleanup(); + setup(card({ type: "episode" }), {}, { admin: true }); + rightClick(); + expect(labels()).not.toContain("plex.collectionAdd.row"); + cleanup(); + setup(card({})); + rightClick(); + expect(labels()).not.toContain("plex.collectionAdd.row"); + }); + + it("ticks the title into a collection, out of one, and names a new one with it inside", async () => { + vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({ + collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)], + }); + const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection); + const remove = vi + .spyOn(api, "plexCollectionRemoveItem") + .mockResolvedValue({} as PlexCollection); + const create = vi.spyOn(api, "plexCreateCollection").mockResolvedValue({} as PlexCollection); + setup(card({}), {}, { admin: true }); + rightClick(); + fireEvent.click(item("plex.collectionAdd.row")); + const row = async (name: string) => + vi.waitFor(() => { + const found = screen + .queryAllByRole("menuitemcheckbox", { hidden: true }) + .find((b) => b.textContent === name); + expect(found).toBeDefined(); + return found!; + }); + fireEvent.click(await row("Beta")); + await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1")); + // One write at a time: the next tick waits for this one to settle. + await vi.waitFor(() => + expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"), + ); + fireEvent.click(await row("Alpha")); + await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cA", "m1")); + const name = screen.getByLabelText("plex.collection.namePlaceholder"); + fireEvent.change(name, { target: { value: " Rainy days " } }); + fireEvent.keyDown(name, { key: "Enter" }); + await vi.waitFor(() => expect(create).toHaveBeenCalledWith("", "Rainy days", "m1")); + }); +}); diff --git a/frontend/src/components/PlexMenu.tsx b/frontend/src/components/PlexMenu.tsx index 3549eaba..f9b74592 100644 --- a/frontend/src/components/PlexMenu.tsx +++ b/frontend/src/components/PlexMenu.tsx @@ -28,7 +28,7 @@ export function PlexMenu({ {actions.panel} diff --git a/frontend/src/components/usePlexCardActions.tsx b/frontend/src/components/usePlexCardActions.tsx index 069d39ef..846f57ac 100644 --- a/frontend/src/components/usePlexCardActions.tsx +++ b/frontend/src/components/usePlexCardActions.tsx @@ -1,8 +1,11 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Check, Info, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Check, Info, Layers, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react"; import { type PlexCard } from "../lib/api"; +import { qk } from "../lib/queryKeys"; +import PlexCollectionAddAt, { type PlexCollectionTarget } from "./PlexCollectionAdd"; import { pinPlex, useComfortPin } from "./comfortPin"; import { useCopyItems } from "./copyItems"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; @@ -46,15 +49,26 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean) const copy = useCopyItems(); const addLabel = (c: PlexCard) => t(plexAddLabelKey(c)); const [adding, setAdding] = useState(null); + const [collecting, setCollecting] = useState(null); + // Collections are shared and written to Plex itself, so only an admin edits them (as on the title's + // own page). + const qc = useQueryClient(); + const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; // A whole show needs its episode keys, which only the show page has ("Add whole show" there), so // only a leaf is added from a card. const add = (c: PlexCard) => setAdding({ kind: "single", ratingKey: c.id, title: c.title }); /** `play: false` where the host menu leads with its own Play row (Home's shelf resolves `next`); - * `pin: false` where the host carries its own shelf rows or the object is not a pin kind. */ + * `pin: false` where the host carries its own shelf rows or the object is not a pin kind; + * `anchor`: the element the menu came from, which "Add to collection…" opens its panel beside — + * without one the row is left out rather than opening the panel at a corner. */ const items = ( c: PlexCard, - { play = true, pin: withPin = true }: { play?: boolean; pin?: boolean } = {}, + { + play = true, + pin: withPin = true, + anchor = null, + }: { play?: boolean; pin?: boolean; anchor?: Element | null } = {}, ): ActionMenuItem[] => { const { onOpen, onInfo, onToggleWatched, onRestart } = handlers; const playable = isPlexPlayable(c); @@ -104,6 +118,18 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean) }, ] : []), + // A movie or a show can be a collection member; an episode cannot. + ...(isAdmin && anchor && c.type !== "episode" + ? [ + { + id: "collection", + label: t("plex.collectionAdd.row"), + icon: , + separatorBefore: !playable, + onSelect: () => setCollecting({ ratingKey: c.id, anchor }), + }, + ] + : []), ...(playable && onInfo ? [ { @@ -120,7 +146,14 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean) ]; }; - const panel = adding && setAdding(null)} />; + const panel = ( + <> + {adding && setAdding(null)} />} + {collecting && ( + setCollecting(null)} /> + )} + + ); return { items, add, panel }; } diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 81a520f1..6862834b 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -384,5 +384,11 @@ "byGenre": "Browse by genre", "becauseWatched": "Because you watched {{title}}", "empty": "Nothing to discover yet — run a Plex sync from the admin Config page." + }, + "collectionAdd": { + "row": "Add to collection…", + "title": "Add to collection", + "none": "No collection to add to yet — name one below.", + "loadFailed": "Couldn't load the collections" } } diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index d6a081e4..d8caa7c8 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -384,5 +384,11 @@ "byGenre": "Böngészés műfaj szerint", "becauseWatched": "Mert megnézted: {{title}}", "empty": "Még nincs mit felfedezni — futtass egy Plex-szinkront az admin Konfiguráció oldalról." + }, + "collectionAdd": { + "row": "Kollekcióhoz adás…", + "title": "Kollekcióhoz adás", + "none": "Még nincs kollekció, amihez adhatnád – adj meg egy nevet lent.", + "loadFailed": "Nem sikerült betölteni a kollekciókat" } } diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index a31ffd93..eddb7988 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -508,6 +508,12 @@ export const plexApi = { const qs = u.toString(); return req(`/api/plex/collections${qs ? `?${qs}` : ""}`); }, + // A movie or show card's "Add to collection": its library's manual collections, each saying whether + // the title is in it already. + plexCollectionsFor: ( + itemRk: string, + ): Promise<{ collections: (PlexCollection & { contains: boolean })[] }> => + req(`/api/plex/collections/for/${encodeURIComponent(itemRk)}`), // One collection's hero + its members in the requested order (the Collection info page). The sort // vocabulary is the browse grid's, so the same Sort control means the same thing on both. plexCollection: (rk: string, sort?: SortState, lang?: string): Promise => {