Add private Jellystat viewing stats to Magent beta
Magent CI/CD / verify (push) Successful in 10m31s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m16s

This commit is contained in:
2026-09-08 11:25:05 +12:00
parent a3b5759708
commit 2976145dd8
24 changed files with 1010 additions and 3 deletions
+1
View File
@@ -23,6 +23,7 @@ Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It s
- Local database for speed and audit history. - Local database for speed and audit history.
- Users and access control (admin vs user, block access). - Users and access control (admin vs user, block access).
- Local account password changes via "My profile". - Local account password changes via "My profile".
- Personal viewing stats from Jellystat: minutes, movies, episodes, streaks, and recent plays alongside requests. See [Jellystat setup](docs/jellystat-integration.md).
- Docker-first deployment for easy hosting. - Docker-first deployment for easy hosting.
## Quick start (Docker - primary) ## Quick start (Docker - primary)
+86
View File
@@ -0,0 +1,86 @@
"""Jellystat API adapter. Credentials and raw history never leave the backend."""
import asyncio
import json
import re
from datetime import datetime
import httpx
from .base import ApiClient
class JellystatError(Exception):
pass
class HistoryLimitError(JellystatError):
pass
def same_user_id(left, right) -> bool:
return bool(left and right) and str(left).replace("-", "").lower() == str(right).replace("-", "").lower()
class JellystatClient(ApiClient):
PAGE_SIZE = 200
MAX_PAGES = 50
def configured(self) -> bool:
return bool(self.base_url and self.api_key)
async def _read(self, client: httpx.AsyncClient, method: str, path: str, **kwargs):
try:
response = await client.request(method, f"{self.base_url}{path}",
headers={"x-api-token": self.api_key}, **kwargs)
response.raise_for_status()
return response.json()
except (httpx.HTTPError, ValueError) as exc:
raise JellystatError("Jellystat did not return a valid response") from exc
async def test_connection(self) -> dict:
# This protected endpoint confirms API authentication without returning user data.
async with httpx.AsyncClient(timeout=10.0) as client:
result = await self._read(client, "GET", "/api/getLibraries")
if not isinstance(result, list):
raise JellystatError("Jellystat returned an unexpected library response")
return {"connected": True}
async def get_user_history(self, user_id: str, start: datetime, end: datetime) -> tuple[list, list]:
if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", user_id):
raise JellystatError("Invalid linked Jellyfin identity")
# Only fixed, user-scoped endpoints are used. Never pass browser search/filters through.
filters = json.dumps([{"field": "ActivityDateInserted", "min": start.isoformat(), "max": end.isoformat()}])
try:
async with asyncio.timeout(30):
async with httpx.AsyncClient(timeout=10.0) as client:
libraries = await self._read(client, "GET", "/api/getLibraries")
if not isinstance(libraries, list) or any(not isinstance(row, dict) for row in libraries):
raise JellystatError("Jellystat returned an unexpected library response")
history = []
for page in range(1, self.MAX_PAGES + 1):
payload = await self._read(client, "POST", "/api/getUserHistory",
json={"userid": user_id}, params={"page": page, "size": self.PAGE_SIZE,
"sort": "ActivityDateInserted", "desc": "true", "filters": filters})
if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
raise JellystatError("Jellystat returned an unexpected history response")
rows = payload["results"]
try:
pages = int(payload["pages"])
except (KeyError, TypeError, ValueError) as exc:
raise JellystatError("Jellystat did not return history pagination") from exc
if pages < 0 or (pages == 0 and rows) or len(rows) > self.PAGE_SIZE:
raise JellystatError("Jellystat returned invalid history pagination")
if pages > self.MAX_PAGES:
raise HistoryLimitError("Select a shorter period to view this history")
for row in rows:
if not isinstance(row, dict) or not same_user_id(row.get("UserId"), user_id):
raise JellystatError("Jellystat returned history for an unexpected account")
history.extend(rows)
if page >= pages:
return history, libraries
if not rows:
raise JellystatError("Jellystat returned incomplete history")
except TimeoutError as exc:
raise JellystatError("Jellystat took too long to return history") from exc
raise HistoryLimitError("Select a shorter period to view this history")
+5
View File
@@ -258,6 +258,11 @@ class Settings(BaseSettings):
jellyseerr_api_key: Optional[str] = Field( jellyseerr_api_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_API_KEY", "JELLYSEERR_KEY") default=None, validation_alias=AliasChoices("JELLYSEERR_API_KEY", "JELLYSEERR_KEY")
) )
jellystat_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSTAT_URL", "JELLYSTAT_BASE_URL")
)
jellystat_api_key: Optional[str] = Field(default=None, validation_alias="JELLYSTAT_API_KEY")
jellyfin_base_url: Optional[str] = Field( jellyfin_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL") default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL")
) )
+6
View File
@@ -187,6 +187,12 @@ def _has_secure_bootstrap_admin_credentials() -> bool:
def init_db() -> None: def init_db() -> None:
with _connect() as conn: with _connect() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS jellyfin_user_links (
source TEXT NOT NULL, local_user_id INTEGER NOT NULL, jellyfin_user_id TEXT NOT NULL,
PRIMARY KEY (source, local_user_id), UNIQUE (source, jellyfin_user_id)
)
""")
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS request_repairs ( CREATE TABLE IF NOT EXISTS request_repairs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
+2
View File
@@ -27,6 +27,7 @@ from .routers.site import router as site_router
from .routers.events import router as events_router from .routers.events import router as events_router
from .routers.portal import router as portal_router from .routers.portal import router as portal_router
from .routers.operations import router as operations_router from .routers.operations import router as operations_router
from .routers.insights import router as insights_router
from .services.jellyfin_sync import run_daily_jellyfin_sync from .services.jellyfin_sync import run_daily_jellyfin_sync
from .services.issue_resolution import run_issue_confirmation_loop from .services.issue_resolution import run_issue_confirmation_loop
from .services.operation_progress import ( from .services.operation_progress import (
@@ -280,3 +281,4 @@ app.include_router(site_router)
app.include_router(events_router) app.include_router(events_router)
app.include_router(portal_router) app.include_router(portal_router)
app.include_router(operations_router) app.include_router(operations_router)
app.include_router(insights_router)
+4
View File
@@ -132,6 +132,7 @@ def _optional_recipient_email(value: object) -> Optional[str]:
raise HTTPException(status_code=400, detail="recipient_email must be a valid email address") raise HTTPException(status_code=400, detail="recipient_email must be a valid email address")
SENSITIVE_KEYS = { SENSITIVE_KEYS = {
"jellystat_api_key",
"magent_ssl_certificate_pem", "magent_ssl_certificate_pem",
"magent_ssl_private_key_pem", "magent_ssl_private_key_pem",
"magent_notify_email_smtp_password", "magent_notify_email_smtp_password",
@@ -150,6 +151,7 @@ SENSITIVE_KEYS = {
} }
URL_SETTING_KEYS = { URL_SETTING_KEYS = {
"jellystat_base_url",
"magent_application_url", "magent_application_url",
"magent_api_url", "magent_api_url",
"magent_proxy_base_url", "magent_proxy_base_url",
@@ -172,6 +174,8 @@ NOTIFICATION_URL_SETTING_KEYS = {
} }
SETTING_KEYS: List[str] = [ SETTING_KEYS: List[str] = [
"jellystat_base_url",
"jellystat_api_key",
"magent_application_url", "magent_application_url",
"magent_application_port", "magent_application_port",
"magent_api_url", "magent_api_url",
+5
View File
@@ -757,6 +757,11 @@ async def jellyfin_login(
save_jellyfin_users_cache(users) save_jellyfin_users_cache(users)
except Exception: except Exception:
pass pass
from ..services.jellyfin_identity import link_user
jellyfin_id = client._extract_user_id(auth_response)
if jellyfin_id:
link_user(canonical_username, jellyfin_id, runtime.jellyfin_base_url)
sync_jellyfin_password_state(canonical_username, password) sync_jellyfin_password_state(canonical_username, password)
if user and user.get("jellyseerr_user_id") is None and candidate_map: if user and user.get("jellyseerr_user_id") is None and candidate_map:
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map) matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
+34
View File
@@ -0,0 +1,34 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response
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
router = APIRouter(prefix="/insights", tags=["insights"])
class InsightsQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
days: int = 30
@field_validator("days")
@classmethod
def supported_period(cls, value: int) -> int:
if value not in {7, 30, 90, 365}:
raise ValueError("Choose 7, 30, 90 or 365 days")
return value
@router.get("")
async def dashboard(query: Annotated[InsightsQuery, Query()], response: Response,
user: dict = Depends(get_current_user)) -> dict:
response.headers["Cache-Control"] = "no-store"
try:
return await get_insights(user, query.days)
except HistoryLimitError as exc:
raise HTTPException(status_code=422, detail="There is too much history for this period. Choose a shorter period.") from exc
except JellystatError as exc:
raise HTTPException(status_code=502, detail="Your viewing stats are temporarily unavailable. Please try again shortly.") from exc
+9
View File
@@ -11,6 +11,7 @@ from ..clients.bazarr import BazarrClient
from ..clients.prowlarr import ProwlarrClient from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient from ..clients.qbittorrent import QBittorrentClient
from ..clients.jellyfin import JellyfinClient from ..clients.jellyfin import JellyfinClient
from ..clients.jellystat import JellystatClient
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)]) router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)])
@@ -118,6 +119,11 @@ async def services_status() -> Dict[str, Any]:
) )
) )
jellystat = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
# Optional analytics must not degrade the media pipeline when not configured.
if jellystat.configured():
services.append(await _check("Jellystat", True, jellystat.test_connection))
overall = "up" overall = "up"
if any(s.get("status") == "down" for s in services): if any(s.get("status") == "down" for s in services):
overall = "down" overall = "down"
@@ -141,6 +147,9 @@ async def test_service(service: str) -> Dict[str, Any]:
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
service_key = service.strip().lower() service_key = service.strip().lower()
if service_key == "jellystat":
jellystat = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
return await _check("Jellystat", jellystat.configured(), jellystat.test_connection)
checks = { checks = {
"seerr": ( "seerr": (
"Seerr", "Seerr",
+181
View File
@@ -0,0 +1,181 @@
import asyncio
import hashlib
import math
import sqlite3
import time
from collections import defaultdict
from contextlib import closing
from datetime import datetime, timedelta, timezone
from .. import db
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
_cache: dict[tuple, tuple[float, dict]] = {}
CACHE_SECONDS = 60
def _date(value) -> datetime:
try:
result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
return result.replace(tzinfo=timezone.utc) if result.tzinfo is None else result.astimezone(timezone.utc)
except (ValueError, TypeError) as exc:
raise JellystatError("Jellystat returned an invalid history date") from exc
def _duration(value) -> float:
try:
result = float(value or 0)
if not math.isfinite(result) or result < 0:
raise ValueError()
return result
except (ValueError, TypeError, OverflowError) as exc:
raise JellystatError("Jellystat returned an invalid playback duration") from exc
async def resolve_identity(user: dict, runtime) -> str | None:
identity = await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
if identity:
return identity
if user.get("auth_provider") != "jellyfin":
return None
# Bootstrap existing Jellyfin accounts from the canonical server, using exact names.
# Local accounts and email-prefix matches cannot claim a Jellyfin identity.
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
return None
try:
users = await client.get_users()
except Exception as exc:
raise JellystatError("Could not resolve the linked Jellyfin account") from exc
matches = [entry for entry in users if isinstance(entry, dict)
and str(entry.get("Name") or "").strip().casefold() == user["username"].strip().casefold()] if isinstance(users, list) else []
if len(matches) != 1 or not matches[0].get("Id"):
return None
await asyncio.to_thread(link_user, user["username"], str(matches[0]["Id"]), runtime.jellyfin_base_url)
return await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
def request_summary(user: dict, start: datetime, end: datetime) -> dict:
clause = "julianday(created_at) >= julianday(?) AND julianday(created_at) <= julianday(?)"
params = [start.isoformat(), end.isoformat()]
if user.get("jellyseerr_user_id") is not None:
clause += " AND requested_by_id = ?"
params.append(user["jellyseerr_user_id"])
else:
clause += " AND requested_by_id IS NULL AND lower(trim(requested_by)) = ?"
params.append(user["username"].strip().lower())
with closing(db._connect()) as conn, conn:
conn.row_factory = sqlite3.Row
counts = conn.execute(f"""SELECT COUNT(*) AS total,
COALESCE(SUM(media_type = 'movie'), 0) AS movies,
COALESCE(SUM(media_type = 'tv'), 0) AS tv,
COALESCE(SUM(status = 1), 0) AS pending,
COALESCE(SUM(status = 2), 0) AS approved,
COALESCE(SUM(status = 3), 0) AS declined FROM requests_cache WHERE {clause}""", params).fetchone()
recent = conn.execute(f"""SELECT request_id, title, media_type, status FROM requests_cache
WHERE {clause} ORDER BY created_at DESC LIMIT 5""", params).fetchall()
return {**dict(counts), "recent": [dict(row) for row in recent]}
def summarize(history: list, libraries: list, start: datetime, end: datetime) -> dict:
library_types = {str(row.get("Id")): str(row.get("CollectionType") or "").lower() for row in libraries}
daily_seconds = defaultdict(float)
clients = defaultdict(float)
methods = defaultdict(float)
titles = {}
movie_ids, episode_ids, seen = set(), set(), set()
recent = []
seconds = 0.0
for row in history:
row_id = str(row.get("Id") or "")
if not row_id:
raise JellystatError("Jellystat returned history without an activity ID")
if row_id in seen:
continue
seen.add(row_id)
date = _date(row.get("ActivityDateInserted"))
# Defend against older upstream versions ignoring the range filter.
if not start <= date <= end:
continue
duration = _duration(row.get("PlaybackDuration"))
if duration <= 0:
continue
item_id = str(row.get("NowPlayingItemId") or row_id)
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"
if media_type == "episode":
episode_ids.add(str(episode_id))
elif media_type == "movie":
movie_ids.add(item_id)
seconds += duration
daily_seconds[date.date().isoformat()] += duration
client = str(row.get("Client") or "Unknown player")[:200]
clients[client] += duration
method = str(row.get("PlayMethod") or "Unknown")
method = {"DirectPlay": "Direct play", "DirectStream": "Direct stream", "Transcode": "Transcode"}.get(method, "Other")
methods[method] += duration
name = str(row.get("NowPlayingItemName") or "Untitled")[:500]
series = str(row.get("SeriesName") or "")[:500]
title = titles.setdefault(item_id, {"title": series or name, "type": "series" if episode_id else media_type, "minutes": 0, "plays": 0})
title["minutes"] += duration / 60
title["plays"] += 1
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})
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)]
active_days = {day for day, duration in daily_seconds.items() if duration >= 60}
longest = run = 0
for day in daily:
run = run + 1 if day["date"] in active_days else 0
longest = max(longest, run)
current = 0
cursor = end.date() if end.date().isoformat() in active_days else end.date() - timedelta(days=1)
while cursor.isoformat() in active_days:
current += 1
cursor -= timedelta(days=1)
top = sorted(titles.values(), key=lambda row: (-row["minutes"], row["title"]))[:6]
for row in top:
row["minutes"] = round(row["minutes"], 1)
return {"summary": {"minutes": round(seconds / 60, 1), "plays": len(recent), "movies": len(movie_ids),
"episodes": len(episode_ids), "active_days": len(active_days),
"current_streak": current, "longest_streak": longest},
"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])],
"recent": sorted(recent, key=lambda row: row["played_at"], reverse=True)[:20]}
async def get_insights(user: dict, days: int) -> dict:
runtime = await asyncio.to_thread(get_runtime_settings)
end = datetime.now(timezone.utc)
start = end - timedelta(days=days)
requests = await asyncio.to_thread(request_summary, user, start, end)
base = {"source": "Jellystat", "days": days, "timezone": "UTC", "requests": requests,
"is_admin": user.get("role") == "admin", "summary": None}
client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
if not client.configured():
return {**base, "state": "not_configured"}
identity = await resolve_identity(user, runtime)
if not identity:
return {**base, "state": "unlinked"}
key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
runtime.jellyfin_base_url, identity, days)
cached = _cache.get(key)
if cached and cached[0] > time.monotonic():
return {**base, **cached[1]}
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()}
for expired in [key for key, value in _cache.items() if value[0] <= time.monotonic()]:
_cache.pop(expired, None)
if len(_cache) >= 128:
_cache.pop(next(iter(_cache)))
_cache[key] = (time.monotonic() + CACHE_SECONDS, data)
return {**base, **data}
+35
View File
@@ -0,0 +1,35 @@
"""Stable Jellyfin identities for private, user-scoped integrations."""
import hashlib
from contextlib import closing
from .. import db
def source_key(base_url: str | None) -> str:
return hashlib.sha256(str(base_url or "").strip().rstrip("/").encode()).hexdigest()
def linked_user_id(username: str, base_url: str | None) -> str | None:
user = db.get_user_by_username(username)
if not user or not base_url:
return None
with closing(db._connect()) as conn, conn:
row = conn.execute(
"SELECT jellyfin_user_id FROM jellyfin_user_links WHERE source = ? AND local_user_id = ?",
(source_key(base_url), user["id"]),
).fetchone()
return row[0] if row else None
def link_user(username: str, jellyfin_user_id: str, base_url: str | None) -> None:
"""Use only verified login or canonical Jellyfin user sync, never playback names."""
user = db.get_user_by_username(username)
if not user or not jellyfin_user_id or not base_url:
return
with closing(db._connect()) as conn, conn:
# A renamed or re-created account must not silently take over an existing identity.
conn.execute(
"INSERT OR IGNORE INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)",
(source_key(base_url), user["id"], str(jellyfin_user_id)),
)
+5
View File
@@ -11,6 +11,7 @@ from ..db import (
set_user_jellyseerr_id, set_user_jellyseerr_id,
) )
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from .jellyfin_identity import link_user
from .user_cache import ( from .user_cache import (
build_jellyseerr_candidate_map, build_jellyseerr_candidate_map,
extract_jellyseerr_user_email, extract_jellyseerr_user_email,
@@ -68,6 +69,10 @@ async def sync_jellyfin_users() -> int:
set_user_jellyseerr_id(name, matched_id) set_user_jellyseerr_id(name, matched_id)
if matched_email: if matched_email:
set_user_email(name, matched_email) set_user_email(name, matched_email)
if user.get("Id"):
local_user = get_user_by_username(name)
if local_user and local_user.get("auth_provider") == "jellyfin":
link_user(name, str(user["Id"]), runtime.jellyfin_base_url)
return imported return imported
+201
View File
@@ -0,0 +1,201 @@
import json
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app import db
from backend.app.clients.jellystat import HistoryLimitError, JellystatClient, JellystatError
from backend.app.routers import admin, insights as router
from backend.app.services import insights
from backend.app.services.jellyfin_identity import link_user, linked_user_id
from backend.tests.test_backend_quality import TempDatabaseMixin
NOW = datetime(2026, 9, 7, 12, tzinfo=timezone.utc)
USER = {"username": "viewer", "role": "user", "auth_provider": "jellyfin", "jellyseerr_user_id": 42}
LIBRARIES = [{"Id": "movies", "CollectionType": "movies"}, {"Id": "music", "CollectionType": "music"}]
def play(id="play-1", **extra):
return {"Id": id, "UserId": "jf-viewer", "UserName": "PRIVATE NAME", "NowPlayingItemId": "movie-1",
"NowPlayingItemName": "Arrival", "ParentId": "movies", "PlaybackDuration": 3600,
"ActivityDateInserted": NOW.isoformat(), "RemoteEndPoint": "PRIVATE IP", "DeviceId": "PRIVATE DEVICE",
"PlayState": {"secret": "PRIVATE STATE"}, "Client": "Jellyfin Web", "PlayMethod": "DirectPlay", **extra}
class JellystatClientTests(unittest.IsolatedAsyncioTestCase):
async def history(self, handler, **kwargs):
original = httpx.AsyncClient
with patch("backend.app.clients.jellystat.httpx.AsyncClient", side_effect=lambda **options: original(transport=httpx.MockTransport(handler), **options)):
return await JellystatClient("http://jellystat/base", "secret-api-key").get_user_history(
kwargs.get("user_id", "jf-viewer"), NOW - timedelta(days=7), NOW)
async def test_paginates_and_sends_only_backend_identity_and_header_credential(self):
calls = []
def handler(request):
calls.append(request)
self.assertEqual(request.headers["x-api-token"], "secret-api-key")
self.assertNotIn("secret-api-key", str(request.url))
if request.url.path == "/base/api/getLibraries":
return httpx.Response(200, json=LIBRARIES)
self.assertEqual(request.method, "POST")
self.assertEqual(request.url.path, "/base/api/getUserHistory")
self.assertEqual(json.loads(request.content), {"userid": "jf-viewer"})
self.assertNotIn("search", request.url.params)
self.assertEqual(json.loads(request.url.params["filters"])[0]["field"], "ActivityDateInserted")
return httpx.Response(200, json={"pages": 2, "results": [play(request.url.params["page"])]})
history, libraries = await self.history(handler)
self.assertEqual(len(calls), 3)
self.assertEqual(len(history), 2)
self.assertEqual(libraries, LIBRARIES)
async def test_rejects_foreign_history_malformed_responses_and_overflow(self):
for payload, exception in [
({"pages": 1, "results": [play(UserId="someone-else")]}, JellystatError),
({"pages": 1, "results": [play(UserId=None)]}, JellystatError),
({"results": []}, JellystatError),
({"pages": 51, "results": []}, HistoryLimitError),
({"pages": 2, "results": []}, JellystatError),
({"pages": 0, "results": [play()]}, JellystatError),
]:
with self.subTest(payload=payload):
def handler(request):
return httpx.Response(200, json=LIBRARIES if request.method == "GET" else payload)
with self.assertRaises(exception):
await self.history(handler)
async def test_empty_history_is_valid(self):
result, _ = await self.history(lambda request: httpx.Response(200, json=LIBRARIES if request.method == "GET" else {"pages": 0, "results": []}))
self.assertEqual(result, [])
async def test_upstream_failure_is_sanitized(self):
with self.assertRaises(JellystatError) as error:
await self.history(lambda _: httpx.Response(401, text="private upstream error"))
self.assertNotIn("private", str(error.exception))
self.assertNotIn("secret-api-key", str(error.exception))
class SummaryTests(unittest.TestCase):
def test_units_media_counts_deduplication_ranges_streaks_and_privacy(self):
rows = [play(), play(), play("rewatch"),
play("episode", EpisodeId="e1", SeriesName="Severance", NowPlayingItemId="series-1", PlaybackDuration="1200",
ActivityDateInserted=(NOW - timedelta(days=1)).isoformat()),
play("episode-rewatch", EpisodeId="e1", SeriesName="Severance", NowPlayingItemId="series-1", PlaybackDuration=1200,
ActivityDateInserted=(NOW - timedelta(days=2)).isoformat()),
play("song", ParentId="music", NowPlayingItemId="song-1", PlaybackDuration=180),
play("old", ActivityDateInserted=(NOW - timedelta(days=8)).isoformat()),
play("zero", PlaybackDuration=0)]
data = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=7), NOW)
self.assertEqual(data["summary"], {"minutes": 163, "plays": 5, "movies": 1, "episodes": 1,
"active_days": 3, "current_streak": 3, "longest_streak": 3})
self.assertAlmostEqual(sum(day["minutes"] for day in data["daily"]), 163)
self.assertEqual(data["top_titles"][0]["title"], "Arrival")
self.assertEqual(len(data["recent"]), 5)
self.assertNotIn("PRIVATE", json.dumps(data))
def test_invalid_durations_do_not_become_zero_or_nan(self):
for value in [-1, "NaN", "Infinity", "nonsense"]:
with self.subTest(value=value), self.assertRaises(JellystatError):
insights.summarize([play(PlaybackDuration=value)], LIBRARIES, NOW - timedelta(days=7), NOW)
def test_empty_history_has_zero_filled_days(self):
result = insights.summarize([], LIBRARIES, NOW - timedelta(days=7), NOW)
self.assertEqual(result["summary"]["minutes"], 0)
self.assertEqual(len(result["daily"]), 8)
self.assertEqual(result["summary"]["current_streak"], 0)
class InsightsIntegrationTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self):
super().setUp()
insights._cache.clear()
db.create_user("viewer", "Test-Password123!", auth_provider="jellyfin")
self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="jf-key",
jellystat_base_url="http://jellystat", jellystat_api_key="stats-key")
async def test_identity_does_not_change_with_username_reuse_or_server_changes(self):
link_user("viewer", "jf-original", "http://jellyfin/")
link_user("viewer", "jf-replacement", "http://jellyfin")
self.assertEqual(linked_user_id("viewer", "http://jellyfin"), "jf-original")
self.assertIsNone(linked_user_id("viewer", "http://other-server"))
async def test_local_account_cannot_claim_same_name_and_verified_user_can_bootstrap(self):
with patch.object(insights.JellyfinClient, "get_users", new_callable=AsyncMock, return_value=[{"Id": "jf-viewer", "Name": "viewer"}]) as remote:
self.assertIsNone(await insights.resolve_identity({**USER, "auth_provider": "local"}, self.runtime))
remote.assert_not_called()
self.assertEqual(await insights.resolve_identity(USER, self.runtime), "jf-viewer")
self.assertEqual(await insights.resolve_identity(USER, self.runtime), "jf-viewer")
self.assertEqual(remote.await_count, 1)
async def test_requests_use_seerr_id_even_when_name_matches_another_user(self):
for request_id, seerr_id in [(1, 42), (2, 99)]:
db.upsert_request_cache(request_id, request_id, "movie", 2, "Request", 2026,
"viewer", "viewer", seerr_id, NOW.isoformat(), NOW.isoformat(), "{}")
report = insights.request_summary(USER, NOW - timedelta(days=7), NOW)
self.assertEqual(report["total"], 1)
self.assertEqual(report["recent"][0]["request_id"], 1)
async def test_cache_isolated_by_identity_period_and_configuration(self):
link_user("viewer", "jf-viewer", "http://jellyfin")
db.create_user("second", "Test-Password123!", auth_provider="jellyfin")
link_user("second", "jf-second", "http://jellyfin")
with patch.object(insights, "get_runtime_settings", return_value=self.runtime), \
patch.object(JellystatClient, "get_user_history", new_callable=AsyncMock, return_value=([], LIBRARIES)) as remote:
await insights.get_insights(USER, 7)
await insights.get_insights(USER, 7)
self.assertEqual(remote.await_count, 1)
await insights.get_insights({**USER, "username": "second"}, 7)
await insights.get_insights(USER, 30)
self.runtime.jellystat_api_key = "rotated-key"
await insights.get_insights(USER, 7)
self.assertEqual(remote.await_count, 4)
async def test_disabled_integration_never_calls_upstream(self):
self.runtime.jellystat_api_key = None
with patch.object(insights, "get_runtime_settings", return_value=self.runtime), \
patch.object(JellystatClient, "get_user_history", new_callable=AsyncMock) as remote:
result = await insights.get_insights(USER, 30)
self.assertEqual(result["state"], "not_configured")
self.assertIsNone(result["summary"])
remote.assert_not_called()
async def test_settings_mask_jellystat_credential(self):
db.set_setting("jellystat_api_key", "private-stats-key")
result = await admin.list_settings()
setting = next(row for row in result["settings"] if row["key"] == "jellystat_api_key")
self.assertTrue(setting["sensitive"])
self.assertTrue(setting["isSet"])
self.assertNotIn("private-stats-key", json.dumps(result))
class InsightsRouteTests(unittest.TestCase):
def app(self, authenticated=True):
app = FastAPI()
app.include_router(router.router)
if authenticated:
app.dependency_overrides[router.get_current_user] = lambda: USER
return TestClient(app)
def test_requires_authentication(self):
self.assertEqual(self.app(False).get("/insights").status_code, 401)
def test_query_accepts_period_and_forbids_identity_and_scope_overrides(self):
with patch.object(router, "get_insights", new_callable=AsyncMock, return_value={"state": "ready"}) as report:
client = self.app()
for days in [7, 30, 90, 365]:
response = client.get(f"/insights?days={days}")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.headers["cache-control"], "no-store")
report.assert_awaited_with(USER, 365)
for query in ["days=-1", "days=999999", "days=invalid", "userid=other", "user_id=other", "scope=server"]:
self.assertEqual(client.get(f"/insights?{query}").status_code, 422, query)
def test_errors_do_not_leak_upstream_details(self):
with patch.object(router, "get_insights", new_callable=AsyncMock, side_effect=JellystatError("PRIVATE key and URL")):
response = self.app().get("/insights")
self.assertEqual(response.status_code, 502)
self.assertNotIn("PRIVATE", response.text)
+47
View File
@@ -0,0 +1,47 @@
# Jellystat in Magent Beta
Magent's **My Stats** page (`/insights`) reads personal viewing history from an existing Jellystat instance. Jellystat owns playback collection, history and retention. Magent does not install Jellystat, collect sessions or keep a second playback database.
## Setup
1. Run Jellystat and connect it to the same Jellyfin server Magent uses. Let its initial sync finish.
2. Create an API key in Jellystat's settings.
3. In Magent, open **Configuration → Jellystat**, enter its internal URL and API key, save, and test the connection. Include any reverse-proxy base path in the URL.
4. Sign in using Jellyfin. Existing Jellyfin accounts can also be linked by **Configuration → Jellyfin → Import Jellyfin users**. First use of My Stats resolves an existing Jellyfin account against Jellyfin's user directory using its exact username.
Alternatively, set these backend environment variables:
```dotenv
JELLYSTAT_URL=http://jellystat:3000
JELLYSTAT_API_KEY=your-jellystat-api-key
```
`JELLYSTAT_BASE_URL` is also accepted. Docker deployments already load the backend environment through `.env`. These are server settings; no `NEXT_PUBLIC_` variables or browser credentials are needed. Saved Configuration values override environment values.
## What users see
- Past 7, 30, 90 or 365 days of watch time, distinct movies and episodes played, and total plays.
- Watch-time chart, current/longest streak within the chosen period, active days, favourite titles, players and streaming methods.
- Latest 20 plays in the chosen period and personal request totals from Magent's Seerr cache.
- Clear setup, account-link, no-history and temporary-unavailability states.
The page is personal for admins as well as ordinary users. There is no arbitrary user-ID parameter or server-wide history endpoint in this version. Reports and newsletters can build on this integration in a later beta increment; they are not included here.
## Data semantics and boundaries
History comes from Jellystat's `POST /api/getUserHistory`, with the backend's linked Jellyfin ID in `userid`, and a fixed date filter. `GET /api/getLibraries` supplies movie-library classification and the connection test. Authentication uses the `x-api-token` header. The adapter follows the [upstream API routes](https://github.com/CyferShepard/Jellystat/blob/main/backend/routes/api.js) and [playback model](https://github.com/CyferShepard/Jellystat/blob/main/backend/models/jf_playback_activity.js); the installed instance exposes its API at `/swagger`.
- Playback duration is in seconds and displayed as minutes. Positive-duration history entries count as plays, including unfinished watches. Repeat plays add time without inflating distinct movie/episode counts.
- Episodes are identified by `EpisodeId`. Movies are identified by their movie library. Mixed libraries or deleted library metadata may leave an item classified as other media; that time still contributes to totals.
- Ranges cover a rolling number of days. Charts and streaks use UTC and Jellystat's `ActivityDateInserted`, so the first/last chart days can be partial. A streak day requires at least one minute. Streaks are bounded by the selected period. Long charts group days for readability.
- Requests use their creation date and the authenticated account's canonical Seerr ID. Exact usernames are only used for legacy requests without an owner ID; conflicting IDs never fall back to a name.
- Pages are fetched at 200 rows per request, up to 50 pages, with a 30-second total timeout. Excess history asks the user to choose a shorter period; it is never presented as a complete partial total.
- A normalized, per-identity response is cached in memory for up to 60 seconds, with a 128-entry bound. The cache is separated by Jellystat URL/key, Jellyfin URL, user ID and period. HTTP responses are marked `no-store`.
- Browser output excludes raw Jellystat responses, usernames from playback data, user/device IDs, IP addresses, tokens and media stream details. Unexpected account IDs in upstream history are rejected.
- The only new database table is the stable Magent-to-Jellyfin identity mapping. It is scoped to the configured Jellyfin URL and does not automatically transfer ownership after account replacement. A changed Jellyfin URL needs identity resolution again.
## 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.
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.
+18
View File
@@ -34,6 +34,7 @@ const SECTION_LABELS: Record<string, string> = {
seerr: 'Seerr', seerr: 'Seerr',
jellyseerr: 'Seerr', jellyseerr: 'Seerr',
jellyfin: 'Jellyfin', jellyfin: 'Jellyfin',
jellystat: 'Jellystat',
artwork: 'Artwork cache', artwork: 'Artwork cache',
cache: 'Request cache', cache: 'Request cache',
sonarr: 'Sonarr', sonarr: 'Sonarr',
@@ -98,6 +99,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
seerr: 'Connect Seerr where users submit content requests.', seerr: 'Connect Seerr where users submit content requests.',
jellyseerr: 'Connect Seerr where users submit content requests.', jellyseerr: 'Connect Seerr where users submit content requests.',
jellyfin: 'Jellyfin connection, public playback links, user sync, and availability checks.', jellyfin: 'Jellyfin connection, public playback links, user sync, and availability checks.',
jellystat: 'Connect Jellystat so users can see their personal viewing stats in Magent.',
artwork: 'Cache posters/backdrops and review artwork coverage.', artwork: 'Cache posters/backdrops and review artwork coverage.',
cache: 'Manage saved requests cache and refresh behavior.', cache: 'Manage saved requests cache and refresh behavior.',
sonarr: 'Sonarr connection and the default profile and library location for TV requests.', sonarr: 'Sonarr connection and the default profile and library location for TV requests.',
@@ -119,6 +121,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
seerr: 'jellyseerr', seerr: 'jellyseerr',
jellyseerr: 'jellyseerr', jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin', jellyfin: 'jellyfin',
jellystat: 'jellystat',
artwork: null, artwork: null,
sonarr: 'sonarr', sonarr: 'sonarr',
radarr: 'radarr', radarr: 'radarr',
@@ -305,6 +308,14 @@ const STANDARD_SECTION_GROUPS: Record<
keys: ['jellyseerr_base_url', 'jellyseerr_api_key'], keys: ['jellyseerr_base_url', 'jellyseerr_api_key'],
}, },
], ],
jellystat: [
{
key: 'jellystat-connection',
title: 'Connection',
description: 'Use the Jellystat instance connected to the same Jellyfin server as Magent. Create a Jellystat API key in its settings, then save and test the connection.',
keys: ['jellystat_base_url', 'jellystat_api_key'],
},
],
jellyfin: [ jellyfin: [
{ {
key: 'jellyfin-connection', key: 'jellyfin-connection',
@@ -483,6 +494,8 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
magent_notify_webhook_url: 'Generic webhook URL', magent_notify_webhook_url: 'Generic webhook URL',
jellyfin_base_url: 'Internal server URL', jellyfin_base_url: 'Internal server URL',
jellyfin_api_key: 'Administrator API key', jellyfin_api_key: 'Administrator API key',
jellystat_base_url: 'Internal server URL',
jellystat_api_key: 'Jellystat API key',
jellyfin_public_url: 'Public playback URL', jellyfin_public_url: 'Public playback URL',
jellyfin_sync_to_arr: 'Reconcile Jellyfin with Sonarr and Radarr', jellyfin_sync_to_arr: 'Reconcile Jellyfin with Sonarr and Radarr',
sonarr_base_url: 'Sonarr server URL', sonarr_base_url: 'Sonarr server URL',
@@ -578,6 +591,7 @@ type SectionFeedback = {
const SERVICE_TEST_ENDPOINTS: Record<string, string> = { const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
'seerr-connection': 'seerr', 'seerr-connection': 'seerr',
'jellyfin-connection': 'jellyfin', 'jellyfin-connection': 'jellyfin',
'jellystat-connection': 'jellystat',
'sonarr-connection': 'sonarr', 'sonarr-connection': 'sonarr',
'radarr-connection': 'radarr', 'radarr-connection': 'radarr',
'bazarr-connection': 'bazarr', 'bazarr-connection': 'bazarr',
@@ -807,6 +821,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
seerr: ['Seerr', 'Jellyseerr', 'Jellyseer'], seerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
jellyseerr: ['Seerr', 'Jellyseerr', 'Jellyseer'], jellyseerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
jellyfin: ['Jellyfin'], jellyfin: ['Jellyfin'],
jellystat: ['Jellystat'],
sonarr: ['Sonarr'], sonarr: ['Sonarr'],
radarr: ['Radarr'], radarr: ['Radarr'],
bazarr: ['Bazarr'], bazarr: ['Bazarr'],
@@ -995,6 +1010,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
jellyfin_base_url: jellyfin_base_url:
'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.', 'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
jellyfin_api_key: 'Admin API key for syncing users and availability.', jellyfin_api_key: 'Admin API key for syncing users and availability.',
jellystat_base_url: 'Jellystat address reachable by Magent, including any base path. Example: http://jellystat:3000.',
jellystat_api_key: 'API key created in Jellystat. Stored privately by Magent and never sent to users browsers.',
jellyfin_public_url: jellyfin_public_url:
'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).', 'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).',
jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.', jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.',
@@ -1076,6 +1093,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
magent_notify_webhook_url: 'https://automation.example.com/webhooks/magent', magent_notify_webhook_url: 'https://automation.example.com/webhooks/magent',
jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055', jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096', jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
jellystat_base_url: 'http://jellystat:3000',
jellyfin_public_url: 'https://jelly.example.com', jellyfin_public_url: 'https://jelly.example.com',
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989', sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
bazarr_base_url: 'https://bazarr.example.com or 10.30.1.81:6767', bazarr_base_url: 'https://bazarr.example.com or 10.30.1.81:6767',
+1
View File
@@ -5,6 +5,7 @@ const ALLOWED_SECTIONS = new Set([
'seerr', 'seerr',
'jellyseerr', 'jellyseerr',
'jellyfin', 'jellyfin',
'jellystat',
'artwork', 'artwork',
'sonarr', 'sonarr',
'radarr', 'radarr',
+1
View File
@@ -5,6 +5,7 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
{ title: 'Media services', description: 'Connect the services that collect, repair and play your content.', items: [ { title: 'Media services', description: 'Connect the services that collect, repair and play your content.', items: [
{ href: '/admin/seerr', label: 'Seerr', description: 'Requests and approvals', symbol: 'SE', service: 'Seerr' }, { href: '/admin/seerr', label: 'Seerr', description: 'Requests and approvals', symbol: 'SE', service: 'Seerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin', description: 'Playback and library availability', symbol: 'JF', service: 'Jellyfin' }, { href: '/admin/jellyfin', label: 'Jellyfin', description: 'Playback and library availability', symbol: 'JF', service: 'Jellyfin' },
{ href: '/admin/jellystat', label: 'Jellystat', description: 'Personal viewing statistics', symbol: 'JS', service: 'Jellystat' },
{ href: '/admin/sonarr', label: 'Sonarr', description: 'TV collection and quality', symbol: 'SO', service: 'Sonarr' }, { href: '/admin/sonarr', label: 'Sonarr', description: 'TV collection and quality', symbol: 'SO', service: 'Sonarr' },
{ href: '/admin/radarr', label: 'Radarr', description: 'Movie collection and quality', symbol: 'RA', service: 'Radarr' }, { href: '/admin/radarr', label: 'Radarr', description: 'Movie collection and quality', symbol: 'RA', service: 'Radarr' },
{ href: '/admin/bazarr', label: 'Bazarr', description: 'Subtitle repairs', symbol: 'BA', service: 'Bazarr' }, { href: '/admin/bazarr', label: 'Bazarr', description: 'Subtitle repairs', symbol: 'BA', service: 'Bazarr' },
+133
View File
@@ -0,0 +1,133 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, getApiBase } from '../lib/auth'
import PageHeading from '../ui/PageHeading'
import './stats.css'
type Breakdown = { name: string; minutes: number }
type Day = { date: string; minutes: number }
type Stats = {
state: 'ready' | 'not_configured' | 'unlinked'
is_admin: boolean
days: number
updated_at?: string
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 }[]
clients?: Breakdown[]
methods?: Breakdown[]
requests: { total: number; movies: number; tv: number; pending: number; approved: number; declined: number; recent: { request_id: number; title: string; media_type: string; status: number }[] }
}
const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 0 })
const dateLabel = (date: string) => new Date(date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
function ViewingChart({ daily }: { daily: Day[] }) {
const [selected, setSelected] = useState<number | null>(null)
const bucket = daily.length > 100 ? 7 : daily.length > 35 ? 3 : 1
const bars: { start: string; end: string; minutes: number }[] = []
for (let i = 0; i < daily.length; i += bucket) {
const group = daily.slice(i, i + bucket)
bars.push({ start: group[0].date, end: group[group.length - 1].date, minutes: group.reduce((sum, day) => sum + day.minutes, 0) })
}
const peak = Math.max(1, ...bars.map((bar) => bar.minutes))
const active = selected === null ? null : bars[selected]
return (
<section className="stats-panel stats-viewing" aria-labelledby="viewing-title">
<div className="stats-panel-heading"><div><h2 id="viewing-title">Your viewing rhythm</h2><p>{bucket === 1 ? 'Daily' : `${bucket}-day`} watch time · UTC</p></div><span className="stats-unit">Minutes</span></div>
<div className="stats-chart-detail" aria-live="polite">{active ? `${dateLabel(active.start)}${active.end !== active.start ? ` ${dateLabel(active.end)}` : ''} · ${number(active.minutes)} minutes` : 'Select a bar to explore your watch time.'}</div>
<div className="stats-chart">
<div className="stats-chart-scale" aria-hidden="true"><span>{number(peak)}</span><span>{number(peak / 2)}</span><span>0</span></div>
<div className="stats-chart-bars">
{bars.map((bar, index) => <button type="button" className={selected === index ? 'is-selected' : ''} key={bar.start} aria-label={`${dateLabel(bar.start)}${bar.end !== bar.start ? ` to ${dateLabel(bar.end)}` : ''}: ${number(bar.minutes)} minutes`} aria-pressed={selected === index} onClick={() => setSelected(index)} onFocus={() => setSelected(index)}><span style={{ height: `${bar.minutes > 0 ? Math.max(2, bar.minutes / peak * 100) : 1}%` }} /></button>)}
</div>
</div>
<div className="stats-chart-axis" aria-hidden="true"><span>{bars[0] && dateLabel(bars[0].start)}</span><span>{bars.length > 0 && dateLabel(bars[bars.length - 1].end)}</span></div>
</section>
)
}
function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) {
const total = rows.reduce((sum, row) => sum + row.minutes, 0)
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>
}
export default function InsightsPage() {
const router = useRouter()
const [days, setDays] = useState(30)
const [data, setData] = useState<Stats | null>(null)
const [busy, setBusy] = useState(true)
const [error, setError] = useState('')
const [revision, setRevision] = useState(0)
const load = useCallback(async (signal: AbortSignal) => {
setBusy(true)
setError('')
setData(null)
try {
const response = await authFetch(`${getApiBase()}/insights?days=${days}`, { signal })
if (response.status === 401) { router.replace('/login?next=%2Finsights'); return }
if (response.status === 403) throw new Error('Your account cannot access viewing stats. Please contact an administrator.')
if (!response.ok) {
const result = await response.json().catch(() => ({}))
throw new Error(typeof result.detail === 'string' ? result.detail : 'Your viewing stats are temporarily unavailable. Please try again shortly.')
}
const result = await response.json() as Stats
if (!signal.aborted) setData(result)
} catch (err) {
if (!signal.aborted) setError(err instanceof Error ? err.message : 'Could not load your stats.')
} finally {
if (!signal.aborted) setBusy(false)
}
}, [days, router])
useEffect(() => {
const controller = new AbortController()
void load(controller.signal)
return () => controller.abort()
}, [load, revision])
const summary = data?.summary
return (
<main className="stats-page">
<PageHeading title="My Stats" description="Your viewing, in numbers. Watch time, favourite stories, and the requests that started it all." actions={<button className="ghost-button" type="button" disabled={busy} onClick={() => setRevision((value) => value + 1)}>{busy ? 'Loading…' : 'Refresh stats'}</button>} />
<div className="stats-toolbar">
<fieldset className="stats-period"><legend className="stats-sr-only">Stats period</legend>{[7, 30, 90, 365].map((value) => <button type="button" key={value} aria-pressed={days === value} onClick={() => setDays(value)}>{value === 365 ? 'Past year' : `${value} days`}</button>)}</fieldset>
<p className="stats-source"><span className={data?.state === 'ready' ? 'stats-source-dot is-ready' : 'stats-source-dot'} />From Jellystat{data?.updated_at && <span> · Updated {new Date(data.updated_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span>}</p>
</div>
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true"></span><h2>Gathering your stats</h2><p>Fetching your viewing history from Jellystat.</p></div>}
{error && <div className="stats-state" role="alert"><h2>Stats couldnt load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button></div>}
{data?.state === 'not_configured' && <section className="stats-state"><span className="stats-state-symbol" aria-hidden="true"></span><h2>Your viewing story starts here</h2><p>{data.is_admin ? 'Connect your Jellystat instance to bring personal viewing stats into Magent.' : 'Viewing stats will appear here once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>}
{data?.state === 'unlinked' && <section className="stats-state"><h2>Link your viewing account</h2><p>Your Magent account needs a Jellyfin identity to find your stats. Sign in using Jellyfin, or ask your administrator to sync Jellyfin users.</p></section>}
{summary && <>
<section className="stats-metrics" aria-label="Viewing totals">
<article className="stats-metric stats-metric-accent"><span>Minutes watched</span><strong>{number(summary.minutes)}</strong><small>{number(summary.minutes / 60)} hours across {number(summary.plays)} plays</small></article>
<article className="stats-metric"><span>Movies played</span><strong>{number(summary.movies)}</strong><small>Different movies you pressed play on</small></article>
<article className="stats-metric"><span>Episodes played</span><strong>{number(summary.episodes)}</strong><small>Different episodes in your history</small></article>
<article className="stats-metric"><span>Requests made</span><strong>{number(data.requests.total)}</strong><small>{number(data.requests.movies)} movies · {number(data.requests.tv)} TV requests</small></article>
</section>
{summary.plays === 0 && <div className="stats-notice" role="status">No viewing history in this period yet. Try a longer period, or come back after your next watch.</div>}
<div className="stats-main-grid">
<ViewingChart key={`${days}-${revision}`} daily={data.daily ?? []} />
<section className="stats-panel stats-highlights"><div className="stats-panel-heading"><h2>A little watch history</h2></div>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.current_streak}<small> days</small></span><div><strong>Current streak</strong><p>Consecutive viewing days through today or yesterday.</p></div></div>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.longest_streak}<small> days</small></span><div><strong>Longest run</strong><p>Your best streak in this period.</p></div></div>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.active_days}<small> days</small></span><div><strong>Time for a story</strong><p>Days with at least a minute watched.</p></div></div>
</section>
</div>
<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 ?? []} />
</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>}
<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>}
</main>
)
}
+95
View File
@@ -0,0 +1,95 @@
.stats-page { padding-bottom: 32px !important; }
.stats-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.stats-period { display: flex; padding: 4px; margin: 0; min-width: 0; gap: 4px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-panel); }
.stats-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.stats-period button { min-height: 38px; padding: 8px 16px; border: 0; border-radius: 6px; background: transparent !important; color: var(--ops-muted) !important; font-size: 13px; text-transform: none; }
.stats-period button[aria-pressed=true] { background: #c7bdff !important; color: #211a36 !important; font-weight: 700; }
.stats-source { margin: 0; font-size: 12px; color: var(--ops-muted); }
.stats-source-dot { display: inline-block; height: 6px; width: 6px; margin-right: 8px; border-radius: 50%; background: var(--ops-faint); }
.stats-source-dot.is-ready { background: #95d5b2; }
.stats-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
.stats-metric { display: grid; align-content: start; gap: 12px; padding: 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); }
.stats-metric > span { font-size: 13px; color: var(--ops-muted); }
.stats-metric > strong { font: 600 clamp(28px, 3vw, 42px)/1.15 "DM Sans", sans-serif; color: var(--ops-text); letter-spacing: -.03em; }
.stats-metric > small { color: var(--ops-faint); font-size: 12px; line-height: 1.6; }
.stats-metric-accent { border-color: #655987; background: linear-gradient(135deg, #2f2940, var(--ops-panel)); }
.stats-metric-accent > strong { color: #d5cbff; }
.stats-main-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); gap: 24px; }
.stats-three-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
.stats-panel { min-width: 0; padding: 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); }
.stats-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 24px; }
.stats-panel h2 { margin: 0; color: var(--ops-text); font-size: 17px; font-weight: 600; }
.stats-panel-heading p { margin: 8px 0 0; color: var(--ops-faint); font-size: 12px; }
.stats-panel-heading a { font-size: 12px; white-space: nowrap; color: #c7bdff; }
.stats-unit { color: var(--ops-faint); font-size: 11px; white-space: nowrap; }
.stats-chart-detail { min-height: 28px; color: var(--ops-muted); font-size: 12px; }
.stats-chart { height: 180px; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; margin-top: 12px; }
.stats-chart-scale { display: flex; flex-direction: column; justify-content: space-between; text-align: right; font: 10px "JetBrains Mono", monospace; color: var(--ops-faint); }
.stats-chart-bars { display: flex; align-items: stretch; gap: clamp(2px, .5vw, 8px); background: repeating-linear-gradient(to top, var(--ops-line-soft) 0px, var(--ops-line-soft) 1px, transparent 1px, transparent 50%); }
.stats-chart-bars button { display: flex; align-items: flex-end; justify-content: center; padding: 0; min-width: 0; flex: 1; border: 0; background: transparent !important; border-radius: 3px; }
.stats-chart-bars button > span { display: block; width: 100%; max-width: 44px; background: #9085b8; border-radius: 3px 3px 0 0; }
.stats-chart-bars button:is(:hover, :focus-visible, .is-selected) > span { background: #d1c6ff; }
.stats-chart-axis { display: flex; justify-content: space-between; padding-left: 50px; margin-top: 12px; color: var(--ops-faint); font-size: 11px; }
.stats-highlight { display: grid; grid-template-columns: 85px minmax(0, 1fr); gap: 16px; align-items: center; padding: 19px 0; border-top: 1px solid var(--ops-line-soft); }
.stats-highlight:first-of-type { border-top: 0; }
.stats-highlight-number { font-size: 28px; color: #d1c6ff; font-weight: 600; }
.stats-highlight-number small { font-size: 11px; color: var(--ops-faint); font-weight: 400; }
.stats-highlight strong { font-size: 13px; color: var(--ops-text); }
.stats-highlight p { margin: 6px 0 0; color: var(--ops-faint); font-size: 12px; line-height: 1.5; }
.stats-top-titles { list-style: none; margin: 0; padding: 0; display: grid; gap: 20px; }
.stats-top-titles li { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 12px; align-items: center; }
.stats-rank { font: 11px "JetBrains Mono", monospace; color: var(--ops-faint); }
.stats-top-titles strong { display: block; font-size: 13px; font-weight: 500; color: var(--ops-text); overflow-wrap: anywhere; }
.stats-top-titles small { display: block; margin-top: 5px; font-size: 11px; color: var(--ops-faint); }
.stats-top-titles li > span:last-child { text-align: right; font-size: 13px; color: var(--ops-muted); }
.stats-breakdown { display: grid; gap: 24px; }
.stats-breakdown-label { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 12px; }
.stats-breakdown-label span { color: var(--ops-muted); overflow-wrap: anywhere; }
.stats-breakdown-label strong { white-space: nowrap; font-size: 11px; color: var(--ops-faint); font-weight: 400; }
.stats-meter { height: 5px; background: var(--ops-line-soft); border-radius: 5px; overflow: hidden; }
.stats-meter > span { display: block; height: 100%; background: #a497c9; border-radius: 5px; }
.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-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; }
.stats-history-title small, .stats-history-title > span { display: block; color: var(--ops-faint); font-size: 11px; line-height: 1.6; margin-top: 3px; overflow-wrap: anywhere; }
.stats-history-title > span { font-size: 10px; }
.stats-history-time { display: grid; gap: 8px; text-align: right; flex-shrink: 0; }
.stats-history-time strong { font-size: 12px; color: var(--ops-muted); font-weight: 500; }
.stats-history-time time { font-size: 11px; color: var(--ops-faint); }
.stats-requests { align-self: start; }
.stats-request-total { display: flex; align-items: center; gap: 16px; }
.stats-request-total > strong { font-size: 36px; color: var(--ops-text); }
.stats-request-total > span { max-width: 15ch; color: var(--ops-muted); font-size: 12px; line-height: 1.6; }
.stats-request-counts { display: flex; justify-content: space-between; gap: 8px; padding: 20px 0; margin-top: 16px; border-block: 1px solid var(--ops-line-soft); }
.stats-request-counts > span { font-size: 11px; color: var(--ops-faint); }
.stats-request-counts strong { display: block; margin-bottom: 8px; color: var(--ops-text); font-size: 18px; font-weight: 500; }
.stats-request-list { list-style: none; margin: 10px 0 0; padding: 0; }
.stats-request-list a { display: flex; justify-content: space-between; gap: 16px; padding: 14px 0; color: var(--ops-muted); font-size: 12px; text-decoration: none; overflow-wrap: anywhere; }
.stats-request-list a:hover { color: #d1c6ff; }
.stats-request-list a > span { color: var(--ops-faint); }
.stats-state { display: grid; justify-items: center; gap: 14px; padding: 56px 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); text-align: center; }
.stats-state h2 { margin: 0; font-size: 22px; color: var(--ops-text); }
.stats-state p { margin: 0; max-width: 60ch; font-size: 14px; color: var(--ops-muted); line-height: 1.8; }
.stats-state-symbol { margin-bottom: 8px; color: #c7bdff; font-size: 36px; }
.stats-action { display: inline-block; margin-top: 8px; padding: 12px 20px; background: #c7bdff; color: #211a36; border-radius: 8px; font-size: 13px; font-weight: 600; text-decoration: none; }
.stats-notice { padding: 16px 20px; border: 1px solid var(--ops-line); border-radius: 8px; color: var(--ops-muted); font-size: 13px; line-height: 1.6; }
.stats-muted, .stats-footnote { color: var(--ops-faint); font-size: 12px; line-height: 1.8; }
.stats-footnote { margin: 0; }
@media (max-width: 1100px) {
.stats-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stats-main-grid { grid-template-columns: minmax(0, 1.5fr) minmax(260px, 1fr); }
.stats-three-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stats-three-grid > :first-child { grid-column: 1 / -1; }
}
@media (max-width: 760px) {
.stats-main-grid, .stats-three-grid { grid-template-columns: minmax(0, 1fr); gap: 20px; }
.stats-metric { padding: 18px; gap: 10px; }
.stats-panel { padding: 20px; }
.stats-period { width: 100%; }
.stats-period button { flex: 1; padding-inline: 8px; }
.stats-metrics { gap: 12px; }
}
+1 -1
View File
@@ -71,7 +71,7 @@ export default function LoginPage() {
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return } if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
setToken('cookie') setToken('cookie')
const next = new URLSearchParams(window.location.search).get('next') || '' const next = new URLSearchParams(window.location.search).get('next') || ''
window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/welcome') window.location.assign(next === '/insights' || /^\/issues\/confirm\/\d+$/.test(next) ? next : '/welcome')
} catch { } catch {
setError('Could not reach Magent. Check your connection and try again.') setError('Could not reach Magent. Check your connection and try again.')
} finally { setLoading(false) } } finally { setLoading(false) }
+5
View File
@@ -75,6 +75,11 @@ export default function HeaderActions() {
] ]
const commonItems = [ const commonItems = [
{
href: '/insights',
label: 'My Stats',
match: (path: string) => path === '/insights',
},
{ {
href: '/', href: '/',
label: 'My Requests', label: 'My Requests',
+4 -2
View File
@@ -8,12 +8,13 @@ type NavigationItem = {
href: string href: string
label: string label: string
shortLabel: string shortLabel: string
icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings' icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings' | 'stats'
adminOnly?: boolean adminOnly?: boolean
match: (path: string) => boolean match: (path: string) => boolean
} }
const NAVIGATION: NavigationItem[] = [ const NAVIGATION: NavigationItem[] = [
{ href: '/insights', label: 'My Stats', shortLabel: 'Stats', icon: 'stats', match: (path) => path === '/insights' },
{ href: '/', label: 'My Requests', shortLabel: 'Requests', icon: 'dashboard', match: (path) => path === '/' || path.startsWith('/requests/') }, { href: '/', label: 'My Requests', shortLabel: 'Requests', icon: 'dashboard', match: (path) => path === '/' || path.startsWith('/requests/') },
{ href: '/new-requests', label: 'New Request', shortLabel: 'New', icon: 'media', match: (path) => path === '/new-requests' }, { href: '/new-requests', label: 'New Request', shortLabel: 'New', icon: 'media', match: (path) => path === '/new-requests' },
{ href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') }, { href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') },
@@ -25,6 +26,7 @@ const HIDDEN_ROUTES = ['/login', '/signup', '/forgot-password', '/reset-password
function NavigationIcon({ name }: { name: NavigationItem['icon'] }) { function NavigationIcon({ name }: { name: NavigationItem['icon'] }) {
const paths: Record<NavigationItem['icon'], React.ReactNode> = { const paths: Record<NavigationItem['icon'], React.ReactNode> = {
stats: <><path d="M4 20h16M6 16v-5m6 5V4m6 12V8" /></>,
dashboard: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>, dashboard: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
media: <><rect x="3" y="5" width="18" height="15" rx="2" /><path d="m8 3 2 4m4-4 2 4M3 10h18" /><path d="m10 13 5 3-5 3z" /></>, media: <><rect x="3" y="5" width="18" height="15" rx="2" /><path d="m8 3 2 4m4-4 2 4M3 10h18" /><path d="m10 13 5 3-5 3z" /></>,
issues: <><path d="M12 3 2.8 19h18.4L12 3Z" /><path d="M12 9v4m0 3h.01" /></>, issues: <><path d="M12 3 2.8 19h18.4L12 3Z" /><path d="M12 9v4m0 3h.01" /></>,
@@ -67,7 +69,7 @@ export default function WorkspaceNavigation() {
return ( return (
<> <>
<nav className="workspace-mobile-nav" aria-label="Mobile navigation"> <nav className="workspace-mobile-nav" aria-label="Mobile navigation">
{items.slice(0, 5).map((item) => ( {items.map((item) => (
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}> <a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
<NavigationIcon name={item.icon} /><span>{item.shortLabel}</span> <NavigationIcon name={item.icon} /><span>{item.shortLabel}</span>
</a> </a>
+2
View File
@@ -158,6 +158,8 @@
.page-heading .home-search { width: 100%; } .page-heading .home-search { width: 100%; }
} }
@media (max-width: 680px) { @media (max-width: 680px) {
.workspace-mobile-nav { padding-inline: 4px; gap: 0; }
.workspace-mobile-nav a { min-width: 0; flex: 1; padding-inline: 3px; }
:root { --workspace-gutter: 16px; --workspace-gap: 20px; } :root { --workspace-gutter: 16px; --workspace-gap: 20px; }
.page > main:not(.login-page), .admin-shell.admin-shell--top-nav { margin-top: 24px; } .page > main:not(.login-page), .admin-shell.admin-shell--top-nav { margin-top: 24px; }
.page-heading { padding-bottom: 20px; } .page-heading { padding-bottom: 20px; }
+129
View File
@@ -0,0 +1,129 @@
// Fixture-only review. No requests reach Jellystat or a live Magent backend.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3114'
const output = process.env.REVIEW_DIR
;(async () => {
const browser = await chromium.launch({ headless: true })
try {
const context = await browser.newContext()
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }])
let mode = 'ready'
let role = 'admin'
const periods = []
const mutations = []
let settings = [
{ key: 'jellystat_base_url', value: 'http://jellystat:3000', isSet: true, source: 'environment', sensitive: false },
{ key: 'jellystat_api_key', value: null, isSet: true, source: 'environment', sensitive: true },
]
const daily = Array.from({ length: 31 }, (_, i) => ({ date: new Date(Date.UTC(2026, 7, 8 + i)).toISOString().slice(0, 10), minutes: i % 4 ? 30 + i * 2 : 0 }))
const fixture = (days) => ({
state: mode, is_admin: role === 'admin', days, source: 'Jellystat', timezone: 'UTC', updated_at: '2026-09-07T12:00:00Z',
summary: { minutes: daily.reduce((sum, day) => sum + day.minutes, 0), movies: 8, episodes: 23, plays: 35, active_days: 21, current_streak: 3, longest_streak: 6 },
daily,
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' }],
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 === '/api/insights') {
const days = Number(url.searchParams.get('days'))
periods.push(days)
if (mode === 'unauthorized') return route.fulfill({ status: 401, json: { detail: 'Sign in' } })
if (mode === 'unavailable') return route.fulfill({ status: 502, json: { detail: 'Your viewing stats are temporarily unavailable. Please try again shortly.' } })
const result = fixture(days)
if (mode === 'empty') {
result.state = 'ready'
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 !== 'ready') result.summary = null
return route.fulfill({ json: result })
}
if (url.pathname === '/api/admin/settings') {
if (request.method() === 'PUT') {
const body = request.postDataJSON()
mutations.push({ path: url.pathname, body })
settings = settings.map((setting) => Object.hasOwn(body, setting.key) ? { ...setting, value: setting.sensitive ? null : body[setting.key], isSet: true, source: 'database' } : setting)
return route.fulfill({ json: { updated: Object.keys(body).length } })
}
return route.fulfill({ json: { settings } })
}
if (url.pathname === '/api/status/services/jellystat/test') {
mutations.push({ path: url.pathname })
return route.fulfill({ json: { name: 'Jellystat', status: 'up', detail: { connected: true } } })
}
if (url.pathname === '/api/status/services') return route.fulfill({ json: { services: [{ name: 'Jellystat', status: 'up' }] } })
if (url.pathname.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' })
return route.fulfill({ json: {} })
})
const page = await context.newPage()
const errors = []
page.on('pageerror', (error) => errors.push(error.message))
for (const width of [1440, 980, 390, 320]) {
await page.setViewportSize({ width, height: 1000 })
await page.goto(base + '/insights')
await page.getByRole('heading', { name: 'Your viewing rhythm' }).waitFor()
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/)
assert.equal(await page.getByRole('link', { name: 'Dune: Part Two' }).getAttribute('href'), '/requests/12')
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Overflow at ${width}px`)
if (width <= 980) {
await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: 'Config' }).waitFor({ state: 'visible' })
await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: 'Stats' }).waitFor({ state: 'visible' })
}
if (output) {
fs.mkdirSync(output, { recursive: true })
await page.screenshot({ path: path.join(output, `insights-${width}.png`), fullPage: true })
}
}
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)
for (const [state, text] of [['empty', 'No viewing history in this period yet.'], ['not_configured', 'Your viewing story starts here'], ['unlinked', 'Link your viewing account'], ['unavailable', 'Stats couldnt load']]) {
mode = state
await page.reload()
await page.getByText(text, { exact: false }).waitFor()
if (state === 'not_configured') assert.equal(await page.getByRole('link', { name: 'Connect Jellystat' }).getAttribute('href'), '/admin/jellystat')
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
}
mode = 'not_configured'
role = 'user'
await page.reload()
await page.getByText('Viewing stats will appear here once your administrator connects Jellystat.').waitFor()
assert.equal(await page.getByRole('link', { name: 'Connect Jellystat' }).count(), 0)
mode = 'unauthorized'
await page.reload()
await page.waitForURL('**/login?next=%2Finsights')
role = 'admin'
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 1000 })
await page.goto(base + '/admin/jellystat')
await page.getByRole('heading', { name: 'Jellystat', exact: true }).waitFor()
await page.getByRole('button', { name: 'Test connection', exact: true }).waitFor()
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
if (output) await page.screenshot({ path: path.join(output, `jellystat-settings-${width}.png`), fullPage: true })
}
await page.getByRole('button', { name: 'Test connection', exact: true }).click()
await page.getByText('Jellystat connection test passed.').waitFor()
assert(mutations.some((request) => request.path === '/api/status/services/jellystat/test'))
const urlInput = page.locator('#setting-jellystat_base_url')
await urlInput.fill('http://jellystat:3001')
await page.getByRole('button', { name: 'Save changes', exact: true }).click()
await page.getByText('Saved', { exact: true }).first().waitFor()
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.')
} finally { await browser.close() }
})().catch((error) => { console.error(error); process.exit(1) })