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 # Docker tab caches stale container IDs, so the footer is the reliable way
# to confirm a `docker compose up -d --build` actually took effect. # to confirm a `docker compose up -d --build` actually took effect.
# Both are env-overridable if the deploy pipeline ever injects them. # Both are env-overridable if the deploy pipeline ever injects them.
APP_VERSION = os.environ.get("APP_VERSION", "2.2.0") APP_VERSION = os.environ.get("APP_VERSION", "2.3.0")
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-09-02") APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-09-05")
# Must point at a path that survives `docker compose up --build` — the # Must point at a path that survives `docker compose up --build` — the
# container filesystem does not. See the data volume in docker-compose.yml. # 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} _radarr_cache = {"data": None, "expires": 0}
ARR_CACHE_TTL = 5 * 60 # 5 minutes 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. # Tautulli stats cache: top shows/movies/users/history refresh every 2 minutes.
# Active streams (get_activity) are always fetched live. # Active streams (get_activity) are always fetched live.
_tautulli_stats_cache = {"data": None, "expires": 0} _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)} 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 # Ombi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -1340,6 +1423,7 @@ PAGE_TASKS = {
"unraid": lambda: get_unraid_stats(), "unraid": lambda: get_unraid_stats(),
"rt_movies": lambda: get_rt_movies(), "rt_movies": lambda: get_rt_movies(),
"rt_tv": lambda: get_rt_tv(), "rt_tv": lambda: get_rt_tv(),
"admin_unwatched": lambda: get_admin_unwatched_shows(),
} }
PAGE_TIMEOUT = 5.0 PAGE_TIMEOUT = 5.0
@ -1742,12 +1826,12 @@ def requests_page():
@login_required @login_required
def system_page(): def system_page():
r = fetch_services(["unraid", "prowlarr", "sonarr", "radarr", r = fetch_services(["unraid", "prowlarr", "sonarr", "radarr",
"sabnzbd", "tautulli", "ombi"]) "sabnzbd", "tautulli", "ombi", "admin_unwatched"])
return render_template("system.html", page="system", return render_template("system.html", page="system",
unraid=r["unraid"], prowlarr=r["prowlarr"], unraid=r["unraid"], prowlarr=r["prowlarr"],
sonarr=r["sonarr"], radarr=r["radarr"], sonarr=r["sonarr"], radarr=r["radarr"],
sabnzbd=r["sabnzbd"], tautulli=r["tautulli"], sabnzbd=r["sabnzbd"], tautulli=r["tautulli"],
ombi=r["ombi"]) ombi=r["ombi"], admin_unwatched=r["admin_unwatched"])
@app.route("/activity") @app.route("/activity")
@ -1906,57 +1990,15 @@ def api_services():
@app.route("/api/tautulli/never-watched") @app.route("/api/tautulli/never-watched")
@login_required @login_required
def api_never_watched(): def api_never_watched():
"""Movies and shows in Radarr/Sonarr that have never been played in Tautulli.""" """Sonarr shows that have never been played in Tautulli — see the
try: Admin section on P105 (`get_admin_unwatched_shows`), which this
# Get all history titles from Tautulli (last 500 entries) delegates to rather than duplicating the match/sort logic."""
hist = tautulli_get("get_history", "&length=500&order_column=date&order_dir=desc") months = request.args.get("months", default=ADMIN_UNWATCHED_MONTHS, type=int)
played_titles = set() limit = request.args.get("limit", default=30, type=int)
if isinstance(hist, dict): result = get_admin_unwatched_shows(limit=limit, months=months)
slots = hist.get("data", []) if result.get("status") != "ok":
if isinstance(slots, dict): return jsonify(result), 500
slots = slots.get("data", []) return jsonify(result)
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"]) @app.route("/api/delete/movie", methods=["POST"])
@ -1973,6 +2015,8 @@ def api_delete_movie():
headers=headers, timeout=15 headers=headers, timeout=15
) )
resp.raise_for_status() resp.raise_for_status()
_radarr_cache["data"] = None
_radarr_cache["expires"] = 0
return jsonify({"success": True}) return jsonify({"success": True})
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@ -1992,6 +2036,8 @@ def api_delete_show():
headers=headers, timeout=15 headers=headers, timeout=15
) )
resp.raise_for_status() resp.raise_for_status()
_sonarr_cache["data"] = None
_sonarr_cache["expires"] = 0
return jsonify({"success": True}) return jsonify({"success": True})
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500

View file

@ -234,6 +234,75 @@
armed = btn; 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) { document.addEventListener('click', function (e) {
if (armed && !armed.contains(e.target)) disarm(); if (armed && !armed.contains(e.target)) disarm();
}); });

View file

@ -161,4 +161,43 @@
<div class="tt-field crit"><span>Indexers</span><span class="tt-field-meta">off air</span></div> <div class="tt-field crit"><span>Indexers</span><span class="tt-field-meta">off air</span></div>
{{ service_error('Prowlarr', prowlarr.error) }} {{ service_error('Prowlarr', prowlarr.error) }}
{% endif %} {% 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' %}
<div class="tt-field crit"><span>Admin</span><span class="tt-field-meta">off air</span></div>
{{ service_error('Sonarr/Tautulli', admin_unwatched.error) }}
{% else %}
<div class="tt-field">
<span>Admin</span>
<span class="tt-field-meta" id="admin-meta">
{{ admin_unwatched.shows | length }} unwatched &middot; {{ admin_unwatched.total_display }} reclaimable
</span>
</div>
{% if admin_unwatched.shows %}
<div class="tt-rows" id="admin-rows">
{% for show in admin_unwatched.shows %}
<div class="tt-row" data-bytes="{{ show.size }}">
<span class="tt-row-name">{{ show.ep_count }} EP</span>
<span class="tt-row-body">
<span class="tt-row-title">{{ show.title }}</span>
{% if show.year %}<span class="tt-row-sub">{{ show.year }}</span>{% endif %}
</span>
<span class="tt-row-val">{{ show.size_display }}</span>
<span class="tt-row-acts">
<button class="tt-btn tt-btn-no tt-btn-sm" data-id="{{ show.sonarr_id }}"
onclick="armDelete(this)" aria-label="Delete {{ show.title }}">Delete</button>
</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="tt-empty" id="admin-empty">Nothing unwatched and older than {{ admin_unwatched.months }} months is taking up meaningful space.</p>
{% endif %}
{% endif %}
{% endblock %} {% endblock %}