The mixed /trending/all chart is movie-heavy — TV was nearly absent (only 1 of the top 10 was a show). Split it like Netflix: 'Top 10 today' now holds two ranked rails, Movies and TV Shows, from the dedicated /trending/movie/day and /trending/tv/day charts (full 10 of each). - client.trending(window, kind): single-type charts force the kind into _disc_map (those endpoints omit media_type). - engine.trending(window, kind): kind in the cache key. - /discover/list: key=trending_movies_today / trending_tv_today, both treated as single un-paged charts; inherit the same hide-owned + ranked rendering. - frontend: one 'Top 10 today' group, two ranked shelves titled Movies / TV Shows (group header carries the 'Top 10' framing).
691 lines
32 KiB
Python
691 lines
32 KiB
Python
"""Video enrichment engine — owns the per-source workers (registry).
|
|
|
|
Parallels music's enrichment registry but is isolated to the video side. Built
|
|
lazily as a process-wide singleton; starts the workers (each idles until its API
|
|
key is configured). Imports only video.db + this package.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
|
|
from utils.logging_config import get_logger
|
|
|
|
from .cache import TTLCache
|
|
from .worker import VideoEnrichmentWorker
|
|
|
|
logger = get_logger("video_enrichment.engine")
|
|
|
|
_DISPLAY = {"tmdb": "TMDB", "tvdb": "TVDB", "omdb": "OMDb"}
|
|
|
|
|
|
class VideoEnrichmentEngine:
|
|
def __init__(self, db, clients: dict, ratings_client=None):
|
|
self.db = db
|
|
self.workers = {
|
|
service: VideoEnrichmentWorker(db, service, client, display_name=_DISPLAY.get(service))
|
|
for service, client in clients.items()
|
|
}
|
|
# Backfill workers (artwork / subtitles / no-key YouTube extras). Same
|
|
# lifecycle + get_stats() shape, so the registry/API/UI drive them too.
|
|
try:
|
|
from .backfill import build_backfill_workers
|
|
self.workers.update(build_backfill_workers(db))
|
|
except Exception:
|
|
logger.exception("video enrichment: backfill workers unavailable")
|
|
# OMDb ratings (IMDb/RT/Metacritic) — not a matcher, so not a worker;
|
|
# backfilled on the lazy detail refresh.
|
|
self.ratings_client = ratings_client
|
|
# Thread-safe TTL+LRU cache for live TMDB detail extras / preview payloads /
|
|
# person pages / seasons / trending, so re-opening a title is instant
|
|
# instead of re-hitting TMDB. (Volatile by design — durable art/episodes/
|
|
# ratings live in video.db; we don't persist this tier.)
|
|
self._cache = TTLCache(maxsize=256, ttl=1800)
|
|
# Restore each worker's persisted pause state (survives restart).
|
|
for w in self.workers.values():
|
|
w.restore_paused()
|
|
|
|
def _region(self):
|
|
try:
|
|
return (self.db.get_setting("watch_region") or "US").upper()
|
|
except Exception:
|
|
return "US"
|
|
|
|
def _cache_get(self, key):
|
|
return self._cache.get(key)
|
|
|
|
def _cache_put(self, key, data, ttl=1800):
|
|
self._cache.put(key, data, ttl=ttl)
|
|
|
|
def _backfill_ratings(self, kind, item_id):
|
|
# The OMDb worker owns the ratings client (fallback to an injected one
|
|
# for tests that don't build a worker).
|
|
w = self.workers.get("omdb")
|
|
rc = w.client if w else self.ratings_client
|
|
if not rc or not getattr(rc, "enabled", False):
|
|
return
|
|
info = (self.db.movie_match_info(item_id) if kind == "movie"
|
|
else self.db.show_match_info(item_id))
|
|
# IMDb id lives on the row — fetch it directly.
|
|
row = None
|
|
try:
|
|
with self.db.connect() as c:
|
|
tbl = "movies" if kind == "movie" else "shows"
|
|
row = c.execute(f"SELECT imdb_id FROM {tbl} WHERE id=?", (item_id,)).fetchone()
|
|
except Exception:
|
|
return
|
|
imdb_id = row["imdb_id"] if row else None
|
|
if not imdb_id:
|
|
return
|
|
try:
|
|
ratings = rc.ratings(imdb_id)
|
|
if ratings:
|
|
self.db.apply_ratings(kind, item_id, ratings)
|
|
except Exception:
|
|
logger.exception("ratings backfill failed for %s %s", kind, item_id)
|
|
|
|
def start_all(self):
|
|
for w in self.workers.values():
|
|
w.start()
|
|
|
|
def stop_all(self):
|
|
for w in self.workers.values():
|
|
w.stop()
|
|
|
|
# ── scan coupling ─────────────────────────────────────────────────────────
|
|
# While a library scan runs, the enrichment workers step aside to cut DB lock
|
|
# contention — exactly like the music side. We pause ONLY workers that were
|
|
# actually running (never a user's manual pause) and remember which, so the
|
|
# post-scan resume can't un-pause something the user deliberately paused. The
|
|
# auto-pause is transient (persist=False) so it never leaks into the saved
|
|
# <service>_paused flag and survives a restart as a "real" pause.
|
|
def pause_for_scan(self) -> set:
|
|
self._scan_paused = set()
|
|
for service, w in self.workers.items():
|
|
if not w.paused:
|
|
w.pause(persist=False)
|
|
self._scan_paused.add(service)
|
|
# The YouTube date enricher is a separate singleton (not in self.workers) —
|
|
# pause it too so a scan stops EVERY enricher, not just the matcher/backfill
|
|
# workers. Only if it wasn't already paused (never override a manual pause).
|
|
self._scan_paused_yt = False
|
|
try:
|
|
from core.video.youtube_enrichment import get_youtube_date_enricher
|
|
yt = get_youtube_date_enricher()
|
|
if yt and not getattr(yt, "_paused", False):
|
|
yt.pause()
|
|
self._scan_paused_yt = True
|
|
self._scan_paused.add("youtube")
|
|
except Exception:
|
|
logger.debug("video enrichment: could not pause YouTube date enricher for scan", exc_info=True)
|
|
if self._scan_paused:
|
|
logger.info("video enrichment: paused %s for library scan",
|
|
", ".join(sorted(self._scan_paused)))
|
|
return self._scan_paused
|
|
|
|
def resume_after_scan(self) -> None:
|
|
for service in getattr(self, "_scan_paused", set()):
|
|
w = self.workers.get(service)
|
|
if w:
|
|
w.resume(persist=False)
|
|
if getattr(self, "_scan_paused_yt", False):
|
|
try:
|
|
from core.video.youtube_enrichment import get_youtube_date_enricher
|
|
yt = get_youtube_date_enricher()
|
|
if yt:
|
|
yt.resume()
|
|
except Exception:
|
|
logger.debug("video enrichment: could not resume YouTube date enricher", exc_info=True)
|
|
self._scan_paused_yt = False
|
|
if getattr(self, "_scan_paused", None):
|
|
logger.info("video enrichment: resumed %s after library scan",
|
|
", ".join(sorted(self._scan_paused)))
|
|
self._scan_paused = set()
|
|
|
|
def refresh_show_art(self, show_id) -> dict:
|
|
"""On-demand (lazy) backfill of a show's season posters + episode art from
|
|
TMDB, used when the detail page is opened and art is missing. Works
|
|
regardless of the show's match status (sidesteps 'already matched, never
|
|
re-runs'), and caches the result so it's a one-time cost per show."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled:
|
|
return {"ok": False, "reason": "tmdb_not_configured"}
|
|
info = self.db.show_match_info(show_id)
|
|
if not info:
|
|
return {"ok": False, "reason": "not_found"}
|
|
try:
|
|
result = w.client.match("show", info.get("title"), info.get("year"),
|
|
known_id=info.get("tmdb_id"))
|
|
except Exception:
|
|
logger.exception("refresh_show_art: match failed for show %s", show_id)
|
|
return {"ok": False, "reason": "match_error"}
|
|
if not result or not result.get("id"):
|
|
return {"ok": False, "reason": "no_match"}
|
|
# Backfills season posters + show metadata gaps (never clobbers).
|
|
self.db.enrichment_apply("tmdb", "show", show_id, matched=True,
|
|
external_id=result["id"], metadata=result.get("metadata"))
|
|
try:
|
|
nums = [s["season_number"] for s in (result.get("metadata") or {}).get("seasons") or []]
|
|
w._cascade_episodes(show_id, result["id"], nums) # full list: owned + missing
|
|
except Exception:
|
|
logger.exception("refresh_show_art: episode cascade failed for show %s", show_id)
|
|
self._backfill_ratings("show", show_id)
|
|
return {"ok": True}
|
|
|
|
def refresh_movie_art(self, movie_id) -> dict:
|
|
"""On-demand (lazy) backfill of a movie's cast / genres / backdrop / ratings
|
|
from TMDB when the detail page is opened and they're missing. Works
|
|
regardless of match status; caches the result."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled:
|
|
return {"ok": False, "reason": "tmdb_not_configured"}
|
|
info = self.db.movie_match_info(movie_id)
|
|
if not info:
|
|
return {"ok": False, "reason": "not_found"}
|
|
try:
|
|
result = w.client.match("movie", info.get("title"), info.get("year"),
|
|
known_id=info.get("tmdb_id"))
|
|
except Exception:
|
|
logger.exception("refresh_movie_art: match failed for movie %s", movie_id)
|
|
return {"ok": False, "reason": "match_error"}
|
|
if not result or not result.get("id"):
|
|
return {"ok": False, "reason": "no_match"}
|
|
self.db.enrichment_apply("tmdb", "movie", movie_id, matched=True,
|
|
external_id=result["id"], metadata=result.get("metadata"))
|
|
self._backfill_ratings("movie", movie_id)
|
|
return {"ok": True}
|
|
|
|
def item_extras(self, kind, item_id) -> dict:
|
|
"""Live TMDB extras (trailer / where-to-watch / similar) for the detail
|
|
page. Not cached — fetched per view so providers stay current. For an
|
|
owned item we also surface a 'watch on your server' deep link as the first
|
|
where-to-watch option."""
|
|
out = {}
|
|
w = self.workers.get("tmdb")
|
|
if w and w.enabled:
|
|
info = (self.db.movie_match_info(item_id) if kind == "movie"
|
|
else self.db.show_match_info(item_id))
|
|
if info and info.get("tmdb_id"):
|
|
region = self._region()
|
|
key = ("extras", kind, info["tmdb_id"], region)
|
|
cached = self._cache_get(key)
|
|
if cached is None:
|
|
try:
|
|
cached = w.client.extras(kind, info["tmdb_id"], region=region) or {}
|
|
self._cache_put(key, cached)
|
|
except Exception:
|
|
logger.exception("item_extras failed for %s %s", kind, item_id)
|
|
cached = {}
|
|
out = dict(cached) # copy — the per-item server link isn't cached
|
|
srv = self._server_watch_link(kind, item_id)
|
|
if srv:
|
|
out["server"] = srv
|
|
return out
|
|
|
|
def _server_watch_link(self, kind, item_id) -> dict | None:
|
|
"""A 'play on your media server' deep link for an owned item, or None.
|
|
Plex → the Plex web app at the item; Jellyfin → its web detail page."""
|
|
table = "movies" if kind == "movie" else "shows"
|
|
try:
|
|
with self.db.connect() as c:
|
|
row = c.execute(
|
|
f"SELECT server_source, server_id FROM {table} WHERE id=?", (item_id,)).fetchone()
|
|
except Exception:
|
|
return None
|
|
if not row:
|
|
return None
|
|
source, sid = row["server_source"], row["server_id"]
|
|
if not source or not sid:
|
|
return None # not on a server (e.g. a wishlist row)
|
|
try:
|
|
# Use the VIDEO side's effective connection (its own creds, or inherited
|
|
# from music) — the item lives on the server the video side scanned.
|
|
from core.video.sources import video_plex_config, video_jellyfin_config
|
|
if source == "plex":
|
|
cfg = video_plex_config(self.db)
|
|
base, token = cfg.get("base_url"), cfg.get("token")
|
|
if not base or not token:
|
|
return None
|
|
mid = self._plex_machine_id(base, token)
|
|
if not mid:
|
|
return None
|
|
from urllib.parse import quote
|
|
key = quote("/library/metadata/" + str(sid), safe="")
|
|
return {"server": "Plex",
|
|
"url": "https://app.plex.tv/desktop/#!/server/%s/details?key=%s" % (mid, key)}
|
|
if source == "jellyfin":
|
|
cfg = video_jellyfin_config(self.db)
|
|
base = cfg.get("base_url")
|
|
if not base:
|
|
return None
|
|
return {"server": "Jellyfin",
|
|
"url": base.rstrip("/") + "/web/index.html#!/details?id=" + str(sid)}
|
|
except Exception:
|
|
logger.exception("server watch link failed for %s %s", kind, item_id)
|
|
return None
|
|
|
|
def _plex_machine_id(self, base, token):
|
|
"""The Plex server's machineIdentifier (needed for app.plex.tv deep links),
|
|
fetched once and cached per base URL."""
|
|
cached = getattr(self, "_plex_mid", None)
|
|
if cached and cached[0] == base:
|
|
return cached[1]
|
|
try:
|
|
import requests
|
|
r = requests.get(base.rstrip("/") + "/identity",
|
|
params={"X-Plex-Token": token},
|
|
headers={"Accept": "application/json"}, timeout=8)
|
|
mid = None
|
|
try:
|
|
mid = ((r.json() or {}).get("MediaContainer") or {}).get("machineIdentifier")
|
|
except Exception:
|
|
import re
|
|
m = re.search(r'machineIdentifier="([^"]+)"', r.text or "")
|
|
mid = m.group(1) if m else None
|
|
if mid:
|
|
self._plex_mid = (base, mid)
|
|
return mid
|
|
except Exception:
|
|
logger.exception("plex identity fetch failed")
|
|
return None
|
|
|
|
# ── in-app search + TMDB-backed (un-owned) detail ─────────────────────────
|
|
def search(self, query) -> list:
|
|
"""Multi-search via TMDB, each movie/show annotated with the library row id
|
|
if it's already owned (so the UI links to the owned detail, not the tmdb
|
|
view)."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled:
|
|
return []
|
|
# Short TTL — identical queries within ~a minute reuse the result; ownership
|
|
# is re-stamped fresh below so 'In Library' badges stay current.
|
|
key = ("search", (query or "").strip().lower())
|
|
results = self._cache_get(key)
|
|
if results is None:
|
|
try:
|
|
results = w.client.search(query) or []
|
|
self._cache_put(key, results, ttl=60)
|
|
except Exception:
|
|
logger.exception("video search failed for %r", query)
|
|
return []
|
|
return self._stamp_owned(results)
|
|
|
|
def trending(self, window="week", kind=None) -> list:
|
|
"""Trending titles, annotated owned/not. ``window='day'`` is the real-time
|
|
daily chart (the iconic 'Top 10 today' rows); 'week' is the steadier hero/idle
|
|
feed. ``kind`` None = mixed; 'movie'/'show' = the dedicated single-type charts
|
|
(split 'Top 10 Movies / TV Shows Today'). Order is preserved for rank numbers."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled:
|
|
return []
|
|
window = "day" if window == "day" else "week"
|
|
kind = kind if kind in ("movie", "show") else None
|
|
ck = ("trending", window, kind)
|
|
cached = self._cache_get(ck)
|
|
if cached is None:
|
|
try:
|
|
cached = w.client.trending(window=window, kind=kind) or []
|
|
self._cache_put(ck, cached, ttl=3600)
|
|
except Exception:
|
|
logger.exception("video trending failed")
|
|
return []
|
|
# Re-annotate ownership fresh each call (batched) so it tracks the library.
|
|
return self._stamp_owned(cached)
|
|
|
|
# ── discover (browse TMDB lists; owned titles annotated) ──────────────────
|
|
def _stamp_owned(self, items):
|
|
"""Annotate each movie/show with its library_id for the active server —
|
|
stamped fresh (not cached with ownership) so 'In Library' tracks scans.
|
|
Batched: one query per kind for the whole list, not one per item."""
|
|
srv = self._server()
|
|
by_kind: dict = {}
|
|
for r in items or []:
|
|
if r.get("kind") in ("movie", "show") and r.get("tmdb_id"):
|
|
by_kind.setdefault(r["kind"], []).append(r["tmdb_id"])
|
|
maps = {k: self.db.library_ids_for_tmdb(k, ids, srv) for k, ids in by_kind.items()}
|
|
for r in items or []:
|
|
if r.get("kind") in ("movie", "show") and r.get("tmdb_id"):
|
|
try:
|
|
r["library_id"] = maps.get(r["kind"], {}).get(int(r["tmdb_id"]))
|
|
except (TypeError, ValueError):
|
|
r["library_id"] = None
|
|
# Drop titles the user marked 'Not interested' — one tiny indexed query, always
|
|
# fresh, so every discover surface (rails, recs, collection gaps) hides them uniformly.
|
|
try:
|
|
ignored = self.db.ignored_keys()
|
|
except Exception:
|
|
ignored = None
|
|
if ignored:
|
|
items = [r for r in (items or [])
|
|
if f"{r.get('kind')}:{r.get('tmdb_id')}" not in ignored]
|
|
return items
|
|
|
|
def discover_curated(self, key, page=1) -> list:
|
|
"""A canned TMDB list (popular / top-rated / now-playing / upcoming /
|
|
on-the-air / airing-today), cached then owned-annotated."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled:
|
|
return []
|
|
ck = ("disc-cur", key, page)
|
|
items = self._cache_get(ck)
|
|
if items is None:
|
|
try:
|
|
items = w.client.curated(key, page=page) or []
|
|
self._cache_put(ck, items, ttl=3600)
|
|
except Exception:
|
|
logger.exception("discover curated failed (%s p%s)", key, page)
|
|
return []
|
|
return self._stamp_owned(items)
|
|
|
|
def discover_filter(self, kind, *, genre=None, year=None, decade=None, providers=None,
|
|
sort_by="popularity.desc", page=1, language=None,
|
|
keywords=None, companies=None, networks=None, cast=None, crew=None,
|
|
min_runtime=None, max_runtime=None, certification=None,
|
|
vote_count_min=None, release_window=None) -> list:
|
|
"""Browse /discover filtered by genre / year / decade / streaming provider /
|
|
original language — plus the Netflix-class extensions (keywords/mood,
|
|
companies/studio, networks, cast/crew, runtime, certification, release window).
|
|
Cached + owned-annotated; all extension params are optional/additive."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled:
|
|
return []
|
|
if kind not in ("movie", "show"):
|
|
kind = "movie"
|
|
ck = ("disc-flt", kind, genre, year, decade, providers, sort_by, page, language,
|
|
keywords, companies, networks, cast, crew, min_runtime, max_runtime,
|
|
certification, vote_count_min, release_window)
|
|
items = self._cache_get(ck)
|
|
if items is None:
|
|
try:
|
|
items = w.client.discover(
|
|
kind, genre=genre, year=year, decade=decade, providers=providers,
|
|
sort_by=sort_by, page=page, region=self._region(), language=language,
|
|
keywords=keywords, companies=companies, networks=networks, cast=cast,
|
|
crew=crew, min_runtime=min_runtime, max_runtime=max_runtime,
|
|
certification=certification, vote_count_min=vote_count_min,
|
|
release_window=release_window) or []
|
|
self._cache_put(ck, items, ttl=3600)
|
|
except Exception:
|
|
logger.exception("discover filter failed (%s g=%s y=%s d=%s p=%s)",
|
|
kind, genre, year, decade, providers)
|
|
return []
|
|
return self._stamp_owned(items)
|
|
|
|
def genre_list(self, kind) -> list:
|
|
"""TMDB genre id→name list (long-cached — these barely change)."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled:
|
|
return []
|
|
if kind not in ("movie", "show"):
|
|
kind = "movie"
|
|
ck = ("genres", kind)
|
|
items = self._cache_get(ck)
|
|
if items is None:
|
|
try:
|
|
items = w.client.genres(kind) or []
|
|
self._cache_put(ck, items, ttl=86400)
|
|
except Exception:
|
|
logger.exception("genre list failed (%s)", kind)
|
|
return []
|
|
return items
|
|
|
|
def recommendations(self, kind, tmdb_id, page=1) -> list:
|
|
"""'More like this' titles for a tmdb id — cached + owned-annotated."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled or not tmdb_id:
|
|
return []
|
|
ck = ("recs", kind, tmdb_id, page)
|
|
items = self._cache_get(ck)
|
|
if items is None:
|
|
try:
|
|
items = w.client.recommendations(kind, tmdb_id, page=page) or []
|
|
self._cache_put(ck, items, ttl=3600)
|
|
except Exception:
|
|
logger.exception("recommendations failed (%s %s)", kind, tmdb_id)
|
|
return []
|
|
return self._stamp_owned(items)
|
|
|
|
def collection(self, collection_id) -> list:
|
|
"""The films of a TMDB collection (franchise) — cached + owned-annotated.
|
|
Drives the 'complete your collections' gap rails."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled or not collection_id:
|
|
return []
|
|
ck = ("collection", collection_id)
|
|
items = self._cache_get(ck)
|
|
if items is None:
|
|
try:
|
|
items = w.client.collection(collection_id) or []
|
|
self._cache_put(ck, items, ttl=86400) # franchises rarely change
|
|
except Exception:
|
|
logger.exception("collection failed (%s)", collection_id)
|
|
return []
|
|
return self._stamp_owned(items)
|
|
|
|
def movie_collection(self, tmdb_id) -> dict | None:
|
|
"""A movie's TMDB franchise membership {id, name} (belongs_to_collection), or
|
|
{id: None} when it belongs to no collection. Used to backfill already-matched
|
|
movies that predate the collection column."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled or not tmdb_id:
|
|
return None
|
|
try:
|
|
meta = w.client.match("movie", None, None, known_id=tmdb_id) or {}
|
|
return {"id": meta.get("tmdb_collection_id"), "name": meta.get("tmdb_collection_name")}
|
|
except Exception:
|
|
logger.exception("movie_collection backfill failed for %s", tmdb_id)
|
|
return None
|
|
|
|
def trailer(self, kind, tmdb_id) -> dict | None:
|
|
"""Best YouTube trailer for a title (cached a day — trailers don't move)."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled or not tmdb_id:
|
|
return None
|
|
ck = ("trailer", kind, tmdb_id)
|
|
cached = self._cache_get(ck)
|
|
if cached is None:
|
|
try:
|
|
cached = w.client.video_trailer(kind, tmdb_id) or {}
|
|
self._cache_put(ck, cached, ttl=86400)
|
|
except Exception:
|
|
logger.exception("trailer failed (%s %s)", kind, tmdb_id)
|
|
return None
|
|
return cached or None
|
|
|
|
def tmdb_full_detail(self, kind, tmdb_id) -> dict | None:
|
|
"""Raw TMDB full detail (absolute image URLs + metadata) WITHOUT the
|
|
owned→library redirect — for sidecar / NFO writing, which needs the data even
|
|
for titles already in the library."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled:
|
|
return None
|
|
try:
|
|
return w.client.full_detail(kind, tmdb_id, region=self._region())
|
|
except Exception:
|
|
logger.exception("tmdb_full_detail failed for %s %s", kind, tmdb_id)
|
|
return None
|
|
|
|
def tmdb_detail(self, kind, tmdb_id) -> dict | None:
|
|
"""Full detail for a TMDB title not in the library — same shape as the
|
|
library detail (source='tmdb', direct image URLs, nothing owned). If it IS
|
|
in the library, returns a redirect to the owned detail instead."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled:
|
|
return None
|
|
lib_id = self.db.library_id_for_tmdb(kind, tmdb_id, self._server())
|
|
if lib_id:
|
|
return {"redirect": {"source": "library", "kind": kind, "id": lib_id}}
|
|
region = self._region()
|
|
cached = self._cache_get(("detail", kind, tmdb_id, region))
|
|
if cached is not None:
|
|
return dict(cached)
|
|
try:
|
|
d = w.client.full_detail(kind, tmdb_id, region=region)
|
|
except Exception:
|
|
logger.exception("tmdb_detail failed for %s %s", kind, tmdb_id)
|
|
return None
|
|
if not d:
|
|
return None
|
|
d.update({"source": "tmdb", "id": tmdb_id, "owned": False, "monitored": False,
|
|
"has_poster": bool(d.get("poster_url")), "has_backdrop": bool(d.get("backdrop_url"))})
|
|
ex = d.pop("_extras", {}) or {}
|
|
d.update({"trailer": ex.get("trailer"), "providers": ex.get("providers") or [],
|
|
"providers_link": ex.get("providers_link"), "similar": ex.get("similar") or [],
|
|
"recommendations": ex.get("recommendations") or [], "collection": ex.get("collection"),
|
|
"next_episode": ex.get("next_episode"), "last_episode": ex.get("last_episode"),
|
|
"gallery": ex.get("gallery"), "videos": ex.get("videos") or [],
|
|
"keywords": ex.get("keywords") or [], "facts": ex.get("facts"),
|
|
"cast_full": ex.get("cast_full") or [], "review": ex.get("review")})
|
|
if kind == "show":
|
|
seasons = d.pop("_seasons", []) or []
|
|
for s in seasons:
|
|
s["has_poster"] = bool(s.get("poster_url"))
|
|
s["episode_total"] = s.pop("episode_count", 0) or 0
|
|
s["episode_owned"] = 0
|
|
s["episodes"] = [] # loaded lazily per season (tmdb_season)
|
|
d["seasons"] = seasons
|
|
d["season_count"] = len(seasons)
|
|
d["episode_total"] = sum(s["episode_total"] for s in seasons)
|
|
d["episode_owned"] = 0
|
|
self._fill_tmdb_ratings(d)
|
|
self._cache_put(("detail", kind, tmdb_id, region), d)
|
|
return d
|
|
|
|
def _fill_tmdb_ratings(self, d) -> None:
|
|
imdb_id = d.get("imdb_id")
|
|
ow = self.workers.get("omdb")
|
|
if not imdb_id or not ow or not getattr(ow.client, "enabled", False):
|
|
return
|
|
try:
|
|
r = ow.client.ratings(imdb_id) or {}
|
|
for k in ("imdb_rating", "rt_rating", "metacritic"):
|
|
if r.get(k) is not None:
|
|
d[k] = r[k]
|
|
except Exception:
|
|
logger.exception("tmdb_detail ratings failed for %s", imdb_id)
|
|
|
|
def tmdb_season(self, tv_id, season_number) -> dict | None:
|
|
"""One season's episodes for a TMDB (un-owned) show — lazy-loaded when the
|
|
season is selected on the search detail page. Nothing is owned."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled:
|
|
return None
|
|
key = ("season", tv_id, season_number)
|
|
cached = self._cache_get(key)
|
|
if cached is not None:
|
|
return cached
|
|
try:
|
|
se = w.client.season_episodes(tv_id, season_number)
|
|
except Exception:
|
|
logger.exception("tmdb_season failed for %s S%s", tv_id, season_number)
|
|
return None
|
|
if not se:
|
|
return None
|
|
eps = [{"episode_number": e.get("episode_number"), "title": e.get("title"),
|
|
"overview": e.get("overview"), "air_date": e.get("air_date"),
|
|
"runtime_minutes": e.get("runtime_minutes"), "rating": e.get("rating"),
|
|
"still_url": e.get("still_url"), "has_still": bool(e.get("still_url")),
|
|
"owned": False}
|
|
for e in (se.get("episodes") or []) if e.get("episode_number") is not None]
|
|
out = {"season_number": season_number, "overview": se.get("overview"),
|
|
"poster_url": se.get("poster_url"), "episodes": eps}
|
|
self._cache_put(key, out)
|
|
return out
|
|
|
|
def episode_extra(self, tmdb_id, season_number, episode_number) -> dict | None:
|
|
"""Deeper episode detail (guest stars + still) for the episode expand,
|
|
annotated owned/not + cached."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled or not tmdb_id:
|
|
return None
|
|
key = ("episode", tmdb_id, season_number, episode_number)
|
|
cached = self._cache_get(key)
|
|
if cached is None:
|
|
try:
|
|
cached = w.client.episode_detail(tmdb_id, season_number, episode_number)
|
|
except Exception:
|
|
logger.exception("episode_extra failed for %s S%sE%s", tmdb_id, season_number, episode_number)
|
|
return None
|
|
if cached is None:
|
|
return None
|
|
self._cache_put(key, cached)
|
|
return cached
|
|
|
|
def person_detail(self, tmdb_id) -> dict | None:
|
|
"""A person (actor/director) page — bio + filmography, each credit
|
|
annotated with the library id if owned. Keeps cast clicks in-app."""
|
|
w = self.workers.get("tmdb")
|
|
if not w or not w.enabled:
|
|
return None
|
|
p = self._cache_get(("person", tmdb_id))
|
|
if p is None:
|
|
try:
|
|
p = w.client.person(tmdb_id)
|
|
except Exception:
|
|
logger.exception("person_detail failed for %s", tmdb_id)
|
|
return None
|
|
if not p:
|
|
return None
|
|
self._cache_put(("person", tmdb_id), p)
|
|
# Re-annotate ownership fresh each call (cheap) so it tracks the library.
|
|
srv = self._server()
|
|
for c in p.get("credits") or []:
|
|
if c.get("tmdb_id"):
|
|
c["library_id"] = self.db.library_id_for_tmdb(c["kind"], c["tmdb_id"], srv)
|
|
return p
|
|
|
|
def _server(self):
|
|
"""Active video server — scopes ownership lookups so an item owned only on
|
|
the inactive server doesn't read as owned (Plex/Jellyfin stay separate)."""
|
|
try:
|
|
from core.video.sources import resolve_video_server
|
|
return resolve_video_server(self.db)
|
|
except Exception:
|
|
return None
|
|
|
|
def worker(self, service):
|
|
return self.workers.get(service)
|
|
|
|
def services(self) -> list:
|
|
return [{"id": s, "display_name": w.display_name} for s, w in self.workers.items()]
|
|
|
|
|
|
_engine = None
|
|
_lock = threading.Lock()
|
|
|
|
|
|
def get_video_enrichment_engine():
|
|
"""Process-wide engine, created (and started) on first use."""
|
|
global _engine
|
|
if _engine is None:
|
|
with _lock:
|
|
if _engine is None:
|
|
from database.video_database import VideoDatabase
|
|
from .clients import build_clients, OMDBClient
|
|
db = VideoDatabase()
|
|
eng = VideoEnrichmentEngine(db, build_clients(db),
|
|
ratings_client=OMDBClient(db.get_setting("omdb_api_key")))
|
|
eng.start_all()
|
|
_engine = eng
|
|
return _engine
|
|
|
|
|
|
def peek_video_enrichment_engine():
|
|
"""The engine ONLY if it's already running — never creates or starts it. Lets
|
|
the socket status emitter push updates without spinning up the video engine on
|
|
the music side (it stays None until something actually uses video)."""
|
|
return _engine
|
|
|
|
|
|
def rebuild_video_enrichment_engine():
|
|
"""Rebuild the engine so workers pick up changed API keys (stops the old
|
|
workers first so threads don't leak)."""
|
|
global _engine
|
|
with _lock:
|
|
if _engine is not None:
|
|
try:
|
|
_engine.stop_all()
|
|
except Exception:
|
|
logger.exception("video enrichment: stopping old engine failed")
|
|
_engine = None
|
|
return get_video_enrichment_engine()
|