From c1e30bc8f0bcaeed7ea99493ba1209a1ef85e624 Mon Sep 17 00:00:00 2001 From: mattie726 Date: Tue, 25 Aug 2026 22:59:31 -0700 Subject: [PATCH] Initial commit: Arr Summary source from server --- .claude/settings.local.json | 29 + .env.example | 56 + .gitignore | 3 + Dockerfile | 16 + UNRAID-SETUP.md | 154 ++ app.py | 1371 ++++++++++++++ arr-summary/.env.example | 56 + arr-summary/Dockerfile | 16 + arr-summary/UNRAID-SETUP.md | 154 ++ arr-summary/app.py | 1371 ++++++++++++++ arr-summary/docker-compose.yml | 31 + arr-summary/export-to-unraid.sh | 80 + arr-summary/gunicorn.conf.py | 22 + arr-summary/nginx/greendale-subdomain.conf | 68 + arr-summary/nginx/greendale-subpath.conf | 34 + arr-summary/requirements.txt | 3 + arr-summary/static/favicon.ico | Bin 0 -> 3739 bytes arr-summary/static/favicon.png | Bin 0 -> 34878 bytes arr-summary/static/logo.png | Bin 0 -> 8395 bytes arr-summary/templates/dashboard.html | 1964 ++++++++++++++++++++ arr-summary/templates/login.html | 203 ++ arr-summary/templates/settings.html | 486 +++++ dashboard.html | 1964 ++++++++++++++++++++ docker-compose.yml | 31 + export-to-unraid.sh | 80 + gunicorn.conf.py | 22 + login.html | 203 ++ nginx/greendale-subdomain.conf | 68 + nginx/greendale-subpath.conf | 34 + requirements.txt | 3 + settings.html | 486 +++++ static/favicon.ico | Bin 0 -> 3739 bytes static/favicon.png | Bin 0 -> 34878 bytes static/logo.png | Bin 0 -> 8395 bytes templates/dashboard.html | 1964 ++++++++++++++++++++ templates/login.html | 203 ++ templates/settings.html | 486 +++++ 37 files changed, 11661 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 UNRAID-SETUP.md create mode 100644 app.py create mode 100644 arr-summary/.env.example create mode 100644 arr-summary/Dockerfile create mode 100644 arr-summary/UNRAID-SETUP.md create mode 100644 arr-summary/app.py create mode 100644 arr-summary/docker-compose.yml create mode 100755 arr-summary/export-to-unraid.sh create mode 100644 arr-summary/gunicorn.conf.py create mode 100644 arr-summary/nginx/greendale-subdomain.conf create mode 100644 arr-summary/nginx/greendale-subpath.conf create mode 100644 arr-summary/requirements.txt create mode 100644 arr-summary/static/favicon.ico create mode 100644 arr-summary/static/favicon.png create mode 100644 arr-summary/static/logo.png create mode 100644 arr-summary/templates/dashboard.html create mode 100644 arr-summary/templates/login.html create mode 100644 arr-summary/templates/settings.html create mode 100644 dashboard.html create mode 100644 docker-compose.yml create mode 100755 export-to-unraid.sh create mode 100644 gunicorn.conf.py create mode 100644 login.html create mode 100644 nginx/greendale-subdomain.conf create mode 100644 nginx/greendale-subpath.conf create mode 100644 requirements.txt create mode 100644 settings.html create mode 100644 static/favicon.ico create mode 100644 static/favicon.png create mode 100644 static/logo.png create mode 100644 templates/dashboard.html create mode 100644 templates/login.html create mode 100644 templates/settings.html 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 0000000000000000000000000000000000000000..43b7e12c9d7162d3c0ab6798dc45d10122c9f496 GIT binary patch literal 3739 zcmV;M4rK8F0096301yxW0000W0KEeM044wc03aX$0000W0JsVO0OJDy0EtjeM-2)Z z3IG5A4M|8uQUCw|5C8xG5C{eU001BJ|6u?C1B6LLK~#90O;c@XTV)h}-uK?yZpoT< zP180XOIFiV(>2*z6(;_PP}gWd&@IxnT4W5xNux_e{IQt?=3t`*so+n+O<9+Wje;_k z;K%fr)J+F%Si2=zlO|0_P1mk%5_5B3$!#khc;TG$p6B5l9v)yjyk0NeP8(~@eJ3rB zcdn{lc>j*g`QCK9WB7_-eeDE*y<4+vXBK1&%_#gtg26G=Wk29mHnv*SHAVaqr+87n zi5h{2D2iChm*!?>B0g#1r^y`*ak*SvFc_?8Z2dl4&vRa(oQGOLq;8v)8BJ}JBMkEo zW714yj+82dJ{EW5-k&pPmuJ5}=YoqPgaC@R-a&`s`S19D@|CWqE#%aRL8{YfkxV?2 zm)vJGVR12yufG0P`fD!9h_adc`PPqb78AFFlrdG~!S2rU^|g%ItsUgtnNb;BUHQsn z0FzgP5XA}%1|80S_6fCh7>Q6^V)pLN3k=vx6_&%tbS-KVCzaR-C*PuhKmhrC9z4%O zuh&DT(}CwXxfcjt!|8X2sKD_|)1qywZ-41^s?}2;qaP!kN}*M&gY_9JORb8`U^4dcjrDbHDg{S$TCFUNdt?N;Tnl@v6Xmjqa=8qrvkznAWAX_rD}Uqq z&EE)-mI*dpchOUI7B0kv9w8Vm6lOC}!4c{-J1r#>C4K;J70 z_CZ$D-tXG-g3HAr7_129mwgrOVQ+(y!z;$elD(^wnayTOAR!u!Nwc#Na$}*DZmh3^ zWTwxE(a`5r?6#S_l=%G;GdPCqJ%_v&eZ8f)xq(cf21>33lX^F*B1KY#xqH)p_(=Np zB&)jD@0Yd+wn?gus$~@&eMim4)?Srz*Ha9@lR_yvA4`Wz4@1?PSN=DPe*rGihl3i0 zfb#$V002ovPDHLkV1kKIPDc$28VUda01ZhKuE2uWylVt6z@pLp8d$|;#{HG zKMCK9JB^I&smh+CU^ZhIOKg{+P0NRGpo0V=pk9p)14ABM>gfZl0(L9SVQNfF?}|jB=pfVlZd^oCU}NNa98FKjL8|G zp%EC(2?!1LC_(Rt5-??nqxu}2I=sI>(0*twgx++u3cLR@Y?15bz`;2nj@oPc29R_it73S0vNIC1h6{`$8Mai*;oGYY36&BSoN<+P&7uV5$7bqYO4 z-rhuI*UQQ^?jW&%BZSC|kwJwryiN z#_Xoz2Y)luk*tiK`-hNB$4FDxAj%8VajIwx9bOt#~i79mq&bI+RVkv;zLd)(bFP^i5`iX zu*y6fjzE?rm3h?KQ3eKs0gDzb!n}F&pwsD~D2lqL`cn`fOOi_a>sPB`5d#R1d;=-y z5wooZC8coQDhA|()X5Y~0gO41&ndDC<|!qL6lcR1M?c1EFKto%tkr6u(P*NfrtqSM z34&b9IEKTm#A!IKpwZ%$?d#Fj-huRjTj3wTT79jKONyv21`AruOOQmXp;dO>20`5wm!hr@X6u^(dn`kx>W2%>aO z8RisbqqW10?npYFVvUDBJ^Nk-5UB_hnJlS}uwX>Q?4aG3x>0%m5>*RsT6<SiPpJU83{DObyU_wMqoCXP su?GI_NUL{;>C-qukXJ59;{fg0^Qx+ zs-L;GeDFEPS-yO^ic@~uZRqOi!j2u=qp3;=ZY?W?67k~0o>X+ll`3gAn+O^=ixQ3A z9@M46kX;T}K!7n$j|5BdI3WkS-HumZ{UuJHKBY!~VPO%ftE&<42jKVn@$$>nC@Lya z&CPLYYO0Zyl{LXdDXD2lN-!Z+9D-%&1T)3y0G-t#!jzhlDteHYWWjOg5G)C%D?Viq zgscAL|GBxjIB?(qjvqgcXP#M)Q;m&kDeLaOh>s3`gt=vNReUuK2~n*RJBw#grqu*p z&;x@82oV9GqE4htLa*O|Pus`f95tXo|E*nfLQB`{^{T(+E5XQnzeXF)lvrHEm|sij z3CcLqVMc0pD$L3d@Agcjkr8)Wzl^VaHYmd*ND(`uhtjL574!AQi{03;VFL~x{5v*o z+=Psb3{}&@!a|gn--e2c3e?opz;4f+AUajgQs5f#fvfKt4Pn3+goZ{WN&|ykA|jx} z7XVK?E(`<_^bV_4ECxM2J=n5k3rb2#)S}eX)P${DUx3kQR0p3oHJw${u%u)LHb1`^ zPN#F+8vLYDcR#uYBfwA_gunn1Nf_`CU!Zcxdy4tpj0HyoA=HoVQ4N}#&W@MdojZ3T zGcywh4<5w+{q?A?uUBIsR)qNAbG}wz|E{`!@PiL9dGciJ-o1M~n;IJ%;qu2p<9C9E zJe2v|5+eRHbP{k5o^Lwf?T1G4E6nS~zWs+(LS9h#;ZHpIB#s_Eira6$UDcAW^VEyh z{%GmtYkZI6eE#_{EL-+4dV8G+g+eOcALHGCmFJrvY)^B;F2sT_CYd8#ggzEoIs{KVtk$VexK7oO-W29!SHsZPCln6%e>7H5&bLVb*F!r`{hH z%^8ZOppv2BF}Ad775lQKd4g|cg%3rIlj~#U%&8OXuKSiV6gOez_OA-?-B6A)GX|3BRg}v3+GXE92^wCh(oqMdsi<6g39)u~)E=63O zRAQ70q6YuG7zc;Wg|B@U%(Gm^6vtFVJZ@OiY)VIKqmrCkCjRboA42Vi`n8?^S{nfB zVrPG+rx{QyCjgE>O8$=YX|q?PISK^3F#rR<{{@GYK20vO2G7H(FF=m(IMS!hB~Irh z8dP-X3+T{!?xf`EY~DxQ?OOvt@3%3(fdE$mlTcZ`GXgVUoVwCtq`+Ve6JMIk+7 z5<h;0v^`XZ#CVBdM&W~Q|cpttCZ>lfl|L<`+4(juq)YZvgjJMV3^PF0X zq0wg%v&F{3K?RaL0N+3t;8CCF6bLik11c*8)ZX}c{%?9Wl)_xNn4SOt002ovPDHLk FV1nqE@P+^Y literal 0 HcmV?d00001 diff --git a/arr-summary/static/favicon.png b/arr-summary/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..f34089bacfbca5cddfbe102a54bc811c363a4353 GIT binary patch literal 34878 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 G0000Ul{i5F literal 0 HcmV?d00001 diff --git a/arr-summary/static/logo.png b/arr-summary/static/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1e839e43807e62e471addb2d5c5889ebece15b52 GIT binary patch literal 8395 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 + + + + + {% 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' %} + 📋  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 0000000000000000000000000000000000000000..43b7e12c9d7162d3c0ab6798dc45d10122c9f496 GIT binary patch literal 3739 zcmV;M4rK8F0096301yxW0000W0KEeM044wc03aX$0000W0JsVO0OJDy0EtjeM-2)Z z3IG5A4M|8uQUCw|5C8xG5C{eU001BJ|6u?C1B6LLK~#90O;c@XTV)h}-uK?yZpoT< zP180XOIFiV(>2*z6(;_PP}gWd&@IxnT4W5xNux_e{IQt?=3t`*so+n+O<9+Wje;_k z;K%fr)J+F%Si2=zlO|0_P1mk%5_5B3$!#khc;TG$p6B5l9v)yjyk0NeP8(~@eJ3rB zcdn{lc>j*g`QCK9WB7_-eeDE*y<4+vXBK1&%_#gtg26G=Wk29mHnv*SHAVaqr+87n zi5h{2D2iChm*!?>B0g#1r^y`*ak*SvFc_?8Z2dl4&vRa(oQGOLq;8v)8BJ}JBMkEo zW714yj+82dJ{EW5-k&pPmuJ5}=YoqPgaC@R-a&`s`S19D@|CWqE#%aRL8{YfkxV?2 zm)vJGVR12yufG0P`fD!9h_adc`PPqb78AFFlrdG~!S2rU^|g%ItsUgtnNb;BUHQsn z0FzgP5XA}%1|80S_6fCh7>Q6^V)pLN3k=vx6_&%tbS-KVCzaR-C*PuhKmhrC9z4%O zuh&DT(}CwXxfcjt!|8X2sKD_|)1qywZ-41^s?}2;qaP!kN}*M&gY_9JORb8`U^4dcjrDbHDg{S$TCFUNdt?N;Tnl@v6Xmjqa=8qrvkznAWAX_rD}Uqq z&EE)-mI*dpchOUI7B0kv9w8Vm6lOC}!4c{-J1r#>C4K;J70 z_CZ$D-tXG-g3HAr7_129mwgrOVQ+(y!z;$elD(^wnayTOAR!u!Nwc#Na$}*DZmh3^ zWTwxE(a`5r?6#S_l=%G;GdPCqJ%_v&eZ8f)xq(cf21>33lX^F*B1KY#xqH)p_(=Np zB&)jD@0Yd+wn?gus$~@&eMim4)?Srz*Ha9@lR_yvA4`Wz4@1?PSN=DPe*rGihl3i0 zfb#$V002ovPDHLkV1kKIPDc$28VUda01ZhKuE2uWylVt6z@pLp8d$|;#{HG zKMCK9JB^I&smh+CU^ZhIOKg{+P0NRGpo0V=pk9p)14ABM>gfZl0(L9SVQNfF?}|jB=pfVlZd^oCU}NNa98FKjL8|G zp%EC(2?!1LC_(Rt5-??nqxu}2I=sI>(0*twgx++u3cLR@Y?15bz`;2nj@oPc29R_it73S0vNIC1h6{`$8Mai*;oGYY36&BSoN<+P&7uV5$7bqYO4 z-rhuI*UQQ^?jW&%BZSC|kwJwryiN z#_Xoz2Y)luk*tiK`-hNB$4FDxAj%8VajIwx9bOt#~i79mq&bI+RVkv;zLd)(bFP^i5`iX zu*y6fjzE?rm3h?KQ3eKs0gDzb!n}F&pwsD~D2lqL`cn`fOOi_a>sPB`5d#R1d;=-y z5wooZC8coQDhA|()X5Y~0gO41&ndDC<|!qL6lcR1M?c1EFKto%tkr6u(P*NfrtqSM z34&b9IEKTm#A!IKpwZ%$?d#Fj-huRjTj3wTT79jKONyv21`AruOOQmXp;dO>20`5wm!hr@X6u^(dn`kx>W2%>aO z8RisbqqW10?npYFVvUDBJ^Nk-5UB_hnJlS}uwX>Q?4aG3x>0%m5>*RsT6<SiPpJU83{DObyU_wMqoCXP su?GI_NUL{;>C-qukXJ59;{fg0^Qx+ zs-L;GeDFEPS-yO^ic@~uZRqOi!j2u=qp3;=ZY?W?67k~0o>X+ll`3gAn+O^=ixQ3A z9@M46kX;T}K!7n$j|5BdI3WkS-HumZ{UuJHKBY!~VPO%ftE&<42jKVn@$$>nC@Lya z&CPLYYO0Zyl{LXdDXD2lN-!Z+9D-%&1T)3y0G-t#!jzhlDteHYWWjOg5G)C%D?Viq zgscAL|GBxjIB?(qjvqgcXP#M)Q;m&kDeLaOh>s3`gt=vNReUuK2~n*RJBw#grqu*p z&;x@82oV9GqE4htLa*O|Pus`f95tXo|E*nfLQB`{^{T(+E5XQnzeXF)lvrHEm|sij z3CcLqVMc0pD$L3d@Agcjkr8)Wzl^VaHYmd*ND(`uhtjL574!AQi{03;VFL~x{5v*o z+=Psb3{}&@!a|gn--e2c3e?opz;4f+AUajgQs5f#fvfKt4Pn3+goZ{WN&|ykA|jx} z7XVK?E(`<_^bV_4ECxM2J=n5k3rb2#)S}eX)P${DUx3kQR0p3oHJw${u%u)LHb1`^ zPN#F+8vLYDcR#uYBfwA_gunn1Nf_`CU!Zcxdy4tpj0HyoA=HoVQ4N}#&W@MdojZ3T zGcywh4<5w+{q?A?uUBIsR)qNAbG}wz|E{`!@PiL9dGciJ-o1M~n;IJ%;qu2p<9C9E zJe2v|5+eRHbP{k5o^Lwf?T1G4E6nS~zWs+(LS9h#;ZHpIB#s_Eira6$UDcAW^VEyh z{%GmtYkZI6eE#_{EL-+4dV8G+g+eOcALHGCmFJrvY)^B;F2sT_CYd8#ggzEoIs{KVtk$VexK7oO-W29!SHsZPCln6%e>7H5&bLVb*F!r`{hH z%^8ZOppv2BF}Ad775lQKd4g|cg%3rIlj~#U%&8OXuKSiV6gOez_OA-?-B6A)GX|3BRg}v3+GXE92^wCh(oqMdsi<6g39)u~)E=63O zRAQ70q6YuG7zc;Wg|B@U%(Gm^6vtFVJZ@OiY)VIKqmrCkCjRboA42Vi`n8?^S{nfB zVrPG+rx{QyCjgE>O8$=YX|q?PISK^3F#rR<{{@GYK20vO2G7H(FF=m(IMS!hB~Irh z8dP-X3+T{!?xf`EY~DxQ?OOvt@3%3(fdE$mlTcZ`GXgVUoVwCtq`+Ve6JMIk+7 z5<h;0v^`XZ#CVBdM&W~Q|cpttCZ>lfl|L<`+4(juq)YZvgjJMV3^PF0X zq0wg%v&F{3K?RaL0N+3t;8CCF6bLik11c*8)ZX}c{%?9Wl)_xNn4SOt002ovPDHLk FV1nqE@P+^Y literal 0 HcmV?d00001 diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..f34089bacfbca5cddfbe102a54bc811c363a4353 GIT binary patch literal 34878 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 G0000Ul{i5F literal 0 HcmV?d00001 diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1e839e43807e62e471addb2d5c5889ebece15b52 GIT binary patch literal 8395 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 + + + + + {% 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' %} + 📋  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
+ + + + +