fix(playlists): a refused edit is taken back on screen, and a gone list or tag is said, not swallowed
Deep-review round 4, the playlist surfaces. - A failed move or reorder relied on the resync to put the rows back — but the server's list is unchanged, so the refetch comes back deep-equal, react-query keeps the same `data` object, and the re-seed effect keyed on it never ran: the moved row stayed gone, the rejected order stayed on screen. Both restore from the cached detail now. - A failed removal puts its row back itself: when the server is unreachable the resync fails too, and round 3's "keep the list on a failed refetch" guard then kept showing it as removed. - The Plex playlist page (remove / rename / delete / reorder), the add-to-playlist popover and the tag manager had no failure handling at all: a list or tag deleted elsewhere (a 404, now a structured `not_found` the request layer leaves to the caller) failed in silence and an optimistic removal stayed removed. Each says it and resyncs; deleting a Plex playlist that is already gone just leaves the page. - Playlists' own loading/error branches use the shared `LoadingState` / `StateMessage` (the loading line had no live region). - The "player stays mounted" tests count mounts: a remount ended with the same flag as staying up.
This commit is contained in:
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
@@ -49,14 +50,19 @@ export default function AddToPlaylist({
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
}
|
||||
|
||||
// A failure is said, and the list refreshed either way. With no catch, a playlist deleted on
|
||||
// another device (a 404 `not_found/playlist` — still offered, from a cache up to 30 s old) was an
|
||||
// unhandled rejection: the tick did not change, nothing was said, the gone row stayed.
|
||||
async function toggle(pl: Playlist) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
||||
else await api.addToPlaylist(pl.id, videoId);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
||||
} finally {
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -69,8 +75,10 @@ export default function AddToPlaylist({
|
||||
const pl = await api.createPlaylist(name);
|
||||
await api.addToPlaylist(pl.id, videoId);
|
||||
setNewName("");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("playlists.addFailed"));
|
||||
} finally {
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A playlist or tag edit that fails is said, and what the screen shows goes back to what is true
|
||||
// (deep-review round 4). The Plex playlist page, the add-to-playlist popover and the tag manager had
|
||||
// no failure handling at all: a 404 for a list or tag deleted elsewhere — now a structured
|
||||
// `not_found`, which the request layer leaves to the caller — failed in silence, and an optimistic
|
||||
// removal stayed removed.
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
||||
}));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify,
|
||||
}));
|
||||
|
||||
import PlexPlaylistView from "./PlexPlaylistView";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import TagManager from "./TagManager";
|
||||
import { api, HttpError, type PlexCard } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
beforeEach(() => notify.mockClear());
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const gone = (what: string) =>
|
||||
new HttpError(404, `gone ${what}`, { code: "not_found", reason: what });
|
||||
const errors = () => notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.message);
|
||||
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const movie = (id: string) =>
|
||||
({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard;
|
||||
|
||||
describe("the Plex playlist page", () => {
|
||||
it("puts back a row whose removal failed, and says why", async () => {
|
||||
vi.spyOn(api, "plexPlaylist").mockResolvedValue({
|
||||
id: 5,
|
||||
title: "Films",
|
||||
items: [movie("1"), movie("2")],
|
||||
});
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockRejectedValue(gone("playlist"));
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<PlexPlaylistView playlistId={5} onBack={() => {}} onPlay={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Movie 1");
|
||||
fireEvent.click(screen.getAllByLabelText("remove")[0]!);
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(screen.getByText("Movie 1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves a playlist that is already gone when asked to delete it", async () => {
|
||||
vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items: [] });
|
||||
vi.spyOn(api, "plexDeletePlaylist").mockRejectedValue(gone("playlist"));
|
||||
const onBack = vi.fn();
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByTitle("plex.playlist.delete"));
|
||||
await waitFor(() => expect(onBack).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("the add-to-playlist popover", () => {
|
||||
it("says so when the playlist ticked is gone, and refreshes the list", async () => {
|
||||
const lists = vi
|
||||
.spyOn(api, "playlists")
|
||||
.mockResolvedValue([{ id: 3, name: "Gone", kind: "user", has_video: false }] as never);
|
||||
vi.spyOn(api, "addToPlaylist").mockRejectedValue(gone("playlist"));
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<AddToPlaylist videoId="v1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
|
||||
fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true }));
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tag manager", () => {
|
||||
it("says so when the tag deleted is gone, and refreshes the list", async () => {
|
||||
const tags = vi
|
||||
.spyOn(api, "tags")
|
||||
.mockResolvedValue([{ id: 4, name: "Gone", system: false, channel_count: 0 }] as never);
|
||||
vi.spyOn(api, "channels").mockResolvedValue([] as never);
|
||||
vi.spyOn(api, "deleteTag").mockRejectedValue(gone("tag"));
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<TagManager onClose={() => {}} onFocusChannel={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByLabelText("tagManager.delete"));
|
||||
await waitFor(() => expect(errors()).toEqual(["gone tag"]));
|
||||
await waitFor(() => expect(tags.mock.calls.length).toBeGreaterThan(1));
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ const h = vi.hoisted(() => ({
|
||||
notify: vi.fn(),
|
||||
selectedId: 1 as number | null,
|
||||
mounted: false,
|
||||
mounts: 0,
|
||||
player: null as null | {
|
||||
video: { id: string; position_seconds: number };
|
||||
onClose: () => void;
|
||||
@@ -43,6 +44,7 @@ vi.mock("./PlayerModal", async () => {
|
||||
h.player = props;
|
||||
useEffect(() => {
|
||||
h.mounted = true;
|
||||
h.mounts++;
|
||||
return () => {
|
||||
h.mounted = false;
|
||||
};
|
||||
@@ -95,7 +97,7 @@ const list = (id: number, items: Video[]) => ({
|
||||
|
||||
let qc: QueryClient;
|
||||
|
||||
function mount(lists: Record<number, Video[]>) {
|
||||
function mount(lists: Record<number, Video[]>, prefs: Record<string, unknown> = {}) {
|
||||
vi.spyOn(api, "playlists").mockResolvedValue(
|
||||
Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never,
|
||||
);
|
||||
@@ -103,7 +105,7 @@ function mount(lists: Record<number, Video[]>) {
|
||||
async (id: number) => list(id, lists[id] ?? []) as never,
|
||||
);
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: prefs });
|
||||
const tree = () => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<Playlists />
|
||||
@@ -120,6 +122,7 @@ beforeEach(() => {
|
||||
h.selectedId = 1;
|
||||
h.player = null;
|
||||
h.mounted = false;
|
||||
h.mounts = 0;
|
||||
h.notify.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -248,8 +251,12 @@ describe("Playlists — the player outlives a change of selection", () => {
|
||||
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {}));
|
||||
h.selectedId = 2;
|
||||
rerender();
|
||||
await screen.findByText("playlists.loading");
|
||||
// The shared loading state, which a screen reader hears (the old bare line had no live region).
|
||||
expect(
|
||||
(await screen.findByText("playlists.loading")).closest('[role="status"]'),
|
||||
).not.toBeNull();
|
||||
expect(h.mounted).toBe(true);
|
||||
expect(h.mounts).toBe(1); // never went down — a remount would also end `mounted`
|
||||
});
|
||||
|
||||
it("stays mounted when the newly selected list fails to load — a list deleted elsewhere", async () => {
|
||||
@@ -261,6 +268,7 @@ describe("Playlists — the player outlives a change of selection", () => {
|
||||
rerender();
|
||||
await screen.findByText("playlists.loadFailed");
|
||||
expect(h.mounted).toBe(true);
|
||||
expect(h.mounts).toBe(1);
|
||||
});
|
||||
|
||||
it("stays mounted when nothing is selected any more", async () => {
|
||||
@@ -271,6 +279,7 @@ describe("Playlists — the player outlives a change of selection", () => {
|
||||
rerender();
|
||||
await screen.findByText("playlists.pickOne");
|
||||
expect(h.mounted).toBe(true);
|
||||
expect(h.mounts).toBe(1);
|
||||
});
|
||||
|
||||
it("refreshes the list it was launched from when the viewer marks the video in it", async () => {
|
||||
@@ -289,3 +298,64 @@ describe("Playlists — the player outlives a change of selection", () => {
|
||||
expect(qc.getQueryState(qk.playlist(1))?.isInvalidated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const rowsOnScreen = () =>
|
||||
screen
|
||||
.getAllByRole("button", { name: /^card\.play — / })
|
||||
.map((b) => b.getAttribute("aria-label")?.replace("card.play — ", ""));
|
||||
|
||||
async function rowMenu(title: string, item: string) {
|
||||
const row = (await screen.findByRole("button", { name: `card.play — ${title}` })).closest("tr")!;
|
||||
fireEvent.contextMenu(row);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getAllByRole("menuitem", { hidden: true }).some((b) => b.textContent === item),
|
||||
).toBe(true),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === item)!,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Playlists — a change the server refused is taken back on screen", () => {
|
||||
// The resync refetch returns the SAME list the server had, react-query keeps the same `data`
|
||||
// object for a deep-equal answer, and the page's re-seed effect is keyed on that object — so the
|
||||
// optimistic change used to stay on screen for good.
|
||||
it("puts back a row whose move failed", async () => {
|
||||
vi.spyOn(api, "moveToPlaylist").mockRejectedValue(
|
||||
new HttpError(404, "gone", { code: "not_found", reason: "move_target" }),
|
||||
);
|
||||
mount({ 1: [vid("V", 0), vid("W", 0)], 2: [] }, { lastMovePlaylistId: 2 });
|
||||
await rowMenu("Video V", "playlists.moveToRecent");
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" })),
|
||||
);
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
|
||||
});
|
||||
|
||||
it("puts back the order when saving a reorder failed", async () => {
|
||||
vi.spyOn(api, "reorderPlaylist").mockRejectedValue(new HttpError(500, "boom"));
|
||||
mount({ 1: [vid("V", 0), vid("W", 0)] });
|
||||
await rowMenu("Video V", "playlists.orderDown");
|
||||
await waitFor(() => expect(api.reorderPlaylist).toHaveBeenCalled());
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
|
||||
});
|
||||
|
||||
it("puts back a row whose removal failed, even when the resync fails too", async () => {
|
||||
// A removal during an API restart: the DELETE fails, and so does the refetch meant to put the row
|
||||
// back — the list kept showing it as removed while it was still in the playlist.
|
||||
vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
mount({ 1: [vid("V", 0), vid("W", 0)] });
|
||||
await screen.findByRole("button", { name: "card.play — Video V" });
|
||||
vi.mocked(api.playlist).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
await rowMenu("Video V", "playlists.removeItem");
|
||||
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalled());
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", message: "playlists.removeFailed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowDownToLine,
|
||||
ArrowUp,
|
||||
@@ -51,6 +50,7 @@ import { formatRuntime } from "../lib/format";
|
||||
import { applyView, sameOrder } from "../lib/playlistView";
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { useComfortPin, pinVideo } from "./comfortPin";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
@@ -184,9 +184,10 @@ export default function Playlists() {
|
||||
const order = useUndoable<Video[]>([], {
|
||||
onApply: (next) => {
|
||||
if (selectedId == null) return;
|
||||
const listId = selectedId;
|
||||
api
|
||||
.reorderPlaylist(
|
||||
selectedId,
|
||||
listId,
|
||||
next.map((v) => v.id),
|
||||
)
|
||||
.then(() => {
|
||||
@@ -198,13 +199,11 @@ export default function Playlists() {
|
||||
})
|
||||
.catch((e) => {
|
||||
// Was a bare .then(): a failed reorder left the UI showing an order the server had
|
||||
// rejected, and produced an unhandled rejection. Say so and resync from the server.
|
||||
// rejected, and produced an unhandled rejection. Say so, and put back the order the server
|
||||
// still has — or the rejected one becomes the base for the next drag.
|
||||
notifyYouTubeActionError(e, t("playlists.reorderFailed"));
|
||||
// Clear the membership guard first: a reorder never changes the id SET, so without this
|
||||
// the arriving detail is judged "same list" and the rejected order stays on screen — and
|
||||
// becomes the base for the next drag.
|
||||
lastSetRef.current = "";
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
|
||||
restoreFromCache(listId);
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -220,6 +219,18 @@ export default function Playlists() {
|
||||
// The selection as of NOW, for code that runs after an await or on behalf of the open player.
|
||||
const selectedRef = useRef(selectedId);
|
||||
selectedRef.current = selectedId;
|
||||
// Put the rows back to what the CACHED detail says — the server's list as last fetched. A failed
|
||||
// reorder or move must not wait for the resync to do it: the server's list is unchanged, so the
|
||||
// refetch comes back deep-equal, react-query keeps the same `data` object, and the effect below
|
||||
// (keyed on it) never runs. The moved row stayed gone and the rejected order stayed on screen
|
||||
// (deep-review round 4). Only for the list on screen: the selection may have moved meanwhile.
|
||||
function restoreFromCache(listId: number) {
|
||||
if (listId !== selectedRef.current) return;
|
||||
const cur = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
||||
if (!cur) return;
|
||||
lastSetRef.current = `${listId}:${idsKey(cur.items)}`;
|
||||
order.reset(cur.items);
|
||||
}
|
||||
// What the player was LAUNCHED with, snapshotted when it opens: the video, the queue and the index
|
||||
// inside it. `shown` is derived (sort / filter / view), and the player freezes its queue and owns
|
||||
// its own cursor from mount — so deriving the mount from `shown` every render meant anything that
|
||||
@@ -568,6 +579,7 @@ export default function Playlists() {
|
||||
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
|
||||
// so the guard reset the order: the removed row came back and the undo history the rebase had
|
||||
// kept went with it. (A failure resyncs below, which puts the row back where it belongs.)
|
||||
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
||||
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
||||
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
|
||||
);
|
||||
@@ -578,9 +590,13 @@ export default function Playlists() {
|
||||
} catch (e) {
|
||||
// Said here, as moveItemTo does, or a confirmed removal fails in silence (inside the
|
||||
// full-screen player there is not even a row snapping back to show it) — unless `req` already
|
||||
// said it (the error dialog, the connection notice), which `notifyActionError` checks.
|
||||
// put it in its error dialog, which `notifyActionError` checks.
|
||||
notifyActionError(e, t("playlists.removeFailed"));
|
||||
refreshAll(listId); // …and resync, which restores any row the optimistic removal took out
|
||||
// …and the row goes back NOW, not when the resync lands: a removal that failed because the
|
||||
// server was unreachable has a resync that fails too, and the list kept showing the row as
|
||||
// removed while it was still in the playlist.
|
||||
if (before) qc.setQueryData(qk.playlist(listId), before);
|
||||
refreshAll(listId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -611,9 +627,10 @@ export default function Playlists() {
|
||||
// every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already
|
||||
// impossible — the submenu never offers the current list). So the row used to snap back with no
|
||||
// explanation and the shipped moveErrors strings were unreachable (E10 review round 1).
|
||||
// A fault `req` already reported (the error dialog, the connection notice) is not toasted
|
||||
// again — `notifyActionError` checks (E10 review round 2 found it reported twice).
|
||||
// A fault `req` already put in its error dialog is not toasted again — `notifyActionError`
|
||||
// checks (E10 review round 2 found it reported twice).
|
||||
notifyActionError(e, t("playlists.moveFailed"));
|
||||
restoreFromCache(selectedId); // the row is still in this list
|
||||
}
|
||||
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
|
||||
// key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as
|
||||
@@ -819,15 +836,9 @@ export default function Playlists() {
|
||||
if (detailQuery.isError && !detail) {
|
||||
return (
|
||||
<>
|
||||
<div role="alert" className="text-sm p-4 flex flex-col items-start gap-3">
|
||||
<span className="inline-flex items-center gap-2 text-muted">
|
||||
<AlertTriangle className="w-4 h-4 text-danger" />
|
||||
{t("playlists.loadFailed")}
|
||||
</span>
|
||||
<Button variant="outline" size="xs" onClick={() => detailQuery.refetch()}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
<StateMessage tone="error" onRetry={() => detailQuery.refetch()}>
|
||||
{t("playlists.loadFailed")}
|
||||
</StateMessage>
|
||||
{player}
|
||||
</>
|
||||
);
|
||||
@@ -835,7 +846,7 @@ export default function Playlists() {
|
||||
if (!detail) {
|
||||
return (
|
||||
<>
|
||||
<div className="text-muted text-sm p-4">{t("playlists.loading")}</div>
|
||||
<LoadingState label={t("playlists.loading")} />
|
||||
{player}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,8 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { api, HttpError, type PlexCard } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
@@ -401,6 +402,17 @@ export default function PlexPlaylistView({
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
};
|
||||
|
||||
// Every edit here is optimistic on the local copy, and none of them used to handle a failure: a
|
||||
// removal that failed stayed removed on screen (the resync never ran), and a 404 — the playlist
|
||||
// deleted on another device, now a structured `not_found` — was an unhandled rejection that said
|
||||
// nothing at all (deep-review round 4; the YouTube side was fixed in round 3). A failure is said,
|
||||
// and the rows are put back from what the server last sent — explicitly, because a resync that
|
||||
// returns the same list keeps the same `q.data` and the re-sync effect above never runs.
|
||||
const putBack = () => {
|
||||
if (q.data) setItems(q.data.items);
|
||||
invalidate();
|
||||
};
|
||||
|
||||
function commit(next: PlexCard[]) {
|
||||
setItems(next);
|
||||
api
|
||||
@@ -408,7 +420,11 @@ export default function PlexPlaylistView({
|
||||
playlistId,
|
||||
next.map((i) => i.id),
|
||||
)
|
||||
.then(() => qc.invalidateQueries({ queryKey: qk.plexPlaylists() }));
|
||||
.then(() => qc.invalidateQueries({ queryKey: qk.plexPlaylists() }))
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(e, t("plex.playlist.reorderFailed"));
|
||||
putBack();
|
||||
});
|
||||
}
|
||||
|
||||
async function onRemove(keys: string[]) {
|
||||
@@ -420,6 +436,9 @@ export default function PlexPlaylistView({
|
||||
if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]!);
|
||||
else await api.plexPlaylistRemoveBulk(playlistId, keys);
|
||||
invalidate();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.playlist.removeFailed"));
|
||||
putBack();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -428,7 +447,12 @@ export default function PlexPlaylistView({
|
||||
async function rename() {
|
||||
const title = name.trim();
|
||||
if (!title) return;
|
||||
await api.plexRenamePlaylist(playlistId, title);
|
||||
try {
|
||||
await api.plexRenamePlaylist(playlistId, title);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.playlist.renameFailed")); // the field stays open to try again
|
||||
return;
|
||||
}
|
||||
setRenaming(false);
|
||||
invalidate();
|
||||
}
|
||||
@@ -440,7 +464,16 @@ export default function PlexPlaylistView({
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await api.plexDeletePlaylist(playlistId);
|
||||
try {
|
||||
await api.plexDeletePlaylist(playlistId);
|
||||
} catch (e) {
|
||||
// Gone already (deleted on another device): what was asked for is true — leave the dead page.
|
||||
// Anything else is a real failure, and the page stays.
|
||||
if (!(e instanceof HttpError && e.status === 404)) {
|
||||
notifyActionError(e, t("plex.playlist.deleteFailed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
onBack();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { api, type Tag } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
@@ -133,9 +134,16 @@ export default function TagManager({
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
// A failure is said and the list refetched — a tag deleted in another tab used to fail in silence
|
||||
// (a 404 `not_found/tag` gets no global dialog) and stay listed.
|
||||
const onFailed = (e: unknown, fallback: string) => {
|
||||
notifyActionError(e, fallback);
|
||||
invalidate();
|
||||
};
|
||||
const rename = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => onFailed(e, t("tagManager.renameFailed")),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
|
||||
@@ -143,6 +151,7 @@ export default function TagManager({
|
||||
invalidate();
|
||||
notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) });
|
||||
},
|
||||
onError: (e) => onFailed(e, t("tagManager.deleteFailed")),
|
||||
});
|
||||
|
||||
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
|
||||
|
||||
@@ -124,5 +124,6 @@
|
||||
"viewGroupedAnd": "grouped by channel, {{sort}}",
|
||||
"viewDiffers": "Temporary view ({{view}}) — the playlist's saved order is unchanged. Save it to keep this order.",
|
||||
"viewMatches": "This view ({{view}}) already matches the playlist's saved order, so there is nothing to save.",
|
||||
"viewFiltered": "Filtered view — {{shown}} of {{total}} shown. The order can't be saved while a filter is on."
|
||||
"viewFiltered": "Filtered view — {{shown}} of {{total}} shown. The order can't be saved while a filter is on.",
|
||||
"addFailed": "Couldn't add it to the playlist."
|
||||
}
|
||||
|
||||
@@ -320,7 +320,11 @@
|
||||
"seasonUnknown": "Episodes",
|
||||
"addShow": "Add whole show to a playlist",
|
||||
"addSeason": "Add season to a playlist",
|
||||
"addEpisode": "Add episode to a playlist"
|
||||
"addEpisode": "Add episode to a playlist",
|
||||
"removeFailed": "Couldn't remove it from the playlist.",
|
||||
"renameFailed": "Couldn't rename the playlist.",
|
||||
"deleteFailed": "Couldn't delete the playlist.",
|
||||
"reorderFailed": "Couldn't save the new order."
|
||||
},
|
||||
"people": {
|
||||
"count_one": "{{count}} title",
|
||||
|
||||
@@ -8,5 +8,7 @@
|
||||
"confirmDelete": "Delete the tag “{{name}}”? It will be removed from every channel it's on.",
|
||||
"empty": "No tags yet — add one below.",
|
||||
"newPlaceholder": "New tag name…",
|
||||
"add": "Add"
|
||||
"add": "Add",
|
||||
"renameFailed": "Couldn't rename the tag.",
|
||||
"deleteFailed": "Couldn't delete the tag."
|
||||
}
|
||||
|
||||
@@ -124,5 +124,6 @@
|
||||
"viewGroupedAnd": "csatorna szerint csoportosítva, {{sort}}",
|
||||
"viewDiffers": "Ideiglenes nézet ({{view}}) — a lista mentett sorrendje változatlan. Mentsd, ha ez maradjon.",
|
||||
"viewMatches": "Ez a nézet ({{view}}) megegyezik a lista mentett sorrendjével, így nincs mit menteni.",
|
||||
"viewFiltered": "Szűrt nézet — {{total}} elemből {{shown}} látszik. Szűrés közben a sorrend nem menthető."
|
||||
"viewFiltered": "Szűrt nézet — {{total}} elemből {{shown}} látszik. Szűrés közben a sorrend nem menthető.",
|
||||
"addFailed": "Nem sikerült hozzáadni a lejátszási listához."
|
||||
}
|
||||
|
||||
@@ -320,7 +320,11 @@
|
||||
"seasonUnknown": "Epizódok",
|
||||
"addShow": "Egész sorozat listához adása",
|
||||
"addSeason": "Évad listához adása",
|
||||
"addEpisode": "Epizód listához adása"
|
||||
"addEpisode": "Epizód listához adása",
|
||||
"removeFailed": "Nem sikerült eltávolítani a lejátszási listáról.",
|
||||
"renameFailed": "Nem sikerült átnevezni a lejátszási listát.",
|
||||
"deleteFailed": "Nem sikerült törölni a lejátszási listát.",
|
||||
"reorderFailed": "Nem sikerült menteni az új sorrendet."
|
||||
},
|
||||
"people": {
|
||||
"count_one": "{{count}} cím",
|
||||
|
||||
@@ -8,5 +8,7 @@
|
||||
"confirmDelete": "Törlöd a(z) „{{name}}” címkét? Minden csatornáról lekerül, amin rajta van.",
|
||||
"empty": "Még nincs címke — adj hozzá lentebb.",
|
||||
"newPlaceholder": "Új címke neve…",
|
||||
"add": "Hozzáad"
|
||||
"add": "Hozzáad",
|
||||
"renameFailed": "Nem sikerült átnevezni a címkét.",
|
||||
"deleteFailed": "Nem sikerült törölni a címkét."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user