From 33dd9ff054a6edbddf6ea6782d2f5d50dbede86c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 24 Oct 2026 01:10:29 +0200 Subject: [PATCH] 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. --- backend/app/reader/queries.py | 67 ++++++++--- backend/app/reader/takeout.py | 14 ++- backend/app/routes/channels.py | 6 +- backend/app/routes/feed.py | 16 ++- backend/app/sync/explore.py | 22 ++-- backend/tests/test_catalog_service.py | 25 ++++ backend/tests/test_watched_channels.py | 111 +++++++++++++++++- frontend/src/lib/queryKeys.ts | 4 +- .../channels/WatchedChannels.dom.test.tsx | 15 +++ 9 files changed, 233 insertions(+), 47 deletions(-) diff --git a/backend/app/reader/queries.py b/backend/app/reader/queries.py index fa3fd4fd..e2339369 100644 --- a/backend/app/reader/queries.py +++ b/backend/app/reader/queries.py @@ -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))) - .join(cand, cand.c.channel_id == TakeoutItem.channel_id) - .where(_unfollowed(user), TakeoutItem.looked_up_at.is_(None)) - ) - or 0 - ) + 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)) + .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)] diff --git a/backend/app/reader/takeout.py b/backend/app/reader/takeout.py index 89f9c2f0..39382476 100644 --- a/backend/app/reader/takeout.py +++ b/backend/app/reader/takeout.py @@ -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: diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index fc2a995d..284ea366 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -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") diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 95f9941f..e8fea051 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -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} diff --git a/backend/app/sync/explore.py b/backend/app/sync/explore.py index d3257e24..e6b1b686 100644 --- a/backend/app/sync/explore.py +++ b/backend/app/sync/explore.py @@ -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 { diff --git a/backend/tests/test_catalog_service.py b/backend/tests/test_catalog_service.py index c01d0e9b..bbc2afc0 100644 --- a/backend/tests/test_catalog_service.py +++ b/backend/tests/test_catalog_service.py @@ -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) diff --git a/backend/tests/test_watched_channels.py b/backend/tests/test_watched_channels.py index 1c4cc867..11bfc3cf 100644 --- a/backend/tests/test_watched_channels.py +++ b/backend/tests/test_watched_channels.py @@ -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) - db.add(Subscription(user_id=user.id, channel_id=_ch(1))) + 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 ): diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 3f9b868a..7a138cf6 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -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 --- diff --git a/frontend/src/modules/channels/WatchedChannels.dom.test.tsx b/frontend/src/modules/channels/WatchedChannels.dom.test.tsx index bcaef4a0..5789b065 100644 --- a/frontend/src/modules/channels/WatchedChannels.dom.test.tsx +++ b/frontend/src/modules/channels/WatchedChannels.dom.test.tsx @@ -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,