commit c1e30bc8f0bcaeed7ea99493ba1209a1ef85e624 Author: mattie726 Date: Tue Aug 25 22:59:31 2026 -0700 Initial commit: Arr Summary source from server diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..163a54b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,29 @@ +{ + "permissions": { + "allow": [ + "Bash(docker build:*)", + "Bash(docker compose:*)", + "WebFetch(domain:www.imdb.com)", + "Bash(curl:*)", + "Bash(python3:*)", + "Bash(chmod:*)", + "Bash(ssh:*)", + "Bash(brew install:*)", + "Bash(pip3 install:*)", + "WebFetch(domain:community-sitcom.fandom.com)", + "Bash(docker exec:*)", + "Bash(./export-to-unraid.sh:*)", + "Bash(arp:*)", + "Bash(sshpass -p 'BroSoCo427666!' rsync:*)", + "Bash(SSH_AUTH_SOCK=\"\" ssh:*)", + "Bash(ssh-keyscan:*)", + "Bash(~/.ssh/known_hosts)", + "Bash(expect:*)", + "Bash(bash export-to-unraid.sh root@192.168.86.33)", + "Bash(scp:*)", + "Bash(rsync:*)", + "Bash(/Users/matt/Desktop/summary/arr-summary/gunicorn.conf.py:*)", + "Bash(__NEW_LINE_58e4516f9721fb50__ ssh -o StrictHostKeyChecking=no -o IdentitiesOnly=yes root@192.168.86.33 \"cd /mnt/user/appdata/greendale && docker compose build --no-cache 2>&1 | tail -3 && docker compose up -d 2>&1\")" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a04f9e7 --- /dev/null +++ b/.env.example @@ -0,0 +1,56 @@ +# ══════════════════════════════════════════════════════════════════ +# Greendale Media Centre — Environment Variables +# Copy this file to .env and fill in your values. +# ══════════════════════════════════════════════════════════════════ + +# ── Login ───────────────────────────────────────────────────────── +# Password for the dashboard login page +LOGIN_PASSWORD=changeme + +# Secret key for Flask session signing — generate a random string: +# python3 -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=change_this_to_a_random_secret_key + +# ── Host port (what port Unraid exposes) ────────────────────────── +# Default is 5055 — change if that port is already in use +HOST_PORT=5055 + +# ── Media server host ───────────────────────────────────────────── +# LAN IP of the machine running Sonarr, Radarr, etc. +ARR_HOST=192.168.1.100 + +# ── Sonarr ──────────────────────────────────────────────────────── +SONARR_API_KEY=your_sonarr_api_key_here +# Optional overrides (defaults to ARR_HOST:8989): +# SONARR_HOST=192.168.1.100 +# SONARR_URL=http://192.168.1.100:8989 + +# ── Radarr ──────────────────────────────────────────────────────── +RADARR_API_KEY=your_radarr_api_key_here +# Optional overrides (defaults to ARR_HOST:7878): +# RADARR_HOST=192.168.1.100 +# RADARR_URL=http://192.168.1.100:7878 + +# ── SABnzbd ─────────────────────────────────────────────────────── +SABNZBD_API_KEY=your_sabnzbd_api_key_here +# Optional overrides (defaults to ARR_HOST:8080): +# SABNZBD_HOST=192.168.1.100 +# SABNZBD_PORT=8080 + +# ── Tautulli ────────────────────────────────────────────────────── +TAUTULLI_API_KEY=your_tautulli_api_key_here +# Optional overrides (defaults to ARR_HOST:8181): +# TAUTULLI_HOST=192.168.1.100 +# TAUTULLI_PORT=8181 + +# ── Prowlarr ────────────────────────────────────────────────────── +PROWLARR_API_KEY=your_prowlarr_api_key_here +# Optional overrides (defaults to ARR_HOST:9696): +# PROWLARR_HOST=192.168.1.100 +# PROWLARR_PORT=9696 + +# ── Ombi ────────────────────────────────────────────────────────── +OMBI_API_KEY=your_ombi_api_key_here +# Optional overrides (defaults to ARR_HOST:3579): +# OMBI_HOST=192.168.1.100 +# OMBI_PORT=3579 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cff5543 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..75c8978 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +LABEL net.unraid.docker.icon="https://static.wikia.nocookie.net/community-sitcom/images/e/eb/Greendalelogo.png/revision/latest?cb=20120321140817" +LABEL net.unraid.docker.webui="http://[IP]:[PORT:5000]/" +LABEL maintainer="Greendale Media Centre" + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["gunicorn", "--config=gunicorn.conf.py", "app:app"] diff --git a/UNRAID-SETUP.md b/UNRAID-SETUP.md new file mode 100644 index 0000000..cba8086 --- /dev/null +++ b/UNRAID-SETUP.md @@ -0,0 +1,154 @@ +# Greendale Media Centre — Unraid Setup Guide + +## Quick Start + +### 1. Export to Unraid + +```bash +# Make the export script executable +chmod +x export-to-unraid.sh + +# Export (replace with your Unraid IP) +./export-to-unraid.sh root@192.168.86.10 + +# Or with a custom destination path +./export-to-unraid.sh root@192.168.86.10 /mnt/user/appdata/greendale +``` + +### 2. Configure `.env` on Unraid + +SSH into your Unraid server and edit the config: + +```bash +ssh root@192.168.86.10 +nano /mnt/user/appdata/greendale/.env +``` + +**Minimum required fields:** + +```env +LOGIN_PASSWORD=your_chosen_password +SECRET_KEY= +ARR_HOST=192.168.86.33 +SONARR_API_KEY=... +RADARR_API_KEY=... +SABNZBD_API_KEY=... +TAUTULLI_API_KEY=... +PROWLARR_API_KEY=... +OMBI_API_KEY=... +``` + +Generate a secure `SECRET_KEY`: +```bash +python3 -c "import secrets; print(secrets.token_hex(32))" +``` + +### 3. Start the Container + +```bash +cd /mnt/user/appdata/greendale +docker compose up -d +``` + +The dashboard will be available at `http://YOUR_UNRAID_IP:5055` + +--- + +## Nginx Proxy Manager (Recommended for Unraid) + +If you're using **Nginx Proxy Manager** (NPM) on Unraid: + +1. In NPM, go to **Proxy Hosts → Add Proxy Host** +2. Fill in: + - **Domain:** `media.yourdomain.com` + - **Scheme:** `http` + - **Forward Hostname/IP:** your Unraid IP (e.g. `192.168.86.10`) + - **Forward Port:** `5055` + - Enable **"Block Common Exploits"** + - Enable **"Websockets Support"** +3. On the **SSL tab:** request a Let's Encrypt certificate + +That's it. NPM handles the SSL termination and reverse proxy automatically. + +--- + +## Manual Nginx Config + +If you're running Nginx directly (not NPM), use the configs in the `nginx/` folder: + +| File | Use when | +|------|----------| +| `nginx/greendale-subdomain.conf` | You want `https://media.yourdomain.com` | +| `nginx/greendale-subpath.conf` | You want `https://yourdomain.com/media/` | + +Copy the appropriate file and replace the placeholders: +- `YOUR_UNRAID_IP` → your Unraid server's LAN IP +- `yourdomain.com` / `media.yourdomain.com` → your domain +- `5055` → your `HOST_PORT` (if you changed it) + +--- + +## Login Page + +The dashboard is protected by a single-password login page themed to match Greendale. + +- Set `LOGIN_PASSWORD` in `.env` +- Sessions persist for the browser session (close browser to log out) +- Visit `/logout` to manually log out +- If `LOGIN_PASSWORD` is not set, the login is bypassed (dev mode) + +--- + +## Updating + +To push code changes to Unraid after editing locally: + +```bash +# Re-run the export script +./export-to-unraid.sh root@192.168.86.10 + +# On Unraid: +cd /mnt/user/appdata/greendale +docker compose down +docker compose up -d +``` + +--- + +## Unraid Community Applications (Manual Docker Template) + +If you prefer to set up via the Unraid Docker UI instead of `docker compose`: + +| Field | Value | +|-------|-------| +| Name | `greendale-media-centre` | +| Repository | `arr-summary-arr-summary` (after loading image) | +| Port | Host: `5055` → Container: `5000` | +| ENV: `LOGIN_PASSWORD` | your password | +| ENV: `SECRET_KEY` | random 32-char string | +| ENV: `ARR_HOST` | `192.168.86.33` | +| ENV: `SONARR_API_KEY` | your key | +| ENV: `RADARR_API_KEY` | your key | +| ENV: `SABNZBD_API_KEY` | your key | +| ENV: `TAUTULLI_API_KEY` | your key | +| ENV: `PROWLARR_API_KEY` | your key | +| ENV: `OMBI_API_KEY` | your key | +| Restart Policy | `unless-stopped` | + +--- + +## Troubleshooting + +**Container won't start:** +```bash +docker logs arr-summary +``` + +**Can't reach services (all cards show errors):** +- Verify `ARR_HOST` is the correct LAN IP +- Ensure services are running and accessible from Unraid +- Check API keys are correct + +**Login loop / session issues:** +- Make sure `SECRET_KEY` is set and consistent (random restarts shouldn't clear sessions) +- If running behind a proxy, confirm `X-Forwarded-Proto` header is being passed diff --git a/app.py b/app.py new file mode 100644 index 0000000..e9012ce --- /dev/null +++ b/app.py @@ -0,0 +1,1371 @@ +import os +import re +import json +import time +import hashlib +import secrets +import requests +import shutil +import urllib3 +from datetime import datetime, timedelta +from functools import wraps +from concurrent.futures import ThreadPoolExecutor, as_completed +from flask import Flask, render_template, request, jsonify, redirect, url_for, session + +# Suppress SSL warnings for Unraid's self-signed certificate +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +SETTINGS_FILE = os.path.join(os.path.dirname(__file__), "settings.json") + +def load_settings(): + try: + with open(SETTINGS_FILE) as f: + return json.load(f) + except Exception: + return {} + +def save_settings(data): + existing = load_settings() + existing.update(data) + with open(SETTINGS_FILE, "w") as f: + json.dump(existing, f, indent=2) + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) +app.config["SESSION_COOKIE_PATH"] = "/" +app.config["SESSION_COOKIE_HTTPONLY"] = True +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" +app.config["SESSION_COOKIE_SECURE"] = False # ProxyFix handles HTTPS; keep False so cookie is sent over internal HTTP too + +# Support running under a subpath (e.g. /media/) behind a reverse proxy. +# SCRIPT_NAME is injected into every WSGI request so Flask's url_for() and +# redirects all use the correct prefix automatically. +# ProxyFix also reads X-Forwarded-Prefix sent by NPM/nginx. +from werkzeug.middleware.proxy_fix import ProxyFix + +_script_name = os.environ.get("SCRIPT_NAME", "").rstrip("/") + +class ScriptNameMiddleware: + """Inject SCRIPT_NAME into every WSGI environ so Flask knows its prefix.""" + def __init__(self, wsgi_app, script_name): + self.app = wsgi_app + self.script_name = script_name + def __call__(self, environ, start_response): + if self.script_name: + environ["SCRIPT_NAME"] = self.script_name + path = environ.get("PATH_INFO", "") + if path.startswith(self.script_name): + environ["PATH_INFO"] = path[len(self.script_name):] or "/" + return self.app(environ, start_response) + +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) +if _script_name: + app.wsgi_app = ScriptNameMiddleware(app.wsgi_app, _script_name) + +# ── Authentication ────────────────────────────────────────────────────────── +LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "") +_runtime_password = None # overrides env var after change-password call + + +def get_active_password(): + if _runtime_password is not None: + return _runtime_password + # Check settings.json for a persisted password change + saved = load_settings().get("login_password") + if saved: + return saved + return LOGIN_PASSWORD + +def _hash_password(pw): + """SHA-256 hash for constant-time comparison.""" + return hashlib.sha256(pw.encode()).hexdigest() + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("authenticated"): + return redirect(url_for("login", next=request.path)) + return f(*args, **kwargs) + return decorated + +SONARR_HOST = os.environ.get("ARR_HOST", "localhost") +RADARR_HOST = os.environ.get("ARR_HOST", "localhost") +SONARR_URL = os.environ.get("SONARR_URL", f"http://{SONARR_HOST}:8989") +RADARR_URL = os.environ.get("RADARR_URL", f"http://{RADARR_HOST}:7878") +SONARR_API_KEY = os.environ.get("SONARR_API_KEY", "") +RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "") + +SABNZBD_HOST = os.environ.get("SABNZBD_HOST", os.environ.get("ARR_HOST", "localhost")) +SABNZBD_PORT = os.environ.get("SABNZBD_PORT", "8080") +SABNZBD_URL = f"http://{SABNZBD_HOST}:{SABNZBD_PORT}" +SABNZBD_API_KEY = os.environ.get("SABNZBD_API_KEY", "") + +TAUTULLI_HOST = os.environ.get("TAUTULLI_HOST", os.environ.get("ARR_HOST", "localhost")) +TAUTULLI_PORT = os.environ.get("TAUTULLI_PORT", "8181") +TAUTULLI_URL = f"http://{TAUTULLI_HOST}:{TAUTULLI_PORT}/tautulli" +TAUTULLI_API_KEY = os.environ.get("TAUTULLI_API_KEY", "") + +PROWLARR_HOST = os.environ.get("PROWLARR_HOST", os.environ.get("ARR_HOST", "localhost")) +PROWLARR_PORT = os.environ.get("PROWLARR_PORT", "9696") +PROWLARR_URL = f"http://{PROWLARR_HOST}:{PROWLARR_PORT}/prowlarr" +PROWLARR_API_KEY = os.environ.get("PROWLARR_API_KEY", "") + +OMBI_HOST = os.environ.get("OMBI_HOST", os.environ.get("ARR_HOST", "localhost")) +OMBI_PORT = os.environ.get("OMBI_PORT", "3579") +OMBI_URL = f"http://{OMBI_HOST}:{OMBI_PORT}" +OMBI_API_KEY = os.environ.get("OMBI_API_KEY", "") + +RT_HEADERS = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept-Language": "en-US,en;q=0.9", +} + +# RT cache: refresh every 4 hours +_rt_cache = {} +RT_CACHE_TTL = 4 * 3600 # seconds + +# IMDB description cache — persists for container lifetime, keyed by "title|year" +_imdb_desc_cache = {} +# Tracks titles currently being fetched so we don't double-fetch +_imdb_in_flight = set() + +# SABnzbd history cache: refresh every 10 minutes (history doesn't change fast) +_sab_history_cache = {"data": None, "expires": 0} + +# Sonarr / Radarr library cache: refresh every 5 minutes. +# The full movie list (703 items = 4.4 MB) is the single biggest source of +# dashboard latency — caching it cuts Radarr from ~1.3 s to <10 ms on warm loads. +_sonarr_cache = {"data": None, "expires": 0} +_radarr_cache = {"data": None, "expires": 0} +ARR_CACHE_TTL = 5 * 60 # 5 minutes + +# Tautulli stats cache: top shows/movies/users/history refresh every 2 minutes. +# Active streams (get_activity) are always fetched live. +_tautulli_stats_cache = {"data": None, "expires": 0} +TAUTULLI_CACHE_TTL = 2 * 60 # 2 minutes + + +def _fetch_imdb_description(title, year=""): + """Fetch a single IMDB description. Returns "" on any failure.""" + cache_key = f"{title}|{year}" + if cache_key in _imdb_desc_cache: + return _imdb_desc_cache[cache_key] + try: + query = requests.utils.quote(f"{title} {year}".strip()) + search_url = f"https://www.imdb.com/find/?q={query}&type=tt&s=tt" + resp = requests.get(search_url, headers=RT_HEADERS, timeout=8) + resp.raise_for_status() + match = re.search(r'href="(/title/tt\d+/)[^"]*"', resp.text) + if not match: + _imdb_desc_cache[cache_key] = "" + return "" + title_resp = requests.get(f"https://www.imdb.com{match.group(1)}", headers=RT_HEADERS, timeout=8) + title_resp.raise_for_status() + jld = re.search(r'', title_resp.text, re.DOTALL) + desc = json.loads(jld.group(1)).get("description", "") if jld else "" + _imdb_desc_cache[cache_key] = desc + return desc + except Exception: + _imdb_desc_cache[cache_key] = "" + return "" + + +def _enrich_rt_descriptions_bg(items): + """Background enrichment: fetch IMDB descriptions for items that don't have one yet. + Uses 10 parallel workers. Safe to call from a daemon thread.""" + to_fetch = [ + item for item in items + if not item.get("error") + and not item.get("description") + and f"{item.get('title','')}|{item.get('year','')}" not in _imdb_desc_cache + and f"{item.get('title','')}|{item.get('year','')}" not in _imdb_in_flight + ] + if not to_fetch: + return + keys = {f"{i.get('title','')}|{i.get('year','')}": i for i in to_fetch} + _imdb_in_flight.update(keys.keys()) + try: + def _fetch(item): + desc = _fetch_imdb_description(item.get("title", ""), item.get("year", "")) + item["description"] = desc + with ThreadPoolExecutor(max_workers=10) as ex: + list(ex.map(_fetch, to_fetch)) + finally: + _imdb_in_flight.difference_update(keys.keys()) + + +# Ombi cache: refresh every 4 hours +_ombi_cache = {"data": None, "expires": 0} + + +def _rt_cached(key, fetch_fn): + """Return cached RT data, re-fetching if older than 4 hours.""" + import time + now = time.time() + entry = _rt_cache.get(key) + if entry and entry.get("expires", 0) > now: + return entry["data"] + data = fetch_fn() + _rt_cache[key] = {"data": data, "expires": now + RT_CACHE_TTL} + return data + + +# --------------------------------------------------------------------------- +# Sonarr +# --------------------------------------------------------------------------- + +def sonarr_get(endpoint): + headers = {"X-Api-Key": SONARR_API_KEY} + resp = requests.get(f"{SONARR_URL}/api/v3/{endpoint}", headers=headers, timeout=4) + resp.raise_for_status() + return resp.json() + + +def sonarr_post(endpoint, payload): + headers = {"X-Api-Key": SONARR_API_KEY, "Content-Type": "application/json"} + resp = requests.post(f"{SONARR_URL}/api/v3/{endpoint}", headers=headers, json=payload, timeout=4) + resp.raise_for_status() + return resp.json() + + +def get_sonarr_summary(): + global _sonarr_cache + 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} + wanted = sonarr_get("wanted/missing?pageSize=1") + total_series = len(series) + monitored = sum(1 for s in series if s.get("monitored")) + missing_episodes = wanted.get("totalRecords", 0) + sonarr_titles = {s.get("title", "").lower() for s in series} + return { + "status": "ok", + "total_series": total_series, + "monitored_series": monitored, + "missing_episodes": missing_episodes, + "sonarr_titles": list(sonarr_titles), + } + except Exception as e: + return {"status": "error", "error": str(e), "sonarr_titles": []} + + +# --------------------------------------------------------------------------- +# Radarr +# --------------------------------------------------------------------------- + +def radarr_get(endpoint): + headers = {"X-Api-Key": RADARR_API_KEY} + resp = requests.get(f"{RADARR_URL}/api/v3/{endpoint}", headers=headers, timeout=4) + resp.raise_for_status() + return resp.json() + + +def radarr_post(endpoint, payload): + headers = {"X-Api-Key": RADARR_API_KEY, "Content-Type": "application/json"} + resp = requests.post(f"{RADARR_URL}/api/v3/{endpoint}", headers=headers, json=payload, timeout=4) + resp.raise_for_status() + return resp.json() + + +def get_radarr_summary(): + global _radarr_cache + 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} + 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")) + missing = total_movies - downloaded + radarr_titles = {m.get("title", "").lower() for m in movies} + return { + "status": "ok", + "total_movies": total_movies, + "monitored_movies": monitored, + "downloaded_movies": downloaded, + "missing_movies": missing, + "radarr_titles": list(radarr_titles), + } + except Exception as e: + return {"status": "error", "error": str(e), "radarr_titles": []} + + +# --------------------------------------------------------------------------- +# SABnzbd +# --------------------------------------------------------------------------- + +def sabnzbd_get(mode, extra_params=""): + url = f"{SABNZBD_URL}/api?output=json&apikey={SABNZBD_API_KEY}&mode={mode}{extra_params}" + resp = requests.get(url, timeout=4) + resp.raise_for_status() + return resp.json() + + +def _format_speed(kbps): + """Format KB/s into a human-readable string.""" + if kbps is None: + return "0 B/s" + if kbps >= 1024 * 1024: + return f"{kbps / (1024 * 1024):.1f} GB/s" + if kbps >= 1024: + return f"{kbps / 1024:.1f} MB/s" + return f"{kbps:.0f} KB/s" + + +def _format_size(mb): + """Format MB into a human-readable string.""" + if mb is None: + return "0 MB" + if mb >= 1024: + return f"{mb / 1024:.1f} GB" + return f"{mb:.0f} MB" + + +def _get_sab_history_cached(): + """Fetch SABnzbd history with a 10-minute cache — avoids slow 1000-item fetch on every page load.""" + global _sab_history_cache + now = time.time() + if _sab_history_cache["data"] is not None and now < _sab_history_cache["expires"]: + return _sab_history_cache["data"] + try: + history = sabnzbd_get("history", "&limit=1000") + h = history.get("history", {}) + slots = h.get("slots", []) + week_size = h.get("week_size", "0 B") + day_size = h.get("day_size", "0 B") + seven_days_ago = now - (7 * 24 * 3600) + recent = [s for s in slots if s.get("completed", 0) >= seven_days_ago and s.get("status") != "Failed"] + completed_7d = len(recent) + speeds = [] + for s in recent: + duration = s.get("download_time", 0) or 0 + size_bytes = s.get("bytes", 0) or 0 + if duration > 0 and size_bytes > 0: + 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) + result = {"week_size": week_size, "day_size": day_size, + "completed_7d": completed_7d, "avg_speed": avg_speed} + _sab_history_cache = {"data": result, "expires": now + 600} + return result + except Exception: + return {"week_size": "—", "day_size": "—", "completed_7d": "—", "avg_speed": "—"} + + +def get_sabnzbd_summary(): + try: + # Queue only — fast, no history fetch here + queue = sabnzbd_get("queue") + q = queue.get("queue", {}) + + speed_str = str(q.get("speed", "0")).strip() + speed_kbps = float(q.get("kbpersec", 0) or 0) + if speed_kbps > 0 and speed_str and speed_str != "0": + display_speed = speed_str + "/s" if not speed_str.endswith("/s") else speed_str + else: + display_speed = "0 B/s" + + status = q.get("status", "Unknown") + queue_count = int(q.get("noofslots_total", q.get("noofslots", 0))) + sizeleft = q.get("sizeleft", "0 B") + timeleft = q.get("timeleft", "0:00:00") + + hist = _get_sab_history_cached() + return { + "status": "ok", + "speed": display_speed, + "speed_kbps": speed_kbps, + "sab_status": status, + "queue_count": queue_count, + "sizeleft": sizeleft, + "timeleft": timeleft, + "completed_7d": hist["completed_7d"], + "week_size": hist["week_size"], + "day_size": hist["day_size"], + "avg_speed": hist["avg_speed"], + } + except Exception as e: + return {"status": "error", "error": str(e)} + + +# --------------------------------------------------------------------------- +# Tautulli +# --------------------------------------------------------------------------- + +def tautulli_get(cmd, extra=""): + url = f"{TAUTULLI_URL}/api/v2?apikey={TAUTULLI_API_KEY}&cmd={cmd}{extra}" + resp = requests.get(url, timeout=4) + resp.raise_for_status() + return resp.json().get("response", {}).get("data", {}) + + +def get_tautulli_summary(): + global _tautulli_stats_cache + try: + now = time.time() + # Stats (top shows/movies/users/history) are cached for 2 minutes + if _tautulli_stats_cache["data"] is not None and now < _tautulli_stats_cache["expires"]: + cached = _tautulli_stats_cache["data"] + top_tv_list = cached["top_tv_list"] + top_movie_list = cached["top_movie_list"] + top_played = cached["top_played"] + top_users = cached["top_users"] + else: + # Top 3 most watched TV shows (30d) + tv_data = tautulli_get("get_home_stats", "&stat_id=top_tv&stats_count=3&stats_type=plays&time_range=30") + tv_rows = tv_data.get("rows", []) if isinstance(tv_data, dict) else [] + top_tv_list = [{"title": r.get("title", "Unknown"), "plays": r.get("total_plays", 0)} for r in tv_rows] + + # Top 3 most watched movies (30d) + movie_data = tautulli_get("get_home_stats", "&stat_id=top_movies&stats_count=3&stats_type=plays&time_range=30") + movie_rows = movie_data.get("rows", []) if isinstance(movie_data, dict) else [] + top_movie_list = [{"title": r.get("title", "Unknown"), "plays": r.get("total_plays", 0)} for r in movie_rows] + + # Top played per user from recent history + history_data = tautulli_get("get_history", "&length=100&order_column=date&order_dir=desc") + history_slots = [] + if isinstance(history_data, dict): + history_slots = history_data.get("data", []) + if isinstance(history_slots, dict): + history_slots = history_slots.get("data", []) + play_counts = {} + for entry in history_slots: + user = entry.get("friendly_name") or entry.get("user", "Unknown") + if user.lower() == "ninja_hippo": + continue + title = entry.get("full_title") or entry.get("title", "Unknown") + media_type = entry.get("media_type", "") + key = (title, user) + if key not in play_counts: + play_counts[key] = {"title": title, "user": user, "plays": 0, "media_type": media_type} + play_counts[key]["plays"] += 1 + top_played = sorted(play_counts.values(), key=lambda x: x["plays"], reverse=True)[:3] + + # Top 3 viewers (30d) excluding ninja_hippo + users_data = tautulli_get("get_home_stats", "&stat_id=top_users&stats_count=10&stats_type=plays&time_range=30") + users_rows = users_data.get("rows", []) if isinstance(users_data, dict) else [] + top_users = [] + for r in users_rows: + username = r.get("friendly_name") or r.get("user", "") + if username.lower() == "ninja_hippo": + continue + top_users.append({"username": username, "plays": r.get("total_plays", 0), "thumb": r.get("user_thumb", "")}) + if len(top_users) >= 3: + break + + _tautulli_stats_cache = { + "data": {"top_tv_list": top_tv_list, "top_movie_list": top_movie_list, + "top_played": top_played, "top_users": top_users}, + "expires": now + TAUTULLI_CACHE_TTL, + } + + # Active streams always fetched live (changes second-to-second) + activity = tautulli_get("get_activity") + stream_count = int(activity.get("stream_count", 0) or 0) if isinstance(activity, dict) else 0 + raw_sessions = activity.get("sessions", []) if isinstance(activity, dict) else [] + sessions = [] + for s in raw_sessions: + 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", ""), + }) + + top_tv = top_tv_list[0] if top_tv_list else None + top_movie = top_movie_list[0] if top_movie_list else None + + return { + "status": "ok", + "top_tv": top_tv, + "top_tv_list": top_tv_list, + "top_movie": top_movie, + "top_movie_list": top_movie_list, + "top_played": top_played, + "top_users": top_users, + "active_streams": stream_count, + "sessions": sessions, + } + except Exception as e: + return {"status": "error", "error": str(e)} + + +# --------------------------------------------------------------------------- +# Ombi +# --------------------------------------------------------------------------- + +def ombi_get(endpoint): + headers = {"ApiKey": OMBI_API_KEY} + resp = requests.get(f"{OMBI_URL}/api/v1/{endpoint}", headers=headers, timeout=4) + resp.raise_for_status() + return resp.json() + + +def _ombi_status_label(available, approved, denied): + if denied: + return "Denied" + if available: + return "Available" + if approved: + return "Approved" + return "Pending" + + +def get_ombi_summary(): + global _ombi_cache + now = time.time() + if _ombi_cache["data"] is not None and now < _ombi_cache["expires"]: + return _ombi_cache["data"] + try: + movies_raw = ombi_get("Request/movie") + tv_raw = ombi_get("Request/tv") + + # Flatten all requests with common fields + all_requests = [] + + for m in movies_raw: + user = m.get("requestedUser", {}) + alias = (user.get("alias") or user.get("userAlias") or user.get("userName") or "Unknown").split("@")[0] + if alias.lower() == "ninja_hippo": + continue + all_requests.append({ + "title": m.get("title", "Unknown"), + "type": "Movie", + "requested_date": m.get("requestedDate", ""), + "user": alias, + "status": _ombi_status_label(m.get("available"), m.get("approved"), m.get("denied")), + "poster": m.get("posterPath", ""), + }) + + for show in tv_raw: + for child in show.get("childRequests", []): + user = child.get("requestedUser", {}) + alias = (user.get("alias") or user.get("userAlias") or user.get("userName") or "Unknown").split("@")[0] + if alias.lower() == "ninja_hippo": + continue + # Determine child status from season approvals + approved = child.get("approved", False) + available = any( + ep.get("available", False) + for season in child.get("seasonRequests", []) + for ep in season.get("episodes", []) + ) + denied = child.get("denied", False) + all_requests.append({ + "title": show.get("title", "Unknown"), + "type": "TV", + "requested_date": child.get("requestedDate", ""), + "user": alias, + "status": _ombi_status_label(available, approved, denied), + "poster": show.get("posterPath", ""), + }) + + # Sort by requested date descending, take 5 most recent + all_requests.sort(key=lambda x: x["requested_date"], reverse=True) + recent = all_requests[:10] + + # Tidy date display + for r in recent: + d = r["requested_date"] + r["date_display"] = d[:10] if d else "—" + + total_pending = sum(1 for r in all_requests if r["status"] == "Pending") + total_requests = len(all_requests) + + result = { + "status": "ok", + "recent": recent, + "total_requests": total_requests, + "total_pending": total_pending, + } + _ombi_cache["data"] = result + _ombi_cache["expires"] = now + (4 * 3600) + return result + except Exception as e: + return {"status": "error", "error": str(e)} + + +# --------------------------------------------------------------------------- +# Prowlarr +# --------------------------------------------------------------------------- + +def prowlarr_get(endpoint): + headers = {"X-Api-Key": PROWLARR_API_KEY} + resp = requests.get(f"{PROWLARR_URL}/api/v1/{endpoint}", headers=headers, timeout=4) + resp.raise_for_status() + return resp.json() + + +def get_prowlarr_summary(): + try: + indexers = prowlarr_get("indexer") + # indexerstatus returns only blocked/failed indexers + try: + failed_raw = prowlarr_get("indexerstatus") + failed_ids = {s.get("indexerId") for s in failed_raw} if isinstance(failed_raw, list) else set() + except Exception: + failed_ids = set() + + result = [] + for idx in sorted(indexers, key=lambda x: x.get("name", "").lower()): + enabled = idx.get("enable", True) + blocked = idx.get("id") in failed_ids + up = enabled and not blocked + result.append({ + "id": idx.get("id"), + "name": idx.get("name", "Unknown"), + "protocol": idx.get("protocol", ""), + "enabled": enabled, + "up": up, + }) + + total = len(result) + up_count = sum(1 for i in result if i["up"]) + return { + "status": "ok", + "indexers": result, + "total": total, + "up_count": up_count, + } + except Exception as e: + return {"status": "error", "error": str(e), "indexers": []} + + +# --------------------------------------------------------------------------- +# Unraid system stats +# --------------------------------------------------------------------------- + +UNRAID_HOST = os.environ.get("UNRAID_HOST", os.environ.get("ARR_HOST", "")) +UNRAID_API_KEY = os.environ.get("UNRAID_API_KEY", "") + + +def get_unraid_stats(): + """Fetch Unraid disk/system stats via GraphQL API, falling back to shutil.""" + + def fmt_bytes(b): + b = int(b or 0) + if b >= 1024**4: return f"{b/1024**4:.1f} TB" + if b >= 1024**3: return f"{b/1024**3:.1f} GB" + if b >= 1024**2: return f"{b/1024**2:.1f} MB" + return f"{b/1024:.0f} KB" + + if UNRAID_HOST and UNRAID_API_KEY: + # HTTPS on 443 with SSL verification disabled (Unraid uses a self-signed cert) + url = f"https://{UNRAID_HOST}:443/graphql" + headers = {"x-api-key": UNRAID_API_KEY, "Content-Type": "application/json"} + query = """{ + vars { version } + array { state disks { fsSize fsFree type } } + }""" + try: + resp = requests.post(url, json={"query": query}, headers=headers, + timeout=4, verify=False) + if resp.ok: + d = resp.json().get("data", {}) + if d: + arr = d.get("array", {}) + disks = arr.get("disks", []) + data_disks = [dk for dk in disks if dk.get("type") == "DATA"] + total_bytes = sum(int(dk.get("fsSize", 0) or 0) for dk in data_disks) + free_bytes = sum(int(dk.get("fsFree", 0) or 0) for dk in data_disks) + used_bytes = total_bytes - free_bytes + used_pct = round(used_bytes / total_bytes * 100) if total_bytes else 0 + return { + "status": "ok", + "source": "api", + "array_state": arr.get("state", ""), + "os_version": d.get("vars", {}).get("version", ""), + "disk_count": len(data_disks), + "array_total": fmt_bytes(total_bytes), + "array_used": fmt_bytes(used_bytes), + "array_free": fmt_bytes(free_bytes), + "array_used_pct": used_pct, + } + except Exception: + pass + + # Fallback: container-visible disk usage + try: + mount_paths = ["/mnt/user", "/data", "/"] + chosen = next((p for p in mount_paths if os.path.exists(p)), "/") + usage = shutil.disk_usage(chosen) + used_pct = round(usage.used / usage.total * 100) if usage.total else 0 + return { + "status": "ok", + "source": "local", + "mount": chosen, + "array_total": fmt_bytes(usage.total), + "array_used": fmt_bytes(usage.used), + "array_free": fmt_bytes(usage.free), + "array_used_pct": used_pct, + } + except Exception as e: + return {"status": "error", "error": str(e)} + + +# --------------------------------------------------------------------------- +# Rotten Tomatoes scraping +# --------------------------------------------------------------------------- + +def _scrape_rt_jsonld(html): + match = re.search(r'', html, re.DOTALL) + if not match: + return [] + try: + data = json.loads(match.group(1)) + inner = data.get("itemListElement", {}) + if isinstance(inner, dict): + return inner.get("itemListElement", []) + if isinstance(inner, list): + return inner + except Exception: + pass + return [] + + +def _scrape_rt_tiles(html): + tiles = re.findall(r']*>(.*?)', html, re.DOTALL) + results = [] + for tile in tiles: + audience_match = re.search(r'slot="audienceScore"[^>]*>\s*([^<\s][^<]*?)\s*<', tile) + audience_score = audience_match.group(1).strip() if audience_match else "" + certified_match = re.search(r'certified="(true|false)"', tile) + certified = certified_match.group(1) == "true" if certified_match else False + results.append({"audience_score": audience_score, "certified": certified}) + return results + + +def _fetch_rt_movies(): + try: + resp = requests.get( + "https://www.rottentomatoes.com/browse/movies_in_theaters/sort:top_box_office", + headers=RT_HEADERS, timeout=4 + ) + resp.raise_for_status() + html = resp.text + items = _scrape_rt_jsonld(html)[:30] + tiles = _scrape_rt_tiles(html) + movies = [] + for i, item in enumerate(items): + tile = tiles[i] if i < len(tiles) else {} + rating = item.get("aggregateRating") + critics_score = str(rating.get("ratingValue", "")) if rating else "" + try: + if critics_score and int(critics_score) < 50: + continue + except ValueError: + pass + movies.append({ + "title": item.get("name", "Unknown"), + "year": str(item.get("dateCreated", ""))[:4], + "poster": item.get("image", ""), + "rt_url": item.get("url", ""), + "critics_score": critics_score, + "audience_score": tile.get("audience_score", ""), + "certified": tile.get("certified", False), + "description": "", + }) + return movies + except Exception as e: + return [{"error": str(e)}] + + +def _fetch_rt_tv(): + try: + resp = requests.get( + "https://www.rottentomatoes.com/browse/tv_series_browse/sort:popular", + headers=RT_HEADERS, timeout=4 + ) + resp.raise_for_status() + html = resp.text + items = _scrape_rt_jsonld(html)[:30] + tiles = _scrape_rt_tiles(html) + shows = [] + for i, item in enumerate(items): + tile = tiles[i] if i < len(tiles) else {} + rating = item.get("aggregateRating") + critics_score = str(rating.get("ratingValue", "")) if rating else "" + try: + if critics_score and int(critics_score) < 50: + continue + except ValueError: + pass + shows.append({ + "title": item.get("name", "Unknown"), + "year": str(item.get("dateCreated", ""))[:4], + "poster": item.get("image", ""), + "rt_url": item.get("url", ""), + "critics_score": critics_score, + "audience_score": tile.get("audience_score", ""), + "certified": tile.get("certified", False), + "description": "", + }) + return shows + except Exception as e: + return [{"error": str(e)}] + + +def get_rt_movies(): + data = _rt_cached("rt_movies", _fetch_rt_movies) + # Kick off background IMDB enrichment (non-blocking) + import threading + threading.Thread(target=_enrich_rt_descriptions_bg, args=(data,), daemon=True).start() + return data + + +def get_rt_tv(): + data = _rt_cached("rt_tv", _fetch_rt_tv) + import threading + threading.Thread(target=_enrich_rt_descriptions_bg, args=(data,), daemon=True).start() + return data + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@app.route("/login", methods=["GET", "POST"]) +def login(): + error = None + if request.method == "POST": + entered = request.form.get("password", "") + if LOGIN_PASSWORD and _hash_password(entered) == _hash_password(get_active_password()): + session["authenticated"] = True + 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")) + error = "Incorrect password. Try again." + # If no password is set, auto-login (dev mode) + if not get_active_password(): + session["authenticated"] = True + return redirect(url_for("dashboard")) + return render_template("login.html", error=error) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +@app.route("/api/unraid") +@login_required +def api_unraid(): + return jsonify(get_unraid_stats()) + + +@app.route("/api/rt/descriptions") +@login_required +def api_rt_descriptions(): + """Return cached IMDB descriptions for all RT items. Called by the page after load. + Also triggers enrichment in this worker if the cache is cold (handles multi-worker case).""" + import threading + movies = _rt_cache.get("rt_movies", {}).get("data", []) + tv = _rt_cache.get("rt_tv", {}).get("data", []) + # If this worker hasn't loaded RT data yet, trigger it and tell client to retry + if not movies and not tv: + threading.Thread(target=get_rt_movies, daemon=True).start() + threading.Thread(target=get_rt_tv, daemon=True).start() + return jsonify({"descriptions": {}, "pending": True}) + all_items = [i for i in movies + tv if not i.get("error")] + # If this worker has no descriptions yet, kick off enrichment now + needs_fetch = [ + i for i in all_items + if f"{i.get('title','')}|{i.get('year','')}" not in _imdb_desc_cache + and f"{i.get('title','')}|{i.get('year','')}" not in _imdb_in_flight + ] + if needs_fetch: + threading.Thread(target=_enrich_rt_descriptions_bg, args=(all_items,), daemon=True).start() + result = {} + for item in all_items: + key = f"{item.get('title','')}|{item.get('year','')}" + desc = _imdb_desc_cache.get(key, None) + if desc is not None: + result[item.get("title", "")] = desc + still_pending = bool(needs_fetch) or any( + _imdb_desc_cache.get(f"{i.get('title','')}|{i.get('year','')}", None) is None + for i in all_items + ) + 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, + } + 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 + for name in tasks: + if name not in results: + results[name] = {"status": "error", "error": "timeout"} + + 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") + theme = s.get("theme", "community") + # Public-facing URLs for card header links (via NPM reverse proxy) + arr_host_public = f"https://hippofam.com" + service_urls = { + "sonarr": f"{arr_host_public}/sonarr", + "radarr": f"{arr_host_public}/radarr", + "sabnzbd": f"{arr_host_public}/sab", + "tautulli": f"{arr_host_public}/tautulli", + "prowlarr": f"{arr_host_public}/prowlarr", + "ombi": f"{arr_host_public}/ombi", + "unraid": f"https://{UNRAID_HOST}", + } + 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, + theme=theme, + service_urls=service_urls, + ) + + +@app.route("/api/sabnzbd") +@login_required +def api_sabnzbd(): + """Lightweight real-time endpoint — queue only (no history re-fetch).""" + try: + queue = sabnzbd_get("queue") + q = queue.get("queue", {}) + speed_str = str(q.get("speed", "0")).strip() + speed_kbps = float(q.get("kbpersec", 0) or 0) + if speed_kbps > 0 and speed_str and speed_str != "0": + display_speed = speed_str + "/s" if not speed_str.endswith("/s") else speed_str + else: + display_speed = "0 B/s" + status = q.get("status", "Unknown") + queue_count = int(q.get("noofslots_total", q.get("noofslots", 0))) + sizeleft = q.get("sizeleft", "0 B") + timeleft = q.get("timeleft", "0:00:00") + resp = jsonify({ + "status": "ok", + "speed": display_speed, + "speed_kbps": speed_kbps, + "sab_status": status, + "queue_count": queue_count, + "sizeleft": sizeleft, + "timeleft": timeleft, + }) + resp.headers["Cache-Control"] = "no-store" + return resp + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 500 + + +@app.route("/api/prowlarr") +@login_required +def api_prowlarr(): + """Real-time Prowlarr indexer status.""" + return jsonify(get_prowlarr_summary()) + + +@app.route("/api/tautulli") +@login_required +def api_tautulli(): + """Real-time Tautulli active streams.""" + try: + activity = tautulli_get("get_activity") + stream_count = int(activity.get("stream_count", 0) or 0) if isinstance(activity, dict) else 0 + raw_sessions = activity.get("sessions", []) if isinstance(activity, dict) else [] + sessions = [] + for s in raw_sessions: + 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", ""), + }) + return jsonify({"status": "ok", "stream_count": stream_count, "sessions": sessions}) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 500 + + +@app.route("/api/ombi") +@login_required +def api_ombi(): + """Return cached Ombi data.""" + return jsonify(get_ombi_summary()) + + +@app.route("/api/services") +@login_required +def api_services(): + """Header status lights — lightweight reachability check for all services.""" + def check(fn): + try: + r = fn() + return r.get("status") == "ok" + except Exception: + return False + + # Quick queue-only checks for speed + def sonarr_ok(): + sonarr_get("series?pageSize=1") + return True + def radarr_ok(): + radarr_get("movie?pageSize=1") + return True + def sabnzbd_ok(): + sabnzbd_get("queue") + return True + def tautulli_ok(): + tautulli_get("get_activity") + return True + def prowlarr_ok(): + prowlarr_get("health") + return True + def ombi_ok(): + ombi_get("Settings/about") + return True + + results = {} + for name, fn in [("sonarr", sonarr_ok), ("radarr", radarr_ok), + ("sabnzbd", sabnzbd_ok), ("tautulli", tautulli_ok), + ("prowlarr", prowlarr_ok), ("ombi", ombi_ok)]: + try: + fn() + results[name] = True + except Exception: + results[name] = False + + return jsonify(results) + + +@app.route("/api/tautulli/never-watched") +@login_required +def api_never_watched(): + """Movies and shows in Radarr/Sonarr that have never been played in Tautulli.""" + try: + # Get all history titles from Tautulli (last 500 entries) + hist = tautulli_get("get_history", "&length=500&order_column=date&order_dir=desc") + played_titles = set() + if isinstance(hist, dict): + slots = hist.get("data", []) + if isinstance(slots, dict): + slots = slots.get("data", []) + for s in slots: + t = s.get("full_title") or s.get("title") or s.get("parent_title", "") + if t: + played_titles.add(t.lower()) + + never = [] + + # Sonarr series with episodes on disk, never played + try: + series_list = sonarr_get("series") + for s in series_list: + size = s.get("sizeOnDisk", 0) + if size == 0: + continue + title = s.get("title", "") + if title.lower() not in played_titles: + never.append({ + "type": "show", + "title": title, + "year": s.get("year", ""), + "size": size, + "sonarr_id": s.get("id"), + }) + except Exception: + pass + + # Sort by size descending + never.sort(key=lambda x: x["size"], reverse=True) + + # Format size + def fmt(b): + gb = b / (1024**3) + if gb >= 1: + return f"{gb:.1f} GB" + return f"{b / (1024**2):.0f} MB" + + for item in never: + item["size_display"] = fmt(item["size"]) + + return jsonify({"status": "ok", "items": never[:30]}) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 500 + + +@app.route("/api/delete/movie", methods=["POST"]) +@login_required +def api_delete_movie(): + data = request.get_json() + radarr_id = data.get("radarr_id") + if not radarr_id: + return jsonify({"error": "No radarr_id"}), 400 + try: + headers = {"X-Api-Key": RADARR_API_KEY} + resp = requests.delete( + f"{RADARR_URL}/api/v3/movie/{radarr_id}?deleteFiles=true", + headers=headers, timeout=15 + ) + resp.raise_for_status() + return jsonify({"success": True}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/delete/show", methods=["POST"]) +@login_required +def api_delete_show(): + data = request.get_json() + sonarr_id = data.get("sonarr_id") + if not sonarr_id: + return jsonify({"error": "No sonarr_id"}), 400 + try: + headers = {"X-Api-Key": SONARR_API_KEY} + resp = requests.delete( + f"{SONARR_URL}/api/v3/series/{sonarr_id}?deleteFiles=true", + headers=headers, timeout=15 + ) + resp.raise_for_status() + return jsonify({"success": True}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/settings", methods=["GET"]) +@login_required +def settings_page(): + s = load_settings() + return render_template("settings.html", + timezone=s.get("timezone", "America/New_York"), + sonarr_api_key=SONARR_API_KEY, + radarr_api_key=RADARR_API_KEY, + sabnzbd_api_key=SABNZBD_API_KEY, + tautulli_api_key=TAUTULLI_API_KEY, + prowlarr_api_key=PROWLARR_API_KEY, + ombi_api_key=OMBI_API_KEY, + arr_host=os.environ.get("ARR_HOST", ""), + theme=s.get("theme", "community"), + ) + + +@app.route("/api/settings", methods=["POST"]) +@login_required +def api_save_settings(): + data = request.get_json() + allowed = {"timezone", "sonarr_api_key", "radarr_api_key", "sabnzbd_api_key", + "tautulli_api_key", "prowlarr_api_key", "ombi_api_key", "arr_host", "theme"} + filtered = {k: v for k, v in data.items() if k in allowed} + save_settings(filtered) + return jsonify({"success": True}) + + +@app.route("/api/settings", methods=["GET"]) +@login_required +def api_get_settings(): + s = load_settings() + return jsonify({ + "timezone": s.get("timezone", "America/New_York"), + "theme": s.get("theme", "community"), + }) + + +@app.route("/api/change-password", methods=["POST"]) +@login_required +def api_change_password(): + global _runtime_password + data = request.get_json() + current = data.get("current", "") + new_pw = data.get("new", "") + confirm = data.get("confirm", "") + if not current or not new_pw or not confirm: + return jsonify({"error": "All fields are required"}), 400 + if _hash_password(current) != _hash_password(get_active_password()): + return jsonify({"error": "Current password is incorrect"}), 403 + if new_pw != confirm: + return jsonify({"error": "New passwords do not match"}), 400 + if len(new_pw) < 6: + return jsonify({"error": "Password must be at least 6 characters"}), 400 + # Persist to settings.json and update runtime variable + save_settings({"login_password": new_pw}) + _runtime_password = new_pw + return jsonify({"success": True}) + + +@app.route("/api/sonarr/lookup") +@login_required +def api_sonarr_lookup(): + q = request.args.get("q", "").strip() + if not q: + return jsonify({"error": "No query provided"}), 400 + try: + results = sonarr_get(f"series/lookup?term={requests.utils.quote(q)}") + if not results: + return jsonify({}) + s = results[0] + title = s.get("title", "") + sonarr_titles = {x.get("title", "").lower() for x in sonarr_get("series")} + return jsonify({ + "title": title, + "year": s.get("year", ""), + "network": s.get("network", ""), + "status": s.get("status", ""), + "exists": title.lower() in sonarr_titles, + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/radarr/lookup") +@login_required +def api_radarr_lookup(): + q = request.args.get("q", "").strip() + if not q: + return jsonify({"error": "No query provided"}), 400 + try: + results = radarr_get(f"movie/lookup?term={requests.utils.quote(q)}") + if not results: + return jsonify({}) + m = results[0] + title = m.get("title", "") + radarr_titles = {x.get("title", "").lower() for x in radarr_get("movie")} + return jsonify({ + "title": title, + "year": m.get("year", ""), + "studio": m.get("studio", ""), + "runtime": m.get("runtime", ""), + "exists": title.lower() in radarr_titles, + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/add-to-radarr", methods=["POST"]) +@login_required +def add_to_radarr(): + data = request.get_json() + title = data.get("title") + if not title: + return jsonify({"error": "No title provided"}), 400 + try: + results = radarr_get(f"movie/lookup?term={requests.utils.quote(title)}") + if not results: + return jsonify({"error": "Movie not found in Radarr lookup"}), 404 + movie = results[0] + root_folders = radarr_get("rootfolder") + quality_profiles = radarr_get("qualityprofile") + if not root_folders or not quality_profiles: + return jsonify({"error": "No root folders or quality profiles configured in Radarr"}), 500 + payload = { + "title": movie.get("title"), + "qualityProfileId": quality_profiles[0]["id"], + "titleSlug": movie.get("titleSlug"), + "images": movie.get("images", []), + "tmdbId": movie.get("tmdbId"), + "year": movie.get("year"), + "rootFolderPath": root_folders[0]["path"], + "monitored": True, + "addOptions": {"searchForMovie": True}, + } + result = radarr_post("movie", payload) + return jsonify({"success": True, "title": result.get("title")}) + except requests.HTTPError as e: + if e.response is not None and e.response.status_code == 400: + return jsonify({"error": "Movie may already exist in Radarr"}), 409 + return jsonify({"error": str(e)}), 500 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/add-to-sonarr", methods=["POST"]) +@login_required +def add_to_sonarr(): + data = request.get_json() + title = data.get("title") + if not title: + return jsonify({"error": "No title provided"}), 400 + try: + results = sonarr_get(f"series/lookup?term={requests.utils.quote(title)}") + if not results: + return jsonify({"error": "Series not found in Sonarr lookup"}), 404 + series = results[0] + root_folders = sonarr_get("rootfolder") + quality_profiles = sonarr_get("qualityprofile") + if not root_folders or not quality_profiles: + return jsonify({"error": "No root folders or quality profiles configured in Sonarr"}), 500 + payload = { + "title": series.get("title"), + "qualityProfileId": quality_profiles[0]["id"], + "titleSlug": series.get("titleSlug"), + "images": series.get("images", []), + "tvdbId": series.get("tvdbId"), + "year": series.get("year"), + "rootFolderPath": root_folders[0]["path"], + "monitored": True, + "seasonFolder": True, + "addOptions": { + "searchForMissingEpisodes": True, + "monitor": "all", + }, + } + result = sonarr_post("series", payload) + return jsonify({"success": True, "title": result.get("title")}) + except requests.HTTPError as e: + if e.response is not None and e.response.status_code == 400: + return jsonify({"error": "Series may already exist in Sonarr"}), 409 + return jsonify({"error": str(e)}), 500 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/arr-summary/.env.example b/arr-summary/.env.example new file mode 100644 index 0000000..a04f9e7 --- /dev/null +++ b/arr-summary/.env.example @@ -0,0 +1,56 @@ +# ══════════════════════════════════════════════════════════════════ +# Greendale Media Centre — Environment Variables +# Copy this file to .env and fill in your values. +# ══════════════════════════════════════════════════════════════════ + +# ── Login ───────────────────────────────────────────────────────── +# Password for the dashboard login page +LOGIN_PASSWORD=changeme + +# Secret key for Flask session signing — generate a random string: +# python3 -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=change_this_to_a_random_secret_key + +# ── Host port (what port Unraid exposes) ────────────────────────── +# Default is 5055 — change if that port is already in use +HOST_PORT=5055 + +# ── Media server host ───────────────────────────────────────────── +# LAN IP of the machine running Sonarr, Radarr, etc. +ARR_HOST=192.168.1.100 + +# ── Sonarr ──────────────────────────────────────────────────────── +SONARR_API_KEY=your_sonarr_api_key_here +# Optional overrides (defaults to ARR_HOST:8989): +# SONARR_HOST=192.168.1.100 +# SONARR_URL=http://192.168.1.100:8989 + +# ── Radarr ──────────────────────────────────────────────────────── +RADARR_API_KEY=your_radarr_api_key_here +# Optional overrides (defaults to ARR_HOST:7878): +# RADARR_HOST=192.168.1.100 +# RADARR_URL=http://192.168.1.100:7878 + +# ── SABnzbd ─────────────────────────────────────────────────────── +SABNZBD_API_KEY=your_sabnzbd_api_key_here +# Optional overrides (defaults to ARR_HOST:8080): +# SABNZBD_HOST=192.168.1.100 +# SABNZBD_PORT=8080 + +# ── Tautulli ────────────────────────────────────────────────────── +TAUTULLI_API_KEY=your_tautulli_api_key_here +# Optional overrides (defaults to ARR_HOST:8181): +# TAUTULLI_HOST=192.168.1.100 +# TAUTULLI_PORT=8181 + +# ── Prowlarr ────────────────────────────────────────────────────── +PROWLARR_API_KEY=your_prowlarr_api_key_here +# Optional overrides (defaults to ARR_HOST:9696): +# PROWLARR_HOST=192.168.1.100 +# PROWLARR_PORT=9696 + +# ── Ombi ────────────────────────────────────────────────────────── +OMBI_API_KEY=your_ombi_api_key_here +# Optional overrides (defaults to ARR_HOST:3579): +# OMBI_HOST=192.168.1.100 +# OMBI_PORT=3579 diff --git a/arr-summary/Dockerfile b/arr-summary/Dockerfile new file mode 100644 index 0000000..75c8978 --- /dev/null +++ b/arr-summary/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +LABEL net.unraid.docker.icon="https://static.wikia.nocookie.net/community-sitcom/images/e/eb/Greendalelogo.png/revision/latest?cb=20120321140817" +LABEL net.unraid.docker.webui="http://[IP]:[PORT:5000]/" +LABEL maintainer="Greendale Media Centre" + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["gunicorn", "--config=gunicorn.conf.py", "app:app"] diff --git a/arr-summary/UNRAID-SETUP.md b/arr-summary/UNRAID-SETUP.md new file mode 100644 index 0000000..cba8086 --- /dev/null +++ b/arr-summary/UNRAID-SETUP.md @@ -0,0 +1,154 @@ +# Greendale Media Centre — Unraid Setup Guide + +## Quick Start + +### 1. Export to Unraid + +```bash +# Make the export script executable +chmod +x export-to-unraid.sh + +# Export (replace with your Unraid IP) +./export-to-unraid.sh root@192.168.86.10 + +# Or with a custom destination path +./export-to-unraid.sh root@192.168.86.10 /mnt/user/appdata/greendale +``` + +### 2. Configure `.env` on Unraid + +SSH into your Unraid server and edit the config: + +```bash +ssh root@192.168.86.10 +nano /mnt/user/appdata/greendale/.env +``` + +**Minimum required fields:** + +```env +LOGIN_PASSWORD=your_chosen_password +SECRET_KEY= +ARR_HOST=192.168.86.33 +SONARR_API_KEY=... +RADARR_API_KEY=... +SABNZBD_API_KEY=... +TAUTULLI_API_KEY=... +PROWLARR_API_KEY=... +OMBI_API_KEY=... +``` + +Generate a secure `SECRET_KEY`: +```bash +python3 -c "import secrets; print(secrets.token_hex(32))" +``` + +### 3. Start the Container + +```bash +cd /mnt/user/appdata/greendale +docker compose up -d +``` + +The dashboard will be available at `http://YOUR_UNRAID_IP:5055` + +--- + +## Nginx Proxy Manager (Recommended for Unraid) + +If you're using **Nginx Proxy Manager** (NPM) on Unraid: + +1. In NPM, go to **Proxy Hosts → Add Proxy Host** +2. Fill in: + - **Domain:** `media.yourdomain.com` + - **Scheme:** `http` + - **Forward Hostname/IP:** your Unraid IP (e.g. `192.168.86.10`) + - **Forward Port:** `5055` + - Enable **"Block Common Exploits"** + - Enable **"Websockets Support"** +3. On the **SSL tab:** request a Let's Encrypt certificate + +That's it. NPM handles the SSL termination and reverse proxy automatically. + +--- + +## Manual Nginx Config + +If you're running Nginx directly (not NPM), use the configs in the `nginx/` folder: + +| File | Use when | +|------|----------| +| `nginx/greendale-subdomain.conf` | You want `https://media.yourdomain.com` | +| `nginx/greendale-subpath.conf` | You want `https://yourdomain.com/media/` | + +Copy the appropriate file and replace the placeholders: +- `YOUR_UNRAID_IP` → your Unraid server's LAN IP +- `yourdomain.com` / `media.yourdomain.com` → your domain +- `5055` → your `HOST_PORT` (if you changed it) + +--- + +## Login Page + +The dashboard is protected by a single-password login page themed to match Greendale. + +- Set `LOGIN_PASSWORD` in `.env` +- Sessions persist for the browser session (close browser to log out) +- Visit `/logout` to manually log out +- If `LOGIN_PASSWORD` is not set, the login is bypassed (dev mode) + +--- + +## Updating + +To push code changes to Unraid after editing locally: + +```bash +# Re-run the export script +./export-to-unraid.sh root@192.168.86.10 + +# On Unraid: +cd /mnt/user/appdata/greendale +docker compose down +docker compose up -d +``` + +--- + +## Unraid Community Applications (Manual Docker Template) + +If you prefer to set up via the Unraid Docker UI instead of `docker compose`: + +| Field | Value | +|-------|-------| +| Name | `greendale-media-centre` | +| Repository | `arr-summary-arr-summary` (after loading image) | +| Port | Host: `5055` → Container: `5000` | +| ENV: `LOGIN_PASSWORD` | your password | +| ENV: `SECRET_KEY` | random 32-char string | +| ENV: `ARR_HOST` | `192.168.86.33` | +| ENV: `SONARR_API_KEY` | your key | +| ENV: `RADARR_API_KEY` | your key | +| ENV: `SABNZBD_API_KEY` | your key | +| ENV: `TAUTULLI_API_KEY` | your key | +| ENV: `PROWLARR_API_KEY` | your key | +| ENV: `OMBI_API_KEY` | your key | +| Restart Policy | `unless-stopped` | + +--- + +## Troubleshooting + +**Container won't start:** +```bash +docker logs arr-summary +``` + +**Can't reach services (all cards show errors):** +- Verify `ARR_HOST` is the correct LAN IP +- Ensure services are running and accessible from Unraid +- Check API keys are correct + +**Login loop / session issues:** +- Make sure `SECRET_KEY` is set and consistent (random restarts shouldn't clear sessions) +- If running behind a proxy, confirm `X-Forwarded-Proto` header is being passed diff --git a/arr-summary/app.py b/arr-summary/app.py new file mode 100644 index 0000000..e9012ce --- /dev/null +++ b/arr-summary/app.py @@ -0,0 +1,1371 @@ +import os +import re +import json +import time +import hashlib +import secrets +import requests +import shutil +import urllib3 +from datetime import datetime, timedelta +from functools import wraps +from concurrent.futures import ThreadPoolExecutor, as_completed +from flask import Flask, render_template, request, jsonify, redirect, url_for, session + +# Suppress SSL warnings for Unraid's self-signed certificate +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +SETTINGS_FILE = os.path.join(os.path.dirname(__file__), "settings.json") + +def load_settings(): + try: + with open(SETTINGS_FILE) as f: + return json.load(f) + except Exception: + return {} + +def save_settings(data): + existing = load_settings() + existing.update(data) + with open(SETTINGS_FILE, "w") as f: + json.dump(existing, f, indent=2) + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) +app.config["SESSION_COOKIE_PATH"] = "/" +app.config["SESSION_COOKIE_HTTPONLY"] = True +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" +app.config["SESSION_COOKIE_SECURE"] = False # ProxyFix handles HTTPS; keep False so cookie is sent over internal HTTP too + +# Support running under a subpath (e.g. /media/) behind a reverse proxy. +# SCRIPT_NAME is injected into every WSGI request so Flask's url_for() and +# redirects all use the correct prefix automatically. +# ProxyFix also reads X-Forwarded-Prefix sent by NPM/nginx. +from werkzeug.middleware.proxy_fix import ProxyFix + +_script_name = os.environ.get("SCRIPT_NAME", "").rstrip("/") + +class ScriptNameMiddleware: + """Inject SCRIPT_NAME into every WSGI environ so Flask knows its prefix.""" + def __init__(self, wsgi_app, script_name): + self.app = wsgi_app + self.script_name = script_name + def __call__(self, environ, start_response): + if self.script_name: + environ["SCRIPT_NAME"] = self.script_name + path = environ.get("PATH_INFO", "") + if path.startswith(self.script_name): + environ["PATH_INFO"] = path[len(self.script_name):] or "/" + return self.app(environ, start_response) + +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) +if _script_name: + app.wsgi_app = ScriptNameMiddleware(app.wsgi_app, _script_name) + +# ── Authentication ────────────────────────────────────────────────────────── +LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "") +_runtime_password = None # overrides env var after change-password call + + +def get_active_password(): + if _runtime_password is not None: + return _runtime_password + # Check settings.json for a persisted password change + saved = load_settings().get("login_password") + if saved: + return saved + return LOGIN_PASSWORD + +def _hash_password(pw): + """SHA-256 hash for constant-time comparison.""" + return hashlib.sha256(pw.encode()).hexdigest() + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("authenticated"): + return redirect(url_for("login", next=request.path)) + return f(*args, **kwargs) + return decorated + +SONARR_HOST = os.environ.get("ARR_HOST", "localhost") +RADARR_HOST = os.environ.get("ARR_HOST", "localhost") +SONARR_URL = os.environ.get("SONARR_URL", f"http://{SONARR_HOST}:8989") +RADARR_URL = os.environ.get("RADARR_URL", f"http://{RADARR_HOST}:7878") +SONARR_API_KEY = os.environ.get("SONARR_API_KEY", "") +RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "") + +SABNZBD_HOST = os.environ.get("SABNZBD_HOST", os.environ.get("ARR_HOST", "localhost")) +SABNZBD_PORT = os.environ.get("SABNZBD_PORT", "8080") +SABNZBD_URL = f"http://{SABNZBD_HOST}:{SABNZBD_PORT}" +SABNZBD_API_KEY = os.environ.get("SABNZBD_API_KEY", "") + +TAUTULLI_HOST = os.environ.get("TAUTULLI_HOST", os.environ.get("ARR_HOST", "localhost")) +TAUTULLI_PORT = os.environ.get("TAUTULLI_PORT", "8181") +TAUTULLI_URL = f"http://{TAUTULLI_HOST}:{TAUTULLI_PORT}/tautulli" +TAUTULLI_API_KEY = os.environ.get("TAUTULLI_API_KEY", "") + +PROWLARR_HOST = os.environ.get("PROWLARR_HOST", os.environ.get("ARR_HOST", "localhost")) +PROWLARR_PORT = os.environ.get("PROWLARR_PORT", "9696") +PROWLARR_URL = f"http://{PROWLARR_HOST}:{PROWLARR_PORT}/prowlarr" +PROWLARR_API_KEY = os.environ.get("PROWLARR_API_KEY", "") + +OMBI_HOST = os.environ.get("OMBI_HOST", os.environ.get("ARR_HOST", "localhost")) +OMBI_PORT = os.environ.get("OMBI_PORT", "3579") +OMBI_URL = f"http://{OMBI_HOST}:{OMBI_PORT}" +OMBI_API_KEY = os.environ.get("OMBI_API_KEY", "") + +RT_HEADERS = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept-Language": "en-US,en;q=0.9", +} + +# RT cache: refresh every 4 hours +_rt_cache = {} +RT_CACHE_TTL = 4 * 3600 # seconds + +# IMDB description cache — persists for container lifetime, keyed by "title|year" +_imdb_desc_cache = {} +# Tracks titles currently being fetched so we don't double-fetch +_imdb_in_flight = set() + +# SABnzbd history cache: refresh every 10 minutes (history doesn't change fast) +_sab_history_cache = {"data": None, "expires": 0} + +# Sonarr / Radarr library cache: refresh every 5 minutes. +# The full movie list (703 items = 4.4 MB) is the single biggest source of +# dashboard latency — caching it cuts Radarr from ~1.3 s to <10 ms on warm loads. +_sonarr_cache = {"data": None, "expires": 0} +_radarr_cache = {"data": None, "expires": 0} +ARR_CACHE_TTL = 5 * 60 # 5 minutes + +# Tautulli stats cache: top shows/movies/users/history refresh every 2 minutes. +# Active streams (get_activity) are always fetched live. +_tautulli_stats_cache = {"data": None, "expires": 0} +TAUTULLI_CACHE_TTL = 2 * 60 # 2 minutes + + +def _fetch_imdb_description(title, year=""): + """Fetch a single IMDB description. Returns "" on any failure.""" + cache_key = f"{title}|{year}" + if cache_key in _imdb_desc_cache: + return _imdb_desc_cache[cache_key] + try: + query = requests.utils.quote(f"{title} {year}".strip()) + search_url = f"https://www.imdb.com/find/?q={query}&type=tt&s=tt" + resp = requests.get(search_url, headers=RT_HEADERS, timeout=8) + resp.raise_for_status() + match = re.search(r'href="(/title/tt\d+/)[^"]*"', resp.text) + if not match: + _imdb_desc_cache[cache_key] = "" + return "" + title_resp = requests.get(f"https://www.imdb.com{match.group(1)}", headers=RT_HEADERS, timeout=8) + title_resp.raise_for_status() + jld = re.search(r'', title_resp.text, re.DOTALL) + desc = json.loads(jld.group(1)).get("description", "") if jld else "" + _imdb_desc_cache[cache_key] = desc + return desc + except Exception: + _imdb_desc_cache[cache_key] = "" + return "" + + +def _enrich_rt_descriptions_bg(items): + """Background enrichment: fetch IMDB descriptions for items that don't have one yet. + Uses 10 parallel workers. Safe to call from a daemon thread.""" + to_fetch = [ + item for item in items + if not item.get("error") + and not item.get("description") + and f"{item.get('title','')}|{item.get('year','')}" not in _imdb_desc_cache + and f"{item.get('title','')}|{item.get('year','')}" not in _imdb_in_flight + ] + if not to_fetch: + return + keys = {f"{i.get('title','')}|{i.get('year','')}": i for i in to_fetch} + _imdb_in_flight.update(keys.keys()) + try: + def _fetch(item): + desc = _fetch_imdb_description(item.get("title", ""), item.get("year", "")) + item["description"] = desc + with ThreadPoolExecutor(max_workers=10) as ex: + list(ex.map(_fetch, to_fetch)) + finally: + _imdb_in_flight.difference_update(keys.keys()) + + +# Ombi cache: refresh every 4 hours +_ombi_cache = {"data": None, "expires": 0} + + +def _rt_cached(key, fetch_fn): + """Return cached RT data, re-fetching if older than 4 hours.""" + import time + now = time.time() + entry = _rt_cache.get(key) + if entry and entry.get("expires", 0) > now: + return entry["data"] + data = fetch_fn() + _rt_cache[key] = {"data": data, "expires": now + RT_CACHE_TTL} + return data + + +# --------------------------------------------------------------------------- +# Sonarr +# --------------------------------------------------------------------------- + +def sonarr_get(endpoint): + headers = {"X-Api-Key": SONARR_API_KEY} + resp = requests.get(f"{SONARR_URL}/api/v3/{endpoint}", headers=headers, timeout=4) + resp.raise_for_status() + return resp.json() + + +def sonarr_post(endpoint, payload): + headers = {"X-Api-Key": SONARR_API_KEY, "Content-Type": "application/json"} + resp = requests.post(f"{SONARR_URL}/api/v3/{endpoint}", headers=headers, json=payload, timeout=4) + resp.raise_for_status() + return resp.json() + + +def get_sonarr_summary(): + global _sonarr_cache + 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} + wanted = sonarr_get("wanted/missing?pageSize=1") + total_series = len(series) + monitored = sum(1 for s in series if s.get("monitored")) + missing_episodes = wanted.get("totalRecords", 0) + sonarr_titles = {s.get("title", "").lower() for s in series} + return { + "status": "ok", + "total_series": total_series, + "monitored_series": monitored, + "missing_episodes": missing_episodes, + "sonarr_titles": list(sonarr_titles), + } + except Exception as e: + return {"status": "error", "error": str(e), "sonarr_titles": []} + + +# --------------------------------------------------------------------------- +# Radarr +# --------------------------------------------------------------------------- + +def radarr_get(endpoint): + headers = {"X-Api-Key": RADARR_API_KEY} + resp = requests.get(f"{RADARR_URL}/api/v3/{endpoint}", headers=headers, timeout=4) + resp.raise_for_status() + return resp.json() + + +def radarr_post(endpoint, payload): + headers = {"X-Api-Key": RADARR_API_KEY, "Content-Type": "application/json"} + resp = requests.post(f"{RADARR_URL}/api/v3/{endpoint}", headers=headers, json=payload, timeout=4) + resp.raise_for_status() + return resp.json() + + +def get_radarr_summary(): + global _radarr_cache + 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} + 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")) + missing = total_movies - downloaded + radarr_titles = {m.get("title", "").lower() for m in movies} + return { + "status": "ok", + "total_movies": total_movies, + "monitored_movies": monitored, + "downloaded_movies": downloaded, + "missing_movies": missing, + "radarr_titles": list(radarr_titles), + } + except Exception as e: + return {"status": "error", "error": str(e), "radarr_titles": []} + + +# --------------------------------------------------------------------------- +# SABnzbd +# --------------------------------------------------------------------------- + +def sabnzbd_get(mode, extra_params=""): + url = f"{SABNZBD_URL}/api?output=json&apikey={SABNZBD_API_KEY}&mode={mode}{extra_params}" + resp = requests.get(url, timeout=4) + resp.raise_for_status() + return resp.json() + + +def _format_speed(kbps): + """Format KB/s into a human-readable string.""" + if kbps is None: + return "0 B/s" + if kbps >= 1024 * 1024: + return f"{kbps / (1024 * 1024):.1f} GB/s" + if kbps >= 1024: + return f"{kbps / 1024:.1f} MB/s" + return f"{kbps:.0f} KB/s" + + +def _format_size(mb): + """Format MB into a human-readable string.""" + if mb is None: + return "0 MB" + if mb >= 1024: + return f"{mb / 1024:.1f} GB" + return f"{mb:.0f} MB" + + +def _get_sab_history_cached(): + """Fetch SABnzbd history with a 10-minute cache — avoids slow 1000-item fetch on every page load.""" + global _sab_history_cache + now = time.time() + if _sab_history_cache["data"] is not None and now < _sab_history_cache["expires"]: + return _sab_history_cache["data"] + try: + history = sabnzbd_get("history", "&limit=1000") + h = history.get("history", {}) + slots = h.get("slots", []) + week_size = h.get("week_size", "0 B") + day_size = h.get("day_size", "0 B") + seven_days_ago = now - (7 * 24 * 3600) + recent = [s for s in slots if s.get("completed", 0) >= seven_days_ago and s.get("status") != "Failed"] + completed_7d = len(recent) + speeds = [] + for s in recent: + duration = s.get("download_time", 0) or 0 + size_bytes = s.get("bytes", 0) or 0 + if duration > 0 and size_bytes > 0: + 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) + result = {"week_size": week_size, "day_size": day_size, + "completed_7d": completed_7d, "avg_speed": avg_speed} + _sab_history_cache = {"data": result, "expires": now + 600} + return result + except Exception: + return {"week_size": "—", "day_size": "—", "completed_7d": "—", "avg_speed": "—"} + + +def get_sabnzbd_summary(): + try: + # Queue only — fast, no history fetch here + queue = sabnzbd_get("queue") + q = queue.get("queue", {}) + + speed_str = str(q.get("speed", "0")).strip() + speed_kbps = float(q.get("kbpersec", 0) or 0) + if speed_kbps > 0 and speed_str and speed_str != "0": + display_speed = speed_str + "/s" if not speed_str.endswith("/s") else speed_str + else: + display_speed = "0 B/s" + + status = q.get("status", "Unknown") + queue_count = int(q.get("noofslots_total", q.get("noofslots", 0))) + sizeleft = q.get("sizeleft", "0 B") + timeleft = q.get("timeleft", "0:00:00") + + hist = _get_sab_history_cached() + return { + "status": "ok", + "speed": display_speed, + "speed_kbps": speed_kbps, + "sab_status": status, + "queue_count": queue_count, + "sizeleft": sizeleft, + "timeleft": timeleft, + "completed_7d": hist["completed_7d"], + "week_size": hist["week_size"], + "day_size": hist["day_size"], + "avg_speed": hist["avg_speed"], + } + except Exception as e: + return {"status": "error", "error": str(e)} + + +# --------------------------------------------------------------------------- +# Tautulli +# --------------------------------------------------------------------------- + +def tautulli_get(cmd, extra=""): + url = f"{TAUTULLI_URL}/api/v2?apikey={TAUTULLI_API_KEY}&cmd={cmd}{extra}" + resp = requests.get(url, timeout=4) + resp.raise_for_status() + return resp.json().get("response", {}).get("data", {}) + + +def get_tautulli_summary(): + global _tautulli_stats_cache + try: + now = time.time() + # Stats (top shows/movies/users/history) are cached for 2 minutes + if _tautulli_stats_cache["data"] is not None and now < _tautulli_stats_cache["expires"]: + cached = _tautulli_stats_cache["data"] + top_tv_list = cached["top_tv_list"] + top_movie_list = cached["top_movie_list"] + top_played = cached["top_played"] + top_users = cached["top_users"] + else: + # Top 3 most watched TV shows (30d) + tv_data = tautulli_get("get_home_stats", "&stat_id=top_tv&stats_count=3&stats_type=plays&time_range=30") + tv_rows = tv_data.get("rows", []) if isinstance(tv_data, dict) else [] + top_tv_list = [{"title": r.get("title", "Unknown"), "plays": r.get("total_plays", 0)} for r in tv_rows] + + # Top 3 most watched movies (30d) + movie_data = tautulli_get("get_home_stats", "&stat_id=top_movies&stats_count=3&stats_type=plays&time_range=30") + movie_rows = movie_data.get("rows", []) if isinstance(movie_data, dict) else [] + top_movie_list = [{"title": r.get("title", "Unknown"), "plays": r.get("total_plays", 0)} for r in movie_rows] + + # Top played per user from recent history + history_data = tautulli_get("get_history", "&length=100&order_column=date&order_dir=desc") + history_slots = [] + if isinstance(history_data, dict): + history_slots = history_data.get("data", []) + if isinstance(history_slots, dict): + history_slots = history_slots.get("data", []) + play_counts = {} + for entry in history_slots: + user = entry.get("friendly_name") or entry.get("user", "Unknown") + if user.lower() == "ninja_hippo": + continue + title = entry.get("full_title") or entry.get("title", "Unknown") + media_type = entry.get("media_type", "") + key = (title, user) + if key not in play_counts: + play_counts[key] = {"title": title, "user": user, "plays": 0, "media_type": media_type} + play_counts[key]["plays"] += 1 + top_played = sorted(play_counts.values(), key=lambda x: x["plays"], reverse=True)[:3] + + # Top 3 viewers (30d) excluding ninja_hippo + users_data = tautulli_get("get_home_stats", "&stat_id=top_users&stats_count=10&stats_type=plays&time_range=30") + users_rows = users_data.get("rows", []) if isinstance(users_data, dict) else [] + top_users = [] + for r in users_rows: + username = r.get("friendly_name") or r.get("user", "") + if username.lower() == "ninja_hippo": + continue + top_users.append({"username": username, "plays": r.get("total_plays", 0), "thumb": r.get("user_thumb", "")}) + if len(top_users) >= 3: + break + + _tautulli_stats_cache = { + "data": {"top_tv_list": top_tv_list, "top_movie_list": top_movie_list, + "top_played": top_played, "top_users": top_users}, + "expires": now + TAUTULLI_CACHE_TTL, + } + + # Active streams always fetched live (changes second-to-second) + activity = tautulli_get("get_activity") + stream_count = int(activity.get("stream_count", 0) or 0) if isinstance(activity, dict) else 0 + raw_sessions = activity.get("sessions", []) if isinstance(activity, dict) else [] + sessions = [] + for s in raw_sessions: + 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", ""), + }) + + top_tv = top_tv_list[0] if top_tv_list else None + top_movie = top_movie_list[0] if top_movie_list else None + + return { + "status": "ok", + "top_tv": top_tv, + "top_tv_list": top_tv_list, + "top_movie": top_movie, + "top_movie_list": top_movie_list, + "top_played": top_played, + "top_users": top_users, + "active_streams": stream_count, + "sessions": sessions, + } + except Exception as e: + return {"status": "error", "error": str(e)} + + +# --------------------------------------------------------------------------- +# Ombi +# --------------------------------------------------------------------------- + +def ombi_get(endpoint): + headers = {"ApiKey": OMBI_API_KEY} + resp = requests.get(f"{OMBI_URL}/api/v1/{endpoint}", headers=headers, timeout=4) + resp.raise_for_status() + return resp.json() + + +def _ombi_status_label(available, approved, denied): + if denied: + return "Denied" + if available: + return "Available" + if approved: + return "Approved" + return "Pending" + + +def get_ombi_summary(): + global _ombi_cache + now = time.time() + if _ombi_cache["data"] is not None and now < _ombi_cache["expires"]: + return _ombi_cache["data"] + try: + movies_raw = ombi_get("Request/movie") + tv_raw = ombi_get("Request/tv") + + # Flatten all requests with common fields + all_requests = [] + + for m in movies_raw: + user = m.get("requestedUser", {}) + alias = (user.get("alias") or user.get("userAlias") or user.get("userName") or "Unknown").split("@")[0] + if alias.lower() == "ninja_hippo": + continue + all_requests.append({ + "title": m.get("title", "Unknown"), + "type": "Movie", + "requested_date": m.get("requestedDate", ""), + "user": alias, + "status": _ombi_status_label(m.get("available"), m.get("approved"), m.get("denied")), + "poster": m.get("posterPath", ""), + }) + + for show in tv_raw: + for child in show.get("childRequests", []): + user = child.get("requestedUser", {}) + alias = (user.get("alias") or user.get("userAlias") or user.get("userName") or "Unknown").split("@")[0] + if alias.lower() == "ninja_hippo": + continue + # Determine child status from season approvals + approved = child.get("approved", False) + available = any( + ep.get("available", False) + for season in child.get("seasonRequests", []) + for ep in season.get("episodes", []) + ) + denied = child.get("denied", False) + all_requests.append({ + "title": show.get("title", "Unknown"), + "type": "TV", + "requested_date": child.get("requestedDate", ""), + "user": alias, + "status": _ombi_status_label(available, approved, denied), + "poster": show.get("posterPath", ""), + }) + + # Sort by requested date descending, take 5 most recent + all_requests.sort(key=lambda x: x["requested_date"], reverse=True) + recent = all_requests[:10] + + # Tidy date display + for r in recent: + d = r["requested_date"] + r["date_display"] = d[:10] if d else "—" + + total_pending = sum(1 for r in all_requests if r["status"] == "Pending") + total_requests = len(all_requests) + + result = { + "status": "ok", + "recent": recent, + "total_requests": total_requests, + "total_pending": total_pending, + } + _ombi_cache["data"] = result + _ombi_cache["expires"] = now + (4 * 3600) + return result + except Exception as e: + return {"status": "error", "error": str(e)} + + +# --------------------------------------------------------------------------- +# Prowlarr +# --------------------------------------------------------------------------- + +def prowlarr_get(endpoint): + headers = {"X-Api-Key": PROWLARR_API_KEY} + resp = requests.get(f"{PROWLARR_URL}/api/v1/{endpoint}", headers=headers, timeout=4) + resp.raise_for_status() + return resp.json() + + +def get_prowlarr_summary(): + try: + indexers = prowlarr_get("indexer") + # indexerstatus returns only blocked/failed indexers + try: + failed_raw = prowlarr_get("indexerstatus") + failed_ids = {s.get("indexerId") for s in failed_raw} if isinstance(failed_raw, list) else set() + except Exception: + failed_ids = set() + + result = [] + for idx in sorted(indexers, key=lambda x: x.get("name", "").lower()): + enabled = idx.get("enable", True) + blocked = idx.get("id") in failed_ids + up = enabled and not blocked + result.append({ + "id": idx.get("id"), + "name": idx.get("name", "Unknown"), + "protocol": idx.get("protocol", ""), + "enabled": enabled, + "up": up, + }) + + total = len(result) + up_count = sum(1 for i in result if i["up"]) + return { + "status": "ok", + "indexers": result, + "total": total, + "up_count": up_count, + } + except Exception as e: + return {"status": "error", "error": str(e), "indexers": []} + + +# --------------------------------------------------------------------------- +# Unraid system stats +# --------------------------------------------------------------------------- + +UNRAID_HOST = os.environ.get("UNRAID_HOST", os.environ.get("ARR_HOST", "")) +UNRAID_API_KEY = os.environ.get("UNRAID_API_KEY", "") + + +def get_unraid_stats(): + """Fetch Unraid disk/system stats via GraphQL API, falling back to shutil.""" + + def fmt_bytes(b): + b = int(b or 0) + if b >= 1024**4: return f"{b/1024**4:.1f} TB" + if b >= 1024**3: return f"{b/1024**3:.1f} GB" + if b >= 1024**2: return f"{b/1024**2:.1f} MB" + return f"{b/1024:.0f} KB" + + if UNRAID_HOST and UNRAID_API_KEY: + # HTTPS on 443 with SSL verification disabled (Unraid uses a self-signed cert) + url = f"https://{UNRAID_HOST}:443/graphql" + headers = {"x-api-key": UNRAID_API_KEY, "Content-Type": "application/json"} + query = """{ + vars { version } + array { state disks { fsSize fsFree type } } + }""" + try: + resp = requests.post(url, json={"query": query}, headers=headers, + timeout=4, verify=False) + if resp.ok: + d = resp.json().get("data", {}) + if d: + arr = d.get("array", {}) + disks = arr.get("disks", []) + data_disks = [dk for dk in disks if dk.get("type") == "DATA"] + total_bytes = sum(int(dk.get("fsSize", 0) or 0) for dk in data_disks) + free_bytes = sum(int(dk.get("fsFree", 0) or 0) for dk in data_disks) + used_bytes = total_bytes - free_bytes + used_pct = round(used_bytes / total_bytes * 100) if total_bytes else 0 + return { + "status": "ok", + "source": "api", + "array_state": arr.get("state", ""), + "os_version": d.get("vars", {}).get("version", ""), + "disk_count": len(data_disks), + "array_total": fmt_bytes(total_bytes), + "array_used": fmt_bytes(used_bytes), + "array_free": fmt_bytes(free_bytes), + "array_used_pct": used_pct, + } + except Exception: + pass + + # Fallback: container-visible disk usage + try: + mount_paths = ["/mnt/user", "/data", "/"] + chosen = next((p for p in mount_paths if os.path.exists(p)), "/") + usage = shutil.disk_usage(chosen) + used_pct = round(usage.used / usage.total * 100) if usage.total else 0 + return { + "status": "ok", + "source": "local", + "mount": chosen, + "array_total": fmt_bytes(usage.total), + "array_used": fmt_bytes(usage.used), + "array_free": fmt_bytes(usage.free), + "array_used_pct": used_pct, + } + except Exception as e: + return {"status": "error", "error": str(e)} + + +# --------------------------------------------------------------------------- +# Rotten Tomatoes scraping +# --------------------------------------------------------------------------- + +def _scrape_rt_jsonld(html): + match = re.search(r'', html, re.DOTALL) + if not match: + return [] + try: + data = json.loads(match.group(1)) + inner = data.get("itemListElement", {}) + if isinstance(inner, dict): + return inner.get("itemListElement", []) + if isinstance(inner, list): + return inner + except Exception: + pass + return [] + + +def _scrape_rt_tiles(html): + tiles = re.findall(r']*>(.*?)', html, re.DOTALL) + results = [] + for tile in tiles: + audience_match = re.search(r'slot="audienceScore"[^>]*>\s*([^<\s][^<]*?)\s*<', tile) + audience_score = audience_match.group(1).strip() if audience_match else "" + certified_match = re.search(r'certified="(true|false)"', tile) + certified = certified_match.group(1) == "true" if certified_match else False + results.append({"audience_score": audience_score, "certified": certified}) + return results + + +def _fetch_rt_movies(): + try: + resp = requests.get( + "https://www.rottentomatoes.com/browse/movies_in_theaters/sort:top_box_office", + headers=RT_HEADERS, timeout=4 + ) + resp.raise_for_status() + html = resp.text + items = _scrape_rt_jsonld(html)[:30] + tiles = _scrape_rt_tiles(html) + movies = [] + for i, item in enumerate(items): + tile = tiles[i] if i < len(tiles) else {} + rating = item.get("aggregateRating") + critics_score = str(rating.get("ratingValue", "")) if rating else "" + try: + if critics_score and int(critics_score) < 50: + continue + except ValueError: + pass + movies.append({ + "title": item.get("name", "Unknown"), + "year": str(item.get("dateCreated", ""))[:4], + "poster": item.get("image", ""), + "rt_url": item.get("url", ""), + "critics_score": critics_score, + "audience_score": tile.get("audience_score", ""), + "certified": tile.get("certified", False), + "description": "", + }) + return movies + except Exception as e: + return [{"error": str(e)}] + + +def _fetch_rt_tv(): + try: + resp = requests.get( + "https://www.rottentomatoes.com/browse/tv_series_browse/sort:popular", + headers=RT_HEADERS, timeout=4 + ) + resp.raise_for_status() + html = resp.text + items = _scrape_rt_jsonld(html)[:30] + tiles = _scrape_rt_tiles(html) + shows = [] + for i, item in enumerate(items): + tile = tiles[i] if i < len(tiles) else {} + rating = item.get("aggregateRating") + critics_score = str(rating.get("ratingValue", "")) if rating else "" + try: + if critics_score and int(critics_score) < 50: + continue + except ValueError: + pass + shows.append({ + "title": item.get("name", "Unknown"), + "year": str(item.get("dateCreated", ""))[:4], + "poster": item.get("image", ""), + "rt_url": item.get("url", ""), + "critics_score": critics_score, + "audience_score": tile.get("audience_score", ""), + "certified": tile.get("certified", False), + "description": "", + }) + return shows + except Exception as e: + return [{"error": str(e)}] + + +def get_rt_movies(): + data = _rt_cached("rt_movies", _fetch_rt_movies) + # Kick off background IMDB enrichment (non-blocking) + import threading + threading.Thread(target=_enrich_rt_descriptions_bg, args=(data,), daemon=True).start() + return data + + +def get_rt_tv(): + data = _rt_cached("rt_tv", _fetch_rt_tv) + import threading + threading.Thread(target=_enrich_rt_descriptions_bg, args=(data,), daemon=True).start() + return data + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@app.route("/login", methods=["GET", "POST"]) +def login(): + error = None + if request.method == "POST": + entered = request.form.get("password", "") + if LOGIN_PASSWORD and _hash_password(entered) == _hash_password(get_active_password()): + session["authenticated"] = True + 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")) + error = "Incorrect password. Try again." + # If no password is set, auto-login (dev mode) + if not get_active_password(): + session["authenticated"] = True + return redirect(url_for("dashboard")) + return render_template("login.html", error=error) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +@app.route("/api/unraid") +@login_required +def api_unraid(): + return jsonify(get_unraid_stats()) + + +@app.route("/api/rt/descriptions") +@login_required +def api_rt_descriptions(): + """Return cached IMDB descriptions for all RT items. Called by the page after load. + Also triggers enrichment in this worker if the cache is cold (handles multi-worker case).""" + import threading + movies = _rt_cache.get("rt_movies", {}).get("data", []) + tv = _rt_cache.get("rt_tv", {}).get("data", []) + # If this worker hasn't loaded RT data yet, trigger it and tell client to retry + if not movies and not tv: + threading.Thread(target=get_rt_movies, daemon=True).start() + threading.Thread(target=get_rt_tv, daemon=True).start() + return jsonify({"descriptions": {}, "pending": True}) + all_items = [i for i in movies + tv if not i.get("error")] + # If this worker has no descriptions yet, kick off enrichment now + needs_fetch = [ + i for i in all_items + if f"{i.get('title','')}|{i.get('year','')}" not in _imdb_desc_cache + and f"{i.get('title','')}|{i.get('year','')}" not in _imdb_in_flight + ] + if needs_fetch: + threading.Thread(target=_enrich_rt_descriptions_bg, args=(all_items,), daemon=True).start() + result = {} + for item in all_items: + key = f"{item.get('title','')}|{item.get('year','')}" + desc = _imdb_desc_cache.get(key, None) + if desc is not None: + result[item.get("title", "")] = desc + still_pending = bool(needs_fetch) or any( + _imdb_desc_cache.get(f"{i.get('title','')}|{i.get('year','')}", None) is None + for i in all_items + ) + 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, + } + 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 + for name in tasks: + if name not in results: + results[name] = {"status": "error", "error": "timeout"} + + 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") + theme = s.get("theme", "community") + # Public-facing URLs for card header links (via NPM reverse proxy) + arr_host_public = f"https://hippofam.com" + service_urls = { + "sonarr": f"{arr_host_public}/sonarr", + "radarr": f"{arr_host_public}/radarr", + "sabnzbd": f"{arr_host_public}/sab", + "tautulli": f"{arr_host_public}/tautulli", + "prowlarr": f"{arr_host_public}/prowlarr", + "ombi": f"{arr_host_public}/ombi", + "unraid": f"https://{UNRAID_HOST}", + } + 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, + theme=theme, + service_urls=service_urls, + ) + + +@app.route("/api/sabnzbd") +@login_required +def api_sabnzbd(): + """Lightweight real-time endpoint — queue only (no history re-fetch).""" + try: + queue = sabnzbd_get("queue") + q = queue.get("queue", {}) + speed_str = str(q.get("speed", "0")).strip() + speed_kbps = float(q.get("kbpersec", 0) or 0) + if speed_kbps > 0 and speed_str and speed_str != "0": + display_speed = speed_str + "/s" if not speed_str.endswith("/s") else speed_str + else: + display_speed = "0 B/s" + status = q.get("status", "Unknown") + queue_count = int(q.get("noofslots_total", q.get("noofslots", 0))) + sizeleft = q.get("sizeleft", "0 B") + timeleft = q.get("timeleft", "0:00:00") + resp = jsonify({ + "status": "ok", + "speed": display_speed, + "speed_kbps": speed_kbps, + "sab_status": status, + "queue_count": queue_count, + "sizeleft": sizeleft, + "timeleft": timeleft, + }) + resp.headers["Cache-Control"] = "no-store" + return resp + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 500 + + +@app.route("/api/prowlarr") +@login_required +def api_prowlarr(): + """Real-time Prowlarr indexer status.""" + return jsonify(get_prowlarr_summary()) + + +@app.route("/api/tautulli") +@login_required +def api_tautulli(): + """Real-time Tautulli active streams.""" + try: + activity = tautulli_get("get_activity") + stream_count = int(activity.get("stream_count", 0) or 0) if isinstance(activity, dict) else 0 + raw_sessions = activity.get("sessions", []) if isinstance(activity, dict) else [] + sessions = [] + for s in raw_sessions: + 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", ""), + }) + return jsonify({"status": "ok", "stream_count": stream_count, "sessions": sessions}) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 500 + + +@app.route("/api/ombi") +@login_required +def api_ombi(): + """Return cached Ombi data.""" + return jsonify(get_ombi_summary()) + + +@app.route("/api/services") +@login_required +def api_services(): + """Header status lights — lightweight reachability check for all services.""" + def check(fn): + try: + r = fn() + return r.get("status") == "ok" + except Exception: + return False + + # Quick queue-only checks for speed + def sonarr_ok(): + sonarr_get("series?pageSize=1") + return True + def radarr_ok(): + radarr_get("movie?pageSize=1") + return True + def sabnzbd_ok(): + sabnzbd_get("queue") + return True + def tautulli_ok(): + tautulli_get("get_activity") + return True + def prowlarr_ok(): + prowlarr_get("health") + return True + def ombi_ok(): + ombi_get("Settings/about") + return True + + results = {} + for name, fn in [("sonarr", sonarr_ok), ("radarr", radarr_ok), + ("sabnzbd", sabnzbd_ok), ("tautulli", tautulli_ok), + ("prowlarr", prowlarr_ok), ("ombi", ombi_ok)]: + try: + fn() + results[name] = True + except Exception: + results[name] = False + + return jsonify(results) + + +@app.route("/api/tautulli/never-watched") +@login_required +def api_never_watched(): + """Movies and shows in Radarr/Sonarr that have never been played in Tautulli.""" + try: + # Get all history titles from Tautulli (last 500 entries) + hist = tautulli_get("get_history", "&length=500&order_column=date&order_dir=desc") + played_titles = set() + if isinstance(hist, dict): + slots = hist.get("data", []) + if isinstance(slots, dict): + slots = slots.get("data", []) + for s in slots: + t = s.get("full_title") or s.get("title") or s.get("parent_title", "") + if t: + played_titles.add(t.lower()) + + never = [] + + # Sonarr series with episodes on disk, never played + try: + series_list = sonarr_get("series") + for s in series_list: + size = s.get("sizeOnDisk", 0) + if size == 0: + continue + title = s.get("title", "") + if title.lower() not in played_titles: + never.append({ + "type": "show", + "title": title, + "year": s.get("year", ""), + "size": size, + "sonarr_id": s.get("id"), + }) + except Exception: + pass + + # Sort by size descending + never.sort(key=lambda x: x["size"], reverse=True) + + # Format size + def fmt(b): + gb = b / (1024**3) + if gb >= 1: + return f"{gb:.1f} GB" + return f"{b / (1024**2):.0f} MB" + + for item in never: + item["size_display"] = fmt(item["size"]) + + return jsonify({"status": "ok", "items": never[:30]}) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 500 + + +@app.route("/api/delete/movie", methods=["POST"]) +@login_required +def api_delete_movie(): + data = request.get_json() + radarr_id = data.get("radarr_id") + if not radarr_id: + return jsonify({"error": "No radarr_id"}), 400 + try: + headers = {"X-Api-Key": RADARR_API_KEY} + resp = requests.delete( + f"{RADARR_URL}/api/v3/movie/{radarr_id}?deleteFiles=true", + headers=headers, timeout=15 + ) + resp.raise_for_status() + return jsonify({"success": True}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/delete/show", methods=["POST"]) +@login_required +def api_delete_show(): + data = request.get_json() + sonarr_id = data.get("sonarr_id") + if not sonarr_id: + return jsonify({"error": "No sonarr_id"}), 400 + try: + headers = {"X-Api-Key": SONARR_API_KEY} + resp = requests.delete( + f"{SONARR_URL}/api/v3/series/{sonarr_id}?deleteFiles=true", + headers=headers, timeout=15 + ) + resp.raise_for_status() + return jsonify({"success": True}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/settings", methods=["GET"]) +@login_required +def settings_page(): + s = load_settings() + return render_template("settings.html", + timezone=s.get("timezone", "America/New_York"), + sonarr_api_key=SONARR_API_KEY, + radarr_api_key=RADARR_API_KEY, + sabnzbd_api_key=SABNZBD_API_KEY, + tautulli_api_key=TAUTULLI_API_KEY, + prowlarr_api_key=PROWLARR_API_KEY, + ombi_api_key=OMBI_API_KEY, + arr_host=os.environ.get("ARR_HOST", ""), + theme=s.get("theme", "community"), + ) + + +@app.route("/api/settings", methods=["POST"]) +@login_required +def api_save_settings(): + data = request.get_json() + allowed = {"timezone", "sonarr_api_key", "radarr_api_key", "sabnzbd_api_key", + "tautulli_api_key", "prowlarr_api_key", "ombi_api_key", "arr_host", "theme"} + filtered = {k: v for k, v in data.items() if k in allowed} + save_settings(filtered) + return jsonify({"success": True}) + + +@app.route("/api/settings", methods=["GET"]) +@login_required +def api_get_settings(): + s = load_settings() + return jsonify({ + "timezone": s.get("timezone", "America/New_York"), + "theme": s.get("theme", "community"), + }) + + +@app.route("/api/change-password", methods=["POST"]) +@login_required +def api_change_password(): + global _runtime_password + data = request.get_json() + current = data.get("current", "") + new_pw = data.get("new", "") + confirm = data.get("confirm", "") + if not current or not new_pw or not confirm: + return jsonify({"error": "All fields are required"}), 400 + if _hash_password(current) != _hash_password(get_active_password()): + return jsonify({"error": "Current password is incorrect"}), 403 + if new_pw != confirm: + return jsonify({"error": "New passwords do not match"}), 400 + if len(new_pw) < 6: + return jsonify({"error": "Password must be at least 6 characters"}), 400 + # Persist to settings.json and update runtime variable + save_settings({"login_password": new_pw}) + _runtime_password = new_pw + return jsonify({"success": True}) + + +@app.route("/api/sonarr/lookup") +@login_required +def api_sonarr_lookup(): + q = request.args.get("q", "").strip() + if not q: + return jsonify({"error": "No query provided"}), 400 + try: + results = sonarr_get(f"series/lookup?term={requests.utils.quote(q)}") + if not results: + return jsonify({}) + s = results[0] + title = s.get("title", "") + sonarr_titles = {x.get("title", "").lower() for x in sonarr_get("series")} + return jsonify({ + "title": title, + "year": s.get("year", ""), + "network": s.get("network", ""), + "status": s.get("status", ""), + "exists": title.lower() in sonarr_titles, + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/radarr/lookup") +@login_required +def api_radarr_lookup(): + q = request.args.get("q", "").strip() + if not q: + return jsonify({"error": "No query provided"}), 400 + try: + results = radarr_get(f"movie/lookup?term={requests.utils.quote(q)}") + if not results: + return jsonify({}) + m = results[0] + title = m.get("title", "") + radarr_titles = {x.get("title", "").lower() for x in radarr_get("movie")} + return jsonify({ + "title": title, + "year": m.get("year", ""), + "studio": m.get("studio", ""), + "runtime": m.get("runtime", ""), + "exists": title.lower() in radarr_titles, + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/add-to-radarr", methods=["POST"]) +@login_required +def add_to_radarr(): + data = request.get_json() + title = data.get("title") + if not title: + return jsonify({"error": "No title provided"}), 400 + try: + results = radarr_get(f"movie/lookup?term={requests.utils.quote(title)}") + if not results: + return jsonify({"error": "Movie not found in Radarr lookup"}), 404 + movie = results[0] + root_folders = radarr_get("rootfolder") + quality_profiles = radarr_get("qualityprofile") + if not root_folders or not quality_profiles: + return jsonify({"error": "No root folders or quality profiles configured in Radarr"}), 500 + payload = { + "title": movie.get("title"), + "qualityProfileId": quality_profiles[0]["id"], + "titleSlug": movie.get("titleSlug"), + "images": movie.get("images", []), + "tmdbId": movie.get("tmdbId"), + "year": movie.get("year"), + "rootFolderPath": root_folders[0]["path"], + "monitored": True, + "addOptions": {"searchForMovie": True}, + } + result = radarr_post("movie", payload) + return jsonify({"success": True, "title": result.get("title")}) + except requests.HTTPError as e: + if e.response is not None and e.response.status_code == 400: + return jsonify({"error": "Movie may already exist in Radarr"}), 409 + return jsonify({"error": str(e)}), 500 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/add-to-sonarr", methods=["POST"]) +@login_required +def add_to_sonarr(): + data = request.get_json() + title = data.get("title") + if not title: + return jsonify({"error": "No title provided"}), 400 + try: + results = sonarr_get(f"series/lookup?term={requests.utils.quote(title)}") + if not results: + return jsonify({"error": "Series not found in Sonarr lookup"}), 404 + series = results[0] + root_folders = sonarr_get("rootfolder") + quality_profiles = sonarr_get("qualityprofile") + if not root_folders or not quality_profiles: + return jsonify({"error": "No root folders or quality profiles configured in Sonarr"}), 500 + payload = { + "title": series.get("title"), + "qualityProfileId": quality_profiles[0]["id"], + "titleSlug": series.get("titleSlug"), + "images": series.get("images", []), + "tvdbId": series.get("tvdbId"), + "year": series.get("year"), + "rootFolderPath": root_folders[0]["path"], + "monitored": True, + "seasonFolder": True, + "addOptions": { + "searchForMissingEpisodes": True, + "monitor": "all", + }, + } + result = sonarr_post("series", payload) + return jsonify({"success": True, "title": result.get("title")}) + except requests.HTTPError as e: + if e.response is not None and e.response.status_code == 400: + return jsonify({"error": "Series may already exist in Sonarr"}), 409 + return jsonify({"error": str(e)}), 500 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/arr-summary/docker-compose.yml b/arr-summary/docker-compose.yml new file mode 100644 index 0000000..38a0e41 --- /dev/null +++ b/arr-summary/docker-compose.yml @@ -0,0 +1,31 @@ +services: + arr-summary: + build: . + container_name: arr-summary + ports: + - "${HOST_PORT:-5055}:5000" + environment: + # ── Login / proxy ────────────────────────────────────── + - LOGIN_PASSWORD=${LOGIN_PASSWORD} + - SECRET_KEY=${SECRET_KEY} + - SCRIPT_NAME=${SCRIPT_NAME:-} + # ── Media services ───────────────────────────────────── + - ARR_HOST=${ARR_HOST} + - SONARR_API_KEY=${SONARR_API_KEY} + - RADARR_API_KEY=${RADARR_API_KEY} + - SABNZBD_HOST=${SABNZBD_HOST:-${ARR_HOST}} + - SABNZBD_PORT=${SABNZBD_PORT:-8080} + - SABNZBD_API_KEY=${SABNZBD_API_KEY} + - TAUTULLI_HOST=${TAUTULLI_HOST:-${ARR_HOST}} + - TAUTULLI_PORT=${TAUTULLI_PORT:-8181} + - TAUTULLI_API_KEY=${TAUTULLI_API_KEY} + - PROWLARR_HOST=${PROWLARR_HOST:-${ARR_HOST}} + - PROWLARR_PORT=${PROWLARR_PORT:-9696} + - PROWLARR_API_KEY=${PROWLARR_API_KEY} + - OMBI_HOST=${OMBI_HOST:-${ARR_HOST}} + - OMBI_PORT=${OMBI_PORT:-3579} + - OMBI_API_KEY=${OMBI_API_KEY} + # ── Unraid ───────────────────────────────────────────── + - UNRAID_HOST=${UNRAID_HOST:-${ARR_HOST}} + - UNRAID_API_KEY=${UNRAID_API_KEY} + restart: unless-stopped diff --git a/arr-summary/export-to-unraid.sh b/arr-summary/export-to-unraid.sh new file mode 100755 index 0000000..5f9715a --- /dev/null +++ b/arr-summary/export-to-unraid.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# ══════════════════════════════════════════════════════════════════ +# Greendale Media Centre — Export to Unraid +# +# Usage: +# chmod +x export-to-unraid.sh +# ./export-to-unraid.sh [unraid-user@unraid-ip] [destination-path] +# +# Examples: +# ./export-to-unraid.sh root@192.168.86.10 +# ./export-to-unraid.sh root@192.168.86.10 /mnt/user/appdata/greendale +# +# What it does: +# 1. Builds the Docker image locally and saves it as a tarball +# 2. rsyncs the project files (minus secrets & cache) to Unraid +# 3. Copies the image tarball to Unraid +# 4. Loads the image on Unraid via SSH +# Leaves your .env on Unraid untouched if it already exists. +# ══════════════════════════════════════════════════════════════════ + +set -euo pipefail + +REMOTE="${1:-}" +DEST="${2:-/mnt/user/appdata/greendale}" +IMAGE_NAME="arr-summary-arr-summary" +TARBALL="/tmp/greendale-image.tar" + +if [[ -z "$REMOTE" ]]; then + echo "Usage: $0 [destination-path]" + echo "Example: $0 root@192.168.86.10 /mnt/user/appdata/greendale" + exit 1 +fi + +echo "▶ Building Docker image..." +docker compose build + +echo "▶ Saving image to tarball (this may take a moment)..." +docker save "$IMAGE_NAME" -o "$TARBALL" + +echo "▶ Syncing project files to $REMOTE:$DEST ..." +rsync -avz --progress \ + --exclude='.env' \ + --exclude='__pycache__' \ + --exclude='*.pyc' \ + --exclude='.git' \ + --exclude='nginx' \ + --exclude='export-to-unraid.sh' \ + --exclude="$(basename "$TARBALL")" \ + ./ "$REMOTE:$DEST/" + +echo "▶ Copying Docker image to Unraid..." +scp "$TARBALL" "$REMOTE:/tmp/greendale-image.tar" + +echo "▶ Loading image on Unraid..." +ssh "$REMOTE" "docker load -i /tmp/greendale-image.tar && rm /tmp/greendale-image.tar" + +echo "▶ Checking if .env exists on Unraid..." +if ssh "$REMOTE" "test -f $DEST/.env"; then + echo " ✓ .env already exists on Unraid — leaving it untouched." +else + echo " ⚠ No .env found. Copying .env.example as a starting point..." + ssh "$REMOTE" "cp $DEST/.env.example $DEST/.env" + echo " → Edit $DEST/.env on Unraid before starting the container!" +fi + +echo "" +echo "══════════════════════════════════════════════════════════" +echo " ✅ Export complete!" +echo "" +echo " On Unraid, to start:" +echo " cd $DEST" +echo " nano .env # set LOGIN_PASSWORD, SECRET_KEY, etc." +echo " docker compose up -d" +echo "" +echo " Or add via Unraid's Docker UI:" +echo " Repository: arr-summary-arr-summary (already loaded)" +echo " Port mapping: HOST_PORT (default 5055) → 5000" +echo "══════════════════════════════════════════════════════════" + +rm -f "$TARBALL" diff --git a/arr-summary/gunicorn.conf.py b/arr-summary/gunicorn.conf.py new file mode 100644 index 0000000..b276b64 --- /dev/null +++ b/arr-summary/gunicorn.conf.py @@ -0,0 +1,22 @@ +# Gunicorn configuration +workers = 2 # 2 workers × 4 threads = 8 concurrent requests; fewer cold-cache workers +threads = 4 +worker_class = "gthread" +bind = "0.0.0.0:5000" +timeout = 30 # fail fast — app has its own 5s internal deadline +keepalive = 5 + +def post_fork(server, worker): + """Pre-warm the slow caches in each worker right after fork. + This runs in the background so the worker is ready instantly.""" + import threading + def _warm(): + try: + import app as a + a.get_radarr_summary() + a.get_sonarr_summary() + a.get_tautulli_summary() + except Exception: + pass + t = threading.Thread(target=_warm, daemon=True) + t.start() diff --git a/arr-summary/nginx/greendale-subdomain.conf b/arr-summary/nginx/greendale-subdomain.conf new file mode 100644 index 0000000..e876335 --- /dev/null +++ b/arr-summary/nginx/greendale-subdomain.conf @@ -0,0 +1,68 @@ +# ───────────────────────────────────────────────────────────── +# Greendale Media Centre — Nginx Reverse Proxy Config +# SUBDOMAIN variant: https://media.yourdomain.com +# +# 1. Copy this file to /etc/nginx/conf.d/ (or your Nginx proxy +# manager's custom config directory) on your Unraid server. +# 2. Replace "media.yourdomain.com" with your actual domain. +# 3. Replace "YOUR_UNRAID_IP" with your Unraid server's LAN IP. +# 4. Replace "5055" if you changed HOST_PORT in .env. +# 5. SSL is handled by Nginx Proxy Manager (recommended on Unraid) +# — point it at this container on port 5055. +# ───────────────────────────────────────────────────────────── + +server { + listen 80; + server_name media.yourdomain.com; + + # Redirect all HTTP → HTTPS + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name media.yourdomain.com; + + # ── SSL certificates (managed by NPM or Certbot) ── + # ssl_certificate /etc/letsencrypt/live/media.yourdomain.com/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/media.yourdomain.com/privkey.pem; + + # ── Security headers ── + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin" always; + add_header Strict-Transport-Security "max-age=31536000" always; + + # ── Proxy to Flask container ── + location / { + proxy_pass http://YOUR_UNRAID_IP:5055; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support (future-proof) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_read_timeout 90; + proxy_connect_timeout 90; + proxy_send_timeout 90; + + # Buffer settings for smooth streaming + proxy_buffering on; + proxy_buffer_size 8k; + proxy_buffers 8 8k; + } + + # ── Static asset caching ── + location ~* \.(css|js|png|jpg|ico|woff2?)$ { + proxy_pass http://YOUR_UNRAID_IP:5055; + proxy_set_header Host $host; + expires 7d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/arr-summary/nginx/greendale-subpath.conf b/arr-summary/nginx/greendale-subpath.conf new file mode 100644 index 0000000..1fb421a --- /dev/null +++ b/arr-summary/nginx/greendale-subpath.conf @@ -0,0 +1,34 @@ +# ───────────────────────────────────────────────────────────── +# Greendale Media Centre — Nginx Reverse Proxy Config +# SUBPATH variant: https://yourdomain.com/media/ +# +# Use this if you want to host under a path on an existing domain +# instead of a dedicated subdomain. +# +# NOTE: The Flask app already serves from "/" — Nginx strips the +# /media prefix and the app sees clean paths. +# ───────────────────────────────────────────────────────────── + +server { + listen 443 ssl http2; + server_name yourdomain.com; + + # ... your existing SSL/other config above ... + + # ── Proxy to Greendale Media Centre ── + location /media/ { + proxy_pass http://YOUR_UNRAID_IP:5055/; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_redirect off; + proxy_read_timeout 90; + + # Required so cookies set at "/" work on the /media/ sub-path + proxy_cookie_path / /media/; + } +} diff --git a/arr-summary/requirements.txt b/arr-summary/requirements.txt new file mode 100644 index 0000000..27e444b --- /dev/null +++ b/arr-summary/requirements.txt @@ -0,0 +1,3 @@ +flask==3.1.0 +requests==2.32.3 +gunicorn==23.0.0 diff --git a/arr-summary/static/favicon.ico b/arr-summary/static/favicon.ico new file mode 100644 index 0000000..43b7e12 Binary files /dev/null and b/arr-summary/static/favicon.ico differ diff --git a/arr-summary/static/favicon.png b/arr-summary/static/favicon.png new file mode 100644 index 0000000..f34089b Binary files /dev/null and b/arr-summary/static/favicon.png differ diff --git a/arr-summary/static/logo.png b/arr-summary/static/logo.png new file mode 100644 index 0000000..1e839e4 Binary files /dev/null and b/arr-summary/static/logo.png differ diff --git a/arr-summary/templates/dashboard.html b/arr-summary/templates/dashboard.html new file mode 100644 index 0000000..46e2979 --- /dev/null +++ b/arr-summary/templates/dashboard.html @@ -0,0 +1,1964 @@ + + + + + + {% if theme == 'office' %}Dunder Mifflin Media Centre + {% elif theme == 'parks' %}Pawnee Parks Media Centre + {% elif theme == 'potter' %}Hogwarts Media Vault + {% elif theme == 'starwars' %}Rebel Alliance Media Archive + {% else %}Greendale Media Centre{% endif %} + {% if theme == 'office' %} + {% elif theme == 'parks' %} + {% elif theme == 'potter' %} + {% elif theme == 'starwars' %} + {% else %}{% endif %} + + + {% if theme == 'office' %} + + {% elif theme == 'parks' %} + + {% elif theme == 'potter' %} + + {% elif theme == 'starwars' %} + + {% else %} + + {% endif %} + + + + +
+
+ {% if theme == 'office' %} +
📎
+ {% elif theme == 'parks' %} +
🌳
+ {% elif theme == 'potter' %} +
+ {% elif theme == 'starwars' %} +
+ + + + + + + + + + + +
+ {% else %} + Greendale + {% endif %} +
+ {% if theme == 'office' %} +

Dunder Mifflin Media Centre

+
That's what she watched.
+ {% elif theme == 'parks' %} +

Pawnee Parks Media Centre

+
The greatest city in the world.
+ {% elif theme == 'potter' %} +

Hogwarts Media Vault

+
Mischief managed.
+ {% elif theme == 'starwars' %} +

Rebel Alliance Media Archive

+
May the downloads be with you.
+ {% else %} +

Greendale Media Centre

+
Human beings welcome. Robots too.
+ {% endif %} +
+
+
+ + + {% set up = sonarr.status == "ok" %} + + + + Sonarr + + {%- if up %}{{ sonarr.total_series }} series{% else %}offline{% endif -%} + + + + + + {% set up = radarr.status == "ok" %} + + + + Radarr + + {%- if up %}{{ radarr.total_movies }} movies{% else %}offline{% endif -%} + + + + + + {% set up = prowlarr.status == "ok" %} + + + + Prowlarr + + {%- if up %}{{ prowlarr.up_count }}/{{ prowlarr.total }} online{% else %}offline{% endif -%} + + + + + + {% set up = ombi.status == "ok" %} + + + + Ombi + + {%- if up %}{{ ombi.total_pending }} pending{% else %}offline{% endif -%} + + + + + + {% set up = sabnzbd.status == "ok" %} + + + + SABnzbd + + {%- if up %}{{ sabnzbd.speed }}{% else %}offline{% endif -%} + + + + + + + {% set up = tautulli.status == "ok" %} + + + + Tautulli + + {%- if up -%} + {%- if tautulli.active_streams > 0 -%} + {{ tautulli.active_streams }} streaming + {%- else -%} + 0 streams + {%- endif -%} + {%- else -%}offline{%- endif -%} + + + + + + +
+
+
+
+ {% if theme == 'office' %}Dunder Mifflin HQ + {% elif theme == 'parks' %}Pawnee City Hall + {% elif theme == 'potter' %}Hogwarts Great Hall + {% elif theme == 'starwars' %}Death Star Control Room + {% else %}Study Room F{% endif %} +  ·  ⚙ Settings +
+
+
+ +
+ +
+ {% if theme == 'office' %} + 📋  Monday morning meeting: checking downloads, missing episodes, and who took the last of the coffee. + {% elif theme == 'parks' %} + 📋  Today's agenda: checking what's missing, what's downloading, and making Pawnee great again. + {% elif theme == 'potter' %} + 📋  Today's scroll: checking what spells are missing, what's being conjured, and what deserves a watch. + {% elif theme == 'starwars' %} + 📋  Rebel briefing: checking what's missing, what's downloading, and what the Force recommends watching. + {% else %} + 📋  Today's agenda: checking what's missing, what's downloading, and what deserves to be watched. + {% endif %} +
+ + +
+
+ 🖥️ +

Unraid

+ {% if unraid.status == "ok" %} + + {% if unraid.array_state %}{{ unraid.array_state | capitalize }}{% endif %} + {% if unraid.disk_count %} ·  {{ unraid.disk_count }} disk{{ 's' if unraid.disk_count != 1 }}{% endif %} + {% if unraid.os_version %} ·  v{{ unraid.os_version }}{% endif %} + + {% endif %} +
+
+ {% if unraid.status == "ok" %} +
+
+
Array Total
+
{{ unraid.array_total }}
+
+
+
Array Used
+
{{ unraid.array_used }}
+
+
+
Array Free
+
{{ unraid.array_free }}
+
+
+ +
+
+ Array Usage + {{ unraid.array_used_pct }}% +
+
+
+
+
+ {% if unraid.source == "local" %} +
+ ⓘ Showing container disk usage ({{ unraid.mount }}). Add UNRAID_API_KEY for full Unraid stats. +
+ {% endif %} + {% else %} +
Could not load system stats: {{ unraid.error }}
+ {% endif %} +
+
+ + +
+
+ 📺 +

Sonarr

+
+
+ +
+
+
+
+
+ +
+ + {% if sonarr.status == "ok" %} +
+
+
Total Series
+
{{ sonarr.total_series }}
+
+
+
Missing Episodes
+
{{ sonarr.missing_episodes }}
+
+
+ {% else %} +
Could not reach Sonarr: {{ sonarr.error }}
+ {% endif %} + +
🍅 Popular on RT
+ {% if rt_tv and rt_tv[0] is defined and rt_tv[0].error is defined %} +
Failed to load TV data: {{ rt_tv[0].error }}
+ {% elif rt_tv %} +
+ {% for show in rt_tv %} +
+
+ {{ show.title }}
+ {{ show.description[:180] if show.description else '' }}{% if show.description and show.description|length > 180 %}…{% endif %} +
+
{{ loop.index }}
+ {% if show.poster %} + {% else %}
{% endif %} +
+
{{ show.title }}{% if show.year %} ({{ show.year }}){% endif %}
+
+ {% if show.certified %}✅ Certified Fresh + {% elif show.critics_score %}🍅 {{ show.critics_score }}%{% endif %} + {% if show.audience_score %}🍿 {{ show.audience_score }}{% endif %} +
+
+
+ +
+
+ {% endfor %} +
+ {% else %} +
No TV data available.
+ {% endif %} +
+
+ + +
+
+ 🎬 +

Radarr

+
+
+ +
+
+
+
+
+ +
+ + {% if radarr.status == "ok" %} +
+
+
Total Movies
+
{{ radarr.total_movies }}
+
+
+
Missing
+
{{ radarr.missing_movies }}
+
+
+ {% else %} +
Could not reach Radarr: {{ radarr.error }}
+ {% endif %} + +
🍅 Top Box Office on RT
+ {% if rt_movies and rt_movies[0] is defined and rt_movies[0].error is defined %} +
Failed to load movie data: {{ rt_movies[0].error }}
+ {% elif rt_movies %} +
+ {% for movie in rt_movies %} +
+
+ {{ movie.title }}
+ {{ movie.description[:180] if movie.description else '' }}{% if movie.description and movie.description|length > 180 %}…{% endif %} +
+
{{ loop.index }}
+ {% if movie.poster %} + {% else %}
{% endif %} +
+
{{ movie.title }}{% if movie.year %} ({{ movie.year }}){% endif %}
+
+ {% if movie.certified %}✅ Certified Fresh + {% elif movie.critics_score %}🍅 {{ movie.critics_score }}%{% endif %} + {% if movie.audience_score %}🍿 {{ movie.audience_score }}{% endif %} +
+
+
+ +
+
+ {% endfor %} +
+ {% else %} +
No movie data available.
+ {% endif %} +
+
+ + +
+
+ 🔍 +

Prowlarr — Indexers

+ {% if prowlarr.status == "ok" %} + + {{ prowlarr.up_count }}/{{ prowlarr.total }} online + + {% endif %} +
+
+ {% if prowlarr.status == "ok" %} +
+ {% for idx in prowlarr.indexers %} +
+ + {{ idx.name }} +
+ {% endfor %} +
+ {% else %} +
Could not reach Prowlarr: {{ prowlarr.error }}
+ {% endif %} +
+
+ + +
+
+ ⬇️ +

SABnzbd

+ {% if sabnzbd.status == "ok" %} + {% set s = sabnzbd.sab_status | lower %} + + {{ sabnzbd.sab_status }} + + {% endif %} +
+
+ {% if sabnzbd.status == "ok" %} +
+
+
Current Speed
+
{{ sabnzbd.speed }}
+
+
+
Avg Speed (7d)
+
{{ sabnzbd.avg_speed }}
+
+
+
Queue Items
+
{{ sabnzbd.queue_count }}
+
+
+
Remaining
+
{{ sabnzbd.sizeleft }}
+
+
+
Downloads (7d)
+
{{ sabnzbd.completed_7d }}
+
+
+
Downloaded (7d)
+
{{ sabnzbd.week_size }}
+
+
+ + + {% else %} +
Could not reach SABnzbd: {{ sabnzbd.error }}
+ {% endif %} +
+
+ + +
+
+ 🎟️ +

Ombi — Requests

+ {% if ombi.status == "ok" %} +
+ {{ ombi.total_requests }} total + {{ ombi.total_pending }} pending +
+ {% endif %} +
+
+ {% if ombi.status == "ok" %} + {% if ombi.recent %} +
+ {% for req in ombi.recent %} +
+ {{ req.type }} +
+
{{ req.title }}
+
{{ req.user }}  ·  {{ req.date_display }}
+
+ {{ req.status }} +
+ {% endfor %} +
+ {% else %} +
No recent requests.
+ {% endif %} + {% else %} +
Could not reach Ombi: {{ ombi.error }}
+ {% endif %} +
+
+ + +
+
+ ▶️ +

Tautulli

+ {% if tautulli.status == "ok" %} +
+ {% if tautulli.active_streams > 0 %} + + {{ tautulli.active_streams }} active stream{{ 's' if tautulli.active_streams != 1 }} + {% else %} + No active streams + {% endif %} +
+ {% endif %} +
+
+ {% if tautulli.status == "ok" %} +
+ + +
+
+
📺
+
Top TV — 30d
+
+ {% if tautulli.top_tv_list %} +
+ {% for item in tautulli.top_tv_list %} +
+
{{ loop.index }}
+
{{ item.title }}
+
{{ item.plays }} plays
+
+ {% endfor %} +
+ {% else %} +
No data yet
+ {% endif %} +
+ + +
+
+
🎬
+
Top Movies — 30d
+
+ {% if tautulli.top_movie_list %} +
+ {% for item in tautulli.top_movie_list %} +
+
{{ loop.index }}
+
{{ item.title }}
+
{{ item.plays }} plays
+
+ {% endfor %} +
+ {% else %} +
No data yet
+ {% endif %} +
+ +
+ + +
+ + +
+
🏆 Top Played (30d)
+
+ {% if tautulli.top_played %} + {% for item in tautulli.top_played %} +
+
{{ loop.index }}
+
+
{{ item.title }}
+
by {{ item.user }}
+
+ {% set mt = item.media_type | lower %} + + {{ 'TV' if mt == 'episode' else ('Movie' if mt == 'movie' else mt or '?') }} + +
{{ item.plays }}×
+
+ {% endfor %} + {% else %} +
No play history available.
+ {% endif %} +
+
+ + +
+
👤 Top Viewers (30d)
+
+ {% if tautulli.top_users %} + {% for u in tautulli.top_users %} +
+
{{ loop.index }}
+
👤
+
+
{{ u.username }}
+
{{ u.plays }} plays
+
+
{{ u.plays }}×
+
+ {% endfor %} + {% else %} +
No viewer data available.
+ {% endif %} +
+
+ +
+ + {% else %} +
Could not reach Tautulli: {{ tautulli.error }}
+ {% endif %} +
+
+ + +
+ +
+ {% if theme == 'office' %} + "I'm not superstitious, but I am a little stitious."  —  Michael Scott  |  Dunder Mifflin Media Centre + {% elif theme == 'parks' %} + "If you believe it, you can achieve it."  —  Leslie Knope  |  Pawnee Parks Media Centre + {% elif theme == 'potter' %} + "It does not do to dwell on dreams and forget to live."  —  Albus Dumbledore  |  Hogwarts Media Vault + {% elif theme == 'starwars' %} + "Do. Or do not. There is no try."  —  Yoda  |  Rebel Alliance Media Archive + {% else %} + "I discovered at a young age that I have... a gift."  —  Jeff Winger  |  Greendale Human Being Media Centre + {% endif %} +
+ + + + + diff --git a/arr-summary/templates/login.html b/arr-summary/templates/login.html new file mode 100644 index 0000000..990b9a3 --- /dev/null +++ b/arr-summary/templates/login.html @@ -0,0 +1,203 @@ + + + + + + Greendale Media Centre — Login + + + + + + + + + + diff --git a/arr-summary/templates/settings.html b/arr-summary/templates/settings.html new file mode 100644 index 0000000..1b8bdc0 --- /dev/null +++ b/arr-summary/templates/settings.html @@ -0,0 +1,486 @@ + + + + + + {% if theme == 'office' %}Settings — Dunder Mifflin Media Centre + {% elif theme == 'parks' %}Settings — Pawnee Parks Media Centre + {% elif theme == 'potter' %}Settings — Hogwarts Media Vault + {% elif theme == 'starwars' %}Settings — Rebel Alliance Media Archive + {% else %}Settings — Greendale Media Centre{% endif %} + {% if theme == 'office' %} + {% elif theme == 'parks' %} + {% elif theme == 'potter' %} + {% elif theme == 'starwars' %} + {% else %}{% endif %} + {% if theme == 'office' %} + + {% elif theme == 'parks' %} + + {% elif theme == 'potter' %} + + {% elif theme == 'starwars' %} + + {% else %} + + {% endif %} + + + + +
+ +

Settings

+ ← Dashboard +
+ +
+
+ + +
+
+ 🌍 +

General

+
+
+
+ + +
+
+ + +
+ +
+
+ + +
+
+ 🎨 +

Theme

+
+
+
+
+
🏫
+
Community
+
Greendale College
+
+
+
📎
+
The Office
+
Dunder Mifflin
+
+
+
🌳
+
Parks & Rec
+
Pawnee, Indiana
+
+
+
+
Harry Potter
+
Hogwarts
+
+
+
🌌
+
Star Wars
+
A long time ago…
+
+
+ +
+
+ + +
+
+ 🔐 +

Change Password

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
📺

Sonarr

+
+
+ + +
+ +
+
+ + +
+
🎬

Radarr

+
+
+ + +
+ +
+
+ + +
+
⬇️

SABnzbd

+
+
+ + +
+ +
+
+ + +
+
▶️

Tautulli

+
+
+ + +
+ +
+
+ + +
+
🔍

Prowlarr

+
+
+ + +
+ +
+
+ + +
+
🎟️

Ombi

+
+
+ + +
+ +
+
+ +
+
+ +
✓ Saved
+ + + + + diff --git a/dashboard.html b/dashboard.html new file mode 100644 index 0000000..46e2979 --- /dev/null +++ b/dashboard.html @@ -0,0 +1,1964 @@ + + + + + + {% if theme == 'office' %}Dunder Mifflin Media Centre + {% elif theme == 'parks' %}Pawnee Parks Media Centre + {% elif theme == 'potter' %}Hogwarts Media Vault + {% elif theme == 'starwars' %}Rebel Alliance Media Archive + {% else %}Greendale Media Centre{% endif %} + {% if theme == 'office' %} + {% elif theme == 'parks' %} + {% elif theme == 'potter' %} + {% elif theme == 'starwars' %} + {% else %}{% endif %} + + + {% if theme == 'office' %} + + {% elif theme == 'parks' %} + + {% elif theme == 'potter' %} + + {% elif theme == 'starwars' %} + + {% else %} + + {% endif %} + + + + +
+
+ {% if theme == 'office' %} +
📎
+ {% elif theme == 'parks' %} +
🌳
+ {% elif theme == 'potter' %} +
+ {% elif theme == 'starwars' %} +
+ + + + + + + + + + + +
+ {% else %} + Greendale + {% endif %} +
+ {% if theme == 'office' %} +

Dunder Mifflin Media Centre

+
That's what she watched.
+ {% elif theme == 'parks' %} +

Pawnee Parks Media Centre

+
The greatest city in the world.
+ {% elif theme == 'potter' %} +

Hogwarts Media Vault

+
Mischief managed.
+ {% elif theme == 'starwars' %} +

Rebel Alliance Media Archive

+
May the downloads be with you.
+ {% else %} +

Greendale Media Centre

+
Human beings welcome. Robots too.
+ {% endif %} +
+
+
+ + + {% set up = sonarr.status == "ok" %} + + + + Sonarr + + {%- if up %}{{ sonarr.total_series }} series{% else %}offline{% endif -%} + + + + + + {% set up = radarr.status == "ok" %} + + + + Radarr + + {%- if up %}{{ radarr.total_movies }} movies{% else %}offline{% endif -%} + + + + + + {% set up = prowlarr.status == "ok" %} + + + + Prowlarr + + {%- if up %}{{ prowlarr.up_count }}/{{ prowlarr.total }} online{% else %}offline{% endif -%} + + + + + + {% set up = ombi.status == "ok" %} + + + + Ombi + + {%- if up %}{{ ombi.total_pending }} pending{% else %}offline{% endif -%} + + + + + + {% set up = sabnzbd.status == "ok" %} + + + + SABnzbd + + {%- if up %}{{ sabnzbd.speed }}{% else %}offline{% endif -%} + + + + + + + {% set up = tautulli.status == "ok" %} + + + + Tautulli + + {%- if up -%} + {%- if tautulli.active_streams > 0 -%} + {{ tautulli.active_streams }} streaming + {%- else -%} + 0 streams + {%- endif -%} + {%- else -%}offline{%- endif -%} + + + + + + +
+
+
+
+ {% if theme == 'office' %}Dunder Mifflin HQ + {% elif theme == 'parks' %}Pawnee City Hall + {% elif theme == 'potter' %}Hogwarts Great Hall + {% elif theme == 'starwars' %}Death Star Control Room + {% else %}Study Room F{% endif %} +  ·  ⚙ Settings +
+
+
+ +
+ +
+ {% if theme == 'office' %} + 📋  Monday morning meeting: checking downloads, missing episodes, and who took the last of the coffee. + {% elif theme == 'parks' %} + 📋  Today's agenda: checking what's missing, what's downloading, and making Pawnee great again. + {% elif theme == 'potter' %} + 📋  Today's scroll: checking what spells are missing, what's being conjured, and what deserves a watch. + {% elif theme == 'starwars' %} + 📋  Rebel briefing: checking what's missing, what's downloading, and what the Force recommends watching. + {% else %} + 📋  Today's agenda: checking what's missing, what's downloading, and what deserves to be watched. + {% endif %} +
+ + +
+
+ 🖥️ +

Unraid

+ {% if unraid.status == "ok" %} + + {% if unraid.array_state %}{{ unraid.array_state | capitalize }}{% endif %} + {% if unraid.disk_count %} ·  {{ unraid.disk_count }} disk{{ 's' if unraid.disk_count != 1 }}{% endif %} + {% if unraid.os_version %} ·  v{{ unraid.os_version }}{% endif %} + + {% endif %} +
+
+ {% if unraid.status == "ok" %} +
+
+
Array Total
+
{{ unraid.array_total }}
+
+
+
Array Used
+
{{ unraid.array_used }}
+
+
+
Array Free
+
{{ unraid.array_free }}
+
+
+ +
+
+ Array Usage + {{ unraid.array_used_pct }}% +
+
+
+
+
+ {% if unraid.source == "local" %} +
+ ⓘ Showing container disk usage ({{ unraid.mount }}). Add UNRAID_API_KEY for full Unraid stats. +
+ {% endif %} + {% else %} +
Could not load system stats: {{ unraid.error }}
+ {% endif %} +
+
+ + +
+
+ 📺 +

Sonarr

+
+
+ +
+
+
+
+
+ +
+ + {% if sonarr.status == "ok" %} +
+
+
Total Series
+
{{ sonarr.total_series }}
+
+
+
Missing Episodes
+
{{ sonarr.missing_episodes }}
+
+
+ {% else %} +
Could not reach Sonarr: {{ sonarr.error }}
+ {% endif %} + +
🍅 Popular on RT
+ {% if rt_tv and rt_tv[0] is defined and rt_tv[0].error is defined %} +
Failed to load TV data: {{ rt_tv[0].error }}
+ {% elif rt_tv %} +
+ {% for show in rt_tv %} +
+
+ {{ show.title }}
+ {{ show.description[:180] if show.description else '' }}{% if show.description and show.description|length > 180 %}…{% endif %} +
+
{{ loop.index }}
+ {% if show.poster %} + {% else %}
{% endif %} +
+
{{ show.title }}{% if show.year %} ({{ show.year }}){% endif %}
+
+ {% if show.certified %}✅ Certified Fresh + {% elif show.critics_score %}🍅 {{ show.critics_score }}%{% endif %} + {% if show.audience_score %}🍿 {{ show.audience_score }}{% endif %} +
+
+
+ +
+
+ {% endfor %} +
+ {% else %} +
No TV data available.
+ {% endif %} +
+
+ + +
+
+ 🎬 +

Radarr

+
+
+ +
+
+
+
+
+ +
+ + {% if radarr.status == "ok" %} +
+
+
Total Movies
+
{{ radarr.total_movies }}
+
+
+
Missing
+
{{ radarr.missing_movies }}
+
+
+ {% else %} +
Could not reach Radarr: {{ radarr.error }}
+ {% endif %} + +
🍅 Top Box Office on RT
+ {% if rt_movies and rt_movies[0] is defined and rt_movies[0].error is defined %} +
Failed to load movie data: {{ rt_movies[0].error }}
+ {% elif rt_movies %} +
+ {% for movie in rt_movies %} +
+
+ {{ movie.title }}
+ {{ movie.description[:180] if movie.description else '' }}{% if movie.description and movie.description|length > 180 %}…{% endif %} +
+
{{ loop.index }}
+ {% if movie.poster %} + {% else %}
{% endif %} +
+
{{ movie.title }}{% if movie.year %} ({{ movie.year }}){% endif %}
+
+ {% if movie.certified %}✅ Certified Fresh + {% elif movie.critics_score %}🍅 {{ movie.critics_score }}%{% endif %} + {% if movie.audience_score %}🍿 {{ movie.audience_score }}{% endif %} +
+
+
+ +
+
+ {% endfor %} +
+ {% else %} +
No movie data available.
+ {% endif %} +
+
+ + +
+
+ 🔍 +

Prowlarr — Indexers

+ {% if prowlarr.status == "ok" %} + + {{ prowlarr.up_count }}/{{ prowlarr.total }} online + + {% endif %} +
+
+ {% if prowlarr.status == "ok" %} +
+ {% for idx in prowlarr.indexers %} +
+ + {{ idx.name }} +
+ {% endfor %} +
+ {% else %} +
Could not reach Prowlarr: {{ prowlarr.error }}
+ {% endif %} +
+
+ + +
+
+ ⬇️ +

SABnzbd

+ {% if sabnzbd.status == "ok" %} + {% set s = sabnzbd.sab_status | lower %} + + {{ sabnzbd.sab_status }} + + {% endif %} +
+
+ {% if sabnzbd.status == "ok" %} +
+
+
Current Speed
+
{{ sabnzbd.speed }}
+
+
+
Avg Speed (7d)
+
{{ sabnzbd.avg_speed }}
+
+
+
Queue Items
+
{{ sabnzbd.queue_count }}
+
+
+
Remaining
+
{{ sabnzbd.sizeleft }}
+
+
+
Downloads (7d)
+
{{ sabnzbd.completed_7d }}
+
+
+
Downloaded (7d)
+
{{ sabnzbd.week_size }}
+
+
+ + + {% else %} +
Could not reach SABnzbd: {{ sabnzbd.error }}
+ {% endif %} +
+
+ + +
+
+ 🎟️ +

Ombi — Requests

+ {% if ombi.status == "ok" %} +
+ {{ ombi.total_requests }} total + {{ ombi.total_pending }} pending +
+ {% endif %} +
+
+ {% if ombi.status == "ok" %} + {% if ombi.recent %} +
+ {% for req in ombi.recent %} +
+ {{ req.type }} +
+
{{ req.title }}
+
{{ req.user }}  ·  {{ req.date_display }}
+
+ {{ req.status }} +
+ {% endfor %} +
+ {% else %} +
No recent requests.
+ {% endif %} + {% else %} +
Could not reach Ombi: {{ ombi.error }}
+ {% endif %} +
+
+ + +
+
+ ▶️ +

Tautulli

+ {% if tautulli.status == "ok" %} +
+ {% if tautulli.active_streams > 0 %} + + {{ tautulli.active_streams }} active stream{{ 's' if tautulli.active_streams != 1 }} + {% else %} + No active streams + {% endif %} +
+ {% endif %} +
+
+ {% if tautulli.status == "ok" %} +
+ + +
+
+
📺
+
Top TV — 30d
+
+ {% if tautulli.top_tv_list %} +
+ {% for item in tautulli.top_tv_list %} +
+
{{ loop.index }}
+
{{ item.title }}
+
{{ item.plays }} plays
+
+ {% endfor %} +
+ {% else %} +
No data yet
+ {% endif %} +
+ + +
+
+
🎬
+
Top Movies — 30d
+
+ {% if tautulli.top_movie_list %} +
+ {% for item in tautulli.top_movie_list %} +
+
{{ loop.index }}
+
{{ item.title }}
+
{{ item.plays }} plays
+
+ {% endfor %} +
+ {% else %} +
No data yet
+ {% endif %} +
+ +
+ + +
+ + +
+
🏆 Top Played (30d)
+
+ {% if tautulli.top_played %} + {% for item in tautulli.top_played %} +
+
{{ loop.index }}
+
+
{{ item.title }}
+
by {{ item.user }}
+
+ {% set mt = item.media_type | lower %} + + {{ 'TV' if mt == 'episode' else ('Movie' if mt == 'movie' else mt or '?') }} + +
{{ item.plays }}×
+
+ {% endfor %} + {% else %} +
No play history available.
+ {% endif %} +
+
+ + +
+
👤 Top Viewers (30d)
+
+ {% if tautulli.top_users %} + {% for u in tautulli.top_users %} +
+
{{ loop.index }}
+
👤
+
+
{{ u.username }}
+
{{ u.plays }} plays
+
+
{{ u.plays }}×
+
+ {% endfor %} + {% else %} +
No viewer data available.
+ {% endif %} +
+
+ +
+ + {% else %} +
Could not reach Tautulli: {{ tautulli.error }}
+ {% endif %} +
+
+ + +
+ +
+ {% if theme == 'office' %} + "I'm not superstitious, but I am a little stitious."  —  Michael Scott  |  Dunder Mifflin Media Centre + {% elif theme == 'parks' %} + "If you believe it, you can achieve it."  —  Leslie Knope  |  Pawnee Parks Media Centre + {% elif theme == 'potter' %} + "It does not do to dwell on dreams and forget to live."  —  Albus Dumbledore  |  Hogwarts Media Vault + {% elif theme == 'starwars' %} + "Do. Or do not. There is no try."  —  Yoda  |  Rebel Alliance Media Archive + {% else %} + "I discovered at a young age that I have... a gift."  —  Jeff Winger  |  Greendale Human Being Media Centre + {% endif %} +
+ + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..38a0e41 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + arr-summary: + build: . + container_name: arr-summary + ports: + - "${HOST_PORT:-5055}:5000" + environment: + # ── Login / proxy ────────────────────────────────────── + - LOGIN_PASSWORD=${LOGIN_PASSWORD} + - SECRET_KEY=${SECRET_KEY} + - SCRIPT_NAME=${SCRIPT_NAME:-} + # ── Media services ───────────────────────────────────── + - ARR_HOST=${ARR_HOST} + - SONARR_API_KEY=${SONARR_API_KEY} + - RADARR_API_KEY=${RADARR_API_KEY} + - SABNZBD_HOST=${SABNZBD_HOST:-${ARR_HOST}} + - SABNZBD_PORT=${SABNZBD_PORT:-8080} + - SABNZBD_API_KEY=${SABNZBD_API_KEY} + - TAUTULLI_HOST=${TAUTULLI_HOST:-${ARR_HOST}} + - TAUTULLI_PORT=${TAUTULLI_PORT:-8181} + - TAUTULLI_API_KEY=${TAUTULLI_API_KEY} + - PROWLARR_HOST=${PROWLARR_HOST:-${ARR_HOST}} + - PROWLARR_PORT=${PROWLARR_PORT:-9696} + - PROWLARR_API_KEY=${PROWLARR_API_KEY} + - OMBI_HOST=${OMBI_HOST:-${ARR_HOST}} + - OMBI_PORT=${OMBI_PORT:-3579} + - OMBI_API_KEY=${OMBI_API_KEY} + # ── Unraid ───────────────────────────────────────────── + - UNRAID_HOST=${UNRAID_HOST:-${ARR_HOST}} + - UNRAID_API_KEY=${UNRAID_API_KEY} + restart: unless-stopped diff --git a/export-to-unraid.sh b/export-to-unraid.sh new file mode 100755 index 0000000..5f9715a --- /dev/null +++ b/export-to-unraid.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# ══════════════════════════════════════════════════════════════════ +# Greendale Media Centre — Export to Unraid +# +# Usage: +# chmod +x export-to-unraid.sh +# ./export-to-unraid.sh [unraid-user@unraid-ip] [destination-path] +# +# Examples: +# ./export-to-unraid.sh root@192.168.86.10 +# ./export-to-unraid.sh root@192.168.86.10 /mnt/user/appdata/greendale +# +# What it does: +# 1. Builds the Docker image locally and saves it as a tarball +# 2. rsyncs the project files (minus secrets & cache) to Unraid +# 3. Copies the image tarball to Unraid +# 4. Loads the image on Unraid via SSH +# Leaves your .env on Unraid untouched if it already exists. +# ══════════════════════════════════════════════════════════════════ + +set -euo pipefail + +REMOTE="${1:-}" +DEST="${2:-/mnt/user/appdata/greendale}" +IMAGE_NAME="arr-summary-arr-summary" +TARBALL="/tmp/greendale-image.tar" + +if [[ -z "$REMOTE" ]]; then + echo "Usage: $0 [destination-path]" + echo "Example: $0 root@192.168.86.10 /mnt/user/appdata/greendale" + exit 1 +fi + +echo "▶ Building Docker image..." +docker compose build + +echo "▶ Saving image to tarball (this may take a moment)..." +docker save "$IMAGE_NAME" -o "$TARBALL" + +echo "▶ Syncing project files to $REMOTE:$DEST ..." +rsync -avz --progress \ + --exclude='.env' \ + --exclude='__pycache__' \ + --exclude='*.pyc' \ + --exclude='.git' \ + --exclude='nginx' \ + --exclude='export-to-unraid.sh' \ + --exclude="$(basename "$TARBALL")" \ + ./ "$REMOTE:$DEST/" + +echo "▶ Copying Docker image to Unraid..." +scp "$TARBALL" "$REMOTE:/tmp/greendale-image.tar" + +echo "▶ Loading image on Unraid..." +ssh "$REMOTE" "docker load -i /tmp/greendale-image.tar && rm /tmp/greendale-image.tar" + +echo "▶ Checking if .env exists on Unraid..." +if ssh "$REMOTE" "test -f $DEST/.env"; then + echo " ✓ .env already exists on Unraid — leaving it untouched." +else + echo " ⚠ No .env found. Copying .env.example as a starting point..." + ssh "$REMOTE" "cp $DEST/.env.example $DEST/.env" + echo " → Edit $DEST/.env on Unraid before starting the container!" +fi + +echo "" +echo "══════════════════════════════════════════════════════════" +echo " ✅ Export complete!" +echo "" +echo " On Unraid, to start:" +echo " cd $DEST" +echo " nano .env # set LOGIN_PASSWORD, SECRET_KEY, etc." +echo " docker compose up -d" +echo "" +echo " Or add via Unraid's Docker UI:" +echo " Repository: arr-summary-arr-summary (already loaded)" +echo " Port mapping: HOST_PORT (default 5055) → 5000" +echo "══════════════════════════════════════════════════════════" + +rm -f "$TARBALL" diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..b276b64 --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,22 @@ +# Gunicorn configuration +workers = 2 # 2 workers × 4 threads = 8 concurrent requests; fewer cold-cache workers +threads = 4 +worker_class = "gthread" +bind = "0.0.0.0:5000" +timeout = 30 # fail fast — app has its own 5s internal deadline +keepalive = 5 + +def post_fork(server, worker): + """Pre-warm the slow caches in each worker right after fork. + This runs in the background so the worker is ready instantly.""" + import threading + def _warm(): + try: + import app as a + a.get_radarr_summary() + a.get_sonarr_summary() + a.get_tautulli_summary() + except Exception: + pass + t = threading.Thread(target=_warm, daemon=True) + t.start() diff --git a/login.html b/login.html new file mode 100644 index 0000000..990b9a3 --- /dev/null +++ b/login.html @@ -0,0 +1,203 @@ + + + + + + Greendale Media Centre — Login + + + + + + + + + + diff --git a/nginx/greendale-subdomain.conf b/nginx/greendale-subdomain.conf new file mode 100644 index 0000000..e876335 --- /dev/null +++ b/nginx/greendale-subdomain.conf @@ -0,0 +1,68 @@ +# ───────────────────────────────────────────────────────────── +# Greendale Media Centre — Nginx Reverse Proxy Config +# SUBDOMAIN variant: https://media.yourdomain.com +# +# 1. Copy this file to /etc/nginx/conf.d/ (or your Nginx proxy +# manager's custom config directory) on your Unraid server. +# 2. Replace "media.yourdomain.com" with your actual domain. +# 3. Replace "YOUR_UNRAID_IP" with your Unraid server's LAN IP. +# 4. Replace "5055" if you changed HOST_PORT in .env. +# 5. SSL is handled by Nginx Proxy Manager (recommended on Unraid) +# — point it at this container on port 5055. +# ───────────────────────────────────────────────────────────── + +server { + listen 80; + server_name media.yourdomain.com; + + # Redirect all HTTP → HTTPS + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name media.yourdomain.com; + + # ── SSL certificates (managed by NPM or Certbot) ── + # ssl_certificate /etc/letsencrypt/live/media.yourdomain.com/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/media.yourdomain.com/privkey.pem; + + # ── Security headers ── + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin" always; + add_header Strict-Transport-Security "max-age=31536000" always; + + # ── Proxy to Flask container ── + location / { + proxy_pass http://YOUR_UNRAID_IP:5055; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support (future-proof) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_read_timeout 90; + proxy_connect_timeout 90; + proxy_send_timeout 90; + + # Buffer settings for smooth streaming + proxy_buffering on; + proxy_buffer_size 8k; + proxy_buffers 8 8k; + } + + # ── Static asset caching ── + location ~* \.(css|js|png|jpg|ico|woff2?)$ { + proxy_pass http://YOUR_UNRAID_IP:5055; + proxy_set_header Host $host; + expires 7d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/nginx/greendale-subpath.conf b/nginx/greendale-subpath.conf new file mode 100644 index 0000000..1fb421a --- /dev/null +++ b/nginx/greendale-subpath.conf @@ -0,0 +1,34 @@ +# ───────────────────────────────────────────────────────────── +# Greendale Media Centre — Nginx Reverse Proxy Config +# SUBPATH variant: https://yourdomain.com/media/ +# +# Use this if you want to host under a path on an existing domain +# instead of a dedicated subdomain. +# +# NOTE: The Flask app already serves from "/" — Nginx strips the +# /media prefix and the app sees clean paths. +# ───────────────────────────────────────────────────────────── + +server { + listen 443 ssl http2; + server_name yourdomain.com; + + # ... your existing SSL/other config above ... + + # ── Proxy to Greendale Media Centre ── + location /media/ { + proxy_pass http://YOUR_UNRAID_IP:5055/; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_redirect off; + proxy_read_timeout 90; + + # Required so cookies set at "/" work on the /media/ sub-path + proxy_cookie_path / /media/; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..27e444b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask==3.1.0 +requests==2.32.3 +gunicorn==23.0.0 diff --git a/settings.html b/settings.html new file mode 100644 index 0000000..1b8bdc0 --- /dev/null +++ b/settings.html @@ -0,0 +1,486 @@ + + + + + + {% if theme == 'office' %}Settings — Dunder Mifflin Media Centre + {% elif theme == 'parks' %}Settings — Pawnee Parks Media Centre + {% elif theme == 'potter' %}Settings — Hogwarts Media Vault + {% elif theme == 'starwars' %}Settings — Rebel Alliance Media Archive + {% else %}Settings — Greendale Media Centre{% endif %} + {% if theme == 'office' %} + {% elif theme == 'parks' %} + {% elif theme == 'potter' %} + {% elif theme == 'starwars' %} + {% else %}{% endif %} + {% if theme == 'office' %} + + {% elif theme == 'parks' %} + + {% elif theme == 'potter' %} + + {% elif theme == 'starwars' %} + + {% else %} + + {% endif %} + + + + +
+ +

Settings

+ ← Dashboard +
+ +
+
+ + +
+
+ 🌍 +

General

+
+
+
+ + +
+
+ + +
+ +
+
+ + +
+
+ 🎨 +

Theme

+
+
+
+
+
🏫
+
Community
+
Greendale College
+
+
+
📎
+
The Office
+
Dunder Mifflin
+
+
+
🌳
+
Parks & Rec
+
Pawnee, Indiana
+
+
+
+
Harry Potter
+
Hogwarts
+
+
+
🌌
+
Star Wars
+
A long time ago…
+
+
+ +
+
+ + +
+
+ 🔐 +

Change Password

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
📺

Sonarr

+
+
+ + +
+ +
+
+ + +
+
🎬

Radarr

+
+
+ + +
+ +
+
+ + +
+
⬇️

SABnzbd

+
+
+ + +
+ +
+
+ + +
+
▶️

Tautulli

+
+
+ + +
+ +
+
+ + +
+
🔍

Prowlarr

+
+
+ + +
+ +
+
+ + +
+
🎟️

Ombi

+
+
+ + +
+ +
+
+ +
+
+ +
✓ Saved
+ + + + + diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..43b7e12 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000..f34089b Binary files /dev/null and b/static/favicon.png differ diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..1e839e4 Binary files /dev/null and b/static/logo.png differ diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..46e2979 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,1964 @@ + + + + + + {% if theme == 'office' %}Dunder Mifflin Media Centre + {% elif theme == 'parks' %}Pawnee Parks Media Centre + {% elif theme == 'potter' %}Hogwarts Media Vault + {% elif theme == 'starwars' %}Rebel Alliance Media Archive + {% else %}Greendale Media Centre{% endif %} + {% if theme == 'office' %} + {% elif theme == 'parks' %} + {% elif theme == 'potter' %} + {% elif theme == 'starwars' %} + {% else %}{% endif %} + + + {% if theme == 'office' %} + + {% elif theme == 'parks' %} + + {% elif theme == 'potter' %} + + {% elif theme == 'starwars' %} + + {% else %} + + {% endif %} + + + + +
+
+ {% if theme == 'office' %} +
📎
+ {% elif theme == 'parks' %} +
🌳
+ {% elif theme == 'potter' %} +
+ {% elif theme == 'starwars' %} +
+ + + + + + + + + + + +
+ {% else %} + Greendale + {% endif %} +
+ {% if theme == 'office' %} +

Dunder Mifflin Media Centre

+
That's what she watched.
+ {% elif theme == 'parks' %} +

Pawnee Parks Media Centre

+
The greatest city in the world.
+ {% elif theme == 'potter' %} +

Hogwarts Media Vault

+
Mischief managed.
+ {% elif theme == 'starwars' %} +

Rebel Alliance Media Archive

+
May the downloads be with you.
+ {% else %} +

Greendale Media Centre

+
Human beings welcome. Robots too.
+ {% endif %} +
+
+
+ + + {% set up = sonarr.status == "ok" %} + + + + Sonarr + + {%- if up %}{{ sonarr.total_series }} series{% else %}offline{% endif -%} + + + + + + {% set up = radarr.status == "ok" %} + + + + Radarr + + {%- if up %}{{ radarr.total_movies }} movies{% else %}offline{% endif -%} + + + + + + {% set up = prowlarr.status == "ok" %} + + + + Prowlarr + + {%- if up %}{{ prowlarr.up_count }}/{{ prowlarr.total }} online{% else %}offline{% endif -%} + + + + + + {% set up = ombi.status == "ok" %} + + + + Ombi + + {%- if up %}{{ ombi.total_pending }} pending{% else %}offline{% endif -%} + + + + + + {% set up = sabnzbd.status == "ok" %} + + + + SABnzbd + + {%- if up %}{{ sabnzbd.speed }}{% else %}offline{% endif -%} + + + + + + + {% set up = tautulli.status == "ok" %} + + + + Tautulli + + {%- if up -%} + {%- if tautulli.active_streams > 0 -%} + {{ tautulli.active_streams }} streaming + {%- else -%} + 0 streams + {%- endif -%} + {%- else -%}offline{%- endif -%} + + + + + + +
+
+
+
+ {% if theme == 'office' %}Dunder Mifflin HQ + {% elif theme == 'parks' %}Pawnee City Hall + {% elif theme == 'potter' %}Hogwarts Great Hall + {% elif theme == 'starwars' %}Death Star Control Room + {% else %}Study Room F{% endif %} +  ·  ⚙ Settings +
+
+
+ +
+ +
+ {% if theme == 'office' %} + 📋  Monday morning meeting: checking downloads, missing episodes, and who took the last of the coffee. + {% elif theme == 'parks' %} + 📋  Today's agenda: checking what's missing, what's downloading, and making Pawnee great again. + {% elif theme == 'potter' %} + 📋  Today's scroll: checking what spells are missing, what's being conjured, and what deserves a watch. + {% elif theme == 'starwars' %} + 📋  Rebel briefing: checking what's missing, what's downloading, and what the Force recommends watching. + {% else %} + 📋  Today's agenda: checking what's missing, what's downloading, and what deserves to be watched. + {% endif %} +
+ + +
+
+ 🖥️ +

Unraid

+ {% if unraid.status == "ok" %} + + {% if unraid.array_state %}{{ unraid.array_state | capitalize }}{% endif %} + {% if unraid.disk_count %} ·  {{ unraid.disk_count }} disk{{ 's' if unraid.disk_count != 1 }}{% endif %} + {% if unraid.os_version %} ·  v{{ unraid.os_version }}{% endif %} + + {% endif %} +
+
+ {% if unraid.status == "ok" %} +
+
+
Array Total
+
{{ unraid.array_total }}
+
+
+
Array Used
+
{{ unraid.array_used }}
+
+
+
Array Free
+
{{ unraid.array_free }}
+
+
+ +
+
+ Array Usage + {{ unraid.array_used_pct }}% +
+
+
+
+
+ {% if unraid.source == "local" %} +
+ ⓘ Showing container disk usage ({{ unraid.mount }}). Add UNRAID_API_KEY for full Unraid stats. +
+ {% endif %} + {% else %} +
Could not load system stats: {{ unraid.error }}
+ {% endif %} +
+
+ + +
+
+ 📺 +

Sonarr

+
+
+ +
+
+
+
+
+ +
+ + {% if sonarr.status == "ok" %} +
+
+
Total Series
+
{{ sonarr.total_series }}
+
+
+
Missing Episodes
+
{{ sonarr.missing_episodes }}
+
+
+ {% else %} +
Could not reach Sonarr: {{ sonarr.error }}
+ {% endif %} + +
🍅 Popular on RT
+ {% if rt_tv and rt_tv[0] is defined and rt_tv[0].error is defined %} +
Failed to load TV data: {{ rt_tv[0].error }}
+ {% elif rt_tv %} +
+ {% for show in rt_tv %} +
+
+ {{ show.title }}
+ {{ show.description[:180] if show.description else '' }}{% if show.description and show.description|length > 180 %}…{% endif %} +
+
{{ loop.index }}
+ {% if show.poster %} + {% else %}
{% endif %} +
+
{{ show.title }}{% if show.year %} ({{ show.year }}){% endif %}
+
+ {% if show.certified %}✅ Certified Fresh + {% elif show.critics_score %}🍅 {{ show.critics_score }}%{% endif %} + {% if show.audience_score %}🍿 {{ show.audience_score }}{% endif %} +
+
+
+ +
+
+ {% endfor %} +
+ {% else %} +
No TV data available.
+ {% endif %} +
+
+ + +
+
+ 🎬 +

Radarr

+
+
+ +
+
+
+
+
+ +
+ + {% if radarr.status == "ok" %} +
+
+
Total Movies
+
{{ radarr.total_movies }}
+
+
+
Missing
+
{{ radarr.missing_movies }}
+
+
+ {% else %} +
Could not reach Radarr: {{ radarr.error }}
+ {% endif %} + +
🍅 Top Box Office on RT
+ {% if rt_movies and rt_movies[0] is defined and rt_movies[0].error is defined %} +
Failed to load movie data: {{ rt_movies[0].error }}
+ {% elif rt_movies %} +
+ {% for movie in rt_movies %} +
+
+ {{ movie.title }}
+ {{ movie.description[:180] if movie.description else '' }}{% if movie.description and movie.description|length > 180 %}…{% endif %} +
+
{{ loop.index }}
+ {% if movie.poster %} + {% else %}
{% endif %} +
+
{{ movie.title }}{% if movie.year %} ({{ movie.year }}){% endif %}
+
+ {% if movie.certified %}✅ Certified Fresh + {% elif movie.critics_score %}🍅 {{ movie.critics_score }}%{% endif %} + {% if movie.audience_score %}🍿 {{ movie.audience_score }}{% endif %} +
+
+
+ +
+
+ {% endfor %} +
+ {% else %} +
No movie data available.
+ {% endif %} +
+
+ + +
+
+ 🔍 +

Prowlarr — Indexers

+ {% if prowlarr.status == "ok" %} + + {{ prowlarr.up_count }}/{{ prowlarr.total }} online + + {% endif %} +
+
+ {% if prowlarr.status == "ok" %} +
+ {% for idx in prowlarr.indexers %} +
+ + {{ idx.name }} +
+ {% endfor %} +
+ {% else %} +
Could not reach Prowlarr: {{ prowlarr.error }}
+ {% endif %} +
+
+ + +
+
+ ⬇️ +

SABnzbd

+ {% if sabnzbd.status == "ok" %} + {% set s = sabnzbd.sab_status | lower %} + + {{ sabnzbd.sab_status }} + + {% endif %} +
+
+ {% if sabnzbd.status == "ok" %} +
+
+
Current Speed
+
{{ sabnzbd.speed }}
+
+
+
Avg Speed (7d)
+
{{ sabnzbd.avg_speed }}
+
+
+
Queue Items
+
{{ sabnzbd.queue_count }}
+
+
+
Remaining
+
{{ sabnzbd.sizeleft }}
+
+
+
Downloads (7d)
+
{{ sabnzbd.completed_7d }}
+
+
+
Downloaded (7d)
+
{{ sabnzbd.week_size }}
+
+
+ + + {% else %} +
Could not reach SABnzbd: {{ sabnzbd.error }}
+ {% endif %} +
+
+ + +
+
+ 🎟️ +

Ombi — Requests

+ {% if ombi.status == "ok" %} +
+ {{ ombi.total_requests }} total + {{ ombi.total_pending }} pending +
+ {% endif %} +
+
+ {% if ombi.status == "ok" %} + {% if ombi.recent %} +
+ {% for req in ombi.recent %} +
+ {{ req.type }} +
+
{{ req.title }}
+
{{ req.user }}  ·  {{ req.date_display }}
+
+ {{ req.status }} +
+ {% endfor %} +
+ {% else %} +
No recent requests.
+ {% endif %} + {% else %} +
Could not reach Ombi: {{ ombi.error }}
+ {% endif %} +
+
+ + +
+
+ ▶️ +

Tautulli

+ {% if tautulli.status == "ok" %} +
+ {% if tautulli.active_streams > 0 %} + + {{ tautulli.active_streams }} active stream{{ 's' if tautulli.active_streams != 1 }} + {% else %} + No active streams + {% endif %} +
+ {% endif %} +
+
+ {% if tautulli.status == "ok" %} +
+ + +
+
+
📺
+
Top TV — 30d
+
+ {% if tautulli.top_tv_list %} +
+ {% for item in tautulli.top_tv_list %} +
+
{{ loop.index }}
+
{{ item.title }}
+
{{ item.plays }} plays
+
+ {% endfor %} +
+ {% else %} +
No data yet
+ {% endif %} +
+ + +
+
+
🎬
+
Top Movies — 30d
+
+ {% if tautulli.top_movie_list %} +
+ {% for item in tautulli.top_movie_list %} +
+
{{ loop.index }}
+
{{ item.title }}
+
{{ item.plays }} plays
+
+ {% endfor %} +
+ {% else %} +
No data yet
+ {% endif %} +
+ +
+ + +
+ + +
+
🏆 Top Played (30d)
+
+ {% if tautulli.top_played %} + {% for item in tautulli.top_played %} +
+
{{ loop.index }}
+
+
{{ item.title }}
+
by {{ item.user }}
+
+ {% set mt = item.media_type | lower %} + + {{ 'TV' if mt == 'episode' else ('Movie' if mt == 'movie' else mt or '?') }} + +
{{ item.plays }}×
+
+ {% endfor %} + {% else %} +
No play history available.
+ {% endif %} +
+
+ + +
+
👤 Top Viewers (30d)
+
+ {% if tautulli.top_users %} + {% for u in tautulli.top_users %} +
+
{{ loop.index }}
+
👤
+
+
{{ u.username }}
+
{{ u.plays }} plays
+
+
{{ u.plays }}×
+
+ {% endfor %} + {% else %} +
No viewer data available.
+ {% endif %} +
+
+ +
+ + {% else %} +
Could not reach Tautulli: {{ tautulli.error }}
+ {% endif %} +
+
+ + +
+ +
+ {% if theme == 'office' %} + "I'm not superstitious, but I am a little stitious."  —  Michael Scott  |  Dunder Mifflin Media Centre + {% elif theme == 'parks' %} + "If you believe it, you can achieve it."  —  Leslie Knope  |  Pawnee Parks Media Centre + {% elif theme == 'potter' %} + "It does not do to dwell on dreams and forget to live."  —  Albus Dumbledore  |  Hogwarts Media Vault + {% elif theme == 'starwars' %} + "Do. Or do not. There is no try."  —  Yoda  |  Rebel Alliance Media Archive + {% else %} + "I discovered at a young age that I have... a gift."  —  Jeff Winger  |  Greendale Human Being Media Centre + {% endif %} +
+ + + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..990b9a3 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,203 @@ + + + + + + Greendale Media Centre — Login + + + + + + + + + + diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..1b8bdc0 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,486 @@ + + + + + + {% if theme == 'office' %}Settings — Dunder Mifflin Media Centre + {% elif theme == 'parks' %}Settings — Pawnee Parks Media Centre + {% elif theme == 'potter' %}Settings — Hogwarts Media Vault + {% elif theme == 'starwars' %}Settings — Rebel Alliance Media Archive + {% else %}Settings — Greendale Media Centre{% endif %} + {% if theme == 'office' %} + {% elif theme == 'parks' %} + {% elif theme == 'potter' %} + {% elif theme == 'starwars' %} + {% else %}{% endif %} + {% if theme == 'office' %} + + {% elif theme == 'parks' %} + + {% elif theme == 'potter' %} + + {% elif theme == 'starwars' %} + + {% else %} + + {% endif %} + + + + +
+ +

Settings

+ ← Dashboard +
+ +
+
+ + +
+
+ 🌍 +

General

+
+
+
+ + +
+
+ + +
+ +
+
+ + +
+
+ 🎨 +

Theme

+
+
+
+
+
🏫
+
Community
+
Greendale College
+
+
+
📎
+
The Office
+
Dunder Mifflin
+
+
+
🌳
+
Parks & Rec
+
Pawnee, Indiana
+
+
+
+
Harry Potter
+
Hogwarts
+
+
+
🌌
+
Star Wars
+
A long time ago…
+
+
+ +
+
+ + +
+
+ 🔐 +

Change Password

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
📺

Sonarr

+
+
+ + +
+ +
+
+ + +
+
🎬

Radarr

+
+
+ + +
+ +
+
+ + +
+
⬇️

SABnzbd

+
+
+ + +
+ +
+
+ + +
+
▶️

Tautulli

+
+
+ + +
+ +
+
+ + +
+
🔍

Prowlarr

+
+
+ + +
+ +
+
+ + +
+
🎟️

Ombi

+
+
+ + +
+ +
+
+ +
+
+ +
✓ Saved
+ + + + +