From 533571f57227de1bcbdfd1c7a188c18bab6ed20c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 19 Oct 2026 21:42:07 +0200 Subject: [PATCH] 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. --- backend/app/discover/pins.py | 3 +- backend/app/discover/queries.py | 3 +- backend/app/discover/rails.py | 3 +- backend/app/downloads/pipeline.py | 3 +- backend/app/downloads/service.py | 3 +- backend/app/models.py | 440 +---------------- backend/app/plex/cards.py | 2 +- backend/app/plex/imgcache.py | 2 +- backend/app/plex/models.py | 463 ++++++++++++++++++ backend/app/plex/overlay.py | 2 +- backend/app/plex/people.py | 2 +- backend/app/plex/query.py | 2 +- backend/app/plex/rollup.py | 2 +- backend/app/plex/stream.py | 2 +- backend/app/plex/subtitles.py | 2 +- backend/app/plex/sync.py | 2 +- backend/app/plex/tmdb.py | 2 +- backend/app/plex/watch_sync.py | 2 +- backend/app/routes/plex/_shared.py | 2 +- backend/app/routes/plex/admin.py | 7 +- backend/app/routes/plex/browse.py | 9 +- backend/app/routes/plex/collections.py | 10 +- backend/app/routes/plex/detail.py | 11 +- backend/app/routes/plex/discover.py | 3 +- backend/app/routes/plex/people.py | 8 +- backend/app/routes/plex/playback.py | 9 +- backend/app/routes/plex/playlists.py | 10 +- backend/app/tables.py | 1 + backend/tests/test_clip.py | 2 +- backend/tests/test_collation.py | 3 +- backend/tests/test_discover.py | 3 +- backend/tests/test_empty_listing_guards.py | 2 +- backend/tests/test_hls_reaper.py | 3 +- backend/tests/test_playlist_position_lock.py | 2 +- backend/tests/test_plex_admin_routes.py | 2 +- backend/tests/test_plex_cards.py | 2 +- backend/tests/test_plex_clear_resume_push.py | 3 +- backend/tests/test_plex_collection_sort.py | 3 +- backend/tests/test_plex_collections_filter.py | 3 +- .../tests/test_plex_collections_for_item.py | 3 +- backend/tests/test_plex_detail.py | 3 +- backend/tests/test_plex_discover.py | 3 +- backend/tests/test_plex_facets.py | 3 +- backend/tests/test_plex_facets_grid_parity.py | 10 +- .../tests/test_plex_field_scoped_search.py | 9 +- backend/tests/test_plex_finish.py | 2 +- backend/tests/test_plex_image_sizes.py | 2 +- backend/tests/test_plex_jsonb_normalize.py | 2 +- backend/tests/test_plex_keyword_filter.py | 3 +- backend/tests/test_plex_lang.py | 4 +- backend/tests/test_plex_last_watched_sort.py | 3 +- backend/tests/test_plex_overlay.py | 2 +- backend/tests/test_plex_people_enrich.py | 3 +- backend/tests/test_plex_person.py | 10 +- backend/tests/test_plex_person_bio.py | 3 +- backend/tests/test_plex_plaintext.py | 2 +- backend/tests/test_plex_playback_class.py | 3 +- backend/tests/test_plex_playback_session.py | 3 +- .../tests/test_plex_playlist_add_positions.py | 2 +- backend/tests/test_plex_query_conversion.py | 5 +- backend/tests/test_plex_subtitle_prepare.py | 2 +- backend/tests/test_plex_sync_prune.py | 10 +- backend/tests/test_plex_sync_txn.py | 2 +- backend/tests/test_plex_tmdb.py | 2 +- backend/tests/test_plex_watch_reconcile.py | 3 +- backend/tests/test_plex_watch_sync.py | 3 +- 66 files changed, 566 insertions(+), 574 deletions(-) create mode 100644 backend/app/plex/models.py diff --git a/backend/app/discover/pins.py b/backend/app/discover/pins.py index ac6307f8..87c691f7 100644 --- a/backend/app/discover/pins.py +++ b/backend/app/discover/pins.py @@ -22,7 +22,8 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session 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.reader.models import ComfortPin, Playlist from app.plex import rollup diff --git a/backend/app/discover/queries.py b/backend/app/discover/queries.py index c06c3001..0ff0d40b 100644 --- a/backend/app/discover/queries.py +++ b/backend/app/discover/queries.py @@ -29,7 +29,8 @@ from app.feedquery import ( serialize, 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.reader.models import PlaylistItem, VideoState from app.plex import rollup diff --git a/backend/app/discover/rails.py b/backend/app/discover/rails.py index 5af9e90a..73e16c81 100644 --- a/backend/app/discover/rails.py +++ b/backend/app/discover/rails.py @@ -11,7 +11,8 @@ from sqlalchemy.orm import Session from app import sysconfig from app.discover import cards, queries 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.rollup import NO_TS diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py index c6ae8622..6dd29a92 100644 --- a/backend/app/downloads/pipeline.py +++ b/backend/app/downloads/pipeline.py @@ -28,7 +28,8 @@ from app.downloads.jobdb import ( ) from app.downloads.stated import StatedFailure 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.plex import lookups as plex_lookups from app.plex import paths as plex_paths diff --git a/backend/app/downloads/service.py b/backend/app/downloads/service.py index 3b4b3453..13010aeb 100644 --- a/backend/app/downloads/service.py +++ b/backend/app/downloads/service.py @@ -24,7 +24,8 @@ from app.downloads import credentials from app.downloads import edit as editmod from app.downloads import folders, formats, mediakind, quota, storage 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.downloads.models import ( DownloadJob, diff --git a/backend/app/models.py b/backend/app/models.py index 5a4efa71..2eeee5ba 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -5,10 +5,8 @@ from sqlalchemy import ( BigInteger, Boolean, CheckConstraint, - Computed, Date, DateTime, - Float, ForeignKey, Index, Integer, @@ -20,7 +18,7 @@ from sqlalchemy import ( UniqueConstraint, func, ) -from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship, validates 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_write: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") can_delete: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - - -# --- Plex integration (optional) ------------------------------------------------------------- -# A dedicated, self-contained catalog mirrored from a locally-reachable Plex server. Playback is -# from the LOCAL physical file (Plex is metadata only); per-user watch state lives in plex_states -# so it works for users without a Plex account. Mirrors the app's "shared catalog, private -# per-user state" model. The weighted FTS search_vector uses the same unaccent_simple config as -# `videos` (migrations 0031/0032). - - -class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin): - """A mirrored Plex library section. Only `enabled` sections are exposed in the feed. - `plex_key` is the section id on the Plex server.""" - - __tablename__ = "plex_libraries" - - id: Mapped[int] = mapped_column(primary_key=True) - plex_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) - title: Mapped[str] = mapped_column(String(255)) - kind: Mapped[str] = mapped_column(String(16)) # "movie" | "show" - enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") - synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - # HU-overlay support. `metadata_lang` tags the section's metadata language. An OVERLAY - # library (overlay_of set) is the Hungarian-metadata twin of another section: it is never browsable - # on its own — its titles' HU fields are written onto the matched EN rows (paired by `guid`). - metadata_lang: Mapped[str] = mapped_column(String(8), default="en", server_default="en") - overlay_of: Mapped[int | None] = mapped_column( - ForeignKey("plex_libraries.id", ondelete="SET NULL"), index=True - ) - - -class PlexShow(Base, TimestampMixin, UpdatedAtMixin): - """A TV show (grandparent of episodes) — holds poster + summary for the drill-down page, plus - the same filterable metadata as movies (genres/rating/content_rating/studio/people) so the TV - grid can be filtered/sorted like the movie grid. All mirrored cheaply from the show listing.""" - - __tablename__ = "plex_shows" - __table_args__ = ( - Index("ix_plex_shows_genres_gin", "genres", postgresql_using="gin"), - Index("ix_plex_shows_directors_gin", "directors", postgresql_using="gin"), - Index("ix_plex_shows_cast_gin", "cast_names", postgresql_using="gin"), - Index("ix_plex_shows_genres_hu_gin", "genres_hu", postgresql_using="gin"), - # ix_plex_shows_collections_gin already created with collections (migration 0047). - ) - - id: Mapped[int] = mapped_column(primary_key=True) - rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) - library_id: Mapped[int] = mapped_column( - ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True - ) - # Canonical external id (e.g. "tmdb:12345") — the key that pairs this EN row with its HU-overlay - # twin during sync. Indexed for the guid->row overlay match. - guid: Mapped[str | None] = mapped_column(String(64), index=True) - title: Mapped[str] = mapped_column(String(512)) - summary: Mapped[str | None] = mapped_column(Text) - year: Mapped[int | None] = mapped_column(Integer) - thumb_key: Mapped[str | None] = mapped_column( - String(512) - ) # Plex thumb path (proxied, not stored) - art_key: Mapped[str | None] = mapped_column(String(512)) - child_count: Mapped[int | None] = mapped_column(Integer) # seasons - collection_keys: Mapped[list | None] = mapped_column( - JSONB(none_as_null=True) - ) # Plex collections this show is in - added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) - # Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem). - rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb) - content_rating: Mapped[str | None] = mapped_column(String(16), index=True) - studio: Mapped[str | None] = mapped_column(String(255), index=True) # network/studio - originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True) # first air date - # none_as_null: an empty tag list must persist as SQL NULL, not JSON `null`. Without it a Python - # None round-trips as a JSON scalar that jsonb_array_elements_text() can't unnest ("cannot extract - # elements from a scalar") — the trap a metadata-poor library sprang on /facets. Same on plex_items. - genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) - directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) - cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) - people_text: Mapped[str | None] = mapped_column( - Text - ) # cast+directors, folded into search_vector - # Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3. - people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so - # the info page serves from this row instead of a live plex.metadata() call. NULL until enriched. - imdb_rating: Mapped[float | None] = mapped_column( - Float - ) # IMDb-specific score (vs `rating` = audienceRating) - imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link - tagline: Mapped[str | None] = mapped_column(Text) - full_cast: Mapped[list | None] = mapped_column( - JSONB(none_as_null=True) - ) # ordered [{name, role, thumb}] - # --- TMDB enrichment: see the identical block on plex_items for the full rationale. Additive; - # served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) --- - tmdb_id: Mapped[int | None] = mapped_column(Integer) - tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10) - tmdb_vote_count: Mapped[int | None] = mapped_column(Integer) - budget: Mapped[int | None] = mapped_column(BigInteger) - revenue: Mapped[int | None] = mapped_column(BigInteger) - keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] - crew: Mapped[list | None] = mapped_column( - JSONB(none_as_null=True) - ) # [{name, job, tmdb_id, thumb}] - studios: Mapped[list | None] = mapped_column( - JSONB(none_as_null=True) - ) # TMDB production companies [str] - production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] - recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) - backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) - # Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched, - # so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors. - title_hu: Mapped[str | None] = mapped_column(String(512)) - tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline - summary_hu: Mapped[str | None] = mapped_column(Text) - genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) - search_vector: Mapped[object | None] = mapped_column( - TSVECTOR, - Computed( - "setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || " - "setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || " - "setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')", - persisted=True, - ), - ) - search_vector_hu: Mapped[object | None] = mapped_column( - TSVECTOR, - Computed( - "setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || " - "setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || " - "setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')", - persisted=True, - ), - ) - - -class PlexSeason(Base, TimestampMixin, UpdatedAtMixin): - """A season under a show — used to order episodes for prev/next stepping.""" - - __tablename__ = "plex_seasons" - - id: Mapped[int] = mapped_column(primary_key=True) - rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) - show_id: Mapped[int] = mapped_column( - ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True - ) - title: Mapped[str | None] = mapped_column(String(255)) - season_number: Mapped[int | None] = mapped_column(Integer, index=True) - thumb_key: Mapped[str | None] = mapped_column(String(512)) - - -class PlexItem(Base, TimestampMixin, UpdatedAtMixin): - """A playable LEAF — a movie or a single episode. This is what the feed lists and the player - plays. Movies carry `library_id` and no parents; episodes carry show/season refs + - season/episode numbers. `file_path` is the Plex-side absolute path of the physical media, - mapped through `plex_path_map` to a local mount at playback time. `playable` is the - ffprobe-derived browser-compatibility class (direct | remux | transcode).""" - - __tablename__ = "plex_items" - __table_args__ = ( - Index("ix_plex_items_show_order", "show_id", "season_number", "episode_number"), - Index("ix_plex_items_genres_gin", "genres", postgresql_using="gin"), - Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"), - Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"), - Index("ix_plex_items_collections_gin", "collection_keys", postgresql_using="gin"), - Index("ix_plex_items_genres_hu_gin", "genres_hu", postgresql_using="gin"), - ) - - id: Mapped[int] = mapped_column(primary_key=True) - rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) - library_id: Mapped[int] = mapped_column( - ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True - ) - kind: Mapped[str] = mapped_column(String(16), index=True) # "movie" | "episode" - # Canonical external id (e.g. "tmdb:12345") — pairs a MOVIE row with its HU-overlay twin during - # sync; indexed for the guid->row match. Episodes carry it too but it's unused for them. - guid: Mapped[str | None] = mapped_column(String(64), index=True) - show_id: Mapped[int | None] = mapped_column( - ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True - ) - season_id: Mapped[int | None] = mapped_column( - ForeignKey("plex_seasons.id", ondelete="CASCADE"), index=True - ) - season_number: Mapped[int | None] = mapped_column(Integer) - episode_number: Mapped[int | None] = mapped_column(Integer) - title: Mapped[str] = mapped_column(String(512)) - summary: Mapped[str | None] = mapped_column(Text) - year: Mapped[int | None] = mapped_column(Integer) - duration_s: Mapped[int | None] = mapped_column(Integer) - thumb_key: Mapped[str | None] = mapped_column(String(512)) - art_key: Mapped[str | None] = mapped_column(String(512)) - cast: Mapped[list | None] = mapped_column(JSON) # [role names] - # Physical media (from the Plex Part). - file_path: Mapped[str | None] = mapped_column(Text) # Plex-side absolute path - part_key: Mapped[str | None] = mapped_column(String(255)) # /library/parts/... (fallback fetch) - container: Mapped[str | None] = mapped_column(String(16)) - codec_video: Mapped[str | None] = mapped_column(String(32)) - codec_audio: Mapped[str | None] = mapped_column(String(32)) - size_bytes: Mapped[int | None] = mapped_column(BigInteger) - # Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed). - playable: Mapped[str | None] = mapped_column(String(12), index=True) - probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - # Transcode HW-capability facet (the playback-class filter). NULL for non-transcode items; for a - # `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable - # AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that - # breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`: - # set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth - # backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable - # home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared - # classifier so the catalog facet and the runtime encoder choice never disagree by codec. - transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True) - # Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL = - # not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`. - video_bit_depth: Mapped[int | None] = mapped_column(Integer) - # Watermark for the bit-depth backfill: when this item's media Stream was last probed for - # bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are - # probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`. - media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) - # Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls; - # `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio - # filters (GIN-indexed) and double as content signals for a future watch-habit recommender. - rating: Mapped[float | None] = mapped_column(Float, index=True) - content_rating: Mapped[str | None] = mapped_column(String(16), index=True) - studio: Mapped[str | None] = mapped_column(String(255), index=True) - originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True) - genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) - directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) - cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) - # rating_keys of the Plex collections this movie belongs to (GIN — filter + card-strip siblings). - collection_keys: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) - # Cast + director names, space-joined — folded into search_vector (weight B) so the search box - # finds titles by a person's name (and ranks them above summary-only mentions). - people_text: Mapped[str | None] = mapped_column(Text) - # Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3. - people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so - # the info page serves from this row instead of a live plex.metadata() call. NULL until enriched. - imdb_rating: Mapped[float | None] = mapped_column( - Float - ) # IMDb-specific score (vs `rating` = audienceRating) - imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link - tagline: Mapped[str | None] = mapped_column(Text) - full_cast: Mapped[list | None] = mapped_column( - JSONB(none_as_null=True) - ) # ordered [{name, role, thumb}] - # --- TMDB enrichment: matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB - # backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating - # above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched; - # `tmdb_enriched_at` is the "tried" watermark (stamped on a definitive no-match too, so an - # unresolvable title isn't re-fetched every run; left NULL only on a transient fetch error). --- - tmdb_id: Mapped[int | None] = mapped_column(Integer) - tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10) - tmdb_vote_count: Mapped[int | None] = mapped_column(Integer) - budget: Mapped[int | None] = mapped_column(BigInteger) # USD (movies; TV has none) - revenue: Mapped[int | None] = mapped_column(BigInteger) - keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] - crew: Mapped[list | None] = mapped_column( - JSONB(none_as_null=True) - ) # [{name, job, tmdb_id, thumb}] - studios: Mapped[list | None] = mapped_column( - JSONB(none_as_null=True) - ) # TMDB production companies [str] - production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] - # [{tmdb_id, title, poster, media_type}] — resolved to library rows (by guid) at serve time. - recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) - backdrops: Mapped[list | None] = mapped_column( - JSONB(none_as_null=True) - ) # TMDB backdrop file_paths [str] (crossfade pool) - # Hungarian-metadata twin fields, written by the overlay sync. NULL = no HU twin matched - # (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors. - title_hu: Mapped[str | None] = mapped_column(String(512)) - tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline - summary_hu: Mapped[str | None] = mapped_column(Text) - genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) - search_vector: Mapped[object | None] = mapped_column( - TSVECTOR, - Computed( - "setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || " - "setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || " - "setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')", - persisted=True, - ), - ) - search_vector_hu: Mapped[object | None] = mapped_column( - TSVECTOR, - Computed( - "setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || " - "setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || " - "setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')", - persisted=True, - ), - ) - - -class PlexPerson(Base, TimestampMixin, UpdatedAtMixin): - """A small cast/crew headshot cache, keyed by name, so person filter-chips (the cards for the - actors currently in the filter) can show a photo + count without a live per-card metadata fetch. - Populated opportunistically when an item's RICH metadata is fetched — the info-page view and the - periodic rich re-sync — since the cheap section listing that drives the main sync only carries the - top few cast. Rows whose name no longer appears in ANY item's cast_names/directors (a library - removal orphaned them) are pruned by the maintenance sweep, mirroring how Plex drops orphaned - metadata. The filterable cast lives in plex_items/plex_shows.cast_names (self-cleaning with the - row); this table is the people's own facts: the headshot link, the TMDB link and the TMDB bio - (the headshot FILE lives in the image cache, filled by the people_enrich job).""" - - __tablename__ = "plex_people" - - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(String(255), unique=True, index=True) - photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None - # TMDB person id, linked by name from a title's TMDB credits (the /person - # biography fetch builds on it). NULL until a TMDB-enriched title names this person. - tmdb_person_id: Mapped[int | None] = mapped_column(Integer) - # --- TMDB person enrichment: fetched from /person/{tmdb_person_id} by the people_enrich job, - # or by the first person-page open the job hasn't reached, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a - # no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id, - # media_type, title}], resolved to library rows by guid at serve time (like recommendations). --- - biography: Mapped[str | None] = mapped_column(Text) - # TMDB's Hungarian biography, from the same request's `translations`; a Hungarian reader is - # served it, and the instance-language `biography` when TMDB has none. Birthplace, aliases and - # keywords have no Hungarian on TMDB, so they stay in the instance language. - biography_hu: Mapped[str | None] = mapped_column(Text) - birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is - deathday: Mapped[str | None] = mapped_column(String(10)) - place_of_birth: Mapped[str | None] = mapped_column(Text) - known_for_department: Mapped[str | None] = mapped_column(String(64)) - also_known_as: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] - known_for: Mapped[list | None] = mapped_column( - JSONB(none_as_null=True) - ) # [{tmdb_id, media_type, title}] - tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - - -class PlexCollection(Base, TimestampMixin, UpdatedAtMixin): - """A mirrored Plex collection (a grouping of movies or shows — franchises like Avatar, curated - sets like MCU, or auto/community lists like IMDb Top 250). Mirrored in full by the background - sync so reads never hit Plex's slow collection queries. Membership is stored as `collection_keys` - on the member movies (plex_items) / shows (plex_shows). `smart` = Plex's query-based flag; - `editable` = we may write changes back to Plex (default False = read-only; set for curated ones).""" - - __tablename__ = "plex_collections" - - id: Mapped[int] = mapped_column(primary_key=True) - rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) - library_id: Mapped[int] = mapped_column( - ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True - ) - title: Mapped[str] = mapped_column(String(512)) - summary: Mapped[str | None] = mapped_column(Text) - thumb_key: Mapped[str | None] = mapped_column(String(512)) - child_count: Mapped[int | None] = mapped_column(Integer) - smart: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - editable: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - - -class PlexState(Base, UpdatedAtMixin): - """Per-user watch state for a Plex item — mirrors VideoState. Lives in Siftlode's own DB so - it works for users WITHOUT a Plex account (the whole point of the access-layer design). - `synced_to_plex` marks whether this state was pushed back to a linked Plex account.""" - - __tablename__ = "plex_states" - __table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),) - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) - item_id: Mapped[int] = mapped_column( - ForeignKey("plex_items.id", ondelete="CASCADE"), index=True - ) - status: Mapped[str] = mapped_column(String(12), default="new", server_default="new") - position_seconds: Mapped[int] = mapped_column( - Integer, default=0, server_default="0", nullable=False - ) - watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - synced_to_plex: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - - -class PlexLink(Base, UpdatedAtMixin): - """Links a Siftlode user to a Plex account for two-way watch-state sync (one row per user). - - For the owner (the MVP scope) the row uses the server's admin token — `uses_admin=True`, - `plex_token_enc=None` — so no separate Plex login is needed; the admin token already reads/writes - the owner account's watch state. `plex_account_id` is that Plex account's numeric id, used to - filter the shared watch-history feed to this user (Phase C). Reserved for later: a friend can link - their OWN Plex account via PIN OAuth, storing a personal `plex_token_enc` (uses_admin=False) — the - sync loop then iterates link rows uniformly. - - `initial_import_done` guards the one-time "Plex is master" import; `last_watch_sync_at` is the - high-water mark for the incremental Plex→Siftlode pass.""" - - __tablename__ = "plex_link" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column( - ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True - ) - uses_admin: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") - plex_token_enc: Mapped[str | None] = mapped_column(Text) # P5b friend tokens (encrypted) - plex_account_id: Mapped[int | None] = mapped_column(Integer) - plex_username: Mapped[str | None] = mapped_column(String(255)) - sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - initial_import_done: Mapped[bool] = mapped_column( - Boolean, default=False, server_default="false" - ) - last_watch_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - linked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - - -class PlexPlaylist(Base, TimestampMixin, UpdatedAtMixin): - """A per-user ORDERED watch-list of Plex items — Siftlode-native (lives in our own DB, works for - users WITHOUT a Plex account, like plex_states). Unlike collections (shared, library-level), a - playlist is owned by one user and its items are ordered. Optional Plex-direction sync is a later - phase (`plex_rating_key` set once pushed back to the owner's Plex account).""" - - __tablename__ = "plex_playlists" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) - title: Mapped[str] = mapped_column(String(512)) - plex_rating_key: Mapped[str | None] = mapped_column(String(32)) - - -class PlexPlaylistItem(Base): - """One ordered entry in a playlist. `position` (0-based) orders playback.""" - - __tablename__ = "plex_playlist_items" - __table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),) - - id: Mapped[int] = mapped_column(primary_key=True) - playlist_id: Mapped[int] = mapped_column(ForeignKey("plex_playlists.id", ondelete="CASCADE")) - item_id: Mapped[int] = mapped_column( - ForeignKey("plex_items.id", ondelete="CASCADE"), index=True - ) - position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False) diff --git a/backend/app/plex/cards.py b/backend/app/plex/cards.py index 75a1d9b8..ac42a1d3 100644 --- a/backend/app/plex/cards.py +++ b/backend/app/plex/cards.py @@ -5,7 +5,7 @@ router. Pure functions — no DB, no request state.""" from typing import NamedTuple -from app.models import PlexItem, PlexShow, PlexState +from app.plex.models import PlexItem, PlexShow, PlexState class ShowState(NamedTuple): diff --git a/backend/app/plex/imgcache.py b/backend/app/plex/imgcache.py index bd7f34a0..70c48483 100644 --- a/backend/app/plex/imgcache.py +++ b/backend/app/plex/imgcache.py @@ -15,7 +15,7 @@ from fastapi import Response from sqlalchemy.orm import Session 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.lookups import by_rating_key diff --git a/backend/app/plex/models.py b/backend/app/plex/models.py new file mode 100644 index 00000000..7b39d04a --- /dev/null +++ b/backend/app/plex/models.py @@ -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) diff --git a/backend/app/plex/overlay.py b/backend/app/plex/overlay.py index 3306db4f..e9d9aec2 100644 --- a/backend/app/plex/overlay.py +++ b/backend/app/plex/overlay.py @@ -13,7 +13,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session 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.client import PlexClient from app.plex.genres import hu_genres diff --git a/backend/app/plex/people.py b/backend/app/plex/people.py index df161e9d..d869e8ef 100644 --- a/backend/app/plex/people.py +++ b/backend/app/plex/people.py @@ -15,7 +15,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session from app import logs, sysconfig -from app.models import PlexPerson +from app.plex.models import PlexPerson from app.plex import imgcache from app.plex.client import PlexClient, PlexError, PlexNotConfigured diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index 5cac29eb..4882a629 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -10,7 +10,7 @@ from sqlalchemy import and_, case, func, or_, select, type_coerce from sqlalchemy.dialects.postgresql import JSONB 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 TS_CONFIG = "public.unaccent_simple" diff --git a/backend/app/plex/rollup.py b/backend/app/plex/rollup.py index 1f2e65cf..69cb9762 100644 --- a/backend/app/plex/rollup.py +++ b/backend/app/plex/rollup.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone from sqlalchemy import and_, case, func, or_, select 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 # Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index 4616bae0..f273710d 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -33,7 +33,7 @@ from sqlalchemy.orm import Session as DbSession from app import logs, sysconfig from app.config import settings 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.sync import HDR_TRANSFERS, HW_DECODABLE_CODECS diff --git a/backend/app/plex/subtitles.py b/backend/app/plex/subtitles.py index 29059f94..19e47828 100644 --- a/backend/app/plex/subtitles.py +++ b/backend/app/plex/subtitles.py @@ -47,7 +47,7 @@ from sqlalchemy.orm import Session from app import logs 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.cache import CACHE_DIR from app.plex.client import PlexClient, PlexError diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index 45125da0..2af5530b 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -21,7 +21,7 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session 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.client import PlexClient, PlexError, PlexNotConfigured from app.plex.genres import hu_genres diff --git a/backend/app/plex/tmdb.py b/backend/app/plex/tmdb.py index ad2d9642..71e8075c 100644 --- a/backend/app/plex/tmdb.py +++ b/backend/app/plex/tmdb.py @@ -22,7 +22,7 @@ from sqlalchemy.orm import Session from app import logs, sysconfig 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.people import people_by_credits, warm_portraits diff --git a/backend/app/plex/watch_sync.py b/backend/app/plex/watch_sync.py index 6a00db2f..f76dfa7c 100644 --- a/backend/app/plex/watch_sync.py +++ b/backend/app/plex/watch_sync.py @@ -29,7 +29,7 @@ from sqlalchemy.orm import Session from app import logs, sysconfig 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.sync import ( _TYPE_EPISODE, diff --git a/backend/app/routes/plex/_shared.py b/backend/app/routes/plex/_shared.py index af59d693..301a4134 100644 --- a/backend/app/routes/plex/_shared.py +++ b/backend/app/routes/plex/_shared.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Session 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.lookups import by_rating_key from app.routes._common import not_found diff --git a/backend/app/routes/plex/admin.py b/backend/app/routes/plex/admin.py index 82642f9e..8387f496 100644 --- a/backend/app/routes/plex/admin.py +++ b/backend/app/routes/plex/admin.py @@ -10,11 +10,8 @@ from sqlalchemy.orm import Session from app import logs, sysconfig from app.auth import admin_user, current_user from app.db import get_db -from app.models import ( - PlexItem, - PlexLink, - User, -) +from app.models import User +from app.plex.models import PlexItem, PlexLink from app.plex import paths as plex_paths from app.plex import sync as plex_sync from app.plex import watch_sync as plex_watch diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index f0d73d75..af9d70fe 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -20,13 +20,8 @@ from app.collation import collated, sorted_words from app import sysconfig from app.auth import current_user from app.db import get_db -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.plex import rollup from app.plex.cards import display_title, episode_card from app.plex.query import ( diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py index 39353d0c..799f8a04 100644 --- a/backend/app/routes/plex/collections.py +++ b/backend/app/routes/plex/collections.py @@ -14,14 +14,8 @@ from app.collation import collated, sorted_words from app import logs from app.auth import admin_user, current_user from app.db import get_db -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 import rollup from app.plex import sync as plex_sync from app.plex.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index 645cf122..1df6db3b 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -15,15 +15,8 @@ from app import logs from app.auth import current_user from app.db import get_db from app.discover import queries -from app.models import ( - PlexCollection, - PlexItem, - PlexLibrary, - PlexSeason, - PlexShow, - PlexState, - User, -) +from app.models import User +from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState from app.plex import detail as plex_detail from app.plex import people as plex_people from app.plex import rollup, subtitles diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py index 8305fed7..1fd57675 100644 --- a/backend/app/routes/plex/discover.py +++ b/backend/app/routes/plex/discover.py @@ -21,7 +21,8 @@ from sqlalchemy.orm import Session from app.collation import sorted_words from app.auth import current_user 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.cards import ( SHOW_NEW, diff --git a/backend/app/routes/plex/people.py b/backend/app/routes/plex/people.py index 4a39bd70..2d59f5a2 100644 --- a/backend/app/routes/plex/people.py +++ b/backend/app/routes/plex/people.py @@ -11,12 +11,8 @@ from app.collation import collated from app import logs, sysconfig from app.auth import current_user from app.db import SessionLocal, get_db -from app.models import ( - PlexItem, - PlexPerson, - PlexShow, - User, -) +from app.models import User +from app.plex.models import PlexItem, PlexPerson, PlexShow from app.plex import rollup from app.plex import tmdb as plex_tmdb from app.plex.cards import SHOW_NEW, movie_card, norm_lang, show_card diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index 3ecada48..457859e5 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -22,13 +22,8 @@ from sqlalchemy.orm import Session from app.auth import current_user, resolved_user_id from app.db import SessionLocal, get_db -from app.models import ( - PlexItem, - PlexSeason, - PlexShow, - PlexState, - User, -) +from app.models import User +from app.plex.models import PlexItem, PlexSeason, PlexShow, PlexState from app.plex import imgcache, subtitles from app.plex import paths as plex_paths from app.plex.lookups import by_rating_key diff --git a/backend/app/routes/plex/playlists.py b/backend/app/routes/plex/playlists.py index ad2356b2..14b1879f 100644 --- a/backend/app/routes/plex/playlists.py +++ b/backend/app/routes/plex/playlists.py @@ -11,14 +11,8 @@ from sqlalchemy.orm import Session from app.auth import current_user from app.db import get_db -from app.models import ( - PlexItem, - PlexPlaylist, - PlexPlaylistItem, - PlexShow, - PlexState, - User, -) +from app.models import User +from app.plex.models import PlexItem, PlexPlaylist, PlexPlaylistItem, PlexShow, PlexState from app.plex.cards import display_title, leaf_card, norm_lang from app.plex.lookups import by_rating_key from app.routes._common import owned_or_404, not_found diff --git a/backend/app/tables.py b/backend/app/tables.py index 183b7e7e..fc1b79c2 100644 --- a/backend/app/tables.py +++ b/backend/app/tables.py @@ -8,4 +8,5 @@ every mapper — imports this module. import app.catalog.models # Catalog's tables import app.downloads.models # Capture's tables import app.models # the core tables +import app.plex.models # Plex's tables import app.reader.models # noqa: F401 — Reader's tables diff --git a/backend/tests/test_clip.py b/backend/tests/test_clip.py index 3a84999b..9f5a10bf 100644 --- a/backend/tests/test_clip.py +++ b/backend/tests/test_clip.py @@ -18,7 +18,7 @@ from app.downloads import errors as dlerrors from app.downloads import formats, pipeline, quota, runner, service from app.downloads import probe as probemod 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.downloads.models import DownloadJob, MediaAsset from app.plex import paths as plex_paths diff --git a/backend/tests/test_collation.py b/backend/tests/test_collation.py index 41d0e6a8..b4b3cbd5 100644 --- a/backend/tests/test_collation.py +++ b/backend/tests/test_collation.py @@ -9,7 +9,8 @@ from app.auth import current_user from app.collation import sorted_words from app.db import get_db 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.reader.models import Playlist, PlaylistItem from datetime import datetime, timezone diff --git a/backend/tests/test_discover.py b/backend/tests/test_discover.py index 49eb0eb5..31566b26 100644 --- a/backend/tests/test_discover.py +++ b/backend/tests/test_discover.py @@ -17,7 +17,8 @@ from app.discover import hero, pins, queries, rails from app.discover.budget import BANDS, fits, remaining_seconds from app.plex import rollup 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.reader.models import ComfortPin, Playlist, PlaylistItem, VideoState diff --git a/backend/tests/test_empty_listing_guards.py b/backend/tests/test_empty_listing_guards.py index bcd868ed..37244067 100644 --- a/backend/tests/test_empty_listing_guards.py +++ b/backend/tests/test_empty_listing_guards.py @@ -12,7 +12,7 @@ contradicts the remote's own count is not believed either. import pytest 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.reader.models import Playlist, PlaylistItem from app.plex import sync as plex_sync diff --git a/backend/tests/test_hls_reaper.py b/backend/tests/test_hls_reaper.py index 6b88dea9..c7dbac3e 100644 --- a/backend/tests/test_hls_reaper.py +++ b/backend/tests/test_hls_reaper.py @@ -28,7 +28,8 @@ import app.state as state from app.auth import current_user from app.db import get_db 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 stream diff --git a/backend/tests/test_playlist_position_lock.py b/backend/tests/test_playlist_position_lock.py index 95ee1ba7..308c1b7d 100644 --- a/backend/tests/test_playlist_position_lock.py +++ b/backend/tests/test_playlist_position_lock.py @@ -19,7 +19,7 @@ from datetime import datetime, timezone import pytest 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.reader.models import Playlist, PlaylistItem diff --git a/backend/tests/test_plex_admin_routes.py b/backend/tests/test_plex_admin_routes.py index d39a4cde..576457ca 100644 --- a/backend/tests/test_plex_admin_routes.py +++ b/backend/tests/test_plex_admin_routes.py @@ -9,7 +9,7 @@ from sqlalchemy import select import app.routes.plex.admin as admin_routes from app import sysconfig -from app.models import PlexLink +from app.plex.models import PlexLink from app.plex.client import PlexError, PlexNotConfigured diff --git a/backend/tests/test_plex_cards.py b/backend/tests/test_plex_cards.py index b1f1267a..e7a861ca 100644 --- a/backend/tests/test_plex_cards.py +++ b/backend/tests/test_plex_cards.py @@ -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 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 diff --git a/backend/tests/test_plex_clear_resume_push.py b/backend/tests/test_plex_clear_resume_push.py index 3d267696..900d33af 100644 --- a/backend/tests/test_plex_clear_resume_push.py +++ b/backend/tests/test_plex_clear_resume_push.py @@ -22,7 +22,8 @@ import app.state as state from app.auth import current_user from app.db import get_db 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 diff --git a/backend/tests/test_plex_collection_sort.py b/backend/tests/test_plex_collection_sort.py index 4c5948fa..4326e4e8 100644 --- a/backend/tests/test_plex_collection_sort.py +++ b/backend/tests/test_plex_collection_sort.py @@ -17,7 +17,8 @@ import app.state as state from app.auth import current_user from app.db import get_db 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.routes.plex.browse import sort_column from app.routes.plex.collections import ( diff --git a/backend/tests/test_plex_collections_filter.py b/backend/tests/test_plex_collections_filter.py index 1c2e000b..c9dccf9d 100644 --- a/backend/tests/test_plex_collections_filter.py +++ b/backend/tests/test_plex_collections_filter.py @@ -13,7 +13,8 @@ import app.state as state from app.auth import admin_user, current_user from app.db import get_db 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.lookups import by_rating_key from app.routes.plex import collections as coll_routes diff --git a/backend/tests/test_plex_collections_for_item.py b/backend/tests/test_plex_collections_for_item.py index ece1025c..6a18b526 100644 --- a/backend/tests/test_plex_collections_for_item.py +++ b/backend/tests/test_plex_collections_for_item.py @@ -9,7 +9,8 @@ import app.state as state from app.auth import current_user from app.db import get_db 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): diff --git a/backend/tests/test_plex_detail.py b/backend/tests/test_plex_detail.py index 2f8f4895..b8e2ec3d 100644 --- a/backend/tests/test_plex_detail.py +++ b/backend/tests/test_plex_detail.py @@ -19,7 +19,8 @@ import app.state as state from app.auth import current_user from app.db import get_db 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.sync import _apply_facet_fields, _enrich_people_batch diff --git a/backend/tests/test_plex_discover.py b/backend/tests/test_plex_discover.py index 7f1d6789..0f4f87f7 100644 --- a/backend/tests/test_plex_discover.py +++ b/backend/tests/test_plex_discover.py @@ -13,7 +13,8 @@ import app.state as state from app.auth import current_user from app.db import get_db 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) diff --git a/backend/tests/test_plex_facets.py b/backend/tests/test_plex_facets.py index 09c334e3..a0093efa 100644 --- a/backend/tests/test_plex_facets.py +++ b/backend/tests/test_plex_facets.py @@ -13,7 +13,8 @@ import app.state as state from app.auth import current_user from app.db import get_db 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): diff --git a/backend/tests/test_plex_facets_grid_parity.py b/backend/tests/test_plex_facets_grid_parity.py index ad17cbb3..d41c35fe 100644 --- a/backend/tests/test_plex_facets_grid_parity.py +++ b/backend/tests/test_plex_facets_grid_parity.py @@ -15,14 +15,8 @@ the merge is provably behaviour-equivalent (and a drift between grid and facets from collections import Counter from datetime import datetime, timezone -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 SCOPES = ("movie", "show", "both") SHOW_STATES = ("all", "watched", "in_progress", "unwatched") diff --git a/backend/tests/test_plex_field_scoped_search.py b/backend/tests/test_plex_field_scoped_search.py index c2aafd5f..a58c64ae 100644 --- a/backend/tests/test_plex_field_scoped_search.py +++ b/backend/tests/test_plex_field_scoped_search.py @@ -14,13 +14,8 @@ import app.state as state from app.auth import current_user from app.db import get_db from app.main import app -from app.models import ( - PlexItem, - PlexLibrary, - PlexSeason, - PlexShow, - User, -) +from app.models import User +from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow from fastapi.testclient import TestClient diff --git a/backend/tests/test_plex_finish.py b/backend/tests/test_plex_finish.py index 43e890da..fccee9e6 100644 --- a/backend/tests/test_plex_finish.py +++ b/backend/tests/test_plex_finish.py @@ -13,7 +13,7 @@ from datetime import datetime, timedelta, timezone from sqlalchemy import select 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 diff --git a/backend/tests/test_plex_image_sizes.py b/backend/tests/test_plex_image_sizes.py index 8091998d..de996f5a 100644 --- a/backend/tests/test_plex_image_sizes.py +++ b/backend/tests/test_plex_image_sizes.py @@ -9,7 +9,7 @@ from fastapi.testclient import TestClient import app.state as app_state 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.routes.plex import playback diff --git a/backend/tests/test_plex_jsonb_normalize.py b/backend/tests/test_plex_jsonb_normalize.py index 77fd2a04..3db414da 100644 --- a/backend/tests/test_plex_jsonb_normalize.py +++ b/backend/tests/test_plex_jsonb_normalize.py @@ -7,7 +7,7 @@ Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable. 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 # SQL is valid for both tables; the assertions below check the seeded plex_shows rows). diff --git a/backend/tests/test_plex_keyword_filter.py b/backend/tests/test_plex_keyword_filter.py index 196ee9ed..94a3f4b6 100644 --- a/backend/tests/test_plex_keyword_filter.py +++ b/backend/tests/test_plex_keyword_filter.py @@ -9,7 +9,8 @@ import app.state as state from app.auth import current_user from app.db import get_db 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): diff --git a/backend/tests/test_plex_lang.py b/backend/tests/test_plex_lang.py index 1c112575..53ebc2e2 100644 --- a/backend/tests/test_plex_lang.py +++ b/backend/tests/test_plex_lang.py @@ -14,7 +14,8 @@ from app.db import get_db from app.main import app from app import sysconfig from app.plex.cards import display_summary, display_title -from app.models import ( +from app.models import User +from app.plex.models import ( PlexCollection, PlexItem, PlexLibrary, @@ -23,7 +24,6 @@ from app.models import ( PlexSeason, PlexShow, PlexState, - User, ) diff --git a/backend/tests/test_plex_last_watched_sort.py b/backend/tests/test_plex_last_watched_sort.py index f3ef2f31..f418accb 100644 --- a/backend/tests/test_plex_last_watched_sort.py +++ b/backend/tests/test_plex_last_watched_sort.py @@ -12,7 +12,8 @@ import app.state as state from app.auth import current_user from app.db import get_db 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) diff --git a/backend/tests/test_plex_overlay.py b/backend/tests/test_plex_overlay.py index 35148196..9e48bbd1 100644 --- a/backend/tests/test_plex_overlay.py +++ b/backend/tests/test_plex_overlay.py @@ -14,7 +14,7 @@ from sqlalchemy.orm import Session import app.plex.sync as syncmod from app import sysconfig 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.overlay import sync_overlay from app.plex.sync import _apply_item, _canonical_guid diff --git a/backend/tests/test_plex_people_enrich.py b/backend/tests/test_plex_people_enrich.py index 307f85ac..34fa6db3 100644 --- a/backend/tests/test_plex_people_enrich.py +++ b/backend/tests/test_plex_people_enrich.py @@ -9,7 +9,8 @@ from datetime import datetime, timezone import httpx 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 people as plex_people from app.plex import tmdb as plex_tmdb diff --git a/backend/tests/test_plex_person.py b/backend/tests/test_plex_person.py index 9e7519f4..51715096 100644 --- a/backend/tests/test_plex_person.py +++ b/backend/tests/test_plex_person.py @@ -12,14 +12,8 @@ import app.state as state from app.auth import current_user from app.db import get_db from app.main import app -from app.models import ( - PlexItem, - PlexLibrary, - PlexPerson, - PlexShow, - PlexState, - User, -) +from app.models import User +from app.plex.models import PlexItem, PlexLibrary, PlexPerson, PlexShow, PlexState from app.plex.query import content_libs NAME = "Robert De Niro" diff --git a/backend/tests/test_plex_person_bio.py b/backend/tests/test_plex_person_bio.py index 9dadf06d..40ca623c 100644 --- a/backend/tests/test_plex_person_bio.py +++ b/backend/tests/test_plex_person_bio.py @@ -10,7 +10,8 @@ import app.state as state from app.auth import current_user from app.db import get_db 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 PAYLOAD = { diff --git a/backend/tests/test_plex_plaintext.py b/backend/tests/test_plex_plaintext.py index ffd86706..4a1bedcc 100644 --- a/backend/tests/test_plex_plaintext.py +++ b/backend/tests/test_plex_plaintext.py @@ -12,7 +12,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session 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.overlay import sync_overlay from app.plex.plaintext import plain_text diff --git a/backend/tests/test_plex_playback_class.py b/backend/tests/test_plex_playback_class.py index 82f16212..39b36f0d 100644 --- a/backend/tests/test_plex_playback_class.py +++ b/backend/tests/test_plex_playback_class.py @@ -14,7 +14,8 @@ import app.state as state from app.auth import current_user from app.db import get_db 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 fastapi.testclient import TestClient diff --git a/backend/tests/test_plex_playback_session.py b/backend/tests/test_plex_playback_session.py index ea823c66..59b9e708 100644 --- a/backend/tests/test_plex_playback_session.py +++ b/backend/tests/test_plex_playback_session.py @@ -19,7 +19,8 @@ import app.state as state from app.auth import current_user from app.db import get_db 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 stream as plex_stream diff --git a/backend/tests/test_plex_playlist_add_positions.py b/backend/tests/test_plex_playlist_add_positions.py index 6c20ed6d..04b1552b 100644 --- a/backend/tests/test_plex_playlist_add_positions.py +++ b/backend/tests/test_plex_playlist_add_positions.py @@ -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. """ -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")): diff --git a/backend/tests/test_plex_query_conversion.py b/backend/tests/test_plex_query_conversion.py index 6dd44467..96c8d121 100644 --- a/backend/tests/test_plex_query_conversion.py +++ b/backend/tests/test_plex_query_conversion.py @@ -16,7 +16,8 @@ import app.state as state from app.auth import current_user from app.db import get_db 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.cards import ShowState @@ -490,7 +491,7 @@ class TestPlaylistAndCollectionEndpoints: ] # input order preserved, m2 gone 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) db.add(lib) diff --git a/backend/tests/test_plex_subtitle_prepare.py b/backend/tests/test_plex_subtitle_prepare.py index 613af2e3..bff5f874 100644 --- a/backend/tests/test_plex_subtitle_prepare.py +++ b/backend/tests/test_plex_subtitle_prepare.py @@ -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(): """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 assert by_rating_key(None, PlexItem, "12\x0034") is None # type: ignore[arg-type] diff --git a/backend/tests/test_plex_sync_prune.py b/backend/tests/test_plex_sync_prune.py index cfb5232a..8a4244c1 100644 --- a/backend/tests/test_plex_sync_prune.py +++ b/backend/tests/test_plex_sync_prune.py @@ -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 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.sync import ( _TYPE_EPISODE, _TYPE_MOVIE, diff --git a/backend/tests/test_plex_sync_txn.py b/backend/tests/test_plex_sync_txn.py index a2c19ea5..b7ebbee4 100644 --- a/backend/tests/test_plex_sync_txn.py +++ b/backend/tests/test_plex_sync_txn.py @@ -7,7 +7,7 @@ from sqlalchemy import select from app.plex import people, sync from app.plex.client import PlexError -from app.models import PlexItem, PlexLibrary, PlexPerson +from app.plex.models import PlexItem, PlexLibrary, PlexPerson class _FakePlex: diff --git a/backend/tests/test_plex_tmdb.py b/backend/tests/test_plex_tmdb.py index 7ee9cc44..e569a7ab 100644 --- a/backend/tests/test_plex_tmdb.py +++ b/backend/tests/test_plex_tmdb.py @@ -9,7 +9,7 @@ from datetime import datetime, timezone import httpx import pytest -from app.models import PlexItem, PlexLibrary, PlexPerson, PlexShow +from app.plex.models import PlexItem, PlexLibrary, PlexPerson, PlexShow from app.plex import tmdb # A TMDB /movie/{id}?append_to_response=keywords,credits,recommendations,images,external_ids payload. diff --git a/backend/tests/test_plex_watch_reconcile.py b/backend/tests/test_plex_watch_reconcile.py index 1b2e7e61..f8c634be 100644 --- a/backend/tests/test_plex_watch_reconcile.py +++ b/backend/tests/test_plex_watch_reconcile.py @@ -7,7 +7,8 @@ import pytest from sqlalchemy import select 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 diff --git a/backend/tests/test_plex_watch_sync.py b/backend/tests/test_plex_watch_sync.py index 6e119974..eae986c7 100644 --- a/backend/tests/test_plex_watch_sync.py +++ b/backend/tests/test_plex_watch_sync.py @@ -13,7 +13,8 @@ from types import SimpleNamespace 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.routes.plex.playback import ( _PUSH_THROTTLE_S,