import base64 from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select from sqlalchemy.orm import Session from app.core.auth_dependency import require_session from app.db import get_db from app.models.channel import Channel from app.models.channel_category import channel_categories from app.models.video import Video from app.services.download_jobs import latest_jobs_map from app.services.video_presentation import channel_categories_map, serialize_video router = APIRouter(dependencies=[Depends(require_session)]) DEFAULT_LIMIT = 30 MAX_LIMIT = 100 def _encode_cursor(published_at: datetime, video_id: int) -> str: raw = f"{published_at.isoformat()}|{video_id}" return base64.urlsafe_b64encode(raw.encode()).decode() def _decode_cursor(cursor: str) -> tuple[datetime, int]: try: raw = base64.urlsafe_b64decode(cursor.encode()).decode() published_at_raw, video_id_raw = raw.rsplit("|", 1) published_at = datetime.fromisoformat(published_at_raw) if published_at.tzinfo is None: published_at = published_at.replace(tzinfo=timezone.utc) return published_at, int(video_id_raw) except Exception: raise HTTPException(status_code=400, detail="Invalid cursor") @router.get("/feed") def get_feed( category_id: int | None = None, uncategorized: bool = False, channel_id: int | None = None, limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), cursor: str | None = None, db: Session = Depends(get_db), ) -> dict: query = db.query(Video) if channel_id is not None: query = query.filter(Video.channel_id == channel_id) elif uncategorized: categorized_channel_ids = select(channel_categories.c.channel_id) query = query.filter(~Video.channel_id.in_(categorized_channel_ids)) elif category_id is not None: channel_ids_in_category = select(channel_categories.c.channel_id).where( channel_categories.c.category_id == category_id ) query = query.filter(Video.channel_id.in_(channel_ids_in_category)) if cursor: cursor_published_at, cursor_id = _decode_cursor(cursor) query = query.filter( (Video.published_at < cursor_published_at) | ((Video.published_at == cursor_published_at) & (Video.id < cursor_id)) ) query = query.order_by(Video.published_at.desc(), Video.id.desc()) rows = query.limit(limit + 1).all() next_cursor = None if len(rows) > limit: last_kept = rows[limit - 1] next_cursor = _encode_cursor(last_kept.published_at, last_kept.id) rows = rows[:limit] channel_ids = list({v.channel_id for v in rows}) channels = {c.id: c for c in db.query(Channel).filter(Channel.id.in_(channel_ids)).all()} categories_map = channel_categories_map(db, channel_ids) jobs_map = latest_jobs_map(db, [v.id for v in rows]) items = [] for video in rows: channel = channels.get(video.channel_id) if channel is None: continue items.append( serialize_video(video, channel, categories_map.get(channel.id, []), jobs_map.get(video.id)) ) return {"items": items, "next_cursor": next_cursor}