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
+21 -2
View File
@@ -254,8 +254,9 @@ class TakeoutItem(Base, TimestampMixin):
"""One video from a user's Google Takeout export: watched (with when) or in their YouTube Watch
later list (with when it was added). The rows are both the import job's input and its memory: a
video the catalog does not know yet waits here, with no outcome, until it enters the catalog.
The id is not a foreign key for that reason. The channel is kept for a later "channels you
watch but do not follow" list."""
The id is not a foreign key for that reason. A watched row's channel and length feed the
"channels you watch but do not follow" list: the length is asked of YouTube for that list alone
and kept here, so its videos need not enter the shared catalog to be counted."""
__tablename__ = "takeout_items"
__table_args__ = (
@@ -277,3 +278,21 @@ class TakeoutItem(Base, TimestampMixin):
# Watched: the last time in the export; Watch later: when it was added.
at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
outcome: Mapped[str | None] = mapped_column(String(12))
# When YouTube was asked for the video's length (NULL = not yet), and the length it gave; a
# video it no longer has keeps a NULL length.
looked_up_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
duration_seconds: Mapped[int | None] = mapped_column(Integer)
class TakeoutChannelDismissal(Base, TimestampMixin):
"""A channel the user put out of their "watched but not followed" list. The id is not a foreign
key: the channel may never have entered the catalog, or may leave it, and the choice stays."""
__tablename__ = "takeout_channel_dismissals"
__table_args__ = (
UniqueConstraint("user_id", "channel_id", name="uq_takeout_channel_dismissal"),
)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
channel_id: Mapped[str] = mapped_column(String(32))
+163 -1
View File
@@ -16,13 +16,14 @@ from sqlalchemy.orm import InstrumentedAttribute, Session, aliased
from app.collation import collated
from app.feedquery import feed_columns, serialize
from app.models import User
from app.catalog.models import Channel, Subscription, Video
from app.catalog.models import BlockedChannel, Channel, Subscription, Video
from app.reader.models import (
ChannelTag,
Playlist,
PlaylistItem,
SavedView,
Tag,
TakeoutChannelDismissal,
TakeoutItem,
TakeoutKind,
VideoState,
@@ -533,3 +534,164 @@ def takeout_users_watch_later_waiting(db: Session) -> list[int]:
.distinct()
).scalars()
)
# --- channels watched but not followed ---------------------------------------------------------
# A channel joins the list with this many watched videos of at least this length. A Short is three
# minutes at most, so the length alone keeps them out, and a video under five minutes says too
# little of what a channel is about to count towards it.
WATCHED_CHANNEL_MIN_VIDEOS = 5
WATCHED_CHANNEL_MIN_SECONDS = 300
def _unfollowed(user: User) -> ColumnElement[bool]:
"""A watched row whose channel the user neither follows, blocked, put out of the list, nor is."""
subscribed = select(Subscription.channel_id).where(owned_by(Subscription.user_id, user))
blocked = select(BlockedChannel.channel_id).where(owned_by(BlockedChannel.user_id, user))
dismissed = select(TakeoutChannelDismissal.channel_id).where(
owned_by(TakeoutChannelDismissal.user_id, user)
)
clauses = [
owned_by(TakeoutItem.user_id, user),
TakeoutItem.kind == TakeoutKind.WATCHED,
TakeoutItem.channel_id.is_not(None),
TakeoutItem.channel_id.not_in(subscribed),
TakeoutItem.channel_id.not_in(blocked),
TakeoutItem.channel_id.not_in(dismissed),
]
if user.yt_channel_id:
clauses.append(TakeoutItem.channel_id != user.yt_channel_id)
return and_(*clauses)
def _candidate_channels(user: User):
"""The unfollowed channels with enough watched videos to make the list once their lengths are
known: (channel id, how many watched)."""
return (
select(TakeoutItem.channel_id, func.count().label("watched"))
.where(_unfollowed(user))
.group_by(TakeoutItem.channel_id)
.having(func.count() >= WATCHED_CHANNEL_MIN_VIDEOS)
.subquery()
)
def takeout_lengths_to_ask(db: Session, user: User) -> list[str]:
"""The watched videos whose length the list still needs: those of the candidate channels not
asked about yet, the most watched channels first, so a lookup cut short fills the top."""
cand = _candidate_channels(user)
return list(
db.execute(
select(TakeoutItem.video_id)
.join(cand, cand.c.channel_id == TakeoutItem.channel_id)
.where(_unfollowed(user), TakeoutItem.looked_up_at.is_(None))
.order_by(cand.c.watched.desc(), TakeoutItem.channel_id, TakeoutItem.at.desc())
).scalars()
)
def watched_channels_pending(db: Session, user: User) -> int:
"""How many candidate channels still have a watched video whose length is not known: the
channels that may yet join the list."""
cand = _candidate_channels(user)
return (
db.scalar(
select(func.count(func.distinct(TakeoutItem.channel_id)))
.join(cand, cand.c.channel_id == TakeoutItem.channel_id)
.where(_unfollowed(user), TakeoutItem.looked_up_at.is_(None))
)
or 0
)
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 = (
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()
)
return list(
db.execute(
select(counted.c.channel_id)
.outerjoin(Channel, Channel.id == counted.c.channel_id)
.where(Channel.details_synced_at.is_(None))
).scalars()
)
def watched_channels(db: Session, user: User) -> list[tuple[Channel, int, datetime]]:
"""The channels the user watched often but does not follow: (channel, how many of its videos
over five minutes they watched, when last). Most watched first; on a tie the one watched more
lately, which is the likelier interest."""
n = func.count(TakeoutItem.id)
last = func.max(TakeoutItem.at)
return list(
db.execute(
select(Channel, n, last)
.join(Channel, Channel.id == TakeoutItem.channel_id)
.where(_unfollowed(user), TakeoutItem.duration_seconds >= WATCHED_CHANNEL_MIN_SECONDS)
.group_by(Channel.id)
.having(n >= WATCHED_CHANNEL_MIN_VIDEOS)
.order_by(n.desc(), last.desc(), Channel.id)
).tuples()
)
def has_watch_history(db: Session, user: User) -> bool:
"""Whether the user has uploaded a Takeout watch history at all."""
return bool(
db.scalar(
select(
select(TakeoutItem.id)
.where(owned_by(TakeoutItem.user_id, user), TakeoutItem.kind == TakeoutKind.WATCHED)
.exists()
)
)
)
def watched_channel_videos(
db: Session, user: User, channel_id: str
) -> tuple[list[tuple[str, datetime]], int]:
"""The user's watched videos of one channel that the list counts, last watched first: (video
id, when). And how many YouTube no longer has."""
rows = db.execute(
select(TakeoutItem.video_id, TakeoutItem.at, TakeoutItem.duration_seconds)
.where(
owned_by(TakeoutItem.user_id, user),
TakeoutItem.kind == TakeoutKind.WATCHED,
TakeoutItem.channel_id == channel_id,
TakeoutItem.looked_up_at.is_not(None),
)
.order_by(TakeoutItem.at.desc(), TakeoutItem.video_id)
).tuples()
counted: list[tuple[str, datetime]] = []
gone = 0
for vid, at, seconds in rows:
if seconds is None:
gone += 1
elif seconds >= WATCHED_CHANNEL_MIN_SECONDS:
counted.append((vid, at))
return counted, gone
def takeout_users_with_lengths_to_ask(db: Session) -> list[int]:
"""Every user with a channel of enough watched videos not all asked about yet. It does not ask
whether they follow the channel: that is each user's own question, which
`takeout_lengths_to_ask` answers (and an empty answer costs a run nothing)."""
per_channel = (
select(TakeoutItem.user_id)
.where(TakeoutItem.kind == TakeoutKind.WATCHED, TakeoutItem.channel_id.is_not(None))
.group_by(TakeoutItem.user_id, TakeoutItem.channel_id)
.having(
func.count() >= WATCHED_CHANNEL_MIN_VIDEOS,
func.count().filter(TakeoutItem.looked_up_at.is_(None)) > 0,
)
.subquery()
)
return list(db.execute(select(per_channel.c.user_id).distinct()).scalars())
+52 -1
View File
@@ -9,7 +9,9 @@ unique key, a row lock taken before a presence check. The comfort shelf's writes
from datetime import datetime, timezone
from sqlalchemy import exists, func, select, text, update
from typing import cast
from sqlalchemy import Table, bindparam, delete, exists, func, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
@@ -21,6 +23,7 @@ from app.reader.models import (
PlaylistItem,
SavedView,
Tag,
TakeoutChannelDismissal,
TakeoutItem,
TakeoutKind,
TakeoutOutcome,
@@ -662,3 +665,51 @@ def apply_takeout_watch_later(
"watch_later_present": len(done[TakeoutOutcome.PRESENT]),
"watch_later_missing": len(done[TakeoutOutcome.MISSING]),
}
# --- channels watched but not followed ---------------------------------------------------------
def record_takeout_lengths(
db: Session, user: User, asked: list[str], lengths: dict[str, int | None]
) -> None:
"""Keep what YouTube said of the user's watched videos `asked` about: each one's length, or none
for a video it no longer has (`lengths` leaves it out). Every asked row is marked asked, so a
video YouTube lost is not asked about again."""
if not asked:
return
lock_takeout(db, user) # these rows are an upload's to update too
now = datetime.now(timezone.utc)
table = cast(Table, TakeoutItem.__table__)
db.execute(
table.update()
.where(
owned_by(TakeoutItem.user_id, user),
table.c.kind == TakeoutKind.WATCHED,
table.c.video_id == bindparam("vid"),
)
.values(looked_up_at=now, duration_seconds=bindparam("seconds")),
[{"vid": vid, "seconds": lengths.get(vid)} for vid in asked],
)
db.flush()
def dismiss_watched_channel(db: Session, user: User, channel_id: str) -> None:
"""Put a channel out of the user's "watched but not followed" list (again is a no-op)."""
db.execute(
pg_insert(TakeoutChannelDismissal)
.values(user_id=user.id, channel_id=channel_id)
.on_conflict_do_nothing(constraint="uq_takeout_channel_dismissal")
)
db.flush()
def restore_watched_channel(db: Session, user: User, channel_id: str) -> None:
"""Let a channel the user put out of the list back on it."""
db.execute(
delete(TakeoutChannelDismissal).where(
owned_by(TakeoutChannelDismissal.user_id, user),
TakeoutChannelDismissal.channel_id == channel_id,
)
)
db.flush()
+86 -7
View File
@@ -4,7 +4,9 @@ only way in.
Two files of the export are read, each uploaded on its own: `history/watch-history.json` and
`playlists/Watch later-videos.csv`. Their videos are kept (`TakeoutItem`) and the import job applies
what it can; a video the catalog does not know yet waits for it.
what it can; a video the catalog does not know yet waits for it. The watched videos' channels also
make the list of channels the user watches but does not follow (`queries.watched_channels`): the
job asks YouTube how long those videos are, and what the channels are, for it.
The format, measured on a real export (2026-10): the history is a JSON list, one entry per view,
newest first. A view of a video has a `titleUrl` with `watch?v=<id>` (on www.youtube.com or
@@ -37,8 +39,9 @@ from app.ratelimit import RateLimiter
from app.reader import queries, service
from app.reader.models import TakeoutKind
from app.sync.runner import get_service_user
from app.sync.videos import ensure_videos
from app.youtube.client import YouTubeClient
from app.sync.subscriptions import describe_channels
from app.sync.videos import ensure_videos, parse_iso8601_duration
from app.youtube.client import YouTubeClient, YouTubeError
log = logs.get_logger(__name__)
@@ -351,10 +354,79 @@ def _put_in_watch_later(db: Session, user: User) -> dict:
return service.apply_takeout_watch_later(db, user, waiting, asked)
def _ask_lengths(db: Session, user: User) -> dict:
"""Ask YouTube how long the user's watched videos are that the "watched but not followed" list
still needs, and keep the answer — on the same terms and the same daily share as the Watch
later lookups. What is not asked yet (no YouTube-connected account, the reserve reached, the
share spent) waits for a later run, and the list says it is not complete. A YouTube failure is
logged and left for a later run too: it is no reason to fail the import it follows. The caller
commits."""
ask = queries.takeout_lengths_to_ask(db, user)
fetcher = get_service_user(db) if ask else None
if fetcher is None or quota.backfill_reserve_reached():
return {}
ask = ask[: int(_lookups.room(str(user.id)))]
if not ask:
return {}
try:
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP):
with YouTubeClient(db, fetcher) as yt:
items = yt.get_videos(ask)
except YouTubeError as exc:
log.warning(
"Takeout: asking the watched videos' lengths failed: %s",
exc,
extra=logs.event("takeout.lengths.failed", user_id=user.id, reason=exc.reason),
)
return {}
_lookups.allow(str(user.id), cost=len(ask))
lengths = {
it["id"]: parse_iso8601_duration(it.get("contentDetails", {}).get("duration"))
for it in items
if it.get("id")
}
service.record_takeout_lengths(db, user, ask, lengths)
return {"lengths_asked": len(ask)}
def _describe_channels(db: Session, user: User) -> dict:
"""Bring the channels the user's list holds into the catalog with their details (name, picture,
description), the ones it lacks: without them the list cannot show a channel. A YouTube failure
is logged and left for a later run. The caller commits."""
need = queries.watched_channels_to_describe(db, user)
fetcher = get_service_user(db) if need else None
if fetcher is None or quota.backfill_reserve_reached():
return {}
try:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER):
with YouTubeClient(db, fetcher) as yt:
describe_channels(db, yt, need)
except YouTubeError as exc:
db.rollback()
log.warning(
"Takeout: describing the watched channels failed: %s",
exc,
extra=logs.event("takeout.channels.failed", user_id=user.id, reason=exc.reason),
)
return {}
return {"channels_described": len(need)}
def _channel_list(db: Session, user: User) -> dict:
"""The "watched but not followed" list's part of a run: the lengths, landed, then the channels.
Commits each."""
result = _ask_lengths(db, user)
db.commit()
result |= _describe_channels(db, user)
db.commit()
return result
def run_import(user_id: int) -> dict:
"""The import job: apply what the user's kept Takeout rows can say now, as one recorded run.
The watched part commits before the Watch later part asks YouTube for the videos the catalog
lacks, so a YouTube failure there loses nothing already marked. Returns the counts."""
lacks, so a YouTube failure there loses nothing already marked; the "watched but not followed"
list's lookups come last. Returns the counts."""
with _one_import(user_id), SessionLocal() as db:
user = db.get(User, user_id)
if user is None:
@@ -363,6 +435,7 @@ def run_import(user_id: int) -> dict:
db.commit()
result |= _put_in_watch_later(db, user)
db.commit()
result |= _channel_list(db, user)
outcomes = queries.takeout_outcomes(db, user)
result["waiting"] = outcomes.get((TakeoutKind.WATCHED, None), 0)
if outcomes.get((TakeoutKind.WATCH_LATER, None)):
@@ -373,13 +446,15 @@ def run_import(user_id: int) -> dict:
def apply_waiting(db: Session) -> dict:
"""Settle what every user's import left waiting and can be settled now, one commit each: a
watched video the catalog has come to know and the Shorts probe has cleared (by its job or a
search's), and the Watch later videos a run could not put in yet. Run after each run of the
search's), the Watch later videos a run could not put in yet, and what the "watched but not
followed" list still needs asked of YouTube. Run after each run of the
`shorts` job, which is what clears the most of them. Returns its counts, or an empty dict when
nothing was waiting that could move."""
marked = added = failed = 0
marked = added = asked = failed = 0
ready = set(queries.takeout_users_ready(db))
later = set(queries.takeout_users_watch_later_waiting(db))
for user_id in sorted(ready | later):
lengths = set(queries.takeout_users_with_lengths_to_ask(db))
for user_id in sorted(ready | later | lengths):
user = db.get(User, user_id)
if user is None:
continue
@@ -393,6 +468,8 @@ def apply_waiting(db: Session) -> dict:
if user_id in later:
added += _put_in_watch_later(db, user).get("watch_later_added", 0)
db.commit()
if user_id in lengths:
asked += _channel_list(db, user).get("lengths_asked", 0)
except Exception:
db.rollback()
failed += 1
@@ -406,6 +483,8 @@ def apply_waiting(db: Session) -> dict:
out["takeout_watched"] = marked
if added:
out["takeout_watch_later_added"] = added
if asked:
out["takeout_lengths_asked"] = asked
if failed:
out["takeout_failed"] = failed
return out
+95 -2
View File
@@ -3,12 +3,13 @@ from collections.abc import AsyncIterator
from datetime import date
from functools import partial
from fastapi import APIRouter, Depends, Query, Request
from fastapi import APIRouter, Depends, Path, Query, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy.orm.exc import StaleDataError
from app import jobruns, quota, scheduler
from app.catalog import queries as catalog_queries, service as catalog_service
from app.auth import current_user
from app.db import get_db, release_read_connection
from app.mailtext import lang_for_user
@@ -24,7 +25,7 @@ from app.routes._common import (
not_found,
youtube_refusal,
)
from app.sync.videos import parse_iso8601_duration
from app.sync.videos import ensure_videos, parse_iso8601_duration
from app.utils import iso
from app.youtube.client import YouTubeClient, YouTubeError
from app.refusal import refused
@@ -417,3 +418,95 @@ def takeout_status(user: User = Depends(current_user), db: Session = Depends(get
},
**queries.takeout_summary(db, user),
}
# --- channels watched but not followed -----------------------------------------------------------
@router.get("/takeout/channels")
def watched_channels(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""The channels the user's Takeout history shows them watching often but not following: each
with how many of its videos over five minutes they watched, and when last. `pending` is how many
more channels may join once the import has asked YouTube for their videos' lengths, so a list
still being made does not read as the whole of it; `imported` tells "no history uploaded" from
"nothing to suggest"."""
return {
"imported": queries.has_watch_history(db, user),
"pending": queries.watched_channels_pending(db, user),
"channels": [
{
"id": ch.id,
"title": ch.title,
"handle": ch.handle,
"thumbnail_url": ch.thumbnail_url,
"subscriber_count": ch.subscriber_count,
"video_count": ch.video_count,
"description": ch.description,
"watched": int(n),
"last_watched_at": iso(last),
}
for ch, n, last in queries.watched_channels(db, user)
],
}
@router.post("/takeout/channels/{channel_id}/videos")
def watched_channel_videos(
channel_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""The videos of a channel that the user's list counts, ready to play, last watched first. The
channel is explored for the user (as its page does), and the videos the catalog lacks come in as
the exploration's: the player needs them in the catalog, the shared Library does not get them,
and the cleanup reclaims them once nobody keeps them. `gone`: how many YouTube no longer has."""
channel = catalog_queries.channel(db, channel_id)
if channel is None:
raise not_found("channel")
if catalog_queries.is_blocked(db, user, channel_id):
raise refused(403, "channel_blocked")
counted, gone = queries.watched_channel_videos(db, user, channel_id)
ids = [vid for vid, _ in counted]
known = queries.known_video_ids(db, ids)
missing = [vid for vid in ids if vid not in known]
if missing and quota.backfill_reserve_reached():
raise refused(429, "quota_reserve")
catalog_service.explore(db, user, channel)
db.commit()
if missing:
try:
with (
quota.attribute(user.id, quota.QuotaAction.CHANNELS_EXPLORE),
YouTubeClient(db, user) as yt,
):
ensure_videos(db, yt, missing, explore=True)
except YouTubeError as exc:
raise youtube_refusal(exc) from exc
items = queries.feed_rows_by_ids(db, user, ids)
watched_at = dict(counted)
for item in items:
item["takeout_watched_at"] = iso(watched_at[item["id"]])
# A video YouTube still had when its length was asked may have gone since.
return {"ok": True, "items": items, "gone": gone + len(ids) - len(items)}
@router.put("/takeout/channels/{channel_id}/dismissal")
def dismiss_watched_channel(
channel_id: str = Path(max_length=32),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Put a channel out of the user's list (it stays out until restored)."""
service.dismiss_watched_channel(db, user, channel_id)
db.commit()
return {"ok": True, "channel_id": channel_id}
@router.delete("/takeout/channels/{channel_id}/dismissal")
def restore_watched_channel(
channel_id: str = Path(max_length=32),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Let a channel the user put out of the list back on it."""
service.restore_watched_channel(db, user, channel_id)
db.commit()
return {"ok": True, "channel_id": channel_id}
+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)