feat(plex): P0 backend foundations — catalog model, config, client

Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.

- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
  (playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
  + plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
  path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
  +markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
This commit is contained in:
npeter83 2026-07-05 01:35:08 +02:00
parent 401fe90256
commit a88254c841
9 changed files with 552 additions and 0 deletions

View file

@ -919,3 +919,149 @@ class MessageKey(Base, TimestampMixin):
salt: Mapped[str] = mapped_column(String(64))
wrap_iv: Mapped[str] = mapped_column(String(32))
key_check: Mapped[str] = mapped_column(Text)
# --- 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))
class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
"""A TV show (grandparent of episodes) — holds poster + summary for the drill-down page."""
__tablename__ = "plex_shows"
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)
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
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=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', left(coalesce(summary, ''), 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"),
)
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"
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))
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
markers: Mapped[list | None] = mapped_column(JSON)
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=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', left(coalesce(summary, ''), 1000)), 'C')",
persisted=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 (P5)."""
__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"), index=True
)
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"
)