A user can make a token for a machine of theirs (`/api/tokens`, signed-in only): `sfl_` + 32 random bytes + a checksum, only its SHA-256 kept, shown once. Sent as a Bearer header it is honoured on six `/api/files` routes only — folders, list, file, hash, upload, delete — and there only in the folders its grants name, with the rights they give (list / read / write / delete). One check, `FilesCaller.may`, in every route; a folder or file out of reach answers like a missing one; the files shared with the user are never in reach. A token beats a cookie on the same request. The Hub preset makes the user's one Hub folder (a new folder kind) and grants it every right. Refused tokens are slowed down per client, a runaway machine per token; last use is stamped at most once a minute; making and revoking a token is audited, and a token's uploads and deletes name it in their audit rows.
1668 lines
95 KiB
Python
1668 lines
95 KiB
Python
from datetime import date, datetime, timezone
|
||
|
||
from sqlalchemy import (
|
||
BigInteger,
|
||
Boolean,
|
||
CheckConstraint,
|
||
Computed,
|
||
Date,
|
||
DateTime,
|
||
Float,
|
||
ForeignKey,
|
||
Index,
|
||
Integer,
|
||
JSON,
|
||
LargeBinary,
|
||
SmallInteger,
|
||
String,
|
||
Text,
|
||
UniqueConstraint,
|
||
func,
|
||
text,
|
||
)
|
||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||
|
||
from app.db import Base
|
||
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 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"))
|
||
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).
|
||
# int4: a checkpoint body must stay under POSITION_MAX_S, or the flush is a DataError (a 500).
|
||
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 LibraryState(Base):
|
||
"""Where a user stopped in a library item (their own download, or one shared with them) — the
|
||
library player's resume point. Keyed by the JOB, not the file: a job is the user's library entry,
|
||
a re-download keeps it, and deleting the job deletes the row; the shared file under it may outlive
|
||
both. A revoked share leaves its recipient's row behind, unread — only a job the user can still
|
||
reach is ever read or written — and it is back if the item is shared again. Rows exist only for a
|
||
position worth keeping (see `routes._common.kept_position`); no row means "from the start"."""
|
||
|
||
__tablename__ = "library_states"
|
||
__table_args__ = (UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),)
|
||
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||
job_id: Mapped[int] = mapped_column(
|
||
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
||
)
|
||
# int4: a checkpoint body must stay under POSITION_MAX_S, or the flush is a DataError (a 500).
|
||
position_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
|
||
progress_updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||
|
||
|
||
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
|
||
)
|
||
|
||
|
||
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 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",
|
||
),
|
||
)
|
||
|
||
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))
|
||
job_id: Mapped[int | None] = mapped_column(Integer)
|
||
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 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)
|
||
# JSON list of the ordered video ids as of the last sync — the 3-way-merge baseline the
|
||
# two-way YouTube sync classifies each item against (local-add vs YT-add vs real delete).
|
||
# NULL = no baseline yet; the first merge then does a lossless union (never deletes).
|
||
synced_video_ids: Mapped[str | None] = mapped_column(Text)
|
||
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"))
|
||
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 a module's filter/sort state (a "smart view").
|
||
|
||
`source` names the module the view belongs to ("feed" | "plex"); `filters` is that module's
|
||
serialized filter JSON the frontend applies with one click. `position` orders them in the sidebar
|
||
(drag-reorder, per source); `is_default` marks the one view applied automatically on load — at most
|
||
one per (user, source), enforced by the API on set. The name is unique per (user, source)."""
|
||
|
||
__tablename__ = "saved_views"
|
||
__table_args__ = (
|
||
UniqueConstraint("user_id", "source", "name", name="uq_user_source_saved_view"),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||
source: Mapped[str] = mapped_column(String(16), default="feed", server_default="feed")
|
||
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 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.files_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) frees
|
||
the file with its last holder, and `last_access_at` orders the GC's LRU eviction. How long a file
|
||
stays is not the asset's: each holder's job carries its own `expires_at` (its library folder's)."""
|
||
|
||
__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 files_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 files_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))
|
||
# "video" | "audio" | "file" — the media-kind discriminator that drives which library actions
|
||
# apply (play/edit for media; download-only for a generic uploaded file). Null while an asset is
|
||
# still pending (kind is known once the file is probed at finalize). It is the single stored home
|
||
# of the invariant `downloads.mediakind.derive` used to compute — never re-derived in prose (an
|
||
# earlier ad-hoc `vcodec is None and acodec` check lived only in links.py; consolidated here).
|
||
media_kind: Mapped[str | None] = mapped_column(String(16))
|
||
nfo_written: 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 in the Files library. 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))
|
||
# The file's SHA-256 (hex), so a machine fetching it can check what it got. Hashed while an upload
|
||
# streams in, otherwise on the first ask (`routes.files`); NULL = not hashed yet.
|
||
sha256: Mapped[str | None] = mapped_column(String(64))
|
||
|
||
@validates("rel_path")
|
||
def _forget_hash(self, _key: str, rel_path: str | None) -> str | None:
|
||
# A hash names the bytes at `rel_path`: a new file there (a conversion, a retry) is not them.
|
||
self.sha256 = None
|
||
return rel_path
|
||
|
||
|
||
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 FileFolder(Base, TimestampMixin):
|
||
"""A folder of the Files library (`app.downloads.folders`): a row, never a directory.
|
||
|
||
A SOURCE folder (`source_key` set: youtube / plex / upload / site:<host>) is made the first time a
|
||
user gets a file from that origin; an OWN folder (`name` set) is one the user made; the HUB folder
|
||
(neither set, one per user) is where they swap files with a machine of theirs. Every file —
|
||
a `DownloadJob` — sits in exactly one (`DownloadJob.folder_id`). `retention_days` is how long a
|
||
file stays once done (NULL = until deleted); a file's own `expires_at` is stamped from it."""
|
||
|
||
__tablename__ = "file_folders"
|
||
__table_args__ = (
|
||
UniqueConstraint("user_id", "source_key", name="uq_file_folder_source"),
|
||
Index(
|
||
"uq_file_folder_own_name",
|
||
"user_id",
|
||
func.lower(text("name")),
|
||
unique=True,
|
||
postgresql_where=text("name IS NOT NULL"),
|
||
),
|
||
# One Hub folder per user.
|
||
Index("uq_file_folder_hub", "user_id", unique=True, postgresql_where=text("kind = 'hub'")),
|
||
CheckConstraint(
|
||
"(kind = 'source' AND source_key IS NOT NULL AND name IS NULL)"
|
||
" OR (kind = 'own' AND name IS NOT NULL AND source_key IS NULL)"
|
||
" OR (kind = 'hub' AND name IS NULL AND source_key IS NULL)",
|
||
name="ck_file_folder_kind",
|
||
),
|
||
)
|
||
|
||
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)) # folders.FolderKind
|
||
# "site:" + a host name (DNS caps one at 253 characters).
|
||
source_key: Mapped[str | None] = mapped_column(String(260))
|
||
name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
|
||
retention_days: Mapped[int | None] = mapped_column(Integer)
|
||
|
||
|
||
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))
|
||
|
||
|
||
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")
|
||
|
||
|
||
def _ready_on_insert(context) -> datetime | None:
|
||
"""A job inserted already done (an upload kept as it came) was ready the moment it was made; one
|
||
that becomes done later is stamped then (`folders.stamp_done`, `service.finalize_if_running`)."""
|
||
done = context.get_current_parameters().get("status") == "done"
|
||
return datetime.now(timezone.utc) if done else None
|
||
|
||
|
||
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: there is
|
||
no server push channel, and the worker is a separate process anyway). `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"),
|
||
# Every way a job becomes done stamps when (`folders.stamp_done`, `finalize_if_running`).
|
||
CheckConstraint(
|
||
"status <> 'done' OR ready_at IS NOT NULL", name="ck_download_jobs_ready_at"
|
||
),
|
||
)
|
||
|
||
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), "edit" (a per-user trim/crop derivative of another job), "upload" (a
|
||
# user's own file, converted by the worker when it is not phone-safe — see `downloads.upload`) or
|
||
# "clip" (a range cut straight from a YouTube video or a Plex item — see `downloads.clip`).
|
||
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}} in pixels. For clip
|
||
# jobs: the clip spec {source, ref, start_s, end_s, audio_track?, crop?:{x,y,w,h,aspect}}, its crop
|
||
# in fractions of the box it was drawn in (`downloads.clip.normalize`).
|
||
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(DISPLAY_TEXT_MAX))
|
||
# 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(DISPLAY_TEXT_MAX))
|
||
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) # raw yt-dlp/ffmpeg text (technical detail)
|
||
error_code: Mapped[str | None] = mapped_column(
|
||
String(24)
|
||
) # category, see downloads.errors.classify
|
||
# The host-specific failure-catalog entry on top of the category (`facebook.login_wall`), or NULL.
|
||
error_detail: Mapped[str | None] = mapped_column(String(48)) # see downloads.errors.diagnose
|
||
queue_pos: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||
# The Files library: the folder this file sits in (`downloads.folders.place` at creation; an own
|
||
# folder is emptied back into the source folders before it is deleted, hence RESTRICT), whether
|
||
# the user keeps it past its folder's retention, and when it goes (stamped once done; NULL =
|
||
# never). `expiry_warned` marks the one warning the GC sends before it deletes the file.
|
||
folder_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("file_folders.id", ondelete="RESTRICT"), index=True
|
||
)
|
||
# The origin the file came from (the source folder's key), recorded where it is placed: an edit
|
||
# traces its origin through its parent, and that parent may be deleted or expire later — the
|
||
# file must still know which source folder it may go back to. NULL only for a file placed before
|
||
# the column existed outside a source folder; `folders.source_key_for` works those out.
|
||
source_key: Mapped[str | None] = mapped_column(String(260))
|
||
kept: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||
expiry_warned: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||
# When the file became done — what the library's "Added" means: a download that failed for days
|
||
# and then went through is new to its user, so it sorts as new. `created_at` stays when it was
|
||
# asked for (the queue's order). Set again when a file is made anew (a retry, a re-download).
|
||
ready_at: Mapped[datetime | None] = mapped_column(
|
||
DateTime(timezone=True), default=_ready_on_insert
|
||
)
|
||
|
||
|
||
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"))
|
||
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)
|
||
# Bumped whenever the password changes; folded into the signed watch grant so rotating the
|
||
# password immediately invalidates any grants issued under the old one (instead of them living
|
||
# out the 6h grant TTL).
|
||
password_version: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||
|
||
|
||
class DownloadCredential(Base, TimestampMixin, UpdatedAtMixin):
|
||
"""A user's stored login cookies for ONE auth-walled download host (Facebook, Instagram, …).
|
||
|
||
Per-user, per-host: each user pastes their OWN browser cookies.txt (Netscape format) for a host
|
||
where they're logged in (e.g. a closed Facebook group they belong to), and a download of a
|
||
yt-dlp-supported, auth-walled link on that host is retried with the ACTING user's cookie. The
|
||
blob is Fernet-encrypted at rest (`cookies_enc`) and is NEVER returned by any API/log/audit —
|
||
the routes are write-only (host + updated_at only). See `downloads.credentials`.
|
||
|
||
Two load-bearing security controls live around this row, NOT on it:
|
||
* **cache privacy** — a cookie-authed download SALTS its MediaAsset `format_sig` with the
|
||
user id, so the authed asset is private to that user and can never be cache-served to
|
||
another user (`downloads.credentials.cookie_salt`).
|
||
* **host scope (SEC-12)** — a cookie is attached ONLY when the download URL's netloc matches
|
||
this row's `host_key` (`downloads.credentials.host_key_for_url`), so an arbitrary `?url=`
|
||
can't exfiltrate the user's session to a look-alike host.
|
||
|
||
`host_key` is a short canonical key from the supported-host registry (e.g. "facebook"), not the
|
||
raw netloc — one credential can cover several domains of the same site (facebook.com/fb.watch)."""
|
||
|
||
__tablename__ = "download_credentials"
|
||
__table_args__ = (UniqueConstraint("user_id", "host_key", name="uq_download_credential"),)
|
||
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||
host_key: Mapped[str] = mapped_column(String(64))
|
||
cookies_enc: Mapped[str] = mapped_column(Text) # Fernet ciphertext — never returned/logged
|
||
|
||
|
||
# --- 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)
|
||
|
||
|
||
class ComfortPin(Base, TimestampMixin):
|
||
"""One entry on a user's comfort shelf — the anti-paralysis fallback: "just put on X".
|
||
|
||
Deliberately NOT a boolean on `VideoState`/`PlexState`, and NOT a polymorphic `(kind, ref)` string
|
||
pair. A flag cannot ORDER a shelf and cannot hold a channel or a show — and comfort media is
|
||
usually "put on X", a series or a channel, not one particular video. A string ref would order and
|
||
hold anything, but it would also lose the foreign key: a Plex item dropped from the library, or an
|
||
unsubscribed channel, would leave a dead tile on the one surface whose whole job is to be
|
||
reliable. So instead: one nullable CASCADE-ing FK per pinnable kind, and a check constraint that
|
||
exactly one of them is set.
|
||
|
||
That constraint is the house rule applied to a five-way union — the invariant lives in the schema,
|
||
not in a comment every insert site has to remember. `kind`/`ref` below derive FROM the columns, so
|
||
there is no second vocabulary to keep in sync.
|
||
|
||
NULLs are DISTINCT in a Postgres unique index, so the five per-kind unique constraints each stop a
|
||
double-pin of the same thing without colliding across kinds."""
|
||
|
||
__tablename__ = "comfort_pins"
|
||
__table_args__ = (
|
||
CheckConstraint(
|
||
"num_nonnulls(video_id, channel_id, plex_item_id, plex_show_id, playlist_id) = 1",
|
||
name="ck_comfort_pin_one_ref",
|
||
),
|
||
UniqueConstraint("user_id", "video_id", name="uq_comfort_pin_video"),
|
||
UniqueConstraint("user_id", "channel_id", name="uq_comfort_pin_channel"),
|
||
UniqueConstraint("user_id", "plex_item_id", name="uq_comfort_pin_plex_item"),
|
||
UniqueConstraint("user_id", "plex_show_id", name="uq_comfort_pin_plex_show"),
|
||
UniqueConstraint("user_id", "playlist_id", name="uq_comfort_pin_playlist"),
|
||
)
|
||
|
||
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 | None] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE"))
|
||
channel_id: Mapped[str | None] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
|
||
plex_item_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("plex_items.id", ondelete="CASCADE")
|
||
)
|
||
plex_show_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("plex_shows.id", ondelete="CASCADE")
|
||
)
|
||
playlist_id: Mapped[int | None] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE"))
|
||
|
||
# 0-based shelf order. The shelf is short and hand-curated, so a dense integer the reorder
|
||
# endpoint rewrites wholesale beats a fractional index nobody would ever exercise here.
|
||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|