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