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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user