fix(plex): resolve deep-review findings on the Discover/Collections batch

Round of USER-triggered deep-review fixes (11 CONFIRMED + 1 PLAUSIBLE, all
verified) on the 22-commit branch:

- collections: split the mutate-then-resync error surface. A resync READ that
  fails after a successful Plex mutation no longer reports a false 422 "Plex
  write failed" + rollback; it returns 502 "sync pending" (the next full sync
  reconciles the mirror). New _write_or_pending_error helper; delete is exempt
  (no Plex read). Covered by new happy-path / 422-rollback / 502 tests.
- New-collection dup-name guard is now advisory only: existingNames is the
  search-scoped, all-library union, but the backend enforces uniqueness PER
  library — so it no longer blocks a valid same-name collection in another
  library, and the backend 409 is localized to nameTaken.
- member-remove now confirms before the write-through (matches delete-collection).
- add-title / new-collection library search render an error state on request
  failure instead of a misleading "No matches".
- history: seed navCounter past the current entry's persisted sfScroll on load
  so post-reload ids can't collide with back-stack entries and mis-restore scroll.
- PageScroller: LRU-cap the navId-keyed savedScroll map (was unbounded).
- share isIntrinsicReadOnly between the browse tile and the info page so the
  lock/edit affordance can't drift.
This commit is contained in:
2026-08-23 06:57:33 +02:00
parent b71b33654a
commit 6af238c2ea
10 changed files with 227 additions and 21 deletions
+25 -12
View File
@@ -4,9 +4,16 @@ import type { TFunction } from "i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type PlexCard, type PlexCollection } from "../lib/api";
import {
api,
HttpError,
isIntrinsicReadOnly,
type PlexCard,
type PlexCollection,
} from "../lib/api";
import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import { LoadingState, StateMessage } from "./QueryState";
import PosterTile from "./PosterTile";
@@ -21,12 +28,6 @@ function collectionMeta(c: PlexCollection, t: TFunction): string {
return parts.join(" · ");
}
// A read-only collection (a smart or external auto-list — imdb/tmdb/…) can never be edited; mark it up
// front on the tile, mirroring the badge on the info page.
function isReadOnlyCollection(c: PlexCollection): boolean {
return !!c.smart || (!!c.source && c.source !== "collection");
}
// The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles,
// each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the
// title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections
@@ -87,8 +88,12 @@ function NewCollectionPanel({
const [busy, setBusy] = useState(false);
const debounced = useDebounced(term.trim(), 300);
const trimmed = name.trim();
const dup = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase());
const nameOk = trimmed.length > 0 && !dup;
// Advisory only: `existingNames` is the search-scoped, all-library collection union, whereas the
// backend enforces uniqueness PER library (Plex only conflates same-named collections within one
// section). So a match here is a hint, not a hard block — it must not gate creation, or a valid
// same-name collection in another library would be un-creatable. The backend's 409 is authoritative.
const dupHint = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase());
const nameOk = trimmed.length > 0;
const q = useQuery({
queryKey: qk.plexLibrary("collection-new", debounced),
queryFn: () => api.plexLibrary({ scope: "both", q: debounced, limit: 12 }),
@@ -103,7 +108,13 @@ function NewCollectionPanel({
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
onCreated(col.id);
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
// The backend is authoritative on per-library name uniqueness — localize its 409 to nameTaken
// (notifyActionError would otherwise surface the server's raw English detail).
if (e instanceof HttpError && e.status === 409) {
notify({ level: "error", message: t("plex.collection.nameTaken") });
} else {
notifyActionError(e, t("plex.collection.editFailed"));
}
setBusy(false);
}
}
@@ -128,7 +139,7 @@ function NewCollectionPanel({
aria-label={t("plex.collection.newCollection")}
className="glass-card w-full rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
{dup ? (
{dupHint ? (
<p className="mt-1 mb-2 text-xs text-danger">{t("plex.collection.nameTaken")}</p>
) : (
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.newHint")}</p>
@@ -146,6 +157,8 @@ function NewCollectionPanel({
</div>
{!nameOk || debounced.length === 0 ? null : q.isLoading ? (
<LoadingState label={t("plex.loading")} />
) : q.isError ? (
<p className="text-muted text-sm">{t("common.loadError")}</p>
) : results.length === 0 ? (
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
) : (
@@ -244,7 +257,7 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
art={c.art}
subtitle={collectionMeta(c, t)}
topRight={
isReadOnlyCollection(c) ? (
isIntrinsicReadOnly(c) ? (
<Lock
className="h-3.5 w-3.5 text-white/80 drop-shadow"
aria-label={t("plex.collection.readOnly")}