Add a mesh scope to the Streams card

Local stays the default and the cheap path; mesh asks each partner for its own
sessions over SSH, live rather than cached, because a stream is true for
minutes and a cached one would be confidently wrong about the only thing the
card exists to say.
This commit is contained in:
Gmer4Lfe
2026-08-21 08:38:37 -04:00
parent 331b7b6b13
commit 81abc5adee
4 changed files with 202 additions and 8 deletions
+68 -6
View File
@@ -243,12 +243,20 @@ $_vv_doc_vars = array_merge(vv_conf_vars(), ['SCRIPTS_DIR' => SCRIPTS_DIR]);
<div id="vv-transcode-body">Loading...</div>
</div>
<!-- Scope control lives in the header rather than in the body because the body is replaced on
every poll, and a control that is re-rendered underneath a click is a control that loses
one. Hidden until a partner is known — on a single-host install the two buttons would be
two names for the same view, which is how the Media Stack tab handles it too. -->
<div class="vv-card" id="vv-streams" style="grid-column:span 4;">
<h3>
<span style="display:flex;align-items:center;gap:5px;">
<span class="vv-ico"><svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><polygon points="1.5,1 1.5,11 10,6"/></svg></span>
Streams
</span>
<span id="vv-streams-scope" style="display:none;gap:4px;margin-left:auto;">
<button class="vv-strm-tab on" data-strmscope="local">This host</button>
<button class="vv-strm-tab" data-strmscope="mesh">Mesh</button>
</span>
</h3>
<div id="vv-streams-body">Loading...</div>
</div>
@@ -986,6 +994,12 @@ function vvPollMonitor(live) {
const pt = d.partner ?? {};
const ptHosts = pt.hosts ?? [];
const ptRemote = d.remote_hosts ?? {};
// The Streams scope control is offered only once there is a partner to combine with. On a
// single-host install "This host" and "Mesh" are two names for the same view, and the
// Media Stack tab hides its equivalent for the same reason.
const _strmScopeEl = document.getElementById('vv-streams-scope');
if (_strmScopeEl) _strmScopeEl.style.display = Object.keys(ptRemote).length ? 'flex' : 'none';
const ptStatus = pt.enabled
? `<span style="color:#4caf50;">enabled</span> · sync every ${pt.sync_min}min`
: `<span style="color:#555;">disabled</span>`;
@@ -2407,7 +2421,8 @@ function vvRenderStreams() {
if (sessions.length === 0) {
el.innerHTML = `<div class="vv-stream-servers"><div class="vv-stream-left">${badges}</div></div>`
+ '<p class="vv-stream-empty">Nothing playing</p>';
+ `<p class="vv-stream-empty">Nothing playing${vvStreamScope === 'mesh' ? ' anywhere in the mesh' : ''}</p>`
+ vvStreamNodeNotes();
return;
}
@@ -2437,7 +2452,12 @@ function vvRenderStreams() {
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:2px;">
<span style="color:${s.paused ? '#fdd835' : '#aaa'};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">
<span style="color:${iconColor};">${icon}</span> ${vvEscHtml(s.title)}</span>
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${vvEscHtml(s.server)}</span>
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${vvEscHtml(s.server)}${
// Only in mesh scope: in local scope every row is this host, and the badge would be the
// same word on every line.
vvStreamScope === 'mesh' && s._hostName
? `<span class="vv-strm-node">${vvEscHtml(s._hostName)}</span>` : ''
}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:#555;margin-bottom:3px;">
<span>${vvEscHtml(s.user)}</span>
@@ -2468,22 +2488,36 @@ function vvRenderStreams() {
<div class="vv-stream-left">${badges}${mtypeSection}</div>
<div class="vv-stream-right">${deviceSection}${resSection}${codecSection}</div>
</div>
<div style="display:flex;gap:10px;">${sCols.map(vvStreamCol).join('')}</div>${overflow}`;
<div style="display:flex;gap:10px;">${sCols.map(vvStreamCol).join('')}</div>${overflow}${vvStreamNodeNotes()}`;
}
// Local by default, and it stays the cheap path — one call per media server on this box. Mesh adds
// a bounded SSH hop per partner, paid only while the operator is looking at it.
let vvStreamScope = 'local';
let vvStreamNodes = [];
function vvPollStreams() {
return fetch('/plugins/varaverk/api/media.php')
const scope = vvStreamScope;
return fetch('/plugins/varaverk/api/media.php' + (scope === 'mesh' ? '?scope=mesh' : ''))
.then(r => r.json())
.then(d => {
// A reply that arrives after the operator switched scope describes the other view. Dropped
// rather than rendered: mesh rows landing in a local view is the kind of wrongness nobody
// reads as a bug, they just believe it.
if (scope !== vvStreamScope) return;
vvLastSessions = d.sessions ?? [];
vvLastStreamNames = d.server_names ?? [];
vvStreamServerCount = d.server_count ?? 0;
vvStreamNodes = d.nodes ?? [];
vvLastStreamPollAt = Math.floor(Date.now() / 1000);
const el = document.getElementById('vv-streams-body');
if (vvStreamServerCount === 0) {
el.innerHTML = '<p class="vv-stream-empty">No media servers detected.<br>'
+ '<span>Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.</span></p>';
el.innerHTML = scope === 'mesh'
? '<p class="vv-stream-empty">No media servers anywhere in the mesh.</p>' + vvStreamNodeNotes()
: '<p class="vv-stream-empty">No media servers detected.<br>'
+ '<span>Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.</span></p>';
return;
}
vvRenderStreams();
@@ -2491,6 +2525,34 @@ function vvPollStreams() {
.catch(() => {});
}
// An unreachable partner is stated, never folded into the totals as zero. "Nobody is watching
// there" and "that node did not answer" are opposite facts and look identical in a count.
function vvStreamNodeNotes() {
if (vvStreamScope !== 'mesh') return '';
const bad = vvStreamNodes.filter(n => !n.local && !n.reachable);
if (!bad.length) return '';
return bad.map(n =>
`<div style="font-size:10px;color:#a05a2c;margin-top:4px;">${vvEscHtml(n.name)} — unreachable, not counted</div>`
).join('');
}
// Delegated off the card, so it survives the body being replaced on every poll.
document.getElementById('vv-streams')?.addEventListener('click', ev => {
const btn = ev.target.closest('[data-strmscope]');
if (!btn) return;
const next = btn.dataset.strmscope;
if (next === vvStreamScope) return;
vvStreamScope = next;
document.querySelectorAll('#vv-streams [data-strmscope]').forEach(b =>
b.classList.toggle('on', b.dataset.strmscope === next));
// Switching is a request to see the other view now, not at the next tick.
const el = document.getElementById('vv-streams-body');
if (el) el.innerHTML = '<p class="vv-stream-empty">Loading…</p>';
vvPollStreams();
});
// Guarded like the others — this one reaches out to every configured media server, so a wedged
// Emby is exactly the case where unguarded ticks would stack.
vvPollRunner(vvPollStreams, 12000);