style: apply ruff format across backend + scripts (line-length 100)

Mechanical, whole-tree `ruff format` pass — no behaviour change. Recorded in
.git-blame-ignore-revs (next commit) so git blame skips it.
This commit is contained in:
2026-08-23 07:43:06 +02:00
parent 434a98d6ed
commit b1832afd18
217 changed files with 3149 additions and 1744 deletions
+11 -3
View File
@@ -5,6 +5,7 @@ this client — Siftlode plays the LOCAL physical file (see app.plex.paths). Ple
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
from urllib.parse import quote
@@ -163,7 +164,9 @@ class PlexClient:
self._write("PUT", f"/library/collections/{collection_rating_key}/items?uri={uri}")
def remove_collection_item(self, collection_rating_key: str, item_rating_key: str) -> None:
self._write("DELETE", f"/library/collections/{collection_rating_key}/children/{item_rating_key}")
self._write(
"DELETE", f"/library/collections/{collection_rating_key}/children/{item_rating_key}"
)
def rename_collection(self, section_key: str, collection_rating_key: str, title: str) -> None:
self._write(
@@ -221,7 +224,11 @@ class PlexClient:
return self._get("/accounts").get("Account", []) or []
def watch_history(
self, min_viewed_at: int = 0, account_id: int | None = None, size: int = 500, max_pages: int = 40
self,
min_viewed_at: int = 0,
account_id: int | None = None,
size: int = 500,
max_pages: int = 40,
) -> list[dict]:
"""The watch-history rows (ratingKey / viewedAt / accountID / type) newer than `min_viewed_at`
(epoch seconds), newest first — works without Plex Pass. The efficient "what changed since T"
@@ -255,7 +262,8 @@ class PlexClient:
else:
log.warning(
"watch_history hit the %d-page scan cap (since=%s); reconcile will catch the tail",
max_pages, min_viewed_at,
max_pages,
min_viewed_at,
)
return out
+1 -3
View File
@@ -38,9 +38,7 @@ def rich_detail(meta: dict) -> dict:
"imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None,
"content_rating": meta.get("contentRating"),
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
"directors": [
d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")
][:4],
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
"studio": meta.get("studio"),
"tagline": meta.get("tagline"),
"cast": people.cast_from_meta(meta),
+1 -3
View File
@@ -63,6 +63,4 @@ def write(key: str, data: bytes, ct: str) -> None:
def response(data: bytes, ct: str) -> Response:
return Response(
content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"}
)
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})
+2 -3
View File
@@ -1,4 +1,5 @@
"""Small shared Plex row lookups, so the get-by-rating_key pattern lives in one place."""
from typing import TypeVar
from sqlalchemy import select
@@ -11,6 +12,4 @@ def by_rating_key(db: Session, model: type[_M], rating_key: str) -> _M | None:
"""The mirrored row for a Plex rating_key, or None. `model` is any of the rating_key-keyed Plex
tables (PlexItem / PlexShow / PlexSeason / PlexCollection); the return type is that model, so call
sites keep their `Model | None` static type."""
return db.execute(
select(model).where(model.rating_key == str(rating_key))
).scalars().first()
return db.execute(select(model).where(model.rating_key == str(rating_key))).scalars().first()
+2 -1
View File
@@ -7,6 +7,7 @@ is admin-configured (sysconfig ``plex_path_map``) as newline/comma-separated ``p
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
@@ -33,7 +34,7 @@ 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("/")
rest = plex_path[len(plex_prefix) :].lstrip("/")
return str(Path(local_prefix) / rest) if rest else local_prefix
return plex_path
+3 -1
View File
@@ -19,7 +19,9 @@ PERSON_IMG_HOST = "https://metadata-static.plex.tv/"
def person_thumb_url(thumb: str | None) -> str | None:
"""Proxied /person-image URL for a Plex CDN headshot, or None when it's missing / off-CDN."""
t = str(thumb or "")
return f"/api/plex/person-image?u={quote(t, safe='')}" if t.startswith(PERSON_IMG_HOST) else None
return (
f"/api/plex/person-image?u={quote(t, safe='')}" if t.startswith(PERSON_IMG_HOST) else None
)
def cast_from_meta(meta: dict, limit: int = 24) -> list[dict]:
+5 -7
View File
@@ -104,9 +104,7 @@ def vector_match(model, lang: str, tsq):
by its English title (search_vector_hu has no title tokens for such a row); EN matches only the EN
vector. Ranking still uses `vector_col` (the HU vector in HU mode)."""
if lang == "hu":
return or_(
model.search_vector_hu.op("@@")(tsq), model.search_vector.op("@@")(tsq)
)
return or_(model.search_vector_hu.op("@@")(tsq), model.search_vector.op("@@")(tsq))
return model.search_vector.op("@@")(tsq)
@@ -195,7 +193,9 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list:
for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page (E5 S5)
conds.append(model.keywords.contains([kw]))
csel = split_csv(p.get("collections"))
if csel: # OR (any) or AND (all) — a title can belong to several collections, mirroring genre_mode
if (
csel
): # OR (any) or AND (all) — a title can belong to several collections, mirroring genre_mode
cc = [model.collection_keys.contains([c]) for c in csel]
conds.append(and_(*cc) if p.get("collection_mode") == "all" else or_(*cc))
return conds
@@ -223,9 +223,7 @@ class LibraryFilters:
added_within: str | None = None
directors: str | None = None
actors: str | None = None
actor_mode: str = (
"any" # how multiple actors combine: any (OR, default) | all (AND)
)
actor_mode: str = "any" # how multiple actors combine: any (OR, default) | all (AND)
studios: str | None = None
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
collections: str | None = None # CSV of collection rating_keys
+4 -12
View File
@@ -25,9 +25,7 @@ def rollup(cards: list[dict]) -> dict:
total = len(cards)
watched = sum(1 for c in cards if c.get("status") == "watched")
inprog = [
c
for c in cards
if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched"
c for c in cards if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched"
]
resume = None
if inprog:
@@ -77,15 +75,11 @@ def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
if not show_ids:
return {}
rows = db.execute(
agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))
).all()
rows = db.execute(agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))).all()
return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows}
def item_state_map(
db: Session, user_id: int, item_ids: list[int]
) -> dict[int, PlexState]:
def item_state_map(db: Session, user_id: int, item_ids: list[int]) -> dict[int, PlexState]:
"""A user's PlexState rows for a set of leaf items (movies or episodes), keyed by item_id — the
leaf analogue of `state_map` (which rolls a show's episodes up to a status string). One batched
fetch, so the caller serializes cards without a per-card query. Empty in → empty dict (no `IN ()`)."""
@@ -94,8 +88,6 @@ def item_state_map(
return {
s.item_id: s
for s in db.execute(
select(PlexState).where(
PlexState.user_id == user_id, PlexState.item_id.in_(item_ids)
)
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id.in_(item_ids))
).scalars()
}
+82 -23
View File
@@ -14,6 +14,7 @@ Sessions live in this (single) API process; a periodic reaper kills idle ones an
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 re
import shutil
@@ -31,7 +32,11 @@ 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"
_HLS_ROOT = (
Path(settings.plex_hls_dir)
if settings.plex_hls_dir
else Path(settings.download_root) / ".plex-hls"
)
_SEG_SECONDS = 6
_SESSION_IDLE_S = 600 # reap a session with no access for this long
# A session directory is named `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}` (see start_session);
@@ -66,7 +71,9 @@ _sessions: dict[str, "HlsSession"] = {}
class HlsSession:
def __init__(self, key: str, directory: Path, proc: subprocess.Popen, start_s: float, entry: str):
def __init__(
self, key: str, directory: Path, proc: subprocess.Popen, start_s: float, entry: str
):
self.key = key
self.dir = directory
self.proc = proc
@@ -91,15 +98,21 @@ def _seek_opts(start_s: float) -> list[str]:
_HLS_TAIL = [
"-f", "hls",
"-hls_time", str(_SEG_SECONDS),
"-hls_list_size", "0",
"-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",
"-hls_playlist_type",
"event",
"-hls_segment_type",
"mpegts",
"-hls_flags",
"independent_segments+temp_file",
]
@@ -108,9 +121,21 @@ def _probe_audio_langs(src: Path) -> list[str]:
Used to build the multi-rendition var_stream_map. Empty on probe failure → single-audio path."""
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries", "stream_tags=language",
"-of", "csv=p=0", str(src)],
capture_output=True, text=True, timeout=10,
[
"ffprobe",
"-v",
"error",
"-select_streams",
"a",
"-show_entries",
"stream_tags=language",
"-of",
"csv=p=0",
str(src),
],
capture_output=True,
text=True,
timeout=10,
)
return [ln.strip() for ln in out.stdout.splitlines()]
except (subprocess.SubprocessError, OSError):
@@ -118,8 +143,12 @@ def _probe_audio_langs(src: Path) -> list[str]:
def _ffmpeg_cmd(
src: Path, start_s: float, out_dir: Path, audio_ord: int | None,
aoff: float = 0.0, audio_langs: list[str] | None = None,
src: Path,
start_s: float,
out_dir: Path,
audio_ord: int | None,
aoff: float = 0.0,
audio_langs: list[str] | None = None,
) -> list[str]:
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
@@ -152,15 +181,23 @@ def _ffmpeg_cmd(
entry += ",default:yes"
var.append(entry)
args += _HLS_TAIL + [
"-master_pl_name", "master.m3u8",
"-var_stream_map", " ".join(var),
"-hls_segment_filename", str(out_dir / "seg_%v_%d.ts"), str(out_dir / "stream_%v.m3u8"),
"-master_pl_name",
"master.m3u8",
"-var_stream_map",
" ".join(var),
"-hls_segment_filename",
str(out_dir / "seg_%v_%d.ts"),
str(out_dir / "stream_%v.m3u8"),
]
else:
ao = audio_ord if audio_ord is not None else 0
args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"]
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
args += _HLS_TAIL + ["-hls_segment_filename", str(out_dir / "seg_%d.ts"), str(out_dir / "index.m3u8")]
args += _HLS_TAIL + [
"-hls_segment_filename",
str(out_dir / "seg_%d.ts"),
str(out_dir / "index.m3u8"),
]
return args
@@ -170,9 +207,23 @@ def _probe_first_pts(seg: Path) -> float | None:
zero-bases the timeline to. Returns None on any failure (caller falls back to the requested offset)."""
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0", "-read_intervals", "%+#1",
"-show_entries", "packet=pts_time", "-of", "csv=p=0", str(seg)],
capture_output=True, text=True, timeout=10,
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-read_intervals",
"%+#1",
"-show_entries",
"packet=pts_time",
"-of",
"csv=p=0",
str(seg),
],
capture_output=True,
text=True,
timeout=10,
)
for line in out.stdout.splitlines():
tok = line.strip().strip(",")
@@ -199,7 +250,9 @@ def _enforce_cap(cap: int) -> None:
# Caller holds _lock. Drop the least-recently-accessed sessions over the cap.
if len(_sessions) <= cap:
return
for key, s in sorted(_sessions.items(), key=lambda kv: kv[1].last_access)[: len(_sessions) - cap]:
for key, s in sorted(_sessions.items(), key=lambda kv: kv[1].last_access)[
: len(_sessions) - cap
]:
_kill(s)
_sessions.pop(key, None)
@@ -232,7 +285,9 @@ def start_session(
shutil.rmtree(directory, ignore_errors=True)
directory.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen(
_ffmpeg_cmd(src, start_s, directory, audio_ord, aoff, audio_langs if is_multi else None),
_ffmpeg_cmd(
src, start_s, directory, audio_ord, aoff, audio_langs if is_multi else None
),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
@@ -253,7 +308,11 @@ def start_session(
s.media_start_s = k
log.info(
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s",
key, start_s, s.media_start_s, is_multi, audio_ord,
key,
start_s,
s.media_start_s,
is_multi,
audio_ord,
)
return s
+2 -5
View File
@@ -26,9 +26,7 @@ BITMAP_SUBS = {
def srt_to_vtt(text: str) -> str:
"""Minimal SubRip→WebVTT: add the header and switch the ms separator (`,`→`.`). SRT cue-index
lines are valid in WebVTT, so they're left as-is."""
body = re.sub(
r"(\d\d:\d\d:\d\d),(\d\d\d)", r"\1.\2", text.replace("\r\n", "\n").lstrip("")
)
body = re.sub(r"(\d\d:\d\d:\d\d),(\d\d\d)", r"\1.\2", text.replace("\r\n", "\n").lstrip(""))
return "WEBVTT\n\n" + body.strip() + "\n"
@@ -114,8 +112,7 @@ def ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str:
r = subprocess.run(cmd, capture_output=True)
if r.returncode != 0 or not r.stdout:
raise PlexError(
(r.stderr or b"").decode("utf-8", "replace")[:200]
or "ffmpeg subtitle convert failed"
(r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed"
)
return r.stdout.decode("utf-8", "replace")
+51 -41
View File
@@ -14,6 +14,7 @@ mass-delete is the only real risk). Library-level reconcile (dropping a whole re
dead `enabled` column) is a separate follow-up. Cast + intro/credit markers are fetched lazily on the
item-detail/player path (P2), not here.
"""
import logging
from datetime import datetime, timezone
@@ -154,8 +155,14 @@ def sync(db: Session) -> dict:
if not sysconfig.get_bool(db, "plex_enabled"):
return {"skipped": "disabled"}
stats = {
"libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0,
"collections": 0, "overlaid": 0, "pruned": 0,
"libraries": 0,
"movies": 0,
"shows": 0,
"seasons": 0,
"episodes": 0,
"collections": 0,
"overlaid": 0,
"pruned": 0,
}
wanted = _enabled_section_keys(db)
overlay_map = _overlay_map(db) # {huKey: enKey}
@@ -163,9 +170,7 @@ def sync(db: Session) -> dict:
try:
with PlexClient(db) as plex:
sections = {
str(s.get("key")): s
for s in plex.sections()
if s.get("type") in ("movie", "show")
str(s.get("key")): s for s in plex.sections() if s.get("type") in ("movie", "show")
}
libs_by_key: dict[str, PlexLibrary] = {}
# Pass 1 — browsable (non-overlay) sections. HU-overlay sections are handled in pass 2 and
@@ -196,7 +201,8 @@ def sync(db: Session) -> dict:
if s is None or en_lib is None or s.get("type") != en_lib.kind:
log.warning(
"Plex overlay skipped: hu=%s en=%s (missing section/EN lib or kind mismatch)",
hu_key, en_key,
hu_key,
en_key,
)
continue
lib = _upsert_library(db, hu_key, s.get("title") or hu_key, s.get("type"))
@@ -237,12 +243,16 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT
shared enrich_row_people). Oldest-id first, movies before shows. Stamps people_enriched_at only on
a SUCCESSFUL fetch, so a transient per-item failure retries next run rather than being lost.
Returns how many items were enriched this run."""
movies = db.execute(
select(PlexItem)
.where(PlexItem.kind == "movie", PlexItem.people_enriched_at.is_(None))
.order_by(PlexItem.id)
.limit(limit)
).scalars().all()
movies = (
db.execute(
select(PlexItem)
.where(PlexItem.kind == "movie", PlexItem.people_enriched_at.is_(None))
.order_by(PlexItem.id)
.limit(limit)
)
.scalars()
.all()
)
remaining = limit - len(movies)
shows = (
db.execute(
@@ -250,7 +260,9 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT
.where(PlexShow.people_enriched_at.is_(None))
.order_by(PlexShow.id)
.limit(remaining)
).scalars().all()
)
.scalars()
.all()
if remaining > 0
else []
)
@@ -263,7 +275,9 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT
log.debug("Plex people enrich fetch failed for %s: %s", row.rating_key, e)
continue # leave people_enriched_at NULL so it's retried on a later run
people.enrich_row_people(db, row, people.cast_from_meta(meta))
detail.apply_rich_detail(row, meta) # imdb/tagline/full_cast → served from the row, not live
detail.apply_rich_detail(
row, meta
) # imdb/tagline/full_cast → served from the row, not live
row.people_enriched_at = now
n += 1
db.commit()
@@ -271,9 +285,7 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT
def _upsert_library(db: Session, key: str, title: str, kind: str) -> PlexLibrary:
lib = db.execute(
select(PlexLibrary).where(PlexLibrary.plex_key == key)
).scalars().first()
lib = db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == key)).scalars().first()
if lib is None:
lib = PlexLibrary(plex_key=key)
db.add(lib)
@@ -319,7 +331,7 @@ def _apply_facet_fields(row, meta: dict) -> None:
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.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")
@@ -363,7 +375,9 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
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"))
row.playable = classify_playable(
mf.get("codec_video"), mf.get("codec_audio"), mf.get("container")
)
def _prune_stale(
@@ -411,9 +425,7 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
# 1) shows
shows = {
r.rating_key: r
for r in db.execute(
select(PlexShow).where(PlexShow.library_id == lib.id)
).scalars()
for r in db.execute(select(PlexShow).where(PlexShow.library_id == lib.id)).scalars()
}
shows_seen: set[str] = set()
shows_progress: dict = {}
@@ -427,7 +439,9 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
db.add(sh)
shows[rk] = sh
sh.library_id = lib.id
sh.guid = _canonical_guid(meta) or sh.guid # EN<->HU pairing key; keep prior if listing has none
sh.guid = (
_canonical_guid(meta) or sh.guid
) # EN<->HU pairing key; keep prior if listing has none
sh.title = (meta.get("title") or "").strip() or "Untitled"
sh.summary = meta.get("summary")
sh.year = meta.get("year")
@@ -495,7 +509,9 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
# Prune leaf-first (episodes -> seasons -> shows) so a stale row is deleted directly, before its
# parent's cascade would reach it — avoids deleting the same ORM object twice. Each level is
# independently guarded on a non-empty listing by _prune_stale.
_prune_stale(db, episodes, episodes_seen, stats, complete=episodes_progress.get("complete", False))
_prune_stale(
db, episodes, episodes_seen, stats, complete=episodes_progress.get("complete", False)
)
_prune_stale(db, seasons, seasons_seen, stats, complete=seasons_progress.get("complete", False))
_prune_stale(db, shows, shows_seen, stats, complete=shows_progress.get("complete", False))
@@ -521,9 +537,7 @@ def _sync_overlay(
item_type = _TYPE_MOVIE
else:
rows = db.execute(
select(PlexShow).where(
PlexShow.library_id == en_lib_id, PlexShow.guid.is_not(None)
)
select(PlexShow).where(PlexShow.library_id == en_lib_id, PlexShow.guid.is_not(None))
).scalars()
item_type = _TYPE_SHOW
# guid -> the EN rows carrying it (a list: two editions of one film can share a guid; both overlay).
@@ -535,7 +549,9 @@ def _sync_overlay(
for group in by_guid.values():
for row in group:
row.title_hu = row.summary_hu = row.genres_hu = None
seen: set[str] = set() # first HU entry for a guid wins; a dup HU item can't overwrite or double-count
seen: set[str] = (
set()
) # first HU entry for a guid wins; a dup HU item can't overwrite or double-count
for meta in _paginate(plex, lib.plex_key, item_type):
g = _canonical_guid(meta)
targets = by_guid.get(g) if g else None
@@ -576,22 +592,18 @@ def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str)
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, by_rating_key(db, PlexCollection, rk), rk, lib.id, meta
)
col = _upsert_collection(db, by_rating_key(db, PlexCollection, rk), rk, lib.id, meta)
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.execute(
select(Model).where(Model.collection_keys.contains([rk]))
).scalars().all()
new_members = {
str(c.get("ratingKey")) for c in plex.collection_children(rk) if c.get("ratingKey")
}
current = db.execute(select(Model).where(Model.collection_keys.contains([rk]))).scalars().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.execute(
select(Model).where(Model.rating_key.in_(to_add))
).scalars():
for row in db.execute(select(Model).where(Model.rating_key.in_(to_add))).scalars():
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), rk]))
db.flush()
return col
@@ -600,9 +612,7 @@ def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str)
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.execute(
select(Model).where(Model.collection_keys.contains([rk]))
).scalars():
for row in db.execute(select(Model).where(Model.collection_keys.contains([rk]))).scalars():
row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None
col = by_rating_key(db, PlexCollection, rk)
if col is not None:
+21 -4
View File
@@ -9,6 +9,7 @@ Plex-sourced `imdb_rating` / `rating` are left untouched; `tmdb_rating` is the d
One-parser rule (mirrors app/plex/detail.py): `parse_tmdb` is the pure parser, `apply_tmdb_detail` writes
the row. The enrich batch + scheduler entry live here too so the whole TMDB integration is one module.
"""
import logging
import time
from datetime import datetime, timezone
@@ -184,14 +185,24 @@ def parse_tmdb(payload: dict, media_type: str) -> dict:
for c in payload.get("created_by") or []:
if c.get("name"):
crew.append(
{"name": c["name"], "job": "Creator", "tmdb_id": c.get("id"), "profile_path": c.get("profile_path")}
{
"name": c["name"],
"job": "Creator",
"tmdb_id": c.get("id"),
"profile_path": c.get("profile_path"),
}
)
for c in credits.get("crew") or []:
# Movie crew entries carry `job`; TV aggregate_credits carry a `jobs` array per person.
job = (c.get("jobs") or [{}])[0].get("job") if is_tv else c.get("job")
if job in priority and c.get("name"):
crew.append(
{"name": c["name"], "job": job, "tmdb_id": c.get("id"), "profile_path": c.get("profile_path")}
{
"name": c["name"],
"job": job,
"tmdb_id": c.get("id"),
"profile_path": c.get("profile_path"),
}
)
# Dedup by (name, job) — a person can hold two distinct key roles (Coppola: Director + Writer) — then
# order by display priority so the lead roles come first, and cap.
@@ -235,7 +246,9 @@ def parse_tmdb(payload: dict, media_type: str) -> dict:
"revenue": (payload.get("revenue") or None) if not is_tv else None,
"keywords": keywords or None,
"crew": crew or None,
"studios": [c.get("name") for c in (payload.get("production_companies") or []) if c.get("name")][:8]
"studios": [
c.get("name") for c in (payload.get("production_companies") or []) if c.get("name")
][:8]
or None,
"production_countries": [
c.get("name") for c in (payload.get("production_countries") or []) if c.get("name")
@@ -274,7 +287,11 @@ def _link_people(db: Session, links: dict[str, int]) -> None:
db.execute(
stmt.on_conflict_do_update(
index_elements=[PlexPerson.name],
set_={"tmdb_person_id": func.coalesce(PlexPerson.tmdb_person_id, stmt.excluded.tmdb_person_id)},
set_={
"tmdb_person_id": func.coalesce(
PlexPerson.tmdb_person_id, stmt.excluded.tmdb_person_id
)
},
)
)
+57 -34
View File
@@ -13,6 +13,7 @@ exist only in Siftlode are left untouched (union, Plex-authoritative on the inte
Phases B (Siftlode→Plex push via scrobble/timeline) and C (incremental Plex→Siftlode via the watch
history + onDeck, last-write-wins) build on this and land in later ships.
"""
import logging
from datetime import datetime, timedelta, timezone
@@ -61,10 +62,7 @@ def _plex_watch_to_state(
def _rk_to_id(db: Session) -> dict[str, int]:
"""The rating_key → PlexItem.id lookup used to resolve a Plex leaf back to its mirrored row."""
return {
rk: iid
for rk, iid in db.execute(select(PlexItem.rating_key, PlexItem.id)).all()
}
return {rk: iid for rk, iid in db.execute(select(PlexItem.rating_key, PlexItem.id)).all()}
def _scan_plex_states(
@@ -165,9 +163,7 @@ def link_for_push(db: Session, user_id: int) -> PlexLink | None:
"""The active owner watch-push link for this user, or None when push is off / not the owner.
Push is gated on `initial_import_done` so we never push a half-known local state over Plex's
authoritative one before the one-time "Plex is master" import has reconciled them."""
link = db.execute(
select(PlexLink).where(PlexLink.user_id == user_id)
).scalars().first()
link = db.execute(select(PlexLink).where(PlexLink.user_id == user_id)).scalars().first()
if link and link.uses_admin and link.sync_enabled and link.initial_import_done:
return link
return None
@@ -222,11 +218,13 @@ def push_state_to_plex(
# Flag the row synced — but an "unwatched" via status=new deletes the row, so there may be
# nothing to flag; that's fine (unscrobble still happened). Skip the flag if the row was
# re-edited since we captured `expect_ts` (leave it dirty for its own push / the reconcile).
st = db.execute(
select(PlexState).where(
PlexState.user_id == user_id, PlexState.item_id == item_id
st = (
db.execute(
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
)
).scalars().first()
.scalars()
.first()
)
if st is not None and _row_matches(st, expect_ts):
st.synced_to_plex = True
db.commit()
@@ -270,7 +268,11 @@ def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) -
# One bad item mustn't abort the rest of the batch; leave its row dirty
# (synced_to_plex=False) for a later reconcile.
log.warning(
"Plex bulk push failed (user %s, item %s, %s): %s", user_id, item_id, action, e
"Plex bulk push failed (user %s, item %s, %s): %s",
user_id,
item_id,
action,
e,
)
continue
synced_ids.append(item_id)
@@ -301,13 +303,17 @@ _LWW_TOLERANCE_S = 90
def _active_sync_links(db: Session) -> list[PlexLink]:
"""Every link with two-way sync actually running (owner MVP = one row). Iterated uniformly so
P5b friend links slot in later without touching this loop."""
return db.execute(
select(PlexLink).where(
PlexLink.uses_admin.is_(True),
PlexLink.sync_enabled.is_(True),
PlexLink.initial_import_done.is_(True),
return (
db.execute(
select(PlexLink).where(
PlexLink.uses_admin.is_(True),
PlexLink.sync_enabled.is_(True),
PlexLink.initial_import_done.is_(True),
)
)
).scalars().all()
.scalars()
.all()
)
def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int:
@@ -353,11 +359,13 @@ def _pull_apply(
"""Bring one Plex-side positive signal into Siftlode under last-write-wins. Returns
"watched"/"in_progress" if it changed the row, else None. Never clears a row (un-watch is the
full reconcile's job) — this only pulls in watched/resume."""
st = db.execute(
select(PlexState).where(
PlexState.user_id == user_id, PlexState.item_id == item_id
st = (
db.execute(
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
)
).scalars().first()
.scalars()
.first()
)
if _same_state(st, status, pos):
if st is not None and not st.synced_to_plex:
st.synced_to_plex = True # already matches Plex → just settle the flag, no churn
@@ -370,7 +378,12 @@ def _pull_apply(
st = PlexState(user_id=user_id, item_id=item_id)
db.add(st)
if status == "watched":
st.status, st.position_seconds, st.watched_at, st.progress_updated_at = "watched", 0, plex_ts, plex_ts
st.status, st.position_seconds, st.watched_at, st.progress_updated_at = (
"watched",
0,
plex_ts,
plex_ts,
)
else:
st.status, st.position_seconds, st.progress_updated_at = "new", pos, plex_ts
st.synced_to_plex = True
@@ -390,13 +403,17 @@ def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int:
select(PlexItem.id, PlexItem.rating_key, PlexItem.duration_s)
).all()
}
dirty = db.execute(
select(PlexState).where(
PlexState.user_id == link.user_id,
PlexState.synced_to_plex.is_(False),
PlexState.status != "hidden",
dirty = (
db.execute(
select(PlexState).where(
PlexState.user_id == link.user_id,
PlexState.synced_to_plex.is_(False),
PlexState.status != "hidden",
)
)
).scalars().all()
.scalars()
.all()
)
n = 0
for st in dirty:
info = info_by_id.get(st.item_id)
@@ -440,7 +457,9 @@ def run_plex_watch_sync(db: Session) -> dict:
iid = rk_to_id.get(str(row.get("ratingKey") or ""))
if iid is None:
continue
ch = _pull_apply(db, link.user_id, iid, "watched", 0, _epoch(row.get("viewedAt")))
ch = _pull_apply(
db, link.user_id, iid, "watched", 0, _epoch(row.get("viewedAt"))
)
if ch:
total[ch] += 1
@@ -451,7 +470,9 @@ def run_plex_watch_sync(db: Session) -> dict:
off = int(row.get("viewOffset") or 0) // 1000
if off < _PROGRESS_MIN_S:
continue
ch = _pull_apply(db, link.user_id, iid, "new", off, _epoch(row.get("lastViewedAt")))
ch = _pull_apply(
db, link.user_id, iid, "new", off, _epoch(row.get("lastViewedAt"))
)
if ch:
total[ch] += 1
@@ -495,9 +516,11 @@ def run_plex_watch_reconcile(db: Session) -> dict:
# Siftlode rows Plex no longer has a record for: clear the ones that were mirrored
# (Plex un-watched them); leave hidden + dirty (dirty gets re-pushed just below).
for st in db.execute(
select(PlexState).where(PlexState.user_id == link.user_id)
).scalars().all():
for st in (
db.execute(select(PlexState).where(PlexState.user_id == link.user_id))
.scalars()
.all()
):
if st.item_id in plex_states or st.status == "hidden":
continue
if st.synced_to_plex: