Compare commits
2 commits
517559a460
...
8115ed4855
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8115ed4855 | ||
|
|
cf2b5acd3e |
3 changed files with 119 additions and 4 deletions
|
|
@ -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
75
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")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,29 @@
|
|||
{% else %}
|
||||
{%- set sessions = tautulli.sessions or [] -%}
|
||||
|
||||
{% if selected_user %}
|
||||
<div class="tt-field"><span>History — {{ 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">← 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 %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue