fix(catalog): a follow from YouTube keeps an explored channel, and the list counts only what can come
A subscription pulled in from YouTube left an explored channel flagged as an exploration's, so its later videos came in hidden from the Library with their watched marks waiting for good; a subscribe landing while an exploration's videos came in left those flagged too. One step, settle_explored, now holds the rule by who follows the channel now: the subscribe here, the subscription import, the watched-channel videos and the explore page all end with it. Migration 0116 clears what the gap left behind. The list's "may still grow" count no longer counts a channel it already shows from a stub row, and the comments on the cleanup's keep rule and on the in-memory daily shares say what they do.
This commit is contained in:
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.catalog import queries, search, service
|
||||
from app.db import SessionLocal
|
||||
from app.catalog.models import (
|
||||
BlockedChannel,
|
||||
Channel,
|
||||
@@ -23,6 +24,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
|
||||
from app.sync import subscriptions as sync_subscriptions
|
||||
|
||||
|
||||
def _channel(db, cid="UCc", **kw):
|
||||
@@ -166,6 +168,65 @@ def test_exploring_a_channel_someone_follows_brings_its_uploads_in_as_the_catalo
|
||||
assert (ch.from_explore, db.get(Video, "v1").via_explore) == (False, False)
|
||||
|
||||
|
||||
def test_a_follow_pulled_in_from_youtube_makes_an_explored_channel_the_catalogs_own(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
explorer, follower = make_user(), make_user()
|
||||
ch = _channel(db, details_synced_at=datetime.now(timezone.utc))
|
||||
service.explore(db, explorer, ch)
|
||||
db.commit()
|
||||
_video(db, "v1", via_explore=True)
|
||||
|
||||
class _Yt:
|
||||
def __init__(self, session, user):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def iter_subscriptions(self):
|
||||
yield {"channel_id": "UCc", "title": "UCc", "yt_subscription_id": "sub-UCc"}
|
||||
|
||||
def get_channels(self, ids):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(sync_subscriptions, "YouTubeClient", _Yt)
|
||||
sync_subscriptions.import_subscriptions(db, follower)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
# Followed on youtube.com is followed all the same: left flagged, the channel's later videos
|
||||
# came in as an exploration's, hidden from the Library and their watched marks waiting for good.
|
||||
assert (db.get(Channel, "UCc").from_explore, db.get(Video, "v1").via_explore) == (False, False)
|
||||
|
||||
|
||||
def test_a_follow_landing_while_an_explored_page_comes_in_leaves_none_of_it_flagged(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
explorer, follower = make_user(), make_user()
|
||||
ch = _channel(db, uploads_playlist_id="UUc")
|
||||
service.explore(db, explorer, ch)
|
||||
db.commit()
|
||||
|
||||
class _Yt:
|
||||
def get_playlist_items(self, playlist_id, page_token):
|
||||
# Someone subscribes, in a request of their own, while YouTube answers this one.
|
||||
with SessionLocal() as other:
|
||||
service.record_subscription(other, follower, other.get(Channel, "UCc"), "sub-UCc")
|
||||
other.commit()
|
||||
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 subscribe cleared what was flagged before it; a page that lands after it must not be.
|
||||
assert (db.get(Channel, "UCc").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)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Migration 0116's data step, DB-lane: an explored channel someone already follows becomes the
|
||||
catalog's own, and no video stays flagged as an exploration's on a channel that is not one.
|
||||
|
||||
The schema half is checked by test_migrations.py; a flag left on is invisible there.
|
||||
"""
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
|
||||
|
||||
def test_followed_explored_channels_and_their_videos_are_the_catalogs_own(db, make_user):
|
||||
follower = make_user().id
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0115_takeout_channels")
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
|
||||
def channel(cid, explored):
|
||||
conn.execute(
|
||||
text("INSERT INTO channels (id, from_explore) VALUES (:c, :e)"),
|
||||
{"c": cid, "e": explored},
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO videos (id, channel_id, published_at, via_explore)"
|
||||
" VALUES (:v, :c, now(), true)"
|
||||
),
|
||||
{"v": f"v-{cid}", "c": cid},
|
||||
)
|
||||
|
||||
channel("UCfollowed", True) # explored, then followed on youtube.com
|
||||
channel("UCexplored", True) # explored, nobody follows it: stays the exploration's
|
||||
channel("UCorganic", False) # a video flagged after a subscribe had cleared the channel
|
||||
conn.execute(
|
||||
text("INSERT INTO subscriptions (user_id, channel_id) VALUES (:u, 'UCfollowed')"),
|
||||
{"u": follower},
|
||||
)
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
with engine.connect() as conn:
|
||||
channels = dict(conn.execute(text("SELECT id, from_explore FROM channels")).tuples().all())
|
||||
videos = dict(
|
||||
conn.execute(text("SELECT channel_id, via_explore FROM videos")).tuples().all()
|
||||
)
|
||||
assert channels == {"UCfollowed": False, "UCexplored": True, "UCorganic": False}
|
||||
assert videos == {"UCfollowed": False, "UCexplored": True, "UCorganic": False}
|
||||
@@ -10,6 +10,7 @@ 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.db import SessionLocal
|
||||
from app.reader import queries, service, takeout
|
||||
from app.reader.models import TakeoutChannelDismissal, TakeoutItem, TakeoutKind
|
||||
from app.routes import feed as feed_routes
|
||||
@@ -318,6 +319,18 @@ def test_naming_the_channels_is_tried_again_and_meanwhile_the_list_says_it_is_no
|
||||
assert queries.watched_channels_pending(db, user) == 0
|
||||
|
||||
|
||||
def test_a_listed_channel_still_to_be_described_is_not_counted_as_one_to_come(db, make_user):
|
||||
user = make_user("stub@example.com")
|
||||
# A stub the catalog made when one of its videos came in: a title, no details yet.
|
||||
db.add(Channel(id=_ch(1), title="From a video"))
|
||||
db.commit()
|
||||
_watched(db, user, 1, [LONG] * 5)
|
||||
assert queries.watched_channels_to_describe(db, user) == [_ch(1)]
|
||||
assert _listed(db, user) == [(_ch(1), 5)]
|
||||
# It is on the list already: "the list may grow" would promise one more that cannot come.
|
||||
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
|
||||
):
|
||||
@@ -400,6 +413,31 @@ def test_a_followed_channels_watched_videos_come_in_as_the_catalogs_own(
|
||||
assert db.get(Channel, _ch(1)).from_explore is False
|
||||
|
||||
|
||||
def test_a_follow_landing_while_the_videos_come_in_leaves_none_of_them_flagged(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
user, follower = make_user("racer@example.com"), make_user("late@example.com")
|
||||
_channel(db, 1)
|
||||
_watched(db, user, 1, [LONG] * 5)
|
||||
fake = _fake_ensure(calls := [])
|
||||
|
||||
def ensure(session, yt, ids, *, explore=False):
|
||||
# Someone subscribes, in a request of their own, while YouTube answers this one.
|
||||
with SessionLocal() as other:
|
||||
ch = other.get(Channel, _ch(1))
|
||||
catalog_service.record_subscription(other, follower, ch, "sub-late")
|
||||
other.commit()
|
||||
fake(session, yt, ids, explore=explore)
|
||||
|
||||
monkeypatch.setattr(feed_routes, "ensure_videos", ensure)
|
||||
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
|
||||
assert calls[0][1] is True # asked as the exploration's
|
||||
db.expire_all()
|
||||
flagged = db.scalars(select(Video.id).where(Video.via_explore.is_(True))).all()
|
||||
assert (db.get(Channel, _ch(1)).from_explore, flagged) == (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)
|
||||
|
||||
Reference in New Issue
Block a user