feat(plex): the collection page sorts, and a linked collection has a name
Two halves of the same report ("Collectionben nem működik a rendezés?"). The pasted browse URL
sorts correctly — that part was working. What did not:
The Collection INFO page had no sort control at all and a hardcoded release-year-ascending order.
It now shows the browse grid's Sort control, with the browse grid's vocabulary, because a second
sort vocabulary on a neighbouring surface is exactly the kind of drift this repo keeps paying for:
`PLEX_SORT_KEYS` is now one tuple in `plex/query.py`, and a test pins BOTH implementations to it —
browse's key→column map (extracted from the handler to be reachable) and the collection page's
key→member map. A key added to the vocabulary without teaching one of them fails the gate instead
of putting a dead option in the menu.
Ordering a collection cannot be a single ORDER BY: its two legs are different tables, so the merged
member list is sorted in Python. Members with no value for the key sink to the END in both
directions (a movie with no rating is not "the worst rated" — the browse grid's `nullslast()`), and
ties always resolve title-ascending, so flipping the direction reverses the ranking rather than the
alphabet inside it. `lastwatched` needed the show leg's `resumed_at`, so the page now takes ONE
rollup pass that yields both the badge status and the recency instead of the status alone — without
it that menu entry would have ordered every show as never-watched. The default stays release-year
ascending: a franchise reads in release order, and that is what the page did before.
The other half: a shared Plex link carries the collection filter as bare rating_keys, so a link
opened cold had no name for them — the sidebar token rendered `17045` and the chip could only say
"Collection". `GET /api/plex/collections` takes a `keys=` CSV now, and a display-only hook resolves
just the missing ids. It deliberately does NOT write them into `collectionTitles`: that map is
hashed into the Plex query keys, so storing a resolved label there would split the cache from the
identical state and refetch the grid for a piece of text.
Verified in the browser on localdev against the real Plex library: the movie collection's menu
offers Length and the show collection's does not (the same scope rule as browse); rating-ascending
reorders 8.1/7.4/7.2/6.6/6.5 correctly; `lastwatched` on a show collection brings the in-progress
shows up; the choice survives a reload (it is a server pref, on its own key so opening a collection
never re-sorts the library behind it); and the reported URL now reads "Pirates of the Caribbean
Collection" in both the sidebar and the chip.
12 pytest + 8 vitest, mutation-checked against 14 mutations (direction ignored, nulls first, no
stable tiebreak, unknown key untouched, wrong default, show recency dropped, empty `keys=` falling
through to everything, a key missing from either sort map, every id refetched, ids unsorted,
fetched titles unmerged, the chip ignoring them, the hook's result never reaching the builder) —
each red on exactly the test that names it.
This commit is contained in:
@@ -8,6 +8,10 @@ import { formatRuntime } from "../lib/format";
|
||||
import { notifyActionError, notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { showEpisodeKeys } from "../lib/plexQueue";
|
||||
import { healPlexSort, plexSortOptions } from "../lib/plexSort";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import { type SortState } from "../lib/columnSort";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
@@ -22,6 +26,11 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
// admin-enabled collection — rename / delete / add / remove, all write-through to Plex (the mutation
|
||||
// endpoints round-trip to Plex, so each edit awaits and re-fetches; no optimism). Read-only ones (smart
|
||||
// / imdb / tmdb / … auto-lists) are marked with a lock.
|
||||
// The order this page had before it grew a Sort control, and still its default: a franchise reads in
|
||||
// release order. MUST match the endpoint's `DEFAULT_MEMBER_SORT` — the two only diverge if a request
|
||||
// goes out without the params, which is why the control always sends them.
|
||||
const COLLECTION_SORT_DEFAULT: NonNullable<SortState> = { key: "year", dir: "asc" };
|
||||
|
||||
function shuffled(a: string[]): string[] {
|
||||
const r = [...a];
|
||||
for (let i = r.length - 1; i > 0; i--) {
|
||||
@@ -52,9 +61,17 @@ export default function PlexCollectionView({
|
||||
// One comfort-pin menu for the member grid (see ui/ContextMenu).
|
||||
const pinMenu = useContextMenu<PlexCard>();
|
||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||
// The member order is a PREFERENCE (server-synced, adopted on login), not an ephemeral filter — the
|
||||
// same treatment the feed's view and the player's auto-advance get. It is deliberately its OWN key
|
||||
// rather than the browse grid's sort: opening a collection must not silently re-sort the library
|
||||
// behind it.
|
||||
const [sortPref, setSortPref] = usePref<NonNullable<SortState>>(
|
||||
"plexCollectionSort",
|
||||
COLLECTION_SORT_DEFAULT,
|
||||
);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexCollections("detail", collectionId),
|
||||
queryFn: () => api.plexCollection(collectionId),
|
||||
queryKey: qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir),
|
||||
queryFn: () => api.plexCollection(collectionId, sortPref),
|
||||
});
|
||||
const d = q.data;
|
||||
|
||||
@@ -73,6 +90,11 @@ export default function PlexCollectionView({
|
||||
};
|
||||
|
||||
const items = d?.items ?? [];
|
||||
// A collection lives in ONE library, so its members are all movies or all shows. That decides both
|
||||
// what the add-panel searches and which sort keys are on offer (only a movie has a runtime).
|
||||
const scope = items.some((c) => c.type === "show") ? "show" : "movie";
|
||||
const sortState = healPlexSort(sortPref, scope);
|
||||
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
|
||||
// Movies/episodes are playable leaves that queue as themselves; a show can't queue directly, so
|
||||
// Play-all expands it into its episodes (buildQueue). Either kind means there's something to play —
|
||||
// this is what a show-collection needs: before, `playable` held only leaves, so a show-collection's
|
||||
@@ -334,7 +356,7 @@ export default function PlexCollectionView({
|
||||
{adding && canEdit && (
|
||||
<AddItemPanel
|
||||
collectionId={collectionId}
|
||||
scope={items.some((c) => c.type === "show") ? "show" : "movie"}
|
||||
scope={scope}
|
||||
existing={new Set(items.map((c) => c.id))}
|
||||
onAdded={invalidate}
|
||||
onClose={() => setAdding(false)}
|
||||
@@ -344,30 +366,46 @@ export default function PlexCollectionView({
|
||||
{items.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.empty")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
||||
{items.map((c) => (
|
||||
<div key={c.id} className="relative">
|
||||
<PlexPosterCard
|
||||
card={c}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onInfo={() => onInfoCard(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
menuProps={pinMenu.triggerProps}
|
||||
<>
|
||||
{/* Sort sits directly above the grid, pinned right — the same placement (and the same
|
||||
control, options and labels) as the browse grid, so the two read as one idea. */}
|
||||
<div className="mb-2 flex items-center">
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={sortState}
|
||||
onChange={(next) => setSortPref(next ?? COLLECTION_SORT_DEFAULT)}
|
||||
label={t("plex.filter.sort")}
|
||||
ascLabel={t("plex.filter.dir.asc")}
|
||||
descLabel={t("plex.filter.dir.desc")}
|
||||
/>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => doRemove(c)}
|
||||
disabled={busy}
|
||||
title={t("plex.collection.removeItem")}
|
||||
aria-label={t("plex.collection.removeItem")}
|
||||
className="glass-card glass-hover absolute right-1.5 top-1.5 z-base grid h-6 w-6 place-items-center rounded-full text-fg disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
||||
{items.map((c) => (
|
||||
<div key={c.id} className="relative">
|
||||
<PlexPosterCard
|
||||
card={c}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onInfo={() => onInfoCard(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
menuProps={pinMenu.triggerProps}
|
||||
/>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => doRemove(c)}
|
||||
disabled={busy}
|
||||
title={t("plex.collection.removeItem")}
|
||||
aria-label={t("plex.collection.removeItem")}
|
||||
className="glass-card glass-hover absolute right-1.5 top-1.5 z-base grid h-6 w-6 place-items-center rounded-full text-fg disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { plexLang } from "../i18n";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -447,6 +448,10 @@ function CollectionPicker({
|
||||
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
|
||||
enabled: open, // only fetch while the picker is open
|
||||
});
|
||||
// …but a selection restored from a LINK has ids and no titles, and the list above is only fetched
|
||||
// once the picker is opened — so the token rendered the raw rating_key until then. Resolve just the
|
||||
// selected ids (see usePlexCollectionTitles).
|
||||
const resolved = usePlexCollectionTitles(values, titles);
|
||||
const options: ComboboxOption[] = (q.data?.collections ?? []).map((c) => ({
|
||||
value: c.id,
|
||||
label: c.title,
|
||||
@@ -454,7 +459,7 @@ function CollectionPicker({
|
||||
}));
|
||||
// Combine the fetched labels with the ones already stored so the trip through onChange keeps every
|
||||
// selected collection's title (the fetched list is narrowed by the current search).
|
||||
const labelMap: Record<string, string> = { ...titles };
|
||||
const labelMap: Record<string, string> = { ...resolved };
|
||||
for (const o of options) if (o.label) labelMap[o.value] = o.label;
|
||||
|
||||
return (
|
||||
@@ -463,7 +468,7 @@ function CollectionPicker({
|
||||
clientFilter={false} // the server already narrows to `search`
|
||||
options={options}
|
||||
selected={values}
|
||||
selectedLabels={titles}
|
||||
selectedLabels={resolved}
|
||||
query={search}
|
||||
onQueryChange={setSearch}
|
||||
onOpenChange={setOpen}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
// the provider is mocked so the hook can be driven with a non-default sort present.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { EMPTY_PLEX_FILTERS } from "../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
@@ -21,7 +23,9 @@ const plexState = {
|
||||
filters: {
|
||||
...EMPTY_PLEX_FILTERS,
|
||||
genres: ["Action"],
|
||||
collections: ["A"],
|
||||
// "A" was picked in the sidebar (its title came along); "B" arrived from a shared LINK, which
|
||||
// carries ids only — the case that used to render a raw rating_key.
|
||||
collections: ["A", "B"],
|
||||
collectionTitles: { A: "Marvel" },
|
||||
},
|
||||
};
|
||||
@@ -33,10 +37,17 @@ vi.mock("./PlexProvider", () => ({
|
||||
}));
|
||||
|
||||
import { usePlexActiveFilters } from "./usePlexActiveFilters";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
// The hook resolves collection titles it does not already have through react-query, so it needs a
|
||||
// client. Every id here already HAS a title, so the query stays disabled and nothing is requested.
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client: qc }, children);
|
||||
|
||||
describe("usePlexActiveFilters", () => {
|
||||
it("emits filter + watch-state chips but never a sort chip", () => {
|
||||
const { result } = renderHook(() => usePlexActiveFilters());
|
||||
const { result } = renderHook(() => usePlexActiveFilters(), { wrapper });
|
||||
const keys = result.current.map((c) => c.key);
|
||||
// The in-panel narrowings still surface...
|
||||
expect(keys).toContain("genre:Action");
|
||||
@@ -46,9 +57,22 @@ describe("usePlexActiveFilters", () => {
|
||||
expect(result.current.every((c) => !c.key.startsWith("sort"))).toBe(true);
|
||||
});
|
||||
|
||||
it("labels a link-supplied collection with its resolved title, not the bare id", () => {
|
||||
// Seed the resolution the hook would fetch, so this asserts the WIRING (hook -> builder -> label)
|
||||
// rather than the network. Nothing is mocked.
|
||||
qc.setQueryData(qk.plexCollections("titles", "B"), {
|
||||
collections: [{ id: "B", title: "Pirates of the Caribbean Collection" }],
|
||||
});
|
||||
const { result } = renderHook(() => usePlexActiveFilters(), { wrapper });
|
||||
const byKey = Object.fromEntries(result.current.map((c) => [c.key, c.label]));
|
||||
expect(byKey["collection:B"]).toBe("Pirates of the Caribbean Collection");
|
||||
// …and the one the filter state already knew about is untouched.
|
||||
expect(byKey["collection:A"]).toBe("Marvel");
|
||||
});
|
||||
|
||||
it("removing the last collection chip prunes its title and collapses collectionTitles to undefined", () => {
|
||||
setFilters.mockClear();
|
||||
const { result } = renderHook(() => usePlexActiveFilters());
|
||||
const { result } = renderHook(() => usePlexActiveFilters(), { wrapper });
|
||||
const chip = result.current.find((c) => c.key === "collection:A");
|
||||
expect(chip).toBeDefined();
|
||||
chip!.remove();
|
||||
@@ -59,7 +83,7 @@ describe("usePlexActiveFilters", () => {
|
||||
collections: string[];
|
||||
collectionTitles?: unknown;
|
||||
};
|
||||
expect(arg.collections).toEqual([]);
|
||||
expect(arg.collections).toEqual(["B"]);
|
||||
expect(arg.collectionTitles).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type ActiveFilter } from "../lib/useActiveFilters";
|
||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
||||
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles";
|
||||
|
||||
// Plex's analogue of useActiveFilters: turns the shared Plex state (expanded filters + watch-state)
|
||||
// into the source-agnostic ActiveFilter[] the shared ActiveFilterChips renders above the grid.
|
||||
@@ -19,7 +20,10 @@ export function usePlexActiveFilters(): ActiveFilter[] {
|
||||
const { setShow, setFilters } = usePlexActions();
|
||||
const { t } = useTranslation();
|
||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||
return buildPlexActiveFilters(filters, show, patch, setShow, t);
|
||||
// A link opened cold knows the collection ids but not their names — resolve the missing ones for
|
||||
// display (see usePlexCollectionTitles for why they are NOT written back into the filter state).
|
||||
const collectionTitles = usePlexCollectionTitles(filters.collections, filters.collectionTitles);
|
||||
return buildPlexActiveFilters(filters, show, patch, setShow, t, collectionTitles);
|
||||
}
|
||||
|
||||
// The pure builder behind the hook: turns a Plex filter set + watch-state into the source-agnostic
|
||||
@@ -31,6 +35,9 @@ export function buildPlexActiveFilters(
|
||||
patch: (p: Partial<PlexFilters>) => void,
|
||||
setShow: (s: string) => void,
|
||||
t: TFn,
|
||||
/** Collection titles for the chip labels, resolved beyond what the filter state carries. Omitted by
|
||||
* the saved-views manager, which describes a stored view and has only the captured titles. */
|
||||
collectionTitles?: Record<string, string>,
|
||||
): ActiveFilter[] {
|
||||
const out: ActiveFilter[] = [];
|
||||
|
||||
@@ -127,7 +134,7 @@ export function buildPlexActiveFilters(
|
||||
for (const c of filters.collections) {
|
||||
out.push({
|
||||
key: `collection:${c}`,
|
||||
label: filters.collectionTitles?.[c] ?? t("plex.filter.collection"),
|
||||
label: collectionTitles?.[c] ?? filters.collectionTitles?.[c] ?? t("plex.filter.collection"),
|
||||
// Prune the title too — collectionTitles is hashed into the plex query keys, so a stale entry
|
||||
// splits the cache from the logically-identical no-collection state (mirrors CollectionPicker).
|
||||
// Collapse an empty map back to undefined so the last-removed state key-matches the pristine
|
||||
|
||||
@@ -479,16 +479,31 @@ export const plexApi = {
|
||||
},
|
||||
// With a library plex_key → that library's collections (editor). Without one → the union across all
|
||||
// enabled libraries (the unified-scope sidebar picker).
|
||||
plexCollections: (library?: string, q?: string): Promise<{ collections: PlexCollection[] }> => {
|
||||
plexCollections: (
|
||||
library?: string,
|
||||
q?: string,
|
||||
keys?: string[],
|
||||
): Promise<{ collections: PlexCollection[] }> => {
|
||||
const u = new URLSearchParams();
|
||||
if (library) u.set("library", library);
|
||||
if (q) u.set("q", q);
|
||||
// `keys` resolves specific rating_keys to their titles — a shared Plex link carries the collection
|
||||
// filter as bare ids, so the sidebar token and the filter chip have nothing else to render.
|
||||
if (keys) u.set("keys", keys.join(","));
|
||||
const qs = u.toString();
|
||||
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
// One collection's hero + ordered members (the Collection info page).
|
||||
plexCollection: (rk: string): Promise<PlexCollectionDetail> =>
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}`),
|
||||
// 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): Promise<PlexCollectionDetail> => {
|
||||
const u = new URLSearchParams();
|
||||
if (sort) {
|
||||
u.set("sort", sort.key);
|
||||
u.set("sort_dir", sort.dir);
|
||||
}
|
||||
const qs = u.toString();
|
||||
return req(`/api/plex/collections/${encodeURIComponent(rk)}${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
// --- Collection editing (admin only; writes back to Plex) ---
|
||||
plexCreateCollection: (
|
||||
library: string,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A shared Plex link carries the collection filter as bare rating_keys — `?pf=collections%3D17045`
|
||||
// says nothing about what 17045 is called. Opened cold, the sidebar token rendered that raw id and
|
||||
// the applied-filter chip could only say "Collection". This hook resolves the missing ones.
|
||||
//
|
||||
// Two things it must NOT do: ask the server for a title the filter state already carries (the common
|
||||
// case — the user picked the collection in the sidebar), and pull the whole collection list to find
|
||||
// one name (431 collections / ~200 KB on the owner's library).
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
|
||||
const plexCollections = vi.fn();
|
||||
vi.mock("./api", () => ({ api: { plexCollections: (...a: unknown[]) => plexCollections(...a) } }));
|
||||
|
||||
import { usePlexCollectionTitles } from "./usePlexCollectionTitles";
|
||||
|
||||
const wrap = () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client: qc }, children);
|
||||
};
|
||||
|
||||
const CATALOG: Record<string, string> = {
|
||||
"17045": "Pirates of the Caribbean Collection",
|
||||
"68231": "IMDb Top 250",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
plexCollections.mockReset();
|
||||
// Answer like the endpoint does: only the requested keys come back.
|
||||
plexCollections.mockImplementation((_lib: unknown, _q: unknown, keys: string[]) =>
|
||||
Promise.resolve({
|
||||
collections: (keys ?? []).map((id) => ({ id, title: CATALOG[id] })),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("usePlexCollectionTitles", () => {
|
||||
it("resolves the ids the filter state has no title for", async () => {
|
||||
const { result } = renderHook(() => usePlexCollectionTitles(["17045"], undefined), {
|
||||
wrapper: wrap(),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current["17045"]).toBe("Pirates of the Caribbean Collection"),
|
||||
);
|
||||
// Only the missing ids are asked for — nothing else.
|
||||
expect(plexCollections).toHaveBeenCalledWith(undefined, undefined, ["17045"]);
|
||||
});
|
||||
|
||||
it("asks for nothing when every id already has a title", async () => {
|
||||
const known = { "17045": "Pirates of the Caribbean Collection" };
|
||||
const { result } = renderHook(() => usePlexCollectionTitles(["17045"], known), {
|
||||
wrapper: wrap(),
|
||||
});
|
||||
expect(result.current["17045"]).toBe("Pirates of the Caribbean Collection");
|
||||
// Give a would-be fetch a chance to happen before asserting it did not.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(plexCollections).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks only for the missing half of a mixed selection", async () => {
|
||||
const { result } = renderHook(
|
||||
() => usePlexCollectionTitles(["17045", "68231"], { "17045": "Already Known" }),
|
||||
{ wrapper: wrap() },
|
||||
);
|
||||
await waitFor(() => expect(result.current["68231"]).toBe("IMDb Top 250"));
|
||||
// The known id is not in the request — and its stored title survives the merge.
|
||||
expect(plexCollections).toHaveBeenCalledWith(undefined, undefined, ["68231"]);
|
||||
expect(result.current["17045"]).toBe("Already Known");
|
||||
});
|
||||
|
||||
it("treats the same ids in a different order as one query", async () => {
|
||||
const w = wrap();
|
||||
const a = renderHook(() => usePlexCollectionTitles(["68231", "17045"], undefined), {
|
||||
wrapper: w,
|
||||
});
|
||||
await waitFor(() => expect(a.result.current["17045"]).toBeDefined());
|
||||
const calls = plexCollections.mock.calls.length;
|
||||
const b = renderHook(() => usePlexCollectionTitles(["17045", "68231"], undefined), {
|
||||
wrapper: w,
|
||||
});
|
||||
await waitFor(() => expect(b.result.current["17045"]).toBeDefined());
|
||||
expect(plexCollections.mock.calls.length).toBe(calls);
|
||||
});
|
||||
|
||||
it("returns nothing extra while the lookup is still in flight", () => {
|
||||
plexCollections.mockReturnValue(new Promise(() => {}));
|
||||
const { result } = renderHook(() => usePlexCollectionTitles(["17045"], undefined), {
|
||||
wrapper: wrap(),
|
||||
});
|
||||
// The caller falls back to its own label; it must not be handed a half-built map.
|
||||
expect(result.current["17045"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
// A Plex link carries the collection filter as bare rating_keys (`?pf=collections%3D17045`) — nothing
|
||||
// in the URL says what "17045" is called. The title is normally picked up as a side effect of choosing
|
||||
// the collection in the sidebar (`collectionTitles`), so a link opened cold had no name for it and the
|
||||
// picker token rendered the raw id while the filter chip could only say "Collection".
|
||||
//
|
||||
// This resolves the missing ones from the server and merges them over what the filter state already
|
||||
// knows. It stays OUT of `collectionTitles`: that map is hashed into the Plex query keys, so writing a
|
||||
// resolved title back in would split the cache from the logically identical state and refetch the grid
|
||||
// for a label. Titles are display-only, so they belong in a display-only read.
|
||||
export function usePlexCollectionTitles(
|
||||
ids: string[],
|
||||
known: Record<string, string> | undefined,
|
||||
): Record<string, string> {
|
||||
// Sorted so the same set of ids in a different order is the same query.
|
||||
const missing = ids.filter((id) => !known?.[id]).sort();
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexCollections("titles", missing.join(",")),
|
||||
queryFn: () => api.plexCollections(undefined, undefined, missing),
|
||||
enabled: missing.length > 0,
|
||||
// A collection's name is about as static as data gets, and this can be asked for on every render
|
||||
// of the sidebar and the chip row.
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
const out: Record<string, string> = { ...known };
|
||||
for (const c of q.data?.collections ?? []) out[c.id] = c.title;
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user