From 02468420001785732a8a0a210800f4d3fd8afdd6 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 15 Jun 2026 08:49:35 -0700 Subject: [PATCH] =?UTF-8?q?video:=20Where=20to=20Watch=20=E2=80=94=20play-?= =?UTF-8?q?on-your-server=20tile=20+=20clickable=20providers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Where to Watch' section is now actionable: - For an OWNED title it leads with a 'Play on Plex/Jellyfin' tile (green, play glyph) that deep-links straight to the item on your server — Plex via the app.plex.tv web app (machineIdentifier fetched once + cached), Jellyfin via its web detail page. Built in engine.item_extras from the row's server_source + server_id and the shared media-server config (same source poster.py uses). - Streaming providers (TMDB/JustWatch) are now clickable → the where-to-watch page, with a hover lift. Owned-only: preview (tmdb) items have no library row so they get no server tile. Seam tests cover the Jellyfin + Plex link building and the unowned no-link case. 240 video-suite tests pass. --- core/video/enrichment/engine.py | 89 ++++++++++++++++++++++++++---- tests/test_video_enrichment.py | 56 ++++++++++++++++++- tests/test_video_side_shell.py | 4 ++ webui/static/video/video-detail.js | 25 +++++++-- webui/static/video/video-side.css | 11 ++++ 5 files changed, 169 insertions(+), 16 deletions(-) diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index d5219fb7..8b5f473e 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -150,19 +150,88 @@ class VideoEnrichmentEngine: def item_extras(self, kind, item_id) -> dict: """Live TMDB extras (trailer / where-to-watch / similar) for the detail - page. Not cached — fetched per view so providers stay current.""" + page. Not cached — fetched per view so providers stay current. For an + owned item we also surface a 'watch on your server' deep link as the first + where-to-watch option.""" + out = {} w = self.workers.get("tmdb") - if not w or not w.enabled: - return {} - info = (self.db.movie_match_info(item_id) if kind == "movie" - else self.db.show_match_info(item_id)) - if not info or not info.get("tmdb_id"): - return {} + if w and w.enabled: + info = (self.db.movie_match_info(item_id) if kind == "movie" + else self.db.show_match_info(item_id)) + if info and info.get("tmdb_id"): + try: + out = w.client.extras(kind, info["tmdb_id"]) or {} + except Exception: + logger.exception("item_extras failed for %s %s", kind, item_id) + srv = self._server_watch_link(kind, item_id) + if srv: + out["server"] = srv + return out + + def _server_watch_link(self, kind, item_id) -> dict | None: + """A 'play on your media server' deep link for an owned item, or None. + Plex → the Plex web app at the item; Jellyfin → its web detail page.""" + table = "movies" if kind == "movie" else "shows" try: - return w.client.extras(kind, info["tmdb_id"]) or {} + with self.db.connect() as c: + row = c.execute( + f"SELECT server_source, server_id FROM {table} WHERE id=?", (item_id,)).fetchone() except Exception: - logger.exception("item_extras failed for %s %s", kind, item_id) - return {} + return None + if not row: + return None + source, sid = row["server_source"], row["server_id"] + if not source or not sid: + return None # not on a server (e.g. a wishlist row) + try: + from config.settings import config_manager + if source == "plex": + cfg = config_manager.get_plex_config() or {} + base, token = cfg.get("base_url"), cfg.get("token") + if not base or not token: + return None + mid = self._plex_machine_id(base, token) + if not mid: + return None + from urllib.parse import quote + key = quote("/library/metadata/" + str(sid), safe="") + return {"server": "Plex", + "url": "https://app.plex.tv/desktop/#!/server/%s/details?key=%s" % (mid, key)} + if source == "jellyfin": + cfg = config_manager.get_jellyfin_config() or {} + base = cfg.get("base_url") + if not base: + return None + return {"server": "Jellyfin", + "url": base.rstrip("/") + "/web/index.html#!/details?id=" + str(sid)} + except Exception: + logger.exception("server watch link failed for %s %s", kind, item_id) + return None + + def _plex_machine_id(self, base, token): + """The Plex server's machineIdentifier (needed for app.plex.tv deep links), + fetched once and cached per base URL.""" + cached = getattr(self, "_plex_mid", None) + if cached and cached[0] == base: + return cached[1] + try: + import requests + r = requests.get(base.rstrip("/") + "/identity", + params={"X-Plex-Token": token}, + headers={"Accept": "application/json"}, timeout=8) + mid = None + try: + mid = ((r.json() or {}).get("MediaContainer") or {}).get("machineIdentifier") + except Exception: + import re + m = re.search(r'machineIdentifier="([^"]+)"', r.text or "") + mid = m.group(1) if m else None + if mid: + self._plex_mid = (base, mid) + return mid + except Exception: + logger.exception("plex identity fetch failed") + return None # ── in-app search + TMDB-backed (un-owned) detail ───────────────────────── def search(self, query) -> list: diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index 88c37115..edcb160e 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -455,7 +455,18 @@ def test_tmdb_extras_parse(monkeypatch): assert ex["similar"][0]["title"] == "Other" and ex["similar"][0]["kind"] == "movie" -def test_item_extras_needs_tmdb_and_id(db): +def _no_server_config(monkeypatch): + """Stub the shared config_manager so no media-server watch link is added + (keeps these tests independent of the dev machine's Plex/Jellyfin config).""" + import config.settings as cs + class CM: + def get_plex_config(self): return {} + def get_jellyfin_config(self): return {} + monkeypatch.setattr(cs, "config_manager", CM()) + + +def test_item_extras_needs_tmdb_and_id(db, monkeypatch): + _no_server_config(monkeypatch) sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) # no tmdb_id class C: @@ -468,6 +479,49 @@ def test_item_extras_needs_tmdb_and_id(db): assert eng.item_extras("show", sid2) == {"trailer": {"key": "x"}} +def test_item_extras_adds_jellyfin_watch_link(db, monkeypatch): + mid = db.upsert_movie("jellyfin", {"server_id": "abc123", "title": "Owned"}) + import config.settings as cs + class CM: + def get_jellyfin_config(self): return {"base_url": "http://jelly:8096/"} + def get_plex_config(self): return {} + monkeypatch.setattr(cs, "config_manager", CM()) + ex = VideoEnrichmentEngine(db, {}).item_extras("movie", mid) # no tmdb worker needed + assert ex["server"] == {"server": "Jellyfin", + "url": "http://jelly:8096/web/index.html#!/details?id=abc123"} + + +def test_item_extras_adds_plex_watch_link(db, monkeypatch): + mid = db.upsert_movie("plex", {"server_id": "555", "title": "Owned"}) + import config.settings as cs + class CM: + def get_plex_config(self): return {"base_url": "http://plex:32400", "token": "T"} + def get_jellyfin_config(self): return {} + monkeypatch.setattr(cs, "config_manager", CM()) + class _R: + text = "" + def json(self): return {"MediaContainer": {"machineIdentifier": "MID123"}} + monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _R())) + ex = VideoEnrichmentEngine(db, {}).item_extras("movie", mid) + assert ex["server"]["server"] == "Plex" + assert "app.plex.tv" in ex["server"]["url"] and "MID123" in ex["server"]["url"] + assert "%2Flibrary%2Fmetadata%2F555" in ex["server"]["url"] # url-encoded item key + + +def test_item_extras_no_server_link_when_unowned(db, monkeypatch): + # A wishlist-style row with no server id → no watch link. + import config.settings as cs + class CM: + def get_plex_config(self): return {"base_url": "http://plex:32400", "token": "T"} + def get_jellyfin_config(self): return {} + monkeypatch.setattr(cs, "config_manager", CM()) + with db.connect() as c: + c.execute("INSERT INTO movies (title, server_source, server_id) VALUES ('W', NULL, NULL)") + c.commit() + rid = c.execute("SELECT id FROM movies WHERE title='W'").fetchone()["id"] + assert "server" not in VideoEnrichmentEngine(db, {}).item_extras("movie", rid) + + def test_tmdb_season_episodes_parses(monkeypatch): class _Resp: def __init__(self, b): self._b = b diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index b8d0e89b..2149f9a6 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -420,6 +420,10 @@ def test_detail_keeps_preview_items_in_app(): # 'More like this' and cast now drill in via the shared event, not external links. assert "data-vd-sim" in src and "data-vd-person" in src assert "/video-detail/tmdb/" in src # similar/cast link in-app + # Where-to-watch: a "Play on your server" tile for owned items + clickable + # streaming providers. + assert "vd-prov--server" in src and "Play on " in src + assert "ex.providers_link" in src # The old external 'similar' link (themoviedb.org//) is gone — the # only remaining themoviedb.org ref is the TMDB badge logo asset for owned items. assert "www.themoviedb.org/' + (s.kind" not in src diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 768e434f..fa9f1c00 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -314,15 +314,30 @@ var ps = q('[data-vd-providers-section]'), ph = q('[data-vd-providers]'); if (ps && ph) { + var html = ''; + // If it's on your media server, that's the best place to watch — lead + // with a "Play on Plex/Jellyfin" tile that deep-links to the item. + if (ex.server && ex.server.url) { + var sv = esc(ex.server.server || 'Server'); + html += '' + + '' + + 'Play on ' + sv + ''; + } + // Streaming providers (JustWatch via TMDB) link to the where-to-watch page. + var link = ex.providers_link || ''; if (ex.providers && ex.providers.length) { - ps.hidden = false; - ph.innerHTML = ex.providers.map(function (p) { + html += ex.providers.map(function (p) { var img = p.logo ? '' + esc(p.name) + '' : '' + esc((p.name || '?').charAt(0)) + ''; - return '
' + img + - '' + esc(p.name) + '
'; + var inner = img + '' + esc(p.name) + ''; + return link + ? '' + inner + '' + : '
' + inner + '
'; }).join(''); - } else { ps.hidden = true; } + } + ps.hidden = !html; + ph.innerHTML = html; } var ss = q('[data-vd-similar-section]'), sh = q('[data-vd-similar]'); if (ss && sh) { diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index c5d8eed7..25dfd627 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -754,6 +754,17 @@ body[data-side="video"] .dashboard-header-sweep { color: #fff; background: linear-gradient(135deg, rgba(var(--vd-accent-rgb), 0.4), rgba(var(--vd-accent-rgb), 0.1)); } .vd-prov-name { font-size: 11.5px; color: rgba(255, 255, 255, 0.7); line-height: 1.25; overflow: hidden; text-overflow: ellipsis; } +/* Clickable provider / server tiles (anchors). */ +a.vd-prov { text-decoration: none; color: inherit; cursor: pointer; transition: transform 0.2s ease; } +a.vd-prov:hover { transform: translateY(-4px); } +a.vd-prov:hover .vd-prov-name { color: #fff; } +a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-accent-rgb), 0.6); } +/* "Play on your server" tile — leads the row, glows green like the owned ribbon. */ +.vd-prov--server .vd-prov-play { + background: linear-gradient(135deg, #22c55e, #15803d); color: #fff; font-size: 22px; + box-shadow: 0 6px 18px rgba(34, 197, 94, 0.45); border-color: rgba(255, 255, 255, 0.2); +} +.vd-prov--server .vd-prov-name { color: #6ee7a0; font-weight: 700; } .vd-similar { display: flex; gap: 16px; overflow-x: auto; padding-bottom: 12px; scroll-snap-type: x proximity; } .vd-similar::-webkit-scrollbar { height: 8px; }