Fix viewing-history artwork and add transcode playback metrics
Magent CI/CD / verify (push) Successful in 10m43s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m9s

This commit is contained in:
2026-09-08 16:15:16 +12:00
parent 12611a9819
commit e7e4c9eff3
8 changed files with 413 additions and 11 deletions
+9
View File
@@ -6,10 +6,19 @@ from pydantic import BaseModel, ConfigDict, field_validator
from ..auth import get_current_user
from ..clients.jellystat import HistoryLimitError, JellystatError
from ..services.insights import get_insights
from ..services.insights_artwork import get_artwork
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/insights", tags=["insights"])
@router.get("/artwork/{item_id}")
async def artwork(item_id: str, token: Annotated[str, Query(max_length=100)], user: dict = Depends(get_current_user)):
content, media_type = await get_artwork(user, get_runtime_settings(), item_id, token)
return Response(content=content, media_type=media_type,
headers={"Cache-Control": "private, max-age=600", "Vary": "Cookie, Authorization", "X-Content-Type-Options": "nosniff"})
class InsightsQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
days: int = 30
+52 -3
View File
@@ -1,5 +1,6 @@
import asyncio
import hashlib
import json
import math
import sqlite3
import time
@@ -12,10 +13,50 @@ from ..clients.jellyfin import JellyfinClient
from ..clients.jellystat import JellystatClient, JellystatError
from ..runtime import get_runtime_settings
from .jellyfin_identity import link_user, linked_user_id
from .insights_artwork import item_id as artwork_item_id, with_artwork
_cache: dict[tuple, tuple[float, dict]] = {}
CACHE_SECONDS = 60
HARDWARE = {"amf": "AMD AMF", "qsv": "Intel Quick Sync", "nvenc": "NVIDIA NVENC",
"v4l2m2m": "V4L2", "vaapi": "VAAPI", "videotoolbox": "Apple VideoToolbox", "rkmpp": "Rockchip MPP"}
HARDWARE_ENUM = {0: "none", 1: "amf", 2: "qsv", 3: "nvenc", 4: "v4l2m2m", 5: "vaapi", 6: "videotoolbox", 7: "rkmpp"}
def add_transcoding(row, duration, media_type, totals, hardware, audio_codecs):
# Jellystat can retain stale transcoding metadata after a switch to DirectPlay.
method = row.get("PlayMethod")
if method not in {"Transcode", "DirectStream"}:
return
info = row.get("TranscodingInfo")
if isinstance(info, str):
try:
info = json.loads(info)
except ValueError:
info = None
info = info if isinstance(info, dict) else {}
video_present = media_type in {"movie", "episode"} or bool(info.get("VideoCodec"))
if method == "Transcode" and video_present:
if info.get("IsVideoDirect") is False:
totals["video_minutes"] += duration
value = info.get("HardwareAccelerationType")
value = HARDWARE_ENUM.get(value) if type(value) is int else str(value or "").strip().lower()
if value in HARDWARE:
totals["hardware_video_minutes"] += duration
hardware[HARDWARE[value]] += duration
elif value == "none":
totals["software_video_minutes"] += duration
else:
totals["unknown_hardware_minutes"] += duration
elif info.get("IsVideoDirect") is not True:
totals["unknown_video_minutes"] += duration
if info.get("IsAudioDirect") is False:
totals["audio_minutes"] += duration
codec = str(info.get("AudioCodec") or "Unknown").upper()[:30]
audio_codecs[codec] += duration
elif info.get("IsAudioDirect") is not True:
totals["unknown_audio_minutes"] += duration
def _date(value) -> datetime:
try:
@@ -85,6 +126,9 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime) ->
daily_seconds = defaultdict(float)
clients = defaultdict(float)
methods = defaultdict(float)
transcoding = dict.fromkeys(("video_minutes", "audio_minutes", "hardware_video_minutes", "software_video_minutes",
"unknown_hardware_minutes", "unknown_video_minutes", "unknown_audio_minutes"), 0.0)
hardware, audio_codecs = defaultdict(float), defaultdict(float)
titles = {}
movie_ids, episode_ids, seen = set(), set(), set()
recent = []
@@ -107,6 +151,7 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime) ->
episode_id = row.get("EpisodeId")
library_type = library_types.get(str(row.get("ParentId")), "")
media_type = "episode" if episode_id else "movie" if library_type == "movies" else "other"
add_transcoding(row, duration / 60, media_type, transcoding, hardware, audio_codecs)
if media_type == "episode":
episode_ids.add(str(episode_id))
elif media_type == "movie":
@@ -126,7 +171,7 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime) ->
recent.append({"id": row_id, "title": name, "series": series, "type": media_type,
"episode": f"S{row.get('SeasonNumber', '?')} · E{row.get('EpisodeNumber', '?')}" if episode_id else None,
"minutes": round(duration / 60, 1), "played_at": date.isoformat(), "client": client,
"method": method})
"method": method, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))})
count = (end.date() - start.date()).days + 1
daily = [{"date": (start.date() + timedelta(days=i)).isoformat(),
"minutes": round(daily_seconds.get((start.date() + timedelta(days=i)).isoformat(), 0) / 60, 2)} for i in range(count)]
@@ -149,6 +194,10 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime) ->
"daily": daily, "top_titles": top,
"clients": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(clients.items(), key=lambda pair: -pair[1])[:6]],
"methods": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(methods.items(), key=lambda pair: -pair[1])],
"transcoding": {**{name: round(value, 1) for name, value in transcoding.items()},
"hardware": [{"name": name, "minutes": round(value, 1)} for name, value in sorted(hardware.items(), key=lambda pair: -pair[1])],
"audio_codecs": [{"name": name, "minutes": round(value, 1)} for name, value in sorted(audio_codecs.items(), key=lambda pair: -pair[1])],
"gpu_busy_minutes": None},
"recent": sorted(recent, key=lambda row: row["played_at"], reverse=True)[:20]}
@@ -169,7 +218,7 @@ async def get_insights(user: dict, days: int) -> dict:
runtime.jellyfin_base_url, identity, days)
cached = _cache.get(key)
if cached and cached[0] > time.monotonic():
return {**base, **cached[1]}
return {**base, **with_artwork(cached[1], user, runtime)}
history, libraries = await client.get_user_history(identity, start, end)
data = {**summarize(history, libraries, start, end), "state": "ready", "updated_at": end.isoformat(),
"period_start": start.isoformat(), "period_end": end.isoformat()}
@@ -178,4 +227,4 @@ async def get_insights(user: dict, days: int) -> dict:
if len(_cache) >= 128:
_cache.pop(next(iter(_cache)))
_cache[key] = (time.monotonic() + CACHE_SECONDS, data)
return {**base, **data}
return {**base, **with_artwork(data, user, runtime)}
+98
View File
@@ -0,0 +1,98 @@
"""Private Jellyfin thumbnails for items returned in a user's own viewing history."""
import asyncio
import hashlib
import hmac
import re
import time
from collections import OrderedDict
import httpx
from fastapi import HTTPException
from ..config import settings
TOKEN_SECONDS = 3600
MAX_IMAGE_BYTES = 1024 * 1024
MAX_CACHE_BYTES = 16 * 1024 * 1024
_cache = OrderedDict()
_downloads = asyncio.Semaphore(6)
def item_id(value):
value = str(value or "").replace("-", "").lower()
return value if re.fullmatch(r"[a-f0-9]{32}", value) else None
def source(runtime):
return hashlib.sha256(f"{runtime.jellyfin_base_url}|{runtime.jellyfin_api_key}".encode()).hexdigest()
def signature(user, runtime, media_id, expires):
message = f"insights-artwork\n{user['username']}\n{source(runtime)}\n{media_id}\n{expires}"
return hmac.new(settings.jwt_secret.encode(), message.encode(), hashlib.sha256).hexdigest()
def with_artwork(data, user, runtime):
expires = int(time.time()) + TOKEN_SECONDS
recent = []
for play in data.get("recent", []):
row = {**play}
media_id = row.pop("artwork_item_id", None)
row["artwork_url"] = None
if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key:
token = f"{expires}.{signature(user, runtime, media_id, expires)}"
row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}"
recent.append(row)
return {**data, "recent": recent}
def verify_artwork_token(user, runtime, media_id, token):
if not settings.jwt_secret or not re.fullmatch(r"[a-f0-9]{32}", media_id):
raise HTTPException(404, "Artwork unavailable")
if not re.fullmatch(r"[0-9]{1,12}\.[a-f0-9]{64}", token):
raise HTTPException(403, "Artwork link is invalid or expired")
try:
expires_text, supplied = token.split(".", 1)
expires = int(expires_text)
except (ValueError, TypeError):
raise HTTPException(403, "Artwork link is invalid or expired") from None
now = int(time.time())
if expires < now or expires > now + TOKEN_SECONDS or not hmac.compare_digest(supplied, signature(user, runtime, media_id, expires)):
raise HTTPException(403, "Artwork link is invalid or expired")
async def get_artwork(user, runtime, media_id, token):
verify_artwork_token(user, runtime, media_id, token)
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
raise HTTPException(404, "Artwork unavailable")
key = (source(runtime), media_id)
async with _downloads:
cached = _cache.get(key)
if cached and cached[0] > time.monotonic():
_cache.move_to_end(key)
return cached[1], cached[2]
try:
async with httpx.AsyncClient(timeout=8.0) as client:
async with client.stream("GET", f"{runtime.jellyfin_base_url.rstrip('/')}/Items/{media_id}/Images/Primary",
headers={"X-Emby-Token": runtime.jellyfin_api_key},
params={"maxWidth": 120, "maxHeight": 180, "quality": 85, "format": "Webp"}) as response:
response.raise_for_status()
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type not in {"image/jpeg", "image/png", "image/webp"}:
raise ValueError()
content = bytearray()
async for chunk in response.aiter_bytes():
content.extend(chunk)
if len(content) > MAX_IMAGE_BYTES:
raise ValueError()
if not content:
raise ValueError()
except (httpx.HTTPError, ValueError) as exc:
raise HTTPException(404, "Artwork unavailable") from exc
for expired in [entry for entry, value in _cache.items() if value[0] <= time.monotonic()]:
_cache.pop(expired, None)
while _cache and (len(_cache) >= 128 or sum(len(value[1]) for value in _cache.values()) + len(content) > MAX_CACHE_BYTES):
_cache.popitem(last=False)
_cache[key] = (time.monotonic() + 600, bytes(content), content_type)
return bytes(content), content_type
+165
View File
@@ -0,0 +1,165 @@
import asyncio
import base64
import copy
import unittest
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from urllib.parse import parse_qs, urlsplit
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from backend.app.routers import insights as router
from backend.app.services import insights, insights_artwork as artwork
from backend.tests.test_insights import NOW, LIBRARIES, USER, play
ITEM = "a" * 32
OTHER = "b" * 32
PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jK1sAAAAASUVORK5CYII=")
def transcode(activity, minutes, video_direct=False, audio_direct=False, hardware="nvenc", **kwargs):
return play(activity, NowPlayingItemId=ITEM, PlaybackDuration=minutes * 60, PlayMethod="Transcode",
TranscodingInfo={"IsVideoDirect": video_direct, "IsAudioDirect": audio_direct,
"HardwareAccelerationType": hardware, "VideoCodec": "h264", "AudioCodec": "aac"}, **kwargs)
class TranscodingSummaryTests(unittest.TestCase):
def test_gpu_audio_software_and_unknown_time_are_separate_and_deduplicated(self):
gpu = transcode("gpu", 10)
rows = [gpu, dict(gpu), transcode("audio-only", 5, video_direct=True),
transcode("software", 3, audio_direct=True, hardware="none"),
transcode("remux", 2, video_direct=True, audio_direct=True),
{**transcode("stale", 1), "PlayMethod": "DirectPlay"},
transcode("unknown-hardware", 2, audio_direct=True, hardware=None),
{**transcode("unknown-streams", 1), "TranscodingInfo": None},
transcode("old", 100, ActivityDateInserted=(NOW - timedelta(days=31)).isoformat())]
result = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=30), NOW)
stats = result["transcoding"]
self.assertEqual(stats["hardware_video_minutes"], 10)
self.assertEqual(stats["audio_minutes"], 15)
self.assertEqual(stats["video_minutes"], 15)
self.assertEqual(stats["software_video_minutes"], 3)
self.assertEqual(stats["unknown_hardware_minutes"], 2)
self.assertEqual(stats["unknown_video_minutes"], 1)
self.assertEqual(stats["unknown_audio_minutes"], 1)
self.assertEqual(stats["hardware"], [{"name": "NVIDIA NVENC", "minutes": 10}])
self.assertIsNone(stats["gpu_busy_minutes"])
def test_audio_media_cannot_accumulate_video_gpu_time(self):
row = transcode("music", 4, ParentId="music")
row["TranscodingInfo"]["VideoCodec"] = None
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
self.assertEqual(result["audio_minutes"], 4)
self.assertEqual(result["video_minutes"], 0)
self.assertEqual(result["hardware_video_minutes"], 0)
def test_direct_stream_counts_audio_but_does_not_claim_video_encoding(self):
row = {**transcode("stream", 5), "PlayMethod": "DirectStream"}
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
self.assertEqual(result["audio_minutes"], 5)
self.assertEqual(result["hardware_video_minutes"], 0)
def test_numeric_hardware_enum_and_legacy_json_are_supported(self):
import json
row = transcode("enum", 5, hardware=3)
row["TranscodingInfo"] = json.dumps(row["TranscodingInfo"])
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
self.assertEqual(result["hardware_video_minutes"], 5)
row["TranscodingInfo"] = "invalid JSON"
result = insights.summarize([row], LIBRARIES, NOW - timedelta(days=7), NOW)["transcoding"]
self.assertEqual(result["unknown_video_minutes"], 5)
self.assertEqual(result["hardware_video_minutes"], 0)
def test_episode_artwork_uses_series_id_and_invalid_ids_are_ignored(self):
rows = [play("episode", EpisodeId=OTHER, NowPlayingItemId=ITEM),
play("invalid", NowPlayingItemId="../../secret")]
result = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=7), NOW)
indexed = {row["id"]: row for row in result["recent"]}
self.assertEqual(indexed["episode"]["artwork_item_id"], ITEM)
self.assertIsNone(indexed["invalid"]["artwork_item_id"])
class ArtworkTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="PRIVATE-API-KEY")
self.secret = patch.object(artwork.settings, "jwt_secret", "test-artwork-signing-secret")
self.secret.start()
self.addCleanup(self.secret.stop)
artwork._cache.clear()
artwork._downloads = asyncio.Semaphore(6)
def url_and_token(self, user=USER):
data = {"recent": [{"id": "play-1", "artwork_item_id": ITEM}]}
before = copy.deepcopy(data)
result = artwork.with_artwork(data, user, self.runtime)
self.assertEqual(data, before)
row = result["recent"][0]
self.assertNotIn("artwork_item_id", row)
self.assertNotIn("PRIVATE-API-KEY", row["artwork_url"])
return row["artwork_url"], parse_qs(urlsplit(row["artwork_url"]).query)["token"][0]
async def test_ticket_is_bound_to_user_item_server_credentials_and_time(self):
with patch.object(artwork.time, "time", return_value=1000):
_, token = self.url_and_token()
artwork.verify_artwork_token(USER, self.runtime, ITEM, token)
for user, runtime, media_id in [({**USER, "username": "different"}, self.runtime, ITEM),
(USER, self.runtime, OTHER), (USER, SimpleNamespace(jellyfin_base_url="http://other", jellyfin_api_key="PRIVATE-API-KEY"), ITEM),
(USER, SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="changed"), ITEM)]:
with self.assertRaises(HTTPException) as raised:
artwork.verify_artwork_token(user, runtime, media_id, token)
self.assertEqual(raised.exception.status_code, 403)
with patch.object(artwork.time, "time", return_value=5000), self.assertRaises(HTTPException):
artwork.verify_artwork_token(USER, self.runtime, ITEM, token)
for invalid in ["invalid", "1.", "1." + "\u2603" * 64]:
with self.assertRaises(HTTPException):
artwork.verify_artwork_token(USER, self.runtime, ITEM, invalid)
async def test_private_proxy_returns_image_and_validates_before_cache_access(self):
calls = []
def handler(request):
calls.append(request)
self.assertEqual(request.url.path, f"/Items/{ITEM}/Images/Primary")
self.assertNotIn("PRIVATE", str(request.url))
self.assertEqual(request.headers["X-Emby-Token"], "PRIVATE-API-KEY")
return httpx.Response(200, content=PNG, headers={"Content-Type": "image/png"})
real = httpx.AsyncClient
_, token = self.url_and_token()
with patch.object(artwork.httpx, "AsyncClient", side_effect=lambda **kwargs: real(transport=httpx.MockTransport(handler), **kwargs)):
self.assertEqual(await artwork.get_artwork(USER, self.runtime, ITEM, token), (PNG, "image/png"))
self.assertEqual(await artwork.get_artwork(USER, self.runtime, ITEM, token), (PNG, "image/png"))
with self.assertRaises(HTTPException):
await artwork.get_artwork({**USER, "username": "someone-else"}, self.runtime, ITEM, token)
self.assertEqual(len(calls), 1)
async def test_non_images_missing_images_and_large_images_fail_closed(self):
_, token = self.url_and_token()
real = httpx.AsyncClient
for status, body, mime in [(404, b"PRIVATE", "text/plain"), (200, b"<svg>PRIVATE</svg>", "image/svg+xml"),
(200, b"x" * (artwork.MAX_IMAGE_BYTES + 1), "image/png")]:
transport = httpx.MockTransport(lambda request: httpx.Response(status, content=body, headers={"Content-Type": mime}))
with patch.object(artwork.httpx, "AsyncClient", side_effect=lambda **kwargs: real(transport=transport, **kwargs)):
with self.assertRaises(HTTPException) as raised:
await artwork.get_artwork(USER, self.runtime, ITEM, token)
self.assertEqual(raised.exception.status_code, 404)
self.assertNotIn("PRIVATE", raised.exception.detail)
self.assertEqual(len(artwork._cache), 0)
class ArtworkRouteTests(unittest.TestCase):
def test_authentication_and_private_response_headers(self):
app = FastAPI()
app.include_router(router.router)
client = TestClient(app)
self.assertEqual(client.get(f"/insights/artwork/{ITEM}?token=invalid").status_code, 401)
app.dependency_overrides[router.get_current_user] = lambda: USER
with patch.object(router, "get_runtime_settings", return_value=None), \
patch.object(router, "get_artwork", new_callable=AsyncMock, return_value=(PNG, "image/png")):
result = client.get(f"/insights/artwork/{ITEM}?token=fixture")
self.assertEqual(result.status_code, 200)
self.assertEqual(result.content, PNG)
self.assertEqual(result.headers["cache-control"], "private, max-age=600")
self.assertEqual(result.headers["vary"], "Cookie, Authorization")
self.assertEqual(result.headers["x-content-type-options"], "nosniff")
+9 -1
View File
@@ -42,6 +42,14 @@ History comes from Jellystat's `POST /api/getUserHistory`, with the backend's li
## Validation
Backend coverage is in `backend/tests/test_insights.py`. It checks API contracts, pagination, ownership, credential masking, cache separation, time units, dates, repeat plays, media classification and empty/error states.
Backend coverage is in `backend/tests/test_insights.py` and `backend/tests/test_insights_media.py`. It checks API contracts, pagination, ownership, credential masking, cache separation, time units, dates, repeat plays, media classification, artwork authorization, transcoding attribution and empty/error states.
After building the frontend, `scripts/review_insights_ui.cjs` checks the page and configuration using fixture-only requests. Set `REVIEW_BASE`, `REVIEW_PLAYWRIGHT`, and optionally `REVIEW_DIR` to save screenshots outside the repository. Live Jellystat verification requires configuring the actual instance.
## Artwork and transcoding
Recently watched uses Jellystat's `NowPlayingItemId`, which identifies the movie or series, to load a Jellyfin primary poster. Magent proxies the image through an authenticated endpoint using a short-lived signature bound to the viewer, item and Jellyfin connection. Jellyfin credentials stay on the backend. Missing or deleted artwork falls back to a media tile. Thumbnail responses are privately cached.
How you streamed includes audio transcoding minutes and hardware-assisted video transcoding minutes. These are playback durations attributed to the recorded transcode flags, not GPU busy time or encoder runtime. Audio and video durations can overlap. Video must actually be transcoded for hardware-assisted minutes to count; a hardware label on an audio-only conversion does not count as GPU video work. Direct-play records ignore residual transcoding metadata, and missing details remain unknown.
Jellyfin exposes separate [video/audio passthrough flags and hardware type](https://github.com/jellyfin/jellyfin/blob/master/MediaBrowser.Model/Session/TranscodingInfo.cs), with [hardware type names](https://github.com/jellyfin/jellyfin/blob/master/MediaBrowser.Model/Entities/HardwareAccelerationType.cs). The existing Jellystat history does not contain GPU utilization or GPU busy-time samples, so Magent does not calculate those figures.
+36 -3
View File
@@ -8,6 +8,11 @@ import './stats.css'
type Breakdown = { name: string; minutes: number }
type Day = { date: string; minutes: number }
type Transcoding = {
video_minutes: number; audio_minutes: number; hardware_video_minutes: number; software_video_minutes: number
unknown_hardware_minutes: number; unknown_video_minutes: number; unknown_audio_minutes: number
hardware: Breakdown[]; audio_codecs: Breakdown[]; gpu_busy_minutes: null
}
type Stats = {
state: 'ready' | 'not_configured' | 'unlinked'
is_admin: boolean
@@ -16,9 +21,10 @@ type Stats = {
summary: null | { minutes: number; movies: number; episodes: number; plays: number; current_streak: number; longest_streak: number; active_days: number }
daily?: Day[]
top_titles?: { title: string; type: string; minutes: number; plays: number }[]
recent?: { id: string; title: string; series: string; episode?: string; type: string; minutes: number; played_at: string; client: string; method: string }[]
recent?: { id: string; title: string; series: string; episode?: string; type: string; minutes: number; played_at: string; client: string; method: string; artwork_url?: string | null }[]
clients?: Breakdown[]
methods?: Breakdown[]
transcoding?: Transcoding
requests: { total: number; movies: number; tv: number; pending: number; approved: number; declined: number; recent: { request_id: number; title: string; media_type: string; status: number }[] }
}
@@ -55,6 +61,33 @@ function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) {
return <section className="stats-panel"><div className="stats-panel-heading"><h2>{title}</h2></div>{rows.length ? <div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{number(row.minutes)} min</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div> : <p className="stats-muted">Your next watch will start the story here.</p>}</section>
}
const minutes = (value: number) => `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} min`
function StreamingCard({ rows, transcoding }: { rows: Breakdown[]; transcoding?: Transcoding }) {
const total = rows.reduce((sum, row) => sum + row.minutes, 0)
return <section className="stats-panel stats-streaming">
<div className="stats-panel-heading"><h2>How you streamed</h2></div>
<div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{minutes(row.minutes)}</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div>
{transcoding && <div className="stats-transcoding">
<h3>Transcoding playback time</h3>
<div className="stats-transcode-metrics">
<div><span>GPU-assisted video</span><strong>{transcoding.hardware_video_minutes === 0 && (transcoding.unknown_hardware_minutes > 0 || transcoding.unknown_video_minutes > 0) ? 'Not recorded' : minutes(transcoding.hardware_video_minutes)}</strong><small>{transcoding.hardware.map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Hardware-accelerated video'}</small></div>
<div><span>Audio transcoding</span><strong>{transcoding.audio_minutes === 0 && transcoding.unknown_audio_minutes > 0 ? 'Not recorded' : minutes(transcoding.audio_minutes)}</strong><small>{transcoding.audio_codecs.slice(0, 3).map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Audio converted for your player'}</small></div>
</div>
<dl className="stats-transcode-details"><div><dt>Total video transcoding</dt><dd>{transcoding.video_minutes === 0 && transcoding.unknown_video_minutes > 0 ? 'Not recorded' : minutes(transcoding.video_minutes)}</dd></div>{transcoding.software_video_minutes > 0 && <div><dt>Software video</dt><dd>{minutes(transcoding.software_video_minutes)}</dd></div>}{transcoding.unknown_hardware_minutes > 0 && <div><dt>Video hardware not recorded</dt><dd>{minutes(transcoding.unknown_hardware_minutes)}</dd></div>}</dl>
{(transcoding.unknown_audio_minutes > 0 || transcoding.unknown_video_minutes > 0) && <p className="stats-muted">Some stream details are missing: video {minutes(transcoding.unknown_video_minutes)}, audio {minutes(transcoding.unknown_audio_minutes)}.</p>}
<p className="stats-muted">Playback minutes, with audio and video counted separately. They can overlap. GPU busy time is not recorded by Jellystat.</p>
</div>}
</section>
}
function RecentArtwork({ url, type }: { url?: string | null; type: string }) {
const [failed, setFailed] = useState(false)
return <div className={`stats-media-icon stats-media-icon-${type}`} aria-hidden="true">
{url && !failed ? <img src={`${getApiBase()}${url}`} alt="" width={44} height={66} loading="lazy" onError={() => setFailed(true)} /> : <span>{type === 'episode' ? 'TV' : type === 'movie' ? 'MV' : '▶'}</span>}
</div>
}
export default function InsightsPage() {
const router = useRouter()
const [days, setDays] = useState(30)
@@ -120,11 +153,11 @@ export default function InsightsPage() {
<div className="stats-three-grid">
<section className="stats-panel"><div className="stats-panel-heading"><h2>Most watched</h2><span className="stats-unit">By minutes</span></div>{data.top_titles?.length ? <ol className="stats-top-titles">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><span className="stats-rank">{String(index + 1).padStart(2, '0')}</span><div><strong>{title.title}</strong><small>{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays</small></div><span>{number(title.minutes)}<small>min</small></span></li>)}</ol> : <p className="stats-muted">Your favourites will find their place here.</p>}</section>
<BreakdownCard title="Your players" rows={data.clients ?? []} />
<BreakdownCard title="How you streamed" rows={data.methods ?? []} />
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
</div>
</>}
{data && <div className="stats-main-grid">
{summary && <section className="stats-panel stats-history"><div className="stats-panel-heading"><h2>Recently watched</h2><span className="stats-unit">Latest 20 plays</span></div>{data.recent?.length ? <div className="stats-history-list">{data.recent.map((play) => <article key={play.id}><div className={`stats-media-icon stats-media-icon-${play.type}`} aria-hidden="true">{play.type === 'episode' ? 'TV' : play.type === 'movie' ? 'MV' : '▶'}</div><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.client}</small><span>{play.client} · {play.method}</span></div><div className="stats-history-time"><strong>{number(play.minutes)} min</strong><time dateTime={play.played_at}>{dateLabel(play.played_at)}</time></div></article>)}</div> : <p className="stats-muted">Plays recorded by Jellystat will appear here.</p>}</section>}
{summary && <section className="stats-panel stats-history"><div className="stats-panel-heading"><h2>Recently watched</h2><span className="stats-unit">Latest 20 plays</span></div>{data.recent?.length ? <div className="stats-history-list">{data.recent.map((play) => <article key={play.id}><RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} /><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.type === 'movie' ? 'Movie' : 'Media'}</small><span>{play.client} · {play.method}</span></div><div className="stats-history-time"><strong>{number(play.minutes)} min</strong><time dateTime={play.played_at}>{dateLabel(play.played_at)}</time></div></article>)}</div> : <p className="stats-muted">Plays recorded by Jellystat will appear here.</p>}</section>}
<section className="stats-panel stats-requests"><div className="stats-panel-heading"><h2>Your requests</h2><a href="/">View all</a></div><div className="stats-request-total"><strong>{data.requests.total}</strong><span>submitted in the past {days} days</span></div><div className="stats-request-counts"><span><strong>{data.requests.pending}</strong> Pending</span><span><strong>{data.requests.approved}</strong> Approved</span><span><strong>{data.requests.declined}</strong> Declined</span></div>{data.requests.recent.length > 0 ? <ul className="stats-request-list">{data.requests.recent.map((request) => <li key={request.request_id}><a href={`/requests/${request.request_id}`}>{request.title || `Request ${request.request_id}`}<span aria-hidden="true"></span></a></li>)}</ul> : <p className="stats-muted">Something on your watchlist? <a href="/new-requests">Make a request.</a></p>}</section>
</div>}
{summary && <p className="stats-footnote">Private to your account · Stats come from Jellystat and may take a minute to refresh. Counts describe plays, including unfinished watches. Movies are identified from Jellystats movie libraries; other media still contributes to watch time. Charts and streaks use UTC and the activity dates recorded by Jellystat.</p>}
+13 -1
View File
@@ -51,7 +51,19 @@
.stats-history-list { display: grid; }
.stats-history-list article { display: flex; align-items: center; gap: 14px; padding: 15px 0; border-top: 1px solid var(--ops-line-soft); }
.stats-history-list article:first-child { padding-top: 0; border-top: 0; }
.stats-media-icon { display: grid; place-items: center; flex: 0 0 40px; height: 48px; border-radius: 6px; background: #373043; color: #cfc1eb; font: 10px "JetBrains Mono", monospace; }
.stats-media-icon { display: grid; place-items: center; flex: 0 0 44px; height: 66px; border-radius: 6px; overflow: hidden; background: #373043; color: #cfc1eb; font: 10px "JetBrains Mono", monospace; }
.stats-media-icon img { display: block; width: 100%; height: 100%; object-fit: cover; }
.stats-transcoding { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--ops-line-soft); }
.stats-transcoding h3 { margin: 0 0 16px; color: var(--ops-text); font-size: 13px; font-weight: 500; }
.stats-transcode-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.stats-transcode-metrics > div { display: grid; align-content: start; gap: 8px; min-width: 0; }
.stats-transcode-metrics span { color: var(--ops-muted); font-size: 11px; }
.stats-transcode-metrics strong { color: #d1c6ff; font-size: 20px; font-weight: 500; overflow-wrap: anywhere; }
.stats-transcode-metrics small { color: var(--ops-faint); font-size: 10px; line-height: 1.7; overflow-wrap: anywhere; }
.stats-transcode-details { display: grid; gap: 10px; margin: 20px 0 12px; }
.stats-transcode-details > div { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; font-size: 11px; color: var(--ops-muted); }
.stats-transcode-details dd { margin: 0; white-space: nowrap; color: var(--ops-faint); }
.stats-transcoding > p { margin: 12px 0 0; font-size: 10px; }
.stats-media-icon-movie { background: #3c322c; color: #e5bfa8; }
.stats-history-title { flex: 1; min-width: 0; }
.stats-history-title strong { display: block; color: var(--ops-text); font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }
+31 -3
View File
@@ -13,6 +13,7 @@ const output = process.env.REVIEW_DIR
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }])
let mode = 'ready'
let role = 'admin'
let brokenArtwork = false
const periods = []
const mutations = []
let settings = [
@@ -27,14 +28,19 @@ const output = process.env.REVIEW_DIR
top_titles: [{ title: 'Severance', type: 'series', minutes: 460, plays: 10 }, { title: 'Arrival', type: 'movie', minutes: 116, plays: 1 }, { title: 'The Bear', type: 'series', minutes: 91, plays: 3 }],
clients: [{ name: 'Jellyfin Web', minutes: 740 }, { name: 'Jellyfin for Android TV', minutes: 301 }],
methods: [{ name: 'Direct play', minutes: 880 }, { name: 'Transcode', minutes: 161 }],
recent: [{ id: '1', title: 'Good News About Hell', series: 'Severance', type: 'episode', episode: 'S1 · E1', minutes: 57, played_at: '2026-09-07T10:00:00Z', client: 'Jellyfin Web', method: 'Direct play' },
{ id: '2', title: 'Arrival', series: '', type: 'movie', minutes: 116, played_at: '2026-09-06T10:00:00Z', client: 'Jellyfin for Android TV', method: 'Direct play' }],
transcoding: { video_minutes: 100, audio_minutes: 61, hardware_video_minutes: 80, software_video_minutes: 20, unknown_hardware_minutes: 0, unknown_video_minutes: 0, unknown_audio_minutes: 0, hardware: [{ name: 'NVIDIA NVENC', minutes: 80 }], audio_codecs: [{ name: 'AAC', minutes: 61 }], gpu_busy_minutes: null },
recent: [{ id: '1', title: 'Good News About Hell', series: 'Severance', type: 'episode', episode: 'S1 · E1', minutes: 57, played_at: '2026-09-07T10:00:00Z', client: 'Jellyfin Web', method: 'Direct play', artwork_url: '/insights/artwork/series?token=fixture' },
{ id: '2', title: 'Arrival', series: '', type: 'movie', minutes: 116, played_at: '2026-09-06T10:00:00Z', client: 'Jellyfin for Android TV', method: 'Direct play', artwork_url: '/insights/artwork/movie?token=fixture' }],
requests: { total: 3, movies: 2, tv: 1, pending: 1, approved: 2, declined: 0, recent: [{ request_id: 12, title: 'Dune: Part Two', media_type: 'movie', status: 2 }] },
})
await context.route('**/api/**', async (route) => {
const request = route.request()
const url = new URL(request.url())
if (url.pathname === '/api/auth/me') return route.fulfill({ json: { username: 'Fixture viewer', role } })
if (url.pathname.startsWith('/api/insights/artwork/')) {
if (brokenArtwork) return route.fulfill({ status: 404, body: 'Artwork unavailable' })
return route.fulfill({ contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 180"><rect width="120" height="180" fill="#243946"/><path d="M0 150L65 35L120 150" fill="#c8bdaa"/><circle cx="75" cy="42" r="20" fill="#dbe6de"/></svg>' })
}
if (url.pathname === '/api/insights') {
const days = Number(url.searchParams.get('days'))
periods.push(days)
@@ -46,6 +52,12 @@ const output = process.env.REVIEW_DIR
for (const key of Object.keys(result.summary)) result.summary[key] = 0
result.daily = daily.map((day) => ({ ...day, minutes: 0 }))
result.top_titles = result.recent = result.clients = result.methods = []
} else if (mode === 'missing_transcoding') {
for (const key of Object.keys(result.transcoding)) {
if (typeof result.transcoding[key] === 'number') result.transcoding[key] = 0
}
result.transcoding.hardware = result.transcoding.audio_codecs = []
result.transcoding.unknown_video_minutes = result.transcoding.unknown_audio_minutes = 161
} else if (mode !== 'ready') result.summary = null
return route.fulfill({ json: result })
}
@@ -73,6 +85,11 @@ const output = process.env.REVIEW_DIR
await page.setViewportSize({ width, height: 1000 })
await page.goto(base + '/insights')
await page.getByRole('heading', { name: 'Your viewing rhythm' }).waitFor()
await page.locator('.stats-history').scrollIntoViewIfNeeded()
await page.waitForFunction(() => [...document.querySelectorAll('.stats-media-icon img')].length === 2 && [...document.querySelectorAll('.stats-media-icon img')].every((img) => img.complete && img.naturalWidth > 0))
await page.evaluate(() => window.scrollTo(0, 0))
assert.match(await page.locator('.stats-transcode-metrics').innerText(), /GPU-assisted video[\s\S]*80 min[\s\S]*Audio transcoding[\s\S]*61 min/)
assert.match(await page.locator('.stats-transcoding').innerText(), /GPU busy time is not recorded/)
assert.notEqual(await page.locator('.stats-period [aria-pressed=true]').evaluate((element) => getComputedStyle(element).backgroundColor), await page.locator('.stats-period [aria-pressed=false]').first().evaluate((element) => getComputedStyle(element).backgroundColor), 'Selected period must be visibly different')
await page.locator('.stats-chart-bars button').first().click()
assert.match(await page.locator('.stats-chart-detail').innerText(), /minutes/)
@@ -87,6 +104,17 @@ const output = process.env.REVIEW_DIR
await page.screenshot({ path: path.join(output, `insights-${width}.png`), fullPage: true })
}
}
brokenArtwork = true
await page.reload()
await page.locator('.stats-history').scrollIntoViewIfNeeded()
await page.getByText('MV', { exact: true }).waitFor()
await page.waitForFunction(() => document.querySelectorAll('.stats-media-icon img').length === 0)
brokenArtwork = false
mode = 'missing_transcoding'
await page.reload()
await page.getByText('Some stream details are missing:', { exact: false }).waitFor()
assert.equal(await page.locator('.stats-transcoding').getByText('Not recorded', { exact: true }).count(), 3)
mode = 'ready'
await page.getByRole('button', { name: '90 days', exact: true }).click()
await page.getByRole('heading', { name: 'Your viewing rhythm' }).waitFor()
assert.equal(periods.at(-1), 90)
@@ -124,6 +152,6 @@ const output = process.env.REVIEW_DIR
assert(mutations.some((request) => request.body?.jellystat_base_url === 'http://jellystat:3001'))
assert(mutations.filter((request) => request.body).every((request) => !Object.hasOwn(request.body, 'jellystat_api_key')), 'An unchanged secret must be preserved')
assert.deepEqual(errors, [])
console.log('Insights browser checks passed: desktop/mobile, chart, period, requests, empty/setup/unlinked/error/auth states, settings save/test, preserved secret.')
console.log('Insights browser checks passed: desktop/mobile, posters/fallback, transcode totals/missing metadata, chart, period, requests, empty/setup/unlinked/error/auth states, settings save/test, preserved secret.')
} finally { await browser.close() }
})().catch((error) => { console.error(error); process.exit(1) })