fix(player): related marks survive, Restart restarts, and refused pages offer nothing to click

- Every state and Watch-later write goes through features/video/videoWrites,
  which marks the never-refetched lists (related lists, live searches) once
  the server has it; a contract test holds it. The Notification Center's
  undo, the feed's reset and the Watch-later toggles skipped it before.
- Confirmed marks are also kept apart (qk.videoMarks) and laid over the
  related rows: a Show more page landing later rebuilt them from the pages
  it read when it started, and dropped a mark made meanwhile.
- Restart on a related video starts at 0 (the session's stored position
  won before), and on the one already playing it seeks.
- The watched toggle remembers each video's mark: Back showed the opened
  video's launch-time state after a related one was marked.
- The help sheet leaves out the player's keys while a layer above it has
  them waiting.
- After a refusal a retry cannot answer, neither Show more (related) nor
  Load more (live search) is offered; a failed live-search page keeps the
  results already read and says why under them.
- The related cap message counts requests, as the cap does.
This commit is contained in:
2026-10-25 02:50:34 +02:00
parent ed51f5be7e
commit 4b5b66343d
26 changed files with 667 additions and 93 deletions
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EyeOff, Play, UserPlus } from "lucide-react";
import { qk } from "../../lib/queryKeys";
import { patchRelated } from "../../lib/progressCache";
import { writeVideoState } from "../../features/video/videoWrites";
import { api, type Video, type VideoStatus, type WatchedChannel } from "../../lib/api";
import { formatDate, formatDuration } from "../../lib/format";
import { notifyActionError } from "../../lib/actionErrors";
@@ -54,8 +54,7 @@ export default function WatchedVideosDialog({
// The player's watched / in-progress marks: saved, then shown on this list and the feed's.
const onState = useStableCallback((id: string, status: VideoStatus) => {
api
.setState(id, status)
writeVideoState(qc, id, status)
.then(() => {
qc.setQueryData(
key,
@@ -66,8 +65,6 @@ export default function WatchedVideosDialog({
},
);
void qc.invalidateQueries({ queryKey: qk.feed() });
// The player's related lists are never refetched: the mark goes into them.
patchRelated(qc, id, { status });
})
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
});
+38 -9
View File
@@ -22,14 +22,26 @@ import {
type LucideIcon,
} from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../../lib/api";
import {
api,
HttpError,
refusalReason,
type FeedFilters,
type Video,
type VideoStatus,
} from "../../lib/api";
import i18n from "../../i18n";
import { notifyActionError } from "../../lib/actionErrors";
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
import {
resetVideoState,
writeVideoState,
writeWatchLater,
} from "../../features/video/videoWrites";
import { notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe";
import { useDebounced } from "../../lib/useDebounced";
import { patchCachedProgress, patchRelated, progressFlushes } from "../../lib/progressCache";
import { patchCachedProgress, progressFlushes } from "../../lib/progressCache";
import {
buildSort,
feedSortOptions,
@@ -124,6 +136,15 @@ const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
const YT_COUNTS = [20, 40, 60, 100] as const;
// The live-search refusals a retry cannot answer: the day's cap, turned off on this instance, the
// shared budget spent (ours, or YouTube's own) — tomorrow or the admin does.
const SEARCH_FINAL_REASONS = new Set([
"search_daily_limit",
"search_disabled",
"quota_spent",
"quota",
]);
export default function Feed({
filters,
setFilters,
@@ -358,7 +379,7 @@ export default function Feed({
(id: string, status: VideoStatus, video?: NoticeVideo) => {
const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
const write = api.setState(id, status);
const write = writeVideoState(qc, id, status);
trackSessionWrite(write);
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
@@ -373,7 +394,6 @@ export default function Feed({
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
patchRelated(qc, id, { status });
// The video the player is on may be none of this list's (a related one): it names itself.
const v = video ?? loadedRef.current.find((x) => x.id === id);
announceVideoState(id, status, v, () => onStateRef.current(id, "new"));
@@ -401,8 +421,7 @@ export default function Feed({
const before = loadedRef.current.find((v) => v.id === id)?.position_seconds;
patchCachedProgress(qc, new Map([[id, 0]]));
resets.current.set(id, seq);
api
.clearState(id)
resetVideoState(qc, id)
// Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => {
answered.current.add(seq);
@@ -423,7 +442,7 @@ export default function Feed({
(id: string, saved: boolean) => {
const seq = ++writeSeq.current;
setSavedOverrides((o) => ({ ...o, [id]: { value: saved, seq } }));
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
writeWatchLater(qc, id, saved)
.then(() => {
answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.playlists() });
@@ -491,6 +510,9 @@ export default function Feed({
: ytQuery.isError
? t("feed.yt.error")
: null;
// A refusal a retry cannot answer (the day's cap, turned off, the shared budget): Load more —
// the same request — is not offered after it.
const ytFinal = SEARCH_FINAL_REASONS.has(refusalReason(ytQuery.error) ?? "");
// The backend reports which source served the search; scrape spends no search quota, so we
// drop the quota warning + "uses quota" wording in that mode.
const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape";
@@ -577,7 +599,7 @@ export default function Feed({
{ytQuery.isLoading ? (
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
) : ytError ? (
) : ytError && !ytItems.length ? (
<div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div>
) : ytItems.length === 0 ? (
<div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
@@ -594,7 +616,7 @@ export default function Feed({
isFetching={false}
fetchNextPage={() => {}}
/>
{ytQuery.hasNextPage && (
{ytQuery.hasNextPage && !ytFinal && (
<div className="text-center pt-4">
<Button
variant="outline"
@@ -611,6 +633,13 @@ export default function Feed({
</Button>
</div>
)}
{/* A page that failed after others loaded: they stay, and the failure is said under
them (Load more above is the retry, when there is one). */}
{ytError && (
<StateMessage tone="error" compact className="mt-3">
{ytError}
</StateMessage>
)}
</>
)}
@@ -0,0 +1,122 @@
// @vitest-environment jsdom
//
// A live YouTube search's Load more that fails. The results already read stay on screen and the
// failure is said under them; before, the whole pane turned into the error text. Load more is the
// retry where trying again can help, and is not offered after a refusal it cannot answer (the day's
// cap, the feature turned off, the shared budget).
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("../../features/wizard/wizardContext", () => ({
useWizard: () => ({ openWizard: vi.fn() }),
}));
vi.mock("./VirtualFeed", () => ({
default: ({ items }: { items: { id: string; title: string }[] }) => (
<ol aria-label="results">
{items.map((v) => (
<li key={v.id}>{v.title}</li>
))}
</ol>
),
}));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const hit = (id: string): Video =>
({
id,
title: `hit ${id}`,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds: 0,
published_at: "2026-01-01T00:00:00Z",
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
function setup(failure: HttpError) {
vi.spyOn(api, "searchYoutube")
.mockResolvedValueOnce({ items: [hit("a")], next_cursor: "c2", source: "scrape" } as never)
.mockRejectedValueOnce(failure);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
render(
<QueryClientProvider client={qc}>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch="lemmino"
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
}
const loadMore = () => screen.queryByRole("button", { name: "feed.yt.loadMoreFree" });
describe("Feed — a live search's Load more that fails", () => {
it("keeps the results read, says why under them, and offers Load more again", async () => {
setup(
new HttpError(422, "YouTube isn't reachable", { code: "youtube", reason: "unavailable" }),
);
fireEvent.click(await screen.findByRole("button", { name: "feed.yt.loadMoreFree" }));
expect(await screen.findByText("YouTube isn't reachable")).toBeTruthy();
expect(screen.getByRole("list", { name: "results" }).textContent).toContain("hit a");
expect(loadMore()).toBeTruthy();
});
it("offers no Load more after a refusal a retry cannot answer", async () => {
setup(
new HttpError(429, "Today's limit", {
code: "refused",
reason: "search_daily_limit",
limit: 2,
}),
);
fireEvent.click(await screen.findByRole("button", { name: "feed.yt.loadMoreFree" }));
expect(await screen.findByText("Today's limit")).toBeTruthy();
expect(screen.getByRole("list", { name: "results" }).textContent).toContain("hit a");
await waitFor(() => expect(loadMore()).toBeNull());
});
});
+3 -6
View File
@@ -14,7 +14,6 @@ import {
Star,
} from "lucide-react";
import { qk } from "../../lib/queryKeys";
import { patchRelated } from "../../lib/progressCache";
import { plexLang } from "../../i18n";
import {
api,
@@ -54,6 +53,7 @@ import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import VideoCard, { VideoHoverActions } from "../../features/video/VideoCard";
import { useVideoActions } from "../../features/video/useVideoActions";
import { writeVideoState, writeWatchLater } from "../../features/video/videoWrites";
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
import { PlexHoverBar, PlexPosterCard } from "../../features/plexCards/plexCards";
import { usePlaylistsActions } from "../playlists";
@@ -837,11 +837,8 @@ export default function Home() {
// A full refetch, unlike the feed's stale-mark: these rails are two dozen rows, and marking
// something watched genuinely changes what the page should answer (it can retire the hero).
// The feed avoids this because it would refetch ten infinite-query pages per click.
void api
.setState(id, status)
void writeVideoState(qc, id, status)
.then(() => {
// The player's related lists are never refetched: the mark goes into them.
patchRelated(qc, id, { status });
return qc.invalidateQueries({ queryKey: qk.discover() });
})
// Without this the rejection escapes unhandled and the click just does nothing — the
@@ -858,7 +855,7 @@ export default function Home() {
const onToggleSave = useCallback(
(id: string, saved: boolean) => {
void (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
void writeWatchLater(qc, id, saved)
.then(() => {
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
@@ -48,6 +48,7 @@ import { PageToolbar } from "../../components/ui/PageShell";
import { PageMenu } from "../../components/ui/PageMenu";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { useNavigationActions } from "../../features/navigation/navigationContext";
import { writeVideoState } from "../../features/video/videoWrites";
import { useFilesActions } from "../files";
import { useChannelsActions } from "../channels";
import { useFeedFilters, useFeedFiltersActions } from "../feed";
@@ -170,8 +171,7 @@ export default function NotificationsPanel() {
setPage("feed");
}
function revertState(meta: VideoHiddenMeta | VideoWatchedMeta) {
api
.setState(meta.videoId, "new")
writeVideoState(qc, meta.videoId, "new")
.then(() => {
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedSummary() });
+2 -5
View File
@@ -3,7 +3,7 @@ import { useLatest, useStableCallback } from "../../lib/useLatest";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { patchRelated } from "../../lib/progressCache";
import { writeVideoState } from "../../features/video/videoWrites";
import {
DndContext,
KeyboardSensor,
@@ -611,16 +611,13 @@ function PlaylistDetail() {
}
function playerState(id: string, status: VideoStatus) {
api
.setState(id, status)
writeVideoState(qc, id, status)
.then(() => {
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
// list that is no longer the selected one (see `playing`), and that list holds the row whose
// status just changed. Only lists actually on screen refetch.
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.feed() });
// The player's related lists are never refetched: the mark goes into them.
patchRelated(qc, id, { status });
})
// No catch used to mean an unhandled rejection and nothing said.
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));