diff --git a/backend/app/routers/insights.py b/backend/app/routers/insights.py index ce632e9..ed1a1bf 100644 --- a/backend/app/routers/insights.py +++ b/backend/app/routers/insights.py @@ -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 diff --git a/backend/app/services/insights.py b/backend/app/services/insights.py index 613147b..3d3ba30 100644 --- a/backend/app/services/insights.py +++ b/backend/app/services/insights.py @@ -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)} diff --git a/backend/app/services/insights_artwork.py b/backend/app/services/insights_artwork.py new file mode 100644 index 0000000..9bdec55 --- /dev/null +++ b/backend/app/services/insights_artwork.py @@ -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 diff --git a/backend/tests/test_insights_media.py b/backend/tests/test_insights_media.py new file mode 100644 index 0000000..6ad38c6 --- /dev/null +++ b/backend/tests/test_insights_media.py @@ -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"PRIVATE", "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") diff --git a/docs/jellystat-integration.md b/docs/jellystat-integration.md index de7173e..d8477f6 100644 --- a/docs/jellystat-integration.md +++ b/docs/jellystat-integration.md @@ -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. diff --git a/frontend/app/insights/page.tsx b/frontend/app/insights/page.tsx index 98e6214..8a4e644 100644 --- a/frontend/app/insights/page.tsx +++ b/frontend/app/insights/page.tsx @@ -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

{title}

{rows.length ?
{rows.map((row) =>
{row.name}{number(row.minutes)} min
)}
:

Your next watch will start the story here.

}
} +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
+

How you streamed

+
{rows.map((row) =>
{row.name}{minutes(row.minutes)}
)}
+ {transcoding &&
+

Transcoding playback time

+
+
GPU-assisted video{transcoding.hardware_video_minutes === 0 && (transcoding.unknown_hardware_minutes > 0 || transcoding.unknown_video_minutes > 0) ? 'Not recorded' : minutes(transcoding.hardware_video_minutes)}{transcoding.hardware.map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Hardware-accelerated video'}
+
Audio transcoding{transcoding.audio_minutes === 0 && transcoding.unknown_audio_minutes > 0 ? 'Not recorded' : minutes(transcoding.audio_minutes)}{transcoding.audio_codecs.slice(0, 3).map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Audio converted for your player'}
+
+
Total video transcoding
{transcoding.video_minutes === 0 && transcoding.unknown_video_minutes > 0 ? 'Not recorded' : minutes(transcoding.video_minutes)}
{transcoding.software_video_minutes > 0 &&
Software video
{minutes(transcoding.software_video_minutes)}
}{transcoding.unknown_hardware_minutes > 0 &&
Video hardware not recorded
{minutes(transcoding.unknown_hardware_minutes)}
}
+ {(transcoding.unknown_audio_minutes > 0 || transcoding.unknown_video_minutes > 0) &&

Some stream details are missing: video {minutes(transcoding.unknown_video_minutes)}, audio {minutes(transcoding.unknown_audio_minutes)}.

} +

Playback minutes, with audio and video counted separately. They can overlap. GPU busy time is not recorded by Jellystat.

+
} +
+} + +function RecentArtwork({ url, type }: { url?: string | null; type: string }) { + const [failed, setFailed] = useState(false) + return +} + export default function InsightsPage() { const router = useRouter() const [days, setDays] = useState(30) @@ -120,11 +153,11 @@ export default function InsightsPage() {

Most watched

By minutes
{data.top_titles?.length ?
    {data.top_titles.map((title, index) =>
  1. {String(index + 1).padStart(2, '0')}
    {title.title}{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays
    {number(title.minutes)}min
  2. )}
:

Your favourites will find their place here.

}
- +
} {data &&
- {summary &&

Recently watched

Latest 20 plays
{data.recent?.length ?
{data.recent.map((play) =>
{play.series || play.title}{play.series ? `${play.episode} · ${play.title}` : play.client}{play.client} · {play.method}
{number(play.minutes)} min
)}
:

Plays recorded by Jellystat will appear here.

}
} + {summary &&

Recently watched

Latest 20 plays
{data.recent?.length ?
{data.recent.map((play) =>
{play.series || play.title}{play.series ? `${play.episode} · ${play.title}` : play.type === 'movie' ? 'Movie' : 'Media'}{play.client} · {play.method}
{number(play.minutes)} min
)}
:

Plays recorded by Jellystat will appear here.

}
}

Your requests

View all
{data.requests.total}submitted in the past {days} days
{data.requests.pending} Pending{data.requests.approved} Approved{data.requests.declined} Declined
{data.requests.recent.length > 0 ? :

Something on your watchlist? Make a request.

}
} {summary &&

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.

} diff --git a/frontend/app/insights/stats.css b/frontend/app/insights/stats.css index 689dfbb..e2724e4 100644 --- a/frontend/app/insights/stats.css +++ b/frontend/app/insights/stats.css @@ -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; } diff --git a/scripts/review_insights_ui.cjs b/scripts/review_insights_ui.cjs index 0904eaa..fcfece0 100644 --- a/scripts/review_insights_ui.cjs +++ b/scripts/review_insights_ui.cjs @@ -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: '' }) + } 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) })