soulsync/core/video/enrichment/engine.py
BoulderBadgeDad 877a86c9b0 Video Discover: split Top 10 today into Movies + TV Shows (Netflix-style)
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).
2026-06-24 08:00:13 -07:00

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()