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