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
+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