Fix viewing-history artwork and add transcode playback metrics
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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.
|
||||
|
||||
@@ -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 Jellystat’s movie libraries; other media still contributes to watch time. Charts and streaks use UTC and the activity dates recorded by Jellystat.</p>}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) })
|
||||
|
||||
Reference in New Issue
Block a user