Compare commits

...

2 commits

Author SHA1 Message Date
Barely Removable
8115ed4855 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
2026-08-31 07:56:18 -07:00
Barely Removable
cf2b5acd3e Document that every commit should bump APP_VERSION
Keeps the footer version string a trustworthy signal that a deploy
actually landed, per deploy-arr-summary verification steps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kn4ZaFd6RCnbMewMyMsfe
2026-08-31 07:45:42 -07:00
3 changed files with 119 additions and 4 deletions

View file

@ -202,6 +202,13 @@ See the `deploy-arr-summary` skill for the Forgejo push-to-deploy
pipeline, manual container restart steps, deploy verification, and the
Nginx Proxy Manager / TLS setup and its Cox-port-80/DNS-01 constraint.
**Every commit should be matched with a version change.** Bump
`APP_VERSION` / `APP_VERSION_DATE` near the top of `app.py` as part of
the same commit. This is what makes the footer (`v<APP_VERSION> ·
<APP_VERSION_DATE>`) a trustworthy signal that a given deploy actually
landed — a commit without a version bump can't be distinguished from
"not deployed yet" by looking at the live site.
## Logins
See the `manage-accounts` skill for the multi-user vs. legacy

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")

View file

@ -11,6 +11,29 @@
{% else %}
{%- set sessions = tautulli.sessions or [] -%}
{% if selected_user %}
<div class="tt-field"><span>History &mdash; {{ selected_user }}</span><span class="tt-field-meta">last 48 hours</span></div>
{% if user_history.status != 'ok' %}
{{ service_error('Tautulli', user_history.error) }}
{% elif user_history.entries %}
<div class="tt-rows">
{% for it in user_history.entries %}
<div class="tt-row">
<span class="tt-row-name">{{ (it.media_type or 'play') | upper }}</span>
<span class="tt-row-body">
<span class="tt-row-title">{{ it.title }}</span>
<span class="tt-row-sub">{{ it.when }}</span>
</span>
<span class="tt-row-val">{{ it.percent }}%</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="tt-empty">Nothing played in the last 48 hours.</p>
{% endif %}
<p class="tt-empty"><a href="{{ request.script_root }}/watch">&larr; All viewers</a></p>
{% endif %}
<div class="tt-field {{ 'warn' if sessions else '' }}">
<span>Streaming Now</span>
<span class="tt-field-meta">{{ tautulli.active_streams }} active</span>
@ -75,5 +98,23 @@
{% endfor %}
</div>
{% endif %}
<div class="tt-field"><span>Active Viewers</span><span class="tt-field-meta">last 10 days</span></div>
{% if tautulli.active_users %}
<div class="tt-rows">
{% for u in tautulli.active_users %}
<a class="tt-row" href="{{ request.script_root }}/watch?user={{ u.username | urlencode }}">
<span class="tt-row-num">{{ loop.index }}</span>
<span class="tt-row-body">
<span class="tt-row-title">{{ u.username }}</span>
<span class="tt-row-sub">Last watched {{ u.last_watched }}</span>
</span>
<span class="tt-row-val">{{ u.plays }} PLAYS</span>
</a>
{% endfor %}
</div>
{% else %}
<p class="tt-empty">No viewers in the last 10 days.</p>
{% endif %}
{% endif %}
{% endblock %}