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
+46 -17
View File
@@ -244,6 +244,18 @@ def kept_by_a_reader(video_id: InstrumentedAttribute[str]) -> ColumnElement[bool
)
def in_a_watch_history(channel_id: InstrumentedAttribute[str]) -> ColumnElement[bool]:
"""A channel someone's uploaded Takeout history shows watched. The "watched but not followed"
list names a channel from its catalog row, so the explore cleanup keeps the row of one (its
videos it may still reclaim: opening the list's videos brings them back). As a clause, so the
cleanup's DELETE stays one statement."""
return (
select(TakeoutItem.id)
.where(TakeoutItem.channel_id == channel_id, TakeoutItem.kind == TakeoutKind.WATCHED)
.exists()
)
def playlisted_by(db: Session, video_ids: list[str]) -> list[tuple[int, str]]:
"""(user id, video id) for every user who has one of these videos in a playlist."""
return list(
@@ -592,17 +604,16 @@ def takeout_lengths_to_ask(db: Session, user: User) -> list[str]:
def watched_channels_pending(db: Session, user: User) -> int:
"""How many candidate channels still have a watched video whose length is not known: the
channels that may yet join the list."""
"""How many channels a later run may still add to the list: the candidates with a watched video
whose length is not known, and the ones that made it but the catalog cannot name yet."""
cand = _candidate_channels(user)
return (
db.scalar(
select(func.count(func.distinct(TakeoutItem.channel_id)))
unasked = db.execute(
select(TakeoutItem.channel_id)
.join(cand, cand.c.channel_id == TakeoutItem.channel_id)
.where(_unfollowed(user), TakeoutItem.looked_up_at.is_(None))
)
or 0
)
.distinct()
).scalars()
return len(set(unasked) | set(watched_channels_to_describe(db, user)))
def watched_channels_to_describe(db: Session, user: User) -> list[str]:
@@ -655,6 +666,23 @@ def has_watch_history(db: Session, user: User) -> bool:
)
def watched_in_history(db: Session, user: User, channel_id: str) -> bool:
"""Whether the user's Takeout history shows them watching this channel at all."""
return bool(
db.scalar(
select(
select(TakeoutItem.id)
.where(
owned_by(TakeoutItem.user_id, user),
TakeoutItem.kind == TakeoutKind.WATCHED,
TakeoutItem.channel_id == channel_id,
)
.exists()
)
)
)
def watched_channel_videos(
db: Session, user: User, channel_id: str
) -> tuple[list[tuple[str, datetime]], int]:
@@ -680,18 +708,19 @@ def watched_channel_videos(
return counted, gone
def takeout_users_with_lengths_to_ask(db: Session) -> list[int]:
"""Every user with a channel of enough watched videos not all asked about yet. It does not ask
whether they follow the channel: that is each user's own question, which
`takeout_lengths_to_ask` answers (and an empty answer costs a run nothing)."""
def takeout_users_with_list_work(db: Session) -> list[int]:
"""Every user whose "watched but not followed" list a run can still add to: lengths to ask, or
channels to name. Asked of each user whose history holds a channel of enough watched videos,
since what is off a list (followed, blocked, put out, their own) is that user's own — a row of
one is never asked about, and would otherwise bring the user back on every run."""
per_channel = (
select(TakeoutItem.user_id)
.where(TakeoutItem.kind == TakeoutKind.WATCHED, TakeoutItem.channel_id.is_not(None))
.group_by(TakeoutItem.user_id, TakeoutItem.channel_id)
.having(
func.count() >= WATCHED_CHANNEL_MIN_VIDEOS,
func.count().filter(TakeoutItem.looked_up_at.is_(None)) > 0,
)
.having(func.count() >= WATCHED_CHANNEL_MIN_VIDEOS)
.subquery()
)
return list(db.execute(select(per_channel.c.user_id).distinct()).scalars())
users = db.execute(
select(User).where(User.id.in_(select(per_channel.c.user_id))).order_by(User.id)
).scalars()
return [user.id for user in users if watched_channels_pending(db, user)]
+9 -5
View File
@@ -322,6 +322,10 @@ def stage(db: Session, user: User, parsed: Parsed) -> None:
# How many unknown Watch later videos one user may have looked up on the shared API budget in a day:
# two full Watch later lists (two units a hundred ids). The rest wait for the next day's runs.
_lookups = RateLimiter(max_events=2 * WATCH_LATER_MAX, window_seconds=24 * 3600)
# How many watched videos' lengths one user may ask about in a day for the "watched but not followed"
# list (a unit a fifty). A share of its own: a large history asks for thousands, and on the Watch
# later share it would leave the export's Watch later file waiting a day behind it.
_lengths = RateLimiter(max_events=2 * WATCH_LATER_MAX, window_seconds=24 * 3600)
def _put_in_watch_later(db: Session, user: User) -> dict:
@@ -356,8 +360,8 @@ def _put_in_watch_later(db: Session, user: User) -> dict:
def _ask_lengths(db: Session, user: User) -> dict:
"""Ask YouTube how long the user's watched videos are that the "watched but not followed" list
still needs, and keep the answer — on the same terms and the same daily share as the Watch
later lookups. What is not asked yet (no YouTube-connected account, the reserve reached, the
still needs, and keep the answer — on the same terms as the Watch later lookups, on a daily
share of its own. What is not asked yet (no YouTube-connected account, the reserve reached, the
share spent) waits for a later run, and the list says it is not complete. A YouTube failure is
logged and left for a later run too: it is no reason to fail the import it follows. The caller
commits."""
@@ -365,7 +369,7 @@ def _ask_lengths(db: Session, user: User) -> dict:
fetcher = get_service_user(db) if ask else None
if fetcher is None or quota.backfill_reserve_reached():
return {}
ask = ask[: int(_lookups.room(str(user.id)))]
ask = ask[: int(_lengths.room(str(user.id)))]
if not ask:
return {}
try:
@@ -379,7 +383,7 @@ def _ask_lengths(db: Session, user: User) -> dict:
extra=logs.event("takeout.lengths.failed", user_id=user.id, reason=exc.reason),
)
return {}
_lookups.allow(str(user.id), cost=len(ask))
_lengths.allow(str(user.id), cost=len(ask))
lengths = {
it["id"]: parse_iso8601_duration(it.get("contentDetails", {}).get("duration"))
for it in items
@@ -453,7 +457,7 @@ def apply_waiting(db: Session) -> dict:
marked = added = asked = failed = 0
ready = set(queries.takeout_users_ready(db))
later = set(queries.takeout_users_watch_later_waiting(db))
lengths = set(queries.takeout_users_with_lengths_to_ask(db))
lengths = set(queries.takeout_users_with_list_work(db))
for user_id in sorted(ready | later | lengths):
user = db.get(User, user_id)
if user is None:
+3 -3
View File
@@ -307,9 +307,9 @@ def explore_channel(
"""Open an un-subscribed channel for browsing. Records the exploration (a per-user,
grace-clocked row that gates this user's access to the channel's ephemeral videos and
drives the later cleanup), flags the channel ephemeral unless someone organically follows
it, then ingests one page of its uploads (newest-first) marked via_explore + enriched.
Returns the next page token so the page can "load more". Guarded against the shared
backfill quota reserve."""
it, then ingests one page of its uploads (newest-first), enriched — marked via_explore while
nobody follows it. Returns the next page token so the page can "load more". Guarded against
the shared backfill quota reserve."""
channel = _channel_or_404(db, channel_id)
if queries.is_blocked(db, user, channel_id):
raise refused(403, "channel_blocked")
+10 -6
View File
@@ -427,9 +427,9 @@ def takeout_status(user: User = Depends(current_user), db: Session = Depends(get
def watched_channels(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""The channels the user's Takeout history shows them watching often but not following: each
with how many of its videos over five minutes they watched, and when last. `pending` is how many
more channels may join once the import has asked YouTube for their videos' lengths, so a list
still being made does not read as the whole of it; `imported` tells "no history uploaded" from
"nothing to suggest"."""
more channels may join once the import has asked YouTube for their videos' lengths or their
names, so a list still being made does not read as the whole of it; `imported` tells "no history
uploaded" from "nothing to suggest"."""
return {
"imported": queries.has_watch_history(db, user),
"pending": queries.watched_channels_pending(db, user),
@@ -457,7 +457,8 @@ def watched_channel_videos(
"""The videos of a channel that the user's list counts, ready to play, last watched first. The
channel is explored for the user (as its page does), and the videos the catalog lacks come in as
the exploration's: the player needs them in the catalog, the shared Library does not get them,
and the cleanup reclaims them once nobody keeps them. `gone`: how many YouTube no longer has."""
and the cleanup reclaims them once nobody keeps them. Of a channel someone follows they come in
as the catalog's own, as its uploads do. `gone`: how many YouTube no longer has."""
channel = catalog_queries.channel(db, channel_id)
if channel is None:
raise not_found("channel")
@@ -477,7 +478,7 @@ def watched_channel_videos(
quota.attribute(user.id, quota.QuotaAction.CHANNELS_EXPLORE),
YouTubeClient(db, user) as yt,
):
ensure_videos(db, yt, missing, explore=True)
ensure_videos(db, yt, missing, explore=channel.from_explore)
except YouTubeError as exc:
raise youtube_refusal(exc) from exc
items = queries.feed_rows_by_ids(db, user, ids)
@@ -494,7 +495,10 @@ def dismiss_watched_channel(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Put a channel out of the user's list (it stays out until restored)."""
"""Put a channel out of the user's list (it stays out until restored). Only one their own history
shows them watching: a made-up id would only pile up rows every list read then has to skip."""
if not queries.watched_in_history(db, user, channel_id):
raise not_found("channel")
service.dismiss_watched_channel(db, user, channel_id)
db.commit()
return {"ok": True, "channel_id": channel_id}
+14 -8
View File
@@ -66,11 +66,13 @@ def _enrich_new(db: Session, yt: YouTubeClient, video_ids: list[str]) -> int:
def explore_ingest_page(
db: Session, yt: YouTubeClient, channel: Channel, page_token: str | None = None
) -> dict:
"""Ingest one page (up to 50, newest-first) of the channel's uploads, marking the new
videos via_explore, then enrich them. Returns the count ingested and the next page token
(None when the back-catalogue is exhausted) so the caller can offer "load more". Stateless
pagination: the caller round-trips the token, so the channel's own backfill cursor (used by
the organic deep backfill) is never touched."""
"""Ingest one page (up to 50, newest-first) of the channel's uploads, marking the new videos
via_explore while the channel is an exploration's (`from_explore`): of a channel someone follows
they are its uploads like any other, and a flag there would never be cleared, since subscribing
clears only an explored channel's. Then enrich them. Returns the count ingested and the next
page token (None when the back-catalogue is exhausted) so the caller can offer "load more".
Stateless pagination: the caller round-trips the token, so the channel's own backfill cursor
(used by the organic deep backfill) is never touched."""
if not channel.uploads_playlist_id:
return {"ingested": 0, "next_page_token": None}
try:
@@ -86,7 +88,7 @@ def explore_ingest_page(
for item in data.get("items", []):
stub = stub_from_playlist_item(item, channel.id)
if stub is not None:
stub["via_explore"] = True
stub["via_explore"] = channel.from_explore
stubs.append(stub)
inserted = insert_stubs(db, stubs)
self_enriched = _enrich_new(db, yt, [s["id"] for s in stubs])
@@ -105,7 +107,9 @@ def purge_unkept_explored(db: Session) -> dict:
- a live ExploredChannel row (someone explored it within the grace window — channel-level), or
- a video has per-user state: watched/in-progress (VideoState), in a playlist (PlaylistItem),
or surfaced by the user's own search (SearchFind) — these survive even if the channel goes.
A channel row is dropped only once it holds no videos at all. 0 quota."""
A channel row is dropped only once it holds no videos at all, and not while someone's Takeout
history shows it watched (their "watched but not followed" list names it from that row).
0 quota."""
grace_days = sysconfig.get_int(db, "explore_grace_days")
cutoff = datetime.now(timezone.utc) - timedelta(days=grace_days)
@@ -135,7 +139,9 @@ def purge_unkept_explored(db: Session) -> dict:
has_videos = select(Video.id).where(Video.channel_id == Channel.id).exists()
channels_deleted = db.execute(
delete(Channel).where(Channel.id.in_(orphan_ids), ~has_videos)
delete(Channel).where(
Channel.id.in_(orphan_ids), ~has_videos, ~reader_queries.in_a_watch_history(Channel.id)
)
).rowcount
db.commit()
return {
+25
View File
@@ -22,6 +22,7 @@ from app.catalog.models import (
)
from app.reader import queries as reader_queries
from app.reader import service as reader_service
from app.sync import explore as sync_explore
def _channel(db, cid="UCc", **kw):
@@ -141,6 +142,30 @@ def test_subscribing_keeps_an_explored_channel_and_its_videos(db, make_user):
assert queries.subscription(db, user, "UCc") is None
def test_exploring_a_channel_someone_follows_brings_its_uploads_in_as_the_catalogs_own(
db, make_user, monkeypatch
):
follower, explorer = make_user(), make_user()
ch = _channel(db, uploads_playlist_id="UUc")
db.add(Subscription(user_id=follower.id, channel_id="UCc"))
db.commit()
service.explore(db, explorer, ch)
db.commit()
class _Yt:
def get_playlist_items(self, playlist_id, page_token):
item = {"contentDetails": {"videoId": "v1", "videoPublishedAt": "2026-05-01T00:00:00Z"}}
return {"items": [item]}
monkeypatch.setattr(sync_explore, "_enrich_new", lambda session, yt, ids: 0)
sync_explore.explore_ingest_page(db, _Yt(), ch)
db.commit()
db.expire_all()
# The channel is followed, so not the exploration's: a video flagged as one would stay flagged
# (subscribing clears only an explored channel's) and its watched marks would wait for good.
assert (ch.from_explore, db.get(Video, "v1").via_explore) == (False, False)
def test_a_block_is_once_and_ends_the_exploration(db, make_user):
user = make_user()
ch = _channel(db)
+105 -4
View File
@@ -5,13 +5,15 @@ import asks YouTube for it and when, and the channel's watched videos made ready
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from sqlalchemy import select, update
from app import quota
from app.catalog import service as catalog_service
from app.catalog.models import BlockedChannel, Channel, ExploredChannel, Subscription, Video
from app.reader import queries, service, takeout
from app.reader.models import TakeoutChannelDismissal, TakeoutItem, TakeoutKind
from app.routes import feed as feed_routes
from app.sync import explore as sync_explore
from app.sync import videos as sync_videos
from app.youtube.client import YouTubeError
@@ -147,6 +149,7 @@ class FakeYouTube:
def __init__(self, lengths: dict[str, int] | None = None, *, fail: bool = False):
self.lengths = lengths or {}
self.fail = fail
self.fail_channels = False
self.videos_asked: list[list[str]] = []
self.channels_asked: list[list[str]] = []
self.actions: list[tuple[int | None, str]] = []
@@ -174,6 +177,8 @@ class FakeYouTube:
def get_channels(self, ids):
self.channels_asked.append(list(ids))
self.actions.append((quota._actor_id.get(), quota._action.get()))
if self.fail_channels:
raise YouTubeError("refused", reason="backendError", path="channels")
return [{"id": c, "snippet": {"title": f"Named {c[-2:]}"}} for c in ids]
@@ -222,7 +227,7 @@ def test_the_lookups_stop_at_the_users_daily_share_and_the_rest_wait(
user = make_user("share@example.com")
_watched(db, user, 1, [None] * 8, asked=False)
_watched(db, user, 2, [None] * 6, asked=False)
monkeypatch.setattr(takeout, "_lookups", takeout.RateLimiter(10, 3600))
monkeypatch.setattr(takeout, "_lengths", takeout.RateLimiter(10, 3600))
takeout.run_import(user.id)
assert youtube.videos_asked == [[_vid(1, i) for i in range(8)] + [_vid(2, 0), _vid(2, 1)]]
db.expire_all()
@@ -270,16 +275,64 @@ def test_the_shorts_job_asks_what_an_import_could_not(db, make_user, youtube):
assert [n for _, n, _ in queries.watched_channels(db, user)] == [5]
def test_a_channel_the_user_follows_is_not_asked_about(db, make_user, youtube):
user = make_user("follows@example.com")
@pytest.mark.parametrize("why", ["follows", "blocked", "dismissed", "own"])
def test_a_channel_off_the_list_is_not_asked_about_nor_looked_at_again(
db, make_user, youtube, monkeypatch, why
):
user = make_user(f"off-{why}@example.com", yt_channel_id=_ch(1) if why == "own" else None)
_channel(db, 1)
_watched(db, user, 1, [None] * 5, asked=False)
if why == "follows":
db.add(Subscription(user_id=user.id, channel_id=_ch(1)))
elif why == "blocked":
db.add(BlockedChannel(user_id=user.id, channel_id=_ch(1)))
elif why == "dismissed":
service.dismiss_watched_channel(db, user, _ch(1))
db.commit()
runs = []
monkeypatch.setattr(takeout, "_channel_list", lambda session, u: runs.append(u.id) or {})
assert takeout.apply_waiting(db) == {}
# Its rows are never asked about, so they would stay "to ask" for good: the Shorts job must not
# take the user up again for them on every run.
assert runs == []
assert youtube.videos_asked == []
def test_naming_the_channels_is_tried_again_and_meanwhile_the_list_says_it_is_not_complete(
db, make_user, youtube
):
user = make_user("unnamed@example.com")
_watched(db, user, 1, [None] * 5, asked=False)
youtube.lengths = {_vid(1, i): LONG for i in range(5)}
youtube.fail_channels = True
takeout.run_import(user.id)
db.expire_all()
# Every length is known, the channel could not be named: not on the list, and not complete.
assert queries.takeout_lengths_to_ask(db, user) == []
assert _listed(db, user) == []
assert queries.watched_channels_pending(db, user) == 1
youtube.fail_channels = False
takeout.apply_waiting(db)
db.expire_all()
assert [(ch.title, n) for ch, n, _ in queries.watched_channels(db, user)] == [("Named 01", 5)]
assert queries.watched_channels_pending(db, user) == 0
def test_the_lengths_leave_the_watch_later_lookups_their_own_share(
db, make_user, youtube, monkeypatch
):
user = make_user("shares@example.com")
monkeypatch.setattr(takeout, "_lookups", takeout.RateLimiter(10, 3600))
monkeypatch.setattr(takeout, "_lengths", takeout.RateLimiter(10, 3600))
_watched(db, user, 1, [None] * 15, asked=False)
takeout.run_import(user.id) # the history: its lengths spend all they may today
youtube.videos_asked.clear()
service.stage_takeout(db, user, TakeoutKind.WATCH_LATER, {"later000001": (T0, None)})
db.commit()
takeout.run_import(user.id) # then the export's Watch later file
assert ["later000001"] in youtube.videos_asked
# --- the channel's watched videos, ready to play ------------------------------------------------
@@ -328,6 +381,43 @@ def test_a_channels_watched_videos_come_ready_to_play_and_explored(
) # nobody follows it: the cleanup's to reclaim
def test_a_followed_channels_watched_videos_come_in_as_the_catalogs_own(
db, make_user, as_user, monkeypatch
):
user, follower = make_user("viewer@example.com"), make_user("follower@example.com")
_channel(db, 1)
_watched(db, user, 1, [LONG] * 5)
db.add(Subscription(user_id=follower.id, channel_id=_ch(1)))
db.commit()
calls = []
monkeypatch.setattr(feed_routes, "ensure_videos", _fake_ensure(calls))
monkeypatch.setattr(feed_routes.quota, "backfill_reserve_reached", lambda: False)
assert as_user(user).post(f"/api/takeout/channels/{_ch(1)}/videos").status_code == 200
# Someone follows it, so it is no exploration's to reclaim: a video flagged as one would never
# be cleared again (subscribing clears only an explored channel's) and never marked watched.
assert calls == [([_vid(1, i) for i in range(5)], False)]
db.expire_all()
assert db.get(Channel, _ch(1)).from_explore is False
def test_the_cleanup_keeps_a_channel_a_watch_history_lists_and_reclaims_its_videos(db, make_user):
user = make_user("kept@example.com")
_channel(db, 1)
_watched(db, user, 1, [LONG] * 5)
catalog_service.explore(db, user, db.get(Channel, _ch(1)))
db.add(Video(id=_vid(1, 0), channel_id=_ch(1), published_at=T0, via_explore=True))
db.add(Channel(id=_ch(2), from_explore=True)) # explored, in nobody's history
db.commit()
db.execute(update(ExploredChannel).values(created_at=T0 - timedelta(days=365)))
db.commit()
out = sync_explore.purge_unkept_explored(db)
# The unplayed video goes (opening the list's videos brings it back); the channel stays listed.
assert (out["videos_deleted"], out["channels_deleted"]) == (1, 1)
db.expire_all()
assert db.get(Channel, _ch(2)) is None
assert _listed(db, user) == [(_ch(1), 5)]
def test_a_blocked_or_unknown_channels_videos_are_refused(db, make_user, as_user):
user = make_user("refused@example.com")
_channel(db, 1)
@@ -392,6 +482,17 @@ def test_a_channel_put_out_of_the_list_comes_back_when_restored(db, make_user, a
assert db.execute(select(TakeoutChannelDismissal)).scalars().all() == []
def test_only_a_channel_of_the_users_own_history_can_be_put_out_of_the_list(db, make_user, as_user):
user, other = make_user("mine@example.com"), make_user("theirs@example.com")
_channel(db, 1)
_watched(db, other, 1, [LONG] * 5)
client = as_user(user)
assert client.put(f"/api/takeout/channels/{_ch(1)}/dismissal").status_code == 404
assert client.put(f"/api/takeout/channels/{_ch(9)}/dismissal").status_code == 404
db.expire_all()
assert db.execute(select(TakeoutChannelDismissal)).scalars().all() == []
def test_a_dismissal_of_an_overlong_channel_id_is_refused_before_the_database(
db, make_user, as_user
):
+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,