Add Approve/Deny verdicts to pending requests on P104
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
This commit is contained in:
Barely Removable 2026-09-02 09:05:53 -07:00
parent 8115ed4855
commit d38ab1b561
5 changed files with 208 additions and 7 deletions

View file

@ -205,6 +205,13 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
color: var(--live);
white-space: nowrap;
}
/* The verdicts sit after the status they resolve, so the row reads
state-then-decision. Only pending rows carry one. */
.tt-row-acts {
flex: 0 0 auto;
display: flex;
gap: 1ch;
}
.tt-row-val.ok { color: var(--ok); }
.tt-row-val.alert { color: var(--alert); }
.tt-row-val.warn { color: var(--voice); }
@ -429,6 +436,24 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
.tt-btn-quiet:hover:not(:disabled) { background: var(--live); color: var(--tt-black); }
.tt-btn-sm { min-height: calc(var(--row) * 1.6); width: 100%; }
/* The two verdicts on a pending request (P104). Quiet until armed, then
each reverses into its own meaning rather than the shared red of
.tt-btn.armed — green is the go-ahead, red is the refusal — so the
confirming press says which verdict it is about to send and not merely
that something is live. Both come after .tt-btn.armed to win on
source order at equal specificity. */
.tt-btn-yes, .tt-btn-no {
background: transparent;
padding: 0 1ch;
letter-spacing: 0.12em;
}
.tt-btn-yes { color: var(--ok); }
.tt-btn-no { color: var(--alert); }
.tt-btn-yes:hover:not(:disabled),
.tt-btn-yes.armed { background: var(--ok); color: var(--tt-black); }
.tt-btn-no:hover:not(:disabled),
.tt-btn-no.armed { background: var(--alert); color: var(--tt-black); }
.tt-input {
min-height: var(--tap);
padding: 0 1ch;
@ -649,6 +674,16 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
}
.tt-row-title { white-space: normal; }
.tt-row-val { margin-left: auto; white-space: normal; }
/* Name and status hold line one, the description takes line two, and
the verdicts take a full line of their own — at this width they
cannot share a line with the status without either one shrinking
below the tap target. */
.tt-row-acts {
flex: 1 0 100%;
order: 4;
justify-content: flex-end;
margin-top: 0.35em;
}
.tt-head-name { letter-spacing: 0.12em; }
.tt-head-dl { margin-left: 0; flex-basis: 100%; }
.tt-head-clock { margin-left: 0; flex-basis: 100%; }

View file

@ -109,7 +109,7 @@
function disarm() {
if (!armed) return;
armed.classList.remove('armed');
armed.textContent = armed.dataset.label || '+ ADD';
if (armed.dataset.label) armed.textContent = armed.dataset.label;
armed = null;
}
window.armAdd = function (btn) {
@ -152,6 +152,88 @@
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();
});