from datetime import date, datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import and_, false, func, or_, select from sqlalchemy.orm import Session, aliased from app.auth import current_user from app.db import get_db from app.models import Channel, ChannelTag, Subscription, Tag, User, Video, VideoState router = APIRouter(prefix="/api", tags=["feed"]) VALID_STATES = {"new", "watched", "saved", "hidden"} HIDDEN_LIVE = ("live", "upcoming") def _channel_url(channel_id: str, handle: str | None) -> str: if handle and handle.startswith("@"): return f"https://www.youtube.com/{handle}" return f"https://www.youtube.com/channel/{channel_id}" def _serialize(row) -> dict: return { "id": row.id, "title": row.title, "channel_id": row.channel_id, "channel_title": row.channel_title, "channel_thumbnail": row.channel_thumbnail, "channel_url": _channel_url(row.channel_id, row.channel_handle), "published_at": row.published_at.isoformat() if row.published_at else None, "thumbnail_url": row.thumbnail_url, "duration_seconds": row.duration_seconds, "view_count": row.view_count, "is_short": row.is_short, "live_status": row.live_status, "status": row.status or "new", "watch_url": f"https://www.youtube.com/watch?v={row.id}", } @router.get("/feed") def get_feed( user: User = Depends(current_user), db: Session = Depends(get_db), tags: list[int] = Query(default=[]), tag_mode: str = "or", channel_id: str | None = None, q: str | None = None, min_duration: int | None = None, max_duration: int | None = None, max_age_days: int | None = None, published_after: date | None = None, published_before: date | None = None, show_normal: bool = True, include_shorts: bool = False, include_live: bool = False, show: str = "unwatched", # all | unwatched | watched | saved | hidden sort: str = "newest", seed: int = 0, limit: int = Query(default=60, le=200), offset: int = 0, ) -> dict: state = aliased(VideoState) status_expr = func.coalesce(state.status, "new").label("status") query = ( select( Video.id, Video.title, Video.channel_id, Channel.title.label("channel_title"), Channel.thumbnail_url.label("channel_thumbnail"), Channel.handle.label("channel_handle"), Video.published_at, Video.thumbnail_url, Video.duration_seconds, Video.view_count, Video.is_short, Video.live_status, status_expr, ) .join(Channel, Channel.id == Video.channel_id) # Only channels this user is subscribed to (and hasn't hidden). .join( Subscription, and_( Subscription.channel_id == Video.channel_id, Subscription.user_id == user.id, Subscription.hidden.is_(False), ), ) .outerjoin( state, and_(state.video_id == Video.id, state.user_id == user.id) ) ) # Content-type filter: Normal (regular videos incl. past-stream VODs), Shorts and # Live/Upcoming are independent toggles; the feed is the union of the enabled types. # The explicit Watched/Saved/Hidden views show every type so nothing goes missing. explicit_view = show in ("watched", "saved", "hidden") if not explicit_view: type_clauses = [] if show_normal: type_clauses.append( and_(Video.is_short.is_(False), Video.live_status.notin_(HIDDEN_LIVE)) ) if include_shorts: type_clauses.append(Video.is_short.is_(True)) if include_live: type_clauses.append(Video.live_status.in_(HIDDEN_LIVE)) query = query.where(or_(*type_clauses) if type_clauses else false()) if channel_id: query = query.where(Video.channel_id == channel_id) if min_duration is not None: query = query.where(Video.duration_seconds >= min_duration) if max_duration is not None: query = query.where(Video.duration_seconds <= max_duration) if max_age_days is not None: cutoff = datetime.now(timezone.utc).timestamp() - max_age_days * 86400 query = query.where( Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc) ) if published_after is not None: start = datetime.combine( published_after, datetime.min.time(), tzinfo=timezone.utc ) query = query.where(Video.published_at >= start) if published_before is not None: # Inclusive of the end day. end = datetime.combine( published_before, datetime.min.time(), tzinfo=timezone.utc ) + timedelta(days=1) query = query.where(Video.published_at < end) if q: # Title (and channel name) only — searching descriptions produced noisy matches. like = f"%{q}%" query = query.where(or_(Video.title.ilike(like), Channel.title.ilike(like))) if tags: # Group selected tags by category: AND across categories (e.g. language AND # topic narrows results), OR within a category. The any/all toggle controls # whether multiple topic tags are OR'd (any) or AND'd (all). cat_rows = db.execute( select(Tag.id, Tag.category).where(Tag.id.in_(tags)) ).all() by_category: dict[str, list[int]] = {} for tag_id, category in cat_rows: by_category.setdefault(category, []).append(tag_id) visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) for category, ids in by_category.items(): if category == "topic" and tag_mode == "and" and len(ids) > 1: sub = ( select(ChannelTag.channel_id) .where(ChannelTag.tag_id.in_(ids), visible) .group_by(ChannelTag.channel_id) .having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(ids))) ) else: sub = select(ChannelTag.channel_id).where( ChannelTag.tag_id.in_(ids), visible ) query = query.where(Video.channel_id.in_(sub)) # Watch-state visibility. if show == "unwatched": query = query.where(status_expr.notin_(("watched", "hidden"))) elif show == "watched": query = query.where(status_expr == "watched") elif show == "saved": query = query.where(status_expr == "saved") elif show == "hidden": query = query.where(status_expr == "hidden") else: # all query = query.where(status_expr != "hidden") sorts = { "newest": Video.published_at.desc().nulls_last(), "oldest": Video.published_at.asc().nulls_last(), "views": Video.view_count.desc().nulls_last(), "duration_desc": Video.duration_seconds.desc().nulls_last(), "duration_asc": Video.duration_seconds.asc().nulls_last(), "title": func.lower(Video.title).asc().nulls_last(), "subscribers": Channel.subscriber_count.desc().nulls_last(), "shuffle": func.md5(func.concat(Video.id, str(seed))), } query = query.order_by(sorts.get(sort, sorts["newest"])) rows = db.execute(query.offset(offset).limit(limit + 1)).all() has_more = len(rows) > limit items = [_serialize(r) for r in rows[:limit]] return {"items": items, "has_more": has_more, "offset": offset, "limit": limit} @router.post("/videos/{video_id}/state") def set_video_state( video_id: str, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: status = payload.get("status") if status not in VALID_STATES: raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATES}") if db.get(Video, video_id) is None: raise HTTPException(status_code=404, detail="Unknown video") row = db.execute( select(VideoState).where( VideoState.user_id == user.id, VideoState.video_id == video_id ) ).scalar_one_or_none() if status == "new": if row is not None: db.delete(row) db.commit() return {"video_id": video_id, "status": "new"} if row is None: row = VideoState(user_id=user.id, video_id=video_id) db.add(row) row.status = status row.watched_at = datetime.now(timezone.utc) if status == "watched" else row.watched_at db.commit() return {"video_id": video_id, "status": status}