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:
2026-10-24 01:10:29 +02:00
parent b5f9cf14db
commit 33dd9ff054
9 changed files with 233 additions and 47 deletions
+14 -8
View File
@@ -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 {