diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 5fa702c2..291a7569 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -312,6 +312,7 @@ def facets( empty = { "genres": [], "content_ratings": [], + "studios": [], "year_min": None, "year_max": None, "rating_max": None, @@ -355,6 +356,7 @@ def facets( genre_counts: dict[str, int] = {} cr_counts: dict[str, int] = {} + studio_counts: dict[str, int] = {} years: list[int] = [] ratings: list[float] = [] durs: list[int] = [] @@ -406,6 +408,21 @@ def facets( ) for cr, c in db.execute(crq).all(): cr_counts[cr] = cr_counts.get(cr, 0) + c + # Studios — exclude its own (each offered studio is a valid OR-addition that only widens). + stq = ( + wsq( + select(model.studio, func.count()), + model, + ids, + {**p, "studios": None}, + kind_movie, + True, + ) + .where(model.studio.isnot(None)) + .group_by(model.studio) + ) + for s, c in db.execute(stq).all(): + studio_counts[s] = studio_counts.get(s, 0) + c # Year bounds — exclude the year filter. by = db.execute( wsq( @@ -460,6 +477,9 @@ def facets( # undo a zero-result selection. Count 0 is fine (genre chips don't display counts). for g in split_csv(f.genres): genre_counts.setdefault(g, 0) + # Same for the actively-selected studios, so a selected studio stays removable even at zero matches. + for s in split_csv(f.studios): + studio_counts.setdefault(s, 0) return { "genres": [ {"value": g, "count": c} @@ -469,6 +489,10 @@ def facets( {"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1]) ], + "studios": [ + {"value": s, "count": c} + for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0])) + ], "year_min": min(years) if years else None, "year_max": max(years) if years else None, "rating_max": max(ratings) if ratings else None, diff --git a/backend/tests/test_plex_facets.py b/backend/tests/test_plex_facets.py index fe8198b5..dd7e050a 100644 --- a/backend/tests/test_plex_facets.py +++ b/backend/tests/test_plex_facets.py @@ -12,7 +12,33 @@ 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 PlexLibrary, PlexShow, User +from app.models import PlexItem, PlexLibrary, PlexShow, User + + +def test_facets_returns_studio_vocabulary(db, monkeypatch): + """E5 S7: /facets returns the studio vocabulary (distinct studio + count) for the sidebar picker.""" + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add_all([ + PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A", studio="Pixar"), + PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="B", studio="Pixar"), + PlexItem(rating_key="m3", library_id=lib.id, kind="movie", title="C", studio="A24"), + PlexItem(rating_key="m4", library_id=lib.id, kind="movie", title="D"), # no studio + ]) + user = User(email="studiofacets@example.com") + db.add(user) + db.commit() + monkeypatch.setattr(state, "_configured_cache", True) + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: user + try: + resp = TestClient(app).get("/api/plex/facets?scope=movie") + finally: + app.dependency_overrides.clear() + assert resp.status_code == 200, resp.text + studios = {s["value"]: s["count"] for s in resp.json()["studios"]} + assert studios == {"Pixar": 2, "A24": 1} def test_facets_survives_json_null_genres(db, monkeypatch): diff --git a/frontend/src/components/MultiSelectChips.dom.test.tsx b/frontend/src/components/MultiSelectChips.dom.test.tsx new file mode 100644 index 00000000..91e76244 --- /dev/null +++ b/frontend/src/components/MultiSelectChips.dom.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +// +// E5 S7: the compact listbox-select + chips multi-value filter. Covers the selected-chip removal, the +// popover open, the search filter, and toggling an option on. i18n is key-passthrough; the popover is +// floating-ui based (ActionMenu's test recipe: stub the two observers autoUpdate wires, wrap in a +// FloatingTree, and query the hidden surface with `{ hidden: true }` since jsdom never positions it). +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { FloatingTree } from "@floating-ui/react"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; +}); + +import MultiSelectChips from "./MultiSelectChips"; + +afterEach(cleanup); + +const OPTIONS = [ + { value: "Drama", count: 120 }, + { value: "Action", count: 90 }, + { value: "Comedy", count: 60 }, +]; + +function wrap(ui: React.ReactElement) { + return render({ui}); +} + +describe("MultiSelectChips", () => { + it("renders selected values as removable chips and removes on ✕", () => { + const onChange = vi.fn(); + wrap( + , + ); + expect(screen.getByText("Drama")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "common.remove" })); + expect(onChange).toHaveBeenCalledWith([]); // Drama removed + }); + + it("keeps the option list unmounted until the trigger is clicked", () => { + wrap(); + expect(screen.queryByRole("menuitemcheckbox", { hidden: true })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" })); + expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(3); + }); + + it("filters the options by the search query", () => { + wrap(); + fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" })); + fireEvent.change(screen.getByPlaceholderText("common.search"), { target: { value: "com" } }); + const items = screen.getAllByRole("menuitemcheckbox", { hidden: true }); + expect(items.length).toBe(1); + expect(items[0]?.textContent).toContain("Comedy"); + }); + + it("toggles an option on with its value added to the selection", () => { + const onChange = vi.fn(); + wrap( + , + ); + fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" })); + fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on + expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]); + }); + + it("caps the rendered rows for a large vocabulary and shows a refine hint", () => { + // A studio-sized list (hundreds) must not render every row — cap at 50 + a "N more" footer. + const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i })); + wrap(); + fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" })); + expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(50); + expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search + }); +}); diff --git a/frontend/src/components/MultiSelectChips.tsx b/frontend/src/components/MultiSelectChips.tsx new file mode 100644 index 00000000..981c4f0a --- /dev/null +++ b/frontend/src/components/MultiSelectChips.tsx @@ -0,0 +1,169 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, Plus, Search, X } from "lucide-react"; +import { useScrollFade } from "../lib/useScrollFade"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; + +// A compact "listbox-select + removable chips" multi-value filter control (E5 S7), replacing a flat +// wall of toggle chips: the selected values show as removable chips, and a "+" trigger opens a +// searchable checklist popover (AnchoredPopover platform — menuitemcheckbox items, arrow/Escape, anchor +// flip). Options carry an optional count. Space-saving for a large vocabulary (genres / studios), which +// is exactly why it replaces the always-expanded chip lists in the Plex filter panel. + +export interface MultiSelectOption { + value: string; + count?: number; +} + +export default function MultiSelectChips({ + label, + options, + selected, + onChange, + addLabel, + searchPlaceholder, +}: { + /** Accessible name for the popover + trigger (e.g. "Genre"). */ + label: string; + options: readonly MultiSelectOption[]; + selected: string[]; + onChange: (next: string[]) => void; + addLabel?: string; + searchPlaceholder?: string; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + const fade = useScrollFade(); + // A free-text search field lives in the surface — keep typed chars + pointer-hover out of the roving + // list so neither steals the field's caret (same reason ChannelTags' tag editor sets this). + const pop = useAnchoredPopover({ + open, + onOpenChange: setOpen, + mode: "menu", + placement: "bottom-start", + hostsInput: true, + }); + // Focus the search field when the menu opens; clear the query when it closes. + useEffect(() => { + if (open) inputRef.current?.focus(); + else setQuery(""); + }, [open]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return q ? options.filter((o) => o.value.toLowerCase().includes(q)) : options; + }, [options, query]); + + // A large vocabulary (studios can run to hundreds) must not render every option as a DOM node — cap + // the rendered rows and let search narrow to the rest. The options arrive count-desc, so the default + // view is the most-common values; a footer flags how many more are hidden behind the search. + const MAX_VISIBLE = 50; + const visible = filtered.slice(0, MAX_VISIBLE); + const hiddenCount = filtered.length - visible.length; + + const toggle = (v: string) => + onChange(selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v]); + + return ( +
+ {selected.length > 0 && ( +
+ {selected.map((v) => ( + + {v} + + + ))} +
+ )} + + + + +
+ + setQuery(e.target.value)} + onKeyDown={(e) => { + // Escape bubbles to the popover's dismiss (close + refocus trigger); every other key — + // typed chars, arrows, Home/End — is text editing here, kept out of the surface's + // list-navigation/typeahead handlers so the caret behaves. + if (e.key === "Escape") return; + e.stopPropagation(); + }} + placeholder={searchPlaceholder ?? t("common.search", { defaultValue: "Search" })} + className="w-full rounded-lg border border-border bg-surface py-1 pl-7 pr-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" + /> +
+
+ {filtered.length === 0 ? ( +

+ {t("common.noResults", { defaultValue: "No matches" })} +

+ ) : ( + <> + {visible.map((o) => { + const on = selected.includes(o.value); + return ( + toggle(o.value)} + label={o.value} + className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-hidden transition ${ + on ? "text-fg" : "text-muted" + }`} + > + + {on && } + + {o.value} + {o.count != null && ( + {o.count} + )} + + ); + })} + {hiddenCount > 0 && ( +

+ {t("plex.filter.moreRefine", { + count: hiddenCount, + defaultValue: "{{count}} more — refine with search", + })} +

+ )} + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 97dbf82b..e257bf44 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -1,9 +1,10 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { plexLang } from "../i18n"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react"; +import { Layers, ListMusic, Plus, Search, SlidersHorizontal, X } from "lucide-react"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; import { api, plexFilterCount, type PlexFilters } from "../lib/api"; import { type PanelLayout } from "../lib/panelLayout"; import { type MediaSourceKind } from "../lib/mediaSource"; @@ -12,7 +13,8 @@ import { useScrollFade } from "../lib/useScrollFade"; import FilterRail from "./FilterRail"; import { type PanelItem } from "./PanelGroups"; import SegmentedToggle from "./SegmentedToggle"; -import FilterChipGroup, { filterChipClass } from "./FilterChipGroup"; +import FilterChipGroup from "./FilterChipGroup"; +import MultiSelectChips from "./MultiSelectChips"; import { usePlexClearFilters } from "./usePlexActiveFilters"; import { usePlex, usePlexActions } from "./PlexProvider"; @@ -68,7 +70,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl // hand-off to PlexBrowse via the shared `playlistOpen` slot. const { scope, show, filters } = usePlex(); const { setShow, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions(); - const collFade = useScrollFade(); const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() }); const [newPlaylist, setNewPlaylist] = useState(""); // This rail's media source (the Plex library) — names the abstraction the feed and Plex rails @@ -93,15 +94,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl }); const facets = facetsQ.data; - const [collSearch, setCollSearch] = useState(""); - const collSearchDeb = useDebounced(collSearch.trim(), 300); - const collectionsQ = useQuery({ - queryKey: qk.plexCollections("union", collSearchDeb), - queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined), - enabled: !filters.collection, - }); - const collections = collectionsQ.data?.collections ?? []; - const fCount = plexFilterCount(filters); // Scope and sort moved to the header / above-the-grid toolbar (S3); the panel badge now counts only // the filters that still live in this panel (expanded filters + watch-state). @@ -109,15 +101,14 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl const anyActive = activeCount > 0; const patch = (p: Partial) => setFilters({ ...filters, ...p }); - const toggleIn = (arr: string[], v: string) => - arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]; const clearAll = usePlexClearFilters(); const durBucketKey = DURATION_BUCKETS.find( (b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max, )?.key; - const peopleActive = - filters.directors.length + filters.actors.length + filters.studios.length > 0; + // Directors + actors have no picker (they're added from the detail/cast surfaces) — this section + // just lists them as removable chips. Studios now have their own picker section (below). + const peopleActive = filters.directors.length + filters.actors.length > 0; // Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed, // so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved @@ -206,13 +197,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl onRemove={() => patch({ actors: filters.actors.filter((x) => x !== a) })} /> ))} - {filters.studios.map((s) => ( - patch({ studios: filters.studios.filter((x) => x !== s) })} - /> - ))} ), }, @@ -229,36 +213,9 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl /> ) : ( -
- setCollSearch(e.target.value)} - placeholder={t("plex.filter.collectionSearch")} - className="mb-1.5 w-full rounded-lg border border-border bg-surface px-2 py-1 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" - /> -
- {collections.map((c) => ( - - ))} - {collections.length === 0 && ( - - {t("plex.filter.collectionEmpty")} - - )} -
-
+ patch({ collection: id, collectionTitle: title })} + /> ), }, { @@ -284,27 +241,19 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl body: ( <> {filters.genres.length > 1 && ( - patch({ genreMode: m })} - /> +
+ patch({ genreMode: m })} + /> +
)} -
- {facets.genres.map((g) => ( - - ))} -
+ patch({ genres: next })} + /> ), }, @@ -380,24 +329,28 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl id: "contentrating", title: t("plex.filter.contentRating"), body: ( -
- {facets.content_ratings.map((c) => ( - - ))} -
+ patch({ contentRatings: next })} + /> + ), + }, + ] + : []), + ...(facets && facets.studios.length > 0 + ? [ + { + id: "studio", + title: t("plex.filter.studio"), + body: ( + patch({ studios: next })} + /> ), }, ] @@ -430,6 +383,106 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl ); } +// The collection filter as a compact "+ Add" → searchable popover (E5 S7), matching the genre/studio +// pickers instead of an always-expanded inline search + list. Single-select: picking a collection sets +// the filter and closes; the selected one then shows as a removable chip (handled by the caller). +function CollectionPicker({ onPick }: { onPick: (id: string, title: string) => void }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const searchDeb = useDebounced(search.trim(), 300); + const inputRef = useRef(null); + const fade = useScrollFade(); + const q = useQuery({ + queryKey: qk.plexCollections("union", searchDeb), + queryFn: () => api.plexCollections(undefined, searchDeb || undefined), + enabled: open, // only fetch while the picker is open + }); + const collections = q.data?.collections ?? []; + // Search narrows server-side; cap the client render so a large union (hundreds) stays light. + const visibleCollections = collections.slice(0, 50); + const hiddenCollections = collections.length - visibleCollections.length; + const pop = useAnchoredPopover({ + open, + onOpenChange: setOpen, + mode: "menu", + placement: "bottom-start", + hostsInput: true, + }); + useEffect(() => { + if (open) inputRef.current?.focus(); + else setSearch(""); + }, [open]); + + return ( + <> + + +
+ + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") return; // let the popover dismiss handle it + e.stopPropagation(); // keep text editing out of the surface's list-nav/typeahead + }} + placeholder={t("plex.filter.collectionSearch")} + className="w-full rounded-lg border border-border bg-surface py-1 pl-7 pr-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" + /> +
+
+ {collections.length === 0 ? ( +

{t("plex.filter.collectionEmpty")}

+ ) : ( + <> + {visibleCollections.map((c) => ( + { + onPick(c.id, c.title); + setOpen(false); + }} + label={c.title} + className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted outline-hidden transition" + > + + {c.title} + {c.child_count != null && ( + + {c.child_count} + + )} + + ))} + {hiddenCollections > 0 && ( +

+ {t("plex.filter.moreRefine", { count: hiddenCollections })} +

+ )} + + )} +
+
+ + ); +} + function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) { return ( diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 0ccc7fdb..f476fe5f 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -22,5 +22,8 @@ "loadError": "Couldn't load this — check your connection and try again.", "seeAll": "See all", "scrollLeft": "Scroll left", - "scrollRight": "Scroll right" + "scrollRight": "Scroll right", + "search": "Search", + "remove": "Remove", + "noResults": "No matches" } diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 0eeb4b79..b9f19f6d 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -70,7 +70,10 @@ "collection": "Collection", "collectionSearch": "Search collections…", "collectionEmpty": "No collections", - "matchLabel": "Match" + "matchLabel": "Match", + "studio": "Studio", + "add": "Add", + "moreRefine": "{{count}} more — refine with search" }, "count": "{{count}} titles", "searchCount": "{{count}} matches", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index 350e7d27..b40d4281 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -22,5 +22,8 @@ "loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra.", "seeAll": "Összes", "scrollLeft": "Görgetés balra", - "scrollRight": "Görgetés jobbra" + "scrollRight": "Görgetés jobbra", + "search": "Keresés", + "remove": "Eltávolítás", + "noResults": "Nincs találat" } diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 01fb93f8..09c6c08c 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -70,7 +70,10 @@ "collection": "Kollekció", "collectionSearch": "Kollekciók keresése…", "collectionEmpty": "Nincs kollekció", - "matchLabel": "Egyezés" + "matchLabel": "Egyezés", + "studio": "Stúdió", + "add": "Hozzáadás", + "moreRefine": "még {{count}} — finomíts kereséssel" }, "count": "{{count}} cím", "searchCount": "{{count}} találat", diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index ca04efa5..5812722d 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -288,6 +288,7 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void { export interface PlexFacets { genres: { value: string; count: number }[]; content_ratings: { value: string; count: number }[]; + studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker (E5 S7) year_min: number | null; year_max: number | null; rating_max: number | null;