Add private Jellystat viewing stats to Magent beta
This commit is contained in:
@@ -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")
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
@@ -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)),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user