siftlode/backend/alembic/versions/0044_plex_catalog.py
npeter83 a88254c841 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)
2026-07-05 01:35:08 +02:00

161 lines
8.9 KiB
Python

"""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")