162 lines
8.9 KiB
Python
162 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")
|