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:
2026-10-24 11:29:14 +02:00
parent 33dd9ff054
commit bc8fe2cc6c
11 changed files with 242 additions and 20 deletions
+61
View File
@@ -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)
+50
View File
@@ -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}
+38
View File
@@ -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)