release: 0.66.0 — E5 Plex epic (scoped search, HU localization, toolbar/header)
Ships the E5 sprint stack (S1–S7): field-scoped Plex search + typeahead, cast & crew pages, the HU-overlay Hungarian metadata + language-aware serving, the IMDb/audience rating split, the toolbar relocation, and the narrow/mobile header fix — plus the underlying Plex-module refactors (feedquery/plex.cards/plex.query extraction, db.query→2.0 select). - VERSION 0.65.2 → 0.66.0 - releaseNotes.ts: 0.66.0 user-facing entry - prettier-format the E5-touched frontend files (release hygiene) Merged main (0.65.2 download-gc hotfix) in first; no code conflicts (E5 is Plex-only, the hotfix was notifications/downloads).
This commit is contained in:
@@ -1327,7 +1327,10 @@ function PlexShowView({
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||||
const lang = plexLang();
|
const lang = plexLang();
|
||||||
const q = useQuery({ queryKey: qk.plexShow(showId, lang), queryFn: () => api.plexShow(showId, lang) });
|
const q = useQuery({
|
||||||
|
queryKey: qk.plexShow(showId, lang),
|
||||||
|
queryFn: () => api.plexShow(showId, lang),
|
||||||
|
});
|
||||||
const d = q.data;
|
const d = q.data;
|
||||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||||
const [collOpen, setCollOpen] = useState(false);
|
const [collOpen, setCollOpen] = useState(false);
|
||||||
@@ -1565,7 +1568,10 @@ function PlexSeasonView({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const lang = plexLang();
|
const lang = plexLang();
|
||||||
const q = useQuery({ queryKey: qk.plexShow(showId, lang), queryFn: () => api.plexShow(showId, lang) });
|
const q = useQuery({
|
||||||
|
queryKey: qk.plexShow(showId, lang),
|
||||||
|
queryFn: () => api.plexShow(showId, lang),
|
||||||
|
});
|
||||||
const d = q.data;
|
const d = q.data;
|
||||||
const se = d?.seasons.find((s) => s.id === seasonId);
|
const se = d?.seasons.find((s) => s.id === seasonId);
|
||||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
vi.mock("../lib/useDebounced", () => ({ useDebounced: (v: unknown) => v }));
|
vi.mock("../lib/useDebounced", () => ({ useDebounced: (v: unknown) => v }));
|
||||||
|
|
||||||
// Controllable provider — PlexSearchBox reads q/scope/field and calls setQ/setField/setJumpTo.
|
// Controllable provider — PlexSearchBox reads q/scope/field and calls setQ/setField/setJumpTo.
|
||||||
const plexState = { scope: "both", field: "all", q: "" } as { scope: string; field: string; q: string };
|
const plexState = { scope: "both", field: "all", q: "" } as {
|
||||||
|
scope: string;
|
||||||
|
field: string;
|
||||||
|
q: string;
|
||||||
|
};
|
||||||
// setQ writes back to plexState.q so a typed value flows through to `dq` on the next render (the real
|
// setQ writes back to plexState.q so a typed value flows through to `dq` on the next render (the real
|
||||||
// provider persists it) — lets the reopen test assert a fresh query fires on retype, not just that
|
// provider persists it) — lets the reopen test assert a fresh query fires on retype, not just that
|
||||||
// the panel re-appears from cache.
|
// the panel re-appears from cache.
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ import { usePlex, usePlexActions } from "./PlexProvider";
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import {
|
import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch";
|
||||||
availablePlexFields,
|
|
||||||
healPlexField,
|
|
||||||
plexFieldLabelKey,
|
|
||||||
} from "../lib/plexSearch";
|
|
||||||
|
|
||||||
// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a
|
// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a
|
||||||
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
|
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
|
||||||
@@ -46,8 +42,7 @@ export default function PlexSearchBox() {
|
|||||||
const suggestQ = useQuery({
|
const suggestQ = useQuery({
|
||||||
queryKey: qk.plexSuggest(scope, dq, activeField),
|
queryKey: qk.plexSuggest(scope, dq, activeField),
|
||||||
enabled: suggestEnabled,
|
enabled: suggestEnabled,
|
||||||
queryFn: () =>
|
queryFn: () => api.plexLibrary({ scope, q: dq, field: activeField, limit: SUGGEST_LIMIT }),
|
||||||
api.plexLibrary({ scope, q: dq, field: activeField, limit: SUGGEST_LIMIT }),
|
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []) : [];
|
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []) : [];
|
||||||
@@ -58,9 +53,7 @@ export default function PlexSearchBox() {
|
|||||||
// Keep the highlighted option scrolled into view during arrow-key navigation.
|
// Keep the highlighted option scrolled into view during arrow-key navigation.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hi < 0) return;
|
if (hi < 0) return;
|
||||||
listRef.current
|
listRef.current?.querySelector(`[data-idx="${hi}"]`)?.scrollIntoView({ block: "nearest" });
|
||||||
?.querySelector(`[data-idx="${hi}"]`)
|
|
||||||
?.scrollIntoView({ block: "nearest" });
|
|
||||||
}, [hi]);
|
}, [hi]);
|
||||||
|
|
||||||
const pick = (i: number) => {
|
const pick = (i: number) => {
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
|
|
||||||
describe("SearchBar responsive wrapper (E5 S7)", () => {
|
describe("SearchBar responsive wrapper (E5 S7)", () => {
|
||||||
it("carries the container-query classes that make it wrap instead of collapsing", () => {
|
it("carries the container-query classes that make it wrap instead of collapsing", () => {
|
||||||
const { container } = render(
|
const { container } = render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
|
||||||
<SearchBar value="" onChange={() => {}} placeholder="Search…" />,
|
|
||||||
);
|
|
||||||
const cls = (container.firstElementChild as HTMLElement).className;
|
const cls = (container.firstElementChild as HTMLElement).className;
|
||||||
// A usable min-width floor once the column has room → the box wraps via flex-wrap, never ~0:
|
// A usable min-width floor once the column has room → the box wraps via flex-wrap, never ~0:
|
||||||
expect(cls).toContain("@min-[420px]/appmain:min-w-[15rem]");
|
expect(cls).toContain("@min-[420px]/appmain:min-w-[15rem]");
|
||||||
|
|||||||
@@ -307,7 +307,12 @@ export const plexApi = {
|
|||||||
},
|
},
|
||||||
// Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group
|
// Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group
|
||||||
// narrows the others and the bounds match the visible result set. `lang` picks the genre vocabulary.
|
// narrows the others and the bounds match the visible result set. `lang` picks the genre vocabulary.
|
||||||
plexFacets: (scope: string, f?: PlexFilters, show?: string, lang?: string): Promise<PlexFacets> => {
|
plexFacets: (
|
||||||
|
scope: string,
|
||||||
|
f?: PlexFilters,
|
||||||
|
show?: string,
|
||||||
|
lang?: string,
|
||||||
|
): Promise<PlexFacets> => {
|
||||||
const u = new URLSearchParams({ scope });
|
const u = new URLSearchParams({ scope });
|
||||||
if (show && show !== "all") u.set("show", show);
|
if (show && show !== "all") u.set("show", show);
|
||||||
if (lang === "hu") u.set("lang", "hu");
|
if (lang === "hu") u.set("lang", "hu");
|
||||||
|
|||||||
@@ -4,13 +4,7 @@
|
|||||||
|
|
||||||
export type PlexSearchField = "all" | "title" | "people" | "summary" | "episode";
|
export type PlexSearchField = "all" | "title" | "people" | "summary" | "episode";
|
||||||
|
|
||||||
const PLEX_SEARCH_FIELDS: PlexSearchField[] = [
|
const PLEX_SEARCH_FIELDS: PlexSearchField[] = ["all", "title", "people", "summary", "episode"];
|
||||||
"all",
|
|
||||||
"title",
|
|
||||||
"people",
|
|
||||||
"summary",
|
|
||||||
"episode",
|
|
||||||
];
|
|
||||||
|
|
||||||
// i18n label key per scope (see plex.searchField.* in the locale files).
|
// i18n label key per scope (see plex.searchField.* in the locale files).
|
||||||
export const plexFieldLabelKey = (f: PlexSearchField): string => `plex.searchField.${f}`;
|
export const plexFieldLabelKey = (f: PlexSearchField): string => `plex.searchField.${f}`;
|
||||||
@@ -18,9 +12,7 @@ export const plexFieldLabelKey = (f: PlexSearchField): string => `plex.searchFie
|
|||||||
// Episodes only exist under show libraries, so the Episode scope is offered only when shows are in
|
// Episodes only exist under show libraries, so the Episode scope is offered only when shows are in
|
||||||
// scope (show | both) — mirroring the backend, which returns nothing for episode+movie-only.
|
// scope (show | both) — mirroring the backend, which returns nothing for episode+movie-only.
|
||||||
export function availablePlexFields(scope: string): PlexSearchField[] {
|
export function availablePlexFields(scope: string): PlexSearchField[] {
|
||||||
return scope === "movie"
|
return scope === "movie" ? PLEX_SEARCH_FIELDS.filter((f) => f !== "episode") : PLEX_SEARCH_FIELDS;
|
||||||
? PLEX_SEARCH_FIELDS.filter((f) => f !== "episode")
|
|
||||||
: PLEX_SEARCH_FIELDS;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heal a stored/selected field against the current scope: a movie-only scope can't hold Episode, so
|
// Heal a stored/selected field against the current scope: a movie-only scope can't hold Episode, so
|
||||||
|
|||||||
@@ -14,6 +14,22 @@ export interface ReleaseEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.66.0",
|
||||||
|
date: "2026-08-17",
|
||||||
|
summary:
|
||||||
|
"A big Plex update: Hungarian metadata, scoped search with instant suggestions, cast & crew pages, and a tidier toolbar that finally works on a phone.",
|
||||||
|
features: [
|
||||||
|
"Hungarian Plex library: with the app in Hungarian, film and show titles, summaries and genres appear in Hungarian (falling back to English where a title has no Hungarian version). Search and the genre filter work in the Hungarian vocabulary too, and switching language clears a genre selection so it can't silently hide everything.",
|
||||||
|
"Scoped Plex search: a pill in the search box narrows a search to Titles, Cast, Descriptions or Episodes, and a live list of matching titles drops down as you type — jumping straight to a film's page or a show's episodes.",
|
||||||
|
"Cast & crew pages: click an actor or director anywhere in Plex to see their filmography across your libraries, or filter the library by that person.",
|
||||||
|
"Ratings now separate an IMDb score (shown with a link to IMDb) from Plex's own audience score, so a title with no IMDb rating still shows its audience score instead of nothing.",
|
||||||
|
"Plex toolbar tidy-up: the movie/show scope sits next to the search box and the sort control sits above the cards (like the feed), and the header no longer collapses or overlaps its own controls on a narrow window or a phone.",
|
||||||
|
],
|
||||||
|
chores: [
|
||||||
|
"Large internal refactor of the Plex module (search/card/query builders split out, the feed read-model extracted, the database queries modernized) backed by characterization tests, plus a full-codebase review. No behaviour change intended.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.65.2",
|
version: "0.65.2",
|
||||||
date: "2026-08-16",
|
date: "2026-08-16",
|
||||||
|
|||||||
Reference in New Issue
Block a user