Search Sonarr/Radarr directly on Discover, full top nav, live System links
All checks were successful
Deploy / deploy (push) Successful in 24s

- Discover's search now hits Radarr's/Sonarr's own lookup endpoints
  directly (the same ones /add-to-radarr and /add-to-sonarr already
  use), instead of Ombi — one fewer round trip, and a result always
  resolves the same way its own Add button will. The Ombi-based
  ombi_search/_tmdb_poster helpers are gone.
- The top nav on every page but the index now lists all eight
  destinations (FULL_FASTEXT) instead of the original four; the index
  keeps the original four-entry row as its own closing line
  (HOME_FASTEXT), since its body already lists every destination once.
- System page's Transmission rows are now links to each service's own
  reverse-proxied instance (service_urls, already injected into every
  template but unused until now).
This commit is contained in:
Barely Removable 2026-08-30 08:40:52 -07:00
parent abaccaaacd
commit cef66e7361
4 changed files with 101 additions and 80 deletions

View file

@ -96,12 +96,20 @@ the user's Unraid home server. Server-side project directory is still named
`--ink-dimmer` fails contrast, which is exactly the bug the redesign's `--ink-dimmer` fails contrast, which is exactly the bug the redesign's
first inspection round caught. first inspection round caught.
- **Navigation is the page number.** Typing any three digits anywhere - **Navigation is the page number.** Typing any three digits anywhere
jumps to that page, and `r`/`g`/`y`/`c` fire the four fastext colour jumps to that page, and `r`/`g`/`y`/`c` fire four of the fastext
keys — both in `static/js/service.js`. `nav_items` in `app.py` is the entries — both in `static/js/service.js`. `nav_items` in `app.py` is
source of truth for the numbers and is serialised into the source of truth for the numbers and is serialised into
`<body data-pages>`. `<body data-pages>`.
- **Fastext leads everywhere but the index, and closes the index.**
`FULL_FASTEXT` in `app.py` lists every destination and renders right
under the header on every page but the index — those pages carry no
other nav, so it needs no scroll to reach. The index overrides it
with `HOME_FASTEXT`, the original four-entry row, as its own closing
line, since its body already lists every destination once. Only
those original four (Index/Library/Discover/System) carry an `r`/
`g`/`y`/`c` keyboard shortcut; the rest are click-only.
- **Nothing is clipped, hidden, or sticky.** All seven page numbers and - **Nothing is clipped, hidden, or sticky.** All seven page numbers and
all four fastext keys stay visible at every width, reflowing to another every fastext entry stay visible at every width, reflowing to another
row rather than scrolling, truncating, or folding into a menu. No row rather than scrolling, truncating, or folding into a menu. No
header is sticky. header is sticky.
- **`export-to-unraid.sh`** — a pre-existing standalone deploy script - **`export-to-unraid.sh`** — a pre-existing standalone deploy script

143
app.py
View file

@ -726,61 +726,6 @@ def ombi_get(endpoint):
return resp.json() return resp.json()
def _tmdb_poster(path):
"""Ombi's movie search returns a bare TMDB path ('/abc123.jpg'), not a
usable URL; TV search returns a full TVDB banner URL already."""
if not path:
return ""
if path.startswith("http"):
return path
return f"https://image.tmdb.org/t/p/w300{path}"
def ombi_search(q, limit=12):
"""Movies and TV matching q, via Ombi's own search — it already sits in
front of TheMovieDB/TVDB, so this is one call instead of two more API
keys. Cross-referenced against the cached Sonarr/Radarr title sets so a
title already held shows that instead of an Add button.
"""
term = q.strip()
if not term:
return {"status": "ok", "results": []}
encoded = requests.utils.quote(term)
try:
radarr_titles = {t.lower() for t in get_radarr_summary().get("radarr_titles", [])}
sonarr_titles = {t.lower() for t in get_sonarr_summary().get("sonarr_titles", [])}
results = []
try:
for m in ombi_get(f"Search/movie/{encoded}")[:limit]:
title = m.get("title", "")
results.append({
"title": title,
"year": (m.get("releaseDate") or "")[:4],
"poster": _tmdb_poster(m.get("posterPath")),
"kind": "Film",
"target": "radarr",
"held": bool(m.get("available")) or title.lower() in radarr_titles,
})
except Exception:
pass
try:
for s in ombi_get(f"Search/tv/{encoded}")[:limit]:
title = s.get("title", "")
results.append({
"title": title,
"year": (s.get("firstAired") or "")[:4],
"poster": s.get("banner") or _tmdb_poster(s.get("posterPath")),
"kind": "TV",
"target": "sonarr",
"held": bool(s.get("available")) or title.lower() in sonarr_titles,
})
except Exception:
pass
return {"status": "ok", "results": results}
except Exception as e:
return {"status": "error", "error": str(e), "results": []}
def _ombi_status_label(available, approved, denied): def _ombi_status_label(available, approved, denied):
if denied: if denied:
return "Denied" return "Denied"
@ -1358,6 +1303,30 @@ def service_urls():
} }
# Every destination, for the top of every page but the index. Order
# matches nav_items; the four letter keys keep the destinations they
# always had, cycling colour for the rest since only four exist.
FULL_FASTEXT = [
("k-red", "r", "100", "Index", "/"),
("k-cyan", "", "101", "Watch", "/watch"),
("k-green", "g", "102", "Library", "/library"),
("k-yellow", "y", "103", "Discover", "/discover"),
("k-red", "", "104", "Requests", "/requests"),
("k-cyan", "c", "105", "System", "/system"),
("k-green", "", "106", "Activity", "/activity"),
("k-yellow", "", "900", "Settings", "/settings"),
]
# The index's own closing row: just the four letter keys, since the
# index's body already lists every one of the above once.
HOME_FASTEXT = [
("k-red", "r", "100", "Index", "/"),
("k-green", "g", "102", "Library", "/library"),
("k-yellow", "y", "103", "Discover", "/discover"),
("k-cyan", "c", "105", "System", "/system"),
]
@app.context_processor @app.context_processor
def inject_page_chrome(): def inject_page_chrome():
"""Shell-wide context: the nav, the clock's timezone, service links.""" """Shell-wide context: the nav, the clock's timezone, service links."""
@ -1378,15 +1347,12 @@ def inject_page_chrome():
("activity", "106", "Activity", "/activity"), ("activity", "106", "Activity", "/activity"),
("settings", "900", "Settings", "/settings"), ("settings", "900", "Settings", "/settings"),
], ],
# The four coloured keys along the bottom of every page. A page # Every page, at the top of every page but the index — which
# that wants different destinations passes its own `fastext` to # overrides this with HOME_FASTEXT, its own short closing row,
# render_template, which wins over this default. # since its body already lists all of these once. The four
"fastext": [ # letter keys (r/g/y/c) keep their original destinations; the
("k-red", "r", "100", "Index", "/"), # rest have no keyboard shortcut, just a click.
("k-green", "g", "102", "Library", "/library"), "fastext": FULL_FASTEXT,
("k-yellow", "y", "103", "Discover", "/discover"),
("k-cyan", "c", "105", "System", "/system"),
],
} }
@ -1494,6 +1460,51 @@ def search_library(q, limit=24):
return items[:limit] return items[:limit]
def arr_search(q, limit=12):
"""Movies and TV matching q, straight from Radarr's and Sonarr's own
lookup endpoints — the same ones /add-to-radarr and /add-to-sonarr
already use to resolve a title before adding. No Ombi round trip and
no second title-matching path to keep in sync with the Add buttons.
"""
term = q.strip()
if not term:
return {"status": "ok", "results": []}
encoded = requests.utils.quote(term)
results = []
radarr_ok = sonarr_ok = True
try:
radarr_titles = {t.lower() for t in get_radarr_summary().get("radarr_titles", [])}
for m in radarr_get(f"movie/lookup?term={encoded}")[:limit]:
title = m.get("title", "")
results.append({
"title": title,
"year": str(m.get("year", "")),
"poster": _poster_url(m.get("images")),
"kind": "Film",
"target": "radarr",
"held": title.lower() in radarr_titles,
})
except Exception:
radarr_ok = False
try:
sonarr_titles = {t.lower() for t in get_sonarr_summary().get("sonarr_titles", [])}
for s in sonarr_get(f"series/lookup?term={encoded}")[:limit]:
title = s.get("title", "")
results.append({
"title": title,
"year": str(s.get("year", "")),
"poster": _poster_url(s.get("images")),
"kind": "TV",
"target": "sonarr",
"held": title.lower() in sonarr_titles,
})
except Exception:
sonarr_ok = False
if not results and not radarr_ok and not sonarr_ok:
return {"status": "error", "error": "Sonarr and Radarr both unreachable", "results": []}
return {"status": "ok", "results": results}
def get_activity_feed(sabnzbd, ombi, limit=25): def get_activity_feed(sabnzbd, ombi, limit=25):
"""A single chronological feed assembled from what the fetchers return. """A single chronological feed assembled from what the fetchers return.
@ -1572,7 +1583,7 @@ def home():
others = [s for s in sessions if s is not hero] others = [s for s in sessions if s is not hero]
return render_template( return render_template(
"home.html", page="home", "home.html", page="home", fastext=HOME_FASTEXT,
sonarr=sonarr, radarr=radarr, sabnzbd=r["sabnzbd"], tautulli=tautulli, sonarr=sonarr, radarr=radarr, sabnzbd=r["sabnzbd"], tautulli=tautulli,
prowlarr=r["prowlarr"], ombi=r["ombi"], unraid=r["unraid"], prowlarr=r["prowlarr"], ombi=r["ombi"], unraid=r["unraid"],
hero=hero, other_sessions=others, hero=hero, other_sessions=others,
@ -1615,7 +1626,7 @@ def discover_page():
return render_template("discover.html", page="discover", return render_template("discover.html", page="discover",
rt_movies=_filter_rt(r["rt_movies"], radarr_titles), rt_movies=_filter_rt(r["rt_movies"], radarr_titles),
rt_tv=_filter_rt(r["rt_tv"], sonarr_titles), rt_tv=_filter_rt(r["rt_tv"], sonarr_titles),
q=q, search=ombi_search(q) if q else None) q=q, search=arr_search(q) if q else None)
@app.route("/requests") @app.route("/requests")

View file

@ -7,11 +7,12 @@
<p class="tt-empty">Popular right now, with anything already in library filtered out.</p> <p class="tt-empty">Popular right now, with anything already in library filtered out.</p>
{#- Search sits at the top for the same reason it does on Library: a {#- Search sits at the top for the same reason it does on Library: a
title in mind is the common case. This one asks Ombi, which already title in mind is the common case. This one hits Radarr's and
sits in front of TheMovieDB/TVDB, then lets you add straight to Sonarr's own lookup directly — the same call /add-to-radarr and
Radarr/Sonarr from the result. -#} /add-to-sonarr already make — so a result here always resolves the
same way a click on its own Add button will. -#}
<form class="tt-search" action="{{ request.script_root }}/discover" method="get" role="search"> <form class="tt-search" action="{{ request.script_root }}/discover" method="get" role="search">
<label class="visually-hidden" for="q">Search Ombi for a title</label> <label class="visually-hidden" for="q">Search Sonarr and Radarr for a title to add</label>
<input class="tt-input" type="search" id="q" name="q" value="{{ q }}" <input class="tt-input" type="search" id="q" name="q" value="{{ q }}"
placeholder="SEARCH TO ADD" autocomplete="off" spellcheck="false"> placeholder="SEARCH TO ADD" autocomplete="off" spellcheck="false">
<button class="tt-btn" type="submit">Search</button> <button class="tt-btn" type="submit">Search</button>
@ -23,7 +24,7 @@
<span class="tt-field-meta">&ldquo;{{ q }}&rdquo;</span> <span class="tt-field-meta">&ldquo;{{ q }}&rdquo;</span>
</div> </div>
{% if search.status != 'ok' %} {% if search.status != 'ok' %}
{{ service_error('Ombi', search.error) }} {{ service_error('Sonarr/Radarr', search.error) }}
{% elif search.results %} {% elif search.results %}
<div class="tt-tiles"> <div class="tt-tiles">
{% for it in search.results %} {% for it in search.results %}

View file

@ -7,18 +7,19 @@
{#- ── Service transmission status ─────────────────────────────── {#- ── Service transmission status ───────────────────────────────
Six services, one row each, colour carrying up or down. This is Six services, one row each, colour carrying up or down. This is
what the old header pills were, given room to say something. -#} what the old header pills were, given room to say something. Each
row opens that service's own instance, via the reverse proxy. -#}
<div class="tt-field"><span>Transmission</span></div> <div class="tt-field"><span>Transmission</span></div>
<div class="tt-rows"> <div class="tt-rows">
{% for name, svc in [('Sonarr', sonarr), ('Radarr', radarr), ('SABnzbd', sabnzbd), {% for name, key, svc in [('Sonarr', 'sonarr', sonarr), ('Radarr', 'radarr', radarr), ('SABnzbd', 'sabnzbd', sabnzbd),
('Tautulli', tautulli), ('Prowlarr', prowlarr), ('Ombi', ombi)] %} ('Tautulli', 'tautulli', tautulli), ('Prowlarr', 'prowlarr', prowlarr), ('Ombi', 'ombi', ombi)] %}
<div class="tt-row"> <a class="tt-row" href="{{ service_urls[key] }}" target="_blank" rel="noopener">
<span class="tt-row-name">{{ name }}</span> <span class="tt-row-name">{{ name }}</span>
<span class="tt-row-body"></span> <span class="tt-row-body"></span>
<span class="tt-row-val {{ 'ok' if svc.status == 'ok' else 'alert' }}"> <span class="tt-row-val {{ 'ok' if svc.status == 'ok' else 'alert' }}">
{{- 'ON AIR' if svc.status == 'ok' else 'OFF AIR' -}} {{- 'ON AIR' if svc.status == 'ok' else 'OFF AIR' -}}
</span> </span>
</div> </a>
{% endfor %} {% endfor %}
</div> </div>