Add Approve/Deny verdicts to pending requests on P104
All checks were successful
Deploy / deploy (push) Successful in 55s
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:
parent
8115ed4855
commit
d38ab1b561
5 changed files with 208 additions and 7 deletions
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -35,6 +35,18 @@ the user's Unraid home server. Server-side project directory is still named
|
||||||
SABnzbd history 10 min, Ombi requests 4 hours, Rotten Tomatoes scrapes
|
SABnzbd history 10 min, Ombi requests 4 hours, Rotten Tomatoes scrapes
|
||||||
4 hours. Active streams (Tautulli) and the SABnzbd queue are always
|
4 hours. Active streams (Tautulli) and the SABnzbd queue are always
|
||||||
fetched live, never cached — those need to be current-second accurate.
|
fetched live, never cached — those need to be current-second accurate.
|
||||||
|
- Ombi is the one service the app **writes** to: P104's Approve/Deny
|
||||||
|
buttons post to `/api/ombi/decide`, which fans out to Ombi's four
|
||||||
|
write endpoints via `ombi_write()`. Three things there are easy to
|
||||||
|
get wrong: approve is a `POST` but deny is a `PUT`; a TV decision
|
||||||
|
acts on the **child request** id, not the show, which is why
|
||||||
|
`get_ombi_summary()` carries `child["id"]` for a TV row and
|
||||||
|
`m["id"]` for a movie; and Ombi answers `200` with
|
||||||
|
`{"result": false, "errorMessage": ...}` on a logical refusal
|
||||||
|
("already approved"), so `ombi_write()` checks the body rather than
|
||||||
|
trusting 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.
|
||||||
- Rotten Tomatoes / IMDB are scraped directly (`requests` +
|
- Rotten Tomatoes / IMDB are scraped directly (`requests` +
|
||||||
`BeautifulSoup`, JSON-LD parsing), not via an official API — brittle
|
`BeautifulSoup`, JSON-LD parsing), not via an official API — brittle
|
||||||
to upstream HTML changes by nature.
|
to upstream HTML changes by nature.
|
||||||
|
|
|
||||||
59
app.py
59
app.py
|
|
@ -22,8 +22,8 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
# Docker tab caches stale container IDs, so the footer is the reliable way
|
# Docker tab caches stale container IDs, so the footer is the reliable way
|
||||||
# to confirm a `docker compose up -d --build` actually took effect.
|
# to confirm a `docker compose up -d --build` actually took effect.
|
||||||
# Both are env-overridable if the deploy pipeline ever injects them.
|
# Both are env-overridable if the deploy pipeline ever injects them.
|
||||||
APP_VERSION = os.environ.get("APP_VERSION", "2.1.0")
|
APP_VERSION = os.environ.get("APP_VERSION", "2.2.0")
|
||||||
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-31")
|
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-09-02")
|
||||||
|
|
||||||
# Must point at a path that survives `docker compose up --build` — the
|
# Must point at a path that survives `docker compose up --build` — the
|
||||||
# container filesystem does not. See the data volume in docker-compose.yml.
|
# container filesystem does not. See the data volume in docker-compose.yml.
|
||||||
|
|
@ -790,6 +790,23 @@ def ombi_get(endpoint):
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def ombi_write(method, endpoint, payload):
|
||||||
|
"""Ombi's approve/deny endpoints answer 200 with
|
||||||
|
{"result": false, "errorMessage": ...} on a logical refusal rather than
|
||||||
|
an HTTP error, so the body has to be checked and not just the status."""
|
||||||
|
headers = {"ApiKey": OMBI_API_KEY, "Content-Type": "application/json"}
|
||||||
|
resp = requests.request(method, f"{OMBI_URL}/api/v1/{endpoint}",
|
||||||
|
headers=headers, json=payload, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return {}
|
||||||
|
if isinstance(body, dict) and body.get("result") is False:
|
||||||
|
raise RuntimeError(body.get("errorMessage") or "Ombi refused the decision")
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
def _ombi_status_label(available, approved, denied):
|
def _ombi_status_label(available, approved, denied):
|
||||||
if denied:
|
if denied:
|
||||||
return "Denied"
|
return "Denied"
|
||||||
|
|
@ -818,6 +835,7 @@ def get_ombi_summary():
|
||||||
if alias.lower() == "ninja_hippo":
|
if alias.lower() == "ninja_hippo":
|
||||||
continue
|
continue
|
||||||
all_requests.append({
|
all_requests.append({
|
||||||
|
"id": m.get("id"),
|
||||||
"title": m.get("title", "Unknown"),
|
"title": m.get("title", "Unknown"),
|
||||||
"type": "Movie",
|
"type": "Movie",
|
||||||
"requested_date": m.get("requestedDate", ""),
|
"requested_date": m.get("requestedDate", ""),
|
||||||
|
|
@ -841,6 +859,9 @@ def get_ombi_summary():
|
||||||
)
|
)
|
||||||
denied = child.get("denied", False)
|
denied = child.get("denied", False)
|
||||||
all_requests.append({
|
all_requests.append({
|
||||||
|
# Ombi's tv approve/deny act on the child request, not
|
||||||
|
# the parent show, so this is the child's id.
|
||||||
|
"id": child.get("id"),
|
||||||
"title": show.get("title", "Unknown"),
|
"title": show.get("title", "Unknown"),
|
||||||
"type": "TV",
|
"type": "TV",
|
||||||
"requested_date": child.get("requestedDate", ""),
|
"requested_date": child.get("requestedDate", ""),
|
||||||
|
|
@ -1804,6 +1825,40 @@ def api_ombi():
|
||||||
return jsonify(get_ombi_summary())
|
return jsonify(get_ombi_summary())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/ombi/decide", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_ombi_decide():
|
||||||
|
"""Approve or deny one pending request from P104.
|
||||||
|
|
||||||
|
Ombi splits this four ways — movie/tv x approve/deny — and approve is a
|
||||||
|
POST while deny is a PUT, so both halves are named explicitly rather
|
||||||
|
than derived."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
req_id = data.get("id")
|
||||||
|
kind = (data.get("type") or "").lower()
|
||||||
|
verdict = (data.get("verdict") or "").lower()
|
||||||
|
if not req_id:
|
||||||
|
return jsonify({"error": "No request id"}), 400
|
||||||
|
if kind not in ("movie", "tv"):
|
||||||
|
return jsonify({"error": "Unknown request type"}), 400
|
||||||
|
if verdict not in ("approve", "deny"):
|
||||||
|
return jsonify({"error": "Unknown verdict"}), 400
|
||||||
|
try:
|
||||||
|
if verdict == "approve":
|
||||||
|
ombi_write("POST", f"Request/{kind}/approve", {"id": req_id})
|
||||||
|
else:
|
||||||
|
ombi_write("PUT", f"Request/{kind}/deny", {"id": req_id, "reason": ""})
|
||||||
|
# Without this the 4-hour cache keeps calling the row pending for the
|
||||||
|
# rest of the afternoon, and the page contradicts the decision just
|
||||||
|
# made on it.
|
||||||
|
_ombi_cache["data"] = None
|
||||||
|
_ombi_cache["expires"] = 0
|
||||||
|
return jsonify({"success": True,
|
||||||
|
"status": "Approved" if verdict == "approve" else "Denied"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/services")
|
@app.route("/api/services")
|
||||||
@login_required
|
@login_required
|
||||||
def api_services():
|
def api_services():
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,13 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
|
||||||
color: var(--live);
|
color: var(--live);
|
||||||
white-space: nowrap;
|
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.ok { color: var(--ok); }
|
||||||
.tt-row-val.alert { color: var(--alert); }
|
.tt-row-val.alert { color: var(--alert); }
|
||||||
.tt-row-val.warn { color: var(--voice); }
|
.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-quiet:hover:not(:disabled) { background: var(--live); color: var(--tt-black); }
|
||||||
.tt-btn-sm { min-height: calc(var(--row) * 1.6); width: 100%; }
|
.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 {
|
.tt-input {
|
||||||
min-height: var(--tap);
|
min-height: var(--tap);
|
||||||
padding: 0 1ch;
|
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-title { white-space: normal; }
|
||||||
.tt-row-val { margin-left: auto; 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-name { letter-spacing: 0.12em; }
|
||||||
.tt-head-dl { margin-left: 0; flex-basis: 100%; }
|
.tt-head-dl { margin-left: 0; flex-basis: 100%; }
|
||||||
.tt-head-clock { margin-left: 0; flex-basis: 100%; }
|
.tt-head-clock { margin-left: 0; flex-basis: 100%; }
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@
|
||||||
function disarm() {
|
function disarm() {
|
||||||
if (!armed) return;
|
if (!armed) return;
|
||||||
armed.classList.remove('armed');
|
armed.classList.remove('armed');
|
||||||
armed.textContent = armed.dataset.label || '+ ADD';
|
if (armed.dataset.label) armed.textContent = armed.dataset.label;
|
||||||
armed = null;
|
armed = null;
|
||||||
}
|
}
|
||||||
window.armAdd = function (btn) {
|
window.armAdd = function (btn) {
|
||||||
|
|
@ -152,6 +152,88 @@
|
||||||
btn.textContent = 'CONFIRM?';
|
btn.textContent = 'CONFIRM?';
|
||||||
armed = btn;
|
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) {
|
document.addEventListener('click', function (e) {
|
||||||
if (armed && !armed.contains(e.target)) disarm();
|
if (armed && !armed.contains(e.target)) disarm();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,17 @@
|
||||||
{{ service_error('Ombi', ombi.error) }}
|
{{ service_error('Ombi', ombi.error) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{#- Pending requests are the only thing here that wants a human, so a
|
{#- Pending requests are the only thing here that wants a human, so a
|
||||||
page holding any is set heavier than one holding none (discipline 5). -#}
|
page holding any is set heavier than one holding none (discipline 5).
|
||||||
<div class="tt-field {{ 'warn' if ombi.total_pending else '' }}">
|
The ids are what the verdict buttons write back to when a decision
|
||||||
|
empties the queue, so the page stops contradicting itself. -#}
|
||||||
|
<div class="tt-field {{ 'warn' if ombi.total_pending else '' }}" id="req-field">
|
||||||
<span>Awaiting A Decision</span>
|
<span>Awaiting A Decision</span>
|
||||||
<span class="tt-field-meta">{{ ombi.total_pending }} of {{ ombi.total_requests }}</span>
|
<span class="tt-field-meta"><span id="req-pending-meta">{{ ombi.total_pending }}</span> of {{ ombi.total_requests }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tt-figs">
|
<div class="tt-figs">
|
||||||
<div class="tt-fig">
|
<div class="tt-fig">
|
||||||
<div class="tt-dh tt-fig-v {{ 'pending' if ombi.total_pending else 'ok' }}">{{ ombi.total_pending }}</div>
|
<div class="tt-dh tt-fig-v {{ 'pending' if ombi.total_pending else 'ok' }}" id="req-pending">{{ ombi.total_pending }}</div>
|
||||||
<div class="tt-fig-l">Pending</div>
|
<div class="tt-fig-l">Pending</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tt-fig">
|
<div class="tt-fig">
|
||||||
|
|
@ -39,6 +41,21 @@
|
||||||
</span>
|
</span>
|
||||||
{%- set st = (r.status or '') | lower -%}
|
{%- set st = (r.status or '') | lower -%}
|
||||||
<span class="tt-row-val {% if st in ('available', 'approved') %}ok{% elif st == 'denied' %}alert{% elif st == 'pending' %}pending{% else %}off{% endif %}">{{ (r.status or '—') | upper }}</span>
|
<span class="tt-row-val {% if st in ('available', 'approved') %}ok{% elif st == 'denied' %}alert{% elif st == 'pending' %}pending{% else %}off{% endif %}">{{ (r.status or '—') | upper }}</span>
|
||||||
|
{#- Magenta says a human must decide; these are the two things that
|
||||||
|
human can decide, offered only on the rows actually waiting.
|
||||||
|
Both take a confirming second press, the same two-tap the add
|
||||||
|
buttons use — an approval starts a download and a denial is
|
||||||
|
somebody's answer. -#}
|
||||||
|
{% if st == 'pending' and r.id %}
|
||||||
|
<span class="tt-row-acts">
|
||||||
|
<button class="tt-btn tt-btn-yes" data-id="{{ r.id }}" data-type="{{ (r.type or '') | lower }}"
|
||||||
|
data-verdict="approve" onclick="armDecision(this)"
|
||||||
|
aria-label="Approve {{ r.title }}">Approve</button>
|
||||||
|
<button class="tt-btn tt-btn-no" data-id="{{ r.id }}" data-type="{{ (r.type or '') | lower }}"
|
||||||
|
data-verdict="deny" onclick="armDecision(this)"
|
||||||
|
aria-label="Deny {{ r.title }}">Deny</button>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue