siftlode/backend/app/plex/sync.py
npeter83 280c62dda8 feat(plex): search cast & crew names + clickable person cards
Backend (migration 0046_plex_people_search): a new people_text column (cast +
director names, de-duped) is folded into the generated search_vector at weight B,
so the top search box finds titles by an actor/director name and ranks them above
summary-only mentions. New /api/plex/people endpoint returns the cast/crew matching
the term (name prefix or word-start) with a film count + a headshot (pulled from one
representative film's live metadata; image bytes disk-cached).

Frontend: PlexBrowse shows matching people as virtual cards above the grid; clicking
one adds them to the actor/director filter (multi-value, from the C1 work) and clears
the search box so you land on exactly that person's films. Answers the 'why does
"drew" match Rambo?' confusion — it was matching the word in the synopsis; now names
are searchable. i18n en/hu/de.

⚠️ Prod needs a Plex re-sync after deploy to populate people_text (search_vector
regenerates automatically once the column has data).
2026-07-06 00:56:01 +02:00

270 lines
9.9 KiB
Python

"""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 _date(v):
"""Parse a Plex 'YYYY-MM-DD' string (originallyAvailableAt) → date, or None."""
try:
return datetime.strptime(v, "%Y-%m-%d").date() if v else None
except (TypeError, ValueError):
return None
def _tags(meta: dict, key: str, limit: int | None = None) -> list | None:
"""The .tag values of a Plex child-tag array (Genre/Director/Role) present in the listing."""
vals = [t.get("tag") for t in (meta.get(key) or []) if t.get("tag")]
if limit:
vals = vals[:limit]
return vals or None
def _rating(meta: dict) -> float | None:
try:
return float(meta.get("audienceRating") or meta.get("rating"))
except (TypeError, ValueError):
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"))
# Filterable / orderable extras — all present in the cheap section listing.
row.rating = _rating(meta)
row.content_rating = (meta.get("contentRating") or None)
row.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255]
row.originally_available_at = _date(meta.get("originallyAvailableAt"))
row.genres = _tags(meta, "Genre")
row.directors = _tags(meta, "Director")
row.cast_names = _tags(meta, "Role", limit=20)
# Searchable people blob (cast + directors, de-duped) → folded into search_vector at sync time.
people = list(dict.fromkeys((row.directors or []) + (row.cast_names or [])))
row.people_text = " ".join(people) or None
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()