fix(reader): the watched-channels list keeps its channels, its shares and its retries

- A channel someone follows: the videos its exploration brings in (the list's
  dialog, the channel page's explore) come in as the catalog's own, not
  flagged via_explore, which subscribing would never clear and which kept
  their watched marks waiting for good.
- The explore cleanup keeps the row of a channel a Takeout history shows
  watched (it still reclaims the videos), so a looked-at channel no longer
  drops off the list.
- The Shorts job takes up only users whose list still has lengths to ask or
  channels to name, by each user's own follows, blocks and dismissals; a
  naming that failed is tried again, and the list says it is not complete.
- The length lookups have a daily share of their own: a large history no
  longer leaves the export's Watch later file waiting a day.
- Only a channel of the user's own history can be put out of the list.
- The dialog's videos have a query root of their own: the list's
  invalidations no longer re-ask (and re-explore) under an open dialog.
This commit is contained in:
2026-10-24 01:10:29 +02:00
parent b5f9cf14db
commit 33dd9ff054
9 changed files with 233 additions and 47 deletions
+3 -1
View File
@@ -38,7 +38,9 @@ export const qk = {
channel: (id: string) => ["channel", id] as const,
discoveredChannels: () => ["discovered-channels"] as const,
watchedChannels: () => ["watched-channels"] as const,
watchedChannelVideos: (id: string) => ["watched-channels", "videos", id] as const,
// A root of its own, not under the list's: asking for them explores the channel (a write), so the
// list's invalidations must not refetch them under an open dialog.
watchedChannelVideos: (id: string) => ["watched-channel-videos", id] as const,
blockedChannels: () => ["blocked-channels"] as const,
// --- playlists ---
@@ -47,6 +47,7 @@ vi.mock("../../features/player/PlayerModal", () => ({
}));
import WatchedChannels from "./WatchedChannels";
import { qk } from "../../lib/queryKeys";
import { api, type Video, type WatchedChannel, type WatchedChannels as List } from "../../lib/api";
afterEach(() => {
@@ -160,6 +161,20 @@ describe("WatchedChannels", () => {
await waitFor(() => expect(h.player).toHaveBeenLastCalledWith("a", ["a", "b", "c"], 0));
});
it("asks for a channel's videos once per opening, whatever the list does meanwhile", async () => {
// The ask explores the channel and may spend quota: the list's own refetch (an Undo, a follow)
// must not repeat it under an open dialog, nor swap the queue of a video playing.
const videos = vi
.spyOn(api, "watchedChannelVideos")
.mockResolvedValue({ ok: true, items: [video("a")], gone: 0 });
const qc = mount({ imported: true, pending: 0, channels: [channel(1)] });
fireEvent.click(await screen.findByRole("button", { name: "29" }));
await within(await screen.findByRole("dialog")).findByText("Video a");
await act(() => qc.invalidateQueries({ queryKey: qk.watchedChannels() }));
await waitFor(() => expect(api.watchedChannels).toHaveBeenCalledTimes(2));
expect(videos).toHaveBeenCalledTimes(1);
});
it("follows a channel from its videos after the confirm, and closes", async () => {
vi.spyOn(api, "watchedChannelVideos").mockResolvedValue({
ok: true,