diff --git a/CLAUDE.md b/CLAUDE.md index 10ca8d5..5e06912 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 · +`) 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 diff --git a/app.py b/app.py index 69f014c..b8e3977 100644 --- a/app.py +++ b/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.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") diff --git a/templates/watch.html b/templates/watch.html index 3d0b63c..335cec6 100644 --- a/templates/watch.html +++ b/templates/watch.html @@ -11,6 +11,29 @@ {% else %} {%- set sessions = tautulli.sessions or [] -%} + {% if selected_user %} +
History — {{ selected_user }}last 48 hours
+ {% if user_history.status != 'ok' %} + {{ service_error('Tautulli', user_history.error) }} + {% elif user_history.entries %} +
+ {% for it in user_history.entries %} +
+ {{ (it.media_type or 'play') | upper }} + + {{ it.title }} + {{ it.when }} + + {{ it.percent }}% +
+ {% endfor %} +
+ {% else %} +

Nothing played in the last 48 hours.

+ {% endif %} +

← All viewers

+ {% endif %} +
Streaming Now {{ tautulli.active_streams }} active @@ -75,5 +98,23 @@ {% endfor %}
{% endif %} + +
Active Viewerslast 10 days
+ {% if tautulli.active_users %} + + {% else %} +

No viewers in the last 10 days.

+ {% endif %} {% endif %} {% endblock %}