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:
parent
8f6f992670
commit
80dd2ff21c
12 changed files with 646 additions and 139 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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, '<').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 '<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 → Settings → 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue