feat(reader): the channels a Takeout history shows watched but not followed

The import asks YouTube how long the watched videos of the candidate
channels are (five or more watched, none the user follows, blocked, put
out or owns), keeps the length on the Takeout row and names the channels
that make the list: five videos of five minutes or more. The shorts job
asks what an import could not. A channel's counted videos come in as an
exploration's when its list is opened, ready to play; a channel can be put
out of the list and back.
This commit is contained in:
2026-10-23 23:45:54 +02:00
parent 84af92aa29
commit bd2340c204
10 changed files with 895 additions and 16 deletions
+15
View File
@@ -109,6 +109,21 @@ def apply_channel_details(db: Session, items: list[dict], asked: Iterable[str])
channel.details_synced_at = now
def describe_channels(db: Session, yt: YouTubeClient, channel_ids: list[str]) -> None:
"""Make sure each given channel is in the catalog with its details: a channel it lacks is added
as a stub, then all of them are asked for (`channels.list`, 1 unit a 50). Flushes; the caller
commits."""
if not channel_ids:
return
have = set(db.execute(select(Channel.id).where(Channel.id.in_(channel_ids))).scalars())
for cid in dict.fromkeys(channel_ids):
if cid not in have:
db.add(Channel(id=cid))
db.flush()
apply_channel_details(db, yt.get_channels(channel_ids), channel_ids)
db.flush()
def should_prune(fetched_count: int, existing_count: int) -> bool:
"""Whether an import may delete the local subscriptions missing from this fetch.
+12 -3
View File
@@ -420,11 +420,15 @@ def run_shorts_classification(db: Session, limit: int | None = None) -> dict:
return {"probed": probed, "shorts": shorts}
def ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None:
def ensure_videos(
db: Session, yt: YouTubeClient, video_ids: list[str], *, explore: bool = False
) -> None:
"""Make sure every given video id exists in the catalog, fetching + ingesting the
missing ones (and a stub channel for any unknown channel). Deleted/private videos that
the API won't return are simply left out. The catalog's write for a playlist sync that names
videos it has never seen; commits."""
videos it has never seen; commits. `explore` adds the new ones as an exploration's
(`via_explore`): visible to who explores or follows their channel, and reclaimed by the cleanup
once nobody keeps them."""
if not video_ids:
return
have = set(db.execute(select(Video.id).where(Video.id.in_(video_ids))).scalars().all())
@@ -458,7 +462,12 @@ def ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None:
seen.add(vid)
# published_at is NOT NULL (0068); apply_video_details re-fills a missing date, but seed a now()
# fallback here too so the constructor never carries an explicit NULL.
v = Video(id=vid, channel_id=cid, published_at=parse_dt(sn.get("publishedAt")) or now)
v = Video(
id=vid,
channel_id=cid,
published_at=parse_dt(sn.get("publishedAt")) or now,
via_explore=explore,
)
apply_video_details(v, it)
v.enriched_at = now
db.add(v)