Store a SHA-256 of each playlist's name + ordered video ids as last synced from / pushed to YouTube (migration 0013, backfilled for already-clean linked playlists). 'dirty' is now recomputed on every edit by comparing the current fingerprint to that baseline instead of being stickily set true. So manually restoring the original order (or undo back to it) clears dirty on its own — the Reset to YouTube button disappears and no YouTube read is needed. Baseline is set on read-sync, repull (reset), and a clean push; a missing baseline counts as dirty (unknown YT state). Verified the migration's fingerprint matches the app's runtime fingerprint exactly.
368 lines
15 KiB
Python
368 lines
15 KiB
Python
from datetime import date, datetime
|
|
|
|
from sqlalchemy import (
|
|
BigInteger,
|
|
Boolean,
|
|
Date,
|
|
DateTime,
|
|
Float,
|
|
ForeignKey,
|
|
Integer,
|
|
JSON,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
func,
|
|
)
|
|
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")
|
|
# 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()
|
|
)
|
|
|
|
token: Mapped["OAuthToken | None"] = relationship(
|
|
back_populates="user", uselist=False, cascade="all, delete-orphan"
|
|
)
|
|
subscriptions: Mapped[list["Subscription"]] = relationship(
|
|
back_populates="user", cascade="all, delete-orphan"
|
|
)
|
|
|
|
|
|
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")
|
|
|
|
|
|
class Invite(Base):
|
|
"""Access-request / whitelist row. Source of truth for who may sign in; the env
|
|
ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed)."""
|
|
|
|
__tablename__ = "invites"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
|
# pending | approved | denied
|
|
status: Mapped[str] = mapped_column(
|
|
String(16), default="pending", server_default="pending"
|
|
)
|
|
note: Mapped[str | None] = mapped_column(Text)
|
|
requested_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
|
|
|
|
|
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")
|
|
# Per-user opt-in to full-history (deep) backfill of this channel. Default off so a
|
|
# new user's unique channels don't trigger a big shared-quota burst; the deep
|
|
# scheduler only picks up channels at least one user has requested.
|
|
deep_requested: 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
|
|
)
|
|
# Whether the youtube.com/shorts/<id> probe has run for this video.
|
|
shorts_probed: 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")
|
|
|
|
|
|
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)
|
|
|
|
|
|
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))
|
|
# Resume position (seconds into the video) for the in-app player. Orthogonal to
|
|
# status: a partially-watched video keeps status "new" but a non-zero position, so
|
|
# it can render a progress bar and match the "in progress" feed filter. 0 = not
|
|
# started / finished (trivially-early and near-finished positions are not stored).
|
|
position_seconds: Mapped[int] = mapped_column(
|
|
Integer, default=0, server_default="0", nullable=False
|
|
)
|
|
progress_updated_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."""
|
|
|
|
__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")
|
|
|
|
|
|
class QuotaEvent(Base):
|
|
"""Per-spend audit log for YouTube API quota attribution. `user_id` is the end-user
|
|
whose action caused the spend; NULL means background/system work (scheduler backfill,
|
|
enrichment, resync). The aggregate budget still lives in ApiQuotaUsage; this is detail."""
|
|
|
|
__tablename__ = "quota_events"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
# SET NULL on user delete: keep the audit trail (it just becomes system-attributed).
|
|
user_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
|
)
|
|
action: Mapped[str] = mapped_column(String(40), index=True)
|
|
units: Mapped[int] = mapped_column(Integer)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), index=True
|
|
)
|
|
|
|
|
|
class AppState(Base):
|
|
"""Single-row global app state (admin-controlled)."""
|
|
|
|
__tablename__ = "app_state"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True, default=1)
|
|
sync_paused: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false"
|
|
)
|
|
|
|
|
|
class Playlist(Base):
|
|
"""A per-user named, ordered collection of videos from the shared catalog.
|
|
|
|
`kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user).
|
|
`source`: 'local' (created here) or 'youtube' (mirrored from the user's YouTube
|
|
playlists). `yt_playlist_id` links a mirrored/exported playlist to its YouTube id.
|
|
`dirty` marks a YouTube-linked playlist with local edits not yet pushed, so the read
|
|
sync won't clobber them. (source/yt_playlist_id/dirty are forward-looking for the YT
|
|
sync phases; the foundation phase only uses local playlists.)"""
|
|
|
|
__tablename__ = "playlists"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
)
|
|
name: Mapped[str] = mapped_column(String(255))
|
|
kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
|
source: Mapped[str] = mapped_column(
|
|
String(16), default="local", server_default="local"
|
|
)
|
|
yt_playlist_id: Mapped[str | None] = mapped_column(String(64))
|
|
# True when the current items/name differ from `synced_fingerprint` (unpushed edits).
|
|
dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
# SHA-256 of name + ordered video ids as last synced from / pushed to YouTube; the
|
|
# baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty).
|
|
synced_fingerprint: Mapped[str | None] = mapped_column(String)
|
|
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
items: Mapped[list["PlaylistItem"]] = relationship(
|
|
back_populates="playlist",
|
|
cascade="all, delete-orphan",
|
|
order_by="PlaylistItem.position",
|
|
)
|
|
|
|
|
|
class PlaylistItem(Base):
|
|
"""A video's membership in a playlist, with its order within that playlist."""
|
|
|
|
__tablename__ = "playlist_items"
|
|
__table_args__ = (
|
|
UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
playlist_id: Mapped[int] = mapped_column(
|
|
ForeignKey("playlists.id", ondelete="CASCADE"), index=True
|
|
)
|
|
video_id: Mapped[str] = mapped_column(
|
|
ForeignKey("videos.id", ondelete="CASCADE"), index=True
|
|
)
|
|
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
added_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
|
|
playlist: Mapped["Playlist"] = relationship(back_populates="items")
|