Time per recorded playback session.
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 = '
{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'| {esc(row["name"])} | {number(row["minutes"])} min |
{esc(item["title"])}
{number(item["minutes"])} minutes · {number(item["plays"])} plays
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() {Across the calendar days in this report.
Time per recorded playback session.
Your longest recorded session this month.
Share of viewing on Saturday and Sunday (UTC).
Your most watched titles will appear here.
}Your most watched titles will appear here.
}