Files
Varaverk/Plugin/unraid/pages/arrs.php
T
Gmer4Lfe 90774f1442 Put the AI toggles and findings on the pages they act on
The five AI_ASSIST_ switches and the findings they produce were reachable only
from the AI tab, which is a long way from the page a finding is about.

Nothing was synchronised because nothing needed to be: findings are one file per
finding, and every surface is a view over that store with actions going to the
same endpoint. Acting on the Watchdog tab shows on the AI tab because they are
not two copies. The strip reloads rather than editing its own row, which is the
only way they could have drifted apart.

Each strip shows one page's kinds. Actions are whatever the server offers for
that row, so Move appears on media findings without this card knowing what a
move is.
2026-08-14 22:32:55 -04:00

723 lines
40 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Media Stack tab. Library counts, queue state, root folders and recent cleanup/discovery activity
// for every Sonarr / Radarr / Lidarr instance across all nodes.
//
// DESIGN PRINCIPLES
// Pure view — markup plus a poll against api/arrs.php. Instance discovery, live fetching
// and stats parsing all live in include/arrs.php.
//
// Local nodes are live, remote nodes are cached. The page surfaces cache age rather than
// hiding it, so a 2-hour-old partner count is never presented as current.
//
// OPERATIONAL SAFEGUARDS
// Read-only with respect to media. Nothing here triggers a scan, import, or deletion —
// the arr cleanups are scheduled scripts, not page buttons.
//
// A node with no cache yet renders as "not collected", distinct from a node that genuinely
// has an empty library.
//
// RENDERS
// Per-node arr cards: tracked counts, queue, root folders, cleanup and discovery stats
// Inline conf editing for arr-related settings
//
// DEPENDS ON
// api/arrs.php polled every 60s → include/arrs.php
// api/confform.php inline conf edits → include/confform.php
require_once dirname(__DIR__) . '/include/confui.php';
require_once dirname(__DIR__) . '/include/ai_chat.php';
?>
<style>
/* ── Cards ───────────────────────────────────────────────── */
.vv-arr-card { background:#161616;border:1px solid #2a2a2a;border-radius:6px;padding:12px 14px;min-width:0; }
.vv-arr-sep { border:none;border-top:1px solid #1e1e1e;margin:8px 0; }
/* ── Card header ─────────────────────────────────────────── */
.vv-arr-hdr { display:flex;justify-content:space-between;align-items:center;margin-bottom:10px; }
.vv-arr-name { font-size:12px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.06em; }
.vv-arr-dot { width:7px;height:7px;border-radius:50%;flex-shrink:0;margin-right:4px;display:inline-block; }
.vv-arr-ver { font-size:10px;color:#444; }
/* ── Big stats ───────────────────────────────────────────── */
.vv-arr-stats { display:flex;gap:0;margin-bottom:10px; }
.vv-arr-stat { flex:1;text-align:center;padding:0 4px; }
.vv-arr-stat + .vv-arr-stat { border-left:1px solid #1e1e1e; }
.vv-arr-stat-n{ font-size:18px;font-weight:700;color:#ccc;line-height:1.1; }
.vv-arr-stat-l{ font-size:9px;color:#444;text-transform:uppercase;letter-spacing:.06em;margin-top:1px; }
.vv-arr-stat-n.dim { color:#333; }
/* ── Disk bar ────────────────────────────────────────────── */
.vv-arr-disk { margin-bottom:8px; }
.vv-arr-disk-bar { height:3px;background:#1a1a1a;border-radius:2px;overflow:hidden;margin-top:3px; }
.vv-arr-disk-fill{ height:100%;border-radius:2px;transition:width .3s; }
.vv-arr-disk-lbl { display:flex;justify-content:space-between;align-items:baseline; }
/* ── Queue badges ────────────────────────────────────────── */
.vv-arr-q { display:flex;gap:6px;align-items:center;flex-wrap:wrap; }
.vv-arr-qbadge{ font-size:10px;padding:1px 7px;border-radius:3px;font-weight:600; }
.vv-arr-qbadge.dl { background:#0a1a2a;color:#4a9eff;border:1px solid #1a3a5a; }
.vv-arr-qbadge.warn { background:#1f1500;color:#ffb74d;border:1px solid #3a2800; }
.vv-arr-qbadge.err { background:#200d0d;color:#ef5350;border:1px solid #3a1a1a; }
.vv-arr-qbadge.idle { background:#111;color:#333;border:1px solid #1e1e1e; }
/* ── Health ──────────────────────────────────────────────── */
.vv-arr-health{ background:#1a0a0a;border-left:2px solid #ef5350;border-radius:0 3px 3px 0;
padding:5px 8px;margin-top:6px;font-size:10px;color:#c66;line-height:1.5; }
.vv-arr-health.warn { background:#1a1000;border-color:#ff9800;color:#c96; }
/* ── Cleanup / Discovery rows ────────────────────────────── */
.vv-arr-meta { display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin:3px 0; }
.vv-arr-meta-lbl{ font-size:10px;color:#444; }
.vv-arr-meta-val{ font-size:10px;color:#666;text-align:right; }
.vv-arr-meta-sub{ font-size:9px;color:#333;margin-top:0px; }
.vv-arr-meta-sub.warn { color:#8b4; }
/* ── Root path ───────────────────────────────────────────── */
.vv-arr-root { font-size:10px;color:#2a2a2a;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;margin-top:8px;font-family:monospace; }
/* ── Node section ────────────────────────────────────────── */
.vv-arr-node { grid-column:1/-1; }
.vv-arr-node-hdr { display:flex;align-items:baseline;gap:10px;margin-bottom:10px;
padding-bottom:7px;border-bottom:1px solid #1e1e1e; }
.vv-arr-node-id { font-size:11px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.08em; }
.vv-arr-node-host { font-size:12px;color:#888; }
.vv-arr-node-tag { font-size:9px;color:#2a3a2a;background:#0d180d;border:1px solid #1a3a1a;
border-radius:3px;padding:1px 6px;margin-left:2px; }
.vv-arr-cards { display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px; }
/* ── Sync + Recovery cards ───────────────────────────────── */
.vv-arr-sr-grid { grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;gap:10px; }
@media (max-width:700px) { .vv-arr-sr-grid { grid-template-columns:1fr; } }
.vv-arr-sr-num { font-size:20px;font-weight:700;color:#ccc;line-height:1; }
.vv-arr-sr-lbl { font-size:9px;color:#444;text-transform:uppercase;letter-spacing:.06em;margin-top:2px; }
.vv-arr-sr-row { display:flex;gap:16px;flex-wrap:wrap; }
.vv-arr-sr-item { display:flex;flex-direction:column;gap:1px; }
/* ── Settings card ───────────────────────────────────────── */
.vv-arr-set-card { grid-column:1/-1; }
.vv-arr-tog-lbl { font-size:11px;color:#666; }
.vv-arr-set-inp { background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#bbb;
font-size:11px;padding:3px 7px;outline:none;width:60px; }
.vv-arr-set-inp:focus { border-color:#444; }
.vv-arr-pill { display:inline-block;font-size:9px;padding:1px 7px;border-radius:3px;font-weight:700; }
.vv-arr-pill.ok { background:#0d1f0d;color:#4caf50;border:1px solid #1a3a1a; }
.vv-arr-pill.err { background:#200d0d;color:#ef5350;border:1px solid #3a1a1a; }
.vv-arr-pill.off { background:#1e1e1e;color:#444;border:1px solid #2a2a2a; }
/* ── Media server filter ─────────────────────────────────── */
.vv-arr-mstab { background:#111;border:1px solid #242424;color:#555;font-size:10px;
padding:2px 10px;border-radius:3px;cursor:pointer;text-transform:uppercase;
letter-spacing:.05em; }
.vv-arr-mstab:hover { color:#bbb;border-color:#3a3a3a; }
.vv-arr-mstab.on { color:#4caf50;border-color:#2d4a2d;background:#0d1a0d; }
</style>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;padding:0 2px;">
<span style="font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;">Media Stack</span>
<span style="font-size:11px;color:#3a3a3a;" id="vv-arrs-ts"></span>
</div>
<div id="vv-arrs-grid" style="display:grid;grid-template-columns:repeat(8,1fr);gap:12px;">
<div style="grid-column:1/-1;color:#444;font-size:12px;padding:24px 0;text-align:center;">Loading…</div>
</div>
<?php if (vv_ai_ui_on()): ?>
<div class="vv-card" id="vv-arr-ai-card" style="margin-top:12px;">
<?php
// The factory, the profile registry and the store are three separate emits and none implies
// the others — omitting any renders a chat that looks complete and dies on the first click.
vv_ai_profiles_script();
vv_ai_chat_store_script();
vv_ai_chat_assets();
// The health messages on this page quote indexers and lists by name and link to servarr's wiki,
// which is a lot of vocabulary to carry to a search box. Scoped to the tab so "why are the lists
// unavailable" resolves against what is on screen.
vv_ai_chat_markup('vv-arr-ai', [
'profile' => 'varaverk',
'compact' => true,
'title' => 'Assistant',
'scopeLabel' => 'Arrs',
'empty' => 'Ask about an instance, a health warning, or what the cleanups did.',
'placeholder' => 'Ask about what is on this page…',
]); ?>
</div>
<?php endif; ?>
<?php
// The conf sections this page is about, drawn by the shared renderer. They were reachable
// only from the Settings tab's catch-all, which is a long way to go for a setting named
// after the page you are already looking at.
//
// The match mirrors VV_UI_SECTION_SURFACES in confform.php and has to keep mirroring it: that
// constant is what tells the assistant where to send someone, and this is what the page actually
// draws. The two disagreeing means being given directions to a card that is not there.
vv_conf_ui_card('vv-cf-arrs', 'arr|sonarr|radarr|lidarr|media|play state|emby|jellyfin|plex', 'Media settings');
// Same card as the Watchdog and AI tabs carry — one conf value, three views. AI_ASSIST_DISCOVERY
// is the switch that decides whether the classification triage runs at all, and this is the page
// it produces findings about.
vv_conf_ui_card('vv-cf-ms-ai', 'AI Feature Toggles', 'AI features');
// Misfiled media, with the Move action attached. Same store the AI tab reads, so a series moved
// from either surface is gone from both.
vv_ai_findings_strip('vv-ms-fnd', ['media_misfiled'], 'Media findings');
?>
<script>
(function() {
// ── Arr card ──────────────────────────────────────────────────────────────────
function _arrCard(arr) {
const LABELS = { sonarr:'Sonarr', radarr:'Radarr', lidarr:'Lidarr' };
const name = LABELS[arr.type] || arr.type;
const online = !!arr.online;
const dotCol = online ? '#4caf50' : (arr.remote ? '#444' : '#c62828');
// ── Offline / remote state ────────────────────────────────────────────────
if (!online) {
const msg = arr.remote ? 'not available from this host' : 'offline';
return `<div class="vv-arr-card" style="opacity:.6;">
<div class="vv-arr-hdr">
<span class="vv-arr-name">${name}</span>
<span><span class="vv-arr-dot" style="background:${dotCol}"></span>
<span class="vv-arr-ver">${msg}</span></span>
</div>
${arr.root ? `<div class="vv-arr-root">${vvEscHtml(arr.root)}</div>` : ''}
</div>`;
}
// ── Stats ─────────────────────────────────────────────────────────────────
let stats = '';
if (arr.type === 'sonarr') {
stats = `
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.total)}</div><div class="vv-arr-stat-l">Series</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.monitored)}</div><div class="vv-arr-stat-l">Monitored</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.episodes)}</div><div class="vv-arr-stat-l">Episodes</div></div>`;
} else if (arr.type === 'radarr') {
const pct = arr.total ? Math.round((arr.files||0)/arr.total*100) : 0;
stats = `
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.total)}</div><div class="vv-arr-stat-l">Movies</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.files)}</div><div class="vv-arr-stat-l">Have file</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n ${pct<50?'dim':''}">${pct}%</div><div class="vv-arr-stat-l">Complete</div></div>`;
} else if (arr.type === 'lidarr') {
stats = `
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.total)}</div><div class="vv-arr-stat-l">Artists</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.monitored)}</div><div class="vv-arr-stat-l">Monitored</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n">${_n(arr.albums)}</div><div class="vv-arr-stat-l">Albums</div></div>`;
}
// ── Disk bar ──────────────────────────────────────────────────────────────
let disk = '';
if (arr.disk?.length) {
const d = arr.disk.reduce((a,b) => (b.totalSpace||0) > (a.totalSpace||0) ? b : a);
const free = d.freeSpace, tot = d.totalSpace;
if (free && tot) {
const usedPct = Math.round((1 - free/tot)*100);
const col = usedPct > 90 ? '#ef5350' : usedPct > 75 ? '#ffb74d' : '#4caf50';
disk = `<div class="vv-arr-disk">
<div class="vv-arr-disk-lbl">
<span style="font-size:10px;color:#555;">${_sz(free)} free</span>
<span style="font-size:10px;color:#333;">${_sz(tot)} · ${usedPct}%</span>
</div>
<div class="vv-arr-disk-bar">
<div class="vv-arr-disk-fill" style="width:${usedPct}%;background:${col};"></div>
</div>
</div>`;
}
}
// ── Queue ─────────────────────────────────────────────────────────────────
const q = arr.queue || {};
const qDl = q.dl || 0, qWrn = q.warn || 0, qErr = q.err || 0;
const qTot = qDl + qWrn + qErr;
let qHtml;
if (qTot === 0) {
qHtml = `<span class="vv-arr-qbadge idle">idle</span>`;
} else {
qHtml = '';
if (qDl) qHtml += `<span class="vv-arr-qbadge dl">${qDl} downloading</span>`;
if (qWrn) qHtml += `<span class="vv-arr-qbadge warn">${qWrn} warning</span>`;
if (qErr) qHtml += `<span class="vv-arr-qbadge err">${qErr} error</span>`;
}
// ── Health ────────────────────────────────────────────────────────────────
let health = '';
if (arr.health?.length) {
const hasErr = arr.health.some(h => h.type === 'error');
const cls = hasErr ? 'vv-arr-health' : 'vv-arr-health warn';
// Escaped, and it is the one string on this page that most needs it. These messages come
// straight from the arr's own API and quote things nobody here controls — indexer names from
// Prowlarr, import-list names, release titles. "Indexers unavailable due to failures: NzbNoob
// (Prowlarr)" is an attacker-supplied substring wearing a status message's clothes, and it
// was going into innerHTML raw. The <br> is joined after escaping so the separator survives
// while the content cannot introduce markup of its own.
const msgs = arr.health.slice(0,3).map(h => vvEscHtml(h.message || h.type)).join('<br>');
health = `<div class="${cls}">${msgs}</div>`;
}
// ── Cleanup ───────────────────────────────────────────────────────────────
let cleanup = '';
const cl = arr.cleanup || {};
if (cl.last_run) {
const st = cl.status === 'ok' ? '<span style="color:#4caf50">✓</span>' : '<span style="color:#ef5350">✗</span>';
const orph = cl.orphans || 0, junk = cl.junk || 0;
const orCol = (orph || junk) ? 'color:#8b4a2a' : 'color:#333';
cleanup = `
<div class="vv-arr-meta">
<span class="vv-arr-meta-lbl">Cleanup</span>
<span class="vv-arr-meta-val">${_relTime(cl.last_run)} ${st}</span>
</div>
${cl.tracked != null ? `<div class="vv-arr-meta-sub" style="${orCol}">
${_n(cl.tracked)} files · ${orph} orphans${orph && cl.orphans_sz && cl.orphans_sz !== '0B' ? ' (' + vvEscHtml(cl.orphans_sz) + ')' : ''}${junk ? ' · ' + junk + ' junk' + (cl.junk_sz && cl.junk_sz !== '0B' ? ' (' + vvEscHtml(cl.junk_sz) + ')' : '') : ''}
</div>` : ''}`;
}
// ── Discovery ─────────────────────────────────────────────────────────────
let disc = '';
const dsc = arr.discovery || {};
if (dsc.last_run) {
const added = dsc.added != null ? `&thinsp;<span style="color:#6fcf97">+${dsc.added}</span>` : '';
disc = `<div class="vv-arr-meta">
<span class="vv-arr-meta-lbl">Discovery</span>
<span class="vv-arr-meta-val">${_relTime(dsc.last_run)}${added}</span>
</div>`;
}
const hasMeta = cleanup || disc;
return `<div class="vv-arr-card">
<div class="vv-arr-hdr">
<span class="vv-arr-name">${name}</span>
<span>
<span class="vv-arr-dot" style="background:${dotCol}"></span>
<span class="vv-arr-ver">${vvEscHtml(arr.version || 'online')}</span>
</span>
</div>
<div class="vv-arr-stats">${stats}</div>
${disk}
<hr class="vv-arr-sep" style="margin:6px 0 8px;">
<div class="vv-arr-q">${qHtml}</div>
${health}
${hasMeta ? `<hr class="vv-arr-sep" style="margin:8px 0 4px;">${cleanup}${disc}` : ''}
<div class="vv-arr-root">${vvEscHtml(arr.root || '')}</div>
</div>`;
}
// ── Node section ──────────────────────────────────────────────────────────────
function _nodeSection(node) {
const isLocal = node.local;
const isMiss = node.cache_miss;
let tag, body;
if (isLocal) {
tag = '<span class="vv-arr-node-tag">local</span>';
body = `<div class="vv-arr-cards">${node.arrs.map(_arrCard).join('')}</div>`;
} else if (isMiss) {
tag = `<span class="vv-arr-node-tag" style="background:#111;border-color:#222;color:#333;">no data</span>
<button id="vv-arr-rfsh-${vvEscAttr(node.host)}" onclick="vvArrsRefreshRemote('${vvEscAttr(node.host)}')"
style="font-size:9px;color:#4a9eff;background:#0a1a2a;border:1px solid #1a3a5a;
border-radius:3px;padding:1px 8px;cursor:pointer;margin-left:6px;">↻ Fetch now</button>`;
body = `<div style="color:#333;font-size:11px;padding:10px 0;">
No remote data yet — first fetch runs within 2 hours, or click ↻ Fetch now.
</div>`;
} else {
const age = node.cache_age || 0;
const ageStr = age < 3600 ? Math.floor(age/60) + 'm ago'
: age < 86400 ? Math.floor(age/3600) + 'h ago'
: Math.floor(age/86400) + 'd ago';
tag = `<span class="vv-arr-node-tag" style="background:#1a1000;border-color:#3a2800;color:#b87;">cached ${ageStr}</span>
<button id="vv-arr-rfsh-${vvEscAttr(node.host)}" onclick="vvArrsRefreshRemote('${vvEscAttr(node.host)}')"
style="font-size:9px;color:#666;background:none;border:1px solid #2a2a2a;
border-radius:3px;padding:1px 8px;cursor:pointer;margin-left:6px;">↻</button>`;
body = `<div class="vv-arr-cards" style="opacity:.85;">${node.arrs.map(_arrCard).join('')}</div>`;
}
return `<div class="vv-arr-node">
<div class="vv-arr-node-hdr">
<span class="vv-arr-node-id">${vvEscHtml(String(node.host || '').toUpperCase())}</span>
<span class="vv-arr-node-host">${vvEscHtml(node.name || '')}</span>${tag}
</div>
${body}
</div>`;
}
// ── Sync + Recovery row ───────────────────────────────────────────────────────
function _syncSection(sync, recovery, settings) {
// offTxt distinguishes "has not happened yet" from "cannot happen". Both render with no run
// history, and "never run" on a switched-off subsystem reads as a fault waiting to clear —
// it says the thing has not got round to it, when in fact it has been told not to.
const _card = (title, data, items, extra, offTxt) => {
const ok = data.last_run && data.status === 'ok';
const bad = data.last_run && data.status !== 'ok';
const pillCls = !data.last_run ? 'off' : ok ? 'ok' : 'err';
const pillTxt = !data.last_run ? (offTxt || 'never run') : ok ? 'ok' : data.status || 'error';
let body = '';
if (data.last_run) {
body += `<div style="font-size:11px;color:#555;margin-bottom:10px;">${_relTime(data.last_run)}</div>`;
}
body += `<div class="vv-arr-sr-row">${items}</div>`;
if (extra) body += extra;
return `<div class="vv-arr-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-size:11px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.07em;">${title}</span>
<span class="vv-arr-pill ${pillCls}">${pillTxt}</span>
</div>
${body}
</div>`;
};
const _item = (val, lbl) => `<div class="vv-arr-sr-item">
<div class="vv-arr-sr-num">${val}</div>
<div class="vv-arr-sr-lbl">${lbl}</div>
</div>`;
const syncEnabled = settings?.arr_sync_enabled !== false;
const syncExtra = !syncEnabled
? `<div style="font-size:10px;color:#8b4a2a;margin-top:8px;">ARR_SYNC_ENABLED=false — sync disabled</div>` : '';
const syncItems = sync.last_run
? _item(`<span style="color:#6fcf97">+${_n(sync.added)}</span>`, 'Added') +
(sync.nodes != null ? _item(_n(sync.nodes), 'Nodes') : '') +
(sync.blocklist_count != null ? _item(_n(sync.blocklist_count), 'Blocklist') : '')
: `<span style="color:#333;font-size:11px;">${syncEnabled ? 'No run history' : 'Nothing to show while disabled'}</span>`;
const recItems = recovery.last_run
? _item(_n(recovery.fixed), 'Fixed') + _item(_n(recovery.searched), 'Re-searched')
: `<span style="color:#333;font-size:11px;">No run history</span>`;
return `<div class="vv-arr-sr-grid">
${_card('Arr Sync', sync, syncItems, syncExtra, syncEnabled ? null : 'disabled')}
${_card('Failed / Stalled Recovery', recovery, recItems, '')}
</div>`;
}
// Local, because there is no global one. js/varaverk.js provides _relTime, _sz, _n and _uptime —
// and _uptime floors to whole minutes, so a 33-second cleanup would render as "0m". Same shape the
// rsync page uses for the same reason; if a third page needs it, that is the point to promote it
// to the shared file rather than keep a third copy.
function _dur(s) {
if (!s) return '—';
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
if (h) return h + 'h ' + m + 'm';
if (m) return m + 'm ' + sec + 's';
return sec + 's';
}
// ── Media servers ─────────────────────────────────────────────────────────────
// Two halves of one question. The application supplies its version, whether an update or a restart
// is waiting, and who is watching; the container supplies what that costs in CPU, memory and how
// long it has been up. A server answering happily at 145% CPU is a different situation from one
// answering happily at 8%, and neither half says so alone.
let _vvMsFilter = 'all';
// Local by default. The mesh view is the deliberate act of asking about the whole estate; opening
// the tab to check your own server should not first make you read past a partner's.
let _vvMsScope = 'local';
function _mediaServersSection(nodes) {
if (!nodes || !nodes.length) return '';
const me = nodes.filter(n => n.local);
const others = nodes.filter(n => !n.local);
const inScope = _vvMsScope === 'mesh' ? nodes : me;
// Flattened with their host attached, so a card can say which machine it is on once more than
// one is shown. Without that a mesh view is two Embys and no way to tell them apart.
const servers = [];
for (const n of inScope) for (const s of (n.servers || [])) {
servers.push({ ...s, _host: n.host, _hostName: n.name, _local: !!n.local, _age: n.cache_age });
}
// Only offered when there is a partner to combine with. On a single-host install the control
// would be two words for the same view.
const scopeTabs = others.length
? `<div style="display:flex;gap:4px;margin-left:auto;">` +
[['local', 'This host'], ['mesh', 'Mesh']].map(([k, lbl]) =>
`<button class="vv-arr-mstab${_vvMsScope === k ? ' on' : ''}" data-msscope="${vvEscAttr(k)}">${vvEscHtml(lbl)}</button>`
).join('') + `</div>`
: '';
// A partner that has never been collected is stated, not omitted — an empty mesh view and a
// partner that is simply down look identical otherwise.
const missing = _vvMsScope === 'mesh'
? others.filter(n => n.cache_miss).map(n =>
`<div style="grid-column:1/-1;font-size:10px;color:#3f3f3f;padding:2px 0;">
${vvEscHtml(n.name)} — not collected yet; the partner reports its own media servers over SSH every 2 hours
</div>`).join('')
: '';
const types = [...new Set(servers.map(s => s.type))];
// The filter only earns its space when there is something to filter. One server means the
// buttons would be three ways of saying the same thing.
const tabs = types.length > 1
? `<div style="display:flex;gap:4px;margin-bottom:8px;">` +
[['all', 'All'], ...types.map(t => [t, t.charAt(0).toUpperCase() + t.slice(1)])].map(([k, lbl]) =>
`<button class="vv-arr-mstab${_vvMsFilter === k ? ' on' : ''}" data-mstab="${vvEscAttr(k)}">${vvEscHtml(lbl)}</button>`
).join('') + `</div>`
: '';
const shown = servers.filter(s => _vvMsFilter === 'all' || s.type === _vvMsFilter);
const cards = shown.map(s => {
const up = !!s.online;
const dot = !up ? '#c62828' : s.pending_restart || s.update_available ? '#ffb74d' : '#4caf50';
// cpu_pct is a share of the whole machine, matching Unraid's Docker page. Thresholds are set
// against that: a single container taking a quarter of the host is worth noticing, half is
// worth alarming about. The earlier version coloured docker's raw per-core-summed figure and
// called 145% alarming when it was 4.5% of a 32-thread machine.
const cpu = s.cpu_pct;
const cpuCol = cpu == null ? '#555' : cpu > 50 ? '#ef5350' : cpu > 25 ? '#ffb74d' : '#888';
const flags = [];
if (s.update_available) flags.push(['update available', '#ffb74d']);
if (s.pending_restart) flags.push(['restart pending', '#ffb74d']);
if (s.maintenance) flags.push(['maintenance mode', '#ef5350']);
const row = (l, v) => `<div class="vv-arr-meta"><span class="vv-arr-meta-lbl">${l}</span><span class="vv-arr-meta-val">${v}</span></div>`;
return `<div class="vv-arr-card">
<div class="vv-arr-hdr">
<span class="vv-arr-name">${vvEscHtml(s.name)}</span>
<span><span class="vv-arr-dot" style="background:${dot}"></span>
<span class="vv-arr-ver">${vvEscHtml(up ? (s.version || 'online') : 'offline')}</span></span>
</div>
${_vvMsScope === 'mesh' ? `<div style="font-size:9px;color:#3a3a3a;margin:-6px 0 8px;">
${vvEscHtml(s._hostName || s._host || '')}${s._local ? '' : ' · cached' + (s._age != null ? ' ' + _relTime(Math.floor(Date.now()/1000) - s._age) : '')}
</div>` : ''}
${up ? `
<div class="vv-arr-stats">
<div class="vv-arr-stat"><div class="vv-arr-stat-n${s.sessions ? '' : ' dim'}">${_n(s.sessions)}</div><div class="vv-arr-stat-l">Streams</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n${s.users ? '' : ' dim'}">${_n(s.users)}</div><div class="vv-arr-stat-l">Users</div></div>
<div class="vv-arr-stat"><div class="vv-arr-stat-n${s.transcodes ? '' : ' dim'}">${_n(s.transcodes)}</div><div class="vv-arr-stat-l">Transcode</div></div>
</div>
${row('CPU', `<span style="color:${cpuCol}" title="${vvEscAttr(s.cpu_raw != null ? s.cpu_raw.toFixed(1) + '% as docker stats reports it — summed across all threads' : '')}">${cpu == null ? '—' : cpu.toFixed(1) + '%'}</span>`
+ (s.cpu_cores ? ` <span style="color:#3a3a3a">${s.cpu_cores.toFixed(1)} cores</span>` : ''))}
${row('Memory', vvEscHtml(s.mem_used || '—') + (s.mem_pct != null ? ` <span style="color:#3a3a3a">${s.mem_pct.toFixed(1)}%</span>` : ''))}
${row('Uptime', s.uptime != null ? _uptime(s.uptime) : '—')}
${s.os ? row('Host OS', vvEscHtml(s.os)) : ''}
${flags.length ? `<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:6px;">` +
flags.map(([t, c]) => `<span class="vv-arr-pill" style="color:${c};border:1px solid ${c}33;background:${c}14;">${t}</span>`).join('') +
`</div>` : ''}
<div style="font-size:9px;color:#2a2a2a;margin-top:6px;">checked ${_relTime(s.checked)}</div>
` : `<div style="font-size:11px;color:#444;padding:6px 0;">Did not answer — ${vvEscHtml(s.container || 'container')} may be down</div>`}
</div>`;
}).join('');
return `<div style="grid-column:1/-1;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span style="font-size:11px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.07em;">Media servers</span>
${scopeTabs}
</div>
${tabs}
<div class="vv-arr-cards">${cards}${missing}</div>
</div>`;
}
// Delegated, because the row is rebuilt on every poll and per-button handlers would be rebound
// constantly. Re-renders from the payload already in hand rather than refetching — the filter is
// a view over data the page has, not a new question for the server.
document.addEventListener('click', ev => {
const b = ev.target.closest('.vv-arr-mstab');
if (!b) return;
if (b.dataset.msscope) _vvMsScope = b.dataset.msscope;
else _vvMsFilter = b.dataset.mstab || 'all';
if (_vvArrsLast) _render(_vvArrsLast);
});
// ── Media jobs ────────────────────────────────────────────────────────────────
// Play state sync, permissions and the cleaner. They work on the same files the arrs manage and
// appeared in no tab at all — the only way to know whether one had run was to open the Scheduler
// and read an orchestrator's log. Readable at all only because run_orch_child() now writes a run
// record and a per-script log for its children.
//
// Each shows the sentence the script itself ended on rather than a count re-derived here. The
// three word their outcome differently — "done — 21 shares updated", "clean — nothing to remove" —
// and the wording is the part worth reading.
function _mediaJobsSection(jobs) {
if (!jobs || !jobs.length) return '';
const cards = jobs.map(j => {
const st = j.status || null;
const col = !st ? '#444' : st === 'ok' ? '#4caf50' : st === 'running' ? '#4a9eff'
: st === 'warn' ? '#ffb74d' : '#ef5350';
const pill = !st ? 'never run' : st;
const dur = j.duration != null ? _dur(j.duration) : '';
return `<div class="vv-arr-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:8px;">
<span style="font-size:11px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.07em;">${vvEscHtml(j.label)}</span>
<span class="vv-arr-pill" style="color:${col};border:1px solid ${col}33;background:${col}14;">${vvEscHtml(pill)}</span>
</div>
<div style="font-size:11px;color:#555;">
${j.last_run ? _relTime(j.last_run) : 'no run recorded'}${dur ? ' · ' + dur : ''}
</div>
${j.summary ? `<div style="font-size:10px;color:#3f3f3f;margin-top:5px;line-height:1.45;">${vvEscHtml(j.summary)}</div>` : ''}
</div>`;
}).join('');
return `<div style="grid-column:1/-1;">
<div style="font-size:11px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px;">Media jobs</div>
<div class="vv-arr-cards">${cards}</div>
</div>`;
}
// ── Settings card ─────────────────────────────────────────────────────────────
function _settingsCard(s) {
if (!s) return '';
const myHost = s.my_host || 'host1';
const myId = s.my_id || 'HOST1';
const _tog = (id, on, key, file, label) =>
`<label class="vv-cf-toggle">
<div class="vv-cf-track${on?' on':''}" id="vv-arr-tog-${id}"
onclick="event.stopPropagation();vvArrToggle(this,'${key}','${file}')"></div>
<span class="vv-arr-tog-lbl">${label}</span>
</label>`;
const syncTog = _tog('sync', s.arr_sync_enabled,
'ARR_SYNC_ENABLED', 'master.conf', 'Arr sync enabled');
const recTogs = [
s.sonarr_recovery !== undefined
? _tog('sonarr-rec', s.sonarr_recovery, `${myId}_SONARR_RECOVERY`, `${myHost}.conf`, 'Sonarr') : '',
s.radarr_recovery !== undefined
? _tog('radarr-rec', s.radarr_recovery, `${myId}_RADARR_RECOVERY`, `${myHost}.conf`, 'Radarr') : '',
s.lidarr_recovery !== undefined
? _tog('lidarr-rec', s.lidarr_recovery, `${myId}_LIDARR_RECOVERY`, `${myHost}.conf`, 'Lidarr') : '',
].filter(Boolean).join('');
return `<div class="vv-arr-card vv-arr-set-card">
<div style="font-size:11px;font-weight:700;color:#555;text-transform:uppercase;
letter-spacing:.07em;margin-bottom:12px;">Settings</div>
<div style="display:flex;flex-wrap:wrap;gap:20px;margin-bottom:12px;">
${syncTog}
</div>
<hr class="vv-arr-sep">
<div style="font-size:10px;color:#3a3a3a;text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px;">
Recovery — ${myId}
</div>
<div style="display:flex;flex-wrap:wrap;gap:16px;margin-bottom:10px;">${recTogs}</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:11px;color:#555;">Import recovery age</span>
<input class="vv-arr-set-inp" id="vv-arr-rec-age" type="number" min="1" max="72"
value="${s.recovery_age_hours ?? 6}">
<span style="font-size:10px;color:#3a3a3a;">hours — skip items newer than this</span>
<button onclick="vvArrSaveAge()" id="vv-arr-age-btn"
style="background:#1a1a1a;border:1px solid #333;color:#666;font-size:10px;
padding:3px 10px;border-radius:3px;cursor:pointer;margin-left:4px;">Save</button>
<span id="vv-arr-age-fb" style="font-size:10px;"></span>
</div>
</div>`;
}
// ── Main render ───────────────────────────────────────────────────────────────
let _vvArrsLast = null;
function _render(data) {
_vvArrsLast = data;
const nodes = data.nodes || [];
if (!nodes.length && !(data.sync?.last_run) && !(data.recovery?.last_run)) {
document.getElementById('vv-arrs-grid').innerHTML =
`<div style="grid-column:1/-1;color:#444;font-size:12px;padding:24px 0;text-align:center;">
No arrs configured — add Sonarr/Radarr/Lidarr URLs and API keys to ${data.settings?.my_host ?? 'host'}.conf
</div>`;
return;
}
let html = '';
for (const node of nodes) html += _nodeSection(node);
html += _syncSection(data.sync || {}, data.recovery || {}, data.settings);
html += _mediaServersSection(data.media_servers);
html += _mediaJobsSection(data.media_jobs);
html += _settingsCard(data.settings);
document.getElementById('vv-arrs-grid').innerHTML = html;
const ts = data.ts ? new Date(data.ts * 1000).toLocaleString([],
{month:'numeric',day:'numeric',year:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
document.getElementById('vv-arrs-ts').textContent = ts ? 'Updated: ' + ts : '';
}
function vvArrsLoad() {
fetch('/plugins/varaverk/api/arrs.php')
.then(r => r.json())
.then(_render)
.catch(() => {
document.getElementById('vv-arrs-grid').innerHTML =
'<div style="grid-column:1/-1;color:#555;font-size:12px;padding:24px 0;text-align:center;">Error loading arrs data — check API</div>';
});
}
vvArrsLoad();
setInterval(vvArrsLoad, 60000);
})();
// ── Remote node refresh ───────────────────────────────────────────────────────
function vvArrsRefreshRemote(host) {
const btn = document.getElementById('vv-arr-rfsh-' + host);
if (btn) { btn.disabled = true; btn.textContent = '↻…'; }
const fd = new URLSearchParams();
fd.append('action', 'refresh_remote');
fd.append('host', host);
fetch('/plugins/varaverk/api/arrs.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
if (btn) { btn.disabled = false; btn.textContent = d.ok ? '↻' : '✗'; }
// Main cache was busted — reload to show fresh data
setTimeout(vvArrsLoad, 500);
})
.catch(() => { if (btn) { btn.disabled = false; btn.textContent = '↻'; } });
}
// ── Toggle handler ────────────────────────────────────────────────────────────
function vvArrToggle(track, key, file) {
const on = !track.classList.contains('on');
track.classList.toggle('on', on);
const fd = new URLSearchParams();
fd.append('id', 'arrs');
fd.append('changes', JSON.stringify([{ file, key, value: on ? 'true' : 'false', type: 'scalar' }]));
fetch('/plugins/varaverk/api/confform.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => { if (!d.ok) track.classList.toggle('on', !on); })
.catch(() => track.classList.toggle('on', !on));
}
// ── Recovery age save ─────────────────────────────────────────────────────────
function vvArrSaveAge() {
const val = document.getElementById('vv-arr-rec-age')?.value;
const btn = document.getElementById('vv-arr-age-btn');
const fb = document.getElementById('vv-arr-age-fb');
if (!val) return;
btn.disabled = true; btn.textContent = 'Saving…'; fb.textContent = '';
const fd = new URLSearchParams();
fd.append('id', 'arrs');
fd.append('changes', JSON.stringify([{
file: 'master.conf', key: 'ARR_IMPORT_RECOVERY_AGE', value: val, type: 'scalar'
}]));
fetch('/plugins/varaverk/api/confform.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
btn.disabled = false; btn.textContent = 'Save';
fb.style.color = d.ok ? '#4caf50' : '#ef5350';
fb.textContent = d.ok ? 'Saved ✓' : (d.error || 'Failed');
if (d.ok) setTimeout(() => { fb.textContent = ''; }, 3000);
})
.catch(() => { btn.disabled = false; btn.textContent = 'Save'; fb.style.color='#ef5350'; fb.textContent='Failed'; });
}
// ── Assistant ────────────────────────────────────────────────────────────────
if (document.getElementById('vv-arr-ai-chat')) {
VvAiChat({
prefix: 'vv-arr-ai',
profile: 'varaverk',
scopeLabel: 'Arrs',
scope: () => 'Arrs',
resumeProfile: 'varaverk',
empty: 'Ask about an instance, a health warning, or what the cleanups did.',
});
}
</script>