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