feat(plex): add a movie or show to a collection from its card menu
"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.
This commit is contained in:
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
|
||||
@@ -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" |
|
||||
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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 (
|
||||
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-64 p-2`}>
|
||||
{/* A portal still bubbles through the React tree: keep a click here off the card that opened it. */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="text-xs text-muted px-1.5 pb-1.5">{t("plex.collectionAdd.title")}</div>
|
||||
<div
|
||||
ref={fadeRef}
|
||||
style={fadeStyle}
|
||||
aria-busy={busy}
|
||||
className="max-h-56 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{list.isError ? (
|
||||
<div className="text-xs text-danger px-1.5 py-2">
|
||||
{t("plex.collectionAdd.loadFailed")}
|
||||
</div>
|
||||
) : rows.length === 0 && !list.isLoading ? (
|
||||
<div className="text-xs text-muted px-1.5 py-2">{t("plex.collectionAdd.none")}</div>
|
||||
) : null}
|
||||
{rows.map((c) => (
|
||||
<PopoverItem
|
||||
key={c.id}
|
||||
role="menuitemcheckbox"
|
||||
checked={c.contains}
|
||||
onSelect={() => 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"
|
||||
>
|
||||
<MenuCheck kind="many" on={c.contains} />
|
||||
<span className="truncate flex-1 text-left">{c.title}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
|
||||
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||
<Input
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
value={newName}
|
||||
onChange={(e) => 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")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof import("react-i18next")>();
|
||||
@@ -101,15 +102,21 @@ function Grid({
|
||||
);
|
||||
}
|
||||
|
||||
function setup(c: PlexCard, opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {}) {
|
||||
function setup(
|
||||
c: PlexCard,
|
||||
opts: Omit<Parameters<typeof Grid>[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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<Grid c={c} handlers={handlers} {...opts} />
|
||||
</FloatingTree>
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ export function PlexMenu({
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel={t("plex.menu")}
|
||||
items={c ? [...actions.items(c, { pin }), ...(extra?.(c) ?? [])] : []}
|
||||
items={c ? [...actions.items(c, { pin, anchor: menu.anchor }), ...(extra?.(c) ?? [])] : []}
|
||||
/>
|
||||
{actions.panel}
|
||||
</>
|
||||
|
||||
@@ -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<PlexAddTarget | null>(null);
|
||||
const [collecting, setCollecting] = useState<PlexCollectionTarget | null>(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: <Layers className={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 && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />;
|
||||
const panel = (
|
||||
<>
|
||||
{adding && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />}
|
||||
{collecting && (
|
||||
<PlexCollectionAddAt target={collecting} onClose={() => setCollecting(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return { items, add, panel };
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PlexCollectionDetail> => {
|
||||
|
||||
Reference in New Issue
Block a user