video: Where to Watch — play-on-your-server tile + clickable providers

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.
This commit is contained in:
BoulderBadgeDad 2026-06-15 08:49:35 -07:00
parent 7081dad94e
commit 0246842000
5 changed files with 169 additions and 16 deletions

View file

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

View file

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

View file

@ -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/<kind>/<id>) 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

View file

@ -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 += '<a class="vd-prov vd-prov--server" href="' + esc(ex.server.url) +
'" target="_blank" rel="noopener" title="Play on ' + sv + '">' +
'<span class="vd-prov-ph vd-prov-play">▶</span>' +
'<span class="vd-prov-name">Play on ' + sv + '</span></a>';
}
// 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 ? '<img src="' + esc(p.logo) + '" alt="' + esc(p.name) + '" loading="lazy">'
: '<span class="vd-prov-ph">' + esc((p.name || '?').charAt(0)) + '</span>';
return '<div class="vd-prov" title="' + esc(p.name) + '">' + img +
'<span class="vd-prov-name">' + esc(p.name) + '</span></div>';
var inner = img + '<span class="vd-prov-name">' + esc(p.name) + '</span>';
return link
? '<a class="vd-prov" href="' + esc(link) + '" target="_blank" rel="noopener" title="Where to watch — ' + esc(p.name) + '">' + inner + '</a>'
: '<div class="vd-prov" title="' + esc(p.name) + '">' + inner + '</div>';
}).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) {

View file

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