@@ -0,0 +1,463 @@
""" Plex ' s tables: a self-contained catalog mirrored from a local Plex server — libraries, shows,
seasons, items, people, collections, the server ' s playlists — and each user ' s private part of it:
watch state, the account link, their own playlists. Only this unit writes them; another unit reads
them along a declared edge, or asks this unit ' s service. """
from datetime import datetime
from sqlalchemy import (
BigInteger ,
Boolean ,
Computed ,
Date ,
DateTime ,
Float ,
ForeignKey ,
Index ,
Integer ,
JSON ,
String ,
Text ,
UniqueConstraint ,
)
from sqlalchemy . dialects . postgresql import JSONB , TSVECTOR
from sqlalchemy . orm import Mapped , mapped_column
from app . db import Base
from app . models import TimestampMixin , UpdatedAtMixin
# --- 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 )