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:
parent
7081dad94e
commit
0246842000
5 changed files with 169 additions and 16 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue