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:
Barely Removable 2026-08-29 21:00:45 -07:00
parent 9a277b9031
commit 4b19cbdfb2
25 changed files with 3002 additions and 2343 deletions

454
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", "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():