feat: M4 (part 1) — feed API, watch state, user preferences

- GET /api/feed: filter by tags (and/or), channel, duration, age, search; sort
  newest/oldest/views/duration/shuffle; default hides Shorts, live/upcoming and
  watched (was_live VODs stay visible); offset paging with has_more
- POST /api/videos/{id}/state for watched/saved/hidden/new
- User.preferences JSON + GET /api/me and PUT /api/me/preferences
- Migration 0004 (preferences column + video_states table)
This commit is contained in:
npeter83 2026-06-11 02:11:02 +02:00
parent 96f9c5a797
commit 9a377b7e7e
5 changed files with 285 additions and 1 deletions

View file

@ -0,0 +1,52 @@
"""user preferences + per-user video watch state
Revision ID: 0004_feed_state_prefs
Revises: 0003_tags
Create Date: 2026-06-11
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0004_feed_state_prefs"
down_revision: Union[str, None] = "0003_tags"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("preferences", sa.JSON(), nullable=True))
op.create_table(
"video_states",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"video_id",
sa.String(length=16),
sa.ForeignKey("videos.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("status", sa.String(length=12), nullable=False, server_default="new"),
sa.Column("watched_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint("user_id", "video_id", name="uq_user_video"),
)
op.create_index("ix_video_states_user_id", "video_states", ["user_id"])
op.create_index("ix_video_states_video_id", "video_states", ["video_id"])
def downgrade() -> None:
op.drop_table("video_states")
op.drop_column("users", "preferences")

View file

@ -9,7 +9,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app import auth from app import auth
from app.config import settings from app.config import settings
from app.routes import health, sync, tags from app.routes import feed, health, me, sync, tags
from app.scheduler import shutdown_scheduler, start_scheduler from app.scheduler import shutdown_scheduler, start_scheduler
@ -44,6 +44,8 @@ app.include_router(health.router)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(sync.router) app.include_router(sync.router)
app.include_router(tags.router) app.include_router(tags.router)
app.include_router(feed.router)
app.include_router(me.router)
STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR = Path(__file__).parent / "static"
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets") app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")

View file

@ -28,6 +28,8 @@ class User(Base):
display_name: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(255))
avatar_url: Mapped[str | None] = mapped_column(String(1024)) avatar_url: Mapped[str | None] = mapped_column(String(1024))
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
# Free-form UI preferences (theme, color scheme, font scale, default filters…).
preferences: Mapped[dict | None] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )
@ -201,6 +203,28 @@ class ChannelTag(Base):
confidence: Mapped[float | None] = mapped_column(Float) confidence: Mapped[float | None] = mapped_column(Float)
class VideoState(Base):
"""Per-user watch state for a video. Rows exist only for non-default states; the
absence of a row means 'new/unseen'."""
__tablename__ = "video_states"
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_video"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
video_id: Mapped[str] = mapped_column(
ForeignKey("videos.id", ondelete="CASCADE"), index=True
)
# new | watched | saved | hidden
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class ApiQuotaUsage(Base): class ApiQuotaUsage(Base):
"""Tracks YouTube Data API units spent per Pacific-time day (the quota resets at """Tracks YouTube Data API units spent per Pacific-time day (the quota resets at
midnight Pacific). The whole app shares one daily budget.""" midnight Pacific). The whole app shares one daily budget."""

171
backend/app/routes/feed.py Normal file
View file

@ -0,0 +1,171 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, 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, User, Video, VideoState
router = APIRouter(prefix="/api", tags=["feed"])
VALID_STATES = {"new", "watched", "saved", "hidden"}
HIDDEN_LIVE = ("live", "upcoming")
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,
"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,
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"),
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)
.outerjoin(
state, and_(state.video_id == Video.id, state.user_id == user.id)
)
)
if not include_shorts:
query = query.where(Video.is_short.is_(False))
if not include_live:
query = query.where(Video.live_status.notin_(HIDDEN_LIVE))
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 q:
like = f"%{q}%"
query = query.where(or_(Video.title.ilike(like), Video.description.ilike(like)))
if tags:
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
if tag_mode == "and":
sub = (
select(ChannelTag.channel_id)
.where(ChannelTag.tag_id.in_(tags), visible)
.group_by(ChannelTag.channel_id)
.having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(tags)))
)
else:
sub = select(ChannelTag.channel_id).where(
ChannelTag.tag_id.in_(tags), 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(),
"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}

35
backend/app/routes/me.py Normal file
View file

@ -0,0 +1,35 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.auth import current_user
from app.db import get_db
from app.models import User
router = APIRouter(prefix="/api/me", tags=["me"])
@router.get("")
def get_me(user: User = Depends(current_user)) -> dict:
return {
"id": user.id,
"email": user.email,
"display_name": user.display_name,
"avatar_url": user.avatar_url,
"role": user.role,
"preferences": user.preferences or {},
}
@router.put("/preferences")
def update_preferences(
preferences: dict,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
# Merge so partial updates don't wipe other keys.
merged = dict(user.preferences or {})
merged.update(preferences)
user.preferences = merged
db.add(user)
db.commit()
return {"preferences": merged}