feat(reader): a Google Takeout export becomes watch state and Watch later

The YouTube API reads neither a user's watch history nor their Watch later list, so the Takeout
export is the only way in. `POST /api/takeout` takes one file of it (watch-history.json or
Watch later-videos.csv, told apart by content), keeps its videos in `takeout_items` and queues the
user's `takeout_import` run, whose outcome reaches their inbox.

The run marks watched the known videos the Shorts probe has cleared, at the Takeout time; a Short
is not marked, and a state the user set here (watched, hidden, or changed after that time) wins, by
the upsert's own condition. A video the catalog does not know yet waits with no outcome. Watch later
videos are fetched if unknown and appended oldest first.

An ad-hoc run can now ask for a completion notice (`run_ad_hoc(..., notify=True)`).
This commit is contained in:
2026-10-23 17:04:54 +02:00
parent bf5ceec8a1
commit 7b8505af95
15 changed files with 978 additions and 29 deletions
+121 -1
View File
@@ -15,7 +15,17 @@ from sqlalchemy.orm import Session
from app.models import User
from app.reader import queries
from app.reader.models import ChannelTag, Playlist, PlaylistItem, SavedView, Tag, VideoState
from app.reader.models import (
ChannelTag,
Playlist,
PlaylistItem,
SavedView,
Tag,
TakeoutItem,
TakeoutKind,
TakeoutOutcome,
VideoState,
)
from app.sync.playlists import lock_playlists, recompute_dirty
from app.userscope import owned_by
@@ -495,3 +505,113 @@ def detach_user_tag(db: Session, user: User, channel_id: str, tag_id: int) -> No
if row is not None:
db.delete(row)
db.flush()
# --- Google Takeout --------------------------------------------------------------------------
_TAKEOUT_BATCH = 1000
def stage_takeout(
db: Session, user: User, kind: TakeoutKind, rows: dict[str, tuple[datetime, str | None]]
) -> None:
"""Keep the videos of one Takeout file, `{video id: (time, channel id)}`, for the import job.
A second upload of the same export, or a newer one, adds what is new: a video already kept keeps
the later time, and a row the import has settled keeps its outcome."""
values = [
{"user_id": user.id, "kind": kind, "video_id": vid, "at": at, "channel_id": channel}
for vid, (at, channel) in rows.items()
]
for start in range(0, len(values), _TAKEOUT_BATCH):
stmt = pg_insert(TakeoutItem).values(values[start : start + _TAKEOUT_BATCH])
db.execute(
stmt.on_conflict_do_update(
constraint="uq_takeout_item",
set_={
"at": func.greatest(TakeoutItem.at, stmt.excluded.at),
"channel_id": func.coalesce(stmt.excluded.channel_id, TakeoutItem.channel_id),
},
)
)
db.flush()
def _settle(db: Session, ids: list[int], outcome: TakeoutOutcome) -> None:
for start in range(0, len(ids), _TAKEOUT_BATCH):
db.execute(
update(TakeoutItem)
.where(TakeoutItem.id.in_(ids[start : start + _TAKEOUT_BATCH]))
.values(outcome=outcome)
)
def apply_takeout_watched(db: Session, user: User) -> dict[str, int]:
"""Mark watched what the user watched on YouTube, for the videos the catalog knows and has probed
for Shorts; the rest wait. `watched_at` is the Takeout time. A Short is not marked (swiping one
past is not watching it), and a state the user set here wins: watched or hidden, or anything
changed after the Takeout time. The upsert's own condition is that rule, so a state set while
the import runs wins too."""
short: list[int] = []
todo: dict[str, tuple[int, datetime]] = {}
for row_id, video_id, at, is_short in queries.takeout_watched_ready(db, user):
if is_short:
short.append(row_id)
else:
todo[video_id] = (row_id, at)
marked: set[str] = set()
items = list(todo.items())
now = datetime.now(timezone.utc)
for start in range(0, len(items), _TAKEOUT_BATCH):
stmt = pg_insert(VideoState).values(
[
{"user_id": user.id, "video_id": vid, "status": "watched", "watched_at": at}
for vid, (_, at) in items[start : start + _TAKEOUT_BATCH]
]
)
marked.update(
db.execute(
stmt.on_conflict_do_update(
constraint="uq_user_video",
set_={
"status": "watched",
"watched_at": stmt.excluded.watched_at,
"updated_at": now,
},
where=(VideoState.status == "new")
& (VideoState.updated_at < stmt.excluded.watched_at),
).returning(VideoState.video_id)
).scalars()
)
kept = [row_id for vid, (row_id, _) in items if vid not in marked]
_settle(db, [todo[vid][0] for vid in marked], TakeoutOutcome.WATCHED)
_settle(db, kept, TakeoutOutcome.KEPT)
_settle(db, short, TakeoutOutcome.SHORT)
db.flush()
return {"watched": len(marked), "kept": len(kept), "shorts": len(short)}
def apply_takeout_watch_later(db: Session, user: User) -> dict[str, int]:
"""Put the videos of the user's YouTube Watch later list into theirs here, oldest first, after
what it already holds. Called once the catalog has fetched what it could: a video still unknown
is one YouTube no longer has."""
waiting = queries.takeout_waiting(db, user, TakeoutKind.WATCH_LATER)
if not waiting:
return {}
known = queries.known_video_ids(db, [vid for _, vid in waiting])
pl = watch_later(db, user)
done: dict[TakeoutOutcome, list[int]] = {o: [] for o in TakeoutOutcome}
for row_id, vid in waiting:
if vid not in known:
done[TakeoutOutcome.MISSING].append(row_id)
elif add_item(db, pl, vid, mark_dirty=False):
done[TakeoutOutcome.ADDED].append(row_id)
else:
done[TakeoutOutcome.PRESENT].append(row_id)
for outcome, ids in done.items():
_settle(db, ids, outcome)
db.flush()
return {
"watch_later_added": len(done[TakeoutOutcome.ADDED]),
"watch_later_present": len(done[TakeoutOutcome.PRESENT]),
"watch_later_missing": len(done[TakeoutOutcome.MISSING]),
}