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}
+
+ ))}
+