from datetime import date, datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import Select, and_, false, func, or_, select from sqlalchemy.orm import Session, aliased from app import quota from app.auth import current_user from app.db import get_db from app.models import Channel, ChannelTag, Subscription, Tag, User, Video, VideoState from app.sync.videos import parse_iso8601_duration from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api", tags=["feed"]) VALID_STATES = {"new", "watched", "saved", "hidden"} HIDDEN_LIVE = ("live", "upcoming") # Resume-position thresholds (mirror the client): positions below this are "didn't # really start" and within this of the end are "basically finished" — neither is worth # storing, so we clear the position instead (the video shows no progress bar). PROGRESS_MIN_SECONDS = 5 FINISH_MARGIN_SECONDS = 10 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", "position_seconds": row.position_seconds or 0, "watch_url": f"https://www.youtube.com/watch?v={row.id}", } def _filtered_query( db: Session, user: User, *, tags: list[int], tag_mode: str, channel_id: str | None, q: str | None, min_duration: int | None, max_duration: int | None, max_age_days: int | None, published_after: date | None, published_before: date | None, show_normal: bool, include_shorts: bool, include_live: bool, show: str, ) -> tuple[Select, object]: """Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count. Returns the column-bearing select plus the watch-status expression for sorting.""" state = aliased(VideoState) status_expr = func.coalesce(state.status, "new") position_expr = func.coalesce(state.position_seconds, 0) 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.label("status"), position_expr.label("position_seconds"), ) .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)) ) 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: end = datetime.combine( published_before, datetime.min.time(), tzinfo=timezone.utc ) + timedelta(days=1) query = query.where(Video.published_at < end) if q: like = f"%{q}%" query = query.where(or_(Video.title.ilike(like), Channel.title.ilike(like))) if tags: # AND across tag categories (e.g. language AND topic narrows), OR within a # category; the any/all toggle controls multiple topic tags. 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)) # Content type: Normal / Shorts / Live·Upcoming as a union of enabled types. # 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 show == "unwatched": query = query.where(status_expr.notin_(("watched", "hidden"))) elif show == "in_progress": # Started but not finished: a stored resume position, not yet watched/hidden. query = query.where( and_(position_expr > 0, 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") return query, status_expr # Shared query parameters for /feed and /feed/count. def _feed_params( 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", ) -> dict: return { "tags": tags, "tag_mode": tag_mode, "channel_id": channel_id, "q": q, "min_duration": min_duration, "max_duration": max_duration, "max_age_days": max_age_days, "published_after": published_after, "published_before": published_before, "show_normal": show_normal, "include_shorts": include_shorts, "include_live": include_live, "show": show, } 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(), # Your per-channel priority (set in the channel manager), newest first within a tier. "priority": Subscription.priority.desc(), } @router.get("/feed") def get_feed( params: dict = Depends(_feed_params), sort: str = "newest", seed: int = 0, limit: int = Query(default=60, le=200), offset: int = 0, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: query, _status = _filtered_query(db, user, **params) if sort == "priority": query = query.order_by( Subscription.priority.desc(), Video.published_at.desc().nulls_last() ) else: order = SORTS.get(sort) if order is None and sort == "shuffle": order = func.md5(func.concat(Video.id, str(seed))) query = query.order_by(order if order is not None else SORTS["newest"]) rows = db.execute(query.offset(offset).limit(limit + 1)).all() has_more = len(rows) > limit return { "items": [_serialize(r) for r in rows[:limit]], "has_more": has_more, "offset": offset, "limit": limit, } @router.get("/feed/count") def get_feed_count( params: dict = Depends(_feed_params), user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: query, _status = _filtered_query(db, user, **params) total = db.scalar(select(func.count()).select_from(query.subquery())) return {"count": total or 0} @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: # Keep the row if it still holds a resume position (un-marking "watched" # should restore the in-progress state, not wipe where the user left off). if row.position_seconds: row.status = "new" row.watched_at = None else: 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} @router.post("/videos/{video_id}/progress") def set_video_progress( video_id: str, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Checkpoint the in-app player's resume position (called periodically while playing). Stores a per-user position on the video_states row without touching watch status, so a partially-watched video can render a progress bar and match the "in progress" filter. Trivially-early and near-finished positions clear the position rather than store it.""" try: position = int(payload.get("position_seconds") or 0) duration = int(payload.get("duration_seconds") or 0) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="position_seconds must be an integer") if position < 0: position = 0 if db.get(Video, video_id) is None: raise HTTPException(status_code=404, detail="Unknown video") # Decide whether this position is worth keeping (else clear it). near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS keep = position >= PROGRESS_MIN_SECONDS and not near_end row = db.execute( select(VideoState).where( VideoState.user_id == user.id, VideoState.video_id == video_id ) ).scalar_one_or_none() if not keep: # Nothing meaningful to store: drop a status-less row entirely, otherwise just # zero the position (a saved/hidden video keeps its status). if row is not None: if row.status == "new": db.delete(row) else: row.position_seconds = 0 row.progress_updated_at = datetime.now(timezone.utc) db.commit() return {"video_id": video_id, "position_seconds": 0} if row is None: row = VideoState(user_id=user.id, video_id=video_id) db.add(row) row.position_seconds = position row.progress_updated_at = datetime.now(timezone.utc) db.commit() return {"video_id": video_id, "position_seconds": position} @router.get("/videos/{video_id}") def get_video_detail( video_id: str, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """On-demand detail (description, like count) — kept out of the feed list payload so the feed stays lean; fetched lazily, e.g. for the title hover popover. Videos we already store are served from the DB for free. A video that isn't in our DB (e.g. a YouTube link inside another video's description that the in-app player navigated to) is resolved via the YouTube API (videos.list, 1 unit, attributed to the requesting user).""" v = db.get(Video, video_id) if v is not None: return { "id": v.id, "description": v.description, "like_count": v.like_count, "in_db": True, "channel_id": v.channel_id, "channel_title": v.channel.title if v.channel else None, "published_at": v.published_at.isoformat() if v.published_at else None, "view_count": v.view_count, "duration_seconds": v.duration_seconds, } try: with quota.attribute(user.id, "video_lookup"), YouTubeClient(db, user) as yt: items = yt.get_videos([video_id]) except YouTubeError as exc: raise HTTPException(status_code=502, detail=f"YouTube lookup failed: {exc}") if not items: raise HTTPException(status_code=404, detail="Unknown video") snippet = items[0].get("snippet", {}) stats = items[0].get("statistics", {}) likes = stats.get("likeCount") views = stats.get("viewCount") return { "id": video_id, "description": snippet.get("description"), "like_count": int(likes) if likes is not None else None, "in_db": False, "channel_id": snippet.get("channelId"), "channel_title": snippet.get("channelTitle"), "published_at": snippet.get("publishedAt"), "view_count": int(views) if views is not None else None, "duration_seconds": parse_iso8601_duration( items[0].get("contentDetails", {}).get("duration") ), }