refactor(plex): the unit's tables move into plex/models

PlexLibrary, PlexShow, PlexSeason, PlexItem, PlexPerson, PlexCollection, PlexState, PlexLink,
PlexPlaylist and PlexPlaylistItem leave app/models.py for app/plex/models.py; app.tables registers
them. Every import is re-pointed; no schema change.
This commit is contained in:
2026-10-19 21:42:07 +02:00
parent 4b6db47083
commit 533571f572
66 changed files with 566 additions and 574 deletions
+2 -1
View File
@@ -22,7 +22,8 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.discover import cards, queries from app.discover import cards, queries
from app.models import PlexItem, PlexShow, User from app.models import User
from app.plex.models import PlexItem, PlexShow
from app.catalog.models import Channel, Video from app.catalog.models import Channel, Video
from app.reader.models import ComfortPin, Playlist from app.reader.models import ComfortPin, Playlist
from app.plex import rollup from app.plex import rollup
+2 -1
View File
@@ -29,7 +29,8 @@ from app.feedquery import (
serialize, serialize,
sort_keys, sort_keys,
) )
from app.models import PlexItem, PlexShow, PlexState, User from app.models import User
from app.plex.models import PlexItem, PlexShow, PlexState
from app.catalog.models import Channel, Video from app.catalog.models import Channel, Video
from app.reader.models import PlaylistItem, VideoState from app.reader.models import PlaylistItem, VideoState
from app.plex import rollup from app.plex import rollup
+2 -1
View File
@@ -11,7 +11,8 @@ from sqlalchemy.orm import Session
from app import sysconfig from app import sysconfig
from app.discover import cards, queries from app.discover import cards, queries
from app.discover.queries import RAIL_LIMIT from app.discover.queries import RAIL_LIMIT
from app.models import PlexShow, User from app.models import User
from app.plex.models import PlexShow
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
from app.plex.rollup import NO_TS from app.plex.rollup import NO_TS
+2 -1
View File
@@ -28,7 +28,8 @@ from app.downloads.jobdb import (
) )
from app.downloads.stated import StatedFailure from app.downloads.stated import StatedFailure
from app.downloads.status import JobStatus from app.downloads.status import JobStatus
from app.models import DISPLAY_TEXT_MAX, PlexItem from app.models import DISPLAY_TEXT_MAX
from app.plex.models import PlexItem
from app.downloads.models import DownloadJob, MediaAsset from app.downloads.models import DownloadJob, MediaAsset
from app.plex import lookups as plex_lookups from app.plex import lookups as plex_lookups
from app.plex import paths as plex_paths from app.plex import paths as plex_paths
+2 -1
View File
@@ -24,7 +24,8 @@ from app.downloads import credentials
from app.downloads import edit as editmod from app.downloads import edit as editmod
from app.downloads import folders, formats, mediakind, quota, storage from app.downloads import folders, formats, mediakind, quota, storage
from app.downloads.status import HOLDING, JobKind, JobStatus from app.downloads.status import HOLDING, JobKind, JobStatus
from app.models import DISPLAY_TEXT_MAX, PlexItem, PlexShow, User from app.models import DISPLAY_TEXT_MAX, User
from app.plex.models import PlexItem, PlexShow
from app.catalog.models import Channel, Video from app.catalog.models import Channel, Video
from app.downloads.models import ( from app.downloads.models import (
DownloadJob, DownloadJob,
+1 -439
View File
@@ -5,10 +5,8 @@ from sqlalchemy import (
BigInteger, BigInteger,
Boolean, Boolean,
CheckConstraint, CheckConstraint,
Computed,
Date, Date,
DateTime, DateTime,
Float,
ForeignKey, ForeignKey,
Index, Index,
Integer, Integer,
@@ -20,7 +18,7 @@ from sqlalchemy import (
UniqueConstraint, UniqueConstraint,
func, func,
) )
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from app.db import Base from app.db import Base
@@ -514,439 +512,3 @@ class ApiTokenGrant(Base):
can_read: 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_write: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
can_delete: 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)
+1 -1
View File
@@ -5,7 +5,7 @@ router. Pure functions — no DB, no request state."""
from typing import NamedTuple from typing import NamedTuple
from app.models import PlexItem, PlexShow, PlexState from app.plex.models import PlexItem, PlexShow, PlexState
class ShowState(NamedTuple): class ShowState(NamedTuple):
+1 -1
View File
@@ -15,7 +15,7 @@ from fastapi import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs from app import logs
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow from app.plex.models import PlexCollection, PlexItem, PlexSeason, PlexShow
from app.plex.cache import CACHE_DIR from app.plex.cache import CACHE_DIR
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
+463
View File
@@ -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)
+1 -1
View File
@@ -13,7 +13,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs, sysconfig from app import logs, sysconfig
from app.models import PlexItem, PlexLibrary, PlexShow from app.plex.models import PlexItem, PlexLibrary, PlexShow
from app.plex import sync as plex_sync from app.plex import sync as plex_sync
from app.plex.client import PlexClient from app.plex.client import PlexClient
from app.plex.genres import hu_genres from app.plex.genres import hu_genres
+1 -1
View File
@@ -15,7 +15,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs, sysconfig from app import logs, sysconfig
from app.models import PlexPerson from app.plex.models import PlexPerson
from app.plex import imgcache from app.plex import imgcache
from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.client import PlexClient, PlexError, PlexNotConfigured
+1 -1
View File
@@ -10,7 +10,7 @@ from sqlalchemy import and_, case, func, or_, select, type_coerce
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import PlexItem, PlexLibrary from app.plex.models import PlexItem, PlexLibrary
from app.plex.genres import hu_genre from app.plex.genres import hu_genre
TS_CONFIG = "public.unaccent_simple" TS_CONFIG = "public.unaccent_simple"
+1 -1
View File
@@ -8,7 +8,7 @@ from datetime import datetime, timezone
from sqlalchemy import and_, case, func, or_, select from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app.models import PlexItem, PlexState from app.plex.models import PlexItem, PlexState
from app.plex.cards import ShowState from app.plex.cards import ShowState
# Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the # Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the
+1 -1
View File
@@ -33,7 +33,7 @@ from sqlalchemy.orm import Session as DbSession
from app import logs, sysconfig from app import logs, sysconfig
from app.config import settings from app.config import settings
from app.downloads.edit import encode_thread_cap from app.downloads.edit import encode_thread_cap
from app.models import PlexItem from app.plex.models import PlexItem
from app.plex import paths from app.plex import paths
from app.plex.sync import HDR_TRANSFERS, HW_DECODABLE_CODECS from app.plex.sync import HDR_TRANSFERS, HW_DECODABLE_CODECS
+1 -1
View File
@@ -47,7 +47,7 @@ from sqlalchemy.orm import Session
from app import logs from app import logs
from app.db import SessionLocal from app.db import SessionLocal
from app.models import PlexItem from app.plex.models import PlexItem
from app.plex import paths as plex_paths from app.plex import paths as plex_paths
from app.plex.cache import CACHE_DIR from app.plex.cache import CACHE_DIR
from app.plex.client import PlexClient, PlexError from app.plex.client import PlexClient, PlexError
+1 -1
View File
@@ -21,7 +21,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs, sysconfig from app import logs, sysconfig
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
from app.plex import detail, overlay, people from app.plex import detail, overlay, people
from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.genres import hu_genres from app.plex.genres import hu_genres
+1 -1
View File
@@ -22,7 +22,7 @@ from sqlalchemy.orm import Session
from app import logs, sysconfig from app import logs, sysconfig
from app.config import settings from app.config import settings
from app.models import PlexItem, PlexPerson, PlexShow from app.plex.models import PlexItem, PlexPerson, PlexShow
from app.plex.cards import display_title from app.plex.cards import display_title
from app.plex.people import people_by_credits, warm_portraits from app.plex.people import people_by_credits, warm_portraits
+1 -1
View File
@@ -29,7 +29,7 @@ from sqlalchemy.orm import Session
from app import logs, sysconfig from app import logs, sysconfig
from app.db import SessionLocal from app.db import SessionLocal
from app.models import PlexItem, PlexLibrary, PlexLink, PlexState from app.plex.models import PlexItem, PlexLibrary, PlexLink, PlexState
from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.sync import ( from app.plex.sync import (
_TYPE_EPISODE, _TYPE_EPISODE,
+1 -1
View File
@@ -3,7 +3,7 @@
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs from app import logs
from app.models import PlexItem from app.plex.models import PlexItem
from app.plex.cards import norm_lang as _norm_lang # noqa: F401 — the package's historic name from app.plex.cards import norm_lang as _norm_lang # noqa: F401 — the package's historic name
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
from app.routes._common import not_found from app.routes._common import not_found
+2 -5
View File
@@ -10,11 +10,8 @@ from sqlalchemy.orm import Session
from app import logs, sysconfig from app import logs, sysconfig
from app.auth import admin_user, current_user from app.auth import admin_user, current_user
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import User
PlexItem, from app.plex.models import PlexItem, PlexLink
PlexLink,
User,
)
from app.plex import paths as plex_paths from app.plex import paths as plex_paths
from app.plex import sync as plex_sync from app.plex import sync as plex_sync
from app.plex import watch_sync as plex_watch from app.plex import watch_sync as plex_watch
+2 -7
View File
@@ -20,13 +20,8 @@ from app.collation import collated, sorted_words
from app import sysconfig from app import sysconfig
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import User
PlexItem, from app.plex.models import PlexItem, PlexLibrary, PlexShow, PlexState
PlexLibrary,
PlexShow,
PlexState,
User,
)
from app.plex import rollup from app.plex import rollup
from app.plex.cards import display_title, episode_card from app.plex.cards import display_title, episode_card
from app.plex.query import ( from app.plex.query import (
+2 -8
View File
@@ -14,14 +14,8 @@ from app.collation import collated, sorted_words
from app import logs from app import logs
from app.auth import admin_user, current_user from app.auth import admin_user, current_user
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import User
PlexCollection, from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState
PlexItem,
PlexLibrary,
PlexShow,
PlexState,
User,
)
from app.plex import rollup from app.plex import rollup
from app.plex import sync as plex_sync from app.plex import sync as plex_sync
from app.plex.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card from app.plex.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card
+2 -9
View File
@@ -15,15 +15,8 @@ from app import logs
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.discover import queries from app.discover import queries
from app.models import ( from app.models import User
PlexCollection, from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
PlexItem,
PlexLibrary,
PlexSeason,
PlexShow,
PlexState,
User,
)
from app.plex import detail as plex_detail from app.plex import detail as plex_detail
from app.plex import people as plex_people from app.plex import people as plex_people
from app.plex import rollup, subtitles from app.plex import rollup, subtitles
+2 -1
View File
@@ -21,7 +21,8 @@ from sqlalchemy.orm import Session
from app.collation import sorted_words from app.collation import sorted_words
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.models import PlexItem, PlexShow, PlexState, User from app.models import User
from app.plex.models import PlexItem, PlexShow, PlexState
from app.plex import rollup from app.plex import rollup
from app.plex.cards import ( from app.plex.cards import (
SHOW_NEW, SHOW_NEW,
+2 -6
View File
@@ -11,12 +11,8 @@ from app.collation import collated
from app import logs, sysconfig from app import logs, sysconfig
from app.auth import current_user from app.auth import current_user
from app.db import SessionLocal, get_db from app.db import SessionLocal, get_db
from app.models import ( from app.models import User
PlexItem, from app.plex.models import PlexItem, PlexPerson, PlexShow
PlexPerson,
PlexShow,
User,
)
from app.plex import rollup from app.plex import rollup
from app.plex import tmdb as plex_tmdb from app.plex import tmdb as plex_tmdb
from app.plex.cards import SHOW_NEW, movie_card, norm_lang, show_card from app.plex.cards import SHOW_NEW, movie_card, norm_lang, show_card
+2 -7
View File
@@ -22,13 +22,8 @@ from sqlalchemy.orm import Session
from app.auth import current_user, resolved_user_id from app.auth import current_user, resolved_user_id
from app.db import SessionLocal, get_db from app.db import SessionLocal, get_db
from app.models import ( from app.models import User
PlexItem, from app.plex.models import PlexItem, PlexSeason, PlexShow, PlexState
PlexSeason,
PlexShow,
PlexState,
User,
)
from app.plex import imgcache, subtitles from app.plex import imgcache, subtitles
from app.plex import paths as plex_paths from app.plex import paths as plex_paths
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
+2 -8
View File
@@ -11,14 +11,8 @@ from sqlalchemy.orm import Session
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import User
PlexItem, from app.plex.models import PlexItem, PlexPlaylist, PlexPlaylistItem, PlexShow, PlexState
PlexPlaylist,
PlexPlaylistItem,
PlexShow,
PlexState,
User,
)
from app.plex.cards import display_title, leaf_card, norm_lang from app.plex.cards import display_title, leaf_card, norm_lang
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
from app.routes._common import owned_or_404, not_found from app.routes._common import owned_or_404, not_found
+1
View File
@@ -8,4 +8,5 @@ every mapper — imports this module.
import app.catalog.models # Catalog's tables import app.catalog.models # Catalog's tables
import app.downloads.models # Capture's tables import app.downloads.models # Capture's tables
import app.models # the core tables import app.models # the core tables
import app.plex.models # Plex's tables
import app.reader.models # noqa: F401 — Reader's tables import app.reader.models # noqa: F401 — Reader's tables
+1 -1
View File
@@ -18,7 +18,7 @@ from app.downloads import errors as dlerrors
from app.downloads import formats, pipeline, quota, runner, service from app.downloads import formats, pipeline, quota, runner, service
from app.downloads import probe as probemod from app.downloads import probe as probemod
from app.downloads.edit import INPUT_GUARD from app.downloads.edit import INPUT_GUARD
from app.models import PlexItem, PlexLibrary, PlexShow from app.plex.models import PlexItem, PlexLibrary, PlexShow
from app.catalog.models import Channel, Video from app.catalog.models import Channel, Video
from app.downloads.models import DownloadJob, MediaAsset from app.downloads.models import DownloadJob, MediaAsset
from app.plex import paths as plex_paths from app.plex import paths as plex_paths
+2 -1
View File
@@ -9,7 +9,8 @@ from app.auth import current_user
from app.collation import sorted_words from app.collation import sorted_words
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexCollection, PlexItem, PlexLibrary, User from app.models import User
from app.plex.models import PlexCollection, PlexItem, PlexLibrary
from app.catalog.models import Channel, Subscription, Video from app.catalog.models import Channel, Subscription, Video
from app.reader.models import Playlist, PlaylistItem from app.reader.models import Playlist, PlaylistItem
from datetime import datetime, timezone from datetime import datetime, timezone
+2 -1
View File
@@ -17,7 +17,8 @@ from app.discover import hero, pins, queries, rails
from app.discover.budget import BANDS, fits, remaining_seconds from app.discover.budget import BANDS, fits, remaining_seconds
from app.plex import rollup from app.plex import rollup
from app.plex.cards import leaf_card from app.plex.cards import leaf_card
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexShow, PlexState
from app.catalog.models import BlockedChannel, Channel, Subscription, Video from app.catalog.models import BlockedChannel, Channel, Subscription, Video
from app.reader.models import ComfortPin, Playlist, PlaylistItem, VideoState from app.reader.models import ComfortPin, Playlist, PlaylistItem, VideoState
+1 -1
View File
@@ -12,7 +12,7 @@ contradicts the remote's own count is not believed either.
import pytest import pytest
from sqlalchemy import select from sqlalchemy import select
from app.models import PlexCollection, PlexItem, PlexLibrary from app.plex.models import PlexCollection, PlexItem, PlexLibrary
from app.catalog.models import Channel, Video from app.catalog.models import Channel, Video
from app.reader.models import Playlist, PlaylistItem from app.reader.models import Playlist, PlaylistItem
from app.plex import sync as plex_sync from app.plex import sync as plex_sync
+2 -1
View File
@@ -28,7 +28,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexItem, PlexLibrary, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary
from app.plex import paths as plex_paths from app.plex import paths as plex_paths
from app.plex import stream from app.plex import stream
+1 -1
View File
@@ -19,7 +19,7 @@ from datetime import datetime, timezone
import pytest import pytest
from sqlalchemy import event from sqlalchemy import event
from app.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem from app.plex.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
from app.catalog.models import Channel, Video from app.catalog.models import Channel, Video
from app.reader.models import Playlist, PlaylistItem from app.reader.models import Playlist, PlaylistItem
+1 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy import select
import app.routes.plex.admin as admin_routes import app.routes.plex.admin as admin_routes
from app import sysconfig from app import sysconfig
from app.models import PlexLink from app.plex.models import PlexLink
from app.plex.client import PlexError, PlexNotConfigured from app.plex.client import PlexError, PlexNotConfigured
+1 -1
View File
@@ -5,7 +5,7 @@ and the crossfading hero), but ONLY when the item actually has an `art_key` —
resolve to a blank image on every card that tried to use it, so it must be null and let the client fall resolve to a blank image on every card that tried to use it, so it must be null and let the client fall
back to the poster.""" back to the poster."""
from app.models import PlexItem, PlexShow from app.plex.models import PlexItem, PlexShow
from app.plex.cards import movie_card, show_card from app.plex.cards import movie_card, show_card
+2 -1
View File
@@ -22,7 +22,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexItem, PlexLibrary, PlexState, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexState
from app.plex import watch_sync as plex_watch from app.plex import watch_sync as plex_watch
+2 -1
View File
@@ -17,7 +17,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState, User from app.models import User
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState
from app.plex.query import PLEX_SORT_KEYS from app.plex.query import PLEX_SORT_KEYS
from app.routes.plex.browse import sort_column from app.routes.plex.browse import sort_column
from app.routes.plex.collections import ( from app.routes.plex.collections import (
@@ -13,7 +13,8 @@ import app.state as state
from app.auth import admin_user, current_user from app.auth import admin_user, current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User from app.models import User
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow
from app.plex.client import PlexError from app.plex.client import PlexError
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
from app.routes.plex import collections as coll_routes from app.routes.plex import collections as coll_routes
@@ -9,7 +9,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User from app.models import User
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow
def _seed(db): def _seed(db):
+2 -1
View File
@@ -19,7 +19,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
from app.plex import detail from app.plex import detail
from app.plex.sync import _apply_facet_fields, _enrich_people_batch from app.plex.sync import _apply_facet_fields, _enrich_people_batch
+2 -1
View File
@@ -13,7 +13,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexShow, PlexState
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc) NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
+2 -1
View File
@@ -13,7 +13,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexItem, PlexLibrary, PlexShow, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexShow
def test_facets_returns_studio_vocabulary(db, monkeypatch, override_dep): def test_facets_returns_studio_vocabulary(db, monkeypatch, override_dep):
@@ -15,14 +15,8 @@ the merge is provably behaviour-equivalent (and a drift between grid and facets
from collections import Counter from collections import Counter
from datetime import datetime, timezone from datetime import datetime, timezone
from app.models import ( from app.models import User
PlexItem, from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
PlexLibrary,
PlexSeason,
PlexShow,
PlexState,
User,
)
SCOPES = ("movie", "show", "both") SCOPES = ("movie", "show", "both")
SHOW_STATES = ("all", "watched", "in_progress", "unwatched") SHOW_STATES = ("all", "watched", "in_progress", "unwatched")
@@ -14,13 +14,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import ( from app.models import User
PlexItem, from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow
PlexLibrary,
PlexSeason,
PlexShow,
User,
)
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
+1 -1
View File
@@ -13,7 +13,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import select from sqlalchemy import select
from app.db import SessionLocal from app.db import SessionLocal
from app.models import PlexItem, PlexLibrary, PlexState from app.plex.models import PlexItem, PlexLibrary, PlexState
from app.routes.plex import playback from app.routes.plex import playback
+1 -1
View File
@@ -9,7 +9,7 @@ from fastapi.testclient import TestClient
import app.state as app_state import app.state as app_state
from app.main import app from app.main import app
from app.models import PlexItem, PlexLibrary from app.plex.models import PlexItem, PlexLibrary
from app.plex import imgcache from app.plex import imgcache
from app.routes.plex import playback from app.routes.plex import playback
+1 -1
View File
@@ -7,7 +7,7 @@ Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
from sqlalchemy import text from sqlalchemy import text
from app.models import PlexLibrary, PlexShow from app.plex.models import PlexLibrary, PlexShow
# Mirror of migration 0060's statements (plex_items runs against an empty table here — proving the # Mirror of migration 0060's statements (plex_items runs against an empty table here — proving the
# SQL is valid for both tables; the assertions below check the seeded plex_shows rows). # SQL is valid for both tables; the assertions below check the seeded plex_shows rows).
+2 -1
View File
@@ -9,7 +9,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexItem, PlexLibrary, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary
def _seed(db): def _seed(db):
+2 -2
View File
@@ -14,7 +14,8 @@ from app.db import get_db
from app.main import app from app.main import app
from app import sysconfig from app import sysconfig
from app.plex.cards import display_summary, display_title from app.plex.cards import display_summary, display_title
from app.models import ( from app.models import User
from app.plex.models import (
PlexCollection, PlexCollection,
PlexItem, PlexItem,
PlexLibrary, PlexLibrary,
@@ -23,7 +24,6 @@ from app.models import (
PlexSeason, PlexSeason,
PlexShow, PlexShow,
PlexState, PlexState,
User,
) )
+2 -1
View File
@@ -12,7 +12,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexShow, PlexState
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc) NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
+1 -1
View File
@@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
import app.plex.sync as syncmod import app.plex.sync as syncmod
from app import sysconfig from app import sysconfig
from app.db import engine from app.db import engine
from app.models import PlexItem, PlexLibrary, PlexShow from app.plex.models import PlexItem, PlexLibrary, PlexShow
from app.plex.genres import hu_genre, hu_genres from app.plex.genres import hu_genre, hu_genres
from app.plex.overlay import sync_overlay from app.plex.overlay import sync_overlay
from app.plex.sync import _apply_item, _canonical_guid from app.plex.sync import _apply_item, _canonical_guid
+2 -1
View File
@@ -9,7 +9,8 @@ from datetime import datetime, timezone
import httpx import httpx
import pytest import pytest
from app.models import PlexItem, PlexLibrary, PlexPerson, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexPerson
from app.plex import imgcache from app.plex import imgcache
from app.plex import people as plex_people from app.plex import people as plex_people
from app.plex import tmdb as plex_tmdb from app.plex import tmdb as plex_tmdb
+2 -8
View File
@@ -12,14 +12,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import ( from app.models import User
PlexItem, from app.plex.models import PlexItem, PlexLibrary, PlexPerson, PlexShow, PlexState
PlexLibrary,
PlexPerson,
PlexShow,
PlexState,
User,
)
from app.plex.query import content_libs from app.plex.query import content_libs
NAME = "Robert De Niro" NAME = "Robert De Niro"
+2 -1
View File
@@ -10,7 +10,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexItem, PlexLibrary, PlexPerson, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexPerson
from app.plex import tmdb as plex_tmdb from app.plex import tmdb as plex_tmdb
PAYLOAD = { PAYLOAD = {
+1 -1
View File
@@ -12,7 +12,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db import engine from app.db import engine
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow
from app.plex.detail import rich_detail from app.plex.detail import rich_detail
from app.plex.overlay import sync_overlay from app.plex.overlay import sync_overlay
from app.plex.plaintext import plain_text from app.plex.plaintext import plain_text
+2 -1
View File
@@ -14,7 +14,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexItem, PlexLibrary, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary
from app.plex import sync from app.plex import sync
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
+2 -1
View File
@@ -19,7 +19,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexItem, PlexLibrary, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary
from app.plex import paths as plex_paths from app.plex import paths as plex_paths
from app.plex import stream as plex_stream from app.plex import stream as plex_stream
@@ -6,7 +6,7 @@ rows, since positions have gaps after a removal. One request: an append plus a s
leaves the rows at the end whenever that call fails, and would overwrite another tab's changes. leaves the rows at the end whenever that call fails, and would overwrite another tab's changes.
""" """
from app.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem from app.plex.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
def _seed(db, user, rks, *, positions=None, extra=("x", "y")): def _seed(db, user, rks, *, positions=None, extra=("x", "y")):
+3 -2
View File
@@ -16,7 +16,8 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
from app.plex import rollup, sync from app.plex import rollup, sync
from app.plex.cards import ShowState from app.plex.cards import ShowState
@@ -490,7 +491,7 @@ class TestPlaylistAndCollectionEndpoints:
] # input order preserved, m2 gone ] # input order preserved, m2 gone
def test_collections_list(self, db, monkeypatch, override_dep): def test_collections_list(self, db, monkeypatch, override_dep):
from app.models import PlexCollection from app.plex.models import PlexCollection
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib) db.add(lib)
+1 -1
View File
@@ -1088,7 +1088,7 @@ def test_a_nul_in_the_rating_key_is_not_a_500(client, monkeypatch):
def test_a_nul_rating_key_names_no_row(): def test_a_nul_rating_key_names_no_row():
"""Postgres refuses NUL in text, so looking it up was a DataError (a 500) on every Plex route.""" """Postgres refuses NUL in text, so looking it up was a DataError (a 500) on every Plex route."""
from app.models import PlexItem from app.plex.models import PlexItem
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
assert by_rating_key(None, PlexItem, "12\x0034") is None # type: ignore[arg-type] assert by_rating_key(None, PlexItem, "12\x0034") is None # type: ignore[arg-type]
+2 -8
View File
@@ -8,14 +8,8 @@ with a stub Plex (the same seam the overlay test uses), so no live server is nee
from sqlalchemy import select from sqlalchemy import select
from app.models import ( from app.models import User
PlexItem, from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
PlexLibrary,
PlexSeason,
PlexShow,
PlexState,
User,
)
from app.plex.sync import ( from app.plex.sync import (
_TYPE_EPISODE, _TYPE_EPISODE,
_TYPE_MOVIE, _TYPE_MOVIE,
+1 -1
View File
@@ -7,7 +7,7 @@ from sqlalchemy import select
from app.plex import people, sync from app.plex import people, sync
from app.plex.client import PlexError from app.plex.client import PlexError
from app.models import PlexItem, PlexLibrary, PlexPerson from app.plex.models import PlexItem, PlexLibrary, PlexPerson
class _FakePlex: class _FakePlex:
+1 -1
View File
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
import httpx import httpx
import pytest import pytest
from app.models import PlexItem, PlexLibrary, PlexPerson, PlexShow from app.plex.models import PlexItem, PlexLibrary, PlexPerson, PlexShow
from app.plex import tmdb from app.plex import tmdb
# A TMDB /movie/{id}?append_to_response=keywords,credits,recommendations,images,external_ids payload. # A TMDB /movie/{id}?append_to_response=keywords,credits,recommendations,images,external_ids payload.
+2 -1
View File
@@ -7,7 +7,8 @@ import pytest
from sqlalchemy import select from sqlalchemy import select
from app import sysconfig from app import sysconfig
from app.models import PlexItem, PlexLibrary, PlexLink, PlexState, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexLink, PlexState
from app.plex import watch_sync from app.plex import watch_sync
+2 -1
View File
@@ -13,7 +13,8 @@ from types import SimpleNamespace
from sqlalchemy import select from sqlalchemy import select
from app.models import PlexItem, PlexLibrary, PlexState, User from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexState
from app.plex.watch_sync import _pull_apply, _reconcile_inprogress_movies from app.plex.watch_sync import _pull_apply, _reconcile_inprogress_movies
from app.routes.plex.playback import ( from app.routes.plex.playback import (
_PUSH_THROTTLE_S, _PUSH_THROTTLE_S,