siftlode/backend/app/plex/paths.py
npeter83 a88254c841 feat(plex): P0 backend foundations — catalog model, config, client
Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.

- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
  (playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
  + plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
  path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
  +markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
2026-07-05 01:35:08 +02:00

51 lines
2.2 KiB
Python

"""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