diff --git a/backend/alembic/versions/0044_plex_catalog.py b/backend/alembic/versions/0044_plex_catalog.py new file mode 100644 index 0000000..f090944 --- /dev/null +++ b/backend/alembic/versions/0044_plex_catalog.py @@ -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") diff --git a/backend/app/config.py b/backend/app/config.py index 224b11a..8ef87cd 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -182,6 +182,30 @@ class Settings(BaseSettings): # Default SponsorBlock (skip sponsor segments) for new custom profiles. 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 def allowed_email_set(self) -> set[str]: return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()} diff --git a/backend/app/main.py b/backend/app/main.py index 721712c..bea87ee 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -38,6 +38,7 @@ from app.routes import ( messages, notifications, playlists, + plex as plex_routes, public as public_routes, quota, saved_views, @@ -138,6 +139,7 @@ app.include_router(messages.router) app.include_router(channels.router) app.include_router(playlists.router) app.include_router(saved_views.router) +app.include_router(plex_routes.router) app.include_router(downloads.router) app.include_router(downloads.admin_router) app.include_router(public_routes.router) diff --git a/backend/app/models.py b/backend/app/models.py index 6913ece..fe33961 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -919,3 +919,149 @@ class MessageKey(Base, TimestampMixin): salt: Mapped[str] = mapped_column(String(64)) wrap_iv: Mapped[str] = mapped_column(String(32)) 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" + ) diff --git a/backend/app/plex/__init__.py b/backend/app/plex/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/plex/client.py b/backend/app/plex/client.py new file mode 100644 index 0000000..11362a1 --- /dev/null +++ b/backend/app/plex/client.py @@ -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") diff --git a/backend/app/plex/paths.py b/backend/app/plex/paths.py new file mode 100644 index 0000000..34aaa04 --- /dev/null +++ b/backend/app/plex/paths.py @@ -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 diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py new file mode 100644 index 0000000..9bb5c59 --- /dev/null +++ b/backend/app/routes/plex.py @@ -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") + ], + } diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index ed6c79b..1e0595e 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -76,6 +76,14 @@ SPECS: tuple[ConfigSpec, ...] = ( ConfigSpec("download_layout", "str", "downloads", "download_layout"), ConfigSpec("download_worker_concurrency", "int", "downloads", "download_worker_concurrency", min=1, max=16), 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}