All checks were successful
Deploy / deploy (push) Successful in 55s
Every TV request now needs a decision, so P104 offers the two verdicts
inline on the rows actually waiting rather than sending the visitor to
Ombi's own UI.
Ombi is now the one service the app writes to. Three details the new
ombi_write() helper and /api/ombi/decide route have to get right:
approve is a POST but deny is a PUT; a TV decision acts on the child
request id, not the show, so get_ombi_summary() carries child["id"] for
TV rows and m["id"] for movies; and Ombi answers 200 with
{"result": false, ...} on a logical refusal, so the body is checked
rather than the status code. A successful decision clears _ombi_cache —
the 4-hour TTL would otherwise keep calling the row pending for the
rest of the afternoon.
The buttons reuse the existing two-tap arming and share its armed slot,
so arming one disarms the other: an approval starts a download and a
denial is somebody's answer, neither wants a stray tap. They reverse
into green and red rather than the shared red of .tt-btn.armed, so the
confirming press states which verdict it is about to send. After a
decision the row's status flips in place and the Pending figure and its
"N of M" both settle; at zero the page drops its warn weight, since
nothing waits on a human any more (discipline 5). Below 560px the
verdicts take a full line of their own rather than shrinking below the
tap target.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNc3gd3GRPudFwgPXNEm8A
290 lines
10 KiB
JavaScript
290 lines
10 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;
|
|
};
|
|
|
|
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);
|
|
}
|
|
})();
|