Add older content exploration: channel page + home feed Rediscover mode

Channel page:
- "Explore older videos" button fetches 100 videos at a time further back
  in the channel history using yt-dlp --playlist-start/--playlist-end
- "Fetch entire history" still available for full crawl
- Backend: /channels/{id}/explore?page=N endpoint + playlist offset support
  in fetch_channel_metadata(start_video=N)

Home feed:
- New "Rediscover" mode: older unwatched videos (90+ days old) from
  followed channels, randomly sampled then re-ranked by tag affinity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:17:20 +02:00
parent 0b482b5d49
commit aa91156bbc
6 changed files with 131 additions and 10 deletions

View File

@@ -208,6 +208,58 @@ def home_feed(
for r in rows
]
if mode == "rediscover":
# Older unwatched videos from followed channels, ranked by tag affinity
affinity_rows = db.execute(
text("SELECT tag, score FROM user_tag_affinity WHERE user_id = :user_id AND score > 0"),
{"user_id": current_user.id},
).mappings().all()
affinity = {r["tag"]: r["score"] for r in affinity_rows}
rows = db.execute(
text(f"""
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.queued, 0) AS queued,
NULL AS file_path
FROM videos v
JOIN channels c ON v.channel_id = c.id
JOIN user_channels uc
ON c.id = uc.channel_id AND uc.user_id = :user_id AND uc.status = 'followed'
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE COALESCE(uv.watched, 0) = 0
AND v.published_at < datetime('now', '-90 days')
AND (uc.muted_until IS NULL OR datetime(uc.muted_until) < datetime('now'))
{duration_clause}
ORDER BY RANDOM()
LIMIT :limit OFFSET :offset
"""),
{"user_id": current_user.id, "limit": min(limit * 4, 200), "offset": offset},
).mappings().all()
if affinity:
import json as _json
def _affinity_score(row):
try:
tags = _json.loads(row["tags"] or "[]")
return sum(affinity.get(t.lower().strip(), 0) for t in tags if isinstance(t, str))
except Exception:
return 0
rows = sorted(rows, key=_affinity_score, reverse=True)
rows = rows[:limit]
return [
VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched",)},
is_watched=bool(r["watched"]))
for r in rows
]
if mode == "inbox":
rows = db.execute(
text(f"""