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:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user