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
|
pipeline, manual container restart steps, deploy verification, and the
|
||||||
Nginx Proxy Manager / TLS setup and its Cox-port-80/DNS-01 constraint.
|
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
|
## Logins
|
||||||
|
|
||||||
See the `manage-accounts` skill for the multi-user vs. legacy
|
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
|
# 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.0.0")
|
APP_VERSION = os.environ.get("APP_VERSION", "2.1.0")
|
||||||
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-29")
|
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-31")
|
||||||
|
|
||||||
# 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.
|
||||||
|
|
@ -612,6 +612,7 @@ def get_tautulli_summary():
|
||||||
top_movie_list = cached["top_movie_list"]
|
top_movie_list = cached["top_movie_list"]
|
||||||
top_played = cached["top_played"]
|
top_played = cached["top_played"]
|
||||||
top_users = cached["top_users"]
|
top_users = cached["top_users"]
|
||||||
|
active_users = cached["active_users"]
|
||||||
else:
|
else:
|
||||||
# Top 3 most watched TV shows (30d)
|
# 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")
|
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:
|
if len(top_users) >= 3:
|
||||||
break
|
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 = {
|
_tautulli_stats_cache = {
|
||||||
"data": {"top_tv_list": top_tv_list, "top_movie_list": top_movie_list,
|
"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,
|
"expires": now + TAUTULLI_CACHE_TTL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -708,6 +736,7 @@ def get_tautulli_summary():
|
||||||
"top_movie_list": top_movie_list,
|
"top_movie_list": top_movie_list,
|
||||||
"top_played": top_played,
|
"top_played": top_played,
|
||||||
"top_users": top_users,
|
"top_users": top_users,
|
||||||
|
"active_users": active_users,
|
||||||
"active_streams": stream_count,
|
"active_streams": stream_count,
|
||||||
"sessions": sessions,
|
"sessions": sessions,
|
||||||
}
|
}
|
||||||
|
|
@ -715,6 +744,41 @@ def get_tautulli_summary():
|
||||||
return {"status": "error", "error": str(e)}
|
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
|
# Ombi
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -1616,7 +1680,10 @@ def home():
|
||||||
@login_required
|
@login_required
|
||||||
def watch_page():
|
def watch_page():
|
||||||
r = fetch_services(["tautulli"])
|
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")
|
@app.route("/library")
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,29 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
{%- set sessions = tautulli.sessions or [] -%}
|
{%- 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 '' }}">
|
<div class="tt-field {{ 'warn' if sessions else '' }}">
|
||||||
<span>Streaming Now</span>
|
<span>Streaming Now</span>
|
||||||
<span class="tt-field-meta">{{ tautulli.active_streams }} active</span>
|
<span class="tt-field-meta">{{ tautulli.active_streams }} active</span>
|
||||||
|
|
@ -75,5 +98,23 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue