Overhaul channel page: search, pagination, fetch all history

- Search bar filters indexed videos server-side; "Search YouTube" button
  triggers a deep channel search and indexes matching results
- Server-side sort (newest/oldest/A-Z/unwatched) + infinite scroll (60/page)
- "Fetch recent" indexes last 30, "Fetch all" indexes full history
- Auto-reindex on page visit if stale (>1h), refetches at 8s
- Add /channels/{id}/index-full endpoint (max_videos=0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:15:09 +02:00
parent 50d61b5774
commit 0b482b5d49
3 changed files with 277 additions and 62 deletions

View File

@@ -83,7 +83,7 @@ def _index_channels_batch(channel_ids: list[int], user_id: int, delay: float = 1
_index_channel_task(cid, user_id)
def _index_channel_task(channel_id: int, user_id: int):
def _index_channel_task(channel_id: int, user_id: int, max_videos: int = 30):
from ..database import SessionLocal
db = SessionLocal()
try:
@@ -91,7 +91,7 @@ def _index_channel_task(channel_id: int, user_id: int):
if not channel:
return
result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id)
result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id, max_videos=max_videos)
if not result:
return
@@ -599,26 +599,98 @@ def get_channel(
@router.get("/{channel_id}/videos", response_model=list[VideoOut])
def get_channel_videos(
channel_id: int,
sort: str = "newest",
offset: int = 0,
limit: int = 60,
q: str = "",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_get_channel_or_404(db, channel_id)
order = {
"newest": "v.published_at DESC NULLS LAST",
"oldest": "v.published_at ASC NULLS LAST",
"title": "v.title ASC",
"unwatched": "COALESCE(uv.watched, 0) ASC, v.published_at DESC NULLS LAST",
}.get(sort, "v.published_at DESC NULLS LAST")
params: dict = {"user_id": current_user.id, "channel_id": channel_id, "limit": limit, "offset": offset}
q_clause = ""
if q.strip():
q_clause = "AND (v.title LIKE :q OR v.description LIKE :q)"
params["q"] = f"%{q.strip()}%"
rows = db.execute(
text("""
text(f"""
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
v.duration_seconds, v.published_at,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.watched, 0) AS is_watched
FROM videos v
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE v.channel_id = :channel_id
ORDER BY v.published_at DESC
WHERE v.channel_id = :channel_id {q_clause}
ORDER BY {order}
LIMIT :limit OFFSET :offset
"""),
{"user_id": current_user.id, "channel_id": channel_id},
params,
).mappings().all()
return [VideoOut(**dict(r)) for r in rows]
@router.post("/{channel_id}/search", status_code=status.HTTP_202_ACCEPTED)
def search_channel_youtube(
channel_id: int,
q: str,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Search YouTube within this channel and index matching videos."""
channel = _get_channel_or_404(db, channel_id)
background_tasks.add_task(_search_channel_task, channel_id, channel.youtube_channel_id, q, current_user.id)
return {"detail": "Search started"}
def _search_channel_task(channel_id: int, youtube_channel_id: str, q: str, user_id: int):
"""Fetch videos matching q from YouTube for this channel and index them."""
from ..database import SessionLocal
from urllib.parse import quote
db = SessionLocal()
try:
url = f"https://www.youtube.com/channel/{youtube_channel_id}/search?query={quote(q)}"
result = ytdlp.fetch_channel_metadata(youtube_channel_id, max_videos=100)
if not result:
return
# Filter results by query match (yt-dlp fetches all; we filter titles locally)
q_lower = q.lower()
matched = [v for v in result.get("videos", []) if q_lower in (v.get("title") or "").lower()]
if not matched:
matched = result.get("videos", [])[:30]
channel = db.query(Channel).filter_by(id=channel_id).first()
if not channel:
return
for vdata in matched:
yt_id = vdata.get("youtube_video_id")
if not yt_id:
continue
existing = db.query(Video).filter_by(youtube_video_id=yt_id).first()
if not existing:
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("/{channel_id}/follow", status_code=status.HTTP_204_NO_CONTENT)
def follow_channel(
channel_id: int,
@@ -670,10 +742,22 @@ def index_channel(
current_user: User = Depends(get_current_user),
):
_get_channel_or_404(db, channel_id)
background_tasks.add_task(_index_channel_task, channel_id, current_user.id)
background_tasks.add_task(_index_channel_task, channel_id, current_user.id, 100)
return {"detail": "Indexing started"}
@router.post("/{channel_id}/index-full", status_code=status.HTTP_202_ACCEPTED)
def index_channel_full(
channel_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_get_channel_or_404(db, channel_id)
background_tasks.add_task(_index_channel_task, channel_id, current_user.id, 0)
return {"detail": "Full index started"}
@router.post("/follow-bulk", status_code=200)
def follow_bulk(
body: dict,