From 80dd2ff21c722714532e43d06c8352e549a37b2f Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 15 Jun 2026 16:42:22 -0700 Subject: [PATCH] Video side: own server connection (Plex/Jellyfin), music-style picker, isolated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the video side its OWN server connection — pre-filled from music but stored separately in video.db, fully isolated (video never writes music config/state). - Effective config helpers (video_plex_config / video_jellyfin_config): video's own creds when set, else inherited read-only from music. resolve_video_server + _build_source + watch-link/poster/dashboard all use these (own db threaded in). - Server Connection UI mirrors music's server picker (toggle = select + configure), scoped to Plex/Jellyfin, at the bottom of the Connections tab. - Jellyfin: independent client built from video's creds; explicit USER picker like music (list users → that user's libraries); honors the pick, admin fallback. - Honest connection diagnostics (reachable vs 401 vs no-users) instead of a vague "auth failed". - Auto-save on change with toasts; the shared Save button is intercepted on the video side so it saves video settings (and can't fire a music save). - Enrichment status now PUSHES over the socket like music (no browser polling / access-log flood); config save only rebuilds workers when an API key changed. - Seam tests for effective-config inheritance/override + isolation guard. --- api/video/dashboard.py | 4 +- api/video/enrichment.py | 31 +++- api/video/libraries.py | 118 ++++++++++++- api/video/poster.py | 7 +- core/video/enrichment/engine.py | 15 +- core/video/sources.py | 164 ++++++++++++++---- tests/test_video_server_resolution.py | 50 +++++- web_server.py | 26 +++ webui/index.html | 87 ++++++++-- webui/static/video/video-enrichment.js | 36 +++- webui/static/video/video-settings.js | 228 ++++++++++++++++++++----- webui/static/video/video-side.css | 19 +-- 12 files changed, 646 insertions(+), 139 deletions(-) diff --git a/api/video/dashboard.py b/api/video/dashboard.py index a5dc3918..2e185039 100644 --- a/api/video/dashboard.py +++ b/api/video/dashboard.py @@ -20,8 +20,8 @@ def register_routes(bp): try: stats = get_video_db().dashboard_stats() try: - from config.settings import config_manager - stats["server"] = config_manager.get_active_media_server() + from core.video.sources import resolve_video_server + stats["server"] = resolve_video_server() # the VIDEO server, not music's active except Exception: stats["server"] = None return jsonify(stats) diff --git a/api/video/enrichment.py b/api/video/enrichment.py index 615006f1..3de68812 100644 --- a/api/video/enrichment.py +++ b/api/video/enrichment.py @@ -65,10 +65,18 @@ def register_routes(bp): from . import get_video_db db = get_video_db() body = request.get_json(silent=True) or {} - if "tmdb_api_key" in body: - db.set_setting("tmdb_api_key", body.get("tmdb_api_key") or "") - if "tvdb_api_key" in body: - db.set_setting("tvdb_api_key", body.get("tvdb_api_key") or "") + keys_changed = False + + def put_key(field): + nonlocal keys_changed + if field in body: + val = body.get(field) or "" + if val != (db.get_setting(field) or ""): + keys_changed = True + db.set_setting(field, val) + + put_key("tmdb_api_key") + put_key("tvdb_api_key") if "billboard_autoplay" in body: db.set_setting("billboard_autoplay", "1" if body.get("billboard_autoplay") else "0") if "watch_region" in body: @@ -78,6 +86,7 @@ def register_routes(bp): new_key = body.get("omdb_api_key") or "" changed = new_key != (db.get_setting("omdb_api_key") or "") db.set_setting("omdb_api_key", new_key) + keys_changed = keys_changed or changed # A new/changed OMDb key → re-try every title that still has no rating # (covers items wrongly marked 'synced' during a prior bad-key run). if new_key and changed: @@ -86,11 +95,15 @@ def register_routes(bp): db.enrichment_retry("omdb", kind, scope="failed") except Exception: logger.exception("video enrichment: omdb re-try reset failed") - try: - from core.video.enrichment.engine import rebuild_video_enrichment_engine - rebuild_video_enrichment_engine() - except Exception: - logger.exception("video enrichment: engine rebuild after key change failed") + # Only rebuild the workers when an API KEY actually changed — a prefs-only + # save (autoplay/region) must not churn the running enrichment engine + # (that restart was re-logging the OMDb limit warning on every save). + if keys_changed: + try: + from core.video.enrichment.engine import rebuild_video_enrichment_engine + rebuild_video_enrichment_engine() + except Exception: + logger.exception("video enrichment: engine rebuild after key change failed") return jsonify({"status": "saved"}) @bp.route("/enrichment//status", methods=["GET"]) diff --git a/api/video/libraries.py b/api/video/libraries.py index 54fe6a9d..ca1dc64a 100644 --- a/api/video/libraries.py +++ b/api/video/libraries.py @@ -35,10 +35,10 @@ def register_routes(bp): """Which server the video side uses + which of Plex/Jellyfin are configured (so the UI can show a picker, or a 'connect a server' message).""" try: - from core.video.sources import resolve_video_server - from config.settings import config_manager - plex = bool((config_manager.get_plex_config() or {}).get("base_url")) - jelly = bool((config_manager.get_jellyfin_config() or {}).get("base_url")) + from core.video.sources import (resolve_video_server, + video_plex_config, video_jellyfin_config) + plex = bool(video_plex_config().get("base_url")) + jelly = bool(video_jellyfin_config().get("base_url")) return jsonify({"server": resolve_video_server(), "plex": plex, "jellyfin": jelly}) except Exception: logger.exception("video server status failed") @@ -56,6 +56,116 @@ def register_routes(bp): get_video_db().set_setting("video_server", choice) return jsonify({"status": "saved", "server": choice}) + @bp.route("/server-config", methods=["GET"]) + def video_server_config_get(): + """The video side's OWN server connection — its stored creds when set, else + the values INHERITED (read-only) from music. 'inherited' flags tell the UI a + field is a placeholder it can override; tokens/keys are returned masked.""" + try: + from core.video.sources import video_plex_config, video_jellyfin_config + p, j = video_plex_config(), video_jellyfin_config() + + def mask(v): + v = v or "" + return ("•" * 12) if v else "" + return jsonify({ + "plex": {"base_url": p.get("base_url") or "", "token": mask(p.get("token")), + "has_token": bool(p.get("token")), "inherited": p.get("source") == "music"}, + "jellyfin": {"base_url": j.get("base_url") or "", "api_key": mask(j.get("api_key")), + "has_key": bool(j.get("api_key")), "inherited": j.get("source") == "music"}, + }) + except Exception: + logger.exception("video server-config get failed") + return jsonify({"plex": {}, "jellyfin": {}}) + + @bp.route("/server-config", methods=["POST"]) + def video_server_config_set(): + """Save the video side's OWN Plex/Jellyfin creds to video.db (NEVER the music + config). An empty/blank field clears that override → video falls back to + inheriting music's value. A masked token (all •) is left untouched.""" + from . import get_video_db + body = request.get_json(silent=True) or {} + db = get_video_db() + + def is_mask(v): + return bool(v) and set(str(v)) == {"•"} + + def put(key, val): + if is_mask(val): + return # unchanged masked secret — keep what's stored + db.set_setting(key, (val or "").strip()) + + plex = body.get("plex") or {} + jelly = body.get("jellyfin") or {} + if "base_url" in plex: + put("video_plex_url", plex.get("base_url")) + if "token" in plex: + put("video_plex_token", plex.get("token")) + if "base_url" in jelly: + put("video_jellyfin_url", jelly.get("base_url")) + if "api_key" in jelly: + put("video_jellyfin_key", jelly.get("api_key")) + return jsonify({"status": "saved"}) + + @bp.route("/server-config/test", methods=["POST"]) + def video_server_config_test(): + """Test the video side's effective connection for one server, using its OWN + stored/inherited creds (so it verifies exactly what the video scan will use).""" + body = request.get_json(silent=True) or {} + which = body.get("server") + if which not in ("plex", "jellyfin"): + return jsonify({"success": False, "error": "bad server"}), 400 + try: + if which == "plex": + from core.video.sources import video_plex_config, PLEX_SCAN_TIMEOUT + cfg = video_plex_config() + if not cfg.get("base_url") or not cfg.get("token"): + return jsonify({"success": False, "error": "Plex URL/token not set"}) + from plexapi.server import PlexServer + srv = PlexServer(cfg["base_url"], cfg["token"], timeout=PLEX_SCAN_TIMEOUT) + return jsonify({"success": True, "message": "Connected to " + (srv.friendlyName or "Plex")}) + from core.video.sources import video_jellyfin_config, video_jellyfin_test + ok, message = video_jellyfin_test(video_jellyfin_config()) + if ok: + return jsonify({"success": True, "message": message}) + return jsonify({"success": False, "error": message}) + except Exception as e: + return jsonify({"success": False, "error": str(e) or "connection failed"}) + + @bp.route("/jellyfin/users", methods=["GET"]) + def video_jellyfin_users(): + """List the Jellyfin server's users so the video side can pick one (its + libraries are scoped to that user) — mirrors the music user picker. Uses + video's own effective Jellyfin creds.""" + from . import get_video_db + try: + from core.video.sources import video_jellyfin_config + cfg = video_jellyfin_config() + base = (cfg.get("base_url") or "").rstrip("/") + key = cfg.get("api_key") or "" + if not base or not key: + return jsonify({"success": False, "users": []}) + import requests + r = requests.get(base + "/Users", headers={"X-Emby-Token": key}, timeout=8) + if r.status_code != 200: + return jsonify({"success": False, "users": [], "error": "HTTP %d" % r.status_code}) + users = r.json() or [] + out = [{"id": u.get("Id"), "name": u.get("Name"), + "admin": bool((u.get("Policy") or {}).get("IsAdministrator"))} + for u in users if u.get("Id")] + selected = get_video_db().get_setting("video_jellyfin_user") or "" + return jsonify({"success": True, "users": out, "selected": selected}) + except Exception as e: + return jsonify({"success": False, "users": [], "error": str(e)}) + + @bp.route("/jellyfin/user", methods=["POST"]) + def video_jellyfin_user_set(): + """Persist the chosen Jellyfin user (its Id) for the video side.""" + from . import get_video_db + body = request.get_json(silent=True) or {} + get_video_db().set_setting("video_jellyfin_user", (body.get("user") or "").strip()) + return jsonify({"status": "saved"}) + @bp.route("/libraries", methods=["POST"]) def save_video_libraries(): from . import get_video_db diff --git a/api/video/poster.py b/api/video/poster.py index 6358b121..cc2ef0da 100644 --- a/api/video/poster.py +++ b/api/video/poster.py @@ -34,16 +34,19 @@ def register_routes(bp): content_type=upstream.headers.get("Content-Type", "image/jpeg")) resp.headers["Cache-Control"] = "public, max-age=86400" return resp + # Art is served from the VIDEO side's effective connection (its own + # creds, or inherited from music) — that's where the item was scanned. + from core.video.sources import video_plex_config, video_jellyfin_config source = ref.get("server_source") if source == "plex": - cfg = config_manager.get_plex_config() or {} + cfg = video_plex_config() base, token = cfg.get("base_url"), cfg.get("token") if not base or not token: abort(404) url = base.rstrip("/") + ref["poster_url"] params = {"X-Plex-Token": token} elif source == "jellyfin": - cfg = config_manager.get_jellyfin_config() or {} + cfg = video_jellyfin_config() base, key = cfg.get("base_url"), cfg.get("api_key") if not base: abort(404) diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index d91c98e0..8a3a07df 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -209,9 +209,11 @@ class VideoEnrichmentEngine: if not source or not sid: return None # not on a server (e.g. a wishlist row) try: - from config.settings import config_manager + # 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 = config_manager.get_plex_config() or {} + cfg = video_plex_config(self.db) base, token = cfg.get("base_url"), cfg.get("token") if not base or not token: return None @@ -223,7 +225,7 @@ class VideoEnrichmentEngine: return {"server": "Plex", "url": "https://app.plex.tv/desktop/#!/server/%s/details?key=%s" % (mid, key)} if source == "jellyfin": - cfg = config_manager.get_jellyfin_config() or {} + cfg = video_jellyfin_config(self.db) base = cfg.get("base_url") if not base: return None @@ -456,6 +458,13 @@ def get_video_enrichment_engine(): 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).""" diff --git a/core/video/sources.py b/core/video/sources.py index 477fe13a..19e0590e 100644 --- a/core/video/sources.py +++ b/core/video/sources.py @@ -63,26 +63,70 @@ def _parse_jf_providers(item) -> dict: } +def _vdb(db=None): + """The video DB (the given one, or a fresh handle). None if unavailable.""" + if db is not None: + return db + try: + from database.video_database import VideoDatabase + return VideoDatabase() + except Exception: + return None + + +def video_plex_config(db=None): + """VIDEO's effective Plex connection: the video side's OWN stored creds + (video.db) when set, otherwise INHERITED read-only from the music config. + The video side never writes the music config, so this is one-way.""" + db = _vdb(db) + try: + url = (db.get_setting("video_plex_url") or "").strip() if db else "" + token = (db.get_setting("video_plex_token") or "").strip() if db else "" + except Exception: + url, token = "", "" + if url and token: + return {"base_url": url, "token": token, "source": "video"} + try: + from config.settings import config_manager + cfg = config_manager.get_plex_config() or {} + return {"base_url": cfg.get("base_url") or "", "token": cfg.get("token") or "", + "source": "music"} + except Exception: + return {"base_url": "", "token": "", "source": "music"} + + +def video_jellyfin_config(db=None): + """VIDEO's effective Jellyfin connection: the video side's OWN stored creds + when set, otherwise INHERITED read-only from the music config.""" + db = _vdb(db) + try: + url = (db.get_setting("video_jellyfin_url") or "").strip() if db else "" + key = (db.get_setting("video_jellyfin_key") or "").strip() if db else "" + except Exception: + url, key = "", "" + if url and key: + return {"base_url": url, "api_key": key, "source": "video"} + try: + from config.settings import config_manager + cfg = config_manager.get_jellyfin_config() or {} + return {"base_url": cfg.get("base_url") or "", "api_key": cfg.get("api_key") or "", + "source": "music"} + except Exception: + return {"base_url": "", "api_key": "", "source": "music"} + + def resolve_video_server(db=None): """The server the VIDEO side uses — a configured Plex/Jellyfin, resolved INDEPENDENTLY of the music 'active server' pointer (so e.g. Navidrome-for-music + Plex-for-video works, and music-only servers never apply here). Returns - 'plex' | 'jellyfin' | None. Order: explicit video pick → the music-active one if - it's video-capable → the single configured one → Plex if both → None.""" - try: - from config.settings import config_manager - except Exception: - return None - plex_ok = bool((config_manager.get_plex_config() or {}).get("base_url")) - jelly_ok = bool((config_manager.get_jellyfin_config() or {}).get("base_url")) + 'plex' | 'jellyfin' | None. Order: explicit video pick → the single configured + one → Plex when both → None. 'Configured' means video's EFFECTIVE config + (its own creds, or inherited from music) has a base_url.""" + db = _vdb(db) + plex_ok = bool(video_plex_config(db).get("base_url")) + jelly_ok = bool(video_jellyfin_config(db).get("base_url")) pref = None - if db is None: - try: - from database.video_database import VideoDatabase - db = VideoDatabase() - except Exception: - db = None if db is not None: try: pref = db.get_setting("video_server") @@ -105,20 +149,86 @@ def resolve_video_server(db=None): return None -def _build_source(movies_lib=None, tv_lib=None): - """Build a media source for the VIDEO server (see resolve_video_server), - restricted to the named Movies/TV libraries when given. Reuses the SHARED - connection config — but Plex gets a dedicated long-timeout connection.""" +def _video_jellyfin_source(cfg, movies_lib=None, tv_lib=None): + """A JellyfinVideoSource connected with VIDEO's OWN config — independent of + music's shared singleton client. The video source only needs base_url/api_key + (for _make_request) and any valid user_id (for /Users/{id}/Items browsing).""" + base = (cfg.get("base_url") or "").rstrip("/") + key = cfg.get("api_key") or "" + if not base or not key: + return None try: - from config.settings import config_manager + from core.jellyfin_client import JellyfinClient + client = JellyfinClient() + client.base_url = base + client.api_key = key + users = client._make_request("/Users") or [] + if not users: + return None + # Jellyfin scopes /Users/{id}/Views to that user's library access, so honor + # the user the operator explicitly picked (stored video_jellyfin_user). Until + # they pick, default to an ADMIN (full visibility) so nothing's hidden. + pref = "" + try: + _db = _vdb() + pref = (_db.get_setting("video_jellyfin_user") or "") if _db else "" + except Exception: + pref = "" + chosen = next((u for u in users if u.get("Id") == pref), None) + if chosen is None: + admins = [u for u in users if (u.get("Policy") or {}).get("IsAdministrator")] + chosen = (admins or users)[0] + uid = chosen.get("Id") + if not uid: + return None + client.user_id = uid + return JellyfinVideoSource(client, movies_lib=movies_lib, tv_lib=tv_lib) except Exception: - logger.exception("video sources: config unavailable") + logger.exception("video sources: Jellyfin connect failed") return None - server = resolve_video_server() + +def video_jellyfin_test(cfg): + """Diagnose the video Jellyfin connection precisely (for the Test button). + Returns (ok: bool, message: str). Distinguishes 'can't reach the server', + 'API key rejected', and 'no users' instead of one vague failure — reuses the + same X-Emby-Token header the music client uses (_make_request).""" + base = (cfg.get("base_url") or "").rstrip("/") + key = cfg.get("api_key") or "" + if not base or not key: + return False, "Jellyfin URL/API key not set" + import requests + headers = {"X-Emby-Token": key} + try: + info = requests.get(base + "/System/Info", headers=headers, timeout=8) + except requests.exceptions.ConnectionError: + return False, "Can't reach Jellyfin at %s — is it running and reachable on that host/port?" % base + except requests.exceptions.RequestException as e: + return False, "Couldn't connect to Jellyfin: %s" % (e,) + if info.status_code in (401, 403): + return False, "Jellyfin rejected the API key (HTTP %d). Check the key." % info.status_code + if info.status_code != 200: + return False, "Jellyfin returned HTTP %d for /System/Info." % info.status_code + try: + users = requests.get(base + "/Users", headers=headers, timeout=8).json() + except Exception: + users = None + if not users: + return False, "Connected, but Jellyfin returned no users for this API key." + name = (info.json() or {}).get("ServerName") or "Jellyfin" + return True, "Connected to %s" % name + + +def _build_source(movies_lib=None, tv_lib=None): + """Build a media source for the VIDEO server (see resolve_video_server), + restricted to the named Movies/TV libraries when given. Uses VIDEO's OWN + effective connection config (its creds, or inherited from music) — never the + music side's live connection, so the two stay independent.""" + db = _vdb() + server = resolve_video_server(db) if server == "plex": - cfg = config_manager.get_plex_config() or {} + cfg = video_plex_config(db) base_url, token = cfg.get("base_url"), cfg.get("token") if not base_url or not token: return None @@ -131,15 +241,7 @@ def _build_source(movies_lib=None, tv_lib=None): return None if server == "jellyfin": - try: - from core.media_server.engine import get_media_server_engine - engine = get_media_server_engine() - client = engine.client("jellyfin") if engine else None - if client and client.ensure_connection() and getattr(client, "user_id", None): - return JellyfinVideoSource(client, movies_lib=movies_lib, tv_lib=tv_lib) - except Exception: - logger.exception("video sources: Jellyfin connect failed") - return None + return _video_jellyfin_source(video_jellyfin_config(db), movies_lib, tv_lib) return None diff --git a/tests/test_video_server_resolution.py b/tests/test_video_server_resolution.py index 1d5915f4..143c82d6 100644 --- a/tests/test_video_server_resolution.py +++ b/tests/test_video_server_resolution.py @@ -7,14 +7,15 @@ from __future__ import annotations import config.settings as cs import pytest -from core.video.sources import resolve_video_server +from core.video.sources import (resolve_video_server, video_plex_config, + video_jellyfin_config) from database.video_database import VideoDatabase def _set_cm(monkeypatch, plex, jelly, active): class CM: def get_plex_config(self): return {"base_url": "http://p", "token": "t"} if plex else {} - def get_jellyfin_config(self): return {"base_url": "http://j"} if jelly else {} + def get_jellyfin_config(self): return {"base_url": "http://j", "api_key": "k"} if jelly else {} def get_active_media_server(self): return active monkeypatch.setattr(cs, "config_manager", CM()) @@ -59,3 +60,48 @@ def test_does_not_follow_music_active_server(monkeypatch, vdb): assert resolve_video_server(vdb) == "plex" vdb.set_setting("video_server", "jellyfin") # only an explicit pick switches video assert resolve_video_server(vdb) == "jellyfin" + + +# ── Effective connection config: video's OWN creds, or inherited from music ── + +def test_plex_config_inherits_music_when_unset(monkeypatch, vdb): + _set_cm(monkeypatch, True, False, "plex") + cfg = video_plex_config(vdb) + assert cfg["base_url"] == "http://p" and cfg["token"] == "t" + assert cfg["source"] == "music" # inherited, read-only + + +def test_plex_config_own_creds_override_music(monkeypatch, vdb): + _set_cm(monkeypatch, True, False, "plex") + vdb.set_setting("video_plex_url", "http://video-plex") + vdb.set_setting("video_plex_token", "vt") + cfg = video_plex_config(vdb) + assert cfg["base_url"] == "http://video-plex" and cfg["token"] == "vt" + assert cfg["source"] == "video" # video's own, not music's + + +def test_own_creds_do_not_touch_music_config(monkeypatch, vdb): + # Setting video's own Plex creds must not change what music reports. + _set_cm(monkeypatch, True, False, "plex") + vdb.set_setting("video_plex_url", "http://video-plex") + vdb.set_setting("video_plex_token", "vt") + assert cs.config_manager.get_plex_config()["base_url"] == "http://p" + + +def test_video_jellyfin_override_while_music_is_plex(monkeypatch, vdb): + # The headline scenario: music = Plex, video adds its OWN Jellyfin + picks it. + _set_cm(monkeypatch, True, False, "plex") + vdb.set_setting("video_jellyfin_url", "http://video-jelly") + vdb.set_setting("video_jellyfin_key", "jk") + jcfg = video_jellyfin_config(vdb) + assert jcfg["base_url"] == "http://video-jelly" and jcfg["source"] == "video" + vdb.set_setting("video_server", "jellyfin") + assert resolve_video_server(vdb) == "jellyfin" # video on Jellyfin, music still Plex + + +def test_partial_own_creds_falls_back_to_inherited(monkeypatch, vdb): + # URL without token isn't a usable override → inherit music's full config. + _set_cm(monkeypatch, True, False, "plex") + vdb.set_setting("video_plex_url", "http://video-plex") # token missing + cfg = video_plex_config(vdb) + assert cfg["base_url"] == "http://p" and cfg["source"] == "music" diff --git a/web_server.py b/web_server.py index dc3b666c..9dec4b81 100644 --- a/web_server.py +++ b/web_server.py @@ -37250,6 +37250,29 @@ def _emit_enrichment_status_loop(): except Exception as e: logger.debug(f"Error emitting {name} status: {e}") +def _emit_video_enrichment_status_loop(): + """Push the VIDEO enrichment worker statuses over the socket every 2s, exactly + like the music enrichment loop — so the video dashboard listens instead of + polling /api/video/enrichment//status (that browser polling was flooding + the access log). No-op until the video engine is actually running, so this + never spins it up on the music side.""" + from core.video.enrichment.engine import peek_video_enrichment_engine + services = ('tmdb', 'tvdb', 'omdb') + while not globals().get('IS_SHUTTING_DOWN', False): + socketio.sleep(2) + eng = peek_video_enrichment_engine() + if eng is None: + continue + for svc in services: + try: + w = eng.worker(svc) + if w is None: + continue + socketio.emit(f'enrichment:{svc}', w.get_stats()) + except Exception as e: + logger.debug(f"Error emitting video {svc} status: {e}") + + def _emit_tool_progress_loop(): """Background thread that pushes all tool progress statuses every 1 second.""" while not globals().get('IS_SHUTTING_DOWN', False): @@ -37681,6 +37704,9 @@ def start_runtime_services(): socketio.start_background_task(_emit_wishlist_count_loop) # Phase 3: Enrichment sidebar workers socketio.start_background_task(_emit_enrichment_status_loop) + # Phase 3 (video): push video enrichment status so the video dashboard + # listens instead of polling (matches music; no access-log flood). + socketio.start_background_task(_emit_video_enrichment_status_loop) # Phase 4: Tool progress pollers socketio.start_background_task(_emit_tool_progress_loop) # Phase 5: Sync/discovery progress + scans diff --git a/webui/index.html b/webui/index.html index 92fe0871..5b0cbaf3 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5133,24 +5133,6 @@ - -
-

Video Source

-
-
Checking…
-
-
- - - - - - Which libraries the video side scans. - -
-

API Configuration

@@ -5210,6 +5192,75 @@
+ +
+

Server Connection

+

Pick the server the video side uses. Fields are pre-filled from your Music settings — edit them to point video at a different server without touching Music.

+ +
+ + +
+ + + + + + + + +
+ + + + + + Which libraries the video side scans. + +
+

Video Preferences

diff --git a/webui/static/video/video-enrichment.js b/webui/static/video/video-enrichment.js index 8366f4ea..482d4ef5 100644 --- a/webui/static/video/video-enrichment.js +++ b/webui/static/video/video-enrichment.js @@ -6,8 +6,10 @@ * pause/resume. The Manage Workers button fires 'soulsync:video-open-workers' * for the (isolated) video enrichment modal. Music code is never touched. * - * Self-contained IIFE, no globals, no inline handlers. Only polls on the video - * side so the video engine isn't spun up while the user is on the music side. + * Status arrives over the SAME WebSocket music uses (server emits 'enrichment:tmdb' + * /'enrichment:tvdb'/'enrichment:omdb' every 2s) — so the browser does NOT poll. + * A single fetch on first load fills the buttons instantly; the socket drives the + * rest. Self-contained IIFE, no globals, no inline handlers. Music is never touched. */ (function () { 'use strict'; @@ -68,10 +70,29 @@ .catch(function () { /* ignore */ }); } - function poll() { + // One-time fetch so the buttons aren't blank until the first socket push. + function primeOnce() { if (onVideoSide() && !document.hidden) SERVICES.forEach(pollOne); } + // Listen on the shared socket (set up by core.js) for the server-pushed + // status — mirrors how the music workers report. Retries until the socket + // global exists, then binds once. + var _bound = false; + function bindSocket() { + if (_bound) return; + if (typeof socket === 'undefined' || !socket || !socket.on) { + setTimeout(bindSocket, 600); + return; + } + _bound = true; + SERVICES.forEach(function (svc) { + socket.on('enrichment:' + svc, function (d) { + if (onVideoSide() && d && !d.error) reflect(svc, d); + }); + }); + } + function toggle(svc) { var b = btn(svc); if (!b || b.classList.contains('disabled')) return; @@ -93,8 +114,13 @@ document.dispatchEvent(new CustomEvent('soulsync:video-open-workers')); }); } - poll(); - setInterval(poll, 2500); + primeOnce(); // instant initial state + bindSocket(); // then the socket pushes updates — no polling + // Re-prime when the user returns to the tab (socket kept pushing, but a + // quick fetch snaps the buttons current immediately). + document.addEventListener('visibilitychange', function () { + if (!document.hidden) primeOnce(); + }); } if (document.readyState === 'loading') { diff --git a/webui/static/video/video-settings.js b/webui/static/video/video-settings.js index 7f70caf5..991a094b 100644 --- a/webui/static/video/video-settings.js +++ b/webui/static/video/video-settings.js @@ -14,47 +14,141 @@ var URL = '/api/video/libraries'; var CONFIG_URL = '/api/video/enrichment/config'; var SERVER_URL = '/api/video/server'; + var CONN_URL = '/api/video/server-config'; function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>'); } - // ── Video Source (which Plex/Jellyfin the video side uses) ────────────── + // ── Server Connection ─────────────────────────────────────────────────── + // Mirrors the MUSIC server picker (toggle = select + configure), scoped to + // Plex/Jellyfin. Clicking a toggle reveals that server's creds AND sets it as + // the active video server. Creds are video's own (video.db), pre-filled from + // music; the picker writes only to /api/video/* — never the music config. + function connEl(name) { return document.querySelector('[data-video-conn="' + name + '"]'); } + function note(server, text) { + var n = document.querySelector('[data-video-conn-note="' + server + '"]'); + if (n) n.textContent = text || ''; + } + function showServerConfig(server) { + var btns = document.querySelectorAll('[data-video-server-toggle]'); + for (var i = 0; i < btns.length; i++) { + btns[i].classList.toggle('active', btns[i].getAttribute('data-video-server-toggle') === server); + } + var cfgs = document.querySelectorAll('[data-video-server-config]'); + for (var j = 0; j < cfgs.length; j++) { + cfgs[j].classList.toggle('hidden', cfgs[j].getAttribute('data-video-server-config') !== server); + } + } + // Which server's config to show on load: the explicit pick, else the + // configured one, else Plex (so there's always a panel to fill in). function loadServer() { - var host = document.querySelector('[data-video-source-panel]'); - if (!host) return; fetch(SERVER_URL, { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) - .then(function (d) { renderServer(host, d || {}); }) - .catch(function () { renderServer(host, {}); }); + .then(function (d) { + d = d || {}; + var active = d.server || (d.jellyfin && !d.plex ? 'jellyfin' : 'plex'); + showServerConfig(active); + if (active === 'jellyfin') loadJellyfinUsers(); + }) + .catch(function () { showServerConfig('plex'); }); } - function srvOpt(id, label, configured, selected) { - if (!configured) { - return ''; - } - return ''; + function loadConn() { + fetch(CONN_URL, { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (!d) return; + var p = d.plex || {}, j = d.jellyfin || {}; + var pu = connEl('plex-url'); if (pu) pu.value = p.base_url || ''; + var pt = connEl('plex-token'); if (pt) pt.value = p.has_token ? p.token : ''; + var ju = connEl('jellyfin-url'); if (ju) ju.value = j.base_url || ''; + var jk = connEl('jellyfin-key'); if (jk) jk.value = j.has_key ? j.api_key : ''; + note('plex', p.base_url + ? (p.inherited ? 'Inherited from your Music Plex connection — edit to use a different server for video.' + : 'Custom video connection.') + : 'Not connected — add a server URL and token.'); + note('jellyfin', j.base_url + ? (j.inherited ? 'Inherited from your Music Jellyfin connection — edit to use a different server for video.' + : 'Custom video connection.') + : 'Not connected — add a server URL and API key.'); + }) + .catch(function () { /* ignore */ }); } - function renderServer(host, d) { - var plex = !!d.plex, jelly = !!d.jellyfin; - var html = '
' + - srvOpt('plex', 'Plex', plex, d.server) + srvOpt('jellyfin', 'Jellyfin', jelly, d.server) + '
'; - if (!plex && !jelly) { - html += '
Neither is connected yet. Set up Plex or Jellyfin in ' + - 'Music → Settings → Server Connections — the video side reuses those credentials.
'; - } else { - html += '
The video side uses this server, independent of Music. ' + - 'Greyed-out options aren\'t connected — set them up in Music settings.
'; - } - host.innerHTML = html; + function saveConn(silent) { + var pu = connEl('plex-url'), pt = connEl('plex-token'); + var ju = connEl('jellyfin-url'), jk = connEl('jellyfin-key'); + return fetch(CONN_URL, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ + plex: { base_url: pu ? pu.value : '', token: pt ? pt.value : '' }, + jellyfin: { base_url: ju ? ju.value : '', api_key: jk ? jk.value : '' } + }) + }).then(function () { loadConn(); if (!silent) toast('Connection saved', 'success'); }) + .catch(function () { if (!silent) toast('Could not save connection', 'error'); }); } - function pickServer(id) { + // Toggle click: reveal that server's config immediately (like the music + // toggle) and persist it as the active video server pick. + function pickServer(server) { + showServerConfig(server); + if (server === 'jellyfin') loadJellyfinUsers(); fetch(SERVER_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, - body: JSON.stringify({ server: id }) - }).then(function () { loadServer(); load(); }).catch(function () { /* ignore */ }); + body: JSON.stringify({ server: server }) + }).then(function () { + load(); + toast('Video server set to ' + (server === 'plex' ? 'Plex' : 'Jellyfin'), 'success'); + }).catch(function () { /* ignore */ }); + } + function testConn(server) { + var name = server === 'plex' ? 'Plex' : 'Jellyfin'; + toast('Testing ' + name + ' connection…', 'info'); + saveConn(true).then(function () { + return fetch(CONN_URL + '/test', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ server: server }) + }); + }).then(function (r) { return r.json(); }).then(function (res) { + if (res && res.success) { + toast(res.message || (name + ' connection successful'), 'success'); + if (server === 'jellyfin') { loadJellyfinUsers(); load(); } // user + libraries + } else { + toast(name + ' connection failed: ' + ((res && res.error) || 'unknown'), 'error'); + } + }).catch(function () { toast('Failed to test ' + name + ' connection', 'error'); }); + } + + // ── Jellyfin user picker (mirrors music: pick a user, then its libraries) ── + function loadJellyfinUsers() { + var wrap = document.querySelector('[data-video-jellyfin-user-wrap]'); + var sel = document.querySelector('[data-video-jellyfin-user]'); + if (!wrap || !sel) return; + fetch('/api/video/jellyfin/users', { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (!d || !d.success || !d.users || !d.users.length) { wrap.style.display = 'none'; return; } + sel.textContent = ''; + var none = document.createElement('option'); + none.value = ''; none.textContent = 'Select User'; + sel.appendChild(none); + d.users.forEach(function (u) { + var o = document.createElement('option'); + o.value = u.id; + o.textContent = u.name + (u.admin ? ' (admin)' : ''); + if (u.id === d.selected) o.selected = true; + sel.appendChild(o); + }); + wrap.style.display = 'block'; + }) + .catch(function () { wrap.style.display = 'none'; }); + } + function selectJellyfinUser(id) { + fetch('/api/video/jellyfin/user', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ user: id }) + }).then(function () { + load(); // refresh libraries for the user + if (id) toast('Jellyfin user updated', 'success'); + }).catch(function () { /* ignore */ }); } function status(text) { @@ -92,18 +186,18 @@ .catch(function () { status('Could not load libraries'); }); } - function save() { + function save(silent) { var m = document.querySelector('[data-video-lib-select="movies"]'); var t = document.querySelector('[data-video-lib-select="tv"]'); status('Saving…'); - fetch(URL, { + return fetch(URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ movies: m ? m.value : '', tv: t ? t.value : '' }) }) .then(function (r) { return r.json(); }) - .then(function () { status('Saved'); }) - .catch(function () { status('Save failed'); }); + .then(function () { status('Saved'); if (!silent) toast('Library selection saved', 'success'); }) + .catch(function () { status('Save failed'); if (!silent) toast('Could not save libraries', 'error'); }); } // ── Enrichment API keys (TMDB / TVDB) ─────────────────────────────────── @@ -126,19 +220,20 @@ .catch(function () { /* ignore */ }); } - function savePrefs() { + function savePrefs(silent) { var ap = document.getElementById('video-billboard-autoplay'); var wr = document.getElementById('video-watch-region'); - fetch(CONFIG_URL, { + return fetch(CONFIG_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ billboard_autoplay: ap ? ap.checked : true, watch_region: wr ? wr.value : 'US', }) - }).catch(function () { /* ignore */ }); + }).then(function () { if (!silent) toast('Preferences saved', 'success'); }) + .catch(function () { /* ignore */ }); } - function saveKeys() { + function saveKeys(silent) { var t = document.getElementById('tmdb-api-key'); var v = document.getElementById('tvdb-api-key'); var o = document.getElementById('omdb-api-key'); @@ -149,7 +244,8 @@ tmdb_api_key: t ? t.value : '', tvdb_api_key: v ? v.value : '', omdb_api_key: o ? o.value : '', }) - }).catch(function () { /* ignore */ }); + }).then(function () { if (!silent) toast('API keys saved', 'success'); }) + .catch(function () { /* ignore */ }); } function toast(msg, type) { @@ -161,7 +257,7 @@ function testConnection(svc) { var name = svc.toUpperCase(); toast('Testing ' + name + ' connection…', 'info'); - saveKeys().then(function () { + saveKeys(true).then(function () { return fetch('/api/video/enrichment/' + svc + '/test', { method: 'POST', headers: { 'Accept': 'application/json' } }); }).then(function (r) { return r.json(); }).then(function (res) { @@ -173,6 +269,7 @@ function onPageShown(e) { if (e && e.detail !== PAGE_ID) return; loadServer(); + loadConn(); load(); loadKeys(); } @@ -180,24 +277,46 @@ function init() { // Save the moment a library is picked — same behaviour as the music // 'Music Library' selector above (which saves on change, no button). + // NB: wrap each handler so the DOM Event isn't passed as the function's + // first arg (which is our `silent` flag — it would suppress the toast). var selects = document.querySelectorAll('[data-video-lib-select]'); for (var i = 0; i < selects.length; i++) { - selects[i].addEventListener('change', save); + selects[i].addEventListener('change', function () { save(); }); } // Enrichment keys save on blur/change (turns the workers on). ['tmdb-api-key', 'tvdb-api-key', 'omdb-api-key'].forEach(function (id) { var el = document.getElementById(id); - if (el) el.addEventListener('change', saveKeys); + if (el) el.addEventListener('change', function () { saveKeys(); }); }); var autoplay = document.getElementById('video-billboard-autoplay'); - if (autoplay) autoplay.addEventListener('change', savePrefs); + if (autoplay) autoplay.addEventListener('change', function () { savePrefs(); }); var region = document.getElementById('video-watch-region'); - if (region) region.addEventListener('change', savePrefs); - // Video source picker (delegated — the panel is rendered async). - document.addEventListener('click', function (e) { - var btn = e.target.closest('[data-video-server-pick]'); - if (btn) pickServer(btn.getAttribute('data-video-server-pick')); - }); + if (region) region.addEventListener('change', function () { savePrefs(); }); + // Server toggle (Plex/Jellyfin) — select + reveal that server's config. + var toggles = document.querySelectorAll('[data-video-server-toggle]'); + for (var t = 0; t < toggles.length; t++) { + (function (b) { + b.addEventListener('click', function () { + pickServer(b.getAttribute('data-video-server-toggle')); + }); + })(toggles[t]); + } + // Server Connection (video's own creds) — save on change, test on click. + var connInputs = document.querySelectorAll('[data-video-conn]'); + for (var c = 0; c < connInputs.length; c++) { + connInputs[c].addEventListener('change', function () { saveConn(); }); + } + var connTests = document.querySelectorAll('[data-video-conn-test]'); + for (var d = 0; d < connTests.length; d++) { + (function (b) { + b.addEventListener('click', function () { + testConn(b.getAttribute('data-video-conn-test')); + }); + })(connTests[d]); + } + // Jellyfin user pick → store it + refresh that user's libraries. + var userSel = document.querySelector('[data-video-jellyfin-user]'); + if (userSel) userSel.addEventListener('change', function () { selectJellyfinUser(userSel.value); }); // Per-connection Test buttons (same behaviour as music's testConnection). var testBtns = document.querySelectorAll('[data-video-test-service]'); for (var k = 0; k < testBtns.length; k++) { @@ -207,6 +326,21 @@ }); })(testBtns[k]); } + // The shared "Save Settings" button belongs to MUSIC (it runs music's + // saveSettings, which would fire a music-config write from the video page). + // On the video side we intercept it (capture phase, before music's bubble + // listener), block music's handler, flush all video settings, and toast — + // so the button is real here and can't reach into music. Music side: this + // does nothing, so its behaviour is unchanged. + document.addEventListener('click', function (e) { + if (document.body.getAttribute('data-side') !== 'video') return; + if (!e.target.closest('#save-settings')) return; + e.preventDefault(); + e.stopImmediatePropagation(); + Promise.all([saveConn(true), save(true), saveKeys(true), savePrefs(true)]) + .then(function () { toast('Settings saved', 'success'); }) + .catch(function () { toast('Some settings could not be saved', 'error'); }); + }, true); document.addEventListener('soulsync:video-page-shown', onPageShown); } diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 484e7ba9..fe1c24d5 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -1472,22 +1472,9 @@ a.vd-guest:hover .vd-guest-name { color: rgb(var(--vd-accent-rgb)); } } .vid-pref-row--select select option { background: #16161c; color: #fff; } -/* ── settings: Video Source panel ────────────────────────────────────────── */ -.vid-source-using { font-size: 14px; color: var(--text-primary, #fff); display: flex; align-items: center; gap: 8px; } -.vid-source-check { color: #22c55e; font-weight: 800; } -.vid-source-none { font-size: 13.5px; line-height: 1.6; color: rgba(255, 255, 255, 0.72); } -.vid-source-none strong { color: #fff; } -.vid-source-pick { display: flex; gap: 10px; margin-bottom: 10px; } -.vid-source-btn { - padding: 8px 18px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 700; - background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14); color: rgba(255, 255, 255, 0.8); - transition: all 0.18s ease; -} -.vid-source-btn:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } -.vid-source-btn.active { background: rgba(56, 189, 248, 0.9); border-color: transparent; color: #fff; } -.vid-source-btn--off { opacity: 0.45; cursor: not-allowed; } -.vid-source-btn--off:hover { background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.8); } -.vid-source-off { font-size: 11px; opacity: 0.85; } +/* Server Connection mirrors the music server picker (.server-toggle-container / + .server-config-container) — no video-specific styling needed beyond those + shared classes. */ /* library: no-server gate banner */ .video-noserver {