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:
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user