All checks were successful
Deploy / deploy (push) Successful in 48s
Cross-references Sonarr's per-series disk usage against Tautulli's full play history to surface the 10 largest shows nobody has ever watched, excluding anything added in the last 6 months so new downloads aren't flagged before they've had a chance to be watched. Delete removes the series from Sonarr along with its episode files and folder, behind the site's usual two-tap arm/confirm rather than a native dialog. Also dedupes the never-wired /api/tautulli/never-watched endpoint onto the same helper, and clears the Sonarr/Radarr list caches on delete so a removed title doesn't linger in other cards until the TTL expires. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPJxtE9tPURsaWDZ6rC4Dt
359 lines
13 KiB
JavaScript
359 lines
13 KiB
JavaScript
/* ═══════════════════════════════════════════════════════
|
|
Page 100 — service behaviour.
|
|
|
|
Three things: the header clock, the page-number jump (the
|
|
signature interaction), and the fastext colour keys. Plus
|
|
guarded live polling for the two figures that must be
|
|
current-second accurate.
|
|
|
|
Every element lookup is guarded. Nine pages share this file
|
|
and none of them carry all of its targets.
|
|
═══════════════════════════════════════════════════════ */
|
|
(function () {
|
|
'use strict';
|
|
|
|
var body = document.body;
|
|
var BASE = body.dataset.urlBase || '';
|
|
var TZ = body.dataset.timezone || 'UTC';
|
|
|
|
/* ── Clock ────────────────────────────────────────────
|
|
Teletext's header carried the transmission clock to the
|
|
second. It is the one thing on the page that is always
|
|
moving, and it is how you know the page is live. */
|
|
var clockEl = document.getElementById('head-clock');
|
|
function tick() {
|
|
if (!clockEl) return;
|
|
var now = new Date();
|
|
var d = new Intl.DateTimeFormat('en-GB', {
|
|
timeZone: TZ, weekday: 'short', day: '2-digit', month: 'short'
|
|
}).format(now);
|
|
var t = new Intl.DateTimeFormat('en-GB', {
|
|
timeZone: TZ, hour: '2-digit', minute: '2-digit',
|
|
second: '2-digit', hour12: false
|
|
}).format(now);
|
|
clockEl.textContent = d.toUpperCase() + ' ' + t;
|
|
}
|
|
tick();
|
|
setInterval(tick, 1000);
|
|
|
|
/* ── Page-number jump ─────────────────────────────────
|
|
Type three digits anywhere and the service goes to that
|
|
page, exactly as a teletext remote did. The digits fill in
|
|
the header as you type them, so the interaction shows its
|
|
own state instead of happening invisibly. */
|
|
var pages = {};
|
|
try {
|
|
(JSON.parse(body.dataset.pages || '[]')).forEach(function (p) {
|
|
pages[p[1]] = p[3]; // "100" -> "/"
|
|
});
|
|
} catch (e) { /* no nav data: the jump simply does nothing */ }
|
|
|
|
var entryEl = document.getElementById('page-entry');
|
|
var buf = '';
|
|
var clearTimer = null;
|
|
|
|
function paintEntry() {
|
|
if (!entryEl) return;
|
|
if (!buf) { entryEl.hidden = true; entryEl.textContent = ''; return; }
|
|
entryEl.hidden = false;
|
|
entryEl.textContent = 'P' + buf + '_'.repeat(3 - buf.length);
|
|
}
|
|
function resetEntry() {
|
|
buf = '';
|
|
paintEntry();
|
|
}
|
|
function typing(e) {
|
|
var t = e.target;
|
|
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
|
t.tagName === 'SELECT' || t.isContentEditable);
|
|
}
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
if (typing(e) || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
|
|
if (e.key === 'Escape') { resetEntry(); return; }
|
|
|
|
if (e.key >= '0' && e.key <= '9') {
|
|
buf += e.key;
|
|
paintEntry();
|
|
clearTimeout(clearTimer);
|
|
if (buf.length === 3) {
|
|
var dest = pages[buf];
|
|
if (dest) { window.location.href = BASE + dest; }
|
|
else {
|
|
// No such page. Teletext just sat on the number and waited.
|
|
if (entryEl) entryEl.textContent = 'P' + buf + ' ?';
|
|
clearTimer = setTimeout(resetEntry, 1200);
|
|
}
|
|
buf = '';
|
|
return;
|
|
}
|
|
// A half-typed number times out rather than lingering.
|
|
clearTimer = setTimeout(resetEntry, 2000);
|
|
return;
|
|
}
|
|
|
|
/* Fastext keys: r / g / y / c, the coloured buttons. */
|
|
var k = e.key.toLowerCase();
|
|
if ('rgyc'.indexOf(k) !== -1) {
|
|
var link = document.querySelector('.fastext a[data-key="' + k + '"]');
|
|
if (link) { window.location.href = link.href; }
|
|
}
|
|
});
|
|
|
|
/* ── Two-tap add ──────────────────────────────────────
|
|
Adding to the library is destructive enough to want a
|
|
confirming second press. The button states its own stage
|
|
rather than opening a dialog. */
|
|
var armed = null;
|
|
function disarm() {
|
|
if (!armed) return;
|
|
armed.classList.remove('armed');
|
|
if (armed.dataset.label) armed.textContent = armed.dataset.label;
|
|
armed = null;
|
|
}
|
|
window.armAdd = function (btn) {
|
|
if (btn.disabled) return;
|
|
if (armed && armed !== btn) disarm();
|
|
|
|
if (armed === btn) {
|
|
var title = btn.dataset.title;
|
|
var target = btn.dataset.target;
|
|
btn.disabled = true;
|
|
btn.classList.remove('armed');
|
|
btn.textContent = 'SENDING';
|
|
fetch(BASE + '/add-to-' + target, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: title })
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (d && d.success) {
|
|
btn.classList.add('done');
|
|
btn.textContent = 'ADDED';
|
|
} else {
|
|
btn.classList.add('armed');
|
|
btn.disabled = false;
|
|
btn.textContent = 'FAILED';
|
|
}
|
|
})
|
|
.catch(function () {
|
|
btn.disabled = false;
|
|
btn.classList.add('armed');
|
|
btn.textContent = 'FAILED';
|
|
});
|
|
armed = null;
|
|
return;
|
|
}
|
|
|
|
btn.dataset.label = btn.textContent.trim();
|
|
btn.classList.add('armed');
|
|
btn.textContent = 'CONFIRM?';
|
|
armed = btn;
|
|
};
|
|
/* ── Request verdicts (P104) ──────────────────────────
|
|
Approve and Deny share the two-tap arming above — and share its
|
|
`armed` slot, so arming one disarms the other rather than leaving
|
|
two live verdicts on the same row. An approval starts a download
|
|
and a denial is somebody's answer; neither wants a stray tap.
|
|
|
|
The page keeps its own count honest after a decision. Leaving the
|
|
Pending figure at its server-rendered value would have the page
|
|
contradicting a row the visitor just resolved. */
|
|
function settlePending() {
|
|
var fig = document.getElementById('req-pending');
|
|
var meta = document.getElementById('req-pending-meta');
|
|
var field = document.getElementById('req-field');
|
|
if (!fig) return;
|
|
var n = parseInt(fig.textContent, 10);
|
|
if (isNaN(n) || n <= 0) return;
|
|
n -= 1;
|
|
fig.textContent = n;
|
|
if (meta) meta.textContent = n;
|
|
if (n === 0) {
|
|
// Nothing waits on a human any more, so the page stops being heavy.
|
|
fig.classList.remove('pending');
|
|
fig.classList.add('ok');
|
|
if (field) field.classList.remove('warn');
|
|
}
|
|
}
|
|
|
|
window.armDecision = function (btn) {
|
|
if (btn.disabled) return;
|
|
if (armed && armed !== btn) disarm();
|
|
|
|
if (armed === btn) {
|
|
var acts = btn.parentNode;
|
|
var row = btn.closest('.tt-row');
|
|
var verdict = btn.dataset.verdict;
|
|
var sibs = acts.querySelectorAll('button');
|
|
var i;
|
|
for (i = 0; i < sibs.length; i++) { sibs[i].disabled = true; }
|
|
btn.classList.remove('armed');
|
|
btn.textContent = 'SENDING';
|
|
armed = null;
|
|
|
|
fetch(BASE + '/api/ombi/decide', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: btn.dataset.id,
|
|
type: btn.dataset.type,
|
|
verdict: verdict
|
|
})
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (d && d.success) {
|
|
var val = row && row.querySelector('.tt-row-val');
|
|
if (val) {
|
|
val.className = 'tt-row-val ' + (verdict === 'approve' ? 'ok' : 'alert');
|
|
val.textContent = (d.status || verdict).toUpperCase();
|
|
}
|
|
// The row is settled: the verdicts have nothing left to offer.
|
|
if (acts.parentNode) acts.parentNode.removeChild(acts);
|
|
settlePending();
|
|
} else {
|
|
for (var j = 0; j < sibs.length; j++) { sibs[j].disabled = false; }
|
|
btn.textContent = 'FAILED';
|
|
}
|
|
})
|
|
.catch(function () {
|
|
for (var k = 0; k < sibs.length; k++) { sibs[k].disabled = false; }
|
|
btn.textContent = 'FAILED';
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Captured once, from the server-rendered text — a retry after a
|
|
// FAILED must still disarm back to "Approve", not to "FAILED".
|
|
if (!btn.dataset.label) btn.dataset.label = btn.textContent.trim();
|
|
btn.classList.add('armed');
|
|
btn.textContent = 'CONFIRM?';
|
|
armed = btn;
|
|
};
|
|
|
|
/* ── Admin: delete unwatched (P105) ───────────────────
|
|
Removing a series from Sonarr with its files is permanent,
|
|
so it shares the same two-tap arming as everything else here
|
|
rather than a native confirm(). Each row carries its own byte
|
|
count, so the reclaimable total in the field meta can be
|
|
recomputed client-side after a delete instead of going stale
|
|
until the next page load. */
|
|
function fmtBytes(b) {
|
|
if (b >= 1073741824) return (b / 1073741824).toFixed(1) + ' GB';
|
|
return (b / 1048576).toFixed(0) + ' MB';
|
|
}
|
|
|
|
function settleAdmin() {
|
|
var list = document.getElementById('admin-rows');
|
|
var meta = document.getElementById('admin-meta');
|
|
var rows = list ? list.querySelectorAll('.tt-row') : [];
|
|
var total = 0;
|
|
for (var i = 0; i < rows.length; i++) total += parseInt(rows[i].dataset.bytes, 10) || 0;
|
|
if (meta) meta.textContent = rows.length + ' unwatched · ' + fmtBytes(total) + ' reclaimable';
|
|
if (rows.length === 0 && list) {
|
|
var empty = document.createElement('p');
|
|
empty.className = 'tt-empty';
|
|
empty.id = 'admin-empty';
|
|
empty.textContent = 'Nothing left to reclaim.';
|
|
list.parentNode.replaceChild(empty, list);
|
|
}
|
|
}
|
|
|
|
window.armDelete = function (btn) {
|
|
if (btn.disabled) return;
|
|
if (armed && armed !== btn) disarm();
|
|
|
|
if (armed === btn) {
|
|
var row = btn.closest('.tt-row');
|
|
btn.disabled = true;
|
|
btn.classList.remove('armed');
|
|
btn.textContent = 'DELETING';
|
|
armed = null;
|
|
|
|
fetch(BASE + '/api/delete/show', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sonarr_id: btn.dataset.id })
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (d && d.success) {
|
|
if (row && row.parentNode) row.parentNode.removeChild(row);
|
|
settleAdmin();
|
|
} else {
|
|
btn.disabled = false;
|
|
btn.classList.add('armed');
|
|
btn.textContent = 'FAILED';
|
|
}
|
|
})
|
|
.catch(function () {
|
|
btn.disabled = false;
|
|
btn.classList.add('armed');
|
|
btn.textContent = 'FAILED';
|
|
});
|
|
return;
|
|
}
|
|
|
|
btn.dataset.label = btn.textContent.trim();
|
|
btn.classList.add('armed');
|
|
btn.textContent = 'CONFIRM?';
|
|
armed = btn;
|
|
};
|
|
|
|
document.addEventListener('click', function (e) {
|
|
if (armed && !armed.contains(e.target)) disarm();
|
|
});
|
|
|
|
/* ── Live figures ─────────────────────────────────────
|
|
The queue and the active streams are the only two numbers
|
|
that must be accurate to the second, so they are the only
|
|
two that poll. Everything else is cached server-side and
|
|
arrives with the page. The queue also drives the header's
|
|
download row, present on every page, so it polls
|
|
unconditionally rather than only where #sab-queue exists. */
|
|
function setText(id, value) {
|
|
var el = document.getElementById(id);
|
|
if (el) el.textContent = value;
|
|
}
|
|
|
|
function pollSab() {
|
|
fetch(BASE + '/api/sabnzbd')
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (!d || d.status !== 'ok') return;
|
|
setText('sab-queue', d.queue_count);
|
|
setText('sab-speed', d.speed || '0');
|
|
setText('sab-remaining', d.sizeleft || '—');
|
|
var dl = document.getElementById('head-dl');
|
|
if (dl) {
|
|
var active = d.queue_count > 0;
|
|
dl.textContent = active
|
|
? d.queue_count + ' IN QUEUE · ' + (d.speed || '0')
|
|
: '0 IN QUEUE';
|
|
dl.classList.toggle('live', active);
|
|
}
|
|
})
|
|
.catch(function () { /* offline is the dev baseline, not an error */ });
|
|
}
|
|
|
|
function pollTautulli() {
|
|
if (!document.getElementById('tt-streams')) return;
|
|
fetch(BASE + '/api/tautulli')
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
if (!d || d.status !== 'ok') return;
|
|
setText('tt-streams', d.active_streams);
|
|
})
|
|
.catch(function () { /* as above */ });
|
|
}
|
|
|
|
pollSab();
|
|
setInterval(pollSab, 5000);
|
|
if (document.getElementById('tt-streams')) {
|
|
pollTautulli();
|
|
setInterval(pollTautulli, 15000);
|
|
}
|
|
})();
|