diff --git a/CLAUDE.md b/CLAUDE.md index 5e06912..03e5041 100644 --- a/CLAUDE.md +++ b/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 4 hours. Active streams (Tautulli) and the SABnzbd queue are always 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` + `BeautifulSoup`, JSON-LD parsing), not via an official API — brittle to upstream HTML changes by nature. diff --git a/app.py b/app.py index b8e3977..95486b9 100644 --- a/app.py +++ b/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 # 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(): diff --git a/static/css/teletext.css b/static/css/teletext.css index a8b6b4a..28c4a16 100644 --- a/static/css/teletext.css +++ b/static/css/teletext.css @@ -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%; } diff --git a/static/js/service.js b/static/js/service.js index 475dfcd..71186dc 100644 --- a/static/js/service.js +++ b/static/js/service.js @@ -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(); }); diff --git a/templates/requests.html b/templates/requests.html index 5527db5..fb4d53e 100644 --- a/templates/requests.html +++ b/templates/requests.html @@ -10,15 +10,17 @@ {{ service_error('Ombi', ombi.error) }} {% else %} {#- 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). -#} -