From 4eaa55385f312fc68809cc1ef9a557f753627fa0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 22:12:43 +0200 Subject: [PATCH] fix(plex): a collection kept after a failed refresh holds its seed title When Plex made the collection and only the read after it failed, the kept row had no members: the title showed unticked in the collection it had just been added to, the collection empty, and the name stayed in the field for a retry that could only be refused as taken. The seed's row now names the collection (child_count 1), and the panel clears the field on that refusal as on success. Also held: the panel's lines sit beside its list, and a keyboard Retry lands on the first row. --- backend/app/routes/plex/collections.py | 13 ++++++-- backend/tests/test_plex_collections_filter.py | 3 ++ frontend/src/components/PlexCollectionAdd.tsx | 11 ++++++- frontend/src/components/PlexMenu.dom.test.tsx | 32 +++++++++++++++++-- 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py index abd83d79..16ad7215 100644 --- a/backend/app/routes/plex/collections.py +++ b/backend/app/routes/plex/collections.py @@ -470,8 +470,17 @@ def create_collection( # It is on Plex now, and only the follow-up read failed. Its row is kept, editable: the # full sync preserves `editable` but creates a missing row read-only, so the collection # would drop out of every "add to" list; and the duplicate-name check reads these rows, - # so a retry of the same name would make a second one on Plex. - db.add(PlexCollection(rating_key=new_rk, library_id=lib.id, title=title, editable=True)) + # so a retry of the same name would make a second one on Plex. Plex made it with exactly + # the seed in it, so the seed's row says so too — otherwise the title showed unticked in + # the collection it was just added to, and the collection empty, until that sync. + db.add( + PlexCollection( + rating_key=new_rk, library_id=lib.id, title=title, editable=True, child_count=1 + ) + ) + Model = PlexItem if lib.kind == "movie" else PlexShow + if row := by_rating_key(db, Model, seed): + row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), new_rk])) db.commit() raise _write_or_pending_error(wrote, e) from e return _collection_card(col) diff --git a/backend/tests/test_plex_collections_filter.py b/backend/tests/test_plex_collections_filter.py index dfa8b012..1c2e000b 100644 --- a/backend/tests/test_plex_collections_filter.py +++ b/backend/tests/test_plex_collections_filter.py @@ -360,6 +360,9 @@ def test_create_resync_failure_is_422(db, monkeypatch, override_dep): # where collections are added to, and the same name is not made twice. row = by_rating_key(db, PlexCollection, "NEW") assert row is not None and row.editable and row.title == "Fresh Coll" + # Plex made it with the seed in it: the seed's row says so, so the title shows ticked in it. + assert row.child_count == 1 + assert "NEW" in (by_rating_key(db, PlexItem, "m1").collection_keys or []) again = client.post( "/api/plex/collections", json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"}, diff --git a/frontend/src/components/PlexCollectionAdd.tsx b/frontend/src/components/PlexCollectionAdd.tsx index 403d5f62..1ace05cb 100644 --- a/frontend/src/components/PlexCollectionAdd.tsx +++ b/frontend/src/components/PlexCollectionAdd.tsx @@ -2,7 +2,7 @@ import { useLayoutEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; -import { api, type PlexCollection } from "../lib/api"; +import { api, HttpError, type PlexCollection } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; @@ -82,6 +82,7 @@ export default function PlexCollectionAddAt({ await api.plexCreateCollection("", name, ratingKey); made = true; } catch (e) { + made = madeOnPlex(e); notifyActionError(e, t("plex.collection.editFailed")); } refresh(); @@ -95,6 +96,7 @@ export default function PlexCollectionAddAt({ popover={pop} className={`${SURFACE.popover} w-64 p-2`} ariaLabel={t("plex.collectionAdd.title")} + listInside > ( old: T | undefined, id: string, diff --git a/frontend/src/components/PlexMenu.dom.test.tsx b/frontend/src/components/PlexMenu.dom.test.tsx index 90424624..17e29bee 100644 --- a/frontend/src/components/PlexMenu.dom.test.tsx +++ b/frontend/src/components/PlexMenu.dom.test.tsx @@ -8,7 +8,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { FloatingTree } from "@floating-ui/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { api, type PlexCard, type PlexCollection } from "../lib/api"; +import { api, HttpError, type PlexCard, type PlexCollection } from "../lib/api"; import { qk } from "../lib/queryKeys"; vi.mock("react-i18next", async (importOriginal) => { @@ -361,9 +361,35 @@ describe("a Plex title's menu — Add to collection", () => { const alert = await screen.findByRole("alert", { hidden: true }); expect(alert.textContent).toContain("plex.collectionAdd.loadFailed"); expect(screen.queryByText("plex.collectionAdd.none")).toBeNull(); + // A menu owns its items only: the line about the list sits beside it, not in it. + expect(document.querySelector("[role=menu]")).not.toBeNull(); + expect(alert.closest("[role=menu]")).toBeNull(); list.mockResolvedValue({ collections: [coll("cB", "Beta", false)] }); - fireEvent.click(await screen.findByText("common.retry")); - await checkRow("Beta"); + // Retry from the keyboard: the focused button goes as the list loads, and focus must not be left + // on with the menu open — it lands on the first row that came back. + const retry = (await screen.findByText("common.retry")).closest("button")!; + retry.focus(); + fireEvent.click(retry); + const beta = await checkRow("Beta"); + await vi.waitFor(() => expect(document.activeElement).toBe(beta)); + }); + + it("clears the name when Plex made the collection and only the read after it failed", async () => { + vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({ collections: [] }); + const create = vi + .spyOn(api, "plexCreateCollection") + .mockRejectedValue( + new HttpError(422, "pending", { code: "refused", reason: "collection_pending_sync" }), + ); + setup(card({}), {}, { admin: true }); + rightClick(); + fireEvent.click(item("plex.collectionAdd.row")); + const name = await screen.findByLabelText("plex.collection.namePlaceholder"); + fireEvent.change(name, { target: { value: "Noir" } }); + fireEvent.keyDown(name, { key: "Enter" }); + await vi.waitFor(() => expect(create).toHaveBeenCalledTimes(1)); + // It exists now: a second Enter on the kept name could only be refused as taken. + await vi.waitFor(() => expect(name.value).toBe("")); }); it("steps from the name field into the rows with the arrows — rows that arrived after it opened", async () => {