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
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type PlexCard, type PlexCollectionDetail } from "../lib/api";
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { useDebounced } from "../lib/useDebounced";
@@ -67,8 +67,9 @@ export default function PlexCollectionView({
// The Play queue is the playable-leaf members (movies/episodes) in order — a show can't queue directly.
const playable = items.filter((c) => c.type !== "show").map((c) => c.id);
const canEdit = isAdmin && !!d?.can_edit;
const canEnableEdit =
isAdmin && !d?.can_edit && !d?.editable && !d?.smart && d?.source === "collection";
// A plain (non-smart, non-external) collection can be admin-enabled for editing. Shares
// isIntrinsicReadOnly with the browse tile so the lock/edit affordance can't drift between them.
const canEnableEdit = isAdmin && !d?.can_edit && !d?.editable && !!d && !isIntrinsicReadOnly(d);
const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list
const toggleWatched = (card: PlexCard) =>
@@ -120,6 +121,9 @@ export default function PlexCollectionView({
}
async function doRemove(card: PlexCard) {
// Write-through to the shared Plex library — confirm first, matching the delete-collection guard.
if (!(await confirm({ message: t("plex.collection.removeConfirm", { title: card.title }) })))
return;
setBusy(true);
try {
await api.plexCollectionRemoveItem(collectionId, card.id);
@@ -381,6 +385,8 @@ function AddItemPanel({
<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>
) : 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>
) : (