Redesign Media Centre as a teletext broadcast information service
Replaces the neutral dark card-based theme with "Page 100": teletext's
locked eight colours at full saturation on pure black, numbered pages
reachable by typing the number (or the r/g/y/c fastext keys), and no
borders, boxes, or radius anywhere — grouping is carried by full-bleed
colour fields and row weight instead.
Chosen via the impeccable skill's replacement-world flow: seven visual
candidates derived from the audience's own world, a mandatory roll
assigned Ceefax/US-cable-guide teletext, six catalog challengers weighed
against it, and the user locked the assigned direction on the decision
page (seed aab4510e).
- New templates for all nine pages (home/watch/library/discover/
requests/system/activity/login/settings) behind a shared _shell.html
carrying the direction contract and _macros.html for the row/bar/tile
vocabulary; deletes the old single-page dashboard.html and its
dedicated stylesheets/script.
- static/css/base.css + teletext.css: the locked palette, a two-height
type scale (Chivo Mono / DotGothic16), and the five disciplines the
system is held to (no in-between tone, no enclosure, one shared bar
ruler, trend only where real history exists, pages weighted by state).
- static/js/service.js: header clock, page-number jump, fastext keys,
two-tap arm/confirm add, guarded live polling — replaces dashboard.js.
- app.py: nav_items now carry page numbers; fastext defaults added to
the page-chrome context processor; version bumped to 2.0.0.
- DESIGN.md + .impeccable/design.json regenerated from the built world
by the documenter pass; PRODUCT.md and CLAUDE.md updated to describe
the new system in place of the retired one.
- Fixed a live bug found along the way: settings.html called
url_for('dashboard'), an endpoint removed earlier in the branch, which
would have 500'd the Settings page.
- Fixed a One-Ruler violation the finish review's recapture request
surfaced: --bar-cells disagreed with the bar() macro's hardcoded cell
count below the 760px breakpoint, so a fill could land mid-cell on a
phone. Removed the mobile override; the ruler is now 20 cells at every
width, matching the macro unconditionally.
Passed two inspection rounds (desktop + true-390px mobile, via an
iframe harness since macOS Chrome clamps --window-size) and a finish
review; QA screenshots and interview logs are gitignored as scratch,
not shipped in the repo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS
This commit is contained in:
parent
9a277b9031
commit
4b19cbdfb2
25 changed files with 3002 additions and 2343 deletions
454
app.py
454
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", "1.6.3")
|
||||
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-27")
|
||||
APP_VERSION = os.environ.get("APP_VERSION", "2.0.0")
|
||||
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-29")
|
||||
|
||||
# Must point at a path that survives `docker compose up --build` — the
|
||||
# container filesystem does not. See the data volume in docker-compose.yml.
|
||||
|
|
@ -376,15 +376,24 @@ def sonarr_post(endpoint, payload):
|
|||
return resp.json()
|
||||
|
||||
|
||||
def get_sonarr_summary():
|
||||
def sonarr_series_cached():
|
||||
"""The full series list, behind the 5-minute cache.
|
||||
|
||||
Split out of get_sonarr_summary so the library and home pages can reuse
|
||||
the same cached payload instead of each paying for the fetch.
|
||||
"""
|
||||
global _sonarr_cache
|
||||
now = time.time()
|
||||
if _sonarr_cache["data"] is not None and now < _sonarr_cache["expires"]:
|
||||
return _sonarr_cache["data"]
|
||||
series = sonarr_get("series")
|
||||
_sonarr_cache = {"data": series, "expires": now + ARR_CACHE_TTL}
|
||||
return series
|
||||
|
||||
|
||||
def get_sonarr_summary():
|
||||
try:
|
||||
now = time.time()
|
||||
if _sonarr_cache["data"] is not None and now < _sonarr_cache["expires"]:
|
||||
series = _sonarr_cache["data"]
|
||||
else:
|
||||
series = sonarr_get("series")
|
||||
_sonarr_cache = {"data": series, "expires": now + ARR_CACHE_TTL}
|
||||
series = sonarr_series_cached()
|
||||
wanted = sonarr_get("wanted/missing?pageSize=1")
|
||||
total_series = len(series)
|
||||
monitored = sum(1 for s in series if s.get("monitored"))
|
||||
|
|
@ -419,15 +428,20 @@ def radarr_post(endpoint, payload):
|
|||
return resp.json()
|
||||
|
||||
|
||||
def get_radarr_summary():
|
||||
def radarr_movies_cached():
|
||||
"""The full movie list, behind the 5-minute cache. See sonarr_series_cached."""
|
||||
global _radarr_cache
|
||||
now = time.time()
|
||||
if _radarr_cache["data"] is not None and now < _radarr_cache["expires"]:
|
||||
return _radarr_cache["data"]
|
||||
movies = radarr_get("movie")
|
||||
_radarr_cache = {"data": movies, "expires": now + ARR_CACHE_TTL}
|
||||
return movies
|
||||
|
||||
|
||||
def get_radarr_summary():
|
||||
try:
|
||||
now = time.time()
|
||||
if _radarr_cache["data"] is not None and now < _radarr_cache["expires"]:
|
||||
movies = _radarr_cache["data"]
|
||||
else:
|
||||
movies = radarr_get("movie")
|
||||
_radarr_cache = {"data": movies, "expires": now + ARR_CACHE_TTL}
|
||||
movies = radarr_movies_cached()
|
||||
total_movies = len(movies)
|
||||
monitored = sum(1 for m in movies if m.get("monitored"))
|
||||
downloaded = sum(1 for m in movies if m.get("hasFile"))
|
||||
|
|
@ -499,12 +513,25 @@ def _get_sab_history_cached():
|
|||
speeds.append(size_bytes / duration)
|
||||
avg_bytes_per_sec = sum(speeds) / len(speeds) if speeds else 0
|
||||
avg_speed = _format_speed(avg_bytes_per_sec / 1024)
|
||||
# Keep the newest completed items too — "Landed today" on the home
|
||||
# page and the activity feed both read these. Same request as before;
|
||||
# previously only the aggregates survived.
|
||||
newest = sorted(recent, key=lambda x: x.get("completed", 0), reverse=True)[:20]
|
||||
recent_items = [{
|
||||
"name": s_.get("name", "Unknown"),
|
||||
"category": s_.get("category", ""),
|
||||
"size": s_.get("size", ""),
|
||||
"completed": int(s_.get("completed", 0) or 0),
|
||||
} for s_ in newest]
|
||||
|
||||
result = {"week_size": week_size, "day_size": day_size,
|
||||
"completed_7d": completed_7d, "avg_speed": avg_speed}
|
||||
"completed_7d": completed_7d, "avg_speed": avg_speed,
|
||||
"recent_items": recent_items}
|
||||
_sab_history_cache = {"data": result, "expires": now + 600}
|
||||
return result
|
||||
except Exception:
|
||||
return {"week_size": "—", "day_size": "—", "completed_7d": "—", "avg_speed": "—"}
|
||||
return {"week_size": "—", "day_size": "—", "completed_7d": "—",
|
||||
"avg_speed": "—", "recent_items": []}
|
||||
|
||||
|
||||
def get_sabnzbd_summary():
|
||||
|
|
@ -538,6 +565,7 @@ def get_sabnzbd_summary():
|
|||
"week_size": hist["week_size"],
|
||||
"day_size": hist["day_size"],
|
||||
"avg_speed": hist["avg_speed"],
|
||||
"recent_items": hist.get("recent_items", []),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
|
@ -554,6 +582,23 @@ def tautulli_get(cmd, extra=""):
|
|||
return resp.json().get("response", {}).get("data", {})
|
||||
|
||||
|
||||
def _ms_to_hm(ms):
|
||||
"""Milliseconds to a compact '1h 42m' / '38m' label."""
|
||||
if not ms or ms < 0:
|
||||
return ""
|
||||
mins = int(ms // 60000)
|
||||
h, m = divmod(mins, 60)
|
||||
return f"{h}h {m:02d}m" if h else f"{m}m"
|
||||
|
||||
|
||||
def _ends_at(remaining_ms):
|
||||
"""Wall-clock time this stream finishes, as HH:MM. Server-local, which is
|
||||
the same timezone the header clock renders in."""
|
||||
if not remaining_ms or remaining_ms < 0:
|
||||
return ""
|
||||
return (datetime.now() + timedelta(milliseconds=remaining_ms)).strftime("%H:%M")
|
||||
|
||||
|
||||
def get_tautulli_summary():
|
||||
global _tautulli_stats_cache
|
||||
try:
|
||||
|
|
@ -620,11 +665,34 @@ def get_tautulli_summary():
|
|||
raw_sessions = activity.get("sessions", []) if isinstance(activity, dict) else []
|
||||
sessions = []
|
||||
for s in raw_sessions:
|
||||
try:
|
||||
progress = int(float(s.get("progress_percent", 0) or 0))
|
||||
except (TypeError, ValueError):
|
||||
progress = 0
|
||||
try:
|
||||
duration_ms = int(float(s.get("duration", 0) or 0))
|
||||
offset_ms = int(float(s.get("view_offset", 0) or 0))
|
||||
except (TypeError, ValueError):
|
||||
duration_ms = offset_ms = 0
|
||||
decision = (s.get("transcode_decision") or "").lower()
|
||||
sessions.append({
|
||||
"user": s.get("friendly_name") or s.get("user", "Unknown"),
|
||||
"title": s.get("full_title") or s.get("title", "Unknown"),
|
||||
"media_type": s.get("media_type", ""),
|
||||
"state": s.get("state", ""),
|
||||
# Everything below comes from the same get_activity payload.
|
||||
"progress": max(0, min(100, progress)),
|
||||
"player": s.get("player", ""),
|
||||
"quality": s.get("video_full_resolution") or s.get("quality_profile", ""),
|
||||
"decision": decision,
|
||||
"year": s.get("year", ""),
|
||||
"duration_ms": duration_ms,
|
||||
"offset_ms": offset_ms,
|
||||
"elapsed": _ms_to_hm(offset_ms),
|
||||
"remaining": _ms_to_hm(max(0, duration_ms - offset_ms)),
|
||||
"ends_at": _ends_at(duration_ms - offset_ms),
|
||||
"art": s.get("art", ""),
|
||||
"thumb": s.get("thumb", ""),
|
||||
})
|
||||
|
||||
top_tv = top_tv_list[0] if top_tv_list else None
|
||||
|
|
@ -1109,14 +1177,14 @@ def login():
|
|||
session.permanent = True
|
||||
# Always redirect to dashboard — avoids bare-path redirect issues
|
||||
# when running behind a reverse proxy subpath (e.g. /media/)
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("home"))
|
||||
# Deliberately doesn't say which half was wrong.
|
||||
error = ("Incorrect username or password." if multi_user
|
||||
else "Incorrect password. Try again.")
|
||||
# If nothing is configured at all, auto-login (dev mode)
|
||||
if not multi_user and not get_active_password():
|
||||
session["authenticated"] = True
|
||||
return redirect(url_for("dashboard"))
|
||||
return redirect(url_for("home"))
|
||||
return render_template("login.html", error=error, multi_user=multi_user)
|
||||
|
||||
|
||||
|
|
@ -1167,64 +1235,62 @@ def api_rt_descriptions():
|
|||
return jsonify({"descriptions": result, "pending": still_pending})
|
||||
|
||||
|
||||
@app.route("/")
|
||||
@login_required
|
||||
def dashboard():
|
||||
# Fetch all service data in parallel with a hard 5-second wall-clock deadline.
|
||||
# Any service that hasn't responded in time returns a graceful error dict so
|
||||
# the page always renders quickly — critical for remote/cellular access.
|
||||
DASHBOARD_TIMEOUT = 5.0
|
||||
tasks = {
|
||||
"sonarr": get_sonarr_summary,
|
||||
"radarr": get_radarr_summary,
|
||||
"sabnzbd": get_sabnzbd_summary,
|
||||
"tautulli": get_tautulli_summary,
|
||||
"prowlarr": get_prowlarr_summary,
|
||||
"ombi": get_ombi_summary,
|
||||
"unraid": get_unraid_stats,
|
||||
"rt_movies": get_rt_movies,
|
||||
"rt_tv": get_rt_tv,
|
||||
}
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page plumbing
|
||||
#
|
||||
# Each page fetches only the services it actually renders. The old single
|
||||
# dashboard route fetched all nine every time; now /system never pays for the
|
||||
# Rotten Tomatoes scrape and /requests never pays for the 4.4 MB Radarr list.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PAGE_TASKS = {
|
||||
"sonarr": lambda: get_sonarr_summary(),
|
||||
"radarr": lambda: get_radarr_summary(),
|
||||
"sabnzbd": lambda: get_sabnzbd_summary(),
|
||||
"tautulli": lambda: get_tautulli_summary(),
|
||||
"prowlarr": lambda: get_prowlarr_summary(),
|
||||
"ombi": lambda: get_ombi_summary(),
|
||||
"unraid": lambda: get_unraid_stats(),
|
||||
"rt_movies": lambda: get_rt_movies(),
|
||||
"rt_tv": lambda: get_rt_tv(),
|
||||
}
|
||||
|
||||
PAGE_TIMEOUT = 5.0
|
||||
|
||||
|
||||
def fetch_services(names, timeout=PAGE_TIMEOUT):
|
||||
"""Fetch the named services in parallel under a hard wall-clock deadline.
|
||||
|
||||
Anything that misses the deadline degrades to an error dict rather than
|
||||
blocking the page — the behaviour the single dashboard route always had,
|
||||
lifted out so every page gets it.
|
||||
"""
|
||||
tasks = {n: PAGE_TASKS[n] for n in names}
|
||||
results = {}
|
||||
if not tasks:
|
||||
return results
|
||||
with ThreadPoolExecutor(max_workers=len(tasks)) as ex:
|
||||
futures = {ex.submit(fn): name for name, fn in tasks.items()}
|
||||
deadline = time.time() + DASHBOARD_TIMEOUT
|
||||
for future in as_completed(futures, timeout=DASHBOARD_TIMEOUT):
|
||||
name = futures[future]
|
||||
try:
|
||||
results[name] = future.result(timeout=max(0.1, deadline - time.time()))
|
||||
except Exception as e:
|
||||
results[name] = {"status": "error", "error": str(e)}
|
||||
# Fill in any tasks that didn't complete within the deadline
|
||||
deadline = time.time() + timeout
|
||||
try:
|
||||
for future in as_completed(futures, timeout=timeout):
|
||||
name = futures[future]
|
||||
try:
|
||||
results[name] = future.result(timeout=max(0.1, deadline - time.time()))
|
||||
except Exception as e:
|
||||
results[name] = {"status": "error", "error": str(e)}
|
||||
except Exception:
|
||||
pass # as_completed timed out; the fill-in below covers the rest
|
||||
for name in tasks:
|
||||
if name not in results:
|
||||
results[name] = {"status": "error", "error": "timeout"}
|
||||
return results
|
||||
|
||||
sonarr = results["sonarr"]
|
||||
radarr = results["radarr"]
|
||||
sabnzbd = results["sabnzbd"]
|
||||
tautulli = results["tautulli"]
|
||||
prowlarr = results["prowlarr"]
|
||||
ombi = results["ombi"]
|
||||
unraid = results["unraid"]
|
||||
|
||||
sonarr_titles = set(sonarr.get("sonarr_titles", []))
|
||||
radarr_titles = set(radarr.get("radarr_titles", []))
|
||||
|
||||
_rt_movies_raw = results["rt_movies"]
|
||||
_rt_tv_raw = results["rt_tv"]
|
||||
rt_movies = [m for m in _rt_movies_raw if not m.get("error") and m.get("title", "").lower() not in radarr_titles][:10]
|
||||
if not rt_movies and _rt_movies_raw:
|
||||
rt_movies = _rt_movies_raw
|
||||
rt_tv = [s for s in _rt_tv_raw if not s.get("error") and s.get("title", "").lower() not in sonarr_titles][:10]
|
||||
if not rt_tv and _rt_tv_raw:
|
||||
rt_tv = _rt_tv_raw
|
||||
|
||||
s = load_settings()
|
||||
timezone = s.get("timezone", "America/New_York")
|
||||
# Public-facing URLs for card header links (via NPM reverse proxy)
|
||||
arr_host_public = f"https://hippofam.com"
|
||||
service_urls = {
|
||||
def service_urls():
|
||||
"""Public URLs for the 'open in <service>' links, via the reverse proxy."""
|
||||
arr_host_public = "https://hippofam.com"
|
||||
return {
|
||||
"sonarr": f"{arr_host_public}/sonarr",
|
||||
"radarr": f"{arr_host_public}/radarr",
|
||||
"sabnzbd": f"{arr_host_public}/sab",
|
||||
|
|
@ -1233,17 +1299,251 @@ def dashboard():
|
|||
"ombi": f"{arr_host_public}/ombi",
|
||||
"unraid": f"https://{UNRAID_HOST}",
|
||||
}
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_page_chrome():
|
||||
"""Shell-wide context: the nav, the clock's timezone, service links."""
|
||||
s = load_settings()
|
||||
return {
|
||||
"timezone": s.get("timezone", "America/New_York"),
|
||||
"service_urls": service_urls(),
|
||||
# (key, page number, name, href). The page number is the
|
||||
# navigation: it shows in the header, it's what the visitor
|
||||
# types to jump, and it's the row label on the index.
|
||||
"nav_items": [
|
||||
("home", "100", "Index", "/"),
|
||||
("watch", "101", "Watch", "/watch"),
|
||||
("library", "102", "Library", "/library"),
|
||||
("discover", "103", "Discover", "/discover"),
|
||||
("requests", "104", "Requests", "/requests"),
|
||||
("system", "105", "System", "/system"),
|
||||
("activity", "106", "Activity", "/activity"),
|
||||
("settings", "900", "Settings", "/settings"),
|
||||
],
|
||||
# The four coloured keys along the bottom of every page. A page
|
||||
# that wants different destinations passes its own `fastext` to
|
||||
# render_template, which wins over this default.
|
||||
"fastext": [
|
||||
("k-red", "r", "100", "Index", "/"),
|
||||
("k-green", "g", "102", "Library", "/library"),
|
||||
("k-yellow", "y", "103", "Discover", "/discover"),
|
||||
("k-cyan", "c", "105", "System", "/system"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derived views over data the fetchers already cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _poster_url(images):
|
||||
for img in images or []:
|
||||
if img.get("coverType") == "poster":
|
||||
return img.get("remoteUrl") or img.get("url") or ""
|
||||
return ""
|
||||
|
||||
|
||||
def _ago(iso):
|
||||
"""'2024-08-27T10:00:00Z' -> '2 days'. Empty string if unparseable."""
|
||||
if not iso:
|
||||
return ""
|
||||
try:
|
||||
dt = datetime.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S")
|
||||
except (ValueError, TypeError):
|
||||
return ""
|
||||
days = (datetime.utcnow() - dt).days
|
||||
if days <= 0:
|
||||
return "today"
|
||||
if days == 1:
|
||||
return "1 day"
|
||||
if days < 7:
|
||||
return f"{days} days"
|
||||
weeks = days // 7
|
||||
return "1 wk" if weeks == 1 else f"{weeks} wks"
|
||||
|
||||
|
||||
def get_recently_added(limit=6):
|
||||
"""Newest titles across both libraries.
|
||||
|
||||
Derived from the same cached Sonarr/Radarr lists the summaries already
|
||||
hold — a sort over the `added` field those payloads carry, not a new
|
||||
API call. Poster URLs come from the same records.
|
||||
"""
|
||||
items = []
|
||||
try:
|
||||
for sr in sonarr_series_cached():
|
||||
items.append({
|
||||
"title": sr.get("title", "Unknown"),
|
||||
"sub": f"{sr.get('statistics', {}).get('episodeFileCount', 0)} eps",
|
||||
"kind": "TV",
|
||||
"added": sr.get("added", "") or "",
|
||||
"poster": _poster_url(sr.get("images")),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
for mv in radarr_movies_cached():
|
||||
if not mv.get("hasFile"):
|
||||
continue
|
||||
items.append({
|
||||
"title": mv.get("title", "Unknown"),
|
||||
"sub": str(mv.get("year", "")) or "Film",
|
||||
"kind": "Film",
|
||||
"added": mv.get("added", "") or "",
|
||||
"poster": _poster_url(mv.get("images")),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
items.sort(key=lambda x: x["added"], reverse=True)
|
||||
items = items[:limit]
|
||||
for it in items:
|
||||
it["ago"] = _ago(it["added"])
|
||||
return items
|
||||
|
||||
|
||||
def get_activity_feed(sabnzbd, ombi, limit=25):
|
||||
"""A single chronological feed assembled from what the fetchers return.
|
||||
|
||||
Three real event sources, no new calls: SABnzbd completions, Ombi
|
||||
requests, and library additions. Anything without a usable timestamp is
|
||||
dropped rather than guessed at.
|
||||
"""
|
||||
events = []
|
||||
|
||||
for it in (sabnzbd or {}).get("recent_items", []):
|
||||
ts = it.get("completed", 0)
|
||||
if not ts:
|
||||
continue
|
||||
events.append({
|
||||
"ts": ts, "kind": "grabbed", "label": "Grabbed",
|
||||
"title": it.get("name", "Unknown"),
|
||||
"detail": " · ".join(x for x in [it.get("size", ""), it.get("category", "")] if x),
|
||||
})
|
||||
|
||||
for r in (ombi or {}).get("recent", []):
|
||||
iso = r.get("requested_date", "")
|
||||
try:
|
||||
ts = datetime.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S").timestamp()
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
events.append({
|
||||
"ts": ts, "kind": "requested", "label": "Requested",
|
||||
"title": r.get("title", "Unknown"),
|
||||
"detail": f"{r.get('user', '')} · {r.get('status', '')}",
|
||||
})
|
||||
|
||||
for it in get_recently_added(limit=12):
|
||||
iso = it.get("added", "")
|
||||
try:
|
||||
ts = datetime.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S").timestamp()
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
events.append({
|
||||
"ts": ts, "kind": "added", "label": "Added",
|
||||
"title": it.get("title", "Unknown"),
|
||||
"detail": f"{it.get('kind', '')} · {it.get('sub', '')}",
|
||||
})
|
||||
|
||||
events.sort(key=lambda e: e["ts"], reverse=True)
|
||||
for e in events:
|
||||
e["when"] = datetime.fromtimestamp(e["ts"]).strftime("%d %b · %H:%M")
|
||||
return events[:limit]
|
||||
|
||||
|
||||
def _filter_rt(raw, owned_titles, limit=10):
|
||||
"""Drop anything already in the library; fall back to the unfiltered list
|
||||
if that leaves nothing (better a stale suggestion than an empty card)."""
|
||||
filtered = [x for x in raw if not x.get("error")
|
||||
and x.get("title", "").lower() not in owned_titles][:limit]
|
||||
return filtered or raw
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route("/")
|
||||
@login_required
|
||||
def home():
|
||||
"""Marquee: what's playing, one line of system state, then what's new."""
|
||||
r = fetch_services(["sonarr", "radarr", "sabnzbd", "tautulli",
|
||||
"prowlarr", "ombi", "unraid", "rt_movies", "rt_tv"])
|
||||
sonarr, radarr = r["sonarr"], r["radarr"]
|
||||
radarr_titles = set(radarr.get("radarr_titles", []))
|
||||
sonarr_titles = set(sonarr.get("sonarr_titles", []))
|
||||
|
||||
tautulli = r["tautulli"]
|
||||
sessions = tautulli.get("sessions", []) if tautulli.get("status") == "ok" else []
|
||||
# The hero shows the furthest-along stream; the rest sit beside it.
|
||||
hero = max(sessions, key=lambda s: s.get("progress", 0)) if sessions else None
|
||||
others = [s for s in sessions if s is not hero]
|
||||
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
sonarr=sonarr, radarr=radarr, sabnzbd=sabnzbd, tautulli=tautulli,
|
||||
prowlarr=prowlarr, ombi=ombi, unraid=unraid,
|
||||
rt_movies=rt_movies, rt_tv=rt_tv,
|
||||
sonarr_titles=sonarr_titles, radarr_titles=radarr_titles,
|
||||
timezone=timezone,
|
||||
service_urls=service_urls,
|
||||
"home.html", page="home",
|
||||
sonarr=sonarr, radarr=radarr, sabnzbd=r["sabnzbd"], tautulli=tautulli,
|
||||
prowlarr=r["prowlarr"], ombi=r["ombi"], unraid=r["unraid"],
|
||||
hero=hero, other_sessions=others,
|
||||
recently_added=get_recently_added(6),
|
||||
rt_movies=_filter_rt(r["rt_movies"], radarr_titles, 6),
|
||||
rt_tv=_filter_rt(r["rt_tv"], sonarr_titles, 6),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/watch")
|
||||
@login_required
|
||||
def watch_page():
|
||||
r = fetch_services(["tautulli"])
|
||||
return render_template("watch.html", page="watch", tautulli=r["tautulli"])
|
||||
|
||||
|
||||
@app.route("/library")
|
||||
@login_required
|
||||
def library_page():
|
||||
r = fetch_services(["sonarr", "radarr"])
|
||||
return render_template("library.html", page="library",
|
||||
sonarr=r["sonarr"], radarr=r["radarr"],
|
||||
recently_added=get_recently_added(12))
|
||||
|
||||
|
||||
@app.route("/discover")
|
||||
@login_required
|
||||
def discover_page():
|
||||
r = fetch_services(["sonarr", "radarr", "rt_movies", "rt_tv"])
|
||||
sonarr_titles = set(r["sonarr"].get("sonarr_titles", []))
|
||||
radarr_titles = set(r["radarr"].get("radarr_titles", []))
|
||||
return render_template("discover.html", page="discover",
|
||||
rt_movies=_filter_rt(r["rt_movies"], radarr_titles),
|
||||
rt_tv=_filter_rt(r["rt_tv"], sonarr_titles))
|
||||
|
||||
|
||||
@app.route("/requests")
|
||||
@login_required
|
||||
def requests_page():
|
||||
r = fetch_services(["ombi"])
|
||||
return render_template("requests.html", page="requests", ombi=r["ombi"])
|
||||
|
||||
|
||||
@app.route("/system")
|
||||
@login_required
|
||||
def system_page():
|
||||
r = fetch_services(["unraid", "prowlarr", "sonarr", "radarr",
|
||||
"sabnzbd", "tautulli", "ombi"])
|
||||
return render_template("system.html", page="system",
|
||||
unraid=r["unraid"], prowlarr=r["prowlarr"],
|
||||
sonarr=r["sonarr"], radarr=r["radarr"],
|
||||
sabnzbd=r["sabnzbd"], tautulli=r["tautulli"],
|
||||
ombi=r["ombi"])
|
||||
|
||||
|
||||
@app.route("/activity")
|
||||
@login_required
|
||||
def activity_page():
|
||||
r = fetch_services(["sabnzbd", "ombi"])
|
||||
return render_template("activity.html", page="activity",
|
||||
events=get_activity_feed(r["sabnzbd"], r["ombi"]))
|
||||
|
||||
|
||||
@app.route("/api/sabnzbd")
|
||||
@login_required
|
||||
def api_sabnzbd():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue