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")
|
||||||
|
|
@ -182,6 +182,30 @@ class Settings(BaseSettings):
|
||||||
# Default SponsorBlock (skip sponsor segments) for new custom profiles.
|
# Default SponsorBlock (skip sponsor segments) for new custom profiles.
|
||||||
sponsorblock_default: bool = False
|
sponsorblock_default: bool = False
|
||||||
|
|
||||||
|
# --- Plex integration (optional) ---
|
||||||
|
# The Plex module plays the LOCAL physical media file directly (our own ffmpeg/direct-serve);
|
||||||
|
# Plex is used only for metadata + optional watch-state sync. Requires the Plex server to be
|
||||||
|
# reachable from THIS host and its media reachable on a local mount (see plex_path_map).
|
||||||
|
plex_enabled: bool = False
|
||||||
|
# Base URL of the Plex Media Server reachable from this host, e.g. http://192.168.1.112:32400.
|
||||||
|
plex_server_url: str = ""
|
||||||
|
# Plex server admin token (X-Plex-Token). Server-side only (metadata + image proxy); never
|
||||||
|
# sent to the browser. Stored encrypted when set via the admin Config page.
|
||||||
|
plex_server_token: str = ""
|
||||||
|
# Path map: Plex's on-disk media paths → this host's read-only mount, so Siftlode can open the
|
||||||
|
# physical file for direct playback. Newline/comma-separated "plexPrefix=localPrefix" pairs;
|
||||||
|
# the longest matching prefix wins. E.g. "/data=/plex-media". Empty = identical on both sides.
|
||||||
|
plex_path_map: str = ""
|
||||||
|
# Which Plex library section keys are exposed (comma-separated). Empty = all enabled sections.
|
||||||
|
plex_libraries: str = ""
|
||||||
|
# Stable client identifier Siftlode presents to Plex (X-Plex-Client-Identifier).
|
||||||
|
plex_client_id: str = "siftlode-server"
|
||||||
|
# How often the catalog sync job mirrors Plex metadata, in minutes.
|
||||||
|
plex_sync_interval_min: int = 360
|
||||||
|
# Max concurrent transcodes for the P3 fallback. Low by default — CPU-only transcode is
|
||||||
|
# expensive; direct-serve (browser-compatible files) has no such limit.
|
||||||
|
plex_max_transcodes: int = 1
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def allowed_email_set(self) -> set[str]:
|
def allowed_email_set(self) -> set[str]:
|
||||||
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}
|
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ from app.routes import (
|
||||||
messages,
|
messages,
|
||||||
notifications,
|
notifications,
|
||||||
playlists,
|
playlists,
|
||||||
|
plex as plex_routes,
|
||||||
public as public_routes,
|
public as public_routes,
|
||||||
quota,
|
quota,
|
||||||
saved_views,
|
saved_views,
|
||||||
|
|
@ -138,6 +139,7 @@ app.include_router(messages.router)
|
||||||
app.include_router(channels.router)
|
app.include_router(channels.router)
|
||||||
app.include_router(playlists.router)
|
app.include_router(playlists.router)
|
||||||
app.include_router(saved_views.router)
|
app.include_router(saved_views.router)
|
||||||
|
app.include_router(plex_routes.router)
|
||||||
app.include_router(downloads.router)
|
app.include_router(downloads.router)
|
||||||
app.include_router(downloads.admin_router)
|
app.include_router(downloads.admin_router)
|
||||||
app.include_router(public_routes.router)
|
app.include_router(public_routes.router)
|
||||||
|
|
|
||||||
|
|
@ -919,3 +919,149 @@ class MessageKey(Base, TimestampMixin):
|
||||||
salt: Mapped[str] = mapped_column(String(64))
|
salt: Mapped[str] = mapped_column(String(64))
|
||||||
wrap_iv: Mapped[str] = mapped_column(String(32))
|
wrap_iv: Mapped[str] = mapped_column(String(32))
|
||||||
key_check: Mapped[str] = mapped_column(Text)
|
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"
|
||||||
|
)
|
||||||
|
|
|
||||||
0
backend/app/plex/__init__.py
Normal file
0
backend/app/plex/__init__.py
Normal file
113
backend/app/plex/client.py
Normal file
113
backend/app/plex/client.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""Thin synchronous Plex Media Server client (metadata + images + watch-state).
|
||||||
|
|
||||||
|
Server-side ONLY: uses the admin ``X-Plex-Token`` from sysconfig. Playback never goes through
|
||||||
|
this client — Siftlode plays the LOCAL physical file (see app.plex.paths). Plex is used purely for
|
||||||
|
metadata, image proxying, and (P5) optional watch-state write-back. Requests JSON via the
|
||||||
|
``Accept: application/json`` header (Plex serves XML by default).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import sysconfig
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
log = logging.getLogger("siftlode.plex")
|
||||||
|
|
||||||
|
|
||||||
|
class PlexError(Exception):
|
||||||
|
"""Any failure talking to the Plex server (network, HTTP status, bad payload)."""
|
||||||
|
|
||||||
|
|
||||||
|
class PlexNotConfigured(PlexError):
|
||||||
|
"""The Plex server URL or token isn't set — the module can't do anything."""
|
||||||
|
|
||||||
|
|
||||||
|
class PlexClient:
|
||||||
|
"""Bound to the configured server + admin token. Use as a context manager so the underlying
|
||||||
|
httpx client is closed."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
self.base = sysconfig.get_str(db, "plex_server_url").rstrip("/")
|
||||||
|
self.token = sysconfig.get_str(db, "plex_server_token")
|
||||||
|
if not self.base or not self.token:
|
||||||
|
raise PlexNotConfigured("Plex server URL or token is not configured")
|
||||||
|
self._http = httpx.Client(
|
||||||
|
timeout=30.0,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-Plex-Token": self.token,
|
||||||
|
"X-Plex-Client-Identifier": settings.plex_client_id,
|
||||||
|
"X-Plex-Product": "Siftlode",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __enter__(self) -> "PlexClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc) -> None:
|
||||||
|
self._http.close()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._http.close()
|
||||||
|
|
||||||
|
def _get(self, path: str, params: dict | None = None) -> dict:
|
||||||
|
try:
|
||||||
|
r = self._http.get(f"{self.base}{path}", params=params)
|
||||||
|
r.raise_for_status()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise PlexError(str(e)) from e
|
||||||
|
try:
|
||||||
|
return (r.json() or {}).get("MediaContainer", {}) or {}
|
||||||
|
except ValueError as e:
|
||||||
|
raise PlexError("Plex returned a non-JSON response") from e
|
||||||
|
|
||||||
|
# --- Connectivity + sections (P0) ---
|
||||||
|
def server_info(self) -> dict:
|
||||||
|
"""Root identity (friendlyName / version) — used by the admin 'Test connection'."""
|
||||||
|
return self._get("/")
|
||||||
|
|
||||||
|
def sections(self) -> list[dict]:
|
||||||
|
"""Library sections (Directory entries): key, title, type (movie|show|...)."""
|
||||||
|
mc = self._get("/library/sections")
|
||||||
|
return mc.get("Directory", []) or []
|
||||||
|
|
||||||
|
# --- Catalog mirror (P1) ---
|
||||||
|
def section_items(
|
||||||
|
self, key: str, item_type: int, start: int = 0, size: int = 200
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
"""A page of a section's top-level items. type 1 = movie, 2 = show. Returns
|
||||||
|
(items, total_size)."""
|
||||||
|
mc = self._get(
|
||||||
|
f"/library/sections/{key}/all",
|
||||||
|
params={
|
||||||
|
"type": item_type,
|
||||||
|
"X-Plex-Container-Start": start,
|
||||||
|
"X-Plex-Container-Size": size,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return mc.get("Metadata", []) or [], int(mc.get("totalSize", mc.get("size", 0)) or 0)
|
||||||
|
|
||||||
|
def children(self, rating_key: str) -> list[dict]:
|
||||||
|
"""Direct children (show → seasons, season → episodes)."""
|
||||||
|
mc = self._get(f"/library/metadata/{rating_key}/children")
|
||||||
|
return mc.get("Metadata", []) or []
|
||||||
|
|
||||||
|
def metadata(self, rating_key: str, markers: bool = False) -> dict | None:
|
||||||
|
"""Full metadata for one item; include intro/credit markers when requested."""
|
||||||
|
params = {"includeMarkers": 1} if markers else None
|
||||||
|
mc = self._get(f"/library/metadata/{rating_key}", params=params)
|
||||||
|
items = mc.get("Metadata", []) or []
|
||||||
|
return items[0] if items else None
|
||||||
|
|
||||||
|
def image_bytes(self, image_path: str) -> tuple[bytes, str]:
|
||||||
|
"""Raw bytes + content-type of a Plex thumb/art image (proxied to the browser). The
|
||||||
|
image_path is a Plex path like ``/library/metadata/123/thumb/456``."""
|
||||||
|
try:
|
||||||
|
r = self._http.get(f"{self.base}{image_path}")
|
||||||
|
r.raise_for_status()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise PlexError(str(e)) from e
|
||||||
|
return r.content, r.headers.get("Content-Type", "image/jpeg")
|
||||||
51
backend/app/plex/paths.py
Normal file
51
backend/app/plex/paths.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Map a Plex-side media path to Siftlode's local mount, so the player can open the physical file.
|
||||||
|
|
||||||
|
The Plex metadata gives each media Part an absolute path as the PLEX server sees it (e.g.
|
||||||
|
``/data/movies/Foo.mkv``). Siftlode plays the file directly, so it must translate that to the path
|
||||||
|
as SIFTLODE sees the same storage on a local mount (e.g. ``/plex-media/movies/Foo.mkv``). The map
|
||||||
|
is admin-configured (sysconfig ``plex_path_map``) as newline/comma-separated ``plexPrefix=localPrefix``
|
||||||
|
pairs; the longest matching Plex prefix wins. An empty map means the paths are identical on both
|
||||||
|
sides (Plex and Siftlode see the same filesystem).
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import sysconfig
|
||||||
|
|
||||||
|
|
||||||
|
def parse_map(raw: str) -> list[tuple[str, str]]:
|
||||||
|
"""Parse the ``plexPrefix=localPrefix`` pairs, longest Plex prefix first."""
|
||||||
|
pairs: list[tuple[str, str]] = []
|
||||||
|
for chunk in (raw or "").replace("\n", ",").split(","):
|
||||||
|
chunk = chunk.strip()
|
||||||
|
if not chunk or "=" not in chunk:
|
||||||
|
continue
|
||||||
|
plex, local = chunk.split("=", 1)
|
||||||
|
plex, local = plex.strip(), local.strip()
|
||||||
|
if plex and local:
|
||||||
|
pairs.append((plex, local))
|
||||||
|
pairs.sort(key=lambda p: len(p[0]), reverse=True)
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def map_path(db: Session, plex_path: str) -> str:
|
||||||
|
"""Translate a Plex-side absolute path to the local mount path (no mapping = unchanged)."""
|
||||||
|
for plex_prefix, local_prefix in parse_map(sysconfig.get_str(db, "plex_path_map")):
|
||||||
|
if plex_path == plex_prefix or plex_path.startswith(plex_prefix.rstrip("/") + "/"):
|
||||||
|
rest = plex_path[len(plex_prefix):].lstrip("/")
|
||||||
|
return str(Path(local_prefix) / rest) if rest else local_prefix
|
||||||
|
return plex_path
|
||||||
|
|
||||||
|
|
||||||
|
def local_media_path(db: Session, plex_path: str | None) -> Path | None:
|
||||||
|
"""The resolved, existing local file for a Plex media path, or None if it can't be read.
|
||||||
|
The path originates from the trusted Plex server + an admin-set map (not user input), so no
|
||||||
|
traversal check is needed beyond requiring a real regular file to exist."""
|
||||||
|
if not plex_path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
p = Path(map_path(db, plex_path)).resolve()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return p if p.is_file() else None
|
||||||
47
backend/app/routes/plex.py
Normal file
47
backend/app/routes/plex.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Plex integration API.
|
||||||
|
|
||||||
|
Read endpoints (browse/search/stream/image/state) are available to ANY authenticated user — the
|
||||||
|
whole point of the module is to be an access layer to the Plex library WITHOUT requiring a plex.tv
|
||||||
|
account, and playback is a local file (no quota/credit cost), so demo + pure-Siftlode users are
|
||||||
|
allowed. Admin endpoints configure + test the connection and trigger a sync.
|
||||||
|
|
||||||
|
P0 ships only the admin connectivity endpoints; browse/search/player land in P1/P2.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models import User
|
||||||
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
|
from app.routes.admin import admin_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||||
|
"""Admin: verify the configured Plex server URL + token, returning the server name and the
|
||||||
|
available library sections (movie/show) for the config library-picker."""
|
||||||
|
try:
|
||||||
|
with PlexClient(db) as plex:
|
||||||
|
info = plex.server_info()
|
||||||
|
sections = plex.sections()
|
||||||
|
except PlexNotConfigured as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except PlexError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=f"Plex connection failed: {e}")
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"server_name": info.get("friendlyName") or info.get("title1") or "Plex",
|
||||||
|
"version": info.get("version"),
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"key": str(s.get("key")),
|
||||||
|
"title": s.get("title"),
|
||||||
|
"type": s.get("type"),
|
||||||
|
"count": s.get("count"),
|
||||||
|
}
|
||||||
|
for s in sections
|
||||||
|
if s.get("type") in ("movie", "show")
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,14 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||||
ConfigSpec("download_layout", "str", "downloads", "download_layout"),
|
ConfigSpec("download_layout", "str", "downloads", "download_layout"),
|
||||||
ConfigSpec("download_worker_concurrency", "int", "downloads", "download_worker_concurrency", min=1, max=16),
|
ConfigSpec("download_worker_concurrency", "int", "downloads", "download_worker_concurrency", min=1, max=16),
|
||||||
ConfigSpec("sponsorblock_default", "bool", "downloads", "sponsorblock_default"),
|
ConfigSpec("sponsorblock_default", "bool", "downloads", "sponsorblock_default"),
|
||||||
|
# --- Plex integration (optional; local-file playback + Plex metadata) ---
|
||||||
|
ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled"),
|
||||||
|
ConfigSpec("plex_server_url", "str", "plex", "plex_server_url"),
|
||||||
|
ConfigSpec("plex_server_token", "str", "plex", "plex_server_token", secret=True),
|
||||||
|
ConfigSpec("plex_path_map", "str", "plex", "plex_path_map"),
|
||||||
|
ConfigSpec("plex_libraries", "str", "plex", "plex_libraries"),
|
||||||
|
ConfigSpec("plex_sync_interval_min", "int", "plex", "plex_sync_interval_min", min=5, max=100_000),
|
||||||
|
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
|
||||||
)
|
)
|
||||||
|
|
||||||
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
|
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue