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:
@@ -758,6 +758,54 @@ def index_channel_full(
|
||||
return {"detail": "Full index started"}
|
||||
|
||||
|
||||
@router.post("/{channel_id}/explore", status_code=status.HTTP_202_ACCEPTED)
|
||||
def explore_channel_older(
|
||||
channel_id: int,
|
||||
page: int = 2,
|
||||
background_tasks: BackgroundTasks = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Fetch a page of older videos from this channel (page 1 = newest 30, page 2 = next 100, etc.)."""
|
||||
_get_channel_or_404(db, channel_id)
|
||||
start = 1 if page <= 1 else (30 + (page - 2) * 100 + 1)
|
||||
background_tasks.add_task(_index_channel_explore_task, channel_id, current_user.id, start, 100)
|
||||
return {"detail": f"Fetching older videos (page {page})", "start": start}
|
||||
|
||||
|
||||
def _index_channel_explore_task(channel_id: int, user_id: int, start_video: int, count: int):
|
||||
from ..database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
channel = db.query(Channel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
return
|
||||
result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id, max_videos=count, start_video=start_video)
|
||||
if not result:
|
||||
return
|
||||
for vdata in result.get("videos", []):
|
||||
yt_id = vdata.get("youtube_video_id")
|
||||
if not yt_id or not vdata.get("published_at"):
|
||||
continue
|
||||
if not db.query(Video).filter_by(youtube_video_id=yt_id).first():
|
||||
db.add(Video(
|
||||
youtube_video_id=yt_id,
|
||||
channel_id=channel.id,
|
||||
title=vdata.get("title", ""),
|
||||
description=vdata.get("description"),
|
||||
thumbnail_url=vdata.get("thumbnail_url"),
|
||||
duration_seconds=vdata.get("duration_seconds"),
|
||||
published_at=vdata.get("published_at"),
|
||||
tags=vdata.get("tags"),
|
||||
category=vdata.get("category"),
|
||||
))
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/follow-bulk", status_code=200)
|
||||
def follow_bulk(
|
||||
body: dict,
|
||||
|
||||
@@ -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"""
|
||||
|
||||
Reference in New Issue
Block a user