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:
parent
401fe90256
commit
a88254c841
9 changed files with 552 additions and 0 deletions
161
backend/alembic/versions/0044_plex_catalog.py
Normal file
161
backend/alembic/versions/0044_plex_catalog.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Plex integration catalog + per-user watch state
|
||||
|
||||
Revision ID: 0044_plex_catalog
|
||||
Revises: 0043_asset_source_url
|
||||
Create Date: 2026-07-05
|
||||
|
||||
The optional Plex module's dedicated, self-contained catalog mirrored from a locally-reachable
|
||||
Plex server (playback is from the local physical file; Plex is metadata only). Tables:
|
||||
|
||||
- plex_libraries — mirrored library sections (movie|show), only `enabled` ones are exposed.
|
||||
- plex_shows — TV shows (poster + summary for the drill-down); weighted FTS search_vector.
|
||||
- plex_seasons — seasons, for ordering episodes.
|
||||
- plex_items — the playable LEAF (movie|episode): metadata + physical-media facts + the
|
||||
ffprobe playability class; weighted FTS search_vector.
|
||||
- plex_states — per-user watch/in-progress state (mirrors video_states) so it works for
|
||||
users without a Plex account.
|
||||
|
||||
The generated `search_vector` columns + their GIN indexes are added via raw SQL (same approach
|
||||
as migration 0032), reusing the `unaccent_simple` text-search config from migration 0031.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0044_plex_catalog"
|
||||
down_revision: Union[str, None] = "0043_asset_source_url"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# Weighted document: title (A) > truncated summary (C). Same unaccent_simple config as videos.
|
||||
_SEARCH_VECTOR_EXPR = (
|
||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
||||
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')"
|
||||
)
|
||||
|
||||
|
||||
def _add_search_vector(table: str) -> None:
|
||||
op.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN search_vector tsvector "
|
||||
f"GENERATED ALWAYS AS ({_SEARCH_VECTOR_EXPR}) STORED"
|
||||
)
|
||||
op.execute(f"CREATE INDEX ix_{table}_search_vector ON {table} USING gin (search_vector)")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"plex_libraries",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("plex_key", sa.String(length=32), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), server_default="true", nullable=False),
|
||||
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_plex_libraries_plex_key", "plex_libraries", ["plex_key"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"plex_shows",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
||||
sa.Column("library_id", sa.Integer(), sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("title", sa.String(length=512), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=True),
|
||||
sa.Column("year", sa.Integer(), nullable=True),
|
||||
sa.Column("thumb_key", sa.String(length=512), nullable=True),
|
||||
sa.Column("art_key", sa.String(length=512), nullable=True),
|
||||
sa.Column("child_count", sa.Integer(), nullable=True),
|
||||
sa.Column("added_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_plex_shows_rating_key", "plex_shows", ["rating_key"], unique=True)
|
||||
op.create_index("ix_plex_shows_library_id", "plex_shows", ["library_id"])
|
||||
op.create_index("ix_plex_shows_added_at", "plex_shows", ["added_at"])
|
||||
_add_search_vector("plex_shows")
|
||||
|
||||
op.create_table(
|
||||
"plex_seasons",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
||||
sa.Column("show_id", sa.Integer(), sa.ForeignKey("plex_shows.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=True),
|
||||
sa.Column("season_number", sa.Integer(), nullable=True),
|
||||
sa.Column("thumb_key", sa.String(length=512), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_plex_seasons_rating_key", "plex_seasons", ["rating_key"], unique=True)
|
||||
op.create_index("ix_plex_seasons_show_id", "plex_seasons", ["show_id"])
|
||||
op.create_index("ix_plex_seasons_season_number", "plex_seasons", ["season_number"])
|
||||
|
||||
op.create_table(
|
||||
"plex_items",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
||||
sa.Column("library_id", sa.Integer(), sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||
sa.Column("show_id", sa.Integer(), sa.ForeignKey("plex_shows.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("season_id", sa.Integer(), sa.ForeignKey("plex_seasons.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("season_number", sa.Integer(), nullable=True),
|
||||
sa.Column("episode_number", sa.Integer(), nullable=True),
|
||||
sa.Column("title", sa.String(length=512), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=True),
|
||||
sa.Column("year", sa.Integer(), nullable=True),
|
||||
sa.Column("duration_s", sa.Integer(), nullable=True),
|
||||
sa.Column("thumb_key", sa.String(length=512), nullable=True),
|
||||
sa.Column("art_key", sa.String(length=512), nullable=True),
|
||||
sa.Column("cast", sa.JSON(), nullable=True),
|
||||
sa.Column("file_path", sa.Text(), nullable=True),
|
||||
sa.Column("part_key", sa.String(length=255), nullable=True),
|
||||
sa.Column("container", sa.String(length=16), nullable=True),
|
||||
sa.Column("codec_video", sa.String(length=32), nullable=True),
|
||||
sa.Column("codec_audio", sa.String(length=32), nullable=True),
|
||||
sa.Column("size_bytes", sa.BigInteger(), nullable=True),
|
||||
sa.Column("playable", sa.String(length=12), nullable=True),
|
||||
sa.Column("probed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("markers", sa.JSON(), nullable=True),
|
||||
sa.Column("added_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_plex_items_rating_key", "plex_items", ["rating_key"], unique=True)
|
||||
op.create_index("ix_plex_items_library_id", "plex_items", ["library_id"])
|
||||
op.create_index("ix_plex_items_kind", "plex_items", ["kind"])
|
||||
op.create_index("ix_plex_items_show_id", "plex_items", ["show_id"])
|
||||
op.create_index("ix_plex_items_season_id", "plex_items", ["season_id"])
|
||||
op.create_index("ix_plex_items_playable", "plex_items", ["playable"])
|
||||
op.create_index("ix_plex_items_added_at", "plex_items", ["added_at"])
|
||||
op.create_index(
|
||||
"ix_plex_items_show_order", "plex_items", ["show_id", "season_number", "episode_number"]
|
||||
)
|
||||
_add_search_vector("plex_items")
|
||||
|
||||
op.create_table(
|
||||
"plex_states",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("item_id", sa.Integer(), sa.ForeignKey("plex_items.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("status", sa.String(length=12), server_default="new", nullable=False),
|
||||
sa.Column("position_seconds", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("watched_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("synced_to_plex", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),
|
||||
)
|
||||
op.create_index("ix_plex_states_user_id", "plex_states", ["user_id"])
|
||||
op.create_index("ix_plex_states_item_id", "plex_states", ["item_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("plex_states")
|
||||
op.execute("DROP INDEX IF EXISTS ix_plex_items_search_vector")
|
||||
op.drop_table("plex_items")
|
||||
op.drop_table("plex_seasons")
|
||||
op.execute("DROP INDEX IF EXISTS ix_plex_shows_search_vector")
|
||||
op.drop_table("plex_shows")
|
||||
op.drop_table("plex_libraries")
|
||||
Loading…
Add table
Add a link
Reference in a new issue