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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user