Add personal monthly viewing reports and CSV exports
Magent CI/CD / verify (push) Successful in 10m58s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m42s

This commit is contained in:
2026-09-09 16:25:35 +12:00
parent 437836243c
commit 333a799e21
13 changed files with 836 additions and 92 deletions
+35 -1
View File
@@ -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)
+8 -6
View File
@@ -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)
+149
View File
@@ -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()