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)
|
keys = ",".join(str(k) for k in rating_keys)
|
||||||
return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{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:
|
def create_collection(
|
||||||
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey)."""
|
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="")
|
uri = quote(self._metadata_uri(item_rating_key), safe="")
|
||||||
mc = self._write(
|
mc = self._write(
|
||||||
"POST",
|
"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}",
|
f"&title={quote(title, safe='')}&uri={uri}",
|
||||||
)
|
)
|
||||||
items = mc.get("Metadata", []) or []
|
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):
|
class Member(NamedTuple):
|
||||||
"""One collection member flattened to everything the Sort control can order by. The page's two
|
"""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
|
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
|
seed = body.item_rating_key
|
||||||
if not title or not seed:
|
if not title or not seed:
|
||||||
raise refused(400, "collection_fields_required")
|
raise refused(400, "collection_fields_required")
|
||||||
|
seed_show = by_rating_key(db, PlexShow, seed)
|
||||||
if library:
|
if library:
|
||||||
lib = (
|
lib = (
|
||||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# The "New collection" flow only knows the seed title → derive its library.
|
# 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
|
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
||||||
if lib is None:
|
if lib is None:
|
||||||
raise not_found("library")
|
raise not_found("library")
|
||||||
@@ -421,7 +456,8 @@ def create_collection(
|
|||||||
wrote = False
|
wrote = False
|
||||||
try:
|
try:
|
||||||
with PlexClient(db) as plex:
|
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
|
wrote = True
|
||||||
col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey")))
|
col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey")))
|
||||||
col.editable = True
|
col.editable = True
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ DB lane (`db` fixture) — skipped without Postgres.
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -224,8 +225,9 @@ class _CollStub:
|
|||||||
if self.fail_on == name:
|
if self.fail_on == name:
|
||||||
raise PlexError("boom")
|
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._maybe("create_collection")
|
||||||
|
self.created_type = item_type
|
||||||
return {"ratingKey": "NEW"}
|
return {"ratingKey": "NEW"}
|
||||||
|
|
||||||
def add_collection_item(self, rk, item):
|
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
|
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):
|
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 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".
|
# 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 | 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 | 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 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))
|
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
||||||
: target.plex
|
: target.plex
|
||||||
? plexActions
|
? 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))
|
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
||||||
: target.kind === "channel" || target.kind === "playlist"
|
: target.kind === "channel" || target.kind === "playlist"
|
||||||
? [{ ...openPage.item(target), separatorBefore: true }]
|
? [{ ...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 { FloatingTree } from "@floating-ui/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
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 = {
|
const handlers = {
|
||||||
onOpen: vi.fn(),
|
onOpen: vi.fn(),
|
||||||
onInfo: vi.fn(),
|
onInfo: vi.fn(),
|
||||||
onToggleWatched: vi.fn(),
|
onToggleWatched: vi.fn(),
|
||||||
onRestart: vi.fn(),
|
onRestart: vi.fn(),
|
||||||
};
|
};
|
||||||
|
const qc = new QueryClient();
|
||||||
|
if (admin) qc.setQueryData(qk.me(), { role: "admin" });
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={qc}>
|
||||||
<FloatingTree>
|
<FloatingTree>
|
||||||
<Grid c={c} handlers={handlers} {...opts} />
|
<Grid c={c} handlers={handlers} {...opts} />
|
||||||
</FloatingTree>
|
</FloatingTree>
|
||||||
@@ -223,3 +230,62 @@ describe("a Plex title's menu", () => {
|
|||||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1"));
|
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
|
<ContextMenu
|
||||||
menu={menu}
|
menu={menu}
|
||||||
ariaLabel={t("plex.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}
|
{actions.panel}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { type PlexCard } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import PlexCollectionAddAt, { type PlexCollectionTarget } from "./PlexCollectionAdd";
|
||||||
import { pinPlex, useComfortPin } from "./comfortPin";
|
import { pinPlex, useComfortPin } from "./comfortPin";
|
||||||
import { useCopyItems } from "./copyItems";
|
import { useCopyItems } from "./copyItems";
|
||||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||||
@@ -46,15 +49,26 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
|
|||||||
const copy = useCopyItems();
|
const copy = useCopyItems();
|
||||||
const addLabel = (c: PlexCard) => t(plexAddLabelKey(c));
|
const addLabel = (c: PlexCard) => t(plexAddLabelKey(c));
|
||||||
const [adding, setAdding] = useState<PlexAddTarget | null>(null);
|
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
|
// 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.
|
// only a leaf is added from a card.
|
||||||
const add = (c: PlexCard) => setAdding({ kind: "single", ratingKey: c.id, title: c.title });
|
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`);
|
/** `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 = (
|
const items = (
|
||||||
c: PlexCard,
|
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[] => {
|
): ActionMenuItem[] => {
|
||||||
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
|
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
|
||||||
const playable = isPlexPlayable(c);
|
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
|
...(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 };
|
return { items, add, panel };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -384,5 +384,11 @@
|
|||||||
"byGenre": "Browse by genre",
|
"byGenre": "Browse by genre",
|
||||||
"becauseWatched": "Because you watched {{title}}",
|
"becauseWatched": "Because you watched {{title}}",
|
||||||
"empty": "Nothing to discover yet — run a Plex sync from the admin Config page."
|
"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",
|
"byGenre": "Böngészés műfaj szerint",
|
||||||
"becauseWatched": "Mert megnézted: {{title}}",
|
"becauseWatched": "Mert megnézted: {{title}}",
|
||||||
"empty": "Még nincs mit felfedezni — futtass egy Plex-szinkront az admin Konfiguráció oldalról."
|
"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();
|
const qs = u.toString();
|
||||||
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
|
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
|
// 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.
|
// 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> => {
|
plexCollection: (rk: string, sort?: SortState, lang?: string): Promise<PlexCollectionDetail> => {
|
||||||
|
|||||||
Reference in New Issue
Block a user