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

59
app.py
View file

@ -22,8 +22,8 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Docker tab caches stale container IDs, so the footer is the reliable way
# to confirm a `docker compose up -d --build` actually took effect.
# Both are env-overridable if the deploy pipeline ever injects them.
APP_VERSION = os.environ.get("APP_VERSION", "2.1.0")
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-31")
APP_VERSION = os.environ.get("APP_VERSION", "2.2.0")
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-09-02")
# Must point at a path that survives `docker compose up --build` — the
# container filesystem does not. See the data volume in docker-compose.yml.
@ -790,6 +790,23 @@ def ombi_get(endpoint):
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):
if denied:
return "Denied"
@ -818,6 +835,7 @@ def get_ombi_summary():
if alias.lower() == "ninja_hippo":
continue
all_requests.append({
"id": m.get("id"),
"title": m.get("title", "Unknown"),
"type": "Movie",
"requested_date": m.get("requestedDate", ""),
@ -841,6 +859,9 @@ def get_ombi_summary():
)
denied = child.get("denied", False)
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"),
"type": "TV",
"requested_date": child.get("requestedDate", ""),
@ -1804,6 +1825,40 @@ def api_ombi():
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")
@login_required
def api_services():