Files
Varaverk/Plugin/unraid/pages/arrs.php
T

727 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; }
/* See the same note in watchdog.php: Unraid's `span.warn` blockifies any span carrying that class
and stretches it to the full width of its container, and the queue badge takes `warn` as one of
its states. Ties on specificity, wins on order. */
span.vv-arr-qbadge { display:inline-block; width:auto; }
.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>