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:
@@ -8,6 +8,18 @@ import { ensureNavId, peekNavId, subscribeNav } from "../lib/history";
|
||||
// top WITHIN the same entry. Module-level so a view's position survives its scroller unmounting
|
||||
// (opening a channel page swaps in a different shell/scroller) and is restored when it returns.
|
||||
const savedScroll = new Map<string, number>();
|
||||
// The key embeds an ever-increasing history-entry id, so without a bound this map would grow one
|
||||
// entry per scrolled navigation for the life of the session. Cap it with a small LRU (Map preserves
|
||||
// insertion order): re-insert on write to mark most-recently-used, evict the oldest past the cap.
|
||||
const SCROLL_MEMORY_CAP = 200;
|
||||
function rememberScroll(key: string, top: number): void {
|
||||
savedScroll.delete(key);
|
||||
savedScroll.set(key, top);
|
||||
if (savedScroll.size > SCROLL_MEMORY_CAP) {
|
||||
const oldest = savedScroll.keys().next().value;
|
||||
if (oldest !== undefined) savedScroll.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
// Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame
|
||||
// or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise
|
||||
@@ -120,7 +132,7 @@ export default function PageScroller({
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
if (scrollKeyRef.current == null) return;
|
||||
savedScroll.set(`${peekNavId()}:${scrollKeyRef.current}`, el.scrollTop);
|
||||
rememberScroll(`${peekNavId()}:${scrollKeyRef.current}`, el.scrollTop);
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("popstate", resolve);
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user