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()
|
||||
Reference in New Issue
Block a user