feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
"""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
|
2026-07-06 02:25:48 +02:00
|
|
|
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
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"}
|
2026-07-06 02:25:48 +02:00
|
|
|
stats = {"libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0, "collections": 0}
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
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)
|
2026-07-06 02:25:48 +02:00
|
|
|
_sync_collections(db, plex, lib, stats)
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 23:48:35 +02:00
|
|
|
def _apply_facet_fields(row, meta: dict) -> None:
|
|
|
|
|
"""Set the filterable/orderable metadata columns shared by plex_items + plex_shows (identical
|
|
|
|
|
column names) from the cheap section listing: rating, content_rating, studio, release date,
|
|
|
|
|
genres/directors/cast, and the searchable people blob folded into search_vector at sync time."""
|
|
|
|
|
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)
|
|
|
|
|
people = list(dict.fromkeys((row.directors or []) + (row.cast_names or [])))
|
|
|
|
|
row.people_text = " ".join(people) or None
|
|
|
|
|
|
|
|
|
|
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
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"))
|
2026-07-11 23:48:35 +02:00
|
|
|
_apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows)
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
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"))
|
2026-07-11 23:48:35 +02:00
|
|
|
_apply_facet_fields(sh, meta) # same filterable/orderable tags as movies
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
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()
|
2026-07-06 02:25:48 +02:00
|
|
|
|
|
|
|
|
|
2026-07-11 23:48:35 +02:00
|
|
|
def _upsert_collection(
|
|
|
|
|
db: Session, existing: PlexCollection | None, rk: str, lib_id: int, meta: dict
|
|
|
|
|
) -> PlexCollection:
|
|
|
|
|
"""Get-or-create a PlexCollection row and apply its metadata from a Plex `meta` dict. `editable`
|
|
|
|
|
is user-managed and preserved (never set here). Shared by the full sync + the targeted re-sync."""
|
|
|
|
|
col = existing or PlexCollection(rating_key=rk)
|
2026-07-06 17:33:16 +02:00
|
|
|
if col.id is None:
|
|
|
|
|
db.add(col)
|
2026-07-11 23:48:35 +02:00
|
|
|
col.library_id = lib_id
|
2026-07-06 17:33:16 +02:00
|
|
|
col.title = (meta.get("title") or "").strip() or "Untitled"
|
|
|
|
|
col.summary = meta.get("summary")
|
|
|
|
|
col.thumb_key = meta.get("thumb")
|
|
|
|
|
col.child_count = meta.get("childCount")
|
|
|
|
|
col.smart = str(meta.get("smart")) == "1"
|
|
|
|
|
col.synced_at = datetime.now(timezone.utc)
|
2026-07-11 23:48:35 +02:00
|
|
|
return col
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str) -> PlexCollection:
|
|
|
|
|
"""Targeted re-sync of ONE collection after a write-back (create/add/remove/rename) — avoids the
|
|
|
|
|
full ~4-min library sync. Upserts the row (preserving `editable`) and reconciles ONLY this
|
|
|
|
|
collection's membership on the affected member rows."""
|
|
|
|
|
meta = plex.metadata(rk) or {}
|
|
|
|
|
col = _upsert_collection(
|
|
|
|
|
db, db.query(PlexCollection).filter_by(rating_key=rk).first(), rk, lib.id, meta
|
|
|
|
|
)
|
2026-07-06 17:33:16 +02:00
|
|
|
Model = PlexItem if lib.kind == "movie" else PlexShow
|
|
|
|
|
new_members = {str(c.get("ratingKey")) for c in plex.collection_children(rk) if c.get("ratingKey")}
|
|
|
|
|
current = db.query(Model).filter(Model.collection_keys.contains([rk])).all()
|
|
|
|
|
for row in current: # drop rk from rows that are no longer members
|
|
|
|
|
if row.rating_key not in new_members:
|
|
|
|
|
row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None
|
|
|
|
|
to_add = new_members - {r.rating_key for r in current}
|
|
|
|
|
if to_add:
|
|
|
|
|
for row in db.query(Model).filter(Model.rating_key.in_(to_add)):
|
|
|
|
|
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), rk]))
|
|
|
|
|
db.flush()
|
|
|
|
|
return col
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_collection_local(db: Session, lib: PlexLibrary, rk: str) -> None:
|
|
|
|
|
"""Local cleanup after a collection is deleted from Plex: strip rk from members + drop the row."""
|
|
|
|
|
Model = PlexItem if lib.kind == "movie" else PlexShow
|
|
|
|
|
for row in db.query(Model).filter(Model.collection_keys.contains([rk])):
|
|
|
|
|
row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None
|
|
|
|
|
col = db.query(PlexCollection).filter_by(rating_key=rk).first()
|
|
|
|
|
if col is not None:
|
|
|
|
|
db.delete(col)
|
|
|
|
|
db.flush()
|
|
|
|
|
|
|
|
|
|
|
2026-07-06 02:25:48 +02:00
|
|
|
def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
|
|
|
|
|
"""Mirror a library's collections + membership. Reads never touch Plex afterwards. Membership is
|
|
|
|
|
written as `collection_keys` on member movies (plex_items) / shows (plex_shows). A full reconcile:
|
|
|
|
|
collections gone from Plex are pruned, and each member row's keys are rebuilt from scratch."""
|
|
|
|
|
cols = plex.collections(lib.plex_key)
|
|
|
|
|
existing = {c.rating_key: c for c in db.query(PlexCollection).filter_by(library_id=lib.id)}
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
membership: dict[str, list[str]] = {} # member rating_key -> [collection rating_key, ...]
|
|
|
|
|
for meta in cols:
|
|
|
|
|
rk = str(meta.get("ratingKey") or "")
|
|
|
|
|
if not rk:
|
|
|
|
|
continue
|
2026-07-11 23:48:35 +02:00
|
|
|
_upsert_collection(db, existing.get(rk), rk, lib.id, meta)
|
2026-07-06 02:25:48 +02:00
|
|
|
seen.add(rk)
|
|
|
|
|
for child in plex.collection_children(rk):
|
|
|
|
|
crk = str(child.get("ratingKey") or "")
|
|
|
|
|
if crk:
|
|
|
|
|
membership.setdefault(crk, []).append(rk)
|
|
|
|
|
stats["collections"] += 1
|
|
|
|
|
db.flush()
|
|
|
|
|
for rk, col in existing.items():
|
|
|
|
|
if rk not in seen:
|
|
|
|
|
db.delete(col)
|
|
|
|
|
# Rebuild collection_keys on every member row of this library (clear + set).
|
|
|
|
|
for row in db.query(PlexItem).filter_by(library_id=lib.id):
|
|
|
|
|
row.collection_keys = membership.get(row.rating_key) or None
|
|
|
|
|
for row in db.query(PlexShow).filter_by(library_id=lib.id):
|
|
|
|
|
row.collection_keys = membership.get(row.rating_key) or None
|
|
|
|
|
db.flush()
|