Files
siftlode/backend/app/models.py
T
peter e82f87acb0 refactor(reader): the unit's tables move into reader/models
VideoState, Playlist, PlaylistItem, SavedView, ComfortPin, Tag and ChannelTag leave app/models.py
for app/reader/models.py; app.tables registers them. No schema change.
2026-10-19 00:53:44 +02:00

1186 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import date, datetime
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
Computed,
Date,
DateTime,
Float,
ForeignKey,
Index,
Integer,
JSON,
LargeBinary,
SmallInteger,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from app.db import Base
from app.runstatus import RunStatus, RunTrigger, sql_in
from app.utils import canonical_email
# Every user-supplied display string in this schema shares one width, and it lives HERE so the
# columns below are declared with it rather than with a literal: a name a person typed goes into
# `User.display_name` (their Google profile name), `DownloadJob.display_name` and
# `DownloadJob.display_uploader`. A value wider than the column is a psycopg DataError, which
# `main.py` re-raises for everything but the NUL case — i.e. a bare 500 for a name somebody pasted.
DISPLAY_TEXT_MAX = 255
def display_text(raw: str | None) -> str | None:
"""A user-supplied display string on its way into one of those columns: stripped, truncated,
blank -> None.
One definition because there are SIX writers and they had drifted: `POST /api/downloads`,
`POST /api/downloads/edit`, `POST /api/files/upload`, the library PATCH's two fields, and
the Google sign-in callback. Only the PATCH truncated. Truncating is the right answer rather
than refusing: the name is a label on work the user has already done — a file already
transferred, an account already authenticated — and losing its tail is not worth failing the
request over.
`test_upload.py` holds both halves: that every route assigning one of these fields goes through
this helper (parsed, not grepped), and that each of those routes truncates rather than 500s. A
sweep is exactly the kind of thing that gets done five-sixths of the way, which is how this
ended up being written twice.
"""
return (raw or "").strip()[:DISPLAY_TEXT_MAX] or None
# The resume-position columns (VideoState / PlexState.position_seconds) are int4. The checkpoint
# routes bound their bodies by this, so an out-of-range position is a 422 rather than a 500.
POSITION_MAX_S = 2**31 - 1
class TimestampMixin:
"""Adds a server-defaulted `created_at`. Models that index created_at or order it
differently (QuotaEvent, Notification) keep their own column on purpose."""
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class CanonicalEmailMixin:
"""Keeps `email_canonical` derived from `email` on every write.
The invariant — "identity is matched on the canonical form, mail is sent to the typed one" —
lives HERE, in code, instead of in a comment every write site has to remember. That distinction
is what the 2026-09-03 lockout was made of: one gate lowercased, nothing else did, and an
approved user's Google sign-in silently became a second access request.
"""
@validates("email")
def _derive_email_canonical(self, _key: str, value: str | None) -> str | None:
self.email_canonical = canonical_email(value)
return value
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, CanonicalEmailMixin):
__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)
# The same mailbox, in the form used to MATCH identities (utils.canonical_email) — Gmail dots and
# `+tags` folded away. Kept by the validator below so no write site can forget it. Deliberately
# NOT unique: prod already carries two rows for one mailbox (a password registration and the
# Google sign-in that failed to find it), and a migration must not merge accounts on its own.
email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="")
# The address Google asserted for this account, recorded at sign-in / link time. NOT the same
# thing as `email`: a Google sign-in no longer overwrites the address the user
# registered with, so a deliberate `me+siftlode@gmail.com` survives — and this is where the other
# half of the truth lives, for the UI to show when the two differ. NULL until the next Google
# sign-in for accounts linked before this column existed.
google_email: Mapped[str | None] = mapped_column(String(320))
# argon2id hash for email+password login; NULL for Google-only accounts.
password_hash: Mapped[str | None] = mapped_column(String(255))
# When SEC-10 dropped a preset password on Google adoption (see the single caller of
# auth.revoke_untrusted_preset_password; admin approval no longer revokes). Without
# this trace a revoked account was indistinguishable from a Google-only one, so it failed the
# password-reset eligibility check and could never be recovered — it could neither sign in nor
# reset. Set = "had a password, lost it to the guard" -> reset-eligible. Cleared whenever a
# password is set again (reset-confirm / set-password).
password_revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 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 worker leaves its queued downloads unclaimed
# until the block is lifted (`worker._claim_job`).
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")
# When this account last signed in (Google or password), stamped by the one sign-in path,
# routes/auth._establish_session. NULL = not since the column existed (0085; no backfill is
# possible).
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# The diagnostics level this user consented to and when it lapses back to the base
# level. Both NULL = base. Written only by `PUT /api/me/diagnostics`, which computes the expiry
# itself; read through `app.diagnostics.effective_level`, never directly (an expired level is
# not cleared eagerly — it simply stops counting).
diagnostics_level: Mapped[str | None] = mapped_column(String(8))
diagnostics_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
display_name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
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))
# 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, CanonicalEmailMixin):
"""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)
# See User.email_canonical. Not unique here either: prod has approved invites for both spellings
# of the same mailbox, dating back to 2026-06-16.
email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="")
# 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
# When the admin last SENT this person an invitation from the whitelist row. Adding an address
# stays silent — it opens a door, it does not knock — so telling them is a separate, deliberate
# act, and this is what stops it happening twice to a real person.
invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# The language this person asked us in ("en"/"hu"), captured when the request was made: the page
# their form was on, or the locale Google reported for a refused sign-in. NULL for a row the
# admin whitelisted by hand — nobody has told us anything about that mailbox, which is exactly
# why the invitation send asks. Captured rather than derived for the same reason
# `preferences.language` and `google_email` are: the signal exists only in that one moment and
# cannot be backfilled afterwards.
lang: Mapped[str | None] = mapped_column(String(8))
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))
# Creator keyword tags (brandingSettings.channel.keywords) — a single space-separated string,
# multi-word tags quoted; the client parses it into chips for the About tab.
keywords: Mapped[str | None] = mapped_column(Text)
# 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))
# When the newest uploads were last read through the Data API because the RSS feed would not
# answer (`sync/runner.py` `run_feed_fallback`): the once-per-`rss_fallback_hours` clock.
feed_fallback_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"))
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
# No standalone channel_id index: the composite `ix_videos_channel_pub_cover`
# (channel_id, published_at DESC NULLS LAST, id) from migration 0095 leads with channel_id, so it
# serves every channel_id lookup (FK cascade, sync upsert, channel-detail) and the per-channel feed
# sort. Its INCLUDEd columns make the channel manager's sums and the feed sidebar's counts Index Only
# Scans (no heap): a column those queries start reading belongs in it (test_channel_list_index_only).
# Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`.
channel_id: Mapped[str] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
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)
# NOT NULL (migration 0068): in reality every video carries a publish date, and a nullable key
# forced the feed's keyset predicate into a non-sargable `... OR published_at IS NULL` disjunction
# that blocked the ix_videos_published_at_desc range (deep pages scanned from the top and filtered —
# PERF-15). server_default now() is the safety net for the rare date-less ingest (the enrich fills
# the real date); the ix_videos_published_at index stays for the `oldest` sort.
published_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), index=True, nullable=False, server_default=func.now()
)
thumbnail_url: Mapped[str | None] = mapped_column(String(1024))
# The feed's views / duration / title sorts each ride a migration-defined index in their exact
# ORDER BY, one per direction (and per ICU collation for title): migration 0096, held to
# `feedquery.order_by` by test_feed_sort_indexes.
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)
# No btree here or on the flag columns below: migration 0069 (PERF-3) dropped the never-picked
# boolean btrees and replaced the useful ones with partial indexes over the target subset
# (via_search=true, NOT shorts_probed, unavailable_since IS NOT NULL) — migration-defined, like the
# search_vector GIN.
is_short: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# Whether the youtube.com/shorts/<id> probe has run for this video.
shorts_probed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# 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")
# 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")
# 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: no plain btree — migration 0069 (PERF-18) replaced it with the partial
# ix_videos_recheck_order (last_checked_at NULLS FIRST WHERE unavailable_since IS NULL) that the
# revalidate job's exact ORDER BY rides; unavailable_since is a partial WHERE IS NOT NULL (PERF-3).
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
unavailable_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=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 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"))
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"))
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"))
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
)
# The background run that spent it (app/jobruns.py); NULL = a request's own spend. SET NULL: a
# reaped run leaves the spend in the budget's history.
job_run_id: Mapped[int | None] = mapped_column(
ForeignKey("job_runs.id", ondelete="SET NULL"), index=True
)
class AuditLog(Base):
"""Append-only admin audit trail: who did what, when, and (where it applies) the before->after
values. `actor_id` is the acting admin; NULL means the background did it (SET NULL on user
delete keeps the trail). Logged per ACTION, never per row; a background run is not an action
and has its own history (`JobRun`). `before`/`after` are small JSON snapshots (secrets
excluded). `request_id` is the request that made the change, so its log lines are one click
away (NULL for rows written outside a request, and for rows older than the column)."""
__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
)
request_id: Mapped[str | None] = mapped_column(String(64), index=True)
class LogEvent(Base):
"""One stored log event — the `log_events` table the diagnostics console reads. Rows are
written by app/logsink.py's background writer, never inside a request's transaction, already
redacted (the same `logs.record_fields` the stdout line is built from).
`user_id` has NO foreign key on purpose: the writer runs behind the event, so a line about an
account can arrive after that account was purged, and a constraint would make it fail the whole
batch it travels in. `auth.purge_user` deletes an account's rows explicitly instead, after
`logs.forget_user` makes every later write in this process store such a line without the account;
the retention job (`log_retention_days`) bounds whatever another process writes late."""
__tablename__ = "log_events"
__table_args__ = (
Index("ix_log_events_user_ts", "user_id", "ts"),
# An issue's bundle is `WHERE debug_session_id = …`; nearly every row has none.
Index(
"ix_log_events_debug_session",
"debug_session_id",
postgresql_where="debug_session_id IS NOT NULL",
),
# A run's lines are `WHERE run_id = …`; request lines carry none.
Index("ix_log_events_run", "run_id", postgresql_where="run_id IS NOT NULL"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
level: Mapped[str] = mapped_column(String(10))
sev: Mapped[int] = mapped_column(SmallInteger)
source: Mapped[str] = mapped_column(String(16))
logger: Mapped[str] = mapped_column(String(128))
event: Mapped[str | None] = mapped_column(String(64))
msg: Mapped[str] = mapped_column(Text)
attrs: Mapped[dict | None] = mapped_column(JSONB)
exc: Mapped[str | None] = mapped_column(Text)
request_id: Mapped[str | None] = mapped_column(String(64), index=True)
user_id: Mapped[int | None] = mapped_column(Integer)
debug_session_id: Mapped[str | None] = mapped_column(String(64))
# A DOWNLOAD job's id (download_jobs) — not a background run, which is `run_id`.
job_id: Mapped[int | None] = mapped_column(Integer)
# The background run (job_runs) that logged it. No foreign key, as `user_id`: the writer runs
# behind the event, and a run reaped meanwhile must not fail the batch its lines travel in.
run_id: Mapped[int | None] = mapped_column(BigInteger)
version: Mapped[str | None] = mapped_column(String(64))
class DebugSession(Base):
"""One recording: Start → reproduce → Stop (app/debugsession.py). While it is open, every
request of its user that names it (`X-Debug-Session`) is logged at `level` and tagged with `id`,
and the issue it ends in is built from the rows carrying that tag. `until` is the hard cap the
server set at Start; an open session past it is over whether or not the sweep has closed it yet.
One OPEN session per user (`uq_debug_sessions_open_per_user`, partial on `ended_at IS NULL`)."""
__tablename__ = "debug_sessions"
__table_args__ = (
Index(
"uq_debug_sessions_open_per_user",
"user_id",
unique=True,
postgresql_where="ended_at IS NULL",
),
)
id: Mapped[str] = mapped_column(String(16), primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
level: Mapped[str] = mapped_column(String(8))
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
until: Mapped[datetime] = mapped_column(DateTime(timezone=True))
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# app/debugsession.py `EndReason`: stopped by the user, or past `until`.
ended_reason: Mapped[str | None] = mapped_column(String(16))
issue_url: Mapped[str | None] = mapped_column(String(512))
# The issue being written from it (app/debugsession.py `save_draft` holds its shape).
draft: Mapped[dict | None] = mapped_column(JSONB)
class DebugScreenshot(Base):
"""A screenshot attached to a debug session — pasted, dropped, or captured from the pill.
Stored in the row: bounded per session (app/debugsession.py), and gone with it by cascade."""
__tablename__ = "debug_screenshots"
id: Mapped[int] = mapped_column(primary_key=True)
session_id: Mapped[str] = mapped_column(
ForeignKey("debug_sessions.id", ondelete="CASCADE"), index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
mime: Mapped[str] = mapped_column(String(16))
size: Mapped[int] = mapped_column(Integer)
data: Mapped[bytes] = mapped_column(LargeBinary)
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")
# 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 JobRun(Base):
"""One run of a background job — scheduled, an admin's "run now", or a user's ad-hoc work on one
target — written when it starts and again when it ends (app/jobruns.py), each in its own
committed session, so the history survives the job's own rollback and a restart. A row left
`running` by a process that died is set `interrupted` at the next startup.
`quota_units` is what the run spent of the YouTube budget: the `quota_events` rows carrying its
id, summed when it ends. `summary` is the English one-line fallback; `result` / `failure` are the
same outcome as data, which the client names in the reader's language."""
__tablename__ = "job_runs"
__table_args__ = (
Index("ix_job_runs_job_started", "job", "started_at"),
CheckConstraint(f"status IN ({sql_in(RunStatus)})", name="ck_job_runs_status"),
CheckConstraint(f"trigger IN ({sql_in(RunTrigger)})", name="ck_job_runs_trigger"),
# Only a run still going has no end.
CheckConstraint(
f"(status = '{RunStatus.RUNNING}') = (finished_at IS NULL)", name="ck_job_runs_finished"
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
job: Mapped[str] = mapped_column(String(40))
trigger: Mapped[str] = mapped_column(String(16))
# Who started it: the admin of a manual run, the user of an ad-hoc one; NULL = the schedule.
actor_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), index=True
)
# What an ad-hoc run worked on (a channel id); NULL for the recurring jobs.
target: Mapped[str | None] = mapped_column(String(128))
status: Mapped[str] = mapped_column(String(16))
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
summary: Mapped[str | None] = mapped_column(Text)
result: Mapped[dict | None] = mapped_column(JSONB)
error: Mapped[str | None] = mapped_column(Text)
failure: Mapped[dict | None] = mapped_column(JSONB)
quota_units: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
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 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 ApiToken(Base, TimestampMixin):
"""A user's token for a machine to reach their Files library with (`app.apitokens`): sent as
`Authorization: Bearer sfl_…`, honoured on `/api/files` only, and only in the folders its
grants name. Only the SHA-256 of the token is kept — it is shown once, when it is made — plus
its first characters (`prefix`), so the user can tell their tokens apart."""
__tablename__ = "api_tokens"
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))
prefix: Mapped[str] = mapped_column(String(16))
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # NULL = never
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# The one notice its user gets before it runs out (`apitokens.warn_expiring`).
expiry_warned: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
class ApiTokenGrant(Base):
"""What one token may do in one folder. A folder with no grant is out of the token's reach."""
__tablename__ = "api_token_grants"
__table_args__ = (
UniqueConstraint("token_id", "folder_id", name="uq_api_token_grant"),
CheckConstraint(
"can_list OR can_read OR can_write OR can_delete", name="ck_api_token_grant_some"
),
)
id: Mapped[int] = mapped_column(primary_key=True)
token_id: Mapped[int] = mapped_column(ForeignKey("api_tokens.id", ondelete="CASCADE"))
folder_id: Mapped[int] = mapped_column(
ForeignKey("file_folders.id", ondelete="CASCADE"), index=True
)
can_list: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
can_read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
can_write: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
can_delete: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# --- 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))
# HU-overlay support. `metadata_lang` tags the section's metadata language. An OVERLAY
# library (overlay_of set) is the Hungarian-metadata twin of another section: it is never browsable
# on its own — its titles' HU fields are written onto the matched EN rows (paired by `guid`).
metadata_lang: Mapped[str] = mapped_column(String(8), default="en", server_default="en")
overlay_of: Mapped[int | None] = mapped_column(
ForeignKey("plex_libraries.id", ondelete="SET NULL"), index=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"),
Index("ix_plex_shows_genres_hu_gin", "genres_hu", 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
)
# Canonical external id (e.g. "tmdb:12345") — the key that pairs this EN row with its HU-overlay
# twin during sync. Indexed for the guid->row overlay match.
guid: Mapped[str | None] = mapped_column(String(64), 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(none_as_null=True)
) # 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
# none_as_null: an empty tag list must persist as SQL NULL, not JSON `null`. Without it a Python
# None round-trips as a JSON scalar that jsonb_array_elements_text() can't unnest ("cannot extract
# elements from a scalar") — the trap a metadata-poor library sprang on /facets. Same on plex_items.
genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
people_text: Mapped[str | None] = mapped_column(
Text
) # cast+directors, folded into search_vector
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
imdb_rating: Mapped[float | None] = mapped_column(
Float
) # IMDb-specific score (vs `rating` = audienceRating)
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
tagline: Mapped[str | None] = mapped_column(Text)
full_cast: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # ordered [{name, role, thumb}]
# --- TMDB enrichment: see the identical block on plex_items for the full rationale. Additive;
# served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) ---
tmdb_id: Mapped[int | None] = mapped_column(Integer)
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
budget: Mapped[int | None] = mapped_column(BigInteger)
revenue: Mapped[int | None] = mapped_column(BigInteger)
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
crew: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # [{name, job, tmdb_id, thumb}]
studios: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # TMDB production companies [str]
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
# Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched,
# so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors.
title_hu: Mapped[str | None] = mapped_column(String(512))
tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline
summary_hu: Mapped[str | None] = mapped_column(Text)
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
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,
),
)
search_vector_hu: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 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"),
Index("ix_plex_items_genres_hu_gin", "genres_hu", 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"
# Canonical external id (e.g. "tmdb:12345") — pairs a MOVIE row with its HU-overlay twin during
# sync; indexed for the guid->row match. Episodes carry it too but it's unused for them.
guid: Mapped[str | None] = mapped_column(String(64), index=True)
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))
# Transcode HW-capability facet (the playback-class filter). NULL for non-transcode items; for a
# `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable
# AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that
# breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`:
# set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth
# backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable
# home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared
# classifier so the catalog facet and the runtime encoder choice never disagree by codec.
transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True)
# Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL =
# not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`.
video_bit_depth: Mapped[int | None] = mapped_column(Integer)
# Watermark for the bit-depth backfill: when this item's media Stream was last probed for
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
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(none_as_null=True))
directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
# rating_keys of the Plex collections this movie belongs to (GIN — filter + card-strip siblings).
collection_keys: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
# 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)
# Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
imdb_rating: Mapped[float | None] = mapped_column(
Float
) # IMDb-specific score (vs `rating` = audienceRating)
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
tagline: Mapped[str | None] = mapped_column(Text)
full_cast: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # ordered [{name, role, thumb}]
# --- TMDB enrichment: matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB
# backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating
# above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched;
# `tmdb_enriched_at` is the "tried" watermark (stamped on a definitive no-match too, so an
# unresolvable title isn't re-fetched every run; left NULL only on a transient fetch error). ---
tmdb_id: Mapped[int | None] = mapped_column(Integer)
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
budget: Mapped[int | None] = mapped_column(BigInteger) # USD (movies; TV has none)
revenue: Mapped[int | None] = mapped_column(BigInteger)
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
crew: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # [{name, job, tmdb_id, thumb}]
studios: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # TMDB production companies [str]
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
# [{tmdb_id, title, poster, media_type}] — resolved to library rows (by guid) at serve time.
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
backdrops: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # TMDB backdrop file_paths [str] (crossfade pool)
# Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched
# (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors.
title_hu: Mapped[str | None] = mapped_column(String(512))
tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline
summary_hu: Mapped[str | None] = mapped_column(Text)
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
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,
),
)
search_vector_hu: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')",
persisted=True,
),
)
class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
"""A small cast/crew headshot cache, keyed by name, so person filter-chips (the cards for the
actors currently in the filter) can show a photo + count without a live per-card metadata fetch.
Populated opportunistically when an item's RICH metadata is fetched — the info-page view and the
periodic rich re-sync — since the cheap section listing that drives the main sync only carries the
top few cast. Rows whose name no longer appears in ANY item's cast_names/directors (a library
removal orphaned them) are pruned by the maintenance sweep, mirroring how Plex drops orphaned
metadata. The filterable cast lives in plex_items/plex_shows.cast_names (self-cleaning with the
row); this table is the people's own facts: the headshot link, the TMDB link and the TMDB bio
(the headshot FILE lives in the image cache, filled by the people_enrich job)."""
__tablename__ = "plex_people"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None
# TMDB person id, linked by name from a title's TMDB credits (the /person
# biography fetch builds on it). NULL until a TMDB-enriched title names this person.
tmdb_person_id: Mapped[int | None] = mapped_column(Integer)
# --- TMDB person enrichment: fetched from /person/{tmdb_person_id} by the people_enrich job,
# or by the first person-page open the job hasn't reached, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a
# no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id,
# media_type, title}], resolved to library rows by guid at serve time (like recommendations). ---
biography: Mapped[str | None] = mapped_column(Text)
# TMDB's Hungarian biography, from the same request's `translations`; a Hungarian reader is
# served it, and the instance-language `biography` when TMDB has none. Birthplace, aliases and
# keywords have no Hungarian on TMDB, so they stay in the instance language.
biography_hu: Mapped[str | None] = mapped_column(Text)
birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is
deathday: Mapped[str | None] = mapped_column(String(10))
place_of_birth: Mapped[str | None] = mapped_column(Text)
known_for_department: Mapped[str | None] = mapped_column(String(64))
also_known_as: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
known_for: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True)
) # [{tmdb_id, media_type, title}]
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=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."""
__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"))
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"))
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)