From cad4cd0a84c024975895f4f64c913fb72a327185 Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Sat, 5 Sep 2026 09:32:45 -0700 Subject: [PATCH] Add Admin section to System: reclaim space from unwatched shows Cross-references Sonarr's per-series disk usage against Tautulli's full play history to surface the 10 largest shows nobody has ever watched, excluding anything added in the last 6 months so new downloads aren't flagged before they've had a chance to be watched. Delete removes the series from Sonarr along with its episode files and folder, behind the site's usual two-tap arm/confirm rather than a native dialog. Also dedupes the never-wired /api/tautulli/never-watched endpoint onto the same helper, and clears the Sonarr/Radarr list caches on delete so a removed title doesn't linger in other cards until the TTL expires. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LPJxtE9tPURsaWDZ6rC4Dt --- app.py | 156 +++++++++++++++++++++++++++--------------- static/js/service.js | 69 +++++++++++++++++++ templates/system.html | 39 +++++++++++ 3 files changed, 209 insertions(+), 55 deletions(-) diff --git a/app.py b/app.py index 95486b9..7ecec89 100644 --- a/app.py +++ b/app.py @@ -22,8 +22,8 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # Docker tab caches stale container IDs, so the footer is the reliable way # to confirm a `docker compose up -d --build` actually took effect. # Both are env-overridable if the deploy pipeline ever injects them. -APP_VERSION = os.environ.get("APP_VERSION", "2.2.0") -APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-09-02") +APP_VERSION = os.environ.get("APP_VERSION", "2.3.0") +APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-09-05") # Must point at a path that survives `docker compose up --build` — the # container filesystem does not. See the data volume in docker-compose.yml. @@ -287,6 +287,11 @@ _sonarr_cache = {"data": None, "expires": 0} _radarr_cache = {"data": None, "expires": 0} ARR_CACHE_TTL = 5 * 60 # 5 minutes +# A show has to have sat in Sonarr longer than this, untouched, before the +# Admin section will suggest deleting it — keeps something just downloaded +# and not yet gotten to from showing up next to a delete button. +ADMIN_UNWATCHED_MONTHS = 6 + # 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} @@ -779,6 +784,84 @@ def get_user_watch_history(username, hours=48): return {"status": "error", "error": str(e)} +def _fmt_bytes(b): + if b >= 1024**3: + return f"{b / 1024**3:.1f} GB" + return f"{b / 1024**2:.0f} MB" + + +def get_admin_unwatched_shows(limit=10, months=ADMIN_UNWATCHED_MONTHS): + """Sonarr shows with episodes on disk that Tautulli has never played, + largest first — the source list for P105's Admin section. + + Excludes anything added to Sonarr within the last `months` months, so a + show that's simply new and hasn't been gotten to yet doesn't turn up + next to a delete button. Matches against Tautulli's full play history + (a large `length`, not the 30/100-play windows the rest of the + dashboard uses) because a recency-limited window could mismark a show + that genuinely was watched — just a while ago — as never-watched, + which matters a lot more here than elsewhere given what's attached to + the wrong answer. + """ + try: + series_list = sonarr_series_cached() + except Exception as e: + return {"status": "error", "error": str(e)} + + try: + hist = tautulli_get("get_history", "&length=5000&order_column=date&order_dir=desc") + except Exception as e: + return {"status": "error", "error": str(e)} + + played_titles = set() + slots = hist.get("data", []) if isinstance(hist, dict) else [] + if isinstance(slots, dict): + slots = slots.get("data", []) + for entry in slots: + t = entry.get("grandparent_title") or entry.get("full_title") or entry.get("title", "") + if t: + played_titles.add(t.lower()) + + cutoff = datetime.utcnow() - timedelta(days=30 * months) + + never = [] + for s in series_list: + stats = s.get("statistics") or {} + size = stats.get("sizeOnDisk", 0) or s.get("sizeOnDisk", 0) + if not size: + continue + title = s.get("title", "") + if title.lower() in played_titles: + continue + added = s.get("added", "") or "" + try: + added_dt = datetime.strptime(added[:19], "%Y-%m-%dT%H:%M:%S") + if added_dt > cutoff: + continue # too recently added to fairly call it neglected + except (ValueError, TypeError): + pass # no usable added date — don't let a bad field hide a candidate + never.append({ + "sonarr_id": s.get("id"), + "title": title, + "year": s.get("year", ""), + "size": size, + "ep_count": stats.get("episodeFileCount", 0), + }) + + never.sort(key=lambda x: x["size"], reverse=True) + top = never[:limit] + for show in top: + show["size_display"] = _fmt_bytes(show["size"]) + total_bytes = sum(show["size"] for show in top) + + return { + "status": "ok", + "shows": top, + "months": months, + "total_display": _fmt_bytes(total_bytes) if top else "0 MB", + } + + # --------------------------------------------------------------------------- # Ombi # --------------------------------------------------------------------------- @@ -1340,6 +1423,7 @@ PAGE_TASKS = { "unraid": lambda: get_unraid_stats(), "rt_movies": lambda: get_rt_movies(), "rt_tv": lambda: get_rt_tv(), + "admin_unwatched": lambda: get_admin_unwatched_shows(), } PAGE_TIMEOUT = 5.0 @@ -1742,12 +1826,12 @@ def requests_page(): @login_required def system_page(): r = fetch_services(["unraid", "prowlarr", "sonarr", "radarr", - "sabnzbd", "tautulli", "ombi"]) + "sabnzbd", "tautulli", "ombi", "admin_unwatched"]) return render_template("system.html", page="system", unraid=r["unraid"], prowlarr=r["prowlarr"], sonarr=r["sonarr"], radarr=r["radarr"], sabnzbd=r["sabnzbd"], tautulli=r["tautulli"], - ombi=r["ombi"]) + ombi=r["ombi"], admin_unwatched=r["admin_unwatched"]) @app.route("/activity") @@ -1906,57 +1990,15 @@ def api_services(): @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 + """Sonarr shows that have never been played in Tautulli — see the + Admin section on P105 (`get_admin_unwatched_shows`), which this + delegates to rather than duplicating the match/sort logic.""" + months = request.args.get("months", default=ADMIN_UNWATCHED_MONTHS, type=int) + limit = request.args.get("limit", default=30, type=int) + result = get_admin_unwatched_shows(limit=limit, months=months) + if result.get("status") != "ok": + return jsonify(result), 500 + return jsonify(result) @app.route("/api/delete/movie", methods=["POST"]) @@ -1973,6 +2015,8 @@ def api_delete_movie(): headers=headers, timeout=15 ) resp.raise_for_status() + _radarr_cache["data"] = None + _radarr_cache["expires"] = 0 return jsonify({"success": True}) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -1992,6 +2036,8 @@ def api_delete_show(): headers=headers, timeout=15 ) resp.raise_for_status() + _sonarr_cache["data"] = None + _sonarr_cache["expires"] = 0 return jsonify({"success": True}) except Exception as e: return jsonify({"error": str(e)}), 500 diff --git a/static/js/service.js b/static/js/service.js index 71186dc..e14ef99 100644 --- a/static/js/service.js +++ b/static/js/service.js @@ -234,6 +234,75 @@ armed = btn; }; + /* ── Admin: delete unwatched (P105) ─────────────────── + Removing a series from Sonarr with its files is permanent, + so it shares the same two-tap arming as everything else here + rather than a native confirm(). Each row carries its own byte + count, so the reclaimable total in the field meta can be + recomputed client-side after a delete instead of going stale + until the next page load. */ + function fmtBytes(b) { + if (b >= 1073741824) return (b / 1073741824).toFixed(1) + ' GB'; + return (b / 1048576).toFixed(0) + ' MB'; + } + + function settleAdmin() { + var list = document.getElementById('admin-rows'); + var meta = document.getElementById('admin-meta'); + var rows = list ? list.querySelectorAll('.tt-row') : []; + var total = 0; + for (var i = 0; i < rows.length; i++) total += parseInt(rows[i].dataset.bytes, 10) || 0; + if (meta) meta.textContent = rows.length + ' unwatched · ' + fmtBytes(total) + ' reclaimable'; + if (rows.length === 0 && list) { + var empty = document.createElement('p'); + empty.className = 'tt-empty'; + empty.id = 'admin-empty'; + empty.textContent = 'Nothing left to reclaim.'; + list.parentNode.replaceChild(empty, list); + } + } + + window.armDelete = function (btn) { + if (btn.disabled) return; + if (armed && armed !== btn) disarm(); + + if (armed === btn) { + var row = btn.closest('.tt-row'); + btn.disabled = true; + btn.classList.remove('armed'); + btn.textContent = 'DELETING'; + armed = null; + + fetch(BASE + '/api/delete/show', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sonarr_id: btn.dataset.id }) + }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (d && d.success) { + if (row && row.parentNode) row.parentNode.removeChild(row); + settleAdmin(); + } else { + btn.disabled = false; + btn.classList.add('armed'); + btn.textContent = 'FAILED'; + } + }) + .catch(function () { + btn.disabled = false; + btn.classList.add('armed'); + btn.textContent = 'FAILED'; + }); + return; + } + + btn.dataset.label = btn.textContent.trim(); + btn.classList.add('armed'); + btn.textContent = 'CONFIRM?'; + armed = btn; + }; + document.addEventListener('click', function (e) { if (armed && !armed.contains(e.target)) disarm(); }); diff --git a/templates/system.html b/templates/system.html index 14a7d6e..abbffe1 100644 --- a/templates/system.html +++ b/templates/system.html @@ -161,4 +161,43 @@
Indexersoff air
{{ service_error('Prowlarr', prowlarr.error) }} {% endif %} + + {#- ── Admin ───────────────────────────────────────────────────── + Shows Sonarr is holding, sized, that nobody has ever played in + Tautulli and that have sat untouched for longer than the + neglect window — largest first, since the point is reclaiming + space. Delete removes the series from Sonarr with its episode + files and folder; that's permanent, so it shares the site's + two-tap arming rather than a native confirm(). -#} + {% if admin_unwatched.status != 'ok' %} +
Adminoff air
+ {{ service_error('Sonarr/Tautulli', admin_unwatched.error) }} + {% else %} +
+ Admin + + {{ admin_unwatched.shows | length }} unwatched · {{ admin_unwatched.total_display }} reclaimable + +
+ {% if admin_unwatched.shows %} +
+ {% for show in admin_unwatched.shows %} +
+ {{ show.ep_count }} EP + + {{ show.title }} + {% if show.year %}{{ show.year }}{% endif %} + + {{ show.size_display }} + + + +
+ {% endfor %} +
+ {% else %} +

Nothing unwatched and older than {{ admin_unwatched.months }} months is taking up meaningful space.

+ {% endif %} + {% endif %} {% endblock %}