237 lines
8.5 KiB
Python
237 lines
8.5 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 _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()
|