2026-06-11 01:22:07 +02:00
|
|
|
from datetime import date, datetime
|
2026-06-11 01:01:37 +02:00
|
|
|
|
2026-06-11 01:22:07 +02:00
|
|
|
from sqlalchemy import (
|
|
|
|
|
BigInteger,
|
|
|
|
|
Boolean,
|
|
|
|
|
Date,
|
|
|
|
|
DateTime,
|
2026-06-11 01:57:19 +02:00
|
|
|
Float,
|
2026-06-11 01:22:07 +02:00
|
|
|
ForeignKey,
|
|
|
|
|
Integer,
|
|
|
|
|
JSON,
|
|
|
|
|
String,
|
|
|
|
|
Text,
|
|
|
|
|
UniqueConstraint,
|
|
|
|
|
func,
|
|
|
|
|
)
|
2026-06-11 01:01:37 +02:00
|
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
|
|
|
|
|
|
from app.db import Base
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class User(Base):
|
|
|
|
|
__tablename__ = "users"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
|
|
|
google_sub: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
|
|
|
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
|
|
|
|
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")
|
2026-06-11 02:11:02 +02:00
|
|
|
# Free-form UI preferences (theme, color scheme, font scale, default filters…).
|
|
|
|
|
preferences: Mapped[dict | None] = mapped_column(JSON)
|
2026-06-11 01:01:37 +02:00
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
|
|
|
DateTime(timezone=True), server_default=func.now()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
token: Mapped["OAuthToken | None"] = relationship(
|
|
|
|
|
back_populates="user", uselist=False, cascade="all, delete-orphan"
|
|
|
|
|
)
|
2026-06-11 01:22:07 +02:00
|
|
|
subscriptions: Mapped[list["Subscription"]] = relationship(
|
|
|
|
|
back_populates="user", cascade="all, delete-orphan"
|
|
|
|
|
)
|
2026-06-11 01:01:37 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class OAuthToken(Base):
|
|
|
|
|
__tablename__ = "oauth_tokens"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
|
|
|
user_id: Mapped[int] = mapped_column(
|
|
|
|
|
ForeignKey("users.id", ondelete="CASCADE"), unique=True
|
|
|
|
|
)
|
|
|
|
|
# Refresh token encrypted at rest (Fernet). Access token is short-lived and low risk.
|
|
|
|
|
refresh_token_enc: Mapped[str | None] = mapped_column(String)
|
|
|
|
|
access_token: Mapped[str | None] = mapped_column(String)
|
|
|
|
|
expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
|
scopes: Mapped[str | None] = mapped_column(String)
|
|
|
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
|
|
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
user: Mapped["User"] = relationship(back_populates="token")
|
2026-06-11 01:22:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class Channel(Base):
|
|
|
|
|
"""A YouTube channel. Shared across all users (one channel's videos are the same
|
|
|
|
|
for everyone), so its expensive metadata is fetched and stored only once."""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "channels"
|
|
|
|
|
|
|
|
|
|
id: Mapped[str] = mapped_column(String(32), primary_key=True) # "UC..." channel id
|
|
|
|
|
title: Mapped[str | None] = mapped_column(String(255))
|
|
|
|
|
handle: Mapped[str | None] = mapped_column(String(255))
|
|
|
|
|
description: Mapped[str | None] = mapped_column(Text)
|
|
|
|
|
thumbnail_url: Mapped[str | None] = mapped_column(String(1024))
|
|
|
|
|
uploads_playlist_id: Mapped[str | None] = mapped_column(String(64))
|
|
|
|
|
subscriber_count: Mapped[int | None] = mapped_column(BigInteger)
|
|
|
|
|
video_count: Mapped[int | None] = mapped_column(BigInteger)
|
|
|
|
|
topic_categories: Mapped[list | None] = mapped_column(JSON)
|
|
|
|
|
default_language: Mapped[str | None] = mapped_column(String(16))
|
|
|
|
|
country: Mapped[str | None] = mapped_column(String(8))
|
|
|
|
|
|
|
|
|
|
# Sync bookkeeping.
|
|
|
|
|
details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
|
recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
|
last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
|
backfill_cursor: Mapped[str | None] = mapped_column(String(128))
|
|
|
|
|
backfill_done: Mapped[bool] = mapped_column(
|
|
|
|
|
Boolean, default=False, server_default="false"
|
|
|
|
|
)
|
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
|
|
|
DateTime(timezone=True), server_default=func.now()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
videos: Mapped[list["Video"]] = relationship(back_populates="channel")
|
|
|
|
|
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Subscription(Base):
|
|
|
|
|
"""Per-user link to a channel, with the user's own priority/visibility overrides."""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "subscriptions"
|
|
|
|
|
__table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_channel"),)
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
|
|
|
user_id: Mapped[int] = mapped_column(
|
|
|
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
|
|
|
)
|
|
|
|
|
channel_id: Mapped[str] = mapped_column(
|
|
|
|
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
|
|
|
|
)
|
|
|
|
|
# YouTube's subscription resource id, needed to unsubscribe via the API.
|
|
|
|
|
yt_subscription_id: Mapped[str | None] = mapped_column(String(128))
|
|
|
|
|
priority: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
|
|
|
hidden: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
|
|
|
subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
|
|
|
DateTime(timezone=True), server_default=func.now()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
user: Mapped["User"] = relationship(back_populates="subscriptions")
|
|
|
|
|
channel: Mapped["Channel"] = relationship(back_populates="subscriptions")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Video(Base):
|
|
|
|
|
"""A YouTube video. Shared across users; per-user state lives elsewhere."""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "videos"
|
|
|
|
|
|
|
|
|
|
id: Mapped[str] = mapped_column(String(16), primary_key=True) # video id
|
|
|
|
|
channel_id: Mapped[str] = mapped_column(
|
|
|
|
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
|
|
|
|
)
|
|
|
|
|
title: Mapped[str | None] = mapped_column(Text)
|
|
|
|
|
description: Mapped[str | None] = mapped_column(Text)
|
|
|
|
|
published_at: Mapped[datetime | None] = mapped_column(
|
|
|
|
|
DateTime(timezone=True), index=True
|
|
|
|
|
)
|
|
|
|
|
thumbnail_url: Mapped[str | None] = mapped_column(String(1024))
|
|
|
|
|
duration_seconds: Mapped[int | None] = mapped_column(Integer)
|
|
|
|
|
view_count: Mapped[int | None] = mapped_column(BigInteger)
|
|
|
|
|
like_count: Mapped[int | None] = mapped_column(BigInteger)
|
|
|
|
|
category_id: Mapped[int | None] = mapped_column(Integer)
|
|
|
|
|
topic_categories: Mapped[list | None] = mapped_column(JSON)
|
|
|
|
|
default_language: Mapped[str | None] = mapped_column(String(16))
|
|
|
|
|
detected_language: Mapped[str | None] = mapped_column(String(16))
|
|
|
|
|
is_short: Mapped[bool] = mapped_column(
|
|
|
|
|
Boolean, default=False, server_default="false", index=True
|
|
|
|
|
)
|
|
|
|
|
# none | live | upcoming | premiere | was_live
|
|
|
|
|
live_status: Mapped[str] = mapped_column(
|
|
|
|
|
String(16), default="none", server_default="none", index=True
|
|
|
|
|
)
|
|
|
|
|
enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
|
|
|
DateTime(timezone=True), server_default=func.now()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
channel: Mapped["Channel"] = relationship(back_populates="videos")
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 01:57:19 +02:00
|
|
|
class Tag(Base):
|
|
|
|
|
"""A label. System tags (user_id NULL) are computed automatically and shared by all
|
|
|
|
|
users; user tags (user_id set) are personal. category groups them in the UI."""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "tags"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
|
|
|
user_id: Mapped[int | None] = mapped_column(
|
|
|
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
|
|
|
)
|
|
|
|
|
name: Mapped[str] = mapped_column(String(64))
|
|
|
|
|
color: Mapped[str | None] = mapped_column(String(16))
|
|
|
|
|
# language | topic | other
|
|
|
|
|
category: Mapped[str] = mapped_column(
|
|
|
|
|
String(16), default="other", server_default="other"
|
|
|
|
|
)
|
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
|
|
|
DateTime(timezone=True), server_default=func.now()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ChannelTag(Base):
|
|
|
|
|
"""Links a tag to a channel. source='auto' rows are managed by the auto-tagger and
|
|
|
|
|
regenerated freely; source='user' rows are never touched by the system."""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "channel_tags"
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
UniqueConstraint("channel_id", "tag_id", "user_id", name="uq_channel_tag"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
|
|
|
channel_id: Mapped[str] = mapped_column(
|
|
|
|
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
|
|
|
|
)
|
|
|
|
|
tag_id: Mapped[int] = mapped_column(
|
|
|
|
|
ForeignKey("tags.id", ondelete="CASCADE"), index=True
|
|
|
|
|
)
|
|
|
|
|
# NULL = system/auto link visible to everyone; set = a specific user's link.
|
|
|
|
|
user_id: Mapped[int | None] = mapped_column(
|
|
|
|
|
ForeignKey("users.id", ondelete="CASCADE")
|
|
|
|
|
)
|
|
|
|
|
source: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto")
|
|
|
|
|
confidence: Mapped[float | None] = mapped_column(Float)
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 02:11:02 +02:00
|
|
|
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()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 01:22:07 +02:00
|
|
|
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."""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "api_quota_usage"
|
|
|
|
|
|
|
|
|
|
day: Mapped[date] = mapped_column(Date, primary_key=True)
|
|
|
|
|
units_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|