Add active-viewer list to Watch with per-user 48h history drill-down
All checks were successful
Deploy / deploy (push) Successful in 28s

Lists anyone who's watched something in the last 10 days; clicking a
name shows their play history for the last 48 hours.

Bump to v2.1.0 per the just-added CLAUDE.md rule that every commit
should be matched with a version change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kn4ZaFd6RCnbMewMyMsfe
This commit is contained in:
Barely Removable 2026-08-31 07:56:18 -07:00
parent cf2b5acd3e
commit 8115ed4855
2 changed files with 112 additions and 4 deletions

75
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.0.0")
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-29")
APP_VERSION = os.environ.get("APP_VERSION", "2.1.0")
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-31")
# Must point at a path that survives `docker compose up --build` — the
# container filesystem does not. See the data volume in docker-compose.yml.
@ -612,6 +612,7 @@ def get_tautulli_summary():
top_movie_list = cached["top_movie_list"]
top_played = cached["top_played"]
top_users = cached["top_users"]
active_users = cached["active_users"]
else:
# Top 3 most watched TV shows (30d)
tv_data = tautulli_get("get_home_stats", "&stat_id=top_tv&stats_count=3&stats_type=plays&time_range=30")
@ -655,9 +656,36 @@ def get_tautulli_summary():
if len(top_users) >= 3:
break
# Active viewers (10d) — everyone with a play in the window, for
# the Watch page's "click a name for their 48h history" list.
# A dedicated call rather than reusing top_played/top_users
# above: those are capped at 3 and tuned for different windows
# (all-time-ish top-3, 30d top-10), neither of which is "who's
# been around lately".
cutoff_date = (datetime.now() - timedelta(days=10)).strftime("%Y-%m-%d")
active_data = tautulli_get("get_history", f"&length=1000&order_column=date&order_dir=desc&after={cutoff_date}")
active_slots = []
if isinstance(active_data, dict):
active_slots = active_data.get("data", [])
if isinstance(active_slots, dict):
active_slots = active_slots.get("data", [])
active_by_user = {}
for entry in active_slots:
username = entry.get("friendly_name") or entry.get("user", "Unknown")
if username.lower() == "ninja_hippo":
continue
rec = active_by_user.setdefault(username, {"username": username, "plays": 0, "last_watched_ts": 0})
rec["plays"] += 1
rec["last_watched_ts"] = max(rec["last_watched_ts"], entry.get("date", 0) or 0)
active_users = sorted(active_by_user.values(), key=lambda x: x["last_watched_ts"], reverse=True)
for u in active_users:
u["last_watched"] = (datetime.fromtimestamp(u["last_watched_ts"]).strftime("%d %b · %H:%M")
if u["last_watched_ts"] else "")
_tautulli_stats_cache = {
"data": {"top_tv_list": top_tv_list, "top_movie_list": top_movie_list,
"top_played": top_played, "top_users": top_users},
"top_played": top_played, "top_users": top_users,
"active_users": active_users},
"expires": now + TAUTULLI_CACHE_TTL,
}
@ -708,6 +736,7 @@ def get_tautulli_summary():
"top_movie_list": top_movie_list,
"top_played": top_played,
"top_users": top_users,
"active_users": active_users,
"active_streams": stream_count,
"sessions": sessions,
}
@ -715,6 +744,41 @@ def get_tautulli_summary():
return {"status": "error", "error": str(e)}
def get_user_watch_history(username, hours=48):
"""One viewer's plays in the last `hours`, for the Watch page's
per-user drill-down. Fetched on demand, not cached — this is a click
target, not something every page load needs to pay for."""
try:
cutoff_ts = time.time() - hours * 3600
encoded = requests.utils.quote(username)
hist = tautulli_get("get_history", f"&user={encoded}&length=200&order_column=date&order_dir=desc")
slots = []
if isinstance(hist, dict):
slots = hist.get("data", [])
if isinstance(slots, dict):
slots = slots.get("data", [])
items = []
for entry in slots:
ts = entry.get("date", 0) or 0
if ts < cutoff_ts:
continue
try:
percent = int(float(entry.get("percent_complete", 0) or 0))
except (TypeError, ValueError):
percent = 0
items.append({
"title": entry.get("full_title") or entry.get("title", "Unknown"),
"media_type": entry.get("media_type", ""),
"percent": max(0, min(100, percent)),
"when": datetime.fromtimestamp(ts).strftime("%d %b · %H:%M") if ts else "",
})
# Note: "items" collides with dict.items in Jinja attribute lookup
# (getattr wins over __getitem__), so the template key is "entries".
return {"status": "ok", "entries": items}
except Exception as e:
return {"status": "error", "error": str(e)}
# ---------------------------------------------------------------------------
# Ombi
# ---------------------------------------------------------------------------
@ -1616,7 +1680,10 @@ def home():
@login_required
def watch_page():
r = fetch_services(["tautulli"])
return render_template("watch.html", page="watch", tautulli=r["tautulli"])
selected_user = request.args.get("user", "").strip()
user_history = get_user_watch_history(selected_user) if selected_user else None
return render_template("watch.html", page="watch", tautulli=r["tautulli"],
selected_user=selected_user, user_history=user_history)
@app.route("/library")