Add personal monthly viewing reports and CSV exports
This commit is contained in:
@@ -1,17 +1,51 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..clients.jellystat import HistoryLimitError, JellystatError
|
||||
from ..services.insights import get_insights
|
||||
from ..services.insights_artwork import get_artwork
|
||||
from ..services.monthly_reports import get_monthly_report, report_csv
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/insights", tags=["insights"])
|
||||
|
||||
|
||||
class MonthlyReportQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
month: str | None = Field(default=None, max_length=7, pattern=r"^[0-9]{4}-[0-9]{2}$")
|
||||
|
||||
|
||||
async def monthly_data(user: dict, month: str | None) -> dict:
|
||||
try:
|
||||
return await get_monthly_report(user, month)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, "Choose the current month or one of the previous 23 months.") from exc
|
||||
except HistoryLimitError as exc:
|
||||
raise HTTPException(422, "This report exceeds Jellystat's history limit. No partial report has been generated.") from exc
|
||||
except JellystatError as exc:
|
||||
raise HTTPException(502, "Your monthly report is temporarily unavailable. Please try again shortly.") from exc
|
||||
|
||||
|
||||
@router.get("/reports/monthly")
|
||||
async def monthly_report(query: Annotated[MonthlyReportQuery, Query()], response: Response,
|
||||
user: dict = Depends(get_current_user)) -> dict:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return await monthly_data(user, query.month)
|
||||
|
||||
|
||||
@router.get("/reports/monthly.csv")
|
||||
async def monthly_export(query: Annotated[MonthlyReportQuery, Query()], user: dict = Depends(get_current_user)):
|
||||
report = await monthly_data(user, query.month)
|
||||
if report["state"] != "ready":
|
||||
raise HTTPException(409, "Connect Jellystat and link your viewing account before downloading a report.")
|
||||
return Response(report_csv(report), media_type="text/csv; charset=utf-8", headers={
|
||||
"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
|
||||
"Content-Disposition": f'attachment; filename="magent-monthly-report-{report["month"]}.csv"'})
|
||||
|
||||
|
||||
@router.get("/artwork/{item_id}")
|
||||
async def artwork(item_id: str, token: Annotated[str, Query(max_length=100)], user: dict = Depends(get_current_user)):
|
||||
content, media_type = await get_artwork(user, get_runtime_settings(), item_id, token)
|
||||
|
||||
@@ -99,8 +99,9 @@ async def resolve_identity(user: dict, runtime) -> str | None:
|
||||
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(?)"
|
||||
def request_summary(user: dict, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
|
||||
operator = "<" if end_exclusive else "<="
|
||||
clause = f"julianday(created_at) >= julianday(?) AND julianday(created_at) {operator} julianday(?)"
|
||||
params = [start.isoformat(), end.isoformat()]
|
||||
if user.get("jellyseerr_user_id") is not None:
|
||||
clause += " AND requested_by_id = ?"
|
||||
@@ -121,7 +122,7 @@ def request_summary(user: dict, start: datetime, end: datetime) -> dict:
|
||||
return {**dict(counts), "recent": [dict(row) for row in recent]}
|
||||
|
||||
|
||||
def summarize(history: list, libraries: list, start: datetime, end: datetime) -> dict:
|
||||
def summarize(history: list, libraries: list, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
|
||||
library_types = {str(row.get("Id")): str(row.get("CollectionType") or "").lower() for row in libraries}
|
||||
daily_seconds = defaultdict(float)
|
||||
clients = defaultdict(float)
|
||||
@@ -142,7 +143,7 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime) ->
|
||||
seen.add(row_id)
|
||||
date = _date(row.get("ActivityDateInserted"))
|
||||
# Defend against older upstream versions ignoring the range filter.
|
||||
if not start <= date <= end:
|
||||
if date < start or (date >= end if end_exclusive else date > end):
|
||||
continue
|
||||
duration = _duration(row.get("PlaybackDuration"))
|
||||
if duration <= 0:
|
||||
@@ -172,7 +173,8 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime) ->
|
||||
"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, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))})
|
||||
count = (end.date() - start.date()).days + 1
|
||||
last_date = (end - timedelta(microseconds=1)).date() if end_exclusive and end > start else end.date()
|
||||
count = (last_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}
|
||||
@@ -181,7 +183,7 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime) ->
|
||||
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)
|
||||
cursor = last_date if last_date.isoformat() in active_days else last_date - timedelta(days=1)
|
||||
while cursor.isoformat() in active_days:
|
||||
current += 1
|
||||
cursor -= timedelta(days=1)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Personal calendar-month reports built from retained Jellystat history."""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..clients.jellystat import JellystatClient
|
||||
from ..runtime import get_runtime_settings
|
||||
from .insights import request_summary, resolve_identity, summarize
|
||||
from .insights_artwork import with_artwork
|
||||
|
||||
_cache: dict[tuple, tuple[float, dict]] = {}
|
||||
CACHE_SECONDS = 60
|
||||
MONTH_COUNT = 24
|
||||
|
||||
|
||||
def shift_month(value: datetime, offset: int) -> datetime:
|
||||
year, month = divmod(value.year * 12 + value.month - 1 + offset, 12)
|
||||
return datetime(year, month + 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def month_periods(month: str | None, now: datetime) -> dict:
|
||||
now = now.astimezone(timezone.utc)
|
||||
this_month = shift_month(now, 0)
|
||||
available = [shift_month(this_month, -offset).strftime("%Y-%m") for offset in range(MONTH_COUNT)]
|
||||
selected = month if month is not None else available[1]
|
||||
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}", selected) or selected not in available:
|
||||
raise ValueError("Choose the current month or one of the previous 23 months.")
|
||||
start = datetime.strptime(selected, "%Y-%m").replace(tzinfo=timezone.utc)
|
||||
calendar_end = shift_month(start, 1)
|
||||
end = min(calendar_end, now)
|
||||
previous_start = shift_month(start, -1)
|
||||
partial = end < calendar_end
|
||||
previous_end = min(previous_start + (end - start), start) if partial else start
|
||||
return {"month": selected, "available_months": available, "timezone": "UTC",
|
||||
"period_start": start.isoformat(), "period_end": end.isoformat(),
|
||||
"is_partial": partial, "comparison_month": previous_start.strftime("%Y-%m"),
|
||||
"comparison_start": previous_start.isoformat(), "comparison_end": previous_end.isoformat(),
|
||||
"comparison_capped": partial and previous_start + (end - start) > start}
|
||||
|
||||
|
||||
def change(current: float, previous: float) -> dict:
|
||||
difference = round(current - previous, 1)
|
||||
percent = round(difference / previous * 100, 1) if previous else 0.0 if not current else None
|
||||
return {"current": current, "previous": previous, "difference": difference, "percent": percent}
|
||||
|
||||
|
||||
async def get_monthly_report(user: dict, month: str | None = None) -> dict:
|
||||
now = datetime.now(timezone.utc)
|
||||
periods = month_periods(month, now)
|
||||
runtime = await asyncio.to_thread(get_runtime_settings)
|
||||
base = {**periods, "source": "Jellystat", "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"}
|
||||
# Cache playback only. Request ownership and request statuses are read afresh.
|
||||
key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
|
||||
runtime.jellyfin_base_url, identity, periods["month"], now.strftime("%Y-%m"))
|
||||
cached = _cache.get(key)
|
||||
if cached and cached[0] > time.monotonic():
|
||||
data = cached[1]
|
||||
else:
|
||||
history, libraries = await client.get_user_history(identity,
|
||||
datetime.fromisoformat(periods["comparison_start"]), datetime.fromisoformat(periods["period_end"]))
|
||||
current = summarize(history, libraries, datetime.fromisoformat(periods["period_start"]),
|
||||
datetime.fromisoformat(periods["period_end"]), end_exclusive=True)
|
||||
previous = summarize(history, libraries, datetime.fromisoformat(periods["comparison_start"]),
|
||||
datetime.fromisoformat(periods["comparison_end"]), end_exclusive=True)
|
||||
data = {**periods, **current, "previous_summary": previous["summary"], "updated_at": now.isoformat()}
|
||||
for expired in [entry for entry, 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)
|
||||
requests, previous_requests = await asyncio.gather(
|
||||
asyncio.to_thread(request_summary, user, datetime.fromisoformat(data["period_start"]),
|
||||
datetime.fromisoformat(data["period_end"]), end_exclusive=True),
|
||||
asyncio.to_thread(request_summary, user, datetime.fromisoformat(data["comparison_start"]),
|
||||
datetime.fromisoformat(data["comparison_end"]), end_exclusive=True))
|
||||
changes = {name: change(data["summary"][name], data["previous_summary"][name])
|
||||
for name in ("minutes", "movies", "episodes", "plays", "active_days", "longest_streak")}
|
||||
changes["requests"] = change(requests["total"], previous_requests["total"])
|
||||
return {**base, **with_artwork(data, user, runtime), "state": "ready", "requests": requests,
|
||||
"previous_requests": {name: value for name, value in previous_requests.items() if name != "recent"},
|
||||
"changes": changes}
|
||||
|
||||
|
||||
def report_csv(report: dict) -> str:
|
||||
"""Export normalized data only; protect text cells from spreadsheet formulas."""
|
||||
output = io.StringIO(newline="")
|
||||
writer = csv.writer(output)
|
||||
|
||||
def row(*cells):
|
||||
safe = []
|
||||
for cell in cells:
|
||||
if isinstance(cell, str) and re.match(r"^[\s\ufeff]*[=+\-@]", cell):
|
||||
cell = "'" + cell
|
||||
safe.append(cell)
|
||||
writer.writerow(safe)
|
||||
|
||||
row("Magent monthly viewing report", report["month"])
|
||||
row("Timezone", "UTC")
|
||||
row("Period start (inclusive)", report["period_start"])
|
||||
row("Period end (exclusive)", report["period_end"])
|
||||
row("Report period", "Month to date" if report["is_partial"] else "Complete calendar month")
|
||||
row("Comparison start (inclusive)", report["comparison_start"])
|
||||
row("Comparison end (exclusive)", report["comparison_end"])
|
||||
row("Generated at", report["updated_at"])
|
||||
row("Data coverage", "Retained Jellystat history and requests available in Magent; request statuses are current.")
|
||||
row()
|
||||
row("Metric", "This period", "Previous period", "Difference", "Change (%)")
|
||||
labels = {"minutes": "Minutes watched", "movies": "Distinct movies played", "episodes": "Distinct episodes played",
|
||||
"plays": "Plays", "active_days": "Active days", "longest_streak": "Longest streak (days)", "requests": "Requests made"}
|
||||
for name, label in labels.items():
|
||||
value = report["changes"][name]
|
||||
row(label, value["current"], value["previous"], value["difference"], value["percent"])
|
||||
row()
|
||||
row("Date (UTC)", "Minutes watched")
|
||||
for day in report["daily"]:
|
||||
row(day["date"], day["minutes"])
|
||||
row()
|
||||
row("Most watched title", "Media type", "Minutes", "Plays")
|
||||
for title in report["top_titles"]:
|
||||
row(title["title"], title["type"], title["minutes"], title["plays"])
|
||||
for field, label in (("clients", "Player"), ("methods", "Streaming method")):
|
||||
row()
|
||||
row(label, "Playback minutes")
|
||||
for entry in report[field]:
|
||||
row(entry["name"], entry["minutes"])
|
||||
row()
|
||||
row("Transcoding", "Playback minutes")
|
||||
for field, label in (("hardware_video_minutes", "GPU-assisted video"), ("audio_minutes", "Audio transcoding"),
|
||||
("video_minutes", "Video transcoding"), ("software_video_minutes", "Software video"),
|
||||
("unknown_hardware_minutes", "Video hardware not recorded"),
|
||||
("unknown_video_minutes", "Video details not recorded"), ("unknown_audio_minutes", "Audio details not recorded")):
|
||||
row(label, report["transcoding"][field])
|
||||
row("GPU busy time", "Not recorded; audio/video playback durations can overlap.")
|
||||
row()
|
||||
row("Requests", "Count")
|
||||
for field, label in (("movies", "Movies"), ("tv", "TV shows"), ("pending", "Pending"), ("approved", "Approved"), ("declined", "Declined")):
|
||||
row(label, report["requests"][field])
|
||||
return "\ufeff" + output.getvalue()
|
||||
@@ -0,0 +1,209 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
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 insights as router
|
||||
from backend.app.services import insights, monthly_reports as reports
|
||||
from backend.app.services.jellyfin_identity import link_user
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
from backend.tests.test_insights import LIBRARIES, NOW, USER, play
|
||||
|
||||
|
||||
class MonthlyPeriodTests(unittest.TestCase):
|
||||
def test_default_is_last_complete_calendar_month(self):
|
||||
period = reports.month_periods(None, NOW)
|
||||
self.assertEqual(period['month'], '2026-08')
|
||||
self.assertEqual(period['period_start'], '2026-08-01T00:00:00+00:00')
|
||||
self.assertEqual(period['period_end'], '2026-09-01T00:00:00+00:00')
|
||||
self.assertEqual(period['comparison_start'], '2026-07-01T00:00:00+00:00')
|
||||
self.assertEqual(period['comparison_end'], period['period_start'])
|
||||
self.assertFalse(period['is_partial'])
|
||||
self.assertEqual(len(period['available_months']), 24)
|
||||
|
||||
def test_leap_year_and_year_rollover(self):
|
||||
period = reports.month_periods('2024-02', datetime(2024, 3, 2, tzinfo=timezone.utc))
|
||||
data = insights.summarize([], LIBRARIES, datetime.fromisoformat(period['period_start']),
|
||||
datetime.fromisoformat(period['period_end']), end_exclusive=True)
|
||||
self.assertEqual(len(data['daily']), 29)
|
||||
self.assertEqual(data['daily'][-1]['date'], '2024-02-29')
|
||||
period = reports.month_periods(None, datetime(2026, 1, 1, tzinfo=timezone.utc))
|
||||
self.assertEqual(period['month'], '2025-12')
|
||||
self.assertEqual(period['comparison_month'], '2025-11')
|
||||
|
||||
def test_partial_month_matches_elapsed_time_and_caps_short_month(self):
|
||||
period = reports.month_periods('2026-09', NOW)
|
||||
self.assertTrue(period['is_partial'])
|
||||
self.assertEqual(period['comparison_end'], '2026-08-07T12:00:00+00:00')
|
||||
self.assertFalse(period['comparison_capped'])
|
||||
period = reports.month_periods('2026-03', datetime(2026, 3, 31, 12, tzinfo=timezone.utc))
|
||||
self.assertEqual(period['comparison_end'], '2026-03-01T00:00:00+00:00')
|
||||
self.assertTrue(period['comparison_capped'])
|
||||
|
||||
def test_utc_month_is_used_near_local_month_boundary(self):
|
||||
local = datetime(2026, 9, 1, 0, 30, tzinfo=timezone(timedelta(hours=12)))
|
||||
self.assertEqual(reports.month_periods(None, local)['month'], '2026-07')
|
||||
|
||||
def test_invalid_future_and_out_of_range_months_are_rejected(self):
|
||||
for month in ['', '2026-9', '2026-00', '2026-13', '2026-10', '2024-09', '2026-08\r\nheader', '../../file']:
|
||||
with self.subTest(month=month), self.assertRaises(ValueError):
|
||||
reports.month_periods(month, NOW)
|
||||
|
||||
def test_changes_handle_zero_baselines_and_decreases(self):
|
||||
self.assertEqual(reports.change(0, 0), {'current': 0, 'previous': 0, 'difference': 0, 'percent': 0})
|
||||
self.assertIsNone(reports.change(5, 0)['percent'])
|
||||
self.assertEqual(reports.change(30, 60)['percent'], -50)
|
||||
|
||||
def test_adjacent_months_never_double_count_boundary_play(self):
|
||||
start = datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
||||
row = play(ActivityDateInserted=start.isoformat())
|
||||
previous = insights.summarize([row], LIBRARIES, reports.shift_month(start, -1), start, end_exclusive=True)
|
||||
current = insights.summarize([row, row, play('next-month', ActivityDateInserted=end.isoformat())], LIBRARIES, start, end, end_exclusive=True)
|
||||
self.assertEqual(previous['summary']['plays'], 0)
|
||||
self.assertEqual(current['summary']['plays'], 1)
|
||||
self.assertEqual(current['daily'][-1]['date'], '2026-08-31')
|
||||
|
||||
|
||||
class MonthlyReportTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
reports._cache.clear()
|
||||
db.create_user('viewer', 'Test-Password123!', auth_provider='jellyfin')
|
||||
link_user('viewer', 'jf-viewer', 'http://jellyfin')
|
||||
self.runtime = SimpleNamespace(jellyfin_base_url='http://jellyfin', jellyfin_api_key='PRIVATE-JF-KEY',
|
||||
jellystat_base_url='http://jellystat', jellystat_api_key='PRIVATE-STATS-KEY')
|
||||
self.runtime_patch = patch.object(reports, 'get_runtime_settings', return_value=self.runtime)
|
||||
self.runtime_patch.start()
|
||||
self.addCleanup(self.runtime_patch.stop)
|
||||
self.clock = patch.object(reports, 'datetime', wraps=datetime)
|
||||
self.clock.start().now.return_value = NOW
|
||||
self.addCleanup(self.clock.stop)
|
||||
|
||||
def add_request(self, request_id, date, owner=42, status=2):
|
||||
db.upsert_request_cache(request_id, request_id, 'movie', status, 'Request', 2026,
|
||||
'viewer', 'viewer', owner, date, date, '{}')
|
||||
|
||||
async def test_report_uses_linked_identity_and_separates_periods_and_request_owners(self):
|
||||
rows = [play('previous', PlaybackDuration=1800, ActivityDateInserted='2026-07-31T23:59:59Z'),
|
||||
play('current', ActivityDateInserted='2026-08-01T00:00:00Z'),
|
||||
play('future', ActivityDateInserted='2026-09-01T00:00:00Z')]
|
||||
for request_id, date, owner in [(1, '2026-07-31T23:59:59Z', 42), (2, '2026-08-01T00:00:00Z', 42),
|
||||
(3, '2026-08-15T00:00:00Z', 99), (4, '2026-09-01T00:00:00Z', 42)]:
|
||||
self.add_request(request_id, date, owner)
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=(rows, LIBRARIES)) as remote, \
|
||||
patch.object(insights.JellyfinClient, 'get_users', new_callable=AsyncMock) as directory:
|
||||
result = await reports.get_monthly_report(USER, '2026-08')
|
||||
self.assertEqual(result['summary']['minutes'], 60)
|
||||
self.assertEqual(result['previous_summary']['minutes'], 30)
|
||||
self.assertEqual(result['changes']['minutes']['percent'], 100)
|
||||
self.assertEqual(result['requests']['total'], 1)
|
||||
self.assertEqual(result['previous_requests']['total'], 1)
|
||||
self.assertEqual(result['requests']['recent'][0]['request_id'], 2)
|
||||
self.assertEqual(remote.await_args.args[0], 'jf-viewer')
|
||||
directory.assert_not_called()
|
||||
self.assertNotIn('PRIVATE', json.dumps(result))
|
||||
self.assertNotIn('artwork_item_id', json.dumps(result))
|
||||
self.assertNotIn('jf-viewer', json.dumps(result))
|
||||
|
||||
async def test_partial_comparison_ignores_later_days_in_previous_month(self):
|
||||
rows = [play('previous', ActivityDateInserted='2026-08-07T11:59:59Z'),
|
||||
play('cutoff', ActivityDateInserted='2026-08-07T12:00:00Z'),
|
||||
play('later', ActivityDateInserted='2026-08-30T12:00:00Z'),
|
||||
play('current', ActivityDateInserted='2026-09-01T12:00:00Z')]
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=(rows, LIBRARIES)):
|
||||
result = await reports.get_monthly_report(USER, '2026-09')
|
||||
self.assertEqual(result['previous_summary']['minutes'], 60)
|
||||
self.assertEqual(result['summary']['minutes'], 60)
|
||||
|
||||
async def test_cache_isolated_by_identity_month_and_connections_but_requests_refresh(self):
|
||||
db.create_user('second', 'Test-Password123!', auth_provider='jellyfin')
|
||||
link_user('second', 'jf-second', 'http://jellyfin')
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=([], LIBRARIES)) as remote:
|
||||
await reports.get_monthly_report(USER, '2026-08')
|
||||
self.add_request(1, '2026-08-15T12:00:00Z')
|
||||
result = await reports.get_monthly_report(USER, '2026-08')
|
||||
self.assertEqual(result['requests']['total'], 1)
|
||||
self.assertEqual(remote.await_count, 1)
|
||||
await reports.get_monthly_report({**USER, 'username': 'second'}, '2026-08')
|
||||
await reports.get_monthly_report(USER, '2026-07')
|
||||
self.runtime.jellystat_api_key = 'rotated-key'
|
||||
await reports.get_monthly_report(USER, '2026-08')
|
||||
self.runtime.jellystat_base_url = 'http://other-jellystat'
|
||||
await reports.get_monthly_report(USER, '2026-08')
|
||||
self.assertEqual(remote.await_count, 5)
|
||||
|
||||
async def test_unlinked_and_unconfigured_never_fetch_history(self):
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock) as remote:
|
||||
result = await reports.get_monthly_report({**USER, 'username': 'unlinked', 'auth_provider': 'local'})
|
||||
self.assertEqual(result['state'], 'unlinked')
|
||||
self.runtime.jellystat_api_key = None
|
||||
result = await reports.get_monthly_report(USER)
|
||||
self.assertEqual(result['state'], 'not_configured')
|
||||
remote.assert_not_called()
|
||||
|
||||
async def test_failed_history_is_not_cached_or_returned_as_a_partial_report(self):
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, side_effect=HistoryLimitError()):
|
||||
with self.assertRaises(HistoryLimitError):
|
||||
await reports.get_monthly_report(USER)
|
||||
self.assertEqual(reports._cache, {})
|
||||
|
||||
async def test_csv_preserves_unicode_and_quotes_but_blocks_formulas_and_private_fields(self):
|
||||
rows = [play('csv', ActivityDateInserted='2026-08-10T12:00:00Z', NowPlayingItemName='=HYPERLINK("x")', Client='\t\ufeff@SUM(1,2)'),
|
||||
play('unicode', NowPlayingItemId='second-film', ActivityDateInserted='2026-08-11T12:00:00Z', NowPlayingItemName='Amélie, "Paris"')]
|
||||
with patch.object(JellystatClient, 'get_user_history', new_callable=AsyncMock, return_value=(rows, LIBRARIES)):
|
||||
report = await reports.get_monthly_report(USER)
|
||||
exported = reports.report_csv(report)
|
||||
cells = [cell for row in csv.reader(io.StringIO(exported.lstrip('\ufeff'))) for cell in row]
|
||||
self.assertIn('\'=HYPERLINK("x")', cells)
|
||||
self.assertIn("'\t\ufeff@SUM(1,2)", cells)
|
||||
self.assertIn('Amélie, "Paris"', cells)
|
||||
for private in ['PRIVATE', 'jf-viewer', 'artwork/', 'token=', 'http://jellystat']:
|
||||
self.assertNotIn(private, exported)
|
||||
|
||||
|
||||
class MonthlyReportRouteTests(unittest.TestCase):
|
||||
def client(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_both_formats_require_auth_and_reject_scope_overrides(self):
|
||||
for path in ['/insights/reports/monthly', '/insights/reports/monthly.csv']:
|
||||
self.assertEqual(self.client(False).get(path).status_code, 401)
|
||||
for query in ['userid=other', 'scope=server', 'user_id=1', 'month=2026-9', 'month=2026-08%0D%0Ax']:
|
||||
self.assertEqual(self.client().get(path+'?'+query).status_code, 422)
|
||||
|
||||
def test_report_no_store_and_export_attachment_headers(self):
|
||||
with patch.object(router, 'get_monthly_report', new_callable=AsyncMock, return_value={'state':'ready', 'month':'2026-08'}) as report, \
|
||||
patch.object(router, 'report_csv', return_value='\ufeffMetric,Value\r\nMinutes,60\r\n'):
|
||||
response = self.client().get('/insights/reports/monthly?month=2026-08')
|
||||
self.assertEqual(response.headers['cache-control'], 'no-store')
|
||||
report.assert_awaited_with(USER, '2026-08')
|
||||
response = self.client().get('/insights/reports/monthly.csv?month=2026-08')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers['content-type'], 'text/csv; charset=utf-8')
|
||||
self.assertEqual(response.headers['cache-control'], 'no-store')
|
||||
self.assertEqual(response.headers['x-content-type-options'], 'nosniff')
|
||||
self.assertEqual(response.headers['content-disposition'], 'attachment; filename="magent-monthly-report-2026-08.csv"')
|
||||
self.assertTrue(response.content.startswith(b'\xef\xbb\xbf'))
|
||||
|
||||
def test_errors_are_sanitized_and_unlinked_export_is_blocked(self):
|
||||
for exception, status in [(JellystatError('PRIVATE'), 502), (HistoryLimitError('PRIVATE'), 422), (ValueError('PRIVATE'), 422)]:
|
||||
with patch.object(router, 'get_monthly_report', new_callable=AsyncMock, side_effect=exception):
|
||||
for suffix in ['', '.csv']:
|
||||
response = self.client().get('/insights/reports/monthly'+suffix)
|
||||
self.assertEqual(response.status_code, status)
|
||||
self.assertNotIn('PRIVATE', response.text)
|
||||
with patch.object(router, 'get_monthly_report', new_callable=AsyncMock, return_value={'state':'unlinked'}):
|
||||
self.assertEqual(self.client().get('/insights/reports/monthly.csv').status_code, 409)
|
||||
@@ -25,7 +25,7 @@ JELLYSTAT_API_KEY=your-jellystat-api-key
|
||||
- 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.
|
||||
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. Monthly reports are available from My Stats; admin reporting and newsletters can build on this integration later.
|
||||
|
||||
## Data semantics and boundaries
|
||||
|
||||
@@ -46,6 +46,19 @@ Backend coverage is in `backend/tests/test_insights.py` and `backend/tests/test_
|
||||
|
||||
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.
|
||||
|
||||
## Monthly reports
|
||||
|
||||
Open **My Stats → Monthly reports** (`/insights/reports`). The default is the most recent complete calendar month. The month picker covers the current month and the previous 23 months. Reports include viewing and request totals, changes against the preceding month, daily viewing, active days, longest streak, favourite titles, players, transcoding and the latest 20 plays in that month. The chart and streaming cards are shared with the Stats overview.
|
||||
|
||||
- Completed months compare full UTC calendar months, even when their lengths differ. The current month compares the same elapsed time in the previous month, capped at that month's end when it is shorter. The page identifies reports that are still in progress.
|
||||
- Periods include their start and exclude their end. Midnight activity belongs to exactly one month; leap years and December/January boundaries use calendar arithmetic. Missing prior activity has no percentage increase, rather than an infinite or invented percentage.
|
||||
- Reports use the same stored Jellyfin identity resolution as My Stats, including administrator-confirmed links. They accept only a month, never a browser-supplied user ID or server scope. Requests use the authenticated account's Seerr ID under the existing ownership rules.
|
||||
- `GET /insights/reports/monthly` returns the report; `GET /insights/reports/monthly.csv` downloads its summary, comparisons, daily totals, leading titles, players, streaming methods, transcoding and request counts. Both require authentication and return `Cache-Control: no-store`. CSV text cells are escaped and formula-like values are prefixed to prevent spreadsheet execution. Exports omit account IDs, artwork tokens and upstream credentials.
|
||||
- Reports are generated on demand from retained Jellystat history and Magent's available request cache. They are not immutable historical snapshots. Request statuses are current, and historical totals can change with retention or library metadata. No report database, email delivery or scheduler is added.
|
||||
- One bounded history read covers the selected and comparison months. Playback summaries are cached for 60 seconds in at most 128 entries, separated by identity, connection and month. Request totals are refreshed independently. Upstream errors or history limits fail the report without presenting a partial result.
|
||||
|
||||
`backend/tests/test_monthly_reports.py` covers calendar boundaries, matched partial periods, ownership, cache isolation, comparisons and safe CSV exports. `scripts/review_monthly_reports_ui.cjs` checks the report controls and layouts with fixtures only.
|
||||
|
||||
## Artwork and transcoding
|
||||
|
||||
Recently watched uses Jellystat's `NowPlayingItemId`, which identifies the movie or series, to load a Jellyfin primary poster. Magent proxies the image through an authenticated endpoint using a short-lived signature bound to the viewer, item and Jellyfin connection. Jellyfin credentials stay on the backend. Missing or deleted artwork falls back to a media tile. Thumbnail responses are privately cached.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { getApiBase } from '../lib/auth'
|
||||
|
||||
export type Breakdown = { name: string; minutes: number }
|
||||
export type Day = { date: string; minutes: number }
|
||||
export type Transcoding = {
|
||||
video_minutes: number; audio_minutes: number; hardware_video_minutes: number; software_video_minutes: number
|
||||
unknown_hardware_minutes: number; unknown_video_minutes: number; unknown_audio_minutes: number
|
||||
hardware: Breakdown[]; audio_codecs: Breakdown[]; gpu_busy_minutes: null
|
||||
}
|
||||
export 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; artwork_url?: string | null }[]
|
||||
clients?: Breakdown[]
|
||||
methods?: Breakdown[]
|
||||
transcoding?: Transcoding
|
||||
requests: { total: number; movies: number; tv: number; pending: number; approved: number; declined: number; recent: { request_id: number; title: string; media_type: string; status: number }[] }
|
||||
}
|
||||
|
||||
export const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 0 })
|
||||
export const dateLabel = (date: string) => new Date(date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
|
||||
|
||||
export function ViewingChart({ daily }: { daily: Day[] }) {
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
const bucket = daily.length > 100 ? 7 : daily.length > 35 ? 3 : 1
|
||||
const bars: { start: string; end: string; minutes: number }[] = []
|
||||
for (let i = 0; i < daily.length; i += bucket) {
|
||||
const group = daily.slice(i, i + bucket)
|
||||
bars.push({ start: group[0].date, end: group[group.length - 1].date, minutes: group.reduce((sum, day) => sum + day.minutes, 0) })
|
||||
}
|
||||
const peak = Math.max(1, ...bars.map((bar) => bar.minutes))
|
||||
const active = selected === null ? null : bars[selected]
|
||||
return (
|
||||
<section className="stats-panel stats-viewing" aria-labelledby="viewing-title">
|
||||
<div className="stats-panel-heading"><div><h2 id="viewing-title">Your viewing rhythm</h2><p>{bucket === 1 ? 'Daily' : `${bucket}-day`} watch time · UTC</p></div><span className="stats-unit">Minutes</span></div>
|
||||
<div className="stats-chart-detail" aria-live="polite">{active ? `${dateLabel(active.start)}${active.end !== active.start ? ` – ${dateLabel(active.end)}` : ''} · ${number(active.minutes)} minutes` : 'Select a bar to explore your watch time.'}</div>
|
||||
<div className="stats-chart">
|
||||
<div className="stats-chart-scale" aria-hidden="true"><span>{number(peak)}</span><span>{number(peak / 2)}</span><span>0</span></div>
|
||||
<div className="stats-chart-bars">
|
||||
{bars.map((bar, index) => <button type="button" className={selected === index ? 'is-selected' : ''} key={bar.start} aria-label={`${dateLabel(bar.start)}${bar.end !== bar.start ? ` to ${dateLabel(bar.end)}` : ''}: ${number(bar.minutes)} minutes`} aria-pressed={selected === index} onClick={() => setSelected(index)} onFocus={() => setSelected(index)}><span style={{ height: `${bar.minutes > 0 ? Math.max(2, bar.minutes / peak * 100) : 1}%` }} /></button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats-chart-axis" aria-hidden="true"><span>{bars[0] && dateLabel(bars[0].start)}</span><span>{bars.length > 0 && dateLabel(bars[bars.length - 1].end)}</span></div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) {
|
||||
const total = rows.reduce((sum, row) => sum + row.minutes, 0)
|
||||
return <section className="stats-panel"><div className="stats-panel-heading"><h2>{title}</h2></div>{rows.length ? <div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{number(row.minutes)} min</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div> : <p className="stats-muted">Your next watch will start the story here.</p>}</section>
|
||||
}
|
||||
|
||||
const minutes = (value: number) => `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} min`
|
||||
|
||||
export function StreamingCard({ rows, transcoding }: { rows: Breakdown[]; transcoding?: Transcoding }) {
|
||||
const total = rows.reduce((sum, row) => sum + row.minutes, 0)
|
||||
return <section className="stats-panel stats-streaming">
|
||||
<div className="stats-panel-heading"><h2>How you streamed</h2></div>
|
||||
<div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{minutes(row.minutes)}</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div>
|
||||
{transcoding && <div className="stats-transcoding">
|
||||
<h3>Transcoding playback time</h3>
|
||||
<div className="stats-transcode-metrics">
|
||||
<div><span>GPU-assisted video</span><strong>{transcoding.hardware_video_minutes === 0 && (transcoding.unknown_hardware_minutes > 0 || transcoding.unknown_video_minutes > 0) ? 'Not recorded' : minutes(transcoding.hardware_video_minutes)}</strong><small>{transcoding.hardware.map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Hardware-accelerated video'}</small></div>
|
||||
<div><span>Audio transcoding</span><strong>{transcoding.audio_minutes === 0 && transcoding.unknown_audio_minutes > 0 ? 'Not recorded' : minutes(transcoding.audio_minutes)}</strong><small>{transcoding.audio_codecs.slice(0, 3).map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Audio converted for your player'}</small></div>
|
||||
</div>
|
||||
<dl className="stats-transcode-details"><div><dt>Total video transcoding</dt><dd>{transcoding.video_minutes === 0 && transcoding.unknown_video_minutes > 0 ? 'Not recorded' : minutes(transcoding.video_minutes)}</dd></div>{transcoding.software_video_minutes > 0 && <div><dt>Software video</dt><dd>{minutes(transcoding.software_video_minutes)}</dd></div>}{transcoding.unknown_hardware_minutes > 0 && <div><dt>Video hardware not recorded</dt><dd>{minutes(transcoding.unknown_hardware_minutes)}</dd></div>}</dl>
|
||||
{(transcoding.unknown_audio_minutes > 0 || transcoding.unknown_video_minutes > 0) && <p className="stats-muted">Some stream details are missing: video {minutes(transcoding.unknown_video_minutes)}, audio {minutes(transcoding.unknown_audio_minutes)}.</p>}
|
||||
<p className="stats-muted">Playback minutes, with audio and video counted separately. They can overlap. GPU busy time is not recorded by Jellystat.</p>
|
||||
</div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
export function RecentArtwork({ url, type }: { url?: string | null; type: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
return <div className={`stats-media-icon stats-media-icon-${type}`} aria-hidden="true">
|
||||
{url && !failed ? <img src={`${getApiBase()}${url}`} alt="" width={44} height={66} loading="lazy" onError={() => setFailed(true)} /> : <span>{type === 'episode' ? 'TV' : type === 'movie' ? 'MV' : '▶'}</span>}
|
||||
</div>
|
||||
}
|
||||
|
||||
export function StatsNavigation({ reports = false }: { reports?: boolean }) {
|
||||
return <nav className="stats-view-tabs" aria-label="My Stats views">
|
||||
<a href="/insights" aria-current={!reports ? 'page' : undefined}>Overview</a>
|
||||
<a href="/insights/reports" aria-current={reports ? 'page' : undefined}>Monthly reports</a>
|
||||
</nav>
|
||||
}
|
||||
@@ -4,90 +4,9 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, getApiBase } from '../lib/auth'
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
import { type Stats, BreakdownCard, RecentArtwork, StatsNavigation, StreamingCard, ViewingChart, dateLabel, number } from './components'
|
||||
import './stats.css'
|
||||
|
||||
type Breakdown = { name: string; minutes: number }
|
||||
type Day = { date: string; minutes: number }
|
||||
type Transcoding = {
|
||||
video_minutes: number; audio_minutes: number; hardware_video_minutes: number; software_video_minutes: number
|
||||
unknown_hardware_minutes: number; unknown_video_minutes: number; unknown_audio_minutes: number
|
||||
hardware: Breakdown[]; audio_codecs: Breakdown[]; gpu_busy_minutes: null
|
||||
}
|
||||
type Stats = {
|
||||
state: 'ready' | 'not_configured' | 'unlinked'
|
||||
is_admin: boolean
|
||||
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; artwork_url?: string | null }[]
|
||||
clients?: Breakdown[]
|
||||
methods?: Breakdown[]
|
||||
transcoding?: Transcoding
|
||||
requests: { total: number; movies: number; tv: number; pending: number; approved: number; declined: number; recent: { request_id: number; title: string; media_type: string; status: number }[] }
|
||||
}
|
||||
|
||||
const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 0 })
|
||||
const dateLabel = (date: string) => new Date(date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
|
||||
|
||||
function ViewingChart({ daily }: { daily: Day[] }) {
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
const bucket = daily.length > 100 ? 7 : daily.length > 35 ? 3 : 1
|
||||
const bars: { start: string; end: string; minutes: number }[] = []
|
||||
for (let i = 0; i < daily.length; i += bucket) {
|
||||
const group = daily.slice(i, i + bucket)
|
||||
bars.push({ start: group[0].date, end: group[group.length - 1].date, minutes: group.reduce((sum, day) => sum + day.minutes, 0) })
|
||||
}
|
||||
const peak = Math.max(1, ...bars.map((bar) => bar.minutes))
|
||||
const active = selected === null ? null : bars[selected]
|
||||
return (
|
||||
<section className="stats-panel stats-viewing" aria-labelledby="viewing-title">
|
||||
<div className="stats-panel-heading"><div><h2 id="viewing-title">Your viewing rhythm</h2><p>{bucket === 1 ? 'Daily' : `${bucket}-day`} watch time · UTC</p></div><span className="stats-unit">Minutes</span></div>
|
||||
<div className="stats-chart-detail" aria-live="polite">{active ? `${dateLabel(active.start)}${active.end !== active.start ? ` – ${dateLabel(active.end)}` : ''} · ${number(active.minutes)} minutes` : 'Select a bar to explore your watch time.'}</div>
|
||||
<div className="stats-chart">
|
||||
<div className="stats-chart-scale" aria-hidden="true"><span>{number(peak)}</span><span>{number(peak / 2)}</span><span>0</span></div>
|
||||
<div className="stats-chart-bars">
|
||||
{bars.map((bar, index) => <button type="button" className={selected === index ? 'is-selected' : ''} key={bar.start} aria-label={`${dateLabel(bar.start)}${bar.end !== bar.start ? ` to ${dateLabel(bar.end)}` : ''}: ${number(bar.minutes)} minutes`} aria-pressed={selected === index} onClick={() => setSelected(index)} onFocus={() => setSelected(index)}><span style={{ height: `${bar.minutes > 0 ? Math.max(2, bar.minutes / peak * 100) : 1}%` }} /></button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats-chart-axis" aria-hidden="true"><span>{bars[0] && dateLabel(bars[0].start)}</span><span>{bars.length > 0 && dateLabel(bars[bars.length - 1].end)}</span></div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) {
|
||||
const total = rows.reduce((sum, row) => sum + row.minutes, 0)
|
||||
return <section className="stats-panel"><div className="stats-panel-heading"><h2>{title}</h2></div>{rows.length ? <div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{number(row.minutes)} min</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div> : <p className="stats-muted">Your next watch will start the story here.</p>}</section>
|
||||
}
|
||||
|
||||
const minutes = (value: number) => `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} min`
|
||||
|
||||
function StreamingCard({ rows, transcoding }: { rows: Breakdown[]; transcoding?: Transcoding }) {
|
||||
const total = rows.reduce((sum, row) => sum + row.minutes, 0)
|
||||
return <section className="stats-panel stats-streaming">
|
||||
<div className="stats-panel-heading"><h2>How you streamed</h2></div>
|
||||
<div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{minutes(row.minutes)}</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div>
|
||||
{transcoding && <div className="stats-transcoding">
|
||||
<h3>Transcoding playback time</h3>
|
||||
<div className="stats-transcode-metrics">
|
||||
<div><span>GPU-assisted video</span><strong>{transcoding.hardware_video_minutes === 0 && (transcoding.unknown_hardware_minutes > 0 || transcoding.unknown_video_minutes > 0) ? 'Not recorded' : minutes(transcoding.hardware_video_minutes)}</strong><small>{transcoding.hardware.map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Hardware-accelerated video'}</small></div>
|
||||
<div><span>Audio transcoding</span><strong>{transcoding.audio_minutes === 0 && transcoding.unknown_audio_minutes > 0 ? 'Not recorded' : minutes(transcoding.audio_minutes)}</strong><small>{transcoding.audio_codecs.slice(0, 3).map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Audio converted for your player'}</small></div>
|
||||
</div>
|
||||
<dl className="stats-transcode-details"><div><dt>Total video transcoding</dt><dd>{transcoding.video_minutes === 0 && transcoding.unknown_video_minutes > 0 ? 'Not recorded' : minutes(transcoding.video_minutes)}</dd></div>{transcoding.software_video_minutes > 0 && <div><dt>Software video</dt><dd>{minutes(transcoding.software_video_minutes)}</dd></div>}{transcoding.unknown_hardware_minutes > 0 && <div><dt>Video hardware not recorded</dt><dd>{minutes(transcoding.unknown_hardware_minutes)}</dd></div>}</dl>
|
||||
{(transcoding.unknown_audio_minutes > 0 || transcoding.unknown_video_minutes > 0) && <p className="stats-muted">Some stream details are missing: video {minutes(transcoding.unknown_video_minutes)}, audio {minutes(transcoding.unknown_audio_minutes)}.</p>}
|
||||
<p className="stats-muted">Playback minutes, with audio and video counted separately. They can overlap. GPU busy time is not recorded by Jellystat.</p>
|
||||
</div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function RecentArtwork({ url, type }: { url?: string | null; type: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
return <div className={`stats-media-icon stats-media-icon-${type}`} aria-hidden="true">
|
||||
{url && !failed ? <img src={`${getApiBase()}${url}`} alt="" width={44} height={66} loading="lazy" onError={() => setFailed(true)} /> : <span>{type === 'episode' ? 'TV' : type === 'movie' ? 'MV' : '▶'}</span>}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default function InsightsPage() {
|
||||
const router = useRouter()
|
||||
const [days, setDays] = useState(30)
|
||||
@@ -126,6 +45,7 @@ export default function InsightsPage() {
|
||||
return (
|
||||
<main className="stats-page">
|
||||
<PageHeading title="My Stats" description="Your viewing, in numbers. Watch time, favourite stories, and the requests that started it all." actions={<button className="ghost-button" type="button" disabled={busy} onClick={() => setRevision((value) => value + 1)}>{busy ? 'Loading…' : 'Refresh stats'}</button>} />
|
||||
<StatsNavigation />
|
||||
<div className="stats-toolbar">
|
||||
<fieldset className="stats-period"><legend className="stats-sr-only">Stats period</legend>{[7, 30, 90, 365].map((value) => <button type="button" key={value} aria-pressed={days === value} onClick={() => setDays(value)}>{value === 365 ? 'Past year' : `${value} days`}</button>)}</fieldset>
|
||||
<p className="stats-source"><span className={data?.state === 'ready' ? 'stats-source-dot is-ready' : 'stats-source-dot'} />From Jellystat{data?.updated_at && <span> · Updated {new Date(data.updated_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span>}</p>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, getApiBase } from '../../lib/auth'
|
||||
import PageHeading from '../../ui/PageHeading'
|
||||
import { type Stats, BreakdownCard, RecentArtwork, StatsNavigation, StreamingCard, ViewingChart, dateLabel, number } from '../components'
|
||||
import '../stats.css'
|
||||
import './reports.css'
|
||||
|
||||
type Change = { current: number; previous: number; difference: number; percent: number | null }
|
||||
type MonthlyReport = Omit<Stats, 'days'> & {
|
||||
month: string; available_months: string[]; is_partial: boolean; comparison_capped: boolean
|
||||
period_start: string; period_end: string; comparison_month: string; comparison_start: string; comparison_end: string
|
||||
previous_summary?: Stats['summary']; previous_requests?: Omit<Stats['requests'], 'recent'>
|
||||
changes?: Record<'minutes' | 'movies' | 'episodes' | 'plays' | 'active_days' | 'longest_streak' | 'requests', Change>
|
||||
}
|
||||
|
||||
const monthLabel = (month: string, short = false) => new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: short ? 'short' : 'long', year: 'numeric', timeZone: 'UTC' })
|
||||
const decimal = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
||||
|
||||
function ChangeLabel({ change, unit = '' }: { change: Change; unit?: string }) {
|
||||
const delta = change.difference
|
||||
return <div className={`report-change ${delta > 0 ? 'is-up' : delta < 0 ? 'is-down' : 'is-flat'}`}>
|
||||
<span>{delta === 0 ? 'No change' : `${delta > 0 ? '+' : '−'}${decimal(Math.abs(delta))}${unit}${change.percent === null ? '' : ` (${delta > 0 ? '+' : '−'}${decimal(Math.abs(change.percent))}%)`}`}</span>
|
||||
<small>{change.percent === null ? 'No activity recorded in the comparison period' : `Previously ${decimal(change.previous)}${unit}`}</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default function MonthlyReportsPage() {
|
||||
const router = useRouter()
|
||||
const [month, setMonth] = useState('')
|
||||
const [months, setMonths] = useState<string[]>([])
|
||||
const [data, setData] = useState<MonthlyReport | null>(null)
|
||||
const [busy, setBusy] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [revision, setRevision] = useState(0)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [downloadError, setDownloadError] = useState('')
|
||||
const downloadController = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => () => downloadController.current?.abort(), [])
|
||||
const load = useCallback(async (signal: AbortSignal) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setData(null)
|
||||
setDownloadError('')
|
||||
try {
|
||||
const query = month ? `?month=${encodeURIComponent(month)}` : ''
|
||||
const response = await authFetch(`${getApiBase()}/insights/reports/monthly${query}`, { signal })
|
||||
if (response.status === 401) { router.replace('/login?next=%2Finsights%2Freports'); return }
|
||||
if (response.status === 403) throw new Error('Your account cannot access viewing reports. Please contact an administrator.')
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}))
|
||||
throw new Error(typeof result.detail === 'string' ? result.detail : 'Your report is temporarily unavailable. Please try again shortly.')
|
||||
}
|
||||
const result = await response.json() as MonthlyReport
|
||||
if (!signal.aborted) { setData(result); setMonths(result.available_months) }
|
||||
} catch (err) {
|
||||
if (!signal.aborted) setError(err instanceof Error ? err.message : 'Could not load your report.')
|
||||
} finally {
|
||||
if (!signal.aborted) setBusy(false)
|
||||
}
|
||||
}, [month, router])
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void load(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [load, revision])
|
||||
|
||||
const download = async () => {
|
||||
if (data?.state !== 'ready' || downloading) return
|
||||
const selected = data.month
|
||||
const controller = new AbortController()
|
||||
downloadController.current = controller
|
||||
setDownloading(true)
|
||||
setDownloadError('')
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/insights/reports/monthly.csv?month=${selected}`, { signal: controller.signal })
|
||||
if (response.status === 401) { router.replace('/login?next=%2Finsights%2Freports'); return }
|
||||
if (!response.ok) throw new Error('The report could not be downloaded. Please try again.')
|
||||
const blob = await response.blob()
|
||||
if (controller.signal.aborted) return
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `magent-monthly-report-${selected}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) setDownloadError(err instanceof Error ? err.message : 'Could not download your report.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedMonth = month || data?.month || ''
|
||||
const monthIndex = months.indexOf(selectedMonth)
|
||||
const summary = data?.summary
|
||||
const changes = data?.changes
|
||||
return <main className="stats-page reports-page">
|
||||
<PageHeading title="Monthly report" description="Your month in viewing. See what you watched, what changed, and what you requested." actions={<>
|
||||
<button className="ghost-button" type="button" disabled={busy || downloading} onClick={() => setRevision((value) => value + 1)}>Refresh report</button>
|
||||
<button className="ghost-button" type="button" disabled={busy || downloading || data?.state !== 'ready'} onClick={() => void download()}>{downloading ? 'Downloading…' : 'Download CSV'}</button>
|
||||
</>} />
|
||||
<StatsNavigation reports />
|
||||
<div className="stats-toolbar">
|
||||
<div className="report-month-picker">
|
||||
<button type="button" className="ghost-button" aria-label="Previous month" disabled={busy || downloading || monthIndex < 0 || monthIndex >= months.length - 1} onClick={() => setMonth(months[monthIndex + 1])}>←</button>
|
||||
<label><span className="stats-sr-only">Report month</span><select value={selectedMonth} disabled={busy || downloading || !months.length} onChange={(event) => setMonth(event.target.value)}>{!selectedMonth && <option value="">Latest complete month</option>}{months.map((value, index) => <option value={value} key={value}>{monthLabel(value)}{index === 0 ? ' · month to date' : ''}</option>)}</select></label>
|
||||
<button type="button" className="ghost-button" aria-label="Next month" disabled={busy || downloading || monthIndex <= 0} onClick={() => setMonth(months[monthIndex - 1])}>→</button>
|
||||
</div>
|
||||
<p className="stats-source"><span className={data?.state === 'ready' ? 'stats-source-dot is-ready' : 'stats-source-dot'} />From Jellystat · UTC</p>
|
||||
</div>
|
||||
{downloadError && <p className="stats-notice" role="alert">{downloadError}</p>}
|
||||
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true">◷</span><h2>Putting your month together</h2><p>Gathering your viewing history and the previous month’s comparison.</p></div>}
|
||||
{error && <div className="stats-state" role="alert"><h2>Report couldn’t load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button></div>}
|
||||
{data?.state === 'not_configured' && <section className="stats-state"><h2>Your monthly story starts here</h2><p>{data.is_admin ? 'Connect Jellystat to bring your monthly viewing reports into Magent.' : 'Monthly reports will appear once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>}
|
||||
{data?.state === 'unlinked' && <section className="stats-state"><h2>Link your viewing account</h2><p>Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review your user identities.</p>{data.is_admin && <a className="stats-action" href="/admin/identities">Review user identities</a>}</section>}
|
||||
{data && summary && changes && <>
|
||||
<section className="report-intro" aria-label="Report period">
|
||||
<div><span className="report-kicker">{data.is_partial ? 'Month to date' : 'Your monthly recap'}</span><h2>{monthLabel(data.month)}</h2><p>{data.is_partial ? `Compared with the same elapsed time in ${monthLabel(data.comparison_month)}${data.comparison_capped ? ', capped at the end of that month' : ''}.` : `Compared with ${monthLabel(data.comparison_month)}.`}</p></div>
|
||||
<div className="report-period-meta"><span>{data.is_partial ? 'In progress' : 'Complete month'}</span><small>{data.updated_at && `Updated ${new Date(data.updated_at).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC`}</small></div>
|
||||
</section>
|
||||
<section className="stats-metrics" aria-label="Monthly totals">
|
||||
<article className="stats-metric stats-metric-accent"><span>Minutes watched</span><strong>{number(summary.minutes)}</strong><small>{decimal(summary.minutes / 60)} hours across {number(summary.plays)} plays</small><ChangeLabel change={changes.minutes} unit=" min" /></article>
|
||||
<article className="stats-metric"><span>Movies played</span><strong>{number(summary.movies)}</strong><small>Different movies you pressed play on</small><ChangeLabel change={changes.movies} /></article>
|
||||
<article className="stats-metric"><span>Episodes played</span><strong>{number(summary.episodes)}</strong><small>Different episodes in your history</small><ChangeLabel change={changes.episodes} /></article>
|
||||
<article className="stats-metric"><span>Requests made</span><strong>{number(data.requests.total)}</strong><small>{data.requests.movies} movies · {data.requests.tv} TV requests</small><ChangeLabel change={changes.requests} /></article>
|
||||
</section>
|
||||
{summary.plays === 0 && <div className="stats-notice" role="status">No viewing history was recorded for this month. Your request totals and comparison are still shown.</div>}
|
||||
<div className="stats-main-grid">
|
||||
<ViewingChart key={`${data.month}-${revision}`} daily={data.daily ?? []} />
|
||||
<section className="stats-panel report-highlights"><div className="stats-panel-heading"><h2>Your viewing habits</h2></div>
|
||||
<div className="stats-highlight"><span className="stats-highlight-number">{summary.active_days}<small> days</small></span><div><strong>Days you watched</strong><p>At least one minute of viewing.</p><ChangeLabel change={changes.active_days} unit=" days" /></div></div>
|
||||
<div className="stats-highlight"><span className="stats-highlight-number">{summary.longest_streak}<small> days</small></span><div><strong>Longest run</strong><p>Consecutive viewing days this month.</p><ChangeLabel change={changes.longest_streak} unit=" days" /></div></div>
|
||||
<div className="stats-highlight"><span className="stats-highlight-number">{data.daily?.length ? number(summary.minutes / data.daily.length) : 0}<small> min</small></span><div><strong>Daily average</strong><p>Across the calendar days in this report.</p></div></div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="stats-three-grid">
|
||||
<section className="stats-panel"><div className="stats-panel-heading"><h2>Most watched</h2><span className="stats-unit">By minutes</span></div>{data.top_titles?.length ? <ol className="stats-top-titles report-top-titles">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><div><strong>{title.title}</strong><small>{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays</small></div><span>{number(title.minutes)}<small>min</small></span></li>)}</ol> : <p className="stats-muted">Your most watched titles will appear here.</p>}</section>
|
||||
<BreakdownCard title="Your players" rows={data.clients ?? []} />
|
||||
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
|
||||
</div>
|
||||
<div className="stats-main-grid">
|
||||
<section className="stats-panel stats-history"><div className="stats-panel-heading"><h2>A look back</h2><span className="stats-unit">Latest 20 plays this month</span></div>{data.recent?.length ? <div className="stats-history-list">{data.recent.map((play) => <article key={play.id}><RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} /><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.type === 'movie' ? 'Movie' : 'Media'}</small><span>{play.client} · {play.method}</span></div><div className="stats-history-time"><strong>{number(play.minutes)} min</strong><time dateTime={play.played_at}>{dateLabel(play.played_at)}</time></div></article>)}</div> : <p className="stats-muted">Plays recorded during this month will appear here.</p>}</section>
|
||||
<section className="stats-panel stats-requests"><div className="stats-panel-heading"><h2>Your requests</h2><a href="/">View all</a></div><div className="stats-request-total"><strong>{data.requests.total}</strong><span>submitted in {monthLabel(data.month, true)}</span></div><div className="stats-request-counts"><span><strong>{data.requests.pending}</strong> Pending</span><span><strong>{data.requests.approved}</strong> Approved</span><span><strong>{data.requests.declined}</strong> Declined</span></div>{data.requests.recent.length ? <ul className="stats-request-list">{data.requests.recent.map((request) => <li key={request.request_id}><a href={`/requests/${request.request_id}`}>{request.title || `Request ${request.request_id}`}<span aria-hidden="true">↗</span></a></li>)}</ul> : <p className="stats-muted">No requests recorded during this month.</p>}<p className="stats-muted">Statuses reflect where these requests are now.</p></section>
|
||||
</div>
|
||||
<p className="stats-footnote">Private to your account · Based on history retained by Jellystat and requests available in Magent. Plays include unfinished watches. Dates and streaks use UTC. Reports may take a minute to refresh; historical totals can change when retained history or library metadata changes.</p>
|
||||
</>}
|
||||
</main>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.report-month-picker { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.report-month-picker > button { min-width: 38px; min-height: 42px; padding: 8px; }
|
||||
.report-month-picker label { min-width: 0; }
|
||||
.report-month-picker select { width: 100%; min-height: 42px; padding: 10px 32px 10px 14px; border: 1px solid var(--ops-line); border-radius: 8px; background: var(--ops-panel); color: var(--ops-text); font-size: 13px; }
|
||||
.report-month-picker :disabled { opacity: .5; cursor: default; }
|
||||
.report-intro { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 8px 0; }
|
||||
.report-kicker { color: var(--ops-faint); font-size: 12px; }
|
||||
.report-intro h2 { margin: 8px 0; font-size: clamp(24px, 3vw, 32px); color: var(--ops-text); }
|
||||
.report-intro p { margin: 0; color: var(--ops-muted); font-size: 13px; line-height: 1.7; }
|
||||
.report-period-meta { display: grid; justify-items: end; gap: 10px; text-align: right; }
|
||||
.report-period-meta > span { padding: 6px 10px; border: 1px solid var(--ops-line); border-radius: 6px; color: #d1c6ff; font-size: 11px; white-space: nowrap; }
|
||||
.report-period-meta small { color: var(--ops-faint); font-size: 11px; line-height: 1.6; }
|
||||
.report-change { display: grid; gap: 5px; font-size: 12px; line-height: 1.6; }
|
||||
.stats-metric > .report-change { padding-top: 12px; border-top: 1px solid var(--ops-line-soft); }
|
||||
.report-change > span { color: var(--ops-muted); }
|
||||
.report-change.is-up > span { color: #d1c6ff; }
|
||||
.report-change small { color: var(--ops-faint); font-size: 11px; }
|
||||
.report-highlights .report-change { margin-top: 8px; }
|
||||
.report-top-titles li { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.reports-page .stats-highlight { grid-template-columns: 85px minmax(0, 1fr); }
|
||||
@media (max-width: 760px) {
|
||||
.report-intro { align-items: start; flex-direction: column; gap: 16px; }
|
||||
.report-period-meta { justify-items: start; text-align: left; }
|
||||
.report-month-picker { width: 100%; }
|
||||
.report-month-picker label { flex: 1; }
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
.stats-page { padding-bottom: 32px !important; }
|
||||
.stats-view-tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--ops-line-soft); }
|
||||
.stats-view-tabs a { padding: 0 0 14px; border-bottom: 2px solid transparent; color: var(--ops-muted); font-size: 13px; text-decoration: none; }
|
||||
.stats-view-tabs a[aria-current=page] { border-color: #c7bdff; color: #d1c6ff; }
|
||||
.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; }
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function HeaderActions() {
|
||||
{
|
||||
href: '/insights',
|
||||
label: 'My Stats',
|
||||
match: (path: string) => path === '/insights',
|
||||
match: (path: string) => path === '/insights' || path.startsWith('/insights/'),
|
||||
},
|
||||
{
|
||||
href: '/',
|
||||
|
||||
@@ -14,7 +14,7 @@ type NavigationItem = {
|
||||
}
|
||||
|
||||
const NAVIGATION: NavigationItem[] = [
|
||||
{ href: '/insights', label: 'My Stats', shortLabel: 'Stats', icon: 'stats', match: (path) => path === '/insights' },
|
||||
{ href: '/insights', label: 'My Stats', shortLabel: 'Stats', icon: 'stats', match: (path) => path === '/insights' || path.startsWith('/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') },
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Fixture-only browser review. Every API request is intercepted.
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
|
||||
const base = process.env.REVIEW_BASE || 'http://localhost:3114'
|
||||
const output = process.env.REVIEW_DIR
|
||||
|
||||
;(async () => {
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
try {
|
||||
const context = await browser.newContext({ acceptDownloads: true })
|
||||
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }])
|
||||
const calls = []
|
||||
let mode = 'ready'
|
||||
let role = 'admin'
|
||||
let failDownload = false
|
||||
const months = Array.from({ length: 24 }, (_, index) => new Date(Date.UTC(2026, 8 - index, 1)).toISOString().slice(0, 7))
|
||||
const delta = (current, previous) => ({ current, previous, difference: current - previous, percent: previous ? (current - previous) / previous * 100 : current ? null : 0 })
|
||||
const fixture = (month = '2026-08') => {
|
||||
const index = months.indexOf(month)
|
||||
const next = new Date(`${month}-01T00:00:00Z`)
|
||||
next.setUTCMonth(next.getUTCMonth() + 1)
|
||||
const days = month === '2026-09' ? 7 : new Date(next.getTime() - 1).getUTCDate()
|
||||
const summary = { minutes: 1500, movies: 8, episodes: 23, plays: 35, active_days: 20, longest_streak: 6, current_streak: 0 }
|
||||
return {
|
||||
state: 'ready', month, available_months: months, timezone: 'UTC', is_admin: role === 'admin',
|
||||
is_partial: month === '2026-09', comparison_capped: false,
|
||||
period_start: `${month}-01T00:00:00Z`, period_end: month === '2026-09' ? '2026-09-07T12:00:00Z' : next.toISOString(),
|
||||
comparison_month: months[index + 1] || '2024-09', comparison_start: `${months[index + 1] || '2024-09'}-01T00:00:00Z`, comparison_end: `${month}-01T00:00:00Z`,
|
||||
updated_at: '2026-09-07T12:00:00Z', summary,
|
||||
previous_summary: { ...summary, minutes: 1000 },
|
||||
changes: { minutes: delta(1500, 1000), movies: delta(8, 10), episodes: delta(23, 0), requests: delta(3, 3), plays: delta(35, 25), active_days: delta(20, 15), longest_streak: delta(6, 4) },
|
||||
daily: Array.from({ length: days }, (_, day) => ({ date: `${month}-${String(day + 1).padStart(2, '0')}`, minutes: day % 4 ? 40 + day * 2 : 0 })),
|
||||
top_titles: [{ title: 'Severance', type: 'series', minutes: 460, plays: 10 }, { title: 'Arrival', type: 'movie', minutes: 116, plays: 1 }],
|
||||
clients: [{ name: 'Jellyfin Web', minutes: 1200 }, { name: 'Jellyfin for Android TV', minutes: 300 }],
|
||||
methods: [{ name: 'Direct play', minutes: 1300 }, { name: 'Transcode', minutes: 200 }],
|
||||
transcoding: { video_minutes: 100, audio_minutes: 61, hardware_video_minutes: 80, software_video_minutes: 20, unknown_hardware_minutes: 0, unknown_video_minutes: 0, unknown_audio_minutes: 0, hardware: [{ name: 'NVIDIA NVENC', minutes: 80 }], audio_codecs: [{ name: 'AAC', minutes: 61 }], gpu_busy_minutes: null },
|
||||
recent: [{ id: 'play', title: 'Arrival', series: '', type: 'movie', minutes: 116, played_at: `${month}-05T10:00:00Z`, client: 'Jellyfin Web', method: 'Direct play', artwork_url: '/insights/artwork/fixture?token=fixture' }],
|
||||
requests: { total: 3, movies: 2, tv: 1, pending: 1, approved: 2, declined: 0, recent: [{ request_id: 12, title: 'Dune: Part Two', media_type: 'movie', status: 2 }] },
|
||||
}
|
||||
}
|
||||
await context.route('**/api/**', async (route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
calls.push({ method: request.method(), path: url.pathname, month: url.searchParams.get('month') })
|
||||
if (url.pathname === '/api/auth/me') return route.fulfill({ json: { username: 'Fixture viewer', role } })
|
||||
if (url.pathname.startsWith('/api/insights/artwork/')) return route.fulfill({ contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 180"><rect width="120" height="180" fill="#243946"/><path d="M0 150L65 35L120 150" fill="#c8bdaa"/></svg>' })
|
||||
if (url.pathname === '/api/insights/reports/monthly.csv') {
|
||||
if (failDownload) return route.fulfill({ status: 502, json: { detail: 'Unavailable' } })
|
||||
return route.fulfill({ contentType: 'text/csv; charset=utf-8', body: `\ufeffMonth,Minutes\r\n${url.searchParams.get('month')},1500\r\n` })
|
||||
}
|
||||
if (url.pathname === '/api/insights/reports/monthly' || url.pathname === '/api/insights') {
|
||||
if (mode === 'unauthorized') return route.fulfill({ status: 401, json: { detail: 'Sign in' } })
|
||||
if (mode === 'unavailable') return route.fulfill({ status: 502, json: { detail: 'Your monthly report is temporarily unavailable. Please try again shortly.' } })
|
||||
if (mode === 'limit') return route.fulfill({ status: 422, json: { detail: "This report exceeds Jellystat's history limit. No partial report has been generated." } })
|
||||
const data = { ...fixture(url.searchParams.get('month') || '2026-08'), days: 30 }
|
||||
if (mode === 'empty') {
|
||||
for (const key of Object.keys(data.summary)) data.summary[key] = 0
|
||||
for (const key of Object.keys(data.changes)) data.changes[key] = delta(0, 0)
|
||||
data.requests = { total: 0, movies: 0, tv: 0, pending: 0, approved: 0, declined: 0, recent: [] }
|
||||
data.daily = data.daily.map((day) => ({ ...day, minutes: 0 }))
|
||||
data.top_titles = data.recent = data.methods = data.clients = []
|
||||
} else if (mode !== 'ready') { data.state = mode; data.summary = null }
|
||||
return route.fulfill({ json: data })
|
||||
}
|
||||
if (url.pathname.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' })
|
||||
return route.fulfill({ json: {} })
|
||||
})
|
||||
const page = await context.newPage()
|
||||
const errors = []
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
for (const width of [1440, 980, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
await page.goto(base + '/insights/reports')
|
||||
await page.getByRole('heading', { name: 'August 2026', exact: true }).waitFor()
|
||||
assert.equal(await page.getByLabel('Report month').inputValue(), '2026-08')
|
||||
assert.equal(await page.getByLabel('Report month').locator('option').count(), 24)
|
||||
assert.equal(await page.locator('.stats-view-tabs a[aria-current=page]').innerText(), 'Monthly reports')
|
||||
assert.equal(await page.locator('.header-actions a.is-active').innerText(), 'My Stats')
|
||||
assert.match(await page.locator('.stats-metrics').innerText(), /\+500 min \(\+50%\)/)
|
||||
assert.match(await page.locator('.stats-metrics').innerText(), /−2 \(−20%\)/)
|
||||
assert.match(await page.locator('.stats-metrics').innerText(), /No activity recorded in the comparison period/)
|
||||
assert.equal(await page.getByRole('link', { name: 'Dune: Part Two' }).getAttribute('href'), '/requests/12')
|
||||
await page.locator('.stats-history').scrollIntoViewIfNeeded()
|
||||
await page.waitForFunction(() => document.querySelector('.stats-media-icon img')?.naturalWidth > 0)
|
||||
await page.evaluate(() => window.scrollTo(0, 0))
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Overflow at ${width}px`)
|
||||
if (width <= 980) assert.equal(await page.locator('.workspace-mobile-nav a.is-active').innerText(), 'Stats')
|
||||
if (output) {
|
||||
fs.mkdirSync(output, { recursive: true })
|
||||
await page.screenshot({ path: path.join(output, `monthly-report-${width}.png`), fullPage: true })
|
||||
}
|
||||
}
|
||||
await page.getByRole('button', { name: 'Previous month', exact: true }).click()
|
||||
await page.getByRole('heading', { name: 'July 2026', exact: true }).waitFor()
|
||||
await page.getByRole('button', { name: 'Next month', exact: true }).click()
|
||||
await page.getByRole('heading', { name: 'August 2026', exact: true }).waitFor()
|
||||
await page.getByLabel('Report month').selectOption('2026-09')
|
||||
await page.getByRole('heading', { name: 'September 2026', exact: true }).waitFor()
|
||||
await page.getByText('Compared with the same elapsed time in August 2026.', { exact: true }).waitFor()
|
||||
assert(await page.getByRole('button', { name: 'Next month', exact: true }).isDisabled())
|
||||
const downloaded = page.waitForEvent('download')
|
||||
await page.getByRole('button', { name: 'Download CSV', exact: true }).click()
|
||||
const download = await downloaded
|
||||
assert.equal(download.suggestedFilename(), 'magent-monthly-report-2026-09.csv')
|
||||
assert.match(fs.readFileSync(await download.path(), 'utf8'), /2026-09,1500/)
|
||||
failDownload = true
|
||||
await page.getByRole('button', { name: 'Download CSV', exact: true }).click()
|
||||
await page.getByRole('alert').filter({ hasText: 'could not be downloaded' }).waitFor()
|
||||
failDownload = false
|
||||
await page.getByLabel('Report month').selectOption(months.at(-1))
|
||||
await page.getByRole('heading', { name: 'October 2024', exact: true }).waitFor()
|
||||
assert(await page.getByRole('button', { name: 'Previous month', exact: true }).isDisabled())
|
||||
for (const [state, text] of [['empty', 'No viewing history was recorded for this month.'], ['unlinked', 'Link your viewing account'], ['not_configured', 'Your monthly story starts here'], ['unavailable', 'Report couldn’t load'], ['limit', 'No partial report has been generated.']]) {
|
||||
mode = state
|
||||
await page.reload()
|
||||
await page.getByText(text, { exact: false }).waitFor()
|
||||
if (state !== 'empty') assert(await page.getByRole('button', { name: 'Download CSV', exact: true }).isDisabled())
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
|
||||
}
|
||||
mode = 'not_configured'
|
||||
role = 'user'
|
||||
await page.reload()
|
||||
await page.getByText('Monthly reports will appear once your administrator connects Jellystat.').waitFor()
|
||||
assert.equal(await page.getByRole('link', { name: 'Connect Jellystat' }).count(), 0)
|
||||
mode = 'unauthorized'
|
||||
await page.reload()
|
||||
await page.waitForURL('**/login?next=%2Finsights%2Freports')
|
||||
mode = 'ready'
|
||||
await page.goto(base + '/insights/reports')
|
||||
await page.getByRole('heading', { name: 'August 2026', exact: true }).waitFor()
|
||||
await page.getByRole('navigation', { name: 'My Stats views' }).getByRole('link', { name: 'Overview' }).click()
|
||||
await page.waitForURL('**/insights')
|
||||
await page.getByRole('heading', { name: 'Your viewing rhythm' }).waitFor()
|
||||
assert.equal(await page.locator('.stats-view-tabs a[aria-current=page]').innerText(), 'Overview')
|
||||
assert(calls.filter((call) => call.path.startsWith('/api/insights/reports')).every((call) => call.method === 'GET'))
|
||||
assert.deepEqual(errors, [])
|
||||
console.log('Monthly report browser checks passed: desktop/mobile, comparison labels, periods, navigation, artwork, CSV download/error, empty/setup/link/error/auth states.')
|
||||
} finally { await browser.close() }
|
||||
})().catch((error) => { console.error(error); process.exit(1) })
|
||||
Reference in New Issue
Block a user