Merge: promote dev to prod
This commit is contained in:
commit
a5dc24fa2d
38 changed files with 3270 additions and 22 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.22.3
|
||||
0.23.0
|
||||
|
|
|
|||
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,35 @@ 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.100: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
|
||||
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
|
||||
# dir under download_root. On localdev the download_root is a slow Windows bind-mount, so point
|
||||
# this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's download_root is fast
|
||||
# local disk, so the default is fine there.
|
||||
plex_hls_dir: str = ""
|
||||
|
||||
@property
|
||||
def allowed_email_set(self) -> set[str]:
|
||||
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
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
|
||||
178
backend/app/plex/stream.py
Normal file
178
backend/app/plex/stream.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""On-the-fly HLS remux for Plex playback (seek-restart session model).
|
||||
|
||||
Playback is from the LOCAL physical file. Browser-compatible files (playable="direct") are served
|
||||
raw with HTTP range requests. Everything else that is h264 video (playable="remux") is remuxed on
|
||||
the fly to HLS with **video stream-copy** (cheap, I/O-bound — no video re-encode) and audio to AAC
|
||||
when needed. HEVC/VP9 (playable="transcode") needs a full re-encode and is deferred to P3.
|
||||
|
||||
Seek model (Jellyfin-style): one ffmpeg session per item, started at a given offset via `-ss`
|
||||
(fast keyframe seek). A seek beyond the generated region restarts the session at the new offset,
|
||||
so seeking is responsive even on long movies without pre-generating the whole file. ffmpeg's own
|
||||
HLS muxer does the segmentation (the only reliable way to cut a stream-copy at keyframes).
|
||||
|
||||
Sessions live in this (single) API process; a periodic reaper kills idle ones and frees the temp
|
||||
segments. The remux is CPU-light (video copy + a tiny audio transcode), so it runs fine on the
|
||||
CPU-only prod host; only P3's full transcode is CPU-heavy.
|
||||
"""
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models import PlexItem
|
||||
from app.plex import paths
|
||||
|
||||
log = logging.getLogger("siftlode.plex")
|
||||
|
||||
_HLS_ROOT = Path(settings.plex_hls_dir) if settings.plex_hls_dir else Path(settings.download_root) / ".plex-hls"
|
||||
_SEG_SECONDS = 6
|
||||
_MAX_SESSIONS = 4 # concurrent remux sessions (safety valve for the CPU-only host)
|
||||
_SESSION_IDLE_S = 600 # reap a session with no access for this long
|
||||
|
||||
_lock = threading.Lock()
|
||||
_sessions: dict[str, "HlsSession"] = {}
|
||||
|
||||
|
||||
class HlsSession:
|
||||
def __init__(self, key: str, directory: Path, proc: subprocess.Popen, start_s: float, entry: str):
|
||||
self.key = key
|
||||
self.dir = directory
|
||||
self.proc = proc
|
||||
self.start_s = start_s
|
||||
self.entry = entry # the playlist filename hls.js should load (master.m3u8 with subs, else index.m3u8)
|
||||
self.last_access = time.time()
|
||||
|
||||
|
||||
def _ffmpeg_cmd(
|
||||
src: Path, start_s: float, out_dir: Path, audio_ord: int | None, sub_ord: int | None
|
||||
) -> list[str]:
|
||||
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
|
||||
if start_s > 0:
|
||||
args += ["-ss", f"{start_s:.3f}"] # fast keyframe seek before -i
|
||||
args += ["-i", str(src)]
|
||||
# Video always stream-copied. The selected audio track (default = first) is transcoded to AAC.
|
||||
# A selected subtitle track is muxed in as a WebVTT rendition (ffmpeg aligns it to this session's
|
||||
# timeline, so it stays in sync even after a seek-restart); no selection → drop subtitles.
|
||||
args += ["-map", "0:v:0", "-map", f"0:a:{audio_ord if audio_ord is not None else 0}?"]
|
||||
if sub_ord is not None:
|
||||
args += ["-map", f"0:s:{sub_ord}?", "-c:s", "webvtt"]
|
||||
else:
|
||||
args += ["-sn"]
|
||||
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||
if sub_ord is not None:
|
||||
args += ["-c:s", "webvtt"]
|
||||
args += [
|
||||
"-f", "hls",
|
||||
"-hls_time", str(_SEG_SECONDS),
|
||||
"-hls_list_size", "0",
|
||||
# EVENT (not VOD): ffmpeg writes/appends the playlist AS segments complete, so playback can
|
||||
# start immediately from this session's offset. VOD only writes the playlist at the end. The
|
||||
# full seekbar comes from our known duration + the seek-restart model, not from the playlist.
|
||||
"-hls_playlist_type", "event",
|
||||
"-hls_segment_type", "mpegts",
|
||||
"-hls_flags", "independent_segments+temp_file",
|
||||
]
|
||||
if sub_ord is not None:
|
||||
# A subtitle rendition needs a MASTER playlist tying the video variant to the WebVTT group
|
||||
# (ffmpeg generates the subtitle segments per-session, so they stay in sync after a seek).
|
||||
args += ["-master_pl_name", "master.m3u8", "-var_stream_map", "v:0,a:0,s:0,sgroup:subs"]
|
||||
args += ["-hls_segment_filename", str(out_dir / "seg_%d.ts"), str(out_dir / "index.m3u8")]
|
||||
return args
|
||||
|
||||
|
||||
def _kill(s: HlsSession) -> None:
|
||||
try:
|
||||
s.proc.terminate()
|
||||
try:
|
||||
s.proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
s.proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
shutil.rmtree(s.dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _enforce_cap() -> None:
|
||||
# Caller holds _lock. Drop the least-recently-accessed sessions over the cap.
|
||||
if len(_sessions) <= _MAX_SESSIONS:
|
||||
return
|
||||
for key, s in sorted(_sessions.items(), key=lambda kv: kv[1].last_access)[: len(_sessions) - _MAX_SESSIONS]:
|
||||
_kill(s)
|
||||
_sessions.pop(key, None)
|
||||
|
||||
|
||||
def start_session(
|
||||
db: DbSession,
|
||||
item: PlexItem,
|
||||
start_s: float,
|
||||
audio_ord: int | None = None,
|
||||
sub_ord: int | None = None,
|
||||
) -> HlsSession | None:
|
||||
"""(Re)start the HLS remux for an item at the given offset, with an optional selected audio /
|
||||
subtitle track (ordinals among their stream type). Returns None if the local file can't be read."""
|
||||
src = paths.local_media_path(db, item.file_path)
|
||||
if src is None:
|
||||
return None
|
||||
key = item.rating_key
|
||||
start_s = max(0.0, float(start_s))
|
||||
with _lock:
|
||||
old = _sessions.pop(key, None)
|
||||
if old is not None:
|
||||
_kill(old)
|
||||
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{audio_ord}_{sub_ord}"
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
proc = subprocess.Popen(
|
||||
_ffmpeg_cmd(src, start_s, directory, audio_ord, sub_ord),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
entry = "master.m3u8" if sub_ord is not None else "index.m3u8"
|
||||
s = HlsSession(key, directory, proc, start_s, entry)
|
||||
_sessions[key] = s
|
||||
_enforce_cap()
|
||||
log.info(
|
||||
"plex hls session start key=%s start=%.1f audio=%s sub=%s", key, start_s, audio_ord, sub_ord
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def current_session(key: str) -> HlsSession | None:
|
||||
with _lock:
|
||||
s = _sessions.get(key)
|
||||
if s is not None:
|
||||
s.last_access = time.time()
|
||||
return s
|
||||
|
||||
|
||||
def wait_for(path: Path, timeout: float = 20.0) -> bool:
|
||||
"""Wait until a session file (playlist / segment) exists and is non-empty. Segments are
|
||||
produced ~faster than realtime, so a segment just ahead of playback appears quickly; a segment
|
||||
far beyond the generated region won't (the frontend restarts the session at a seek instead)."""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
try:
|
||||
if path.exists() and path.stat().st_size > 0:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.15)
|
||||
return path.exists()
|
||||
|
||||
|
||||
def reap_idle() -> int:
|
||||
now = time.time()
|
||||
dropped = 0
|
||||
with _lock:
|
||||
for key, s in list(_sessions.items()):
|
||||
done = s.proc.poll() is not None
|
||||
if now - s.last_access > _SESSION_IDLE_S or (done and now - s.last_access > 30):
|
||||
_kill(s)
|
||||
_sessions.pop(key, None)
|
||||
dropped += 1
|
||||
return dropped
|
||||
236
backend/app/plex/sync.py
Normal file
236
backend/app/plex/sync.py
Normal file
|
|
@ -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 <video> without any transcoding.
|
||||
_BROWSER_VIDEO = {"h264"}
|
||||
_BROWSER_AUDIO = {"aac", "mp3", "opus", "vorbis"}
|
||||
_BROWSER_CONTAINER = {"mp4", "mov", "m4v"}
|
||||
|
||||
# Plex section item types.
|
||||
_TYPE_MOVIE = 1
|
||||
_TYPE_SHOW = 2
|
||||
_TYPE_SEASON = 3
|
||||
_TYPE_EPISODE = 4
|
||||
|
||||
_PAGE = 200
|
||||
|
||||
|
||||
def classify_playable(vcodec: str | None, acodec: str | None, container: str | None) -> str:
|
||||
"""How the browser can play this file: 'direct' (native <video>), 'remux' (copy video, fix
|
||||
container / transcode audio — cheap), or 'transcode' (re-encode video — expensive)."""
|
||||
v = (vcodec or "").lower()
|
||||
a = (acodec or "").lower()
|
||||
c = (container or "").lower()
|
||||
if not v or v not in _BROWSER_VIDEO:
|
||||
return "transcode"
|
||||
if a and a not in _BROWSER_AUDIO:
|
||||
return "remux"
|
||||
if c and c not in _BROWSER_CONTAINER:
|
||||
return "remux"
|
||||
return "direct"
|
||||
|
||||
|
||||
def _epoch(v) -> datetime | None:
|
||||
try:
|
||||
return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None
|
||||
except (TypeError, ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _media_facts(meta: dict) -> dict:
|
||||
media = meta.get("Media") or []
|
||||
if not media:
|
||||
return {}
|
||||
m = media[0]
|
||||
part = (m.get("Part") or [{}])[0]
|
||||
return {
|
||||
"container": m.get("container") or part.get("container"),
|
||||
"codec_video": m.get("videoCodec"),
|
||||
"codec_audio": m.get("audioCodec"),
|
||||
"size_bytes": part.get("size"),
|
||||
"file_path": part.get("file"),
|
||||
"part_key": part.get("key"),
|
||||
}
|
||||
|
||||
|
||||
def _dur_s(meta: dict) -> int | None:
|
||||
d = meta.get("duration")
|
||||
return int(d / 1000) if d else None
|
||||
|
||||
|
||||
def _enabled_section_keys(db: Session) -> set[str] | None:
|
||||
"""The configured allowlist of section keys, or None to mean 'all movie/show sections'."""
|
||||
raw = sysconfig.get_str(db, "plex_libraries")
|
||||
keys = {k.strip() for k in raw.split(",") if k.strip()}
|
||||
return keys or None
|
||||
|
||||
|
||||
def sync(db: Session) -> dict:
|
||||
"""Full reconcile of the enabled movie/show sections. Returns a small stats dict."""
|
||||
if not sysconfig.get_bool(db, "plex_enabled"):
|
||||
return {"skipped": "disabled"}
|
||||
stats = {"libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0}
|
||||
wanted = _enabled_section_keys(db)
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
for s in plex.sections():
|
||||
if s.get("type") not in ("movie", "show"):
|
||||
continue
|
||||
key = str(s.get("key"))
|
||||
if wanted is not None and key not in wanted:
|
||||
continue
|
||||
lib = _upsert_library(db, key, s.get("title") or key, s.get("type"))
|
||||
stats["libraries"] += 1
|
||||
if s.get("type") == "movie":
|
||||
_sync_movies(db, plex, lib, stats)
|
||||
else:
|
||||
_sync_shows(db, plex, lib, stats)
|
||||
db.commit()
|
||||
except PlexNotConfigured as e:
|
||||
return {"skipped": str(e)}
|
||||
except PlexError as e:
|
||||
db.rollback()
|
||||
log.warning("Plex sync failed: %s", e)
|
||||
return {"error": str(e)}
|
||||
log.info("Plex sync done: %s", stats)
|
||||
return stats
|
||||
|
||||
|
||||
def _upsert_library(db: Session, key: str, title: str, kind: str) -> PlexLibrary:
|
||||
lib = db.query(PlexLibrary).filter_by(plex_key=key).first()
|
||||
if lib is None:
|
||||
lib = PlexLibrary(plex_key=key)
|
||||
db.add(lib)
|
||||
lib.title = title
|
||||
lib.kind = kind
|
||||
lib.synced_at = datetime.now(timezone.utc)
|
||||
db.flush()
|
||||
return lib
|
||||
|
||||
|
||||
def _paginate(plex: PlexClient, key: str, item_type: int):
|
||||
start = 0
|
||||
while True:
|
||||
items, total = plex.section_items(key, item_type, start, _PAGE)
|
||||
if not items:
|
||||
break
|
||||
for it in items:
|
||||
yield it
|
||||
start += len(items)
|
||||
if total and start >= total:
|
||||
break
|
||||
if len(items) < _PAGE:
|
||||
break
|
||||
|
||||
|
||||
def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
||||
row.library_id = lib_id
|
||||
row.kind = kind
|
||||
row.title = (meta.get("title") or "").strip() or "Untitled"
|
||||
row.summary = meta.get("summary")
|
||||
row.year = meta.get("year")
|
||||
row.duration_s = _dur_s(meta)
|
||||
row.thumb_key = meta.get("thumb")
|
||||
row.art_key = meta.get("art") or meta.get("grandparentArt")
|
||||
row.added_at = _epoch(meta.get("addedAt"))
|
||||
mf = _media_facts(meta)
|
||||
row.file_path = mf.get("file_path")
|
||||
row.part_key = mf.get("part_key")
|
||||
row.container = mf.get("container")
|
||||
row.codec_video = mf.get("codec_video")
|
||||
row.codec_audio = mf.get("codec_audio")
|
||||
row.size_bytes = mf.get("size_bytes")
|
||||
row.playable = classify_playable(mf.get("codec_video"), mf.get("codec_audio"), mf.get("container"))
|
||||
|
||||
|
||||
def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
|
||||
existing = {r.rating_key: r for r in db.query(PlexItem).filter_by(library_id=lib.id, kind="movie")}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_MOVIE):
|
||||
rk = str(meta.get("ratingKey") or "")
|
||||
if not rk:
|
||||
continue
|
||||
row = existing.get(rk) or PlexItem(rating_key=rk)
|
||||
if row.id is None:
|
||||
db.add(row)
|
||||
_apply_item(row, lib.id, "movie", meta)
|
||||
stats["movies"] += 1
|
||||
db.flush()
|
||||
|
||||
|
||||
def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
|
||||
# 1) shows
|
||||
shows = {r.rating_key: r for r in db.query(PlexShow).filter_by(library_id=lib.id)}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_SHOW):
|
||||
rk = str(meta.get("ratingKey") or "")
|
||||
if not rk:
|
||||
continue
|
||||
sh = shows.get(rk) or PlexShow(rating_key=rk)
|
||||
if sh.id is None:
|
||||
db.add(sh)
|
||||
shows[rk] = sh
|
||||
sh.library_id = lib.id
|
||||
sh.title = (meta.get("title") or "").strip() or "Untitled"
|
||||
sh.summary = meta.get("summary")
|
||||
sh.year = meta.get("year")
|
||||
sh.thumb_key = meta.get("thumb")
|
||||
sh.art_key = meta.get("art")
|
||||
sh.child_count = meta.get("childCount")
|
||||
sh.added_at = _epoch(meta.get("addedAt"))
|
||||
stats["shows"] += 1
|
||||
db.flush()
|
||||
show_id = {rk: sh.id for rk, sh in shows.items()}
|
||||
|
||||
# 2) seasons
|
||||
seasons = {r.rating_key: r for r in db.query(PlexSeason).join(PlexShow).filter(PlexShow.library_id == lib.id)}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_SEASON):
|
||||
rk = str(meta.get("ratingKey") or "")
|
||||
sid = show_id.get(str(meta.get("parentRatingKey") or ""))
|
||||
if not rk or sid is None:
|
||||
continue
|
||||
se = seasons.get(rk) or PlexSeason(rating_key=rk)
|
||||
if se.id is None:
|
||||
db.add(se)
|
||||
seasons[rk] = se
|
||||
se.show_id = sid
|
||||
se.title = meta.get("title")
|
||||
se.season_number = meta.get("index")
|
||||
se.thumb_key = meta.get("thumb")
|
||||
stats["seasons"] += 1
|
||||
db.flush()
|
||||
season_id = {rk: se.id for rk, se in seasons.items()}
|
||||
|
||||
# 3) episodes (playable leaves)
|
||||
episodes = {r.rating_key: r for r in db.query(PlexItem).filter_by(library_id=lib.id, kind="episode")}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_EPISODE):
|
||||
rk = str(meta.get("ratingKey") or "")
|
||||
if not rk:
|
||||
continue
|
||||
row = episodes.get(rk) or PlexItem(rating_key=rk)
|
||||
if row.id is None:
|
||||
db.add(row)
|
||||
_apply_item(row, lib.id, "episode", meta)
|
||||
row.show_id = show_id.get(str(meta.get("grandparentRatingKey") or ""))
|
||||
row.season_id = season_id.get(str(meta.get("parentRatingKey") or ""))
|
||||
row.season_number = meta.get("parentIndex")
|
||||
row.episode_number = meta.get("index")
|
||||
stats["episodes"] += 1
|
||||
db.flush()
|
||||
|
|
@ -12,6 +12,7 @@ from app.auth import (
|
|||
optional_current_user,
|
||||
purge_user,
|
||||
)
|
||||
from app import sysconfig
|
||||
from app.db import get_db
|
||||
from app.models import Invite, User
|
||||
|
||||
|
|
@ -98,6 +99,8 @@ def get_me(
|
|||
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
|
||||
# YouTube-access affordances and the onboarding nudge when it isn't configured).
|
||||
"google_enabled": google_enabled(db),
|
||||
# Whether the optional Plex module is enabled (UI shows the Plex feed source when so).
|
||||
"plex_enabled": sysconfig.get_bool(db, "plex_enabled"),
|
||||
"can_read": has_read_scope(user),
|
||||
"can_write": has_write_scope(user),
|
||||
"pending_invites": pending_invites,
|
||||
|
|
|
|||
555
backend/app/routes/plex.py
Normal file
555
backend/app/routes/plex.py
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
"""Plex integration API.
|
||||
|
||||
Read endpoints (libraries/browse/show/image) are available to ANY authenticated user (demo +
|
||||
pure-Siftlode included) — the module is an access layer to the Plex library WITHOUT requiring a
|
||||
plex.tv account, and playback is a local file. Admin endpoints test the connection and run the
|
||||
catalog sync.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app import sysconfig
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||
from app.plex import paths as plex_paths
|
||||
from app.plex import stream as plex_stream
|
||||
from app.plex import sync as plex_sync
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.routes.admin import admin_user
|
||||
from app.routes.feed import _to_tsquery_str
|
||||
|
||||
log = logging.getLogger("siftlode.plex")
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
_TS_CONFIG = "public.unaccent_simple"
|
||||
|
||||
|
||||
# --- Admin: connectivity + sync ---------------------------------------------------------------
|
||||
|
||||
|
||||
@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
|
||||
movie/show sections (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")
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
def trigger_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||
"""Admin: mirror the enabled Plex sections into the local catalog. Synchronous (can take a
|
||||
while on a large library); a background/scheduled sync also runs periodically."""
|
||||
return plex_sync.sync(db)
|
||||
|
||||
|
||||
# --- Read: libraries / browse / show / image --------------------------------------------------
|
||||
|
||||
|
||||
def _enabled() -> None:
|
||||
"""Guard read endpoints when the module is off (avoids leaking a stale mirror)."""
|
||||
# Read endpoints stay usable as long as a mirror exists; the toggle only gates sync + UI.
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/libraries")
|
||||
def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
"""The mirrored libraries the user can browse (drives the Plex scope selector)."""
|
||||
libs = db.query(PlexLibrary).order_by(PlexLibrary.kind.desc(), PlexLibrary.title).all()
|
||||
out = []
|
||||
for lib in libs:
|
||||
if lib.kind == "movie":
|
||||
count = db.query(func.count(PlexItem.id)).filter_by(library_id=lib.id, kind="movie").scalar()
|
||||
else:
|
||||
count = db.query(func.count(PlexShow.id)).filter_by(library_id=lib.id).scalar()
|
||||
out.append({"key": lib.plex_key, "title": lib.title, "kind": lib.kind, "count": count or 0})
|
||||
return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out}
|
||||
|
||||
|
||||
def _movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
||||
return {
|
||||
"id": it.rating_key,
|
||||
"type": "movie",
|
||||
"title": it.title,
|
||||
"year": it.year,
|
||||
"duration_seconds": it.duration_s,
|
||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||
"playable": it.playable,
|
||||
"status": st.status if st else "new",
|
||||
"position_seconds": st.position_seconds if st else 0,
|
||||
}
|
||||
|
||||
|
||||
def _show_card(sh: PlexShow) -> dict:
|
||||
return {
|
||||
"id": sh.rating_key,
|
||||
"type": "show",
|
||||
"title": sh.title,
|
||||
"year": sh.year,
|
||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||
"season_count": sh.child_count,
|
||||
}
|
||||
|
||||
|
||||
def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
||||
return {
|
||||
"id": e.rating_key,
|
||||
"type": "episode",
|
||||
"title": e.title,
|
||||
"summary": e.summary,
|
||||
"season_number": e.season_number,
|
||||
"episode_number": e.episode_number,
|
||||
"duration_seconds": e.duration_s,
|
||||
"thumb": f"/api/plex/image/{e.rating_key}",
|
||||
"playable": e.playable,
|
||||
"status": st.status if st else "new",
|
||||
"position_seconds": st.position_seconds if st else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
def browse(
|
||||
library: str,
|
||||
q: str | None = None,
|
||||
sort: str = "added",
|
||||
show: str = "all",
|
||||
offset: int = 0,
|
||||
limit: int = Query(default=40, ge=1, le=100),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""List a library's movies (leaves) or shows, with optional FTS search + sorting + a per-user
|
||||
watch-state filter (`show`: all|unwatched|in_progress|watched — movie libraries only). Movie
|
||||
libraries return playable movie cards; show libraries return show cards (drill in via /show)."""
|
||||
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
||||
if lib is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown Plex library")
|
||||
offset = max(0, offset)
|
||||
model = PlexItem if lib.kind == "movie" else PlexShow
|
||||
|
||||
query = db.query(model)
|
||||
if lib.kind == "movie":
|
||||
query = query.filter(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
|
||||
# Per-user watch-state filter (movies only; a show isn't a single watch unit).
|
||||
st = aliased(PlexState)
|
||||
query = query.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id))
|
||||
if show == "watched":
|
||||
query = query.filter(st.status == "watched")
|
||||
elif show == "in_progress":
|
||||
query = query.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"))
|
||||
elif show == "unwatched":
|
||||
query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
|
||||
else: # all — hide only the explicitly hidden
|
||||
query = query.filter(or_(st.status.is_(None), st.status != "hidden"))
|
||||
else:
|
||||
query = query.filter(PlexShow.library_id == lib.id)
|
||||
|
||||
ts = _to_tsquery_str(q) if q else None
|
||||
tsq = None
|
||||
if ts:
|
||||
tsq = func.to_tsquery(_TS_CONFIG, ts)
|
||||
query = query.filter(model.search_vector.op("@@")(tsq))
|
||||
|
||||
total = query.count()
|
||||
|
||||
if tsq is not None:
|
||||
order = [func.ts_rank(model.search_vector, tsq).desc()]
|
||||
elif sort == "title":
|
||||
order = [func.lower(model.title).asc()]
|
||||
else: # newest first
|
||||
order = [model.added_at.desc().nullslast()]
|
||||
order.append(model.id.desc())
|
||||
|
||||
rows = query.order_by(*order).offset(offset).limit(limit).all()
|
||||
|
||||
if lib.kind == "movie":
|
||||
by_item = {r.id: r for r in rows}
|
||||
states = {
|
||||
s.item_id: s
|
||||
for s in db.query(PlexState).filter(
|
||||
PlexState.user_id == user.id, PlexState.item_id.in_(list(by_item.keys()) or [0])
|
||||
)
|
||||
}
|
||||
items = [_movie_card(r, states.get(r.id)) for r in rows]
|
||||
else:
|
||||
items = [_show_card(r) for r in rows]
|
||||
|
||||
return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items}
|
||||
|
||||
|
||||
@router.get("/show/{rating_key}")
|
||||
def show_detail(
|
||||
rating_key: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""A show's seasons + episodes (the drill-down view) with per-user watch state."""
|
||||
sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first()
|
||||
if sh is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
||||
seasons = (
|
||||
db.query(PlexSeason).filter_by(show_id=sh.id).order_by(PlexSeason.season_number.asc().nullsfirst()).all()
|
||||
)
|
||||
eps = (
|
||||
db.query(PlexItem)
|
||||
.filter_by(show_id=sh.id, kind="episode")
|
||||
.order_by(PlexItem.season_number.asc().nullsfirst(), PlexItem.episode_number.asc().nullsfirst())
|
||||
.all()
|
||||
)
|
||||
states = {
|
||||
s.item_id: s
|
||||
for s in db.query(PlexState).filter(
|
||||
PlexState.user_id == user.id, PlexState.item_id.in_([e.id for e in eps] or [0])
|
||||
)
|
||||
}
|
||||
by_season: dict[int | None, list] = {}
|
||||
for e in eps:
|
||||
by_season.setdefault(e.season_id, []).append(_episode_card(e, states.get(e.id)))
|
||||
return {
|
||||
"show": {
|
||||
"id": sh.rating_key,
|
||||
"title": sh.title,
|
||||
"summary": sh.summary,
|
||||
"year": sh.year,
|
||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
|
||||
},
|
||||
"seasons": [
|
||||
{
|
||||
"id": se.rating_key,
|
||||
"season_number": se.season_number,
|
||||
"title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"),
|
||||
"thumb": f"/api/plex/image/{se.rating_key}",
|
||||
"episodes": by_season.get(se.id, []),
|
||||
}
|
||||
for se in seasons
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _image_key(db: Session, rating_key: str, variant: str) -> str | None:
|
||||
"""The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys
|
||||
are proxyable — this is not an open image proxy."""
|
||||
it = db.query(PlexItem).filter_by(rating_key=rating_key).first()
|
||||
if it is not None:
|
||||
return it.art_key if variant == "art" else it.thumb_key
|
||||
sh = db.query(PlexShow).filter_by(rating_key=rating_key).first()
|
||||
if sh is not None:
|
||||
return sh.art_key if variant == "art" else sh.thumb_key
|
||||
se = db.query(PlexSeason).filter_by(rating_key=rating_key).first()
|
||||
if se is not None:
|
||||
return se.thumb_key
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/image/{rating_key}")
|
||||
def image(
|
||||
rating_key: str,
|
||||
variant: str = "thumb",
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
"""Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication)."""
|
||||
key = _image_key(db, str(rating_key), "art" if variant == "art" else "thumb")
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="No image")
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
data, ct = plex.image_bytes(key)
|
||||
except PlexNotConfigured as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except PlexError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
|
||||
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})
|
||||
|
||||
|
||||
# --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ----------------------------
|
||||
|
||||
|
||||
def _item_or_404(db: Session, rating_key: str) -> PlexItem:
|
||||
it = db.query(PlexItem).filter_by(rating_key=str(rating_key)).first()
|
||||
if it is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown Plex item")
|
||||
return it
|
||||
|
||||
|
||||
# Progress thresholds (mirror the YouTube player): ignore the first few seconds, and treat the
|
||||
# last stretch as "finished" (mark watched, clear the resume point).
|
||||
_PROGRESS_MIN_S = 5
|
||||
_FINISH_MARGIN_S = 30
|
||||
|
||||
|
||||
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
|
||||
if it.kind != "episode" or it.show_id is None:
|
||||
return None, None
|
||||
keys = [
|
||||
r[0]
|
||||
for r in db.query(PlexItem.rating_key)
|
||||
.filter_by(show_id=it.show_id, kind="episode")
|
||||
.order_by(PlexItem.season_number.asc().nullsfirst(), PlexItem.episode_number.asc().nullsfirst())
|
||||
.all()
|
||||
]
|
||||
try:
|
||||
i = keys.index(it.rating_key)
|
||||
except ValueError:
|
||||
return None, None
|
||||
return (keys[i - 1] if i > 0 else None), (keys[i + 1] if i < len(keys) - 1 else None)
|
||||
|
||||
|
||||
@router.get("/item/{rating_key}")
|
||||
def item_detail(
|
||||
rating_key: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode
|
||||
prev/next, and the per-user resume position + watch status."""
|
||||
it = _item_or_404(db, rating_key)
|
||||
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
|
||||
|
||||
cast: list[str] = []
|
||||
markers: list[dict] = []
|
||||
audio_streams: list[dict] = []
|
||||
subtitle_streams: list[dict] = []
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
meta = plex.metadata(it.rating_key, markers=True) or {}
|
||||
cast = [r.get("tag") for r in (meta.get("Role") or []) if r.get("tag")][:12]
|
||||
for m in meta.get("Marker") or []:
|
||||
if m.get("type") in ("intro", "credits"):
|
||||
markers.append(
|
||||
{
|
||||
"type": m.get("type"),
|
||||
"start_s": round((m.get("startTimeOffset") or 0) / 1000, 1),
|
||||
"end_s": round((m.get("endTimeOffset") or 0) / 1000, 1),
|
||||
}
|
||||
)
|
||||
# Audio + subtitle tracks (ordinals among their stream type — used to -map the selection).
|
||||
part = ((meta.get("Media") or [{}])[0].get("Part") or [{}])[0]
|
||||
for s in part.get("Stream") or []:
|
||||
if s.get("streamType") == 2:
|
||||
audio_streams.append(
|
||||
{
|
||||
"ord": len(audio_streams),
|
||||
"label": s.get("displayTitle") or s.get("language") or f"Audio {len(audio_streams) + 1}",
|
||||
"language": s.get("languageTag") or s.get("language"),
|
||||
"default": bool(s.get("selected") or s.get("default")),
|
||||
}
|
||||
)
|
||||
elif s.get("streamType") == 3:
|
||||
subtitle_streams.append(
|
||||
{
|
||||
"ord": len(subtitle_streams),
|
||||
"label": s.get("displayTitle") or s.get("language") or f"Subtitle {len(subtitle_streams) + 1}",
|
||||
"language": s.get("languageTag") or s.get("language"),
|
||||
}
|
||||
)
|
||||
except (PlexError, PlexNotConfigured):
|
||||
pass # metadata extras are best-effort; playback works without them
|
||||
|
||||
prev_id, next_id = _episode_nav(db, it)
|
||||
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
|
||||
return {
|
||||
"id": it.rating_key,
|
||||
"kind": it.kind,
|
||||
"title": it.title,
|
||||
"summary": it.summary,
|
||||
"year": it.year,
|
||||
"duration_seconds": it.duration_s,
|
||||
"playable": it.playable,
|
||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
||||
"cast": cast,
|
||||
"markers": markers,
|
||||
"audio_streams": audio_streams,
|
||||
"subtitle_streams": subtitle_streams,
|
||||
"status": st.status if st else "new",
|
||||
"position_seconds": st.position_seconds if st else 0,
|
||||
"show_title": show.title if show else None,
|
||||
"season_number": it.season_number,
|
||||
"episode_number": it.episode_number,
|
||||
"prev_id": prev_id,
|
||||
"next_id": next_id,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/item/{rating_key}/progress")
|
||||
def item_progress(
|
||||
rating_key: str,
|
||||
payload: dict,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Checkpoint the resume position while playing (near the end → mark watched + clear it)."""
|
||||
it = _item_or_404(db, rating_key)
|
||||
try:
|
||||
position = max(0, int(payload.get("position_seconds") or 0))
|
||||
duration = int(payload.get("duration_seconds") or 0)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="position_seconds must be an integer")
|
||||
now = datetime.now(timezone.utc)
|
||||
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
|
||||
|
||||
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
|
||||
if near_end:
|
||||
if st is None:
|
||||
st = PlexState(user_id=user.id, item_id=it.id)
|
||||
db.add(st)
|
||||
st.status = "watched"
|
||||
st.watched_at = now
|
||||
st.position_seconds = 0
|
||||
st.progress_updated_at = now
|
||||
db.commit()
|
||||
return {"status": "watched", "position_seconds": 0}
|
||||
|
||||
if position < _PROGRESS_MIN_S:
|
||||
if st is not None and st.status == "new":
|
||||
db.delete(st)
|
||||
elif st is not None:
|
||||
st.position_seconds = 0
|
||||
st.progress_updated_at = now
|
||||
db.commit()
|
||||
return {"position_seconds": 0}
|
||||
|
||||
if st is None:
|
||||
st = PlexState(user_id=user.id, item_id=it.id)
|
||||
db.add(st)
|
||||
st.position_seconds = position
|
||||
st.progress_updated_at = now
|
||||
db.commit()
|
||||
return {"position_seconds": position}
|
||||
|
||||
|
||||
@router.post("/item/{rating_key}/state")
|
||||
def item_state(
|
||||
rating_key: str,
|
||||
payload: dict,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Set the per-user watch status (new|watched|hidden)."""
|
||||
it = _item_or_404(db, rating_key)
|
||||
status = payload.get("status")
|
||||
if status not in ("new", "watched", "hidden"):
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
|
||||
if status == "new":
|
||||
if st is not None:
|
||||
db.delete(st)
|
||||
db.commit()
|
||||
return {"status": "new"}
|
||||
if st is None:
|
||||
st = PlexState(user_id=user.id, item_id=it.id)
|
||||
db.add(st)
|
||||
st.status = status
|
||||
if status == "watched":
|
||||
st.watched_at = datetime.now(timezone.utc)
|
||||
st.position_seconds = 0
|
||||
db.commit()
|
||||
return {"status": status}
|
||||
|
||||
|
||||
@router.post("/stream/{rating_key}/session")
|
||||
def stream_session(
|
||||
rating_key: str,
|
||||
start: float = 0.0,
|
||||
audio: int | None = None,
|
||||
subtitle: int | None = None,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
|
||||
(re)start an HLS session at the offset (seek-restart); transcode (HEVC/VP9) is P3. Selecting a
|
||||
non-default audio/subtitle track (`audio`/`subtitle` = stream ordinals) forces the HLS path so
|
||||
the chosen tracks can be muxed in."""
|
||||
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
|
||||
it = _item_or_404(db, rating_key)
|
||||
if it.playable == "transcode":
|
||||
raise HTTPException(status_code=501, detail="This format needs transcoding (a later phase).")
|
||||
select = audio is not None or subtitle is not None
|
||||
if it.playable == "direct" and not select:
|
||||
return {"mode": "direct", "url": f"/api/plex/stream/{it.rating_key}/file", "start_s": 0.0}
|
||||
if plex_paths.local_media_path(db, it.file_path) is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
s = plex_stream.start_session(db, it, start, audio, subtitle)
|
||||
if s is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
return {"mode": "hls", "start_s": s.start_s, "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}"}
|
||||
|
||||
|
||||
@router.get("/stream/{rating_key}/file")
|
||||
def stream_file(
|
||||
rating_key: str,
|
||||
download: bool = False,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> FileResponse:
|
||||
"""Serve the raw local media file with HTTP range support. Used both for direct-play (inline)
|
||||
and for the player's "download original" button (`?download=1` → attachment). The download is
|
||||
the untouched physical file — no re-encode/repackage."""
|
||||
it = _item_or_404(db, rating_key)
|
||||
src = plex_paths.local_media_path(db, it.file_path)
|
||||
if src is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
if download:
|
||||
# A clean, safe download name from the title + the real file extension.
|
||||
ext = src.suffix or (f".{it.container}" if it.container else "")
|
||||
base = "".join(c for c in (it.title or "video") if c.isalnum() or c in " -_.").strip() or "video"
|
||||
return FileResponse(str(src), filename=f"{base}{ext}")
|
||||
return FileResponse(str(src)) # Starlette honours the Range header (206)
|
||||
|
||||
|
||||
_HLS_CT = {".m3u8": "application/vnd.apple.mpegurl", ".ts": "video/mp2t", ".vtt": "text/vtt"}
|
||||
|
||||
|
||||
@router.get("/stream/{rating_key}/hls/{filename}")
|
||||
def stream_hls_file(
|
||||
rating_key: str,
|
||||
filename: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
"""Serve a file (playlist / segment / subtitle) from the current HLS session directory. The
|
||||
playlist references its segments/subtitles by relative name, all resolving back here."""
|
||||
# Filename must be a plain hls artifact — no path separators, only the expected extensions.
|
||||
ext = "." + filename.rsplit(".", 1)[-1] if "." in filename else ""
|
||||
if ext not in _HLS_CT or "/" in filename or "\\" in filename or ".." in filename:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
s = plex_stream.current_session(str(rating_key))
|
||||
if s is None:
|
||||
# No session yet: only the entry playlist for a remux item may auto-start one at 0.
|
||||
it = _item_or_404(db, rating_key)
|
||||
if filename != "index.m3u8" or it.playable != "remux":
|
||||
raise HTTPException(status_code=404, detail="No active playback session")
|
||||
s = plex_stream.start_session(db, it, 0.0)
|
||||
if s is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
path = s.dir / filename
|
||||
timeout = 30 if ext == ".m3u8" else 20
|
||||
if not plex_stream.wait_for(path, timeout):
|
||||
raise HTTPException(status_code=404, detail="Not available yet")
|
||||
return Response(
|
||||
content=path.read_bytes(), media_type=_HLS_CT[ext], headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
|
@ -14,6 +14,7 @@ from app.config import settings
|
|||
from app.db import SessionLocal
|
||||
from app.downloads.gc import run_download_gc
|
||||
from app.models import SchedulerSetting
|
||||
from app.plex.sync import sync as run_plex_sync
|
||||
from app.notifications import create_notification
|
||||
from app.state import is_sync_paused
|
||||
from app.sync.autotag import run_autotag_all
|
||||
|
|
@ -61,6 +62,7 @@ JOB_INTERVALS: dict[str, int] = {
|
|||
"demo_reset": settings.demo_reset_minutes,
|
||||
"explore_cleanup": settings.explore_cleanup_minutes,
|
||||
"download_gc": settings.download_gc_minutes,
|
||||
"plex_sync": settings.plex_sync_interval_min,
|
||||
}
|
||||
|
||||
# Sane bounds for an admin-set interval (minutes).
|
||||
|
|
@ -284,6 +286,12 @@ def _download_gc_job() -> None:
|
|||
_job("download_gc", run_download_gc)
|
||||
|
||||
|
||||
def _plex_sync_job() -> None:
|
||||
# Mirror the enabled Plex library sections into the local catalog. Pure metadata (no YouTube
|
||||
# quota); a no-op when the Plex module is disabled.
|
||||
_job("plex_sync", run_plex_sync)
|
||||
|
||||
|
||||
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
|
||||
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
|
||||
JOB_FUNCS: dict[str, Callable[[], None]] = {
|
||||
|
|
@ -298,6 +306,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
|
|||
"demo_reset": _demo_reset_job,
|
||||
"explore_cleanup": _explore_cleanup_job,
|
||||
"download_gc": _download_gc_job,
|
||||
"plex_sync": _plex_sync_job,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ services:
|
|||
# Download center: the API serves finished files (it does NOT run the worker loop).
|
||||
DOWNLOAD_ROOT: /downloads
|
||||
WORKER_ENABLED: "false"
|
||||
# Plex HLS remux scratch on fast container-local storage (the /downloads bind-mount is a slow
|
||||
# Windows Docker Desktop mount in dev; prod uses fast local disk so it keeps the default).
|
||||
PLEX_HLS_DIR: /var/tmp/plex-hls
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
@ -58,6 +61,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 +92,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 +107,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"
|
||||
|
|
|
|||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"@tanstack/react-query": "^5.51.0",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"clsx": "^2.1.1",
|
||||
"hls.js": "^1.6.16",
|
||||
"i18next": "^23.11.5",
|
||||
"lucide-react": "^0.408.0",
|
||||
"react": "^18.3.1",
|
||||
|
|
@ -1855,6 +1856,12 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"@tanstack/react-query": "^5.51.0",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"clsx": "^2.1.1",
|
||||
"hls.js": "^1.6.16",
|
||||
"i18next": "^23.11.5",
|
||||
"lucide-react": "^0.408.0",
|
||||
"react": "^18.3.1",
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ import { CURRENT_VERSION } from "./lib/releaseNotes";
|
|||
// so the initial load — and the logged-out landing — pulls only the shell, and admin-only pages
|
||||
// never reach non-admins. All are rendered inside a <Suspense> boundary below.
|
||||
const Feed = lazy(() => import("./components/Feed"));
|
||||
const PlexBrowse = lazy(() => import("./components/PlexBrowse"));
|
||||
const PlexSidebar = lazy(() => import("./components/PlexSidebar"));
|
||||
const ChannelPage = lazy(() => import("./components/ChannelPage"));
|
||||
const Channels = lazy(() => import("./components/Channels"));
|
||||
const Playlists = lazy(() => import("./components/Playlists"));
|
||||
|
|
@ -241,6 +243,10 @@ export default function App() {
|
|||
const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed");
|
||||
const channelsView: ChannelsView =
|
||||
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
|
||||
// Plex module filters (its own left-sidebar filter section) — per-account persisted.
|
||||
const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, "");
|
||||
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
|
||||
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
|
||||
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
||||
// there to see a specific set (the header's "without full history" link).
|
||||
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
||||
|
|
@ -712,6 +718,20 @@ export default function App() {
|
|||
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
||||
/>
|
||||
)}
|
||||
{page === "plex" && meQuery.data!.plex_enabled && (
|
||||
<Suspense fallback={null}>
|
||||
<PlexSidebar
|
||||
library={plexLib}
|
||||
setLibrary={setPlexLib}
|
||||
show={plexShowFilter}
|
||||
setShow={setPlexShowFilter}
|
||||
sort={plexSort}
|
||||
setSort={setPlexSort}
|
||||
collapsed={filterCollapsed}
|
||||
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
<div className="relative flex-1 min-w-0 flex flex-col">
|
||||
{channelView ? (
|
||||
<Suspense fallback={pageFallback}>
|
||||
|
|
@ -781,6 +801,8 @@ export default function App() {
|
|||
prefs={prefsCtl}
|
||||
onOpenWizard={() => setWizardOpen(true)}
|
||||
/>
|
||||
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
||||
<PlexBrowse q={filters.q} library={plexLib} show={plexShowFilter} sort={plexSort} />
|
||||
) : (
|
||||
<Feed
|
||||
filters={filters}
|
||||
|
|
|
|||
|
|
@ -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<PlexTestResult | null>(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 <div className="p-4 max-w-3xl mx-auto text-muted">{t("config.loading")}</div>;
|
||||
}
|
||||
|
|
@ -149,6 +172,54 @@ export default function ConfigPanel() {
|
|||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeGroup === "plex" && (
|
||||
<div className="mt-3 pt-3 border-t border-border/60">
|
||||
<Tooltip hint={t("config.plexTestHint")}>
|
||||
<button
|
||||
onClick={() => testPlex.mutate()}
|
||||
disabled={testPlex.isPending || dirty}
|
||||
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<Plug className={`w-4 h-4 ${testPlex.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("config.plexTest")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{dirty && <p className="text-[11px] text-muted mt-2">{t("config.plexTestDirty")}</p>}
|
||||
{plexResult && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-muted mb-2">
|
||||
{t("config.plexConnected", {
|
||||
name: plexResult.server_name,
|
||||
version: plexResult.version ?? "",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{plexResult.sections.map((s) => (
|
||||
<label
|
||||
key={s.key}
|
||||
className="inline-flex items-center gap-2 text-sm cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
|
||||
onChange={() => togglePlexLib(s.key)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span>{s.title}</span>
|
||||
<span className="text-[11px] text-muted">
|
||||
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
|
||||
{s.count != null ? ` · ${s.count}` : ""}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted mt-2">{t("config.plexPickHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,14 @@ export default function Header({
|
|||
const { t } = useTranslation();
|
||||
const canSearchYt = !me.is_demo;
|
||||
const trimmedQ = filters.q.trim();
|
||||
// The search box serves both the YouTube feed and the Plex module (integrated search); the live
|
||||
// YouTube-search escalation (Enter / button) is feed-only.
|
||||
const isSearchPage = page === "feed" || page === "plex";
|
||||
const isYtCapable = page === "feed" && canSearchYt;
|
||||
|
||||
return (
|
||||
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
|
||||
{page === "feed" ? (
|
||||
{isSearchPage ? (
|
||||
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||
|
|
@ -40,15 +44,16 @@ export default function Header({
|
|||
// When a search first appears, rank the feed by relevance — set atomically with
|
||||
// the query (race-free vs. per-keystroke updates). Only overrides the default
|
||||
// "newest" sort; a custom sort the user chose is left alone. Cleared in Feed.
|
||||
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
|
||||
const startSearch =
|
||||
page === "feed" && !filters.q.trim() && !!q.trim() && filters.sort === "newest";
|
||||
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Enter runs a live YouTube search for the typed term (the box itself filters
|
||||
// the local catalog as you type; Enter escalates to the YouTube API).
|
||||
if (e.key === "Enter" && trimmedQ && canSearchYt) onYtSearch(trimmedQ);
|
||||
// On the feed, Enter escalates the typed term to a live YouTube search (the box
|
||||
// itself filters the local catalog / Plex library as you type).
|
||||
if (e.key === "Enter" && trimmedQ && isYtCapable) onYtSearch(trimmedQ);
|
||||
}}
|
||||
placeholder={t("header.searchPlaceholder")}
|
||||
placeholder={page === "plex" ? t("plex.searchPlaceholder") : t("header.searchPlaceholder")}
|
||||
className="w-full bg-card border border-border rounded-full pl-9 pr-9 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
{filters.q && (
|
||||
|
|
@ -62,7 +67,7 @@ export default function Header({
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
{trimmedQ && canSearchYt && (
|
||||
{trimmedQ && isYtCapable && (
|
||||
<button
|
||||
onClick={() => onYtSearch(trimmedQ)}
|
||||
title={t("header.searchYoutubeTip")}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Bell,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clapperboard,
|
||||
Download,
|
||||
Home,
|
||||
Info,
|
||||
|
|
@ -162,6 +163,8 @@ export default function NavSidebar({
|
|||
{ page: "feed", icon: Home, label: t("header.account.feed") },
|
||||
{ page: "channels", icon: Tv, label: t("header.account.channels") },
|
||||
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
|
||||
// Optional Plex module — browse/play your Plex library. Shown only when the admin enabled it.
|
||||
...(me.plex_enabled ? [{ page: "plex" as Page, icon: Clapperboard, label: t("plex.navLabel") }] : []),
|
||||
{ page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
|
||||
// Direct messaging — its own module; hidden for the shared demo account.
|
||||
...(me.is_demo
|
||||
|
|
|
|||
301
frontend/src/components/PlexBrowse.tsx
Normal file
301
frontend/src/components/PlexBrowse.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import { lazy, Suspense, useEffect, useRef, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, CheckCircle2, Play } from "lucide-react";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
|
||||
type Sub = { kind: "grid" } | { kind: "show"; id: string } | { kind: "player"; id: string };
|
||||
|
||||
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
||||
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
||||
// search term comes from the shared top Header search box (q). A show drill-down rides history
|
||||
// (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces
|
||||
// for now. Rendered by App for page==="plex" (its own nav module).
|
||||
|
||||
type Props = { q: string; library: string; show: string; sort: string };
|
||||
|
||||
const PAGE = 40;
|
||||
|
||||
function dur(n?: number | null): string {
|
||||
if (!n) return "";
|
||||
const h = Math.floor(n / 3600);
|
||||
const m = Math.floor((n % 3600) / 60);
|
||||
return h ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
export default function PlexBrowse({ q, library, show, sort }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const dq = useDebounced(q.trim(), 350);
|
||||
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
||||
|
||||
const browseQ = useInfiniteQuery({
|
||||
queryKey: ["plex-browse", library, dq, sort, show],
|
||||
enabled: !!library && sub.view.kind === "grid",
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.plexBrowse({ library, q: dq || undefined, sort, show, offset: pageParam as number, limit: PAGE }),
|
||||
getNextPageParam: (last) => {
|
||||
const seen = last.offset + last.items.length;
|
||||
return seen < last.total ? seen : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const total = browseQ.data?.pages[0]?.total ?? 0;
|
||||
|
||||
// Infinite scroll: auto-load the next page when the sentinel scrolls into view.
|
||||
const sentinel = useRef<HTMLDivElement>(null);
|
||||
const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ;
|
||||
useEffect(() => {
|
||||
const el = sentinel.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
|
||||
},
|
||||
{ rootMargin: "600px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
function onCard(card: PlexCard) {
|
||||
if (card.type === "show") sub.open({ kind: "show", id: card.id });
|
||||
else sub.open({ kind: "player", id: card.id });
|
||||
}
|
||||
async function toggleWatched(card: PlexCard) {
|
||||
await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched");
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
}
|
||||
|
||||
if (sub.view.kind === "player") {
|
||||
return (
|
||||
<Suspense fallback={<div className="fixed inset-0 z-50 bg-black" />}>
|
||||
<PlexPlayer itemId={sub.view.id} onClose={sub.back} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "show") {
|
||||
return (
|
||||
<PlexShowView
|
||||
showId={sub.view.id}
|
||||
onBack={sub.back}
|
||||
onPlay={(ep) => sub.open({ kind: "player", id: ep.id })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
<p className="text-xs text-muted mb-3">
|
||||
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
|
||||
</p>
|
||||
|
||||
{browseQ.isLoading ? (
|
||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{items.map((c) => (
|
||||
<PlexPosterCard key={c.id} card={c} onOpen={() => onCard(c)} onToggleWatched={toggleWatched} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={sentinel} className="h-8" />
|
||||
{isFetchingNextPage && <p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PLAYABLE_TINT: Record<string, string> = {
|
||||
direct: "bg-emerald-500",
|
||||
remux: "bg-amber-500",
|
||||
transcode: "bg-orange-600",
|
||||
};
|
||||
|
||||
function PlexPosterCard({
|
||||
card,
|
||||
onOpen,
|
||||
onToggleWatched,
|
||||
}: {
|
||||
card: PlexCard;
|
||||
onOpen: () => void;
|
||||
onToggleWatched: (c: PlexCard) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
|
||||
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
|
||||
const pct =
|
||||
inProgress && card.duration_seconds
|
||||
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
className="group text-left"
|
||||
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
|
||||
>
|
||||
<div
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={card.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
{/* Hover affordance: what a click does (play / resume / open show). */}
|
||||
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||
{isPlayable ? (
|
||||
<div className="flex flex-col items-center gap-1 text-white">
|
||||
<Play className="w-10 h-10" fill="currentColor" />
|
||||
<span className="text-xs font-medium">{inProgress ? t("plex.resume") : t("plex.play")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-white text-xs font-medium">{t("plex.openShow")}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Quick watched toggle (movies/episodes), on hover. */}
|
||||
{isPlayable && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleWatched(card);
|
||||
}}
|
||||
title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
|
||||
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
|
||||
>
|
||||
<CheckCircle2 className={`w-4 h-4 ${card.status === "watched" ? "text-emerald-400" : "text-white"}`} />
|
||||
</button>
|
||||
)}
|
||||
{/* Watched badge when not hovering (the toggle replaces it on hover). */}
|
||||
{card.status === "watched" && (
|
||||
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
|
||||
{t("plex.watched")}
|
||||
</span>
|
||||
)}
|
||||
{card.type === "show" && card.season_count != null && (
|
||||
<span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
|
||||
{t("plex.seasons", { count: card.season_count })}
|
||||
</span>
|
||||
)}
|
||||
{card.playable && (
|
||||
<span
|
||||
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
||||
title={t(`plex.playable.${card.playable}`)}
|
||||
/>
|
||||
)}
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{card.title}</div>
|
||||
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlexShowView({
|
||||
showId,
|
||||
onBack,
|
||||
onPlay,
|
||||
}: {
|
||||
showId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (c: PlexCard) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
|
||||
const d = q.data;
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-[1200px] mx-auto">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
|
||||
{q.isLoading || !d ? (
|
||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-4 mb-6">
|
||||
<img
|
||||
src={d.show.thumb}
|
||||
alt=""
|
||||
className="w-32 sm:w-40 aspect-[2/3] object-cover rounded-xl border border-border shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-semibold">{d.show.title}</h1>
|
||||
{d.show.year && <p className="text-sm text-muted">{d.show.year}</p>}
|
||||
{d.show.summary && (
|
||||
<p className="text-sm text-muted mt-2 line-clamp-6 leading-relaxed">{d.show.summary}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{d.seasons.map((se) => (
|
||||
<div key={se.id} className="mb-6">
|
||||
<h2 className="text-sm font-semibold mb-2">{se.title}</h2>
|
||||
<div className="flex flex-col divide-y divide-border/60">
|
||||
{se.episodes.map((ep) => {
|
||||
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
|
||||
return (
|
||||
<button
|
||||
key={ep.id}
|
||||
onClick={() => onPlay(ep)}
|
||||
className="flex items-center gap-3 py-2 text-left group"
|
||||
>
|
||||
<div className="relative w-28 aspect-video rounded-lg overflow-hidden bg-card border border-border shrink-0">
|
||||
<img src={ep.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
|
||||
{ep.status === "watched" && (
|
||||
<span className="absolute top-1 right-1 text-[9px] bg-black/70 text-white px-1 rounded">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
{inProgress && <div className="absolute bottom-0 inset-x-0 h-0.5 bg-accent" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
<span className="text-muted mr-1.5">{ep.episode_number}.</span>
|
||||
{ep.title}
|
||||
</div>
|
||||
{ep.summary && (
|
||||
<div className="text-xs text-muted line-clamp-2 mt-0.5">{ep.summary}</div>
|
||||
)}
|
||||
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
627
frontend/src/components/PlexPlayer.tsx
Normal file
627
frontend/src/components/PlexPlayer.tsx
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Hls from "hls.js";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Pause,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import { api, type PlexMarker } from "../lib/api";
|
||||
|
||||
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
|
||||
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
|
||||
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
|
||||
// currentTime = sessionStart + video.currentTime and, on a seek beyond the generated region,
|
||||
// restart the session at the target. Watch state / resume persist to plex_states.
|
||||
|
||||
type Props = { itemId: string; onClose: () => void };
|
||||
|
||||
function fmt(t: number): string {
|
||||
if (!isFinite(t) || t < 0) t = 0;
|
||||
const s = Math.floor(t % 60);
|
||||
const m = Math.floor((t / 60) % 60);
|
||||
const h = Math.floor(t / 3600);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
}
|
||||
|
||||
export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [id, setId] = useState(itemId);
|
||||
const detailQ = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
|
||||
const detail = detailQ.data;
|
||||
const duration = detail?.duration_seconds ?? 0;
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
|
||||
const durationRef = useRef(0);
|
||||
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [abs, setAbs] = useState(0); // absolute current time
|
||||
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [fs, setFs] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [uiVisible, setUiVisible] = useState(true);
|
||||
const hideTimer = useRef<number | null>(null);
|
||||
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
|
||||
const [audioOrd, setAudioOrd] = useState<number | null>(null);
|
||||
const [subOrd, setSubOrd] = useState<number | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const audioRef = useRef<number | null>(null);
|
||||
const subRef = useRef<number | null>(null);
|
||||
const menuOpenRef = useRef(false);
|
||||
audioRef.current = audioOrd;
|
||||
subRef.current = subOrd;
|
||||
menuOpenRef.current = menuOpen;
|
||||
|
||||
durationRef.current = duration;
|
||||
|
||||
// A new item starts with default tracks (its stream layout differs from the last one's).
|
||||
useEffect(() => {
|
||||
setAudioOrd(null);
|
||||
setSubOrd(null);
|
||||
}, [id]);
|
||||
|
||||
// --- media setup: (re)load a session for `id` at a start offset -------------------------------
|
||||
const loadSession = useCallback(
|
||||
async (startAt: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
setReady(false);
|
||||
let sess;
|
||||
try {
|
||||
sess = await api.plexSession(id, startAt, audioRef.current, subRef.current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
sessionStartRef.current = sess.start_s;
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (sess.mode === "hls" && Hls.isSupported()) {
|
||||
const hls = new Hls({ maxBufferLength: 30, enableWorker: true });
|
||||
hlsRef.current = hls;
|
||||
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
|
||||
hls.attachMedia(video);
|
||||
// Show the (single) muxed subtitle rendition when one was requested, else none. The
|
||||
// subtitle tracks may not be known yet at MANIFEST_PARSED, so also enable on the dedicated
|
||||
// event (otherwise hls.js never fetches the WebVTT segments).
|
||||
const enableSubs = () => {
|
||||
hls.subtitleDisplay = subRef.current != null;
|
||||
hls.subtitleTrack = subRef.current != null ? 0 : -1;
|
||||
};
|
||||
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, enableSubs);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
enableSubs();
|
||||
setReady(true);
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
} else {
|
||||
// direct file (or Safari native HLS)
|
||||
video.src = sess.url;
|
||||
video.load();
|
||||
const onMeta = () => {
|
||||
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
|
||||
setReady(true);
|
||||
video.play().catch(() => {});
|
||||
video.removeEventListener("loadedmetadata", onMeta);
|
||||
};
|
||||
video.addEventListener("loadedmetadata", onMeta);
|
||||
}
|
||||
},
|
||||
[id],
|
||||
);
|
||||
|
||||
// Start playback when the detail arrives (resume from the saved position unless already watched).
|
||||
useEffect(() => {
|
||||
if (!detail) return;
|
||||
const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
|
||||
loadSession(resume);
|
||||
return () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [detail, loadSession]);
|
||||
|
||||
// --- time tracking + progress checkpoints ----------------------------------------------------
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const onTime = () => {
|
||||
const a = sessionStartRef.current + video.currentTime;
|
||||
setAbs(a);
|
||||
try {
|
||||
const end = video.seekable.length ? sessionStartRef.current + video.seekable.end(0) : a;
|
||||
setSeekableEnd(end);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
const onPlay = () => setPlaying(true);
|
||||
const onPause = () => setPlaying(false);
|
||||
video.addEventListener("timeupdate", onTime);
|
||||
video.addEventListener("play", onPlay);
|
||||
video.addEventListener("pause", onPause);
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", onTime);
|
||||
video.removeEventListener("play", onPlay);
|
||||
video.removeEventListener("pause", onPause);
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
// Periodic + on-unmount progress checkpoint.
|
||||
const absRef = useRef(0);
|
||||
absRef.current = abs;
|
||||
useEffect(() => {
|
||||
const flush = () => {
|
||||
if (absRef.current > 0 && durationRef.current > 0)
|
||||
api.plexProgress(id, Math.floor(absRef.current), durationRef.current).catch(() => {});
|
||||
};
|
||||
const iv = window.setInterval(flush, 10000);
|
||||
return () => {
|
||||
window.clearInterval(iv);
|
||||
flush();
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
||||
};
|
||||
}, [id, qc]);
|
||||
|
||||
// --- seeking (native within the loaded region, else restart the session) ---------------------
|
||||
const seekTo = useCallback(
|
||||
(target: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const dur = durationRef.current || target;
|
||||
const T = Math.max(0, Math.min(target, dur - 1));
|
||||
const rel = T - sessionStartRef.current;
|
||||
if (rel >= 0 && rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5) {
|
||||
video.currentTime = rel;
|
||||
} else {
|
||||
loadSession(T);
|
||||
}
|
||||
},
|
||||
[loadSession],
|
||||
);
|
||||
|
||||
// --- controls --------------------------------------------------------------------------------
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) v.play().catch(() => {});
|
||||
else v.pause();
|
||||
}, []);
|
||||
|
||||
const applyVolume = useCallback((vol: number, mute: boolean) => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
v.volume = Math.max(0, Math.min(1, vol));
|
||||
v.muted = mute;
|
||||
}, []);
|
||||
const nudgeVolume = useCallback(
|
||||
(d: number) => {
|
||||
setVolume((prev) => {
|
||||
const nv = Math.max(0, Math.min(1, prev + d));
|
||||
applyVolume(nv, false);
|
||||
setMuted(nv === 0);
|
||||
return nv;
|
||||
});
|
||||
},
|
||||
[applyVolume],
|
||||
);
|
||||
|
||||
const toggleFs = useCallback(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
||||
else el.requestFullscreen().catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const onFs = () => setFs(!!document.fullscreenElement);
|
||||
document.addEventListener("fullscreenchange", onFs);
|
||||
return () => document.removeEventListener("fullscreenchange", onFs);
|
||||
}, []);
|
||||
|
||||
const go = useCallback(
|
||||
(otherId: string | null | undefined) => {
|
||||
if (otherId) {
|
||||
setAbs(0);
|
||||
setId(otherId);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
|
||||
const download = useCallback(() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = api.plexDownloadUrl(id);
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}, [id]);
|
||||
|
||||
// Switching audio/subtitle re-maps the track server-side, so it restarts the session at the
|
||||
// current position (like a seek). The refs are updated synchronously so loadSession sees them.
|
||||
const changeAudio = useCallback(
|
||||
(ord: number | null) => {
|
||||
audioRef.current = ord;
|
||||
setAudioOrd(ord);
|
||||
setMenuOpen(false);
|
||||
loadSession(absRef.current);
|
||||
},
|
||||
[loadSession],
|
||||
);
|
||||
const changeSubtitle = useCallback(
|
||||
(ord: number | null) => {
|
||||
subRef.current = ord;
|
||||
setSubOrd(ord);
|
||||
setMenuOpen(false);
|
||||
loadSession(absRef.current);
|
||||
},
|
||||
[loadSession],
|
||||
);
|
||||
|
||||
// Lift subtitle cues off the very bottom edge. The video element fills the viewport height, so a
|
||||
// default (line "auto") cue renders at the extreme bottom and gets clipped — nudge every cue up
|
||||
// to ~88% so it sits just above the control bar. Re-applied on each cue change (hls.js streams
|
||||
// cues in as subtitle segments load, and TextTrack has no per-cue "added" event).
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const placeAll = () => {
|
||||
for (const tr of Array.from(video.textTracks)) {
|
||||
for (const c of Array.from(tr.cues || [])) {
|
||||
try {
|
||||
(c as VTTCue).snapToLines = false;
|
||||
(c as VTTCue).line = 88;
|
||||
} catch {
|
||||
/* not a positionable cue */
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const bind = (tr: TextTrack) => tr.addEventListener("cuechange", placeAll);
|
||||
for (const tr of Array.from(video.textTracks)) bind(tr);
|
||||
const onAddTrack = (e: TrackEvent) => {
|
||||
if (e.track) {
|
||||
bind(e.track as TextTrack);
|
||||
placeAll();
|
||||
}
|
||||
};
|
||||
video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
|
||||
return () => {
|
||||
video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
|
||||
for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
// Auto-advance to the next episode when one finishes.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const onEnded = () => {
|
||||
api.plexSetState(id, "watched").catch(() => {});
|
||||
if (detail?.next_id) go(detail.next_id);
|
||||
};
|
||||
video.addEventListener("ended", onEnded);
|
||||
return () => video.removeEventListener("ended", onEnded);
|
||||
}, [id, detail, go]);
|
||||
|
||||
// Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key).
|
||||
const wake = useCallback(() => {
|
||||
setUiVisible(true);
|
||||
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
||||
hideTimer.current = window.setTimeout(() => {
|
||||
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
|
||||
if (!videoRef.current?.paused && !menuOpenRef.current) setUiVisible(false);
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
// --- keyboard --------------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
wake(); // keyboard use also reveals the controls
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
case "k":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "f":
|
||||
toggleFs();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) go(detail?.prev_id);
|
||||
else seekTo(absRef.current - 10);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) go(detail?.next_id);
|
||||
else seekTo(absRef.current + 10);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
nudgeVolume(0.05);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
nudgeVolume(-0.05);
|
||||
break;
|
||||
case "m":
|
||||
setMuted((m) => {
|
||||
applyVolume(volume, !m);
|
||||
return !m;
|
||||
});
|
||||
break;
|
||||
case "Escape":
|
||||
if (!document.fullscreenElement) onClose();
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, onClose, wake]);
|
||||
|
||||
const activeMarker: PlexMarker | undefined = detail?.markers.find(
|
||||
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
||||
);
|
||||
|
||||
const pct = duration > 0 ? (abs / duration) * 100 : 0;
|
||||
const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className="fixed inset-0 z-50 bg-black flex items-center justify-center select-none"
|
||||
onMouseMove={wake}
|
||||
onClick={wake}
|
||||
style={{ cursor: uiVisible ? "default" : "none" }}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="max-h-full max-w-full w-auto h-auto"
|
||||
onClick={togglePlay}
|
||||
playsInline
|
||||
/>
|
||||
|
||||
{!ready && (
|
||||
<div className="absolute inset-0 grid place-items-center text-white/70 text-sm pointer-events-none">
|
||||
{t("plex.player.loading")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top bar */}
|
||||
<div
|
||||
className={`absolute top-0 inset-x-0 p-4 bg-gradient-to-b from-black/70 to-transparent transition-opacity ${
|
||||
uiVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/15" title={t("plex.player.back")}>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold truncate">
|
||||
{detail?.kind === "episode" && detail.show_title ? detail.show_title : detail?.title}
|
||||
</div>
|
||||
{detail?.kind === "episode" && (
|
||||
<div className="text-xs text-white/70 truncate">
|
||||
S{detail.season_number}·E{detail.episode_number} — {detail.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skip intro / credits */}
|
||||
{activeMarker && (
|
||||
<button
|
||||
onClick={() => seekTo(activeMarker.end_s)}
|
||||
className={`absolute right-6 bottom-28 px-4 py-2 rounded-lg bg-white/90 text-black text-sm font-medium hover:bg-white transition ${
|
||||
uiVisible ? "opacity-100" : "opacity-80"
|
||||
}`}
|
||||
>
|
||||
{activeMarker.type === "intro" ? t("plex.player.skipIntro") : t("plex.player.skipCredits")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Bottom controls */}
|
||||
<div
|
||||
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-8 bg-gradient-to-t from-black/80 to-transparent transition-opacity ${
|
||||
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{/* Seek bar */}
|
||||
<div
|
||||
className="relative h-1.5 rounded-full bg-white/25 cursor-pointer group mb-2"
|
||||
onClick={(e) => {
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
seekTo(((e.clientX - r.left) / r.width) * duration);
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-y-0 left-0 bg-white/30 rounded-full" style={{ width: `${bufPct}%` }} />
|
||||
{/* intro/credit marker regions */}
|
||||
{detail?.markers.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute inset-y-0 bg-amber-400/50"
|
||||
style={{ left: `${(m.start_s / duration) * 100}%`, width: `${((m.end_s - m.start_s) / duration) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
<div className="absolute inset-y-0 left-0 bg-accent rounded-full" style={{ width: `${pct}%` }} />
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent opacity-0 group-hover:opacity-100"
|
||||
style={{ left: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
|
||||
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
{detail?.kind === "episode" && (
|
||||
<>
|
||||
<Ctrl label={t("plex.player.prev")} onClick={() => go(detail?.prev_id)} disabled={!detail?.prev_id}>
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.next")} onClick={() => go(detail?.next_id)} disabled={!detail?.next_id}>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 group/vol">
|
||||
<Ctrl
|
||||
label={t("plex.player.mute")}
|
||||
onClick={() =>
|
||||
setMuted((m) => {
|
||||
applyVolume(volume, !m);
|
||||
return !m;
|
||||
})
|
||||
}
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
|
||||
</Ctrl>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => {
|
||||
const nv = Number(e.target.value);
|
||||
setVolume(nv);
|
||||
setMuted(nv === 0);
|
||||
applyVolume(nv, false);
|
||||
}}
|
||||
className="w-20 accent-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs tabular-nums text-white/90">
|
||||
{fmt(abs)} / {fmt(duration)}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{detail && (detail.audio_streams.length > 1 || detail.subtitle_streams.length > 0) && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
aria-label={t("plex.player.tracks")}
|
||||
className={`p-1 hover:text-accent ${menuOpen ? "text-accent" : ""}`}
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute bottom-full right-0 mb-2 w-60 max-h-72 overflow-auto rounded-lg border border-white/15 bg-black/95 p-1.5 text-sm">
|
||||
{detail.audio_streams.length > 1 && (
|
||||
<>
|
||||
<div className="px-2 pt-1 pb-0.5 text-[11px] uppercase tracking-wide text-white/50">
|
||||
{t("plex.player.audio")}
|
||||
</div>
|
||||
{detail.audio_streams.map((a) => (
|
||||
<button
|
||||
key={a.ord}
|
||||
onClick={() => changeAudio(a.ord)}
|
||||
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="px-2 pt-2 pb-0.5 text-[11px] uppercase tracking-wide text-white/50">
|
||||
{t("plex.player.subtitles")}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => changeSubtitle(null)}
|
||||
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||
subOrd == null ? "text-accent" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{t("plex.player.subOff")}
|
||||
</button>
|
||||
{detail.subtitle_streams.map((s) => (
|
||||
<button
|
||||
key={s.ord}
|
||||
onClick={() => changeSubtitle(s.ord)}
|
||||
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||
subOrd === s.ord ? "text-accent" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Ctrl label={t("plex.player.download")} onClick={download}>
|
||||
<Download className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs}>
|
||||
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
|
||||
</Ctrl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the
|
||||
// viewport, unlike a native title tooltip on the bottom control bar).
|
||||
function Ctrl({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex group/ctrl">
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
className="p-1 hover:text-accent disabled:opacity-30"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-black/90 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover/ctrl:opacity-100">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/PlexSidebar.tsx
Normal file
135
frontend/src/components/PlexSidebar.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Film, SlidersHorizontal, Tv2 } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
// The Plex module's left filter column (mirrors the feed Sidebar's shell): pick a library, filter
|
||||
// by watch state (movie libraries only), and sort. State is owned by App (per-account persisted).
|
||||
|
||||
type Props = {
|
||||
library: string;
|
||||
setLibrary: (v: string) => void;
|
||||
show: string;
|
||||
setShow: (v: string) => void;
|
||||
sort: string;
|
||||
setSort: (v: string) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
};
|
||||
|
||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||
const SORT_OPTS = ["added", "title"] as const;
|
||||
|
||||
export default function PlexSidebar({
|
||||
library,
|
||||
setLibrary,
|
||||
show,
|
||||
setShow,
|
||||
sort,
|
||||
setSort,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
|
||||
const libs = libsQ.data?.libraries ?? [];
|
||||
const activeLib = libs.find((l) => l.key === library);
|
||||
|
||||
// Default to the first library once loaded (or if the stored one vanished).
|
||||
useEffect(() => {
|
||||
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
|
||||
}, [libs, library, setLibrary]);
|
||||
|
||||
const isMovieLib = activeLib?.kind === "movie";
|
||||
const activeCount = (show !== "all" && isMovieLib ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={t("sidebar.expandPanel")}
|
||||
aria-label={t("sidebar.expandPanel")}
|
||||
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={t("sidebar.filters")}
|
||||
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<SlidersHorizontal className="w-5 h-5" />
|
||||
{activeCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4">
|
||||
{/* Library scope */}
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.library")}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{libs.map((l) => (
|
||||
<button
|
||||
key={l.key}
|
||||
onClick={() => setLibrary(l.key)}
|
||||
className={`inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition ${
|
||||
l.key === library ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||
}`}
|
||||
>
|
||||
{l.kind === "movie" ? <Film className="w-4 h-4" /> : <Tv2 className="w-4 h-4" />}
|
||||
<span className="flex-1 text-left truncate">{l.title}</span>
|
||||
<span className="opacity-60 text-xs">{l.count.toLocaleString()}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Watch state (movie libraries only — an episode's state lives in the show drill-down) */}
|
||||
{isMovieLib && (
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.show")}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{SHOW_OPTS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setShow(s)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs transition ${
|
||||
show === s ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||
}`}
|
||||
>
|
||||
{t(`plex.filter.showOpt.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort */}
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.sort")}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{SORT_OPTS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSort(s)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs transition ${
|
||||
sort === s ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||
}`}
|
||||
>
|
||||
{t(`plex.sort.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,70 @@
|
|||
"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."
|
||||
},
|
||||
"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)."
|
||||
},
|
||||
"plex_server_url": {
|
||||
"label": "Plex-Server-URL",
|
||||
"hint": "Von diesem Host erreichbare Basis-URL, z. B. http://192.168.1.100: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 +187,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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
"tip": "Danach filtern, wie Videos hierher kamen: nur deine Abos/Katalog, samt deiner über Live-YouTube-Suche gefundenen Videos, oder nur diese Suchergebnisse.",
|
||||
"organic": "Ohne Suchergebnisse",
|
||||
"all": "Mit Suchergebnissen",
|
||||
"only": "Nur Suchergebnisse"
|
||||
"only": "Nur Suchergebnisse",
|
||||
"plex": "Plex"
|
||||
},
|
||||
"yt": {
|
||||
"searchFor": "Auf YouTube suchen nach „{{query}}”",
|
||||
|
|
|
|||
57
frontend/src/i18n/locales/de/plex.json
Normal file
57
frontend/src/i18n/locales/de/plex.json
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"navLabel": "Plex",
|
||||
"backToFeed": "YouTube-Feed",
|
||||
"backToLibrary": "Zurück zur Bibliothek",
|
||||
"searchPlaceholder": "Plex durchsuchen…",
|
||||
"sort": {
|
||||
"added": "Kürzlich hinzugefügt",
|
||||
"title": "Titel"
|
||||
},
|
||||
"filter": {
|
||||
"library": "Bibliothek",
|
||||
"show": "Anzeige",
|
||||
"showOpt": {
|
||||
"all": "Alle",
|
||||
"unwatched": "Ungesehen",
|
||||
"in_progress": "Läuft",
|
||||
"watched": "Gesehen"
|
||||
},
|
||||
"sort": "Sortierung"
|
||||
},
|
||||
"count": "{{count}} Titel",
|
||||
"searchCount": "{{count}} Treffer",
|
||||
"noMatches": "Keine Treffer.",
|
||||
"loading": "Wird geladen…",
|
||||
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
|
||||
"loadMore": "Mehr laden",
|
||||
"watched": "Angesehen",
|
||||
"play": "Abspielen",
|
||||
"resume": "Fortsetzen",
|
||||
"openShow": "Serie öffnen",
|
||||
"markWatched": "Als gesehen markieren",
|
||||
"markUnwatched": "Als ungesehen markieren",
|
||||
"seasons": "{{count}} Staffeln",
|
||||
"playerSoon": "Player kommt bald — „{{title}}“",
|
||||
"player": {
|
||||
"loading": "Wird geladen…",
|
||||
"back": "Zurück",
|
||||
"skipIntro": "Intro überspringen",
|
||||
"skipCredits": "Abspann überspringen",
|
||||
"playPause": "Wiedergabe / Pause (Leertaste)",
|
||||
"restart": "Von vorn beginnen",
|
||||
"prev": "Vorherige Folge",
|
||||
"next": "Nächste Folge",
|
||||
"mute": "Stumm (m)",
|
||||
"fullscreen": "Vollbild (f)",
|
||||
"download": "Originaldatei herunterladen",
|
||||
"tracks": "Audio & Untertitel",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Untertitel",
|
||||
"subOff": "Aus"
|
||||
},
|
||||
"playable": {
|
||||
"direct": "Spielt direkt im Browser",
|
||||
"remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)",
|
||||
"transcode": "Braucht Transcoding (aufwendiger)"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,70 @@
|
|||
"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."
|
||||
},
|
||||
"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)."
|
||||
},
|
||||
"plex_server_url": {
|
||||
"label": "Plex server URL",
|
||||
"hint": "Base URL reachable from this host, e.g. http://192.168.1.100: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 +187,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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
"tip": "Filter by how videos got here: your subscriptions/catalog only, plus videos you found via live YouTube search, or only those search results.",
|
||||
"organic": "Hide search results",
|
||||
"all": "Include search results",
|
||||
"only": "Search results only"
|
||||
"only": "Search results only",
|
||||
"plex": "Plex"
|
||||
},
|
||||
"yt": {
|
||||
"searchFor": "Search YouTube for “{{query}}”",
|
||||
|
|
|
|||
57
frontend/src/i18n/locales/en/plex.json
Normal file
57
frontend/src/i18n/locales/en/plex.json
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"navLabel": "Plex",
|
||||
"backToFeed": "YouTube feed",
|
||||
"backToLibrary": "Back to library",
|
||||
"searchPlaceholder": "Search Plex…",
|
||||
"sort": {
|
||||
"added": "Recently added",
|
||||
"title": "Title"
|
||||
},
|
||||
"filter": {
|
||||
"library": "Library",
|
||||
"show": "Show",
|
||||
"showOpt": {
|
||||
"all": "All",
|
||||
"unwatched": "Unwatched",
|
||||
"in_progress": "In progress",
|
||||
"watched": "Watched"
|
||||
},
|
||||
"sort": "Sort"
|
||||
},
|
||||
"count": "{{count}} titles",
|
||||
"searchCount": "{{count}} matches",
|
||||
"noMatches": "No matches.",
|
||||
"loading": "Loading…",
|
||||
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
|
||||
"loadMore": "Load more",
|
||||
"watched": "Watched",
|
||||
"play": "Play",
|
||||
"resume": "Resume",
|
||||
"openShow": "Open show",
|
||||
"markWatched": "Mark watched",
|
||||
"markUnwatched": "Mark unwatched",
|
||||
"seasons": "{{count}} seasons",
|
||||
"playerSoon": "Player coming soon — “{{title}}”",
|
||||
"player": {
|
||||
"loading": "Loading…",
|
||||
"back": "Back",
|
||||
"skipIntro": "Skip intro",
|
||||
"skipCredits": "Skip credits",
|
||||
"playPause": "Play / pause (space)",
|
||||
"restart": "Restart from beginning",
|
||||
"prev": "Previous episode",
|
||||
"next": "Next episode",
|
||||
"mute": "Mute (m)",
|
||||
"fullscreen": "Fullscreen (f)",
|
||||
"download": "Download original file",
|
||||
"tracks": "Audio & subtitles",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Subtitles",
|
||||
"subOff": "Off"
|
||||
},
|
||||
"playable": {
|
||||
"direct": "Plays directly in the browser",
|
||||
"remux": "Needs a light remux (no video re-encode)",
|
||||
"transcode": "Needs transcoding (heavier)"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,70 @@
|
|||
"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."
|
||||
},
|
||||
"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)."
|
||||
},
|
||||
"plex_server_url": {
|
||||
"label": "Plex-szerver URL",
|
||||
"hint": "Erről a gépről elérhető alap-URL, pl. http://192.168.1.100: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 +187,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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
"tip": "Szűrés aszerint, hogyan kerültek be a videók: csak a feliratkozásaid/katalógus, az élő YouTube-keresésből talált videóiddal együtt, vagy csak azok a keresési találatok.",
|
||||
"organic": "Keresés nélkül",
|
||||
"all": "Kereséssel együtt",
|
||||
"only": "Csak keresésből"
|
||||
"only": "Csak keresésből",
|
||||
"plex": "Plex"
|
||||
},
|
||||
"yt": {
|
||||
"searchFor": "Keresés a YouTube-on: „{{query}}”",
|
||||
|
|
|
|||
57
frontend/src/i18n/locales/hu/plex.json
Normal file
57
frontend/src/i18n/locales/hu/plex.json
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"navLabel": "Plex",
|
||||
"backToFeed": "YouTube feed",
|
||||
"backToLibrary": "Vissza a könyvtárhoz",
|
||||
"searchPlaceholder": "Keresés a Plexben…",
|
||||
"sort": {
|
||||
"added": "Nemrég hozzáadott",
|
||||
"title": "Cím"
|
||||
},
|
||||
"filter": {
|
||||
"library": "Könyvtár",
|
||||
"show": "Megjelenítés",
|
||||
"showOpt": {
|
||||
"all": "Mind",
|
||||
"unwatched": "Nem nézett",
|
||||
"in_progress": "Folyamatban",
|
||||
"watched": "Megnézett"
|
||||
},
|
||||
"sort": "Rendezés"
|
||||
},
|
||||
"count": "{{count}} cím",
|
||||
"searchCount": "{{count}} találat",
|
||||
"noMatches": "Nincs találat.",
|
||||
"loading": "Betöltés…",
|
||||
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
|
||||
"loadMore": "Több betöltése",
|
||||
"watched": "Megnézve",
|
||||
"play": "Lejátszás",
|
||||
"resume": "Folytatás",
|
||||
"openShow": "Sorozat megnyitása",
|
||||
"markWatched": "Megnézettnek jelöl",
|
||||
"markUnwatched": "Nem-nézettnek jelöl",
|
||||
"seasons": "{{count}} évad",
|
||||
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”",
|
||||
"player": {
|
||||
"loading": "Betöltés…",
|
||||
"back": "Vissza",
|
||||
"skipIntro": "Intro átugrása",
|
||||
"skipCredits": "Stáblista átugrása",
|
||||
"playPause": "Lejátszás / szünet (szóköz)",
|
||||
"restart": "Újra az elejéről",
|
||||
"prev": "Előző rész",
|
||||
"next": "Következő rész",
|
||||
"mute": "Némítás (m)",
|
||||
"fullscreen": "Teljes képernyő (f)",
|
||||
"download": "Eredeti fájl letöltése",
|
||||
"tracks": "Audió és felirat",
|
||||
"audio": "Audió",
|
||||
"subtitles": "Felirat",
|
||||
"subOff": "Ki"
|
||||
},
|
||||
"playable": {
|
||||
"direct": "Közvetlenül játszható a böngészőben",
|
||||
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",
|
||||
"transcode": "Transcode kell (nehezebb)"
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ export interface Me {
|
|||
has_google: boolean;
|
||||
has_password: boolean;
|
||||
google_enabled: boolean;
|
||||
plex_enabled: boolean;
|
||||
can_read: boolean;
|
||||
can_write: boolean;
|
||||
pending_invites: number;
|
||||
|
|
@ -179,7 +180,7 @@ export interface FeedFilters {
|
|||
show: string;
|
||||
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
|
||||
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
|
||||
librarySource?: "organic" | "all" | "search";
|
||||
librarySource?: "organic" | "all" | "search" | "plex";
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
maxAgeDays?: number;
|
||||
|
|
@ -609,6 +610,101 @@ 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[];
|
||||
}
|
||||
|
||||
// Plex browse/search/drill-down (the mirrored catalog rendered as feed-style cards).
|
||||
export interface PlexLibrary {
|
||||
key: string;
|
||||
title: string;
|
||||
kind: "movie" | "show";
|
||||
count: number;
|
||||
}
|
||||
export interface PlexCard {
|
||||
id: string;
|
||||
type: "movie" | "show" | "episode";
|
||||
title: string;
|
||||
year?: number | null;
|
||||
duration_seconds?: number | null;
|
||||
thumb: string;
|
||||
playable?: string; // direct | remux | transcode
|
||||
status?: string; // new | watched | hidden
|
||||
position_seconds?: number;
|
||||
season_count?: number | null; // show
|
||||
season_number?: number | null; // episode
|
||||
episode_number?: number | null; // episode
|
||||
summary?: string | null; // episode
|
||||
}
|
||||
export interface PlexBrowseResult {
|
||||
kind: "movie" | "show";
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
items: PlexCard[];
|
||||
}
|
||||
export interface PlexSeasonDetail {
|
||||
id: string;
|
||||
season_number: number | null;
|
||||
title: string;
|
||||
thumb: string;
|
||||
episodes: PlexCard[];
|
||||
}
|
||||
export interface PlexShowDetail {
|
||||
show: {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string | null;
|
||||
year?: number | null;
|
||||
thumb: string;
|
||||
art: string;
|
||||
};
|
||||
seasons: PlexSeasonDetail[];
|
||||
}
|
||||
|
||||
export interface PlexMarker {
|
||||
type: "intro" | "credits";
|
||||
start_s: number;
|
||||
end_s: number;
|
||||
}
|
||||
export interface PlexItemDetail {
|
||||
id: string;
|
||||
kind: "movie" | "episode";
|
||||
title: string;
|
||||
summary?: string | null;
|
||||
year?: number | null;
|
||||
duration_seconds: number | null;
|
||||
playable: string; // direct | remux | transcode
|
||||
thumb: string;
|
||||
art: string;
|
||||
cast: string[];
|
||||
markers: PlexMarker[];
|
||||
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
|
||||
subtitle_streams: { ord: number; label: string; language?: string | null }[];
|
||||
status: string;
|
||||
position_seconds: number;
|
||||
show_title?: string | null;
|
||||
season_number?: number | null;
|
||||
episode_number?: number | null;
|
||||
prev_id?: string | null;
|
||||
next_id?: string | null;
|
||||
}
|
||||
export interface PlexPlaySession {
|
||||
mode: "direct" | "hls";
|
||||
url: string;
|
||||
start_s: number;
|
||||
}
|
||||
|
||||
// Admin Users & roles page.
|
||||
export interface AdminUserRow {
|
||||
id: number;
|
||||
|
|
@ -921,6 +1017,56 @@ 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<PlexTestResult> => req("/api/plex/test", { method: "POST" }),
|
||||
syncPlex: (): Promise<Record<string, unknown>> => req("/api/plex/sync", { method: "POST" }),
|
||||
plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> =>
|
||||
req("/api/plex/libraries"),
|
||||
plexBrowse: (p: {
|
||||
library: string;
|
||||
q?: string;
|
||||
sort?: string;
|
||||
show?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}): Promise<PlexBrowseResult> => {
|
||||
const u = new URLSearchParams({ library: p.library });
|
||||
if (p.q) u.set("q", p.q);
|
||||
if (p.sort) u.set("sort", p.sort);
|
||||
if (p.show && p.show !== "all") u.set("show", p.show);
|
||||
if (p.offset) u.set("offset", String(p.offset));
|
||||
if (p.limit) u.set("limit", String(p.limit));
|
||||
return req(`/api/plex/browse?${u.toString()}`);
|
||||
},
|
||||
plexShow: (id: string): Promise<PlexShowDetail> =>
|
||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
||||
plexSession: (
|
||||
id: string,
|
||||
start = 0,
|
||||
audio?: number | null,
|
||||
subtitle?: number | null,
|
||||
): Promise<PlexPlaySession> => {
|
||||
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
|
||||
if (audio != null) p.set("audio", String(audio));
|
||||
if (subtitle != null) p.set("subtitle", String(subtitle));
|
||||
return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST" });
|
||||
},
|
||||
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> =>
|
||||
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ position_seconds, duration_seconds }),
|
||||
}),
|
||||
plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise<unknown> =>
|
||||
req(`/api/plex/item/${encodeURIComponent(id)}/state`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
plexStreamFileUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file`,
|
||||
plexDownloadUrl: (id: string): string =>
|
||||
`/api/plex/stream/${encodeURIComponent(id)}/file?download=1`,
|
||||
plexImageUrl: (id: string, variant?: "thumb" | "art"): string =>
|
||||
`/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`,
|
||||
// --- admin: users & roles ---
|
||||
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
|
||||
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export function pageTitleKey(page: Page): string {
|
|||
return "downloads.navLabel";
|
||||
case "stats":
|
||||
return "header.usageStats";
|
||||
case "plex":
|
||||
return "plex.navLabel";
|
||||
case "scheduler":
|
||||
return "header.scheduler";
|
||||
case "config":
|
||||
|
|
|
|||
|
|
@ -14,6 +14,18 @@ export interface ReleaseEntry {
|
|||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.23.0",
|
||||
date: "2026-07-05",
|
||||
summary: "New optional Plex module: browse, search and play your Plex movies & TV shows.",
|
||||
features: [
|
||||
"Plex integration (optional): when your admin turns it on, a new “Plex” section appears in the sidebar. Browse and search your Plex movies and TV shows as cards, just like the feed, and drill into a show's seasons and episodes.",
|
||||
"Rich Plex player: play a movie or episode right in the app. Play/pause, resume where you left off (or restart), seek, skip intro/credits, step to the previous/next episode with auto-advance, pick the audio track and turn on subtitles, adjust volume, go fullscreen — all keyboard-controllable (Space, F, arrows).",
|
||||
"Your watched and in-progress state is saved per account and shown on the cards. Hover a card for a quick Play/Resume and a mark-watched toggle.",
|
||||
"Download the original media file straight from the player.",
|
||||
"Admins: enable and set up Plex under Settings → Configuration (server URL, token, media path map, which libraries to expose), with a “Test connection” + library picker.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.22.3",
|
||||
date: "2026-07-04",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ export const LS = {
|
|||
filterCollapsed: "siftlode.filterCollapsed",
|
||||
playlist: "siftlode.playlist",
|
||||
plSort: "siftlode.plSort",
|
||||
plexLibrary: "siftlode.plexLibrary",
|
||||
plexShow: "siftlode.plexShow",
|
||||
plexSort: "siftlode.plexSort",
|
||||
notifHistory: "siftlode.notifications",
|
||||
notifSettings: "siftlode.notifSettings",
|
||||
onboardingDismissed: "siftlode.onboarding.dismissed",
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
|
|||
if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my";
|
||||
if (params.has("source")) {
|
||||
const s = params.get("source");
|
||||
f.librarySource = s === "all" || s === "search" ? s : "organic";
|
||||
f.librarySource = s === "all" || s === "search" || s === "plex" ? s : "organic";
|
||||
}
|
||||
if (params.has("normal")) f.includeNormal = params.get("normal") !== "0";
|
||||
if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1";
|
||||
|
|
@ -101,6 +101,7 @@ export const PAGES = [
|
|||
"notifications",
|
||||
"messages",
|
||||
"downloads",
|
||||
"plex",
|
||||
] as const;
|
||||
|
||||
export type Page = (typeof PAGES)[number];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue