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
+9 -6
View File
@@ -71,10 +71,12 @@ class Channel(Base, TimestampMixin):
Boolean, default=False, server_default="false", index=True
)
# True when this channel exists only because a user EXPLORED it (opened its page without
# subscribing) — an ephemeral, per-explorer addition. The cleanup job hard-deletes such a
# channel (and its via_explore videos) once nobody keeps it (no subscription, no live
# ExploredChannel row, no per-user state) after a grace period. Cleared when someone
# subscribes (the "keep" signal promotes it to organic).
# subscribing) — an ephemeral, per-explorer addition. After a grace period the cleanup job
# hard-deletes its via_explore videos nobody keeps (no subscription, no live ExploredChannel
# row, no per-user state), and then the channel row itself once it holds no videos — unless a
# Takeout watch history shows it watched: the "watched but not followed" list names it from
# that row. Cleared when someone subscribes, here or on YouTube (the "keep" signal promotes it
# to organic).
from_explore: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True
)
@@ -235,8 +237,9 @@ class ExploredChannel(Base, TimestampMixin):
Exploring an un-subscribed channel ingests its recent uploads (flagged via_explore) so the
user can browse them, but keeps them out of everyone else's catalog. This row (a) gates the
explorer's access to those ephemeral videos in the feed query and (b) drives the grace-period
purge: a from_explore channel with no live ExploredChannel row (and no subscription / no
per-user state on its videos) is hard-deleted by the cleanup job. Subscribing is the "keep"
purge: of a from_explore channel with no live ExploredChannel row (and no subscription) the
cleanup job hard-deletes the videos without per-user state, then the channel once it holds none
and no Takeout watch history shows it watched. Subscribing is the "keep"
signal — it promotes the channel to organic and these rows become irrelevant. `created_at`
(from TimestampMixin) is the grace clock; a re-visit refreshes it."""
+18 -7
View File
@@ -96,13 +96,24 @@ def record_subscription(db: Session, user: User, channel: Channel, yt_subscripti
subscribed_at=datetime.now(timezone.utc),
)
)
if channel.from_explore:
channel.from_explore = False
db.execute(
update(Video)
.where(Video.channel_id == channel.id, Video.via_explore.is_(True))
.values(via_explore=False)
)
db.flush()
settle_explored(db, channel)
def settle_explored(db: Session, channel: Channel) -> None:
"""Hold the rule that a channel's videos are an exploration's only while nobody follows it,
decided by who follows it now, not by the flag held in memory: a subscribe in another request
may have cleared that while YouTube answered this one, and the videos that came in meanwhile
would stay flagged. A channel someone follows, however the follow came (here or pulled in from
YouTube), is the catalog's own, and so are its videos."""
if channel.from_explore and not queries.followed(db, channel.id):
return
channel.from_explore = False
db.execute(
update(Video)
.where(Video.channel_id == channel.id, Video.via_explore.is_(True))
.values(via_explore=False)
)
db.flush()
+19 -6
View File
@@ -605,7 +605,9 @@ def takeout_lengths_to_ask(db: Session, user: User) -> list[str]:
def watched_channels_pending(db: Session, user: User) -> int:
"""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."""
whose length is not known, and the ones that made it but have no catalog row to be listed from
yet. One the catalog holds without details is listed already (by the name it came in with), so
it is not one to come."""
cand = _candidate_channels(user)
unasked = db.execute(
select(TakeoutItem.channel_id)
@@ -613,19 +615,30 @@ def watched_channels_pending(db: Session, user: User) -> int:
.where(_unfollowed(user), TakeoutItem.looked_up_at.is_(None))
.distinct()
).scalars()
return len(set(unasked) | set(watched_channels_to_describe(db, user)))
counted = _counted_channels(user)
unlisted = db.execute(
select(counted.c.channel_id)
.outerjoin(Channel, Channel.id == counted.c.channel_id)
.where(Channel.id.is_(None))
).scalars()
return len(set(unasked) | set(unlisted))
def watched_channels_to_describe(db: Session, user: User) -> list[str]:
"""The channels on the list (by their counted videos) that the catalog lacks or has no details
of yet: what the list cannot name."""
counted = (
def _counted_channels(user: User):
"""The channels that make the list by their counted videos, whatever the catalog holds of them."""
return (
select(TakeoutItem.channel_id)
.where(_unfollowed(user), TakeoutItem.duration_seconds >= WATCHED_CHANNEL_MIN_SECONDS)
.group_by(TakeoutItem.channel_id)
.having(func.count() >= WATCHED_CHANNEL_MIN_VIDEOS)
.subquery()
)
def watched_channels_to_describe(db: Session, user: User) -> list[str]:
"""The channels on the list (by their counted videos) that the catalog lacks or has no details
of yet: what the list cannot name, or names with no more than a video brought."""
counted = _counted_channels(user)
return list(
db.execute(
select(counted.c.channel_id)
+4 -1
View File
@@ -320,7 +320,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.
# two full Watch later lists (two units a hundred ids). The rest wait for the next day's runs. Both
# shares pace the work, they do not bound its cost: they are held in memory, so a restart starts
# them afresh, which only brings the rest forward — every video is asked about once (a looked-up
# row is marked), and `quota.backfill_reserve_reached` still guards the shared budget.
_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
+2
View File
@@ -481,6 +481,8 @@ def watched_channel_videos(
ensure_videos(db, yt, missing, explore=channel.from_explore)
except YouTubeError as exc:
raise youtube_refusal(exc) from exc
catalog_service.settle_explored(db, channel)
db.commit()
items = queries.feed_rows_by_ids(db, user, ids)
watched_at = dict(counted)
for item in items:
+2
View File
@@ -14,6 +14,7 @@ from sqlalchemy import delete, or_, select
from sqlalchemy.orm import Session
from app import logs, sysconfig
from app.catalog import service as catalog_service
from app.catalog.models import Channel, ExploredChannel, SearchFind, Subscription, Video
from app.reader import queries as reader_queries
from app.sync.videos import insert_stubs, stub_from_playlist_item, apply_video_details
@@ -91,6 +92,7 @@ def explore_ingest_page(
stub["via_explore"] = channel.from_explore
stubs.append(stub)
inserted = insert_stubs(db, stubs)
catalog_service.settle_explored(db, channel)
self_enriched = _enrich_new(db, yt, [s["id"] for s in stubs])
log.info(
"explore ingest channel=%s ingested=%s enriched=%s", channel.id, inserted, self_enriched
+5
View File
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from app import logs
from app.models import User
from app.catalog import service as catalog_service
from app.catalog.models import Channel, Subscription
from app.utils import normalize_description
from app.youtube.client import YouTubeClient, YouTubeError, best_thumbnail
@@ -197,6 +198,10 @@ def import_subscriptions(db: Session, user: User) -> dict:
db.add(sub_row)
sub_row.yt_subscription_id = sub.get("yt_subscription_id")
db.flush()
# A follow made on youtube.com keeps an explored channel as a follow made here does.
explored = select(Channel).where(Channel.id.in_(fetched), Channel.from_explore.is_(True))
for channel in db.execute(explored).scalars():
catalog_service.settle_explored(db, channel)
# Prune subscriptions removed on YouTube — but only when this fetch is trustworthy
# (see `should_prune`).