Move fastext nav to page top, fix Settings header, add working search
All checks were successful
Deploy / deploy (push) Successful in 23s
All checks were successful
Deploy / deploy (push) Successful in 23s
- Fastext colour keys now lead every page but the index, since they're the only nav those pages carry; the index keeps them as its closing row since its own body is already the full nav. - Settings page was missing page="settings" in its render_template call, so its header silently fell back to "P100 Index". - Library's search form posted a q param the route never read; it now filters the cached Sonarr/Radarr lists and shows matching tiles. - Discover gained a search bar that queries Ombi (already in front of TMDB/TVDB) and lets you add a result straight to Radarr/Sonarr, showing "Held" instead of a button for anything already in library. - Fixed the Add button's success check, which read d.ok against a response that actually sets d.success — it reported FAILED even when the add worked.
This commit is contained in:
parent
08491c0c9b
commit
abaccaaacd
6 changed files with 172 additions and 16 deletions
100
app.py
100
app.py
|
|
@ -726,6 +726,61 @@ 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"
|
||||||
|
|
@ -1404,6 +1459,41 @@ def get_recently_added(limit=6):
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def search_library(q, limit=24):
|
||||||
|
"""Titles in Sonarr/Radarr matching q, case-insensitive substring.
|
||||||
|
|
||||||
|
Filters the same cached lists get_recently_added uses — no new fetch.
|
||||||
|
"""
|
||||||
|
needle = q.strip().lower()
|
||||||
|
if not needle:
|
||||||
|
return []
|
||||||
|
items = []
|
||||||
|
try:
|
||||||
|
for sr in sonarr_series_cached():
|
||||||
|
if needle in sr.get("title", "").lower():
|
||||||
|
items.append({
|
||||||
|
"title": sr.get("title", "Unknown"),
|
||||||
|
"kind": "TV",
|
||||||
|
"year": str(sr.get("year", "")),
|
||||||
|
"poster": _poster_url(sr.get("images")),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
for mv in radarr_movies_cached():
|
||||||
|
if needle in mv.get("title", "").lower():
|
||||||
|
items.append({
|
||||||
|
"title": mv.get("title", "Unknown"),
|
||||||
|
"kind": "Film",
|
||||||
|
"year": str(mv.get("year", "")),
|
||||||
|
"poster": _poster_url(mv.get("images")),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
items.sort(key=lambda x: x["title"].lower())
|
||||||
|
return items[:limit]
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|
@ -1508,9 +1598,11 @@ def watch_page():
|
||||||
@login_required
|
@login_required
|
||||||
def library_page():
|
def library_page():
|
||||||
r = fetch_services(["sonarr", "radarr"])
|
r = fetch_services(["sonarr", "radarr"])
|
||||||
|
q = request.args.get("q", "").strip()
|
||||||
return render_template("library.html", page="library",
|
return render_template("library.html", page="library",
|
||||||
sonarr=r["sonarr"], radarr=r["radarr"],
|
sonarr=r["sonarr"], radarr=r["radarr"],
|
||||||
recently_added=get_recently_added(12))
|
recently_added=get_recently_added(12),
|
||||||
|
q=q, search_results=search_library(q) if q else None)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/discover")
|
@app.route("/discover")
|
||||||
|
|
@ -1519,9 +1611,11 @@ def discover_page():
|
||||||
r = fetch_services(["sonarr", "radarr", "rt_movies", "rt_tv"])
|
r = fetch_services(["sonarr", "radarr", "rt_movies", "rt_tv"])
|
||||||
sonarr_titles = set(r["sonarr"].get("sonarr_titles", []))
|
sonarr_titles = set(r["sonarr"].get("sonarr_titles", []))
|
||||||
radarr_titles = set(r["radarr"].get("radarr_titles", []))
|
radarr_titles = set(r["radarr"].get("radarr_titles", []))
|
||||||
|
q = request.args.get("q", "").strip()
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/requests")
|
@app.route("/requests")
|
||||||
|
|
@ -1760,7 +1854,7 @@ def api_delete_show():
|
||||||
@login_required
|
@login_required
|
||||||
def settings_page():
|
def settings_page():
|
||||||
s = load_settings()
|
s = load_settings()
|
||||||
return render_template("settings.html",
|
return render_template("settings.html", page="settings",
|
||||||
timezone=s.get("timezone", "America/New_York"),
|
timezone=s.get("timezone", "America/New_York"),
|
||||||
sonarr_api_key=SONARR_API_KEY,
|
sonarr_api_key=SONARR_API_KEY,
|
||||||
radarr_api_key=RADARR_API_KEY,
|
radarr_api_key=RADARR_API_KEY,
|
||||||
|
|
|
||||||
|
|
@ -465,10 +465,15 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
|
||||||
.tt-empty { padding: 0 1ch; color: var(--ink-dim); }
|
.tt-empty { padding: 0 1ch; color: var(--ink-dim); }
|
||||||
|
|
||||||
/* ── Fastext ────────────────────────────────────────────
|
/* ── Fastext ────────────────────────────────────────────
|
||||||
The four coloured keys along the bottom of every teletext
|
The four coloured keys. Real navigation, not decoration:
|
||||||
page. Real navigation, not decoration: click one, or press
|
click one, or press its initial. Deliberately NOT fixed to
|
||||||
its initial. Deliberately NOT fixed to the viewport — it is
|
the viewport — it is a row of the page, where it always
|
||||||
the last row of the page, where it always was. */
|
was, not furniture bolted over it. On the index it closes
|
||||||
|
the page, under Now Showing/Just Added, because the index's
|
||||||
|
own body is already the full nav. Everywhere else — where
|
||||||
|
these four keys are the only nav on the page — it opens the
|
||||||
|
page, right under the header, so it doesn't need a scroll
|
||||||
|
to reach. */
|
||||||
.fastext {
|
.fastext {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
|
@ -477,6 +482,9 @@ a.tt-row:focus-visible .tt-row-sub { color: var(--tt-black); }
|
||||||
padding: 0 var(--gutter);
|
padding: 0 var(--gutter);
|
||||||
gap: 1ch;
|
gap: 1ch;
|
||||||
}
|
}
|
||||||
|
.fastext-top {
|
||||||
|
margin: var(--row) auto calc(var(--row) * 1.5);
|
||||||
|
}
|
||||||
.fastext a {
|
.fastext a {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@
|
||||||
})
|
})
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (d) {
|
.then(function (d) {
|
||||||
if (d && d.ok) {
|
if (d && d.success) {
|
||||||
btn.classList.add('done');
|
btn.classList.add('done');
|
||||||
btn.textContent = 'ADDED';
|
btn.textContent = 'ADDED';
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -45,19 +45,28 @@ provenance.
|
||||||
<span class="tt-head-clock" id="head-clock"></span>
|
<span class="tt-head-clock" id="head-clock"></span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="tt-page">
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{#- Fastext: the four coloured keys. Each page names its own four, so the
|
{#- Fastext: the four coloured keys. Each page names its own four, so the
|
||||||
keys always point somewhere useful from where you are. -#}
|
keys always point somewhere useful from where you are. On the index
|
||||||
<nav class="fastext" aria-label="Fastext">
|
it stays the closing row, under Now Showing/Just Added, since the
|
||||||
|
index's own body already is the full nav. Everywhere else it leads,
|
||||||
|
right under the header, since it's the only nav those pages carry. -#}
|
||||||
|
{% set fastext_nav %}
|
||||||
|
<nav class="fastext{{ '' if page == 'home' else ' fastext-top' }}" aria-label="Fastext">
|
||||||
{% for cls, key, num, label, href in fastext %}
|
{% for cls, key, num, label, href in fastext %}
|
||||||
<a class="{{ cls }}" href="{{ request.script_root }}{{ href }}" data-key="{{ key }}">
|
<a class="{{ cls }}" href="{{ request.script_root }}{{ href }}" data-key="{{ key }}">
|
||||||
<span>{{ num }} {{ label }}</span>
|
<span>{{ num }} {{ label }}</span>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
|
{% endset %}
|
||||||
|
|
||||||
|
{% if page != 'home' %}{{ fastext_nav }}{% endif %}
|
||||||
|
|
||||||
|
<main class="tt-page">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% if page == 'home' %}{{ fastext_nav }}{% endif %}
|
||||||
|
|
||||||
<footer class="tt-foot">
|
<footer class="tt-foot">
|
||||||
<span>MEDIA CENTRE</span>
|
<span>MEDIA CENTRE</span>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,40 @@
|
||||||
{% extends "_shell.html" %}
|
{% extends "_shell.html" %}
|
||||||
{% from "_macros.html" import tile %}
|
{% from "_macros.html" import tile, service_error %}
|
||||||
{% block title %}P103 Discover{% endblock %}
|
{% block title %}P103 Discover{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="tt-dh bloom tt-masthead">DISCOVER</h1>
|
<h1 class="tt-dh bloom tt-masthead">DISCOVER</h1>
|
||||||
<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
|
||||||
|
title in mind is the common case. This one asks Ombi, which already
|
||||||
|
sits in front of TheMovieDB/TVDB, then lets you add straight to
|
||||||
|
Radarr/Sonarr from the result. -#}
|
||||||
|
<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>
|
||||||
|
<input class="tt-input" type="search" id="q" name="q" value="{{ q }}"
|
||||||
|
placeholder="SEARCH TO ADD" autocomplete="off" spellcheck="false">
|
||||||
|
<button class="tt-btn" type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if search %}
|
||||||
|
<div class="tt-field">
|
||||||
|
<span>Results</span>
|
||||||
|
<span class="tt-field-meta">“{{ q }}”</span>
|
||||||
|
</div>
|
||||||
|
{% if search.status != 'ok' %}
|
||||||
|
{{ service_error('Ombi', search.error) }}
|
||||||
|
{% elif search.results %}
|
||||||
|
<div class="tt-tiles">
|
||||||
|
{% for it in search.results %}
|
||||||
|
{{ tile(it.title, it.kind ~ (' · Held' if it.held else ''), it.poster, it.year, ('' if it.held else '+ Add'), it.target) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="tt-empty">No matches for “{{ q }}”.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="tt-field">
|
<div class="tt-field">
|
||||||
<span>Films</span>
|
<span>Films</span>
|
||||||
<span class="tt-field-meta">{{ rt_movies | length }} not in library</span>
|
<span class="tt-field-meta">{{ rt_movies | length }} not in library</span>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,22 @@
|
||||||
<button class="tt-btn" type="submit">Search</button>
|
<button class="tt-btn" type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if search_results is not none %}
|
||||||
|
<div class="tt-field">
|
||||||
|
<span>Results</span>
|
||||||
|
<span class="tt-field-meta">{{ search_results | length }} match{{ 'es' if search_results | length != 1 else '' }}</span>
|
||||||
|
</div>
|
||||||
|
{% if search_results %}
|
||||||
|
<div class="tt-tiles">
|
||||||
|
{% for it in search_results %}
|
||||||
|
{{ tile(it.title, it.kind, it.poster, it.year) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="tt-empty">No matches for “{{ q }}”.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="tt-field">
|
<div class="tt-field">
|
||||||
<span>Holdings</span>
|
<span>Holdings</span>
|
||||||
<span class="tt-field-meta">
|
<span class="tt-field-meta">
|
||||||
|
|
@ -55,7 +71,7 @@
|
||||||
{{ service_error('Radarr', radarr.error) }}
|
{{ service_error('Radarr', radarr.error) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if recently_added %}
|
{% if not q and recently_added %}
|
||||||
<div class="tt-field"><span>Newest In</span></div>
|
<div class="tt-field"><span>Newest In</span></div>
|
||||||
<div class="tt-tiles">
|
<div class="tt-tiles">
|
||||||
{% for it in recently_added %}
|
{% for it in recently_added %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue