Append-only who/what/when trail for admin actions + scheduler run summaries, generalizing QuotaEvent. Logged at ACTION / run-summary granularity — never per-row (a scheduler run touching thousands of videos is ONE row). Backend: - migration 0054_audit_log + AuditLog model (actor_id FK SET NULL, action, target_type/id, summary, before/after JSON, created_at). - app/audit.py: AuditAction constants (single source of truth, i18n'd on the client) + record() (adds to the caller's txn) + gc(retention_days). - producers wired: role change / suspend / delete / invite approve-deny-add / demo add-remove-reset / discovery purge (admin.py); config set/reset — secret VALUES never logged, key only (config.py); scheduler interval + maintenance tune (scheduler routes); sync pause/resume (sync.py); and the scheduler _job() choke point writes ONE run-summary row per run — manual runs + errors always, automatic runs only when they changed something (no no-op-poll spam). - retention: audit_gc scheduler job prunes rows older than audit_retention_days (sysconfig, default 180; 0 = keep forever). - admin API routes/audit.py (/api/admin/audit): recent-window list (actor emails resolved, System for background) + clear (leaves one tamper-evidence row). Frontend: - new admin-only "Audit log" nav page (ScrollText) using the shared DataTable (first admin page to reuse it) — sort/filter/paginate/persist, action select filter, actor text filter, before→after detail, Clear-all with confirm. - AuditLog.tsx + auditColumns.tsx + api.adminAudit/clearAudit + AuditRow type. - trilingual audit + auditActions namespaces (HU/EN/DE) + nav + config-group + scheduler job labels; Audit config group (retention days).
1236 lines
61 KiB
Python
1236 lines
61 KiB
Python
from datetime import date, datetime
|
|
|
|
from sqlalchemy import (
|
|
BigInteger,
|
|
Boolean,
|
|
Computed,
|
|
Date,
|
|
DateTime,
|
|
Float,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
JSON,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
func,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.db import Base
|
|
|
|
|
|
class TimestampMixin:
|
|
"""Adds a server-defaulted `created_at`. Models that index created_at or order it
|
|
differently (QuotaEvent, Notification, Message) keep their own column on purpose."""
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now()
|
|
)
|
|
|
|
|
|
class UpdatedAtMixin:
|
|
"""Adds an `updated_at` that server-defaults on insert and bumps on every UPDATE."""
|
|
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
|
|
class User(Base, TimestampMixin):
|
|
__tablename__ = "users"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
# NULL for an email+password account that hasn't linked Google yet (Postgres allows
|
|
# multiple NULLs under a unique index). Set on Google sign-in / in-app linking.
|
|
google_sub: Mapped[str | None] = mapped_column(String(64), unique=True, index=True)
|
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
|
# argon2id hash for email+password login; NULL for Google-only / demo accounts.
|
|
password_hash: Mapped[str | None] = mapped_column(String(255))
|
|
# Email ownership proven (Google sign-in is implicitly verified; password registration
|
|
# verifies via a link). Login requires this AND is_active.
|
|
email_verified: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false"
|
|
)
|
|
# Admin-approved & enabled. A pending password registration starts inactive; admin
|
|
# approval activates it. A deactivated account can't log in.
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
|
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
|
|
# account can't sign in by any method and its live sessions are rejected; the user is told
|
|
# why and pointed at the operator's contact. The demo account is never suspendable.
|
|
is_suspended: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false"
|
|
)
|
|
# Server-side session revocation (SA4): a monotonic counter bumped on password reset, password
|
|
# change, or "log out everywhere". A signed session cookie records the epoch it was minted under;
|
|
# current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those
|
|
# events (client-side signed cookies otherwise stay valid until they expire).
|
|
session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
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")
|
|
# The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and
|
|
# cached. Used to exclude their own channel from playlist discovery. NULL = unresolved.
|
|
yt_channel_id: Mapped[str | None] = mapped_column(String(32))
|
|
# 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
|
|
)
|
|
# Free-form UI preferences (theme, color scheme, font scale, default filters…).
|
|
preferences: Mapped[dict | None] = mapped_column(JSON)
|
|
|
|
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, UpdatedAtMixin):
|
|
__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)
|
|
|
|
user: Mapped["User"] = relationship(back_populates="token")
|
|
|
|
|
|
class AuthToken(Base, TimestampMixin):
|
|
"""Single-use, expiring token for email verification or password reset. Only the SHA-256
|
|
HASH of the token is stored, so a DB leak can't be used to verify/reset; the raw token
|
|
lives only in the emailed link. Consumed (used_at set) on first valid use."""
|
|
|
|
__tablename__ = "auth_tokens"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
)
|
|
kind: Mapped[str] = mapped_column(String(16)) # "verify" | "reset"
|
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
|
|
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 DemoWhitelist(Base, TimestampMixin):
|
|
"""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
|
|
|
|
|
|
class Channel(Base, TimestampMixin):
|
|
"""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)
|
|
# Channel "About" metadata (fetched with the same channels.list call, no extra quota):
|
|
# lifetime view count, the channel's join date, banner image, and external links.
|
|
total_view_count: Mapped[int | None] = mapped_column(BigInteger)
|
|
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
banner_url: Mapped[str | None] = mapped_column(String(1024))
|
|
external_links: Mapped[list | None] = mapped_column(JSON)
|
|
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"
|
|
)
|
|
# True when this channel only entered the catalog because a live YouTube search
|
|
# surfaced one of its videos (nobody subscribes to it / it has no organic upload sync).
|
|
# Lets the Library hide search-only "noise" by default.
|
|
from_search: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false", index=True
|
|
)
|
|
# True when this channel exists only because a user EXPLORED it (opened its page without
|
|
# subscribing) — an ephemeral, per-explorer addition. The cleanup job hard-deletes such a
|
|
# channel (and its via_explore videos) once nobody keeps it (no subscription, no live
|
|
# ExploredChannel row, no per-user state) after a grace period. Cleared when someone
|
|
# subscribes (the "keep" signal promotes it to organic).
|
|
from_explore: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false", index=True
|
|
)
|
|
|
|
videos: Mapped[list["Video"]] = relationship(back_populates="channel")
|
|
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
|
|
|
|
|
|
class Subscription(Base, TimestampMixin):
|
|
"""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))
|
|
|
|
user: Mapped["User"] = relationship(back_populates="subscriptions")
|
|
channel: Mapped["Channel"] = relationship(back_populates="subscriptions")
|
|
|
|
|
|
# Video.live_status values that mean "currently a live/upcoming broadcast" — transient states
|
|
# hidden from feeds/search and revisited by the refresh job until they settle into a VOD.
|
|
LIVE_OR_UPCOMING = ("live", "upcoming")
|
|
|
|
|
|
class Video(Base, TimestampMixin):
|
|
"""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) # normalized for display (see app.titles)
|
|
original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved
|
|
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))
|
|
# Creator-supplied keyword tags (snippet.tags joined by spaces) — fetched for free with the
|
|
# snippet we already request. Indexed into search_vector so the feed search matches the
|
|
# uploader's own keywords, not just words in the title.
|
|
keywords: Mapped[str | None] = mapped_column(Text)
|
|
# Accumulated distinct live-search queries that surfaced this video (across all users) —
|
|
# folds YouTube's own relevance judgement into the local search: a video YouTube returned
|
|
# for "ti amo magyarul" becomes findable by that query locally even if its title lacks it.
|
|
search_terms: Mapped[str | None] = mapped_column(Text)
|
|
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
|
|
)
|
|
# True when this video first entered the catalog via a live YouTube search (not an
|
|
# organic channel/playlist sync). Used to hide search-discovered "noise" from the
|
|
# Library by default; left False by the organic ingest paths.
|
|
via_search: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false", index=True
|
|
)
|
|
# True when this video entered the catalog because a user EXPLORED its (un-subscribed)
|
|
# channel. Like via_search it's hidden from the shared Library by default, but it's
|
|
# additionally PER-USER private: a via_explore video is only visible to users who
|
|
# explored or subscribe to its channel (a gate in the feed query), so one user's
|
|
# curiosity never floods everyone else's catalog. Left False by the organic ingest paths.
|
|
via_explore: 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))
|
|
# 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))
|
|
|
|
# Weighted full-text search document (DB-generated, never written by the app): title (A) >
|
|
# creator keywords + search queries (B) > truncated description (C). ts_rank honours the
|
|
# weights so title matches outrank description matches. Backed by a GIN index (migration
|
|
# 0032). The accent-insensitive `unaccent_simple` config comes from migration 0031.
|
|
search_vector: Mapped[object | None] = mapped_column(
|
|
TSVECTOR,
|
|
Computed(
|
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(keywords, '') || ' ' || "
|
|
"coalesce(search_terms, '')), 'B') || "
|
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(description, ''), 1000)), 'C')",
|
|
persisted=True,
|
|
),
|
|
)
|
|
|
|
channel: Mapped["Channel"] = relationship(back_populates="videos")
|
|
|
|
|
|
class Tag(Base, TimestampMixin):
|
|
"""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"
|
|
)
|
|
|
|
|
|
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, UpdatedAtMixin):
|
|
"""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 | hidden ("saved" is now Watch-later playlist membership, not a status)
|
|
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))
|
|
|
|
|
|
class SearchFind(Base, TimestampMixin):
|
|
"""Per-user record of a video the user surfaced via their own live YouTube search.
|
|
|
|
Lets "videos I searched for" appear in that user's own (Mine) feed and the Mine Source
|
|
filter — distinct from the GLOBAL `Video.via_search` provenance, which marks a video as
|
|
search-discovered by *anyone* and drives the shared Library's Source filter."""
|
|
|
|
__tablename__ = "search_finds"
|
|
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_search_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
|
|
)
|
|
|
|
|
|
class ExploredChannel(Base, TimestampMixin):
|
|
"""Per-user record of a channel the user opened the page of WITHOUT subscribing.
|
|
|
|
Exploring an un-subscribed channel ingests its recent uploads (flagged via_explore) so the
|
|
user can browse them, but keeps them out of everyone else's catalog. This row (a) gates the
|
|
explorer's access to those ephemeral videos in the feed query and (b) drives the grace-period
|
|
purge: a from_explore channel with no live ExploredChannel row (and no subscription / no
|
|
per-user state on its videos) is hard-deleted by the cleanup job. Subscribing is the "keep"
|
|
signal — it promotes the channel to organic and these rows become irrelevant. `created_at`
|
|
(from TimestampMixin) is the grace clock; a re-visit refreshes it."""
|
|
|
|
__tablename__ = "explored_channels"
|
|
__table_args__ = (
|
|
UniqueConstraint("user_id", "channel_id", name="uq_user_explored_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
|
|
)
|
|
|
|
|
|
class BlockedChannel(Base, TimestampMixin):
|
|
"""Per-user channel blocklist. Videos from a blocked channel are excluded from this user's
|
|
live YouTube search (and not ingested on their behalf) and hidden from their feed / explore
|
|
views — a personal "never show me this channel again" that doesn't affect other users."""
|
|
|
|
__tablename__ = "blocked_channels"
|
|
__table_args__ = (
|
|
UniqueConstraint("user_id", "channel_id", name="uq_user_blocked_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
|
|
)
|
|
|
|
|
|
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 AuditLog(Base):
|
|
"""Append-only admin/scheduler audit trail: who did what, when, and (where it applies)
|
|
the before->after values. `actor_id` is the acting admin; NULL means the scheduler /
|
|
background did it (SET NULL on user delete keeps the trail). Logged at ACTION / job-run
|
|
granularity — never per-row — so a scheduler run that touches thousands of videos is ONE
|
|
row summarising the outcome. `before`/`after` are small JSON snapshots (secrets excluded)."""
|
|
|
|
__tablename__ = "audit_log"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
actor_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
|
)
|
|
action: Mapped[str] = mapped_column(String(48), index=True)
|
|
target_type: Mapped[str | None] = mapped_column(String(32))
|
|
target_id: Mapped[str | None] = mapped_column(String(128))
|
|
summary: Mapped[str | None] = mapped_column(String(255))
|
|
before: Mapped[dict | None] = mapped_column(JSON)
|
|
after: Mapped[dict | None] = mapped_column(JSON)
|
|
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"
|
|
)
|
|
# 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)
|
|
# First-run install wizard. `configured` is False on a fresh instance (the app runs in
|
|
# setup mode: only the wizard works) and flipped True when the wizard finishes. While
|
|
# unconfigured, a one-time setup token (only its SHA-256 hash stored) gates the wizard;
|
|
# the raw token + URL are printed to the container logs on each startup.
|
|
configured: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false"
|
|
)
|
|
setup_token_hash: Mapped[str | None] = mapped_column(String(64))
|
|
|
|
|
|
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)
|
|
|
|
|
|
class SystemConfig(Base):
|
|
"""Admin-set overrides for operational config that otherwise comes from env/config
|
|
defaults. Generic key/value store (one row per key); absent = use the env/config
|
|
default. DB-backed so it's editable from the admin Configuration page at runtime without
|
|
a redeploy. Secret values (e.g. SMTP password) are stored Fernet-encrypted (`encrypted`
|
|
flag) using TOKEN_ENCRYPTION_KEY; non-secrets are plaintext. The set of known keys + their
|
|
types/groups/defaults lives in app.sysconfig (the single source of truth)."""
|
|
|
|
__tablename__ = "system_config"
|
|
|
|
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
value: Mapped[str | None] = mapped_column(Text)
|
|
encrypted: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
|
|
class Playlist(Base, TimestampMixin, UpdatedAtMixin):
|
|
"""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")
|
|
|
|
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")
|
|
|
|
|
|
class SavedView(Base, TimestampMixin, UpdatedAtMixin):
|
|
"""A per-user named snapshot of the feed's filter/sort state (a "smart view").
|
|
|
|
`filters` is the serialized FeedFilters JSON the frontend applies with one click.
|
|
`position` orders them in the sidebar (drag-reorder); `is_default` marks the one view
|
|
applied automatically on load — at most one per user, enforced by the API on set."""
|
|
|
|
__tablename__ = "saved_views"
|
|
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_user_saved_view"),)
|
|
|
|
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(80))
|
|
filters: Mapped[dict] = mapped_column(JSON)
|
|
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
is_default: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false"
|
|
)
|
|
|
|
|
|
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
|
|
)
|
|
|
|
|
|
class Message(Base):
|
|
"""A message in the notification module (phase 2).
|
|
|
|
Two kinds share this table:
|
|
- `kind="user"`: a private, END-TO-END ENCRYPTED direct message between two users. The
|
|
server only ever stores `ciphertext` + `iv` (base64); it never holds the plaintext or
|
|
the keys, so not even an admin can read it. A conversation is just the set of messages
|
|
between two users — no separate thread entity; queries group by the {sender, recipient}
|
|
pair.
|
|
- `kind="system"`: a server-authored plaintext message (e.g. the welcome, or future admin
|
|
announcements). `sender_id` is NULL and `body` holds the text. These are non-private
|
|
boilerplate, readable without any E2EE key setup, and surface as a "Siftlode" conversation.
|
|
|
|
`read_at` is stamped when the recipient opens the thread (NULL = unread, feeds the nav
|
|
badge). The recipient FK cascades (and sender, for user messages) so a GDPR account erasure
|
|
removes that user's messages outright."""
|
|
|
|
__tablename__ = "messages"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
kind: Mapped[str] = mapped_column(
|
|
String(16), default="user", server_default="user"
|
|
)
|
|
# NULL for system messages (no human sender).
|
|
sender_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
)
|
|
recipient_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
)
|
|
# System messages carry plaintext `body`; user messages carry `ciphertext`+`iv` (base64).
|
|
body: Mapped[str | None] = mapped_column(Text)
|
|
ciphertext: Mapped[str | None] = mapped_column(Text)
|
|
iv: Mapped[str | None] = mapped_column(String(32))
|
|
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), index=True
|
|
)
|
|
|
|
|
|
class MediaAsset(Base, TimestampMixin):
|
|
"""A physical downloaded media file — the SHARED cache layer of the download center.
|
|
|
|
Mirrors the app's "shared catalog, private per-user state" model: one row per unique
|
|
``(source_kind, source_ref, format_sig)`` is the global cache. When a second user requests
|
|
the same video with a format that hashes to the same `format_sig`, their `download_job`
|
|
links to this ready asset instantly — no re-download, no cost. Only the SOURCE download is
|
|
shared; per-user edited derivatives (phase 2) are never cached here.
|
|
|
|
`rel_path` is relative to `settings.download_root` (the mount), so the physical filename is
|
|
system-decided and bound to the source id — never renamed (the user's custom name is display
|
|
metadata on `download_job`, applied only when downloading to their own machine). The naming
|
|
fields (`title`/`uploader`/`upload_date`) are denormalized so the Plex-tree path can be built
|
|
uniformly for both catalog videos and ad-hoc URLs. `ref_count` (active jobs pointing here)
|
|
plus `last_access_at`/`expires_at` drive retention + LRU eviction in the GC job."""
|
|
|
|
__tablename__ = "media_assets"
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"source_kind", "source_ref", "format_sig", name="uq_media_asset_cache_key"
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
|
source_ref: Mapped[str] = mapped_column(String(512)) # video id, or full URL for ad-hoc
|
|
format_sig: Mapped[str] = mapped_column(String(64)) # hash of the output-affecting spec
|
|
status: Mapped[str] = mapped_column(
|
|
String(16), default="pending", server_default="pending", index=True
|
|
) # "pending" | "ready" | "error"
|
|
rel_path: Mapped[str | None] = mapped_column(Text) # relative to download_root
|
|
storyboard_path: Mapped[str | None] = mapped_column(Text) # phase-2 filmstrip mosaic
|
|
# A locally-generated poster frame (ffmpeg), set only when the source had NO thumbnail so the
|
|
# card / watch page / link preview can still show an image. Relative to download_root.
|
|
poster_path: Mapped[str | None] = mapped_column(Text)
|
|
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
|
|
duration_s: Mapped[int | None] = mapped_column(Integer)
|
|
width: Mapped[int | None] = mapped_column(Integer)
|
|
height: Mapped[int | None] = mapped_column(Integer)
|
|
container: Mapped[str | None] = mapped_column(String(16))
|
|
vcodec: Mapped[str | None] = mapped_column(String(32))
|
|
acodec: Mapped[str | None] = mapped_column(String(32))
|
|
nfo_written: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false"
|
|
)
|
|
# Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run.
|
|
gc_notified: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false"
|
|
)
|
|
# Denormalized naming/metadata for the Plex tree + ad-hoc display.
|
|
title: Mapped[str | None] = mapped_column(Text)
|
|
uploader: Mapped[str | None] = mapped_column(String(255))
|
|
# yt-dlp's channel/uploader page URL (channel_url|uploader_url) — lets an auto-filled channel
|
|
# render as a clickable link. Null for sources without one (e.g. reddit) or older rows.
|
|
uploader_url: Mapped[str | None] = mapped_column(Text)
|
|
upload_date: Mapped[date | None] = mapped_column(Date)
|
|
thumbnail_url: Mapped[str | None] = mapped_column(Text)
|
|
# yt-dlp's canonical page URL for the source (webpage_url) — the clean "downloaded from"
|
|
# reference shown on the Downloads page. Null until the worker extracts it (older/queued rows
|
|
# fall back to a URL derived from source_kind+source_ref).
|
|
source_webpage_url: Mapped[str | None] = mapped_column(Text)
|
|
error: Mapped[str | None] = mapped_column(Text)
|
|
ref_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
last_access_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
|
|
class DownloadProfile(Base, TimestampMixin):
|
|
"""A named download preset — a reusable format profile.
|
|
|
|
`user_id` NULL + `is_builtin` marks the shipped presets (Best MP4, 1080p, audio-only, …);
|
|
a user's saved custom profiles carry their `user_id`. `spec` is the format definition
|
|
(mode av/v/a, max_height, container, audio_format, codec prefs, embed subs/chapters/
|
|
thumbnail, sponsorblock) that `downloads.formats` turns into a yt-dlp selector + a cache
|
|
`format_sig`."""
|
|
|
|
__tablename__ = "download_profiles"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
) # NULL for a builtin preset
|
|
name: Mapped[str] = mapped_column(String(80))
|
|
spec: Mapped[dict] = mapped_column(JSON)
|
|
is_builtin: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false"
|
|
)
|
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
|
|
|
|
class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
|
"""A user's request to download one source — the per-user layer over `MediaAsset`.
|
|
|
|
The worker claims `queued` jobs with ``FOR UPDATE SKIP LOCKED`` and writes live
|
|
`progress`/`speed_bps`/`eta_s`/`phase` here (the frontend polls via useLiveQuery — the
|
|
worker is a separate process and can't reach the in-process WS registry). `profile_snapshot`
|
|
freezes the spec at enqueue so later edits/deletion of the profile don't change this job.
|
|
`display_name` is the user's own name for the file (display + the Content-Disposition on
|
|
local download); the on-disk asset is never renamed. `asset_id` links to the shared file
|
|
(SET NULL if the asset is GC'd — the job then shows as expired).
|
|
|
|
Phase 2 (editor): `job_kind='edit'` jobs derive a new per-user clip from an already-downloaded
|
|
ready job. They still hang off a `MediaAsset` (with `source_kind='edit'`, a per-user cache key)
|
|
so all the file-serve/ref-count/GC/quota machinery is reused; the worker branches on the
|
|
asset's `source_kind` and runs ffmpeg instead of yt-dlp. `source_job_id`/`source_asset_id`
|
|
point at the parent download the clip was cut from; `edit_spec` is the (trim/crop) recipe."""
|
|
|
|
__tablename__ = "download_jobs"
|
|
__table_args__ = (Index("ix_download_jobs_claim", "status", "queue_pos"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
)
|
|
asset_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("media_assets.id", ondelete="SET NULL"), index=True
|
|
)
|
|
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
|
source_ref: Mapped[str] = mapped_column(String(512))
|
|
# "download" (default) or "edit" (a per-user trim/crop derivative of another job).
|
|
job_kind: Mapped[str] = mapped_column(
|
|
String(16), default="download", server_default="download"
|
|
)
|
|
# For edit jobs: the parent download this clip is cut from (SET NULL if the parent is deleted).
|
|
source_job_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("download_jobs.id", ondelete="SET NULL")
|
|
)
|
|
source_asset_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("media_assets.id", ondelete="SET NULL")
|
|
)
|
|
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}}.
|
|
edit_spec: Mapped[dict | None] = mapped_column(JSON)
|
|
profile_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("download_profiles.id", ondelete="SET NULL")
|
|
)
|
|
profile_snapshot: Mapped[dict] = mapped_column(JSON)
|
|
display_name: Mapped[str | None] = mapped_column(String(255))
|
|
# Per-user display overrides + extras (like display_name — metadata only, never the file).
|
|
# `display_uploader`/`display_uploader_url` override the asset's auto channel name/link;
|
|
# `extra_links` is an optional list of extra reference URLs shown on the card + watch page.
|
|
display_uploader: Mapped[str | None] = mapped_column(String(255))
|
|
display_uploader_url: Mapped[str | None] = mapped_column(Text)
|
|
extra_links: Mapped[list | None] = mapped_column(JSON)
|
|
status: Mapped[str] = mapped_column(
|
|
String(16), default="queued", server_default="queued", index=True
|
|
) # queued | running | paused | done | error | canceled
|
|
progress: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
speed_bps: Mapped[int | None] = mapped_column(BigInteger)
|
|
eta_s: Mapped[int | None] = mapped_column(Integer)
|
|
phase: Mapped[str | None] = mapped_column(String(32))
|
|
error: Mapped[str | None] = mapped_column(Text)
|
|
queue_pos: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
|
|
|
|
class DownloadShare(Base, TimestampMixin):
|
|
"""A user→user grant: `to_user` may access `from_user`'s downloaded file (private by
|
|
default). Since assets are already deduped/shared on disk, a share is just an ACL row."""
|
|
|
|
__tablename__ = "download_shares"
|
|
__table_args__ = (
|
|
UniqueConstraint("job_id", "to_user_id", name="uq_download_share"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
job_id: Mapped[int] = mapped_column(
|
|
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
|
)
|
|
from_user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
)
|
|
to_user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
)
|
|
|
|
|
|
class DownloadQuota(Base, UpdatedAtMixin):
|
|
"""Per-user download limits (admin-managed). A row exists only when an admin customizes a
|
|
user; otherwise the sysconfig `download_default_*` values apply. `unlimited` bypasses the
|
|
byte cap entirely (e.g. for the admin's own account)."""
|
|
|
|
__tablename__ = "download_quotas"
|
|
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
max_bytes: Mapped[int] = mapped_column(BigInteger)
|
|
max_concurrent: Mapped[int] = mapped_column(Integer)
|
|
max_jobs: Mapped[int] = mapped_column(Integer)
|
|
unlimited: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false"
|
|
)
|
|
|
|
|
|
class DownloadLink(Base, TimestampMixin):
|
|
"""A public capability link to one download — the "share by link" surface.
|
|
|
|
Anyone holding the unguessable `token` can watch the file on the login-free `/watch/{token}`
|
|
player page (Google-Drive style). Optional per-link controls: `expires_at`, `allow_download`
|
|
(stream-only vs downloadable), and a `password_hash` (argon2, like a user password). Revoke =
|
|
delete the row. Distinct from `DownloadShare`, which grants a *registered* user access via the
|
|
in-app "Shared with me" list."""
|
|
|
|
__tablename__ = "download_links"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
job_id: Mapped[int] = mapped_column(
|
|
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
|
)
|
|
token: Mapped[str] = mapped_column(String(64), unique=True)
|
|
created_by: Mapped[int | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE")
|
|
)
|
|
allow_download: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false"
|
|
)
|
|
password_hash: Mapped[str | None] = mapped_column(Text)
|
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
|
|
|
|
class MessageKey(Base, TimestampMixin):
|
|
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
|
|
|
|
One row per user. `public_key` (base64 SPKI, ECDH P-256) is shared with others so they can
|
|
derive the pairwise conversation key. `wrapped_private_key` is the user's private key
|
|
encrypted IN THE BROWSER with a key derived (PBKDF2) from a message passphrase the server
|
|
never sees — so the server stores only an opaque blob it cannot unwrap. `salt`/`wrap_iv`
|
|
parameterise that derivation; `key_check` is a small ciphertext the client decrypts to
|
|
verify a passphrase on unlock. The server is purely a key directory + blob store; it can
|
|
never read any message."""
|
|
|
|
__tablename__ = "message_keys"
|
|
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
public_key: Mapped[str] = mapped_column(Text)
|
|
wrapped_private_key: Mapped[str] = mapped_column(Text)
|
|
salt: Mapped[str] = mapped_column(String(64))
|
|
wrap_iv: Mapped[str] = mapped_column(String(32))
|
|
key_check: Mapped[str] = mapped_column(Text)
|
|
|
|
|
|
# --- Plex integration (optional) -------------------------------------------------------------
|
|
# A dedicated, self-contained catalog mirrored from a locally-reachable Plex server. Playback is
|
|
# from the LOCAL physical file (Plex is metadata only); per-user watch state lives in plex_states
|
|
# so it works for users without a Plex account. Mirrors the app's "shared catalog, private
|
|
# per-user state" model. The weighted FTS search_vector uses the same unaccent_simple config as
|
|
# `videos` (migrations 0031/0032).
|
|
|
|
|
|
class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin):
|
|
"""A mirrored Plex library section. Only `enabled` sections are exposed in the feed.
|
|
`plex_key` is the section id on the Plex server."""
|
|
|
|
__tablename__ = "plex_libraries"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
plex_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
|
title: Mapped[str] = mapped_column(String(255))
|
|
kind: Mapped[str] = mapped_column(String(16)) # "movie" | "show"
|
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
|
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
|
|
class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
|
"""A TV show (grandparent of episodes) — holds poster + summary for the drill-down page, plus
|
|
the same filterable metadata as movies (genres/rating/content_rating/studio/people) so the TV
|
|
grid can be filtered/sorted like the movie grid. All mirrored cheaply from the show listing."""
|
|
|
|
__tablename__ = "plex_shows"
|
|
__table_args__ = (
|
|
Index("ix_plex_shows_genres_gin", "genres", postgresql_using="gin"),
|
|
Index("ix_plex_shows_directors_gin", "directors", postgresql_using="gin"),
|
|
Index("ix_plex_shows_cast_gin", "cast_names", postgresql_using="gin"),
|
|
# ix_plex_shows_collections_gin already created with collections (migration 0047).
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
|
library_id: Mapped[int] = mapped_column(
|
|
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
|
)
|
|
title: Mapped[str] = mapped_column(String(512))
|
|
summary: Mapped[str | None] = mapped_column(Text)
|
|
year: Mapped[int | None] = mapped_column(Integer)
|
|
thumb_key: Mapped[str | None] = mapped_column(String(512)) # Plex thumb path (proxied, not stored)
|
|
art_key: Mapped[str | None] = mapped_column(String(512))
|
|
child_count: Mapped[int | None] = mapped_column(Integer) # seasons
|
|
collection_keys: Mapped[list | None] = mapped_column(JSONB) # Plex collections this show is in
|
|
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
|
# Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem).
|
|
rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb)
|
|
content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
|
|
studio: Mapped[str | None] = mapped_column(String(255), index=True) # network/studio
|
|
originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True) # first air date
|
|
genres: Mapped[list | None] = mapped_column(JSONB)
|
|
directors: Mapped[list | None] = mapped_column(JSONB)
|
|
cast_names: Mapped[list | None] = mapped_column(JSONB)
|
|
people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector
|
|
search_vector: Mapped[object | None] = mapped_column(
|
|
TSVECTOR,
|
|
Computed(
|
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
|
|
persisted=True,
|
|
),
|
|
)
|
|
|
|
|
|
class PlexSeason(Base, TimestampMixin, UpdatedAtMixin):
|
|
"""A season under a show — used to order episodes for prev/next stepping."""
|
|
|
|
__tablename__ = "plex_seasons"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
|
show_id: Mapped[int] = mapped_column(
|
|
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
|
|
)
|
|
title: Mapped[str | None] = mapped_column(String(255))
|
|
season_number: Mapped[int | None] = mapped_column(Integer, index=True)
|
|
thumb_key: Mapped[str | None] = mapped_column(String(512))
|
|
|
|
|
|
class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
|
"""A playable LEAF — a movie or a single episode. This is what the feed lists and the player
|
|
plays. Movies carry `library_id` and no parents; episodes carry show/season refs +
|
|
season/episode numbers. `file_path` is the Plex-side absolute path of the physical media,
|
|
mapped through `plex_path_map` to a local mount at playback time. `playable` is the
|
|
ffprobe-derived browser-compatibility class (direct | remux | transcode)."""
|
|
|
|
__tablename__ = "plex_items"
|
|
__table_args__ = (
|
|
Index("ix_plex_items_show_order", "show_id", "season_number", "episode_number"),
|
|
Index("ix_plex_items_genres_gin", "genres", postgresql_using="gin"),
|
|
Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"),
|
|
Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"),
|
|
Index("ix_plex_items_collections_gin", "collection_keys", postgresql_using="gin"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
|
library_id: Mapped[int] = mapped_column(
|
|
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
|
)
|
|
kind: Mapped[str] = mapped_column(String(16), index=True) # "movie" | "episode"
|
|
show_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
|
|
)
|
|
season_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("plex_seasons.id", ondelete="CASCADE"), index=True
|
|
)
|
|
season_number: Mapped[int | None] = mapped_column(Integer)
|
|
episode_number: Mapped[int | None] = mapped_column(Integer)
|
|
title: Mapped[str] = mapped_column(String(512))
|
|
summary: Mapped[str | None] = mapped_column(Text)
|
|
year: Mapped[int | None] = mapped_column(Integer)
|
|
duration_s: Mapped[int | None] = mapped_column(Integer)
|
|
thumb_key: Mapped[str | None] = mapped_column(String(512))
|
|
art_key: Mapped[str | None] = mapped_column(String(512))
|
|
cast: Mapped[list | None] = mapped_column(JSON) # [role names]
|
|
# Physical media (from the Plex Part).
|
|
file_path: Mapped[str | None] = mapped_column(Text) # Plex-side absolute path
|
|
part_key: Mapped[str | None] = mapped_column(String(255)) # /library/parts/... (fallback fetch)
|
|
container: Mapped[str | None] = mapped_column(String(16))
|
|
codec_video: Mapped[str | None] = mapped_column(String(32))
|
|
codec_audio: Mapped[str | None] = mapped_column(String(32))
|
|
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
|
|
# Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
|
|
playable: Mapped[str | None] = mapped_column(String(12), index=True)
|
|
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
|
|
markers: Mapped[list | None] = mapped_column(JSON)
|
|
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
|
# Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls;
|
|
# `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio
|
|
# filters (GIN-indexed) and double as content signals for a future watch-habit recommender.
|
|
rating: Mapped[float | None] = mapped_column(Float, index=True)
|
|
content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
|
|
studio: Mapped[str | None] = mapped_column(String(255), index=True)
|
|
originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True)
|
|
genres: Mapped[list | None] = mapped_column(JSONB)
|
|
directors: Mapped[list | None] = mapped_column(JSONB)
|
|
cast_names: Mapped[list | None] = mapped_column(JSONB)
|
|
# rating_keys of the Plex collections this movie belongs to (GIN — filter + card-strip siblings).
|
|
collection_keys: Mapped[list | None] = mapped_column(JSONB)
|
|
# Cast + director names, space-joined — folded into search_vector (weight B) so the search box
|
|
# finds titles by a person's name (and ranks them above summary-only mentions).
|
|
people_text: Mapped[str | None] = mapped_column(Text)
|
|
search_vector: Mapped[object | None] = mapped_column(
|
|
TSVECTOR,
|
|
Computed(
|
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
|
|
persisted=True,
|
|
),
|
|
)
|
|
|
|
|
|
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
|
|
"""A mirrored Plex collection (a grouping of movies or shows — franchises like Avatar, curated
|
|
sets like MCU, or auto/community lists like IMDb Top 250). Mirrored in full by the background
|
|
sync so reads never hit Plex's slow collection queries. Membership is stored as `collection_keys`
|
|
on the member movies (plex_items) / shows (plex_shows). `smart` = Plex's query-based flag;
|
|
`editable` = we may write changes back to Plex (default False = read-only; set for curated ones)."""
|
|
|
|
__tablename__ = "plex_collections"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
|
library_id: Mapped[int] = mapped_column(
|
|
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
|
)
|
|
title: Mapped[str] = mapped_column(String(512))
|
|
summary: Mapped[str | None] = mapped_column(Text)
|
|
thumb_key: Mapped[str | None] = mapped_column(String(512))
|
|
child_count: Mapped[int | None] = mapped_column(Integer)
|
|
smart: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
editable: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
|
|
class PlexState(Base, UpdatedAtMixin):
|
|
"""Per-user watch state for a Plex item — mirrors VideoState. Lives in Siftlode's own DB so
|
|
it works for users WITHOUT a Plex account (the whole point of the access-layer design).
|
|
`synced_to_plex` marks whether this state was pushed back to a linked Plex account (P5)."""
|
|
|
|
__tablename__ = "plex_states"
|
|
__table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
)
|
|
item_id: Mapped[int] = mapped_column(
|
|
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
|
|
)
|
|
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
|
|
position_seconds: Mapped[int] = mapped_column(
|
|
Integer, default=0, server_default="0", nullable=False
|
|
)
|
|
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
synced_to_plex: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false"
|
|
)
|
|
|
|
|
|
class PlexLink(Base, UpdatedAtMixin):
|
|
"""Links a Siftlode user to a Plex account for two-way watch-state sync (one row per user).
|
|
|
|
For the owner (the MVP scope) the row uses the server's admin token — `uses_admin=True`,
|
|
`plex_token_enc=None` — so no separate Plex login is needed; the admin token already reads/writes
|
|
the owner account's watch state. `plex_account_id` is that Plex account's numeric id, used to
|
|
filter the shared watch-history feed to this user (Phase C). Reserved for later: a friend can link
|
|
their OWN Plex account via PIN OAuth, storing a personal `plex_token_enc` (uses_admin=False) — the
|
|
sync loop then iterates link rows uniformly.
|
|
|
|
`initial_import_done` guards the one-time "Plex is master" import; `last_watch_sync_at` is the
|
|
high-water mark for the incremental Plex→Siftlode pass."""
|
|
|
|
__tablename__ = "plex_link"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
|
|
)
|
|
uses_admin: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
|
plex_token_enc: Mapped[str | None] = mapped_column(Text) # P5b friend tokens (encrypted)
|
|
plex_account_id: Mapped[int | None] = mapped_column(Integer)
|
|
plex_username: Mapped[str | None] = mapped_column(String(255))
|
|
sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
initial_import_done: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false"
|
|
)
|
|
last_watch_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
linked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
|
|
class PlexPlaylist(Base, TimestampMixin, UpdatedAtMixin):
|
|
"""A per-user ORDERED watch-list of Plex items — Siftlode-native (lives in our own DB, works for
|
|
users WITHOUT a Plex account, like plex_states). Unlike collections (shared, library-level), a
|
|
playlist is owned by one user and its items are ordered. Optional Plex-direction sync is a later
|
|
phase (`plex_rating_key` set once pushed back to the owner's Plex account)."""
|
|
|
|
__tablename__ = "plex_playlists"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
|
title: Mapped[str] = mapped_column(String(512))
|
|
plex_rating_key: Mapped[str | None] = mapped_column(String(32))
|
|
|
|
|
|
class PlexPlaylistItem(Base):
|
|
"""One ordered entry in a playlist. `position` (0-based) orders playback."""
|
|
|
|
__tablename__ = "plex_playlist_items"
|
|
__table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
playlist_id: Mapped[int] = mapped_column(
|
|
ForeignKey("plex_playlists.id", ondelete="CASCADE"), index=True
|
|
)
|
|
item_id: Mapped[int] = mapped_column(
|
|
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
|
|
)
|
|
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|