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:
@@ -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)]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user