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:
@@ -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