Add Admin section to System: reclaim space from unwatched shows
All checks were successful
Deploy / deploy (push) Successful in 48s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPJxtE9tPURsaWDZ6rC4Dt
This commit is contained in:
Barely Removable 2026-09-05 09:32:45 -07:00
parent d38ab1b561
commit cad4cd0a84
3 changed files with 209 additions and 55 deletions

156
app.py
View file

@ -22,8 +22,8 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Docker tab caches stale container IDs, so the footer is the reliable way
# to confirm a `docker compose up -d --build` actually took effect.
# Both are env-overridable if the deploy pipeline ever injects them.
APP_VERSION = os.environ.get("APP_VERSION", "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