Video side: own server connection (Plex/Jellyfin), music-style picker, isolated

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.
This commit is contained in:
BoulderBadgeDad 2026-06-15 16:42:22 -07:00
parent 8f6f992670
commit 80dd2ff21c
12 changed files with 646 additions and 139 deletions

View file

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

View file

@ -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/<service>/status", methods=["GET"])

View file

@ -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

View file

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

View file

@ -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)."""

View file

@ -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

View file

@ -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"

View file

@ -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/<svc>/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

View file

@ -5133,24 +5133,6 @@
<!-- Video API Configuration (placeholders; shown only on the video
side in place of the music API services). Same structure as the
music service frames above. -->
<!-- Video Source — the video side's OWN server pick + library
mapping, independent of the music server (it just reuses
the shared Plex/Jellyfin credentials). -->
<div class="settings-group" data-stg="connections" data-video-only>
<h3>Video Source</h3>
<div class="vid-source-panel" data-video-source-panel>
<div class="callback-help">Checking…</div>
</div>
<div class="form-group video-library-selectors" style="margin-top: 16px;">
<label>Movies Library:</label>
<select data-video-lib-select="movies"><option value="">Loading...</option></select>
<label style="margin-top: 12px;">TV Shows Library:</label>
<select data-video-lib-select="tv"><option value="">Loading...</option></select>
<small style="color: #999; font-size: 0.9em; display: block; margin-top: 5px;">
Which libraries the video side scans. <span data-video-lib-status></span>
</small>
</div>
</div>
<div class="settings-group" data-stg="connections" data-video-only>
<h3>API Configuration <button class="stg-accordion-toggle" onclick="toggleAllServiceAccordions(this)">Expand All</button></h3>
<div class="api-service-frame stg-service" data-video-service="tmdb">
@ -5210,6 +5192,75 @@
</div>
</div>
</div>
<!-- Server Connection — mirrors the music server picker, scoped
to Plex/Jellyfin. The toggle BOTH selects the active video
server AND reveals its credentials. Creds are pre-filled from
the music settings but stored separately (video.db), so the
two sides stay fully independent. Sits at the bottom of the
Connections tab. -->
<div class="settings-group" data-stg="connections" data-video-only>
<h3>Server Connection</h3>
<p class="callback-help" style="margin: -4px 0 14px;">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.</p>
<div class="server-toggle-container">
<button class="server-toggle-btn" type="button" data-video-server-toggle="plex">
<img src="https://www.plex.tv/wp-content/themes/plex/assets/img/plex-logo.svg" alt="Plex" class="server-logo">
</button>
<button class="server-toggle-btn" type="button" data-video-server-toggle="jellyfin">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/jellyfin.png" alt="Jellyfin" class="server-logo">
</button>
</div>
<!-- Plex -->
<div class="server-config-container hidden" data-video-server-config="plex">
<div class="form-group">
<label>Plex Server URL:</label>
<input type="text" data-video-conn="plex-url" placeholder="http://localhost:32400">
</div>
<div class="form-group">
<label>Plex Token:</label>
<input type="password" data-video-conn="plex-token" placeholder="X-Plex-Token">
</div>
<div class="callback-help" data-video-conn-note="plex"></div>
<div class="form-actions">
<button class="test-button" type="button" data-video-conn-test="plex">Test Connection</button>
</div>
</div>
<!-- Jellyfin -->
<div class="server-config-container hidden" data-video-server-config="jellyfin">
<div class="form-group">
<label>Jellyfin Server URL:</label>
<input type="text" data-video-conn="jellyfin-url" placeholder="http://localhost:8096">
</div>
<div class="form-group">
<label>API Key:</label>
<input type="password" data-video-conn="jellyfin-key" placeholder="Jellyfin API Key">
</div>
<div class="callback-help" data-video-conn-note="jellyfin"></div>
<div class="form-actions">
<button class="test-button" type="button" data-video-conn-test="jellyfin">Test Connection</button>
</div>
<div class="form-group" data-video-jellyfin-user-wrap style="display:none; margin-top:14px;">
<label>Jellyfin User</label>
<select data-video-jellyfin-user><option value="">Select User</option></select>
<small style="color: #999; font-size: 0.9em; display: block; margin-top: 5px;">
Which user's libraries to scan. The Movies/TV lists below reflect this user — pick an admin to see everything.
</small>
</div>
</div>
<!-- Which libraries on the chosen server the video side scans. -->
<div class="form-group video-library-selectors" style="margin-top: 20px;">
<label>Movies Library:</label>
<select data-video-lib-select="movies"><option value="">Loading...</option></select>
<label style="margin-top: 12px;">TV Shows Library:</label>
<select data-video-lib-select="tv"><option value="">Loading...</option></select>
<small style="color: #999; font-size: 0.9em; display: block; margin-top: 5px;">
Which libraries the video side scans. <span data-video-lib-status></span>
</small>
</div>
</div>
<!-- Video Preferences — lives on the Library settings tab -->
<div class="settings-group" data-stg="library" data-video-only>
<h3>Video Preferences</h3>

View file

@ -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') {

View file

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ── 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 '<button class="vid-source-btn vid-source-btn--off" type="button" disabled ' +
'title="Not connected — set it up in Music settings">' + label +
' <span class="vid-source-off">(not connected)</span></button>';
}
return '<button class="vid-source-btn' + (id === selected ? ' active' : '') +
'" type="button" data-video-server-pick="' + id + '">' + label + '</button>';
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 = '<div class="vid-source-pick">' +
srvOpt('plex', 'Plex', plex, d.server) + srvOpt('jellyfin', 'Jellyfin', jelly, d.server) + '</div>';
if (!plex && !jelly) {
html += '<div class="callback-help">Neither is connected yet. Set up Plex or Jellyfin in ' +
'<strong>Music &rarr; Settings &rarr; Server Connections</strong> — the video side reuses those credentials.</div>';
} else {
html += '<div class="callback-help">The video side uses this server, independent of Music. ' +
'Greyed-out options aren\'t connected — set them up in Music settings.</div>';
}
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);
}

View file

@ -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 {