Add Admin section to System: reclaim space from unwatched shows
All checks were successful
Deploy / deploy (push) Successful in 48s
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:
parent
d38ab1b561
commit
cad4cd0a84
3 changed files with 209 additions and 55 deletions
156
app.py
156
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue