From 2976145dd8987e649aedf9134f82954f6fdbd12d Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Tue, 8 Sep 2026 11:25:05 +1200 Subject: [PATCH] Add private Jellystat viewing stats to Magent beta --- README.md | 1 + backend/app/clients/jellystat.py | 86 +++++++++ backend/app/config.py | 5 + backend/app/db.py | 6 + backend/app/main.py | 2 + backend/app/routers/admin.py | 4 + backend/app/routers/auth.py | 5 + backend/app/routers/insights.py | 34 ++++ backend/app/routers/status.py | 9 + backend/app/services/insights.py | 181 +++++++++++++++++++ backend/app/services/jellyfin_identity.py | 35 ++++ backend/app/services/jellyfin_sync.py | 5 + backend/tests/test_insights.py | 201 ++++++++++++++++++++++ docs/jellystat-integration.md | 47 +++++ frontend/app/admin/SettingsPage.tsx | 18 ++ frontend/app/admin/[section]/page.tsx | 1 + frontend/app/admin/configNavigation.ts | 1 + frontend/app/insights/page.tsx | 133 ++++++++++++++ frontend/app/insights/stats.css | 95 ++++++++++ frontend/app/login/page.tsx | 2 +- frontend/app/ui/HeaderActions.tsx | 5 + frontend/app/ui/WorkspaceNavigation.tsx | 6 +- frontend/app/workspace.css | 2 + scripts/review_insights_ui.cjs | 129 ++++++++++++++ 24 files changed, 1010 insertions(+), 3 deletions(-) create mode 100644 backend/app/clients/jellystat.py create mode 100644 backend/app/routers/insights.py create mode 100644 backend/app/services/insights.py create mode 100644 backend/app/services/jellyfin_identity.py create mode 100644 backend/tests/test_insights.py create mode 100644 docs/jellystat-integration.md create mode 100644 frontend/app/insights/page.tsx create mode 100644 frontend/app/insights/stats.css create mode 100644 scripts/review_insights_ui.cjs diff --git a/README.md b/README.md index 7671587..22f6fd1 100644 --- a/README.md +++ b/README.md @@ -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. - Users and access control (admin vs user, block access). - 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. ## Quick start (Docker - primary) diff --git a/backend/app/clients/jellystat.py b/backend/app/clients/jellystat.py new file mode 100644 index 0000000..77a3667 --- /dev/null +++ b/backend/app/clients/jellystat.py @@ -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") diff --git a/backend/app/config.py b/backend/app/config.py index c46407f..a5a2640 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -258,6 +258,11 @@ class Settings(BaseSettings): jellyseerr_api_key: Optional[str] = Field( 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( default=None, validation_alias=AliasChoices("JELLYFIN_URL", "JELLYFIN_BASE_URL") ) diff --git a/backend/app/db.py b/backend/app/db.py index e682f9e..d7dc41a 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -187,6 +187,12 @@ def _has_secure_bootstrap_admin_credentials() -> bool: def init_db() -> None: 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(""" CREATE TABLE IF NOT EXISTS request_repairs ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/backend/app/main.py b/backend/app/main.py index 8cccdd6..e034a19 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -27,6 +27,7 @@ from .routers.site import router as site_router from .routers.events import router as events_router from .routers.portal import router as portal_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.issue_resolution import run_issue_confirmation_loop from .services.operation_progress import ( @@ -280,3 +281,4 @@ app.include_router(site_router) app.include_router(events_router) app.include_router(portal_router) app.include_router(operations_router) +app.include_router(insights_router) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 8273a6d..0e6f49d 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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") SENSITIVE_KEYS = { + "jellystat_api_key", "magent_ssl_certificate_pem", "magent_ssl_private_key_pem", "magent_notify_email_smtp_password", @@ -150,6 +151,7 @@ SENSITIVE_KEYS = { } URL_SETTING_KEYS = { + "jellystat_base_url", "magent_application_url", "magent_api_url", "magent_proxy_base_url", @@ -172,6 +174,8 @@ NOTIFICATION_URL_SETTING_KEYS = { } SETTING_KEYS: List[str] = [ + "jellystat_base_url", + "jellystat_api_key", "magent_application_url", "magent_application_port", "magent_api_url", diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b58847c..4bf3a61 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -757,6 +757,11 @@ async def jellyfin_login( save_jellyfin_users_cache(users) except Exception: 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) if user and user.get("jellyseerr_user_id") is None and candidate_map: matched_id = match_jellyseerr_user_id(canonical_username, candidate_map) diff --git a/backend/app/routers/insights.py b/backend/app/routers/insights.py new file mode 100644 index 0000000..ce632e9 --- /dev/null +++ b/backend/app/routers/insights.py @@ -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 diff --git a/backend/app/routers/status.py b/backend/app/routers/status.py index 0d65bff..d85463c 100644 --- a/backend/app/routers/status.py +++ b/backend/app/routers/status.py @@ -11,6 +11,7 @@ from ..clients.bazarr import BazarrClient from ..clients.prowlarr import ProwlarrClient from ..clients.qbittorrent import QBittorrentClient from ..clients.jellyfin import JellyfinClient +from ..clients.jellystat import JellystatClient 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" if any(s.get("status") == "down" for s in services): 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) 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 = { "seerr": ( "Seerr", diff --git a/backend/app/services/insights.py b/backend/app/services/insights.py new file mode 100644 index 0000000..613147b --- /dev/null +++ b/backend/app/services/insights.py @@ -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} diff --git a/backend/app/services/jellyfin_identity.py b/backend/app/services/jellyfin_identity.py new file mode 100644 index 0000000..7f0b20a --- /dev/null +++ b/backend/app/services/jellyfin_identity.py @@ -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)), + ) diff --git a/backend/app/services/jellyfin_sync.py b/backend/app/services/jellyfin_sync.py index 0946ef3..1ae9818 100644 --- a/backend/app/services/jellyfin_sync.py +++ b/backend/app/services/jellyfin_sync.py @@ -11,6 +11,7 @@ from ..db import ( set_user_jellyseerr_id, ) from ..runtime import get_runtime_settings +from .jellyfin_identity import link_user from .user_cache import ( build_jellyseerr_candidate_map, extract_jellyseerr_user_email, @@ -68,6 +69,10 @@ async def sync_jellyfin_users() -> int: set_user_jellyseerr_id(name, matched_id) if 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 diff --git a/backend/tests/test_insights.py b/backend/tests/test_insights.py new file mode 100644 index 0000000..e0740c4 --- /dev/null +++ b/backend/tests/test_insights.py @@ -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) diff --git a/docs/jellystat-integration.md b/docs/jellystat-integration.md new file mode 100644 index 0000000..de7173e --- /dev/null +++ b/docs/jellystat-integration.md @@ -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. diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index 2ad0174..83501f8 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -34,6 +34,7 @@ const SECTION_LABELS: Record = { seerr: 'Seerr', jellyseerr: 'Seerr', jellyfin: 'Jellyfin', + jellystat: 'Jellystat', artwork: 'Artwork cache', cache: 'Request cache', sonarr: 'Sonarr', @@ -98,6 +99,7 @@ const SECTION_DESCRIPTIONS: Record = { seerr: '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.', + jellystat: 'Connect Jellystat so users can see their personal viewing stats in Magent.', artwork: 'Cache posters/backdrops and review artwork coverage.', cache: 'Manage saved requests cache and refresh behavior.', sonarr: 'Sonarr connection and the default profile and library location for TV requests.', @@ -119,6 +121,7 @@ const SETTINGS_SECTION_MAP: Record = { seerr: 'jellyseerr', jellyseerr: 'jellyseerr', jellyfin: 'jellyfin', + jellystat: 'jellystat', artwork: null, sonarr: 'sonarr', radarr: 'radarr', @@ -305,6 +308,14 @@ const STANDARD_SECTION_GROUPS: Record< 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: [ { key: 'jellyfin-connection', @@ -483,6 +494,8 @@ const SETTING_LABEL_OVERRIDES: Record = { magent_notify_webhook_url: 'Generic webhook URL', jellyfin_base_url: 'Internal server URL', 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_sync_to_arr: 'Reconcile Jellyfin with Sonarr and Radarr', sonarr_base_url: 'Sonarr server URL', @@ -578,6 +591,7 @@ type SectionFeedback = { const SERVICE_TEST_ENDPOINTS: Record = { 'seerr-connection': 'seerr', 'jellyfin-connection': 'jellyfin', + 'jellystat-connection': 'jellystat', 'sonarr-connection': 'sonarr', 'radarr-connection': 'radarr', 'bazarr-connection': 'bazarr', @@ -807,6 +821,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { seerr: ['Seerr', 'Jellyseerr', 'Jellyseer'], jellyseerr: ['Seerr', 'Jellyseerr', 'Jellyseer'], jellyfin: ['Jellyfin'], + jellystat: ['Jellystat'], sonarr: ['Sonarr'], radarr: ['Radarr'], bazarr: ['Bazarr'], @@ -995,6 +1010,8 @@ export default function SettingsPage({ section }: SettingsPageProps) { jellyfin_base_url: 'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.', 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: '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.', @@ -1076,6 +1093,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { magent_notify_webhook_url: 'https://automation.example.com/webhooks/magent', 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', + jellystat_base_url: 'http://jellystat:3000', jellyfin_public_url: 'https://jelly.example.com', 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', diff --git a/frontend/app/admin/[section]/page.tsx b/frontend/app/admin/[section]/page.tsx index 7285bd2..37a2079 100644 --- a/frontend/app/admin/[section]/page.tsx +++ b/frontend/app/admin/[section]/page.tsx @@ -5,6 +5,7 @@ const ALLOWED_SECTIONS = new Set([ 'seerr', 'jellyseerr', 'jellyfin', + 'jellystat', 'artwork', 'sonarr', 'radarr', diff --git a/frontend/app/admin/configNavigation.ts b/frontend/app/admin/configNavigation.ts index 5cc212d..890a88d 100644 --- a/frontend/app/admin/configNavigation.ts +++ b/frontend/app/admin/configNavigation.ts @@ -5,6 +5,7 @@ export const CONFIG_GROUPS: ConfigGroup[] = [ { 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/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/radarr', label: 'Radarr', description: 'Movie collection and quality', symbol: 'RA', service: 'Radarr' }, { href: '/admin/bazarr', label: 'Bazarr', description: 'Subtitle repairs', symbol: 'BA', service: 'Bazarr' }, diff --git a/frontend/app/insights/page.tsx b/frontend/app/insights/page.tsx new file mode 100644 index 0000000..98e6214 --- /dev/null +++ b/frontend/app/insights/page.tsx @@ -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(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 ( +
+

Your viewing rhythm

{bucket === 1 ? 'Daily' : `${bucket}-day`} watch time · UTC

Minutes
+
{active ? `${dateLabel(active.start)}${active.end !== active.start ? ` – ${dateLabel(active.end)}` : ''} · ${number(active.minutes)} minutes` : 'Select a bar to explore your watch time.'}
+
+ +
+ {bars.map((bar, index) => )} +
+
+ +
+ ) +} + +function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) { + const total = rows.reduce((sum, row) => sum + row.minutes, 0) + return

{title}

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

Your next watch will start the story here.

}
+} + +export default function InsightsPage() { + const router = useRouter() + const [days, setDays] = useState(30) + const [data, setData] = useState(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 ( +
+ setRevision((value) => value + 1)}>{busy ? 'Loading…' : 'Refresh stats'}} /> +
+
Stats period{[7, 30, 90, 365].map((value) => )}
+

From Jellystat{data?.updated_at && · Updated {new Date(data.updated_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}}

+
+ {busy &&

Gathering your stats

Fetching your viewing history from Jellystat.

} + {error &&

Stats couldn’t load

{error}

} + {data?.state === 'not_configured' &&

Your viewing story starts here

{data.is_admin ? 'Connect your Jellystat instance to bring personal viewing stats into Magent.' : 'Viewing stats will appear here once your administrator connects Jellystat.'}

{data.is_admin && Connect Jellystat}
} + {data?.state === 'unlinked' &&

Link your viewing account

Your Magent account needs a Jellyfin identity to find your stats. Sign in using Jellyfin, or ask your administrator to sync Jellyfin users.

} + {summary && <> +
+
Minutes watched{number(summary.minutes)}{number(summary.minutes / 60)} hours across {number(summary.plays)} plays
+
Movies played{number(summary.movies)}Different movies you pressed play on
+
Episodes played{number(summary.episodes)}Different episodes in your history
+
Requests made{number(data.requests.total)}{number(data.requests.movies)} movies · {number(data.requests.tv)} TV requests
+
+ {summary.plays === 0 &&
No viewing history in this period yet. Try a longer period, or come back after your next watch.
} +
+ +

A little watch history

+
{summary.current_streak} days
Current streak

Consecutive viewing days through today or yesterday.

+
{summary.longest_streak} days
Longest run

Your best streak in this period.

+
{summary.active_days} days
Time for a story

Days with at least a minute watched.

+
+
+
+

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.

}
} +

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 new file mode 100644 index 0000000..689dfbb --- /dev/null +++ b/frontend/app/insights/stats.css @@ -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; } +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index f298c06..46622b7 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -71,7 +71,7 @@ export default function LoginPage() { if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return } setToken('cookie') 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 { setError('Could not reach Magent. Check your connection and try again.') } finally { setLoading(false) } diff --git a/frontend/app/ui/HeaderActions.tsx b/frontend/app/ui/HeaderActions.tsx index 622057a..3372e58 100644 --- a/frontend/app/ui/HeaderActions.tsx +++ b/frontend/app/ui/HeaderActions.tsx @@ -75,6 +75,11 @@ export default function HeaderActions() { ] const commonItems = [ + { + href: '/insights', + label: 'My Stats', + match: (path: string) => path === '/insights', + }, { href: '/', label: 'My Requests', diff --git a/frontend/app/ui/WorkspaceNavigation.tsx b/frontend/app/ui/WorkspaceNavigation.tsx index 69e93fa..4e30e78 100644 --- a/frontend/app/ui/WorkspaceNavigation.tsx +++ b/frontend/app/ui/WorkspaceNavigation.tsx @@ -8,12 +8,13 @@ type NavigationItem = { href: string label: string shortLabel: string - icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings' + icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings' | 'stats' adminOnly?: boolean match: (path: string) => boolean } 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: '/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') }, @@ -25,6 +26,7 @@ const HIDDEN_ROUTES = ['/login', '/signup', '/forgot-password', '/reset-password function NavigationIcon({ name }: { name: NavigationItem['icon'] }) { const paths: Record = { + stats: <>, dashboard: <>, media: <>, issues: <>, @@ -67,7 +69,7 @@ export default function WorkspaceNavigation() { return ( <>