From a88254c841b0b4c913c8a9ed13d8823e7ed5ad71 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 5 Jul 2026 01:35:08 +0200 Subject: [PATCH 01/13] =?UTF-8?q?feat(plex):=20P0=20backend=20foundations?= =?UTF-8?q?=20=E2=80=94=20catalog=20model,=20config,=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/alembic/versions/0044_plex_catalog.py | 161 ++++++++++++++++++ backend/app/config.py | 24 +++ backend/app/main.py | 2 + backend/app/models.py | 146 ++++++++++++++++ backend/app/plex/__init__.py | 0 backend/app/plex/client.py | 113 ++++++++++++ backend/app/plex/paths.py | 51 ++++++ backend/app/routes/plex.py | 47 +++++ backend/app/sysconfig.py | 8 + 9 files changed, 552 insertions(+) create mode 100644 backend/alembic/versions/0044_plex_catalog.py create mode 100644 backend/app/plex/__init__.py create mode 100644 backend/app/plex/client.py create mode 100644 backend/app/plex/paths.py create mode 100644 backend/app/routes/plex.py 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} From 5684d6c406736e278fabd134c72b3881237fe822 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 5 Jul 2026 01:35:08 +0200 Subject: [PATCH 02/13] =?UTF-8?q?feat(plex):=20P0=20admin=20Config=20UI=20?= =?UTF-8?q?=E2=80=94=20connection=20test=20+=20library=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConfigPanel 'plex' group auto-renders; adds a Test-connection button + a library-picker (checked sections write plex_libraries, applied on Save) - api.testPlex + PlexTestResult/PlexSection types - i18n config namespace: plex group + 7 fields + test strings (en/hu/de); also fills the previously-missing 'downloads' group label --- frontend/src/components/ConfigPanel.tsx | 77 +++++++++++++++++++++++- frontend/src/i18n/locales/de/config.json | 44 +++++++++++++- frontend/src/i18n/locales/en/config.json | 44 +++++++++++++- frontend/src/i18n/locales/hu/config.json | 44 +++++++++++++- frontend/src/lib/api.ts | 15 +++++ 5 files changed, 215 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index fb110c7..2198268 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { RotateCcw, Send } from "lucide-react"; -import { api, type ConfigItem } from "../lib/api"; +import { Plug, RotateCcw, Send } from "lucide-react"; +import { api, type ConfigItem, type PlexTestResult } from "../lib/api"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import Tabs, { usePersistedTab } from "./Tabs"; @@ -14,7 +14,7 @@ import { DraftSaveBar } from "./ui/DraftSaveBar"; // applied together via one Save/Discard bar (consistent with the Settings page); nothing hits // the server until Save. Group order is fixed where known so logically related settings (e.g. // Email/SMTP) stay together. -const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch"]; +const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "plex"]; type SaveState = "idle" | "saving" | "saved" | "error"; export default function ConfigPanel() { @@ -93,6 +93,29 @@ export default function ConfigPanel() { onError: () => notify({ level: "error", message: t("config.testFailed") }), }); + // Plex "Test connection": verifies the server URL + token and returns the library sections, + // which drive the library-picker below (checked sections are written into the plex_libraries + // draft as a comma-separated list of section keys, applied on Save). + const [plexResult, setPlexResult] = useState(null); + const testPlex = useMutation({ + mutationFn: () => api.testPlex(), + onSuccess: (r) => { + setPlexResult(r); + notify({ level: "success", message: t("config.plexTestOk", { name: r.server_name }) }); + }, + onError: () => { + setPlexResult(null); + notify({ level: "error", message: t("config.plexTestFailed") }); + }, + }); + const plexLibs = (draft["plex_libraries"] ?? "").split(",").map((s) => s.trim()).filter(Boolean); + const togglePlexLib = (key: string) => { + const next = plexLibs.includes(key) + ? plexLibs.filter((k) => k !== key) + : [...plexLibs, key]; + setDraft((d) => ({ ...d, plex_libraries: next.join(",") })); + }; + if (q.isLoading || !data) { return
{t("config.loading")}
; } @@ -149,6 +172,54 @@ export default function ConfigPanel() { )} + + {activeGroup === "plex" && ( +
+ + + + {dirty &&

{t("config.plexTestDirty")}

} + {plexResult && ( +
+

+ {t("config.plexConnected", { + name: plexResult.server_name, + version: plexResult.version ?? "", + })} +

+

{t("config.plexPickLibraries")}

+
+ {plexResult.sections.map((s) => ( + + ))} +
+

{t("config.plexPickHint")}

+
+ )} +
+ )} )} diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json index e830ef2..03f2b38 100644 --- a/frontend/src/i18n/locales/de/config.json +++ b/frontend/src/i18n/locales/de/config.json @@ -10,7 +10,9 @@ "backfill": "Nachladen (Backfill)", "shorts": "Shorts-Prüfung", "batch": "Stapelgrößen", - "explore": "Kanal erkunden" + "explore": "Kanal erkunden", + "downloads": "Downloads", + "plex": "Plex" }, "fields": { "smtp_host": { @@ -100,6 +102,34 @@ "search_grace_days": { "label": "Suchergebnisse — Karenzzeit (Tage)", "hint": "Wie lange ein nicht behaltenes Live-Suchergebnis-Video (niemand hat es angesehen/gespeichert/abonniert) behalten wird, bevor die Entdeckungs-Aufräumaufgabe es entfernt." + }, + "plex_enabled": { + "label": "Plex aktivieren", + "hint": "Zeigt die Plex-Bibliothek als Feed-Quelle mit einem umfangreichen Player. Erfordert, dass der Plex-Server von diesem Host erreichbar ist und die Medien auf einem lokalen Mount liegen (siehe Pfad-Zuordnung)." + }, + "plex_server_url": { + "label": "Plex-Server-URL", + "hint": "Von diesem Host erreichbare Basis-URL, z. B. http://192.168.1.112:32400." + }, + "plex_server_token": { + "label": "Plex-Server-Token", + "hint": "Der Admin-X-Plex-Token des Servers. Nur serverseitig genutzt (Metadaten + Bilder); wird nie an den Browser gesendet. Verschlüsselt gespeichert; nur schreibbar." + }, + "plex_path_map": { + "label": "Medien-Pfad-Zuordnung", + "hint": "Ordnet die Plex-Dateipfade dem lokalen Mount dieses Hosts zu, damit Siftlode die physische Datei abspielen kann. Durch Zeilenumbruch/Komma getrennte plexPrefix=localPrefix-Paare, z. B. /data=/plex-media. Leer = auf beiden Seiten identisch." + }, + "plex_libraries": { + "label": "Angezeigte Bibliotheken", + "hint": "Durch Kommas getrennte Plex-Sektionsschlüssel. Leer = alle Sektionen. Nutze nach dem Verbindungstest die Bibliotheksauswahl unten." + }, + "plex_sync_interval_min": { + "label": "Sync-Intervall (Minuten)", + "hint": "Wie oft die Katalog-Sync-Aufgabe die Plex-Metadaten spiegelt." + }, + "plex_max_transcodes": { + "label": "Max. gleichzeitige Transcodes", + "hint": "Obergrenze für gleichzeitige CPU-Transcodes inkompatibler Dateien (späterer Fallback). Direkt abspielbare Dateien sind nicht begrenzt." } }, "save": "Speichern", @@ -121,5 +151,15 @@ "testEmail": "Test-E-Mail senden", "testEmailHint": "Sende eine Test-E-Mail an deine eigene Adresse mit den obigen Einstellungen, um zu prüfen, ob sie funktionieren.", "testSent": "Test-E-Mail gesendet an {{to}}", - "testFailed": "Test-E-Mail fehlgeschlagen — prüfe die Einstellungen" + "testFailed": "Test-E-Mail fehlgeschlagen — prüfe die Einstellungen", + "plexTest": "Verbindung testen", + "plexTestHint": "Prüft die Plex-Server-URL + den Token und lädt die Bibliotheksliste. Speichere zuerst deine Änderungen.", + "plexTestDirty": "Speichere deine Änderungen vor dem Verbindungstest.", + "plexTestOk": "Verbunden mit {{name}}", + "plexTestFailed": "Plex-Verbindung fehlgeschlagen — prüfe URL und Token", + "plexConnected": "Verbunden mit {{name}} {{version}}", + "plexPickLibraries": "Anzuzeigende Bibliotheken:", + "plexPickHint": "Keine auszuwählen entspricht dem Anzeigen aller Bibliotheken. Nicht vergessen zu speichern.", + "plexMovies": "Filme", + "plexShows": "Serien" } diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json index df49ea3..587594e 100644 --- a/frontend/src/i18n/locales/en/config.json +++ b/frontend/src/i18n/locales/en/config.json @@ -10,7 +10,9 @@ "backfill": "Backfill", "shorts": "Shorts probe", "batch": "Batch sizes", - "explore": "Channel explore" + "explore": "Channel explore", + "downloads": "Downloads", + "plex": "Plex" }, "fields": { "smtp_host": { @@ -100,6 +102,34 @@ "search_grace_days": { "label": "Search results — grace period (days)", "hint": "How long an un-kept live-search result video (nobody watched / saved / subscribed to) is kept before the discovery-cleanup job removes it." + }, + "plex_enabled": { + "label": "Enable Plex", + "hint": "Expose the Plex library as a feed source with a rich player. Requires the Plex server to be reachable from this host and its media on a local mount (see the path map)." + }, + "plex_server_url": { + "label": "Plex server URL", + "hint": "Base URL reachable from this host, e.g. http://192.168.1.112:32400." + }, + "plex_server_token": { + "label": "Plex server token", + "hint": "The server admin X-Plex-Token. Used server-side only (metadata + images); never sent to the browser. Stored encrypted; write-only." + }, + "plex_path_map": { + "label": "Media path map", + "hint": "Maps Plex's on-disk paths to this host's local mount so Siftlode can play the physical file. Newline/comma-separated plexPrefix=localPrefix pairs, e.g. /data=/plex-media. Empty = identical on both sides." + }, + "plex_libraries": { + "label": "Exposed libraries", + "hint": "Comma-separated Plex section keys to expose. Empty = all sections. Use the library picker below after testing the connection." + }, + "plex_sync_interval_min": { + "label": "Sync interval (minutes)", + "hint": "How often the catalog sync job mirrors Plex metadata." + }, + "plex_max_transcodes": { + "label": "Max concurrent transcodes", + "hint": "Cap on simultaneous CPU transcodes for incompatible files (a later fallback). Direct-playable files aren't limited." } }, "save": "Save", @@ -121,5 +151,15 @@ "testEmail": "Send test email", "testEmailHint": "Send a test email to your own address using the settings above, to confirm they work.", "testSent": "Test email sent to {{to}}", - "testFailed": "Test email failed — check the settings" + "testFailed": "Test email failed — check the settings", + "plexTest": "Test connection", + "plexTestHint": "Verify the Plex server URL + token and load the library list. Save any edits first.", + "plexTestDirty": "Save your changes before testing the connection.", + "plexTestOk": "Connected to {{name}}", + "plexTestFailed": "Plex connection failed — check the URL and token", + "plexConnected": "Connected to {{name}} {{version}}", + "plexPickLibraries": "Libraries to expose:", + "plexPickHint": "Unchecking all is the same as exposing every library. Remember to Save.", + "plexMovies": "Movies", + "plexShows": "TV Shows" } diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index b56fdcf..bfd765f 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -10,7 +10,9 @@ "backfill": "Letöltés (backfill)", "shorts": "Shorts-vizsgálat", "batch": "Kötegméretek", - "explore": "Csatorna-felfedezés" + "explore": "Csatorna-felfedezés", + "downloads": "Letöltések", + "plex": "Plex" }, "fields": { "smtp_host": { @@ -100,6 +102,34 @@ "search_grace_days": { "label": "Keresési találatok — türelmi idő (nap)", "hint": "Meddig marad meg egy meg nem tartott YouTube-keresési találat (amit senki nem nézett meg / mentett el / iratkozott fel rá), mielőtt a felfedezés-takarító feladat törli." + }, + "plex_enabled": { + "label": "Plex engedélyezése", + "hint": "A Plex-könyvtár megjelenítése forrásként, gazdag lejátszóval. Ehhez a Plex-szervernek elérhetőnek kell lennie erről a gépről, a média pedig helyi mounton (lásd az útvonal-leképezést)." + }, + "plex_server_url": { + "label": "Plex-szerver URL", + "hint": "Erről a gépről elérhető alap-URL, pl. http://192.168.1.112:32400." + }, + "plex_server_token": { + "label": "Plex-szerver token", + "hint": "A szerver admin X-Plex-Tokenje. Csak szerveroldalon használjuk (metaadat + képek); soha nem kerül a böngészőbe. Titkosítva tárolva; csak írható." + }, + "plex_path_map": { + "label": "Média-útvonal leképezés", + "hint": "A Plex lemezes útvonalait képezi le erre a gépre, hogy a Siftlode lejátszhassa a fizikai fájlt. Új sorral/vesszővel elválasztott plexPrefix=localPrefix párok, pl. /data=/plex-media. Üres = mindkét oldalon azonos." + }, + "plex_libraries": { + "label": "Megjelenített könyvtárak", + "hint": "Vesszővel elválasztott Plex-szekciókulcsok. Üres = minden szekció. Kapcsolat-teszt után használd az alábbi könyvtár-választót." + }, + "plex_sync_interval_min": { + "label": "Szinkron gyakorisága (perc)", + "hint": "Milyen gyakran tükrözi a katalógus-szinkron a Plex metaadatait." + }, + "plex_max_transcodes": { + "label": "Max párhuzamos transcode", + "hint": "A nem kompatibilis fájlok egyidejű CPU-transcode-jainak felső korlátja (későbbi tartalék). A direktben játszható fájlokra nincs korlát." } }, "save": "Mentés", @@ -121,5 +151,15 @@ "testEmail": "Teszt-email küldése", "testEmailHint": "Teszt-email küldése a saját címedre a fenti beállításokkal, hogy ellenőrizd, működnek-e.", "testSent": "Teszt-email elküldve ide: {{to}}", - "testFailed": "A teszt-email sikertelen — ellenőrizd a beállításokat" + "testFailed": "A teszt-email sikertelen — ellenőrizd a beállításokat", + "plexTest": "Kapcsolat tesztelése", + "plexTestHint": "Ellenőrzi a Plex-szerver URL-t és tokent, és betölti a könyvtárlistát. Előbb mentsd a módosításokat.", + "plexTestDirty": "Mentsd a változásokat a kapcsolat tesztelése előtt.", + "plexTestOk": "Kapcsolódva: {{name}}", + "plexTestFailed": "A Plex-kapcsolat sikertelen — ellenőrizd az URL-t és a tokent", + "plexConnected": "Kapcsolódva: {{name}} {{version}}", + "plexPickLibraries": "Megjelenítendő könyvtárak:", + "plexPickHint": "Ha egyiket sem jelölöd be, az az összes könyvtár megjelenítésével egyenértékű. Ne feledj menteni.", + "plexMovies": "Filmek", + "plexShows": "Sorozatok" } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 24f2246..5f92a79 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -609,6 +609,20 @@ export interface SystemConfigData { secrets_manageable: boolean; } +// Admin: result of testing the Plex server connection (also drives the library-picker). +export interface PlexSection { + key: string; + title: string; + type: string; // "movie" | "show" + count?: number; +} +export interface PlexTestResult { + ok: boolean; + server_name: string; + version?: string; + sections: PlexSection[]; +} + // Admin Users & roles page. export interface AdminUserRow { id: number; @@ -921,6 +935,7 @@ export const api = { req(`/api/admin/config/${key}`, { method: "DELETE" }), testEmail: (): Promise<{ sent: boolean; to: string }> => req("/api/admin/config/test-email", { method: "POST" }), + testPlex: (): Promise => req("/api/plex/test", { method: "POST" }), // --- admin: users & roles --- adminUsers: (): Promise => req("/api/admin/users"), setUserRole: (id: number, role: "user" | "admin"): Promise => From c764d3f1adf3b2ac1ce5f4a830a82d54f613099e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 5 Jul 2026 01:42:56 +0200 Subject: [PATCH 03/13] i18n(config): friendly labels for the download_* config fields (en/hu/de) The Downloads config group was added to the registry without field translations, so it showed raw keys. Adds label+hint for all 9 download_* fields, matching the Plex group's treatment. --- frontend/src/i18n/locales/de/config.json | 36 ++++++++++++++++++++++++ frontend/src/i18n/locales/en/config.json | 36 ++++++++++++++++++++++++ frontend/src/i18n/locales/hu/config.json | 36 ++++++++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json index 03f2b38..48a9bc0 100644 --- a/frontend/src/i18n/locales/de/config.json +++ b/frontend/src/i18n/locales/de/config.json @@ -103,6 +103,42 @@ "label": "Suchergebnisse — Karenzzeit (Tage)", "hint": "Wie lange ein nicht behaltenes Live-Suchergebnis-Video (niemand hat es angesehen/gespeichert/abonniert) behalten wird, bevor die Entdeckungs-Aufräumaufgabe es entfernt." }, + "download_total_max_bytes": { + "label": "Gesamt-Cache-Limit (Bytes)", + "hint": "Maximale Gesamtgröße über die Downloads aller Nutzer. 0 = unbegrenzt (die Festplatte ist die Grenze); bei Überschreitung entfernt die GC die am längsten ungenutzten Dateien." + }, + "download_default_max_bytes": { + "label": "Standard-Kontingent pro Nutzer (Bytes)", + "hint": "Wie viel Speicher ein Nutzer belegen darf, bevor neue Downloads blockiert werden. Eine Überschreibung pro Nutzer hat Vorrang. Standard 5 GiB." + }, + "download_default_max_concurrent": { + "label": "Gleichzeitige Downloads / Nutzer", + "hint": "Wie viele Downloads ein Nutzer gleichzeitig ausführen darf." + }, + "download_default_max_jobs": { + "label": "Job-Limit / Nutzer", + "hint": "Maximale Anzahl an Downloads, die ein Nutzer in seiner Bibliothek behalten darf." + }, + "download_retention_days": { + "label": "Aufbewahrung (Tage)", + "hint": "Ein Download läuft so viele Tage nach dem letzten Zugriff ab; die Aufräumaufgabe entfernt ihn dann (sobald nichts mehr darauf verweist) nach der Karenzzeit." + }, + "download_gc_grace_days": { + "label": "Aufräum-Karenzzeit (Tage)", + "hint": "Zusätzliche Tage, die ein abgelaufener Download vor dem Löschen behalten wird." + }, + "download_layout": { + "label": "Ablage-Layout", + "hint": "„plex“ = ein Kanal/Staffel JJJJ/…-Baum mit .nfo + Poster (in Plex indexierbar); „flat“ = ein einzelner Ordner." + }, + "download_worker_concurrency": { + "label": "Worker-Parallelität", + "hint": "Wie viele Downloads der Worker parallel verarbeitet." + }, + "sponsorblock_default": { + "label": "SponsorBlock standardmäßig", + "hint": "SponsorBlock (Sponsor-Segmente überspringen) für neu erstellte benutzerdefinierte Download-Profile aktivieren." + }, "plex_enabled": { "label": "Plex aktivieren", "hint": "Zeigt die Plex-Bibliothek als Feed-Quelle mit einem umfangreichen Player. Erfordert, dass der Plex-Server von diesem Host erreichbar ist und die Medien auf einem lokalen Mount liegen (siehe Pfad-Zuordnung)." diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json index 587594e..3a382f0 100644 --- a/frontend/src/i18n/locales/en/config.json +++ b/frontend/src/i18n/locales/en/config.json @@ -103,6 +103,42 @@ "label": "Search results — grace period (days)", "hint": "How long an un-kept live-search result video (nobody watched / saved / subscribed to) is kept before the discovery-cleanup job removes it." }, + "download_total_max_bytes": { + "label": "Total cache cap (bytes)", + "hint": "Max total size across all users' downloads. 0 = unlimited (disk is the limit); when exceeded, the GC evicts the least-recently-used files." + }, + "download_default_max_bytes": { + "label": "Default per-user quota (bytes)", + "hint": "How much storage a user may hold before new downloads are blocked. A per-user override takes precedence. Default 5 GiB." + }, + "download_default_max_concurrent": { + "label": "Default concurrent downloads / user", + "hint": "How many downloads one user may run at the same time." + }, + "download_default_max_jobs": { + "label": "Default job limit / user", + "hint": "Max number of downloads a user may keep in their library." + }, + "download_retention_days": { + "label": "Retention (days)", + "hint": "A download expires this many days after its last access; the cleanup job then removes it (once nothing references it) after the grace window." + }, + "download_gc_grace_days": { + "label": "Cleanup grace (days)", + "hint": "Extra days an expired download is kept before it's deleted." + }, + "download_layout": { + "label": "On-disk layout", + "hint": "\"plex\" = a Channel/Season YYYY/… tree with .nfo + poster (Plex-indexable); \"flat\" = a single folder." + }, + "download_worker_concurrency": { + "label": "Worker concurrency", + "hint": "How many downloads the worker processes in parallel." + }, + "sponsorblock_default": { + "label": "SponsorBlock by default", + "hint": "Enable SponsorBlock (skip sponsor segments) for newly created custom download profiles." + }, "plex_enabled": { "label": "Enable Plex", "hint": "Expose the Plex library as a feed source with a rich player. Requires the Plex server to be reachable from this host and its media on a local mount (see the path map)." diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index bfd765f..64eb60f 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -103,6 +103,42 @@ "label": "Keresési találatok — türelmi idő (nap)", "hint": "Meddig marad meg egy meg nem tartott YouTube-keresési találat (amit senki nem nézett meg / mentett el / iratkozott fel rá), mielőtt a felfedezés-takarító feladat törli." }, + "download_total_max_bytes": { + "label": "Teljes gyorsítótár-korlát (bájt)", + "hint": "Az összes felhasználó letöltéseinek maximális együttes mérete. 0 = korlátlan (a lemez a határ); túllépéskor a takarító a legrégebben használt fájlokat üríti." + }, + "download_default_max_bytes": { + "label": "Alapértelmezett felhasználói kvóta (bájt)", + "hint": "Mennyi tárhelyet tarthat egy felhasználó, mielőtt az új letöltések tiltásra kerülnek. A felhasználónkénti felülírás elsőbbséget élvez. Alapérték 5 GiB." + }, + "download_default_max_concurrent": { + "label": "Alap. párhuzamos letöltés / felhasználó", + "hint": "Hány letöltést futtathat egy felhasználó egyszerre." + }, + "download_default_max_jobs": { + "label": "Alap. feladatkorlát / felhasználó", + "hint": "Legfeljebb hány letöltést tarthat meg egy felhasználó a könyvtárában." + }, + "download_retention_days": { + "label": "Megőrzés (nap)", + "hint": "Egy letöltés az utolsó hozzáféréstől számítva ennyi nap múlva jár le; a takarító feladat ezután (ha semmi nem hivatkozik rá) a türelmi idő letelte után törli." + }, + "download_gc_grace_days": { + "label": "Takarítási türelmi idő (nap)", + "hint": "Hány további napig marad meg egy lejárt letöltés a törlés előtt." + }, + "download_layout": { + "label": "Lemezes elrendezés", + "hint": "„plex” = Csatorna/Évad ÉÉÉÉ/… fastruktúra .nfo + poszter fájlokkal (Plexben indexelhető); „flat” = egyetlen mappa." + }, + "download_worker_concurrency": { + "label": "Worker párhuzamosság", + "hint": "Hány letöltést dolgoz fel a worker párhuzamosan." + }, + "sponsorblock_default": { + "label": "SponsorBlock alapból", + "hint": "SponsorBlock (szponzorszakaszok átugrása) bekapcsolása az újonnan létrehozott egyéni letöltési profilokhoz." + }, "plex_enabled": { "label": "Plex engedélyezése", "hint": "A Plex-könyvtár megjelenítése forrásként, gazdag lejátszóval. Ehhez a Plex-szervernek elérhetőnek kell lennie erről a gépről, a média pedig helyi mounton (lásd az útvonal-leképezést)." From 9afb1a97881c66764e2b50194070f73134a737da Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 5 Jul 2026 02:07:05 +0200 Subject: [PATCH 04/13] chore(localdev): optional read-only Plex media CIFS mount for dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the dev container play the physical media file locally (this box isn't the Plex host). Host/share/credentials come from .env (gitignored) — no site-specific values in the tracked compose. Mounts //${PLEX_SMB_HOST}/${PLEX_SMB_SHARE} at /plex-media read-only in api + worker; harmless when the vars are unset. Prod (LXC on the Plex host) uses a plain host bind-mount instead. --- docker-compose.localdev.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docker-compose.localdev.yml b/docker-compose.localdev.yml index 48b06ff..0822086 100644 --- a/docker-compose.localdev.yml +++ b/docker-compose.localdev.yml @@ -58,6 +58,8 @@ services: # Downloaded media lives on the host so you can inspect the generated Plex tree. # Gitignored. The worker writes here; the API reads it to serve local downloads. - ./downloads:/downloads + # Plex media, read-only (see the plex_media volume note below). Maps to plex_path_map. + - plex_media:/plex-media:ro restart: unless-stopped # Dedicated download worker: same image, runs the yt-dlp job loop instead of the API. @@ -87,6 +89,7 @@ services: - apparmor:unconfined volumes: - ./downloads:/downloads + - plex_media:/plex-media:ro restart: unless-stopped # PO-token provider: mints YouTube Proof-of-Origin tokens on demand so the worker bypasses @@ -101,3 +104,16 @@ services: volumes: pgdata: + # OPTIONAL (Plex integration dev testing): the Plex media, mounted READ-ONLY from the SMB share + # so the container can play the physical file locally (this dev box isn't the Plex host). Uses a + # dedicated read-only samba user via PLEX_SMB_USER/PLEX_SMB_PASS in .env. Only referenced when the + # Plex module is enabled; harmless otherwise (Docker mounts it lazily on first container use). On + # prod (LXC on the Plex host) this is a plain host bind-mount instead — no SMB. + plex_media: + driver: local + driver_opts: + type: cifs + # Host/share + credentials come from .env (gitignored) so no site-specific values live in + # the tracked compose: PLEX_SMB_HOST, PLEX_SMB_SHARE, PLEX_SMB_USER, PLEX_SMB_PASS. + device: "//${PLEX_SMB_HOST:-localhost}/${PLEX_SMB_SHARE:-data}" + o: "username=${PLEX_SMB_USER:-},password=${PLEX_SMB_PASS:-},ro,vers=3.0,uid=1000,gid=1000,file_mode=0444,dir_mode=0555" From 63c6e782c8f65b5c4eda2ab4ab6e2d0f3e0eef77 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 5 Jul 2026 02:20:23 +0200 Subject: [PATCH 05/13] =?UTF-8?q?feat(plex):=20P1=20backend=20=E2=80=94=20?= =?UTF-8?q?catalog=20sync=20+=20browse/search/show/image=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/plex/sync.py: full reconcile of enabled movie/show sections into plex_* (upsert by rating_key). Reuses Plex's own codec info to classify browser playability (direct/remux/transcode) — no ffprobe. Movies + episodes become playable leaves; shows/seasons mirrored for the drill-down. - routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image (proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed. - scheduler: plex_sync job (interval settings.plex_sync_interval_min). - Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/ 14768 episodes synced in 13s; FTS search + drill-down work. Playability: ~1% direct, 89% remux, 6% transcode (informs P2/P3). --- backend/app/plex/sync.py | 236 ++++++++++++++++++++++++++++++++++ backend/app/routes/plex.py | 252 ++++++++++++++++++++++++++++++++++--- backend/app/scheduler.py | 9 ++ backend/app/sysconfig.py | 2 +- 4 files changed, 483 insertions(+), 16 deletions(-) create mode 100644 backend/app/plex/sync.py diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py new file mode 100644 index 0000000..ea1b1c1 --- /dev/null +++ b/backend/app/plex/sync.py @@ -0,0 +1,236 @@ +"""Mirror a locally-reachable Plex library into Siftlode's own catalog (plex_* tables). + +Playback is from the local physical file, so this only mirrors METADATA + the physical-media facts +(codecs/container/path) needed to browse, search, and later play. It reuses Plex's own codec info +(no ffprobe needed) to classify each item's browser playability. Movies + episodes become playable +LEAF rows (plex_items); shows/seasons are mirrored for the drill-down + episode ordering. + +The sync is a full reconcile of the enabled sections (upsert by rating_key). Pruning of items that +disappeared from Plex is intentionally left for later (a follow-up can diff rating_keys per library). +Cast + intro/credit markers are fetched lazily on the item-detail/player path (P2), not here. +""" +import logging +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from app import sysconfig +from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow +from app.plex.client import PlexClient, PlexError, PlexNotConfigured + +log = logging.getLogger("siftlode.plex") + +# Codecs/containers a browser can play in a native