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

@ -28,6 +28,8 @@ class User(Base):
display_name: Mapped[str | None] = mapped_column(String(255))
avatar_url: Mapped[str | None] = mapped_column(String(1024))
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(
DateTime(timezone=True), server_default=func.now()
)
@ -201,6 +203,28 @@ class ChannelTag(Base):
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):
"""Tracks YouTube Data API units spent per Pacific-time day (the quota resets at
midnight Pacific). The whole app shares one daily budget."""