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
+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(
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")
)
+6
View File
@@ -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,
+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.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)
+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")
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",
+5
View File
@@ -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)
+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.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",
+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,
)
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
+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)