from datetime import date, datetime from typing import TYPE_CHECKING from sqlalchemy import ( BigInteger, Boolean, CheckConstraint, Computed, Date, DateTime, Float, ForeignKey, Index, Integer, JSON, LargeBinary, SmallInteger, String, Text, UniqueConstraint, func, ) from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from app.db import Base from app.runstatus import RunStatus, RunTrigger, sql_in from app.utils import canonical_email if TYPE_CHECKING: from app.catalog.models import Subscription # 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 ApiQuotaUsage(Base): """Tracks YouTube Data API units spent per Pacific-time day (the quota resets at midnight Pacific). The whole app shares one daily budget.""" __tablename__ = "api_quota_usage" day: Mapped[date] = mapped_column(Date, primary_key=True) units_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0") class QuotaEvent(Base): """Per-spend audit log for YouTube API quota attribution. `user_id` is the end-user whose action caused the spend; NULL means background/system work (scheduler backfill, enrichment, resync). The aggregate budget still lives in ApiQuotaUsage; this is detail.""" __tablename__ = "quota_events" id: Mapped[int] = mapped_column(primary_key=True) # SET NULL on user delete: keep the audit trail (it just becomes system-attributed). user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), index=True ) action: Mapped[str] = mapped_column(String(40), index=True) units: Mapped[int] = mapped_column(Integer) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True ) # The background run that spent it (app/jobruns.py); NULL = a request's own spend. SET NULL: a # reaped run leaves the spend in the budget's history. job_run_id: Mapped[int | None] = mapped_column( ForeignKey("job_runs.id", ondelete="SET NULL"), index=True ) class AuditLog(Base): """Append-only admin audit trail: who did what, when, and (where it applies) the before->after values. `actor_id` is the acting admin; NULL means the background did it (SET NULL on user delete keeps the trail). Logged per ACTION, never per row; a background run is not an action and has its own history (`JobRun`). `before`/`after` are small JSON snapshots (secrets excluded). `request_id` is the request that made the change, so its log lines are one click away (NULL for rows written outside a request, and for rows older than the column).""" __tablename__ = "audit_log" id: Mapped[int] = mapped_column(primary_key=True) actor_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), index=True ) action: Mapped[str] = mapped_column(String(48), index=True) target_type: Mapped[str | None] = mapped_column(String(32)) target_id: Mapped[str | None] = mapped_column(String(128)) summary: Mapped[str | None] = mapped_column(String(255)) before: Mapped[dict | None] = mapped_column(JSON) after: Mapped[dict | None] = mapped_column(JSON) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True ) request_id: Mapped[str | None] = mapped_column(String(64), index=True) class LogEvent(Base): """One stored log event — the `log_events` table the diagnostics console reads. Rows are written by app/logsink.py's background writer, never inside a request's transaction, already redacted (the same `logs.record_fields` the stdout line is built from). `user_id` has NO foreign key on purpose: the writer runs behind the event, so a line about an account can arrive after that account was purged, and a constraint would make it fail the whole batch it travels in. `auth.purge_user` deletes an account's rows explicitly instead, after `logs.forget_user` makes every later write in this process store such a line without the account; the retention job (`log_retention_days`) bounds whatever another process writes late.""" __tablename__ = "log_events" __table_args__ = ( Index("ix_log_events_user_ts", "user_id", "ts"), # An issue's bundle is `WHERE debug_session_id = …`; nearly every row has none. Index( "ix_log_events_debug_session", "debug_session_id", postgresql_where="debug_session_id IS NOT NULL", ), # A run's lines are `WHERE run_id = …`; request lines carry none. Index("ix_log_events_run", "run_id", postgresql_where="run_id IS NOT NULL"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True) ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) level: Mapped[str] = mapped_column(String(10)) sev: Mapped[int] = mapped_column(SmallInteger) source: Mapped[str] = mapped_column(String(16)) logger: Mapped[str] = mapped_column(String(128)) event: Mapped[str | None] = mapped_column(String(64)) msg: Mapped[str] = mapped_column(Text) attrs: Mapped[dict | None] = mapped_column(JSONB) exc: Mapped[str | None] = mapped_column(Text) request_id: Mapped[str | None] = mapped_column(String(64), index=True) user_id: Mapped[int | None] = mapped_column(Integer) debug_session_id: Mapped[str | None] = mapped_column(String(64)) # A DOWNLOAD job's id (download_jobs) — not a background run, which is `run_id`. job_id: Mapped[int | None] = mapped_column(Integer) # The background run (job_runs) that logged it. No foreign key, as `user_id`: the writer runs # behind the event, and a run reaped meanwhile must not fail the batch its lines travel in. run_id: Mapped[int | None] = mapped_column(BigInteger) version: Mapped[str | None] = mapped_column(String(64)) class DebugSession(Base): """One recording: Start → reproduce → Stop (app/debugsession.py). While it is open, every request of its user that names it (`X-Debug-Session`) is logged at `level` and tagged with `id`, and the issue it ends in is built from the rows carrying that tag. `until` is the hard cap the server set at Start; an open session past it is over whether or not the sweep has closed it yet. One OPEN session per user (`uq_debug_sessions_open_per_user`, partial on `ended_at IS NULL`).""" __tablename__ = "debug_sessions" __table_args__ = ( Index( "uq_debug_sessions_open_per_user", "user_id", unique=True, postgresql_where="ended_at IS NULL", ), ) id: Mapped[str] = mapped_column(String(16), primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) level: Mapped[str] = mapped_column(String(8)) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) until: Mapped[datetime] = mapped_column(DateTime(timezone=True)) ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # app/debugsession.py `EndReason`: stopped by the user, or past `until`. ended_reason: Mapped[str | None] = mapped_column(String(16)) issue_url: Mapped[str | None] = mapped_column(String(512)) # The issue being written from it (app/debugsession.py `save_draft` holds its shape). draft: Mapped[dict | None] = mapped_column(JSONB) class DebugScreenshot(Base): """A screenshot attached to a debug session — pasted, dropped, or captured from the pill. Stored in the row: bounded per session (app/debugsession.py), and gone with it by cascade.""" __tablename__ = "debug_screenshots" id: Mapped[int] = mapped_column(primary_key=True) session_id: Mapped[str] = mapped_column( ForeignKey("debug_sessions.id", ondelete="CASCADE"), index=True ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) mime: Mapped[str] = mapped_column(String(16)) size: Mapped[int] = mapped_column(Integer) data: Mapped[bytes] = mapped_column(LargeBinary) class AppState(Base): """Single-row global app state (admin-controlled).""" __tablename__ = "app_state" id: Mapped[int] = mapped_column(primary_key=True, default=1) sync_paused: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # First-run install wizard. `configured` is False on a fresh instance (the app runs in # setup mode: only the wizard works) and flipped True when the wizard finishes. While # unconfigured, a one-time setup token (only its SHA-256 hash stored) gates the wizard; # the raw token + URL are printed to the container logs on each startup. configured: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") setup_token_hash: Mapped[str | None] = mapped_column(String(64)) class JobRun(Base): """One run of a background job — scheduled, an admin's "run now", or a user's ad-hoc work on one target — written when it starts and again when it ends (app/jobruns.py), each in its own committed session, so the history survives the job's own rollback and a restart. A row left `running` by a process that died is set `interrupted` at the next startup. `quota_units` is what the run spent of the YouTube budget: the `quota_events` rows carrying its id, summed when it ends. `summary` is the English one-line fallback; `result` / `failure` are the same outcome as data, which the client names in the reader's language.""" __tablename__ = "job_runs" __table_args__ = ( Index("ix_job_runs_job_started", "job", "started_at"), CheckConstraint(f"status IN ({sql_in(RunStatus)})", name="ck_job_runs_status"), CheckConstraint(f"trigger IN ({sql_in(RunTrigger)})", name="ck_job_runs_trigger"), # Only a run still going has no end. CheckConstraint( f"(status = '{RunStatus.RUNNING}') = (finished_at IS NULL)", name="ck_job_runs_finished" ), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True) job: Mapped[str] = mapped_column(String(40)) trigger: Mapped[str] = mapped_column(String(16)) # Who started it: the admin of a manual run, the user of an ad-hoc one; NULL = the schedule. actor_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), index=True ) # What an ad-hoc run worked on (a channel id); NULL for the recurring jobs. target: Mapped[str | None] = mapped_column(String(128)) status: Mapped[str] = mapped_column(String(16)) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) summary: Mapped[str | None] = mapped_column(Text) result: Mapped[dict | None] = mapped_column(JSONB) error: Mapped[str | None] = mapped_column(Text) failure: Mapped[dict | None] = mapped_column(JSONB) quota_units: Mapped[int] = mapped_column(Integer, default=0, server_default="0") class SystemConfig(Base): """Admin-set overrides for operational config that otherwise comes from env/config defaults. Generic key/value store (one row per key); absent = use the env/config default. DB-backed so it's editable from the admin Configuration page at runtime without a redeploy. Secret values (e.g. SMTP password) are stored Fernet-encrypted (`encrypted` flag) using TOKEN_ENCRYPTION_KEY; non-secrets are plaintext. The set of known keys + their types/groups/defaults lives in app.sysconfig (the single source of truth).""" __tablename__ = "system_config" key: Mapped[str] = mapped_column(String(64), primary_key=True) value: Mapped[str | None] = mapped_column(Text) encrypted: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=func.now(), onupdate=func.now() ) class Notification(Base): """A durable, server-backed per-user notification (the inbox center, phase 1). Distinct from the client-side transient "bell" (toasts kept only in localStorage): these survive reloads and devices, and are produced server-side (first producer = the maintenance/validation job's batched "N saved/playlisted videos removed" notice). The column is named `data` (not `metadata`, which SQLAlchemy's declarative Base reserves): a free-form JSON payload (e.g. the list of removed videos) the UI can render. `read` and `dismissed` are separate: read clears the unread badge but keeps the row in the inbox; dismissed removes it from the default view. Unread rows never auto-expire; read ones are trimmed past a soft per-user cap (see app.notifications.trim_read).""" __tablename__ = "notifications" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) type: Mapped[str] = mapped_column(String(32), index=True) # e.g. "maintenance" title: Mapped[str] = mapped_column(String(255)) body: Mapped[str | None] = mapped_column(Text) data: Mapped[dict | None] = mapped_column(JSON) read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", index=True) dismissed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True ) class ApiToken(Base, TimestampMixin): """A user's token for a machine to reach their Files library with (`app.apitokens`): sent as `Authorization: Bearer sfl_…`, honoured on `/api/files` only, and only in the folders its grants name. Only the SHA-256 of the token is kept — it is shown once, when it is made — plus its first characters (`prefix`), so the user can tell their tokens apart.""" __tablename__ = "api_tokens" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) name: Mapped[str] = mapped_column(String(80)) prefix: Mapped[str] = mapped_column(String(16)) token_hash: Mapped[str] = mapped_column(String(64), unique=True) expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # NULL = never last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # The one notice its user gets before it runs out (`apitokens.warn_expiring`). expiry_warned: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") class ApiTokenGrant(Base): """What one token may do in one folder. A folder with no grant is out of the token's reach.""" __tablename__ = "api_token_grants" __table_args__ = ( UniqueConstraint("token_id", "folder_id", name="uq_api_token_grant"), CheckConstraint( "can_list OR can_read OR can_write OR can_delete", name="ck_api_token_grant_some" ), ) id: Mapped[int] = mapped_column(primary_key=True) token_id: Mapped[int] = mapped_column(ForeignKey("api_tokens.id", ondelete="CASCADE")) folder_id: Mapped[int] = mapped_column( ForeignKey("file_folders.id", ondelete="CASCADE"), index=True ) can_list: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") can_read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") can_write: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") can_delete: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # --- Plex integration (optional) ------------------------------------------------------------- # A dedicated, self-contained catalog mirrored from a locally-reachable Plex server. Playback is # from the LOCAL physical file (Plex is metadata only); per-user watch state lives in plex_states # so it works for users without a Plex account. Mirrors the app's "shared catalog, private # per-user state" model. The weighted FTS search_vector uses the same unaccent_simple config as # `videos` (migrations 0031/0032). class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin): """A mirrored Plex library section. Only `enabled` sections are exposed in the feed. `plex_key` is the section id on the Plex server.""" __tablename__ = "plex_libraries" id: Mapped[int] = mapped_column(primary_key=True) plex_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) title: Mapped[str] = mapped_column(String(255)) kind: Mapped[str] = mapped_column(String(16)) # "movie" | "show" enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # HU-overlay support. `metadata_lang` tags the section's metadata language. An OVERLAY # library (overlay_of set) is the Hungarian-metadata twin of another section: it is never browsable # on its own — its titles' HU fields are written onto the matched EN rows (paired by `guid`). metadata_lang: Mapped[str] = mapped_column(String(8), default="en", server_default="en") overlay_of: Mapped[int | None] = mapped_column( ForeignKey("plex_libraries.id", ondelete="SET NULL"), index=True ) class PlexShow(Base, TimestampMixin, UpdatedAtMixin): """A TV show (grandparent of episodes) — holds poster + summary for the drill-down page, plus the same filterable metadata as movies (genres/rating/content_rating/studio/people) so the TV grid can be filtered/sorted like the movie grid. All mirrored cheaply from the show listing.""" __tablename__ = "plex_shows" __table_args__ = ( Index("ix_plex_shows_genres_gin", "genres", postgresql_using="gin"), Index("ix_plex_shows_directors_gin", "directors", postgresql_using="gin"), Index("ix_plex_shows_cast_gin", "cast_names", postgresql_using="gin"), Index("ix_plex_shows_genres_hu_gin", "genres_hu", postgresql_using="gin"), # ix_plex_shows_collections_gin already created with collections (migration 0047). ) id: Mapped[int] = mapped_column(primary_key=True) rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) library_id: Mapped[int] = mapped_column( ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True ) # Canonical external id (e.g. "tmdb:12345") — the key that pairs this EN row with its HU-overlay # twin during sync. Indexed for the guid->row overlay match. guid: Mapped[str | None] = mapped_column(String(64), index=True) title: Mapped[str] = mapped_column(String(512)) summary: Mapped[str | None] = mapped_column(Text) year: Mapped[int | None] = mapped_column(Integer) thumb_key: Mapped[str | None] = mapped_column( String(512) ) # Plex thumb path (proxied, not stored) art_key: Mapped[str | None] = mapped_column(String(512)) child_count: Mapped[int | None] = mapped_column(Integer) # seasons collection_keys: Mapped[list | None] = mapped_column( JSONB(none_as_null=True) ) # Plex collections this show is in added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) # Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem). rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb) content_rating: Mapped[str | None] = mapped_column(String(16), index=True) studio: Mapped[str | None] = mapped_column(String(255), index=True) # network/studio originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True) # first air date # none_as_null: an empty tag list must persist as SQL NULL, not JSON `null`. Without it a Python # None round-trips as a JSON scalar that jsonb_array_elements_text() can't unnest ("cannot extract # elements from a scalar") — the trap a metadata-poor library sprang on /facets. Same on plex_items. genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) people_text: Mapped[str | None] = mapped_column( Text ) # cast+directors, folded into search_vector # Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3. people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so # the info page serves from this row instead of a live plex.metadata() call. NULL until enriched. imdb_rating: Mapped[float | None] = mapped_column( Float ) # IMDb-specific score (vs `rating` = audienceRating) imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link tagline: Mapped[str | None] = mapped_column(Text) full_cast: Mapped[list | None] = mapped_column( JSONB(none_as_null=True) ) # ordered [{name, role, thumb}] # --- TMDB enrichment: see the identical block on plex_items for the full rationale. Additive; # served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) --- tmdb_id: Mapped[int | None] = mapped_column(Integer) tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10) tmdb_vote_count: Mapped[int | None] = mapped_column(Integer) budget: Mapped[int | None] = mapped_column(BigInteger) revenue: Mapped[int | None] = mapped_column(BigInteger) keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] crew: Mapped[list | None] = mapped_column( JSONB(none_as_null=True) ) # [{name, job, tmdb_id, thumb}] studios: Mapped[list | None] = mapped_column( JSONB(none_as_null=True) ) # TMDB production companies [str] production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched, # so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors. title_hu: Mapped[str | None] = mapped_column(String(512)) tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline summary_hu: Mapped[str | None] = mapped_column(Text) genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) search_vector: Mapped[object | None] = mapped_column( TSVECTOR, Computed( "setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || " "setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || " "setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')", persisted=True, ), ) search_vector_hu: Mapped[object | None] = mapped_column( TSVECTOR, Computed( "setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || " "setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || " "setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')", persisted=True, ), ) class PlexSeason(Base, TimestampMixin, UpdatedAtMixin): """A season under a show — used to order episodes for prev/next stepping.""" __tablename__ = "plex_seasons" id: Mapped[int] = mapped_column(primary_key=True) rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) show_id: Mapped[int] = mapped_column( ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True ) title: Mapped[str | None] = mapped_column(String(255)) season_number: Mapped[int | None] = mapped_column(Integer, index=True) thumb_key: Mapped[str | None] = mapped_column(String(512)) class PlexItem(Base, TimestampMixin, UpdatedAtMixin): """A playable LEAF — a movie or a single episode. This is what the feed lists and the player plays. Movies carry `library_id` and no parents; episodes carry show/season refs + season/episode numbers. `file_path` is the Plex-side absolute path of the physical media, mapped through `plex_path_map` to a local mount at playback time. `playable` is the ffprobe-derived browser-compatibility class (direct | remux | transcode).""" __tablename__ = "plex_items" __table_args__ = ( Index("ix_plex_items_show_order", "show_id", "season_number", "episode_number"), Index("ix_plex_items_genres_gin", "genres", postgresql_using="gin"), Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"), Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"), Index("ix_plex_items_collections_gin", "collection_keys", postgresql_using="gin"), Index("ix_plex_items_genres_hu_gin", "genres_hu", postgresql_using="gin"), ) id: Mapped[int] = mapped_column(primary_key=True) rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) library_id: Mapped[int] = mapped_column( ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True ) kind: Mapped[str] = mapped_column(String(16), index=True) # "movie" | "episode" # Canonical external id (e.g. "tmdb:12345") — pairs a MOVIE row with its HU-overlay twin during # sync; indexed for the guid->row match. Episodes carry it too but it's unused for them. guid: Mapped[str | None] = mapped_column(String(64), index=True) show_id: Mapped[int | None] = mapped_column( ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True ) season_id: Mapped[int | None] = mapped_column( ForeignKey("plex_seasons.id", ondelete="CASCADE"), index=True ) season_number: Mapped[int | None] = mapped_column(Integer) episode_number: Mapped[int | None] = mapped_column(Integer) title: Mapped[str] = mapped_column(String(512)) summary: Mapped[str | None] = mapped_column(Text) year: Mapped[int | None] = mapped_column(Integer) duration_s: Mapped[int | None] = mapped_column(Integer) thumb_key: Mapped[str | None] = mapped_column(String(512)) art_key: Mapped[str | None] = mapped_column(String(512)) cast: Mapped[list | None] = mapped_column(JSON) # [role names] # Physical media (from the Plex Part). file_path: Mapped[str | None] = mapped_column(Text) # Plex-side absolute path part_key: Mapped[str | None] = mapped_column(String(255)) # /library/parts/... (fallback fetch) container: Mapped[str | None] = mapped_column(String(16)) codec_video: Mapped[str | None] = mapped_column(String(32)) codec_audio: Mapped[str | None] = mapped_column(String(32)) size_bytes: Mapped[int | None] = mapped_column(BigInteger) # Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed). playable: Mapped[str | None] = mapped_column(String(12), index=True) probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Transcode HW-capability facet (the playback-class filter). NULL for non-transcode items; for a # `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable # AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that # breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`: # set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth # backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable # home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared # classifier so the catalog facet and the runtime encoder choice never disagree by codec. transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True) # Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL = # not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`. video_bit_depth: Mapped[int | None] = mapped_column(Integer) # Watermark for the bit-depth backfill: when this item's media Stream was last probed for # bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are # probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`. media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) # Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls; # `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio # filters (GIN-indexed) and double as content signals for a future watch-habit recommender. rating: Mapped[float | None] = mapped_column(Float, index=True) content_rating: Mapped[str | None] = mapped_column(String(16), index=True) studio: Mapped[str | None] = mapped_column(String(255), index=True) originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True) genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # rating_keys of the Plex collections this movie belongs to (GIN — filter + card-strip siblings). collection_keys: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # Cast + director names, space-joined — folded into search_vector (weight B) so the search box # finds titles by a person's name (and ranks them above summary-only mentions). people_text: Mapped[str | None] = mapped_column(Text) # Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3. people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so # the info page serves from this row instead of a live plex.metadata() call. NULL until enriched. imdb_rating: Mapped[float | None] = mapped_column( Float ) # IMDb-specific score (vs `rating` = audienceRating) imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link tagline: Mapped[str | None] = mapped_column(Text) full_cast: Mapped[list | None] = mapped_column( JSONB(none_as_null=True) ) # ordered [{name, role, thumb}] # --- TMDB enrichment: matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB # backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating # above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched; # `tmdb_enriched_at` is the "tried" watermark (stamped on a definitive no-match too, so an # unresolvable title isn't re-fetched every run; left NULL only on a transient fetch error). --- tmdb_id: Mapped[int | None] = mapped_column(Integer) tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10) tmdb_vote_count: Mapped[int | None] = mapped_column(Integer) budget: Mapped[int | None] = mapped_column(BigInteger) # USD (movies; TV has none) revenue: Mapped[int | None] = mapped_column(BigInteger) keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] crew: Mapped[list | None] = mapped_column( JSONB(none_as_null=True) ) # [{name, job, tmdb_id, thumb}] studios: Mapped[list | None] = mapped_column( JSONB(none_as_null=True) ) # TMDB production companies [str] production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] # [{tmdb_id, title, poster, media_type}] — resolved to library rows (by guid) at serve time. recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) backdrops: Mapped[list | None] = mapped_column( JSONB(none_as_null=True) ) # TMDB backdrop file_paths [str] (crossfade pool) # Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched # (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors. title_hu: Mapped[str | None] = mapped_column(String(512)) tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline summary_hu: Mapped[str | None] = mapped_column(Text) genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) search_vector: Mapped[object | None] = mapped_column( TSVECTOR, Computed( "setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || " "setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || " "setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')", persisted=True, ), ) search_vector_hu: Mapped[object | None] = mapped_column( TSVECTOR, Computed( "setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || " "setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || " "setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')", persisted=True, ), ) class PlexPerson(Base, TimestampMixin, UpdatedAtMixin): """A small cast/crew headshot cache, keyed by name, so person filter-chips (the cards for the actors currently in the filter) can show a photo + count without a live per-card metadata fetch. Populated opportunistically when an item's RICH metadata is fetched — the info-page view and the periodic rich re-sync — since the cheap section listing that drives the main sync only carries the top few cast. Rows whose name no longer appears in ANY item's cast_names/directors (a library removal orphaned them) are pruned by the maintenance sweep, mirroring how Plex drops orphaned metadata. The filterable cast lives in plex_items/plex_shows.cast_names (self-cleaning with the row); this table is the people's own facts: the headshot link, the TMDB link and the TMDB bio (the headshot FILE lives in the image cache, filled by the people_enrich job).""" __tablename__ = "plex_people" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(255), unique=True, index=True) photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None # TMDB person id, linked by name from a title's TMDB credits (the /person # biography fetch builds on it). NULL until a TMDB-enriched title names this person. tmdb_person_id: Mapped[int | None] = mapped_column(Integer) # --- TMDB person enrichment: fetched from /person/{tmdb_person_id} by the people_enrich job, # or by the first person-page open the job hasn't reached, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a # no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id, # media_type, title}], resolved to library rows by guid at serve time (like recommendations). --- biography: Mapped[str | None] = mapped_column(Text) # TMDB's Hungarian biography, from the same request's `translations`; a Hungarian reader is # served it, and the instance-language `biography` when TMDB has none. Birthplace, aliases and # keywords have no Hungarian on TMDB, so they stay in the instance language. biography_hu: Mapped[str | None] = mapped_column(Text) birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is deathday: Mapped[str | None] = mapped_column(String(10)) place_of_birth: Mapped[str | None] = mapped_column(Text) known_for_department: Mapped[str | None] = mapped_column(String(64)) also_known_as: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] known_for: Mapped[list | None] = mapped_column( JSONB(none_as_null=True) ) # [{tmdb_id, media_type, title}] tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class PlexCollection(Base, TimestampMixin, UpdatedAtMixin): """A mirrored Plex collection (a grouping of movies or shows — franchises like Avatar, curated sets like MCU, or auto/community lists like IMDb Top 250). Mirrored in full by the background sync so reads never hit Plex's slow collection queries. Membership is stored as `collection_keys` on the member movies (plex_items) / shows (plex_shows). `smart` = Plex's query-based flag; `editable` = we may write changes back to Plex (default False = read-only; set for curated ones).""" __tablename__ = "plex_collections" id: Mapped[int] = mapped_column(primary_key=True) rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) library_id: Mapped[int] = mapped_column( ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True ) title: Mapped[str] = mapped_column(String(512)) summary: Mapped[str | None] = mapped_column(Text) thumb_key: Mapped[str | None] = mapped_column(String(512)) child_count: Mapped[int | None] = mapped_column(Integer) smart: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") editable: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class PlexState(Base, UpdatedAtMixin): """Per-user watch state for a Plex item — mirrors VideoState. Lives in Siftlode's own DB so it works for users WITHOUT a Plex account (the whole point of the access-layer design). `synced_to_plex` marks whether this state was pushed back to a linked Plex account.""" __tablename__ = "plex_states" __table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) item_id: Mapped[int] = mapped_column( ForeignKey("plex_items.id", ondelete="CASCADE"), index=True ) status: Mapped[str] = mapped_column(String(12), default="new", server_default="new") position_seconds: Mapped[int] = mapped_column( Integer, default=0, server_default="0", nullable=False ) watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) synced_to_plex: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") class PlexLink(Base, UpdatedAtMixin): """Links a Siftlode user to a Plex account for two-way watch-state sync (one row per user). For the owner (the MVP scope) the row uses the server's admin token — `uses_admin=True`, `plex_token_enc=None` — so no separate Plex login is needed; the admin token already reads/writes the owner account's watch state. `plex_account_id` is that Plex account's numeric id, used to filter the shared watch-history feed to this user (Phase C). Reserved for later: a friend can link their OWN Plex account via PIN OAuth, storing a personal `plex_token_enc` (uses_admin=False) — the sync loop then iterates link rows uniformly. `initial_import_done` guards the one-time "Plex is master" import; `last_watch_sync_at` is the high-water mark for the incremental Plex→Siftlode pass.""" __tablename__ = "plex_link" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True ) uses_admin: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") plex_token_enc: Mapped[str | None] = mapped_column(Text) # P5b friend tokens (encrypted) plex_account_id: Mapped[int | None] = mapped_column(Integer) plex_username: Mapped[str | None] = mapped_column(String(255)) sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") initial_import_done: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) last_watch_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) linked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class PlexPlaylist(Base, TimestampMixin, UpdatedAtMixin): """A per-user ORDERED watch-list of Plex items — Siftlode-native (lives in our own DB, works for users WITHOUT a Plex account, like plex_states). Unlike collections (shared, library-level), a playlist is owned by one user and its items are ordered. Optional Plex-direction sync is a later phase (`plex_rating_key` set once pushed back to the owner's Plex account).""" __tablename__ = "plex_playlists" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) title: Mapped[str] = mapped_column(String(512)) plex_rating_key: Mapped[str | None] = mapped_column(String(32)) class PlexPlaylistItem(Base): """One ordered entry in a playlist. `position` (0-based) orders playback.""" __tablename__ = "plex_playlist_items" __table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),) id: Mapped[int] = mapped_column(primary_key=True) playlist_id: Mapped[int] = mapped_column(ForeignKey("plex_playlists.id", ondelete="CASCADE")) item_id: Mapped[int] = mapped_column( ForeignKey("plex_items.id", ondelete="CASCADE"), index=True ) position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)