From c194db167a4060f0bccbecb30830efb872eb3413 Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Sun, 13 Sep 2026 22:21:02 +1200 Subject: [PATCH] Illustrate monthly reports and add viewing pattern breakdowns --- backend/app/services/email_recaps.py | 34 +++++++++++++++++++++-- backend/app/services/insights.py | 13 ++++++++- backend/app/services/insights_artwork.py | 23 ++++++++------- backend/app/services/recap_email.py | 20 +++++++++++-- backend/tests/test_report_graphics.py | 34 +++++++++++++++++++++++ frontend/app/insights/components.tsx | 3 +- frontend/app/insights/reports/page.tsx | 13 ++++++++- frontend/app/insights/reports/reports.css | 11 ++++++++ 8 files changed, 133 insertions(+), 18 deletions(-) create mode 100644 backend/tests/test_report_graphics.py diff --git a/backend/app/services/email_recaps.py b/backend/app/services/email_recaps.py index 55d814b..50b1699 100644 --- a/backend/app/services/email_recaps.py +++ b/backend/app/services/email_recaps.py @@ -142,6 +142,34 @@ def completed_month(month: str | None) -> str: return period["month"] +async def illustrated_recap(report, account, public_url, unsubscribe_url, *, preview=False, **kwargs): + """Embed only signed artwork from this account's report; missing art is optional.""" + import base64 + import re + from .insights_artwork import get_artwork + runtime = get_runtime_settings() + images = [] + report = {**report, "top_titles": [dict(row) for row in report.get("top_titles", [])]} + + async def picture(index, row): + match = re.fullmatch(r"/insights/artwork/([a-f0-9]{32})\?token=([0-9]+\.[a-f0-9]{64})", row.get("artwork_url") or "") + if not match: + return + try: + data, mime = await get_artwork(account, runtime, *match.groups()) + cid = f"recap-title-{index}@magent" + row["email_artwork"] = f"data:{mime};base64,{base64.b64encode(data).decode()}" if preview else f"cid:{cid}" + images.append({"cid": cid, "data": data, "subtype": mime.split("/")[1]}) + except Exception: + pass # An unavailable poster must never prevent a personal report. + + await asyncio.gather(*(picture(i, row) for i, row in enumerate(report["top_titles"][:3]))) + rendered = mail.render_recap(report, account["username"], public_url, unsubscribe_url, **kwargs) + if not preview: + rendered["inline_images"] = images + return rendered + + async def preview(user: dict, month: str | None) -> dict: account = current_account(user) selected = completed_month(month) @@ -156,8 +184,8 @@ async def preview(user: dict, month: str | None) -> dict: raise RecapError("Your report is temporarily unavailable. Please try again shortly.", 502) from exc if report["state"] != "ready": raise RecapError("Connect Jellystat and link your Jellyfin account to preview your recap.") - return {"month": selected, "email": account.get("email"), **mail.render_recap( - report, account["username"], config["public_url"], config["public_url"] + "/profile#monthly-recaps")} + return {"month": selected, "email": account.get("email"), **await illustrated_recap( + report, account, config["public_url"], config["public_url"] + "/profile#monthly-recaps", preview=True)} def queue_test(user: dict, month: str | None, request_id: str) -> dict: @@ -200,7 +228,7 @@ async def process_delivery(delivery: dict) -> None: if report["state"] != "ready" or (report["is_partial"] and delivery["kind"] != "on_demand"): raise mail.DeliveryError("failed", "A complete personal report is not available.") unsubscribe = f"{delivery['public_url']}/email-recaps#" + urlencode({"action": "unsubscribe", "token": sub["unsubscribe_token"]}) - rendered = mail.render_recap(report, account["username"], delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand") + rendered = await illustrated_recap(report, account, delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand") def before_data(): eligible_delivery(delivery) diff --git a/backend/app/services/insights.py b/backend/app/services/insights.py index dea7530..2e2e69d 100644 --- a/backend/app/services/insights.py +++ b/backend/app/services/insights.py @@ -125,6 +125,9 @@ def request_summary(user: dict, start: datetime, end: datetime, *, end_exclusive 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) + weekdays = [0.0] * 7 + media_minutes = defaultdict(float) + longest_play = 0.0 clients = defaultdict(float) methods = defaultdict(float) transcoding = dict.fromkeys(("video_minutes", "audio_minutes", "hardware_video_minutes", "software_video_minutes", @@ -157,6 +160,9 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime, *, episode_ids.add(str(episode_id)) elif media_type == "movie": movie_ids.add(item_id) + weekdays[date.weekday()] += duration / 60 + media_minutes[media_type] += duration / 60 + longest_play = max(longest_play, duration / 60) seconds += duration daily_seconds[date.date().isoformat()] += duration client = str(row.get("Client") or "Unknown player")[:200] @@ -166,7 +172,7 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime, *, methods[method] += duration name = str(row.get("NowPlayingItemName") or "Untitled")[:500] series = str(row.get("SeriesName") or "")[:500] - title = titles.setdefault(item_id, {"title": series or name, "type": "series" if episode_id else media_type, "minutes": 0, "plays": 0}) + title = titles.setdefault(item_id, {"title": series or name, "type": "series" if episode_id else media_type, "minutes": 0, "plays": 0, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))}) title["minutes"] += duration / 60 title["plays"] += 1 recent.append({"id": row_id, "title": name, "series": series, "type": media_type, @@ -193,6 +199,11 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime, *, return {"summary": {"minutes": round(seconds / 60, 1), "plays": len(recent), "movies": len(movie_ids), "episodes": len(episode_ids), "active_days": len(active_days), "current_streak": current, "longest_streak": longest}, + "patterns": {"average_play_minutes": round(seconds / 60 / len(recent), 1) if recent else 0, + "longest_play_minutes": round(longest_play, 1), + "weekend_percent": round(sum(weekdays[5:]) / (seconds / 60) * 100, 1) if seconds else 0, + "weekdays": [{"name": name, "minutes": round(weekdays[i], 1)} for i, name in enumerate(("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"))], + "media": [{"name": name, "minutes": round(media_minutes[key], 1)} for key, name in (("movie", "Movies"), ("episode", "TV episodes"), ("other", "Other media"))]}, "daily": daily, "top_titles": top, "clients": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(clients.items(), key=lambda pair: -pair[1])[:6]], "methods": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(methods.items(), key=lambda pair: -pair[1])], diff --git a/backend/app/services/insights_artwork.py b/backend/app/services/insights_artwork.py index 9bdec55..f7a4082 100644 --- a/backend/app/services/insights_artwork.py +++ b/backend/app/services/insights_artwork.py @@ -35,16 +35,19 @@ def signature(user, runtime, media_id, expires): def with_artwork(data, user, runtime): expires = int(time.time()) + TOKEN_SECONDS - recent = [] - for play in data.get("recent", []): - row = {**play} - media_id = row.pop("artwork_item_id", None) - row["artwork_url"] = None - if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key: - token = f"{expires}.{signature(user, runtime, media_id, expires)}" - row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}" - recent.append(row) - return {**data, "recent": recent} + result = {**data} + for field in ("recent", "top_titles"): + rows = [] + for play in data.get(field, []): + row = {**play} + media_id = row.pop("artwork_item_id", None) + row["artwork_url"] = None + if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key: + token = f"{expires}.{signature(user, runtime, media_id, expires)}" + row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}" + rows.append(row) + result[field] = rows + return result def verify_artwork_token(user, runtime, media_id, token): diff --git a/backend/app/services/recap_email.py b/backend/app/services/recap_email.py index 3756230..5ad6138 100644 --- a/backend/app/services/recap_email.py +++ b/backend/app/services/recap_email.py @@ -88,10 +88,26 @@ def render_recap(report: dict, username: str, public_url: str, unsubscribe_url: content = '' + ''.join(cells[:2]) + '' + ''.join(cells[2:]) + '' habit = f"{number(summary['active_days'])} days watched · {number(summary['longest_streak'])}-day longest run" content += f'

{esc(habit)}

' + patterns = report.get("patterns", {}) + if patterns: + detail = f"Average play: {number(patterns['average_play_minutes'])} min. Longest play: {number(patterns['longest_play_minutes'])} min. Weekend viewing: {number(patterns['weekend_percent'])}%." + lines.append(detail) + content += f'

{esc(detail)}

' + for heading, rows in (("Your week in viewing (UTC)", patterns["weekdays"]), ("Movies, TV and more", patterns["media"])): + peak = max(1, *(row["minutes"] for row in rows)) + content += f'

{heading}

' + for row in rows: + width = round(row["minutes"] / peak * 100) + content += f'' + lines.append(f"{row['name']}: {number(row['minutes'])} minutes") + content += '
{esc(row["name"])}
 
{number(row["minutes"])} min
' top = report.get("top_titles", [])[:3] if top: content += '

Your most watched

' for item in top: + artwork = item.get("email_artwork", "") + if artwork.startswith(("cid:", "data:image/")): + content += f'{esc(item[' content += f'

{esc(item["title"])}
{number(item["minutes"])} minutes · {number(item["plays"])} plays

' else: content += '

No viewing was recorded this month. Your requests are still included.

' @@ -130,8 +146,8 @@ def send_email(recipient: str, rendered: dict, message_id: str, before_data=lamb html_part = message.get_payload()[-1] for attachment in rendered.get('inline_images', []): html_part.add_related( - attachment['data'], maintype='image', subtype='jpeg', cid=f"<{attachment['cid']}>", - filename=attachment['cid'].split('@')[0] + '.jpg', disposition='inline') + attachment['data'], maintype='image', subtype=attachment.get('subtype', 'jpeg'), cid=f"<{attachment['cid']}>", + filename=attachment['cid'].split('@')[0] + '.' + attachment.get('subtype', 'jpeg'), disposition='inline') payload = message.as_bytes() smtp, stage = None, "connect" try: diff --git a/backend/tests/test_report_graphics.py b/backend/tests/test_report_graphics.py new file mode 100644 index 0000000..6bf7fba --- /dev/null +++ b/backend/tests/test_report_graphics.py @@ -0,0 +1,34 @@ +import unittest +from datetime import timedelta +from unittest.mock import AsyncMock, patch +from backend.tests.test_insights import play, NOW, LIBRARIES +from backend.tests.test_email_recaps import fixture_report +from backend.app.services.insights import summarize +from backend.app.services.email_recaps import illustrated_recap + +class ReportGraphicsTests(unittest.IsolatedAsyncioTestCase): + def test_patterns_deduplicate_and_handle_empty_history(self): + first = play() + second = play("second", PlaybackDuration=1800, EpisodeId="episode", ActivityDateInserted=(NOW-timedelta(days=1)).isoformat()) + report = summarize([first, first, second], LIBRARIES, NOW-timedelta(days=7), NOW) + self.assertEqual(report["patterns"]["average_play_minutes"], 45) + self.assertEqual(report["patterns"]["longest_play_minutes"], 60) + self.assertEqual(report["patterns"]["weekend_percent"], 33.3) + self.assertEqual(sum(r["minutes"] for r in report["patterns"]["media"]), 90) + empty = summarize([], [], NOW-timedelta(days=7), NOW) + self.assertEqual(empty["patterns"]["average_play_minutes"], 0) + + async def test_artwork_embedded_without_private_links_and_optional_on_failure(self): + report = fixture_report() + report["top_titles"][0]["artwork_url"] = "/insights/artwork/" + "a"*32 + "?token=123." + "b"*64 + with patch("backend.app.services.email_recaps.get_runtime_settings"), patch("backend.app.services.insights_artwork.get_artwork", new=AsyncMock(return_value=(b"picture", "image/webp"))): + rendered = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe") + self.assertIn("cid:recap-title-0@magent", rendered["body_html"]) + self.assertNotIn("?token=", rendered["body_html"]) + self.assertEqual(rendered["inline_images"][0]["subtype"], "webp") + preview = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe", preview=True) + self.assertIn("data:image/webp;base64,", preview["body_html"]) + self.assertNotIn("inline_images", preview) + with patch("backend.app.services.email_recaps.get_runtime_settings"), patch("backend.app.services.insights_artwork.get_artwork", new=AsyncMock(side_effect=RuntimeError())): + rendered = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe") + self.assertEqual(rendered["inline_images"], []) diff --git a/frontend/app/insights/components.tsx b/frontend/app/insights/components.tsx index 7c2af72..607797b 100644 --- a/frontend/app/insights/components.tsx +++ b/frontend/app/insights/components.tsx @@ -17,7 +17,8 @@ export type Stats = { 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 }[] + patterns?: { average_play_minutes: number; longest_play_minutes: number; weekend_percent: number; weekdays: Breakdown[]; media: Breakdown[] } + top_titles?: { artwork_url?: string | null; 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[] diff --git a/frontend/app/insights/reports/page.tsx b/frontend/app/insights/reports/page.tsx index 478897f..6e7b715 100644 --- a/frontend/app/insights/reports/page.tsx +++ b/frontend/app/insights/reports/page.tsx @@ -151,8 +151,19 @@ export default function MonthlyReportsPage() {
{data.daily?.length ? number(summary.minutes / data.daily.length) : 0} min
Daily average

Across the calendar days in this report.

+ {data.patterns && <> +
+
Average play{decimal(data.patterns.average_play_minutes)} min

Time per recorded playback session.

+
Longest play{decimal(data.patterns.longest_play_minutes)} min

Your longest recorded session this month.

+
Weekend viewing{decimal(data.patterns.weekend_percent)}%

Share of viewing on Saturday and Sunday (UTC).

+
+
+ + +
+ }
-

Most watched

By minutes
{data.top_titles?.length ?
    {data.top_titles.map((title, index) =>
  1. {title.title}{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays
    {number(title.minutes)}min
  2. )}
:

Your most watched titles will appear here.

}
+

Most watched

By minutes
{data.top_titles?.length ?
    {data.top_titles.map((title, index) =>
  1. {title.title}{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays
    {number(title.minutes)}min
  2. )}
:

Your most watched titles will appear here.

}
diff --git a/frontend/app/insights/reports/reports.css b/frontend/app/insights/reports/reports.css index a069aec..02e2ce3 100644 --- a/frontend/app/insights/reports/reports.css +++ b/frontend/app/insights/reports/reports.css @@ -28,3 +28,14 @@ .report-email-panel { display: grid; gap: 12px; } .report-email-panel > button { justify-self: start; } .report-email-panel p, .report-email-panel li { overflow-wrap: anywhere; } + +.report-pattern-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; margin:20px 0; } +.report-pattern-summary article { padding:24px; border:1px solid #45404e; border-radius:16px; background:linear-gradient(135deg,#262137,#14292d); } +.report-pattern-summary span { color:#d0c6e7; font-size:13px; } +.report-pattern-summary strong { display:block; font-size:36px; margin:12px 0; color:#c5baff; } +.report-pattern-summary small { font-size:16px; } +.report-pattern-summary p { color:#b6b6c0; font-size:13px; margin:0; } +.report-top-titles li { grid-template-columns:44px minmax(0,1fr) auto; gap:12px; } +.report-top-titles li > div { flex:1; min-width:0; } +.report-top-titles li > .stats-media-icon { width:44px; } +@media(max-width:640px) { .report-pattern-summary { grid-template-columns:1fr; } }