150 lines
7.9 KiB
Python
150 lines
7.9 KiB
Python
"""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()
|