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
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 · {{ 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 %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue