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-16 09:17:14 +02:00
|
|
|
# The single shared "demo" account: signed into without Google OAuth (via a whitelisted
|
|
|
|
|
# email on the login page), has no OAuth token / YouTube scope, and its per-user state is
|
|
|
|
|
# shared by everyone who enters this way. Exactly one such row is expected.
|
|
|
|
|
is_demo: Mapped[bool] = mapped_column(
|
|
|
|
|
Boolean, default=False, server_default="false", index=True
|
|
|
|
|
)
|
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
|
|
|
|
|
|
|
|
|
2026-06-12 01:43:07 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 09:17:14 +02:00
|
|
|
class DemoWhitelist(Base):
|
|
|
|
|
"""Emails that may enter the shared demo account from the login page (no Google OAuth).
|
|
|
|
|
A hidden, admin-curated list of "keys to the same door" — every entry logs into the one
|
|
|
|
|
shared demo user. Distinct from Invite (which gates real Google sign-in)."""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "demo_whitelist"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
|
|
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
|
|
|
|
note: Mapped[str | None] = mapped_column(Text)
|
|
|
|
|
added_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
|
|
|
DateTime(timezone=True), server_default=func.now()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
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")
|
2026-06-11 23:07:09 +02:00
|
|
|
# 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"
|
|
|
|
|
)
|
2026-06-11 01:22:07 +02:00
|
|
|
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
|
|
|
|
|
)
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
# 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
|
|
|
|
|
)
|
2026-06-11 01:22:07 +02:00
|
|
|
# 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))
|
2026-06-18 03:20:17 +02:00
|
|
|
# videos.list?part=status fields (populated during enrichment); used by the
|
|
|
|
|
# maintenance/validation job to decide whether a video is playable anywhere.
|
|
|
|
|
embeddable: Mapped[bool | None] = mapped_column(Boolean)
|
|
|
|
|
privacy_status: Mapped[str | None] = mapped_column(String(16)) # public|unlisted|private
|
|
|
|
|
upload_status: Mapped[str | None] = mapped_column(String(16)) # processed|failed|rejected|deleted
|
|
|
|
|
# Maintenance/validation lifecycle. last_checked_at drives the rolling re-validation
|
|
|
|
|
# (least-recently-checked first); unavailable_since marks a video as currently
|
|
|
|
|
# unplayable (hidden from the feed immediately, hard-deleted after a grace period);
|
|
|
|
|
# unavailable_reason records why (deleted|private|paywalled|abandoned|stuck_live).
|
|
|
|
|
last_checked_at: Mapped[datetime | None] = mapped_column(
|
|
|
|
|
DateTime(timezone=True), index=True
|
|
|
|
|
)
|
|
|
|
|
unavailable_since: Mapped[datetime | None] = mapped_column(
|
|
|
|
|
DateTime(timezone=True), index=True
|
|
|
|
|
)
|
|
|
|
|
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
2026-06-11 01:22:07 +02:00
|
|
|
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))
|
2026-06-14 18:40:05 +02:00
|
|
|
# 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))
|
2026-06-11 02:11:02 +02:00
|
|
|
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")
|
2026-06-11 04:15:25 +02:00
|
|
|
|
|
|
|
|
|
2026-06-12 02:47:55 +02:00
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 04:15:25 +02:00
|
|
|
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"
|
|
|
|
|
)
|
2026-06-18 04:37:08 +02:00
|
|
|
# Admin override for the maintenance job's rolling re-validation batch (videos re-checked
|
|
|
|
|
# per run). NULL = use the config/env default (settings.maintenance_revalidate_batch).
|
|
|
|
|
maintenance_revalidate_batch: Mapped[int | None] = mapped_column(Integer)
|
2026-06-15 14:37:09 +02:00
|
|
|
|
|
|
|
|
|
2026-06-16 15:58:23 +02:00
|
|
|
class SchedulerSetting(Base):
|
|
|
|
|
"""Admin override for a scheduler job's run interval (minutes). One row per job id;
|
|
|
|
|
absent = use the env/config default. DB-backed so it's editable from the admin UI at
|
|
|
|
|
runtime without a redeploy (env stays a fallback)."""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "scheduler_settings"
|
|
|
|
|
|
|
|
|
|
job_id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
|
|
|
|
interval_minutes: Mapped[int] = mapped_column(Integer)
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 14:37:09 +02:00
|
|
|
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))
|
2026-06-15 23:00:56 +02:00
|
|
|
# True when the current items/name differ from `synced_fingerprint` (unpushed edits).
|
2026-06-15 14:37:09 +02:00
|
|
|
dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
2026-06-15 23:00:56 +02:00
|
|
|
# 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)
|
2026-06-15 14:37:09 +02:00
|
|
|
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")
|
2026-06-18 03:20:17 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class Notification(Base):
|
|
|
|
|
"""A durable, server-backed per-user notification (the inbox center, phase 1).
|
|
|
|
|
|
|
|
|
|
Distinct from the client-side transient "bell" (toasts kept only in localStorage):
|
|
|
|
|
these survive reloads and devices, and are produced server-side (first producer = the
|
|
|
|
|
maintenance/validation job's batched "N saved/playlisted videos removed" notice). The
|
|
|
|
|
column is named `data` (not `metadata`, which SQLAlchemy's declarative Base reserves):
|
|
|
|
|
a free-form JSON payload (e.g. the list of removed videos) the UI can render. `read`
|
|
|
|
|
and `dismissed` are separate: read clears the unread badge but keeps the row in the
|
|
|
|
|
inbox; dismissed removes it from the default view. Unread rows never auto-expire; read
|
|
|
|
|
ones are trimmed past a soft per-user cap (see app.notifications.trim_read)."""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "notifications"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
|
|
|
user_id: Mapped[int] = mapped_column(
|
|
|
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
|
|
|
)
|
|
|
|
|
type: Mapped[str] = mapped_column(String(32), index=True) # e.g. "maintenance"
|
|
|
|
|
title: Mapped[str] = mapped_column(String(255))
|
|
|
|
|
body: Mapped[str | None] = mapped_column(Text)
|
|
|
|
|
data: Mapped[dict | None] = mapped_column(JSON)
|
|
|
|
|
read: Mapped[bool] = mapped_column(
|
|
|
|
|
Boolean, default=False, server_default="false", index=True
|
|
|
|
|
)
|
|
|
|
|
dismissed: Mapped[bool] = mapped_column(
|
|
|
|
|
Boolean, default=False, server_default="false"
|
|
|
|
|
)
|
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
|
|
|
DateTime(timezone=True), server_default=func.now(), index=True
|
|
|
|
|
)
|