Compare commits

..
5 Commits
Author SHA1 Message Date
Assclaw dd51332f3c Wait for Arr download queue hand-off before reporting search outcome
Magent CI/CD / verify (push) Successful in 2m10s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-15 14:15:24 +12:00
Assclaw 6a84e68a03 Show request submission progress in a focused follow-up dialog
Magent CI/CD / verify (push) Successful in 1m53s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 55s
2026-09-15 14:02:08 +12:00
Assclaw c194db167a Illustrate monthly reports and add viewing pattern breakdowns
Magent CI/CD / verify (push) Successful in 1m48s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 49s
2026-09-13 22:21:02 +12:00
Assclaw e232335ca9 Organize diagnostics into horizontal rows and group notification controls
Magent CI/CD / verify (push) Successful in 1m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 50s
2026-09-13 22:08:41 +12:00
Assclaw 8df02fdfd7 Feather the top edge of request artwork
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 53s
2026-09-13 21:57:26 +12:00
16 changed files with 279 additions and 72 deletions
+31 -3
View File
@@ -142,6 +142,34 @@ def completed_month(month: str | None) -> str:
return period["month"] 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: async def preview(user: dict, month: str | None) -> dict:
account = current_account(user) account = current_account(user)
selected = completed_month(month) 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 raise RecapError("Your report is temporarily unavailable. Please try again shortly.", 502) from exc
if report["state"] != "ready": if report["state"] != "ready":
raise RecapError("Connect Jellystat and link your Jellyfin account to preview your recap.") raise RecapError("Connect Jellystat and link your Jellyfin account to preview your recap.")
return {"month": selected, "email": account.get("email"), **mail.render_recap( return {"month": selected, "email": account.get("email"), **await illustrated_recap(
report, account["username"], config["public_url"], config["public_url"] + "/profile#monthly-recaps")} 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: 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"): if report["state"] != "ready" or (report["is_partial"] and delivery["kind"] != "on_demand"):
raise mail.DeliveryError("failed", "A complete personal report is not available.") 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"]}) 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(): def before_data():
eligible_delivery(delivery) eligible_delivery(delivery)
+12 -1
View File
@@ -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: 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} library_types = {str(row.get("Id")): str(row.get("CollectionType") or "").lower() for row in libraries}
daily_seconds = defaultdict(float) daily_seconds = defaultdict(float)
weekdays = [0.0] * 7
media_minutes = defaultdict(float)
longest_play = 0.0
clients = defaultdict(float) clients = defaultdict(float)
methods = defaultdict(float) methods = defaultdict(float)
transcoding = dict.fromkeys(("video_minutes", "audio_minutes", "hardware_video_minutes", "software_video_minutes", 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)) episode_ids.add(str(episode_id))
elif media_type == "movie": elif media_type == "movie":
movie_ids.add(item_id) 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 seconds += duration
daily_seconds[date.date().isoformat()] += duration daily_seconds[date.date().isoformat()] += duration
client = str(row.get("Client") or "Unknown player")[:200] 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 methods[method] += duration
name = str(row.get("NowPlayingItemName") or "Untitled")[:500] name = str(row.get("NowPlayingItemName") or "Untitled")[:500]
series = str(row.get("SeriesName") or "")[: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["minutes"] += duration / 60
title["plays"] += 1 title["plays"] += 1
recent.append({"id": row_id, "title": name, "series": series, "type": media_type, 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), return {"summary": {"minutes": round(seconds / 60, 1), "plays": len(recent), "movies": len(movie_ids),
"episodes": len(episode_ids), "active_days": len(active_days), "episodes": len(episode_ids), "active_days": len(active_days),
"current_streak": current, "longest_streak": longest}, "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, "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]], "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])], "methods": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(methods.items(), key=lambda pair: -pair[1])],
+7 -4
View File
@@ -35,16 +35,19 @@ def signature(user, runtime, media_id, expires):
def with_artwork(data, user, runtime): def with_artwork(data, user, runtime):
expires = int(time.time()) + TOKEN_SECONDS expires = int(time.time()) + TOKEN_SECONDS
recent = [] result = {**data}
for play in data.get("recent", []): for field in ("recent", "top_titles"):
rows = []
for play in data.get(field, []):
row = {**play} row = {**play}
media_id = row.pop("artwork_item_id", None) media_id = row.pop("artwork_item_id", None)
row["artwork_url"] = None row["artwork_url"] = None
if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key: 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)}" token = f"{expires}.{signature(user, runtime, media_id, expires)}"
row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}" row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}"
recent.append(row) rows.append(row)
return {**data, "recent": recent} result[field] = rows
return result
def verify_artwork_token(user, runtime, media_id, token): def verify_artwork_token(user, runtime, media_id, token):
+18 -2
View File
@@ -88,10 +88,26 @@ def render_recap(report: dict, username: str, public_url: str, unsubscribe_url:
content = '<table role="presentation" class="email-metrics" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed"><tr>' + ''.join(cells[:2]) + '</tr><tr>' + ''.join(cells[2:]) + '</tr></table>' content = '<table role="presentation" class="email-metrics" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed"><tr>' + ''.join(cells[:2]) + '</tr><tr>' + ''.join(cells[2:]) + '</tr></table>'
habit = f"{number(summary['active_days'])} days watched · {number(summary['longest_streak'])}-day longest run" habit = f"{number(summary['active_days'])} days watched · {number(summary['longest_streak'])}-day longest run"
content += f'<p style="color:#e5e1e4;font-size:14px;line-height:1.7;margin:24px 0">{esc(habit)}</p>' content += f'<p style="color:#e5e1e4;font-size:14px;line-height:1.7;margin:24px 0">{esc(habit)}</p>'
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'<p style="padding:18px;background:#242334;border-radius:12px;color:#d8cfff;line-height:1.8">{esc(detail)}</p>'
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'<h2 style="font-size:18px;color:#e5e1e4">{heading}</h2><table role="presentation" width="100%" cellspacing="0" cellpadding="0">'
for row in rows:
width = round(row["minutes"] / peak * 100)
content += f'<tr><td style="padding:8px 0;color:#bdb6c3;font-size:12px;width:100px">{esc(row["name"])}</td><td style="padding:8px"><table role="presentation" width="{width}%" cellspacing="0" cellpadding="0"><tr><td height="8" style="background:{"#8cdbdd" if width else "transparent"};border-radius:4px;font-size:0">&nbsp;</td></tr></table></td><td style="width:65px;color:#e0d8ff;font-size:12px;text-align:right">{number(row["minutes"])} min</td></tr>'
lines.append(f"{row['name']}: {number(row['minutes'])} minutes")
content += '</table>'
top = report.get("top_titles", [])[:3] top = report.get("top_titles", [])[:3]
if top: if top:
content += '<h2 style="font-size:18px;color:#e5e1e4;margin:24px 0 8px">Your most watched</h2>' content += '<h2 style="font-size:18px;color:#e5e1e4;margin:24px 0 8px">Your most watched</h2>'
for item in top: for item in top:
artwork = item.get("email_artwork", "")
if artwork.startswith(("cid:", "data:image/")):
content += f'<img src="{esc(artwork, quote=True)}" alt="{esc(item["title"], quote=True)}" width="80" style="display:block;border-radius:10px;margin-top:20px" />'
content += f'<p style="font-size:14px;line-height:1.6;color:#e5e1e4;margin:12px 0;overflow-wrap:anywhere">{esc(item["title"])}<br><span style="font-size:12px;color:#a69fac">{number(item["minutes"])} minutes · {number(item["plays"])} plays</span></p>' content += f'<p style="font-size:14px;line-height:1.6;color:#e5e1e4;margin:12px 0;overflow-wrap:anywhere">{esc(item["title"])}<br><span style="font-size:12px;color:#a69fac">{number(item["minutes"])} minutes · {number(item["plays"])} plays</span></p>'
else: else:
content += '<p style="font-size:14px;color:#bdb6c3;line-height:1.7">No viewing was recorded this month. Your requests are still included.</p>' content += '<p style="font-size:14px;color:#bdb6c3;line-height:1.7">No viewing was recorded this month. Your requests are still included.</p>'
@@ -130,8 +146,8 @@ def send_email(recipient: str, rendered: dict, message_id: str, before_data=lamb
html_part = message.get_payload()[-1] html_part = message.get_payload()[-1]
for attachment in rendered.get('inline_images', []): for attachment in rendered.get('inline_images', []):
html_part.add_related( html_part.add_related(
attachment['data'], maintype='image', subtype='jpeg', cid=f"<{attachment['cid']}>", attachment['data'], maintype='image', subtype=attachment.get('subtype', 'jpeg'), cid=f"<{attachment['cid']}>",
filename=attachment['cid'].split('@')[0] + '.jpg', disposition='inline') filename=attachment['cid'].split('@')[0] + '.' + attachment.get('subtype', 'jpeg'), disposition='inline')
payload = message.as_bytes() payload = message.as_bytes()
smtp, stage = None, "connect" smtp, stage = None, "connect"
try: try:
+5 -5
View File
@@ -100,10 +100,11 @@ async def movie_search_outcome(client, movie_id, command, attempts=12, delay=2):
movie = await client.get_movie(movie_id) movie = await client.get_movie(movie_id)
if (movie or {}).get('hasFile'): if (movie or {}).get('hasFile'):
return {'status': 'complete', 'message': 'Radarr already has the movie file. Recheck the request for Jellyfin availability.'} return {'status': 'complete', 'message': 'Radarr already has the movie file. Recheck the request for Jellyfin availability.'}
return {'status': 'attention', 'message': 'Search finished, but no download appeared in Radarr. Use Search and choose a download to see matching releases and rejection reasons. For a foreign-language title, review the audio choice above.'} # Command completion precedes download-client queue refresh. Keep polling.
pass
if attempt + 1 < attempts: if attempt + 1 < attempts:
await asyncio.sleep(delay) await asyncio.sleep(delay)
return {'status': 'searching', 'message': 'Radarr is still searching. No download is confirmed yet; the pipeline will keep checking. You can close this window.'} return {'status': 'pending', 'message': 'The search was submitted, but a download is not confirmed yet. The download queue may still be updating. Close this window and recheck the request shortly.'}
async def series_search_outcome(client, series_id, commands, attempts=12, delay=2): async def series_search_outcome(client, series_id, commands, attempts=12, delay=2):
@@ -122,8 +123,7 @@ async def series_search_outcome(client, series_id, commands, attempts=12, delay=
statuses = {str((state or {}).get('status', '')).lower() for state in states} statuses = {str((state or {}).get('status', '')).lower() for state in states}
if statuses & {'failed', 'aborted', 'cancelled'}: if statuses & {'failed', 'aborted', 'cancelled'}:
return {'status': 'attention', 'message': 'A Sonarr search failed. Check the service or try Search and choose a download.'} return {'status': 'attention', 'message': 'A Sonarr search failed. Check the service or try Search and choose a download.'}
if statuses == {'completed'}: # Even completed commands can precede Sonarr's download queue refresh.
return {'status': 'attention', 'message': 'Sonarr finished searching, but no download is visible yet. Use Search and choose a download to review available releases and rejection reasons.'}
if attempt + 1 < attempts: if attempt + 1 < attempts:
await asyncio.sleep(delay) await asyncio.sleep(delay)
return {'status': 'searching', 'message': 'Sonarr is still searching. No download is confirmed yet; you can close this window and follow the pipeline.'} return {'status': 'pending', 'message': 'The search was submitted, but a download is not confirmed yet. The download queue may still be updating. Close this window and recheck the request shortly.'}
+34
View File
@@ -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"], [])
+20 -3
View File
@@ -85,8 +85,8 @@ class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
await apply_original_to_movie(client, 613) await apply_original_to_movie(client, 613)
async def test_search_reports_real_outcomes(self): async def test_search_reports_real_outcomes(self):
for command_status, queue, expected in [('completed', [], 'attention'), ('failed', [], 'attention'), for command_status, queue, expected in [('completed', [], 'pending'), ('failed', [], 'attention'),
('started', [], 'searching'), ('completed', [{'movieId': 6940}], 'downloading')]: ('started', [], 'pending'), ('completed', [{'movieId': 6940}], 'downloading')]:
client = SimpleNamespace(get=AsyncMock(return_value={'status': command_status}), client = SimpleNamespace(get=AsyncMock(return_value={'status': command_status}),
get_queue=AsyncMock(return_value={'records': queue}), get_movie=AsyncMock(return_value={'hasFile': False})) get_queue=AsyncMock(return_value={'records': queue}), get_movie=AsyncMock(return_value={'hasFile': False}))
result = await movie_search_outcome(client, 6940, {'id': 1}, attempts=1, delay=0) result = await movie_search_outcome(client, 6940, {'id': 1}, attempts=1, delay=0)
@@ -114,6 +114,23 @@ class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
async def test_tv_search_distinguishes_no_download_and_queue(self): async def test_tv_search_distinguishes_no_download_and_queue(self):
from backend.app.services.request_language import series_search_outcome from backend.app.services.request_language import series_search_outcome
client = SimpleNamespace(get=AsyncMock(return_value={'status': 'completed'}), get_queue=AsyncMock(return_value={'records': []})) client = SimpleNamespace(get=AsyncMock(return_value={'status': 'completed'}), get_queue=AsyncMock(return_value={'records': []}))
self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'attention') self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'pending')
client.get_queue.return_value = {'records': [{'seriesId': 50}]} client.get_queue.return_value = {'records': [{'seriesId': 50}]}
self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'downloading') self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'downloading')
class SearchHandoffTests(unittest.IsolatedAsyncioTestCase):
async def test_radarr_completed_before_queue_refresh(self):
client = SimpleNamespace(get=AsyncMock(return_value={'status':'completed'}),
get_queue=AsyncMock(side_effect=[{'records':[]}, {'records':[]}, {'records':[{'movieId':2206}]}]),
get_movie=AsyncMock(return_value={'hasFile':False}))
result = await movie_search_outcome(client, 2206, {'id':1}, attempts=3, delay=0)
self.assertEqual(result['status'], 'downloading')
self.assertEqual(client.get_queue.await_count, 3)
async def test_sonarr_completed_before_queue_refresh(self):
from backend.app.services.request_language import series_search_outcome
client = SimpleNamespace(get=AsyncMock(return_value={'status':'completed'}),
get_queue=AsyncMock(side_effect=[{'records':[{'seriesId':999}]}, {'records':[{'seriesId':50}]}]))
result = await series_search_outcome(client, 50, [{'id':1}], attempts=2, delay=0)
self.assertEqual(result['status'], 'downloading')
+2 -1
View File
@@ -17,7 +17,8 @@ export type Stats = {
updated_at?: string updated_at?: string
summary: null | { minutes: number; movies: number; episodes: number; plays: number; current_streak: number; longest_streak: number; active_days: number } summary: null | { minutes: number; movies: number; episodes: number; plays: number; current_streak: number; longest_streak: number; active_days: number }
daily?: Day[] 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 }[] 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[] clients?: Breakdown[]
methods?: Breakdown[] methods?: Breakdown[]
+12 -1
View File
@@ -151,8 +151,19 @@ export default function MonthlyReportsPage() {
<div className="stats-highlight"><span className="stats-highlight-number">{data.daily?.length ? number(summary.minutes / data.daily.length) : 0}<small> min</small></span><div><strong>Daily average</strong><p>Across the calendar days in this report.</p></div></div> <div className="stats-highlight"><span className="stats-highlight-number">{data.daily?.length ? number(summary.minutes / data.daily.length) : 0}<small> min</small></span><div><strong>Daily average</strong><p>Across the calendar days in this report.</p></div></div>
</section> </section>
</div> </div>
{data.patterns && <>
<section className="report-pattern-summary" aria-label="Viewing insights">
<article><span>Average play</span><strong>{decimal(data.patterns.average_play_minutes)} <small>min</small></strong><p>Time per recorded playback session.</p></article>
<article><span>Longest play</span><strong>{decimal(data.patterns.longest_play_minutes)} <small>min</small></strong><p>Your longest recorded session this month.</p></article>
<article><span>Weekend viewing</span><strong>{decimal(data.patterns.weekend_percent)}<small>%</small></strong><p>Share of viewing on Saturday and Sunday (UTC).</p></article>
</section>
<div className="stats-main-grid">
<BreakdownCard title="Your week in viewing (UTC)" rows={data.patterns.weekdays} />
<BreakdownCard title="Movies, TV and more" rows={data.patterns.media} />
</div>
</>}
<div className="stats-three-grid"> <div className="stats-three-grid">
<section className="stats-panel"><div className="stats-panel-heading"><h2>Most watched</h2><span className="stats-unit">By minutes</span></div>{data.top_titles?.length ? <ol className="stats-top-titles report-top-titles">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><div><strong>{title.title}</strong><small>{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays</small></div><span>{number(title.minutes)}<small>min</small></span></li>)}</ol> : <p className="stats-muted">Your most watched titles will appear here.</p>}</section> <section className="stats-panel"><div className="stats-panel-heading"><h2>Most watched</h2><span className="stats-unit">By minutes</span></div>{data.top_titles?.length ? <ol className="stats-top-titles report-top-titles">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><RecentArtwork url={title.artwork_url} type={title.type} /><div><strong>{title.title}</strong><small>{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays</small></div><span>{number(title.minutes)}<small>min</small></span></li>)}</ol> : <p className="stats-muted">Your most watched titles will appear here.</p>}</section>
<BreakdownCard title="Your players" rows={data.clients ?? []} /> <BreakdownCard title="Your players" rows={data.clients ?? []} />
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} /> <StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
</div> </div>
+11
View File
@@ -28,3 +28,14 @@
.report-email-panel { display: grid; gap: 12px; } .report-email-panel { display: grid; gap: 12px; }
.report-email-panel > button { justify-self: start; } .report-email-panel > button { justify-self: start; }
.report-email-panel p, .report-email-panel li { overflow-wrap: anywhere; } .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; } }
+33 -20
View File
@@ -1,6 +1,8 @@
'use client' 'use client'
import PageHeading from '../ui/PageHeading' import PageHeading from '../ui/PageHeading'
import { lockBodyScroll } from '../lib/scrollLock'
import './request-progress.css'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
@@ -96,12 +98,6 @@ const apiError = async (response: Response, fallback: string) => {
return fallback return fallback
} }
const formatDuration = (milliseconds?: number | null) => {
if (milliseconds == null) return null
if (milliseconds < 1000) return `${Math.round(milliseconds)} ms`
return `${(milliseconds / 1000).toFixed(1)} s`
}
export default function NewRequestClient() { export default function NewRequestClient() {
const router = useRouter() const router = useRouter()
const searchSectionRef = useRef<HTMLElement | null>(null) const searchSectionRef = useRef<HTMLElement | null>(null)
@@ -118,6 +114,16 @@ export default function NewRequestClient() {
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([]) const [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState<boolean | null>(null) const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState<boolean | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [progressOpen, setProgressOpen] = useState(false)
const progressDialog = useRef<HTMLDialogElement>(null)
useEffect(() => {
if (!progressOpen) return
const previous = document.activeElement as HTMLElement | null
progressDialog.current?.showModal()
const unlock = lockBodyScroll()
return () => { progressDialog.current?.close(); unlock(); previous?.focus() }
}, [progressOpen])
const [operation, setOperation] = useState<OperationProgress | null>(null) const [operation, setOperation] = useState<OperationProgress | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
@@ -269,12 +275,13 @@ export default function NewRequestClient() {
} }
const submitRequest = async () => { const submitRequest = async () => {
if (!selected || !options) return if (!selected || !options || submitting) return
if (options.media.originalLanguage && acceptOriginalLanguage === null) { setError('Choose an audio language option before requesting.'); return } if (options.media.originalLanguage && acceptOriginalLanguage === null) { setError('Choose an audio language option before requesting.'); return }
if (selected.type === 'tv' && selectedSeasons.length === 0) { if (selected.type === 'tv' && selectedSeasons.length === 0) {
setError('Select at least one season.') setError('Select at least one season.')
return return
} }
setProgressOpen(true)
setSubmitting(true) setSubmitting(true)
setError(null) setError(null)
setSuccess(null) setSuccess(null)
@@ -309,7 +316,7 @@ export default function NewRequestClient() {
setResults((current) => current.map((item) => item.tmdbId === selected.tmdbId && item.type === selected.type setResults((current) => current.map((item) => item.tmdbId === selected.tmdbId && item.type === selected.type
? { ...item, requestId, statusLabel: payload?.statusLabel ?? item.statusLabel } ? { ...item, requestId, statusLabel: payload?.statusLabel ?? item.statusLabel }
: item)) : item))
setSuccess(requestId ? `Request #${requestId} has been accepted by Seerr.` : 'Your request has been accepted by Seerr.') setSuccess('Your request has been received.')
} catch (caught) { } catch (caught) {
setError(caught instanceof Error ? caught.message : 'The request could not be submitted.') setError(caught instanceof Error ? caught.message : 'The request could not be submitted.')
} finally { } finally {
@@ -336,6 +343,22 @@ export default function NewRequestClient() {
return ( return (
<main className="card request-portal-page"> <main className="card request-portal-page">
<dialog ref={progressDialog} className="create-request-dialog" aria-labelledby="create-progress-title" onCancel={() => setProgressOpen(false)} onClose={() => setProgressOpen(false)}>
<div className="create-progress-header"><span>Request progress</span><button type="button" className="ghost-button" onClick={() => setProgressOpen(false)} aria-label="Close request progress">Close</button></div>
<div className="create-progress-body" aria-live="polite" aria-atomic="true">
{submitting && <span className="create-progress-spinner" aria-hidden="true" />}
<h2 id="create-progress-title">{submitting ? 'Sending your request' : success ? 'Request received' : 'Your request needs attention'}</h2>
<p className="create-progress-title">{selected?.title}</p>
<p>{submitting ? (operation?.events.some(event => event.service === 'Sonarr' || event.service === 'Radarr') ? 'Setting up your title for collection. Please wait.' : 'Checking your selection and sending it to the request service. Please wait.') : success ? 'Your request is now in the pipeline. Follow it to see approval, download progress and when it is ready to watch.' : error || 'We could not confirm the result. Check My requests before trying again.'}</p>
{submitting && <div className="create-progress-track" role="progressbar" aria-label="Submitting request"><span /></div>}
{success && <div className="create-progress-stage"><span>Current stage</span><strong>{selected?.statusLabel || 'Request received'}</strong></div>}
</div>
<div className="create-progress-actions">
{!submitting && <button type="button" className="create-progress-follow" onClick={() => router.push(selected?.requestId ? `/requests/${selected.requestId}` : '/')} >{success ? 'Follow your request' : 'Check My requests'} <span aria-hidden="true">&rarr;</span></button>}
{!submitting && <button type="button" className="ghost-button" onClick={() => setProgressOpen(false)}>{success ? 'Back to browsing' : 'Back to request'}</button>}
{submitting && <small>You can close this window. Submission will continue while you stay on this page.</small>}
</div>
</dialog>
<PageHeading title="New request" description="Find a movie or TV show and choose what you want to watch." /> <PageHeading title="New request" description="Find a movie or TV show and choose what you want to watch." />
<ol className="request-master-stepper" aria-label="New request progress"> <ol className="request-master-stepper" aria-label="New request progress">
@@ -513,20 +536,10 @@ export default function NewRequestClient() {
</div> </div>
) : null} ) : null}
{operation && ( {operation && <button type="button" className="ghost-button" onClick={() => setProgressOpen(true)}>View request progress</button>}
<div className={`request-submit-progress is-${operation.status}`} aria-live="polite">
<header><div><span>Remote activity</span><strong>{operation.status === 'running' ? 'Sending your request' : operation.status === 'complete' ? 'Request hand-off complete' : 'Request hand-off needs attention'}</strong></div>{formatDuration(operation.duration_ms) && <small>{formatDuration(operation.duration_ms)}</small>}</header>
<div>
{operation.events.map((event) => (
<p key={event.id} className={`is-${event.state}`}><i aria-hidden="true" /><span><strong>{event.service}</strong>{event.message}</span><small>{formatDuration(event.duration_ms)}{event.status_code ? ` · HTTP ${event.status_code}` : ''}</small></p>
))}
{operation.events.length === 0 && <p className="is-active"><i aria-hidden="true" /><span><strong>Magent</strong>Preparing the request</span></p>}
</div>
</div>
)}
{success && selected.requestId && ( {success && selected.requestId && (
<div className="request-complete-actions"><button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>Track request #{selected.requestId}</button><button type="button" className="ghost-button" onClick={() => resetAfterType(selected.type)}>Request something else</button></div> <div className="request-complete-actions"><button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>Follow your request</button><button type="button" className="ghost-button" onClick={() => resetAfterType(selected.type)}>Request something else</button></div>
)} )}
</section> </section>
)} )}
@@ -0,0 +1,18 @@
.create-request-dialog { width:min(560px,calc(100vw - 32px)); max-height:90dvh; overflow:auto; padding:28px; border:1px solid #514d62; border-radius:20px; background:#1b1b22; color:#eeeaf5; box-shadow:0 30px 100px #0009; }
.create-request-dialog::backdrop { background:#080910c9; backdrop-filter:blur(6px); }
.create-progress-header { display:flex; align-items:center; justify-content:space-between; gap:16px; color:#79e0eb; font-size:13px; }
.create-progress-body { padding:24px 0; }
.create-progress-body h2 { font-size:clamp(25px,4vw,34px); margin:12px 0; }
.create-progress-body p { color:#c3bfce; line-height:1.7; overflow-wrap:anywhere; }
.create-progress-body .create-progress-title { font-size:20px; color:#fff; font-weight:600; }
.create-progress-spinner { display:block; width:42px; height:42px; border:4px solid #ffffff20; border-top-color:#70e0e4; border-radius:50%; animation:create-spin .8s linear infinite; }
.create-progress-track { height:6px; background:#ffffff15; overflow:hidden; border-radius:6px; margin-top:24px; }
.create-progress-track span { display:block; width:35%; height:100%; background:#8edee5; animation:create-track 1.5s ease-in-out infinite alternate; }
.create-progress-stage { display:grid; gap:8px; border:1px solid #4c536a; border-radius:12px; padding:18px; background:#242938; }
.create-progress-stage span { color:#b9b6c6; font-size:12px; }
.create-progress-actions { display:grid; gap:12px; }
.create-progress-actions .create-progress-follow { padding:18px; background:#c5b8ff; color:#191629; font-size:18px; font-weight:700; border-radius:12px; }
.create-progress-actions small { color:#b9b6c6; line-height:1.6; }
@keyframes create-spin { to { transform:rotate(360deg); } }
@keyframes create-track { to { transform:translateX(185%); } }
@media(prefers-reduced-motion:reduce) { .create-progress-spinner,.create-progress-track span { animation:none; } }
+28 -2
View File
@@ -4982,8 +4982,34 @@ textarea:focus {
/* Fade the entire artwork layer, including its tint, into the page surface. */ /* Fade the entire artwork layer, including its tint, into the page surface. */
.request-cinematic-art { .request-cinematic-art {
-webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 38%, transparent 100%), linear-gradient(to right, transparent 0%, #000 9%, #000 91%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 12%, #000 38%, transparent 100%), linear-gradient(to right, transparent 0%, #000 9%, #000 91%, transparent 100%);
-webkit-mask-composite: source-in; -webkit-mask-composite: source-in;
mask-image: linear-gradient(to bottom, #000 0%, #000 38%, transparent 100%), linear-gradient(to right, transparent 0%, #000 9%, #000 91%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, #000 12%, #000 38%, transparent 100%), linear-gradient(to right, transparent 0%, #000 9%, #000 91%, transparent 100%);
mask-composite: intersect; mask-composite: intersect;
} }
/* Diagnostics: scan each service from identity to result to action. */
.diagnostics-page .diagnostics-grid { grid-template-columns: minmax(0, 1fr); }
.diagnostics-page .diagnostic-card { display: grid; grid-template-columns: minmax(180px, 1.15fr) minmax(280px, 1.65fr) minmax(160px, 1fr) auto; align-items: center; gap: 20px; }
.diagnostics-page .diagnostic-card-top { display: contents; }
.diagnostics-page .diagnostic-card-copy { grid-column: 1; grid-row: 1; }
.diagnostics-page .diagnostic-card .system-test { grid-column: 4; grid-row: 1; white-space: nowrap; }
.diagnostics-page .diagnostic-meta-grid { grid-column: 2; grid-row: 1; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; }
.diagnostics-page .diagnostic-message { grid-column: 3; grid-row: 1; margin: 0; }
.diagnostics-page .diagnostic-detail-panel { grid-column: 1 / -1; border-top: 1px solid var(--ops-line-soft); padding-top: 14px; }
.diagnostic-detail-panel > summary { cursor: pointer; font-weight: 600; padding: 6px 0; }
.diagnostics-page .diagnostic-detail-grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
.diagnostics-notification-controls { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 16px; margin-bottom: 20px; padding: 16px; border: 1px solid var(--ops-line-soft); border-radius: 10px; }
.diagnostics-notification-controls .diagnostics-email-recipient { flex: 1 1 280px; }
.diagnostics-notification-controls p { flex: 1 1 220px; margin: 0; color: var(--ops-muted); font-size: .85rem; }
@media (max-width: 1050px) {
.diagnostics-page .diagnostic-card { grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto; gap: 14px; }
.diagnostics-page .diagnostic-card .system-test { grid-column: 3; }
.diagnostics-page .diagnostic-message { grid-column: 1 / -1; grid-row: 2; }
}
@media (max-width: 640px) {
.diagnostics-page .diagnostic-card { grid-template-columns: minmax(0, 1fr) auto; }
.diagnostics-page .diagnostic-card .system-test { grid-column: 2; }
.diagnostics-page .diagnostic-meta-grid { grid-column: 1 / -1; grid-row: 2; }
.diagnostics-page .diagnostic-message { grid-row: 3; }
}
+4
View File
@@ -615,6 +615,10 @@ export default function RequestTimelinePage() {
: canChoose : canChoose
? { title: available ? 'Downloads found' : 'Other versions are available', message: available ? 'Choose the version you want to download.' : 'These versions are outside your usual download settings.', next: available ? 'Your download starts after you choose a version.' : 'You can review them and confirm a download outside your profile.', action: 'Choose a version' } ? { title: available ? 'Downloads found' : 'Other versions are available', message: available ? 'Choose the version you want to download.' : 'These versions are outside your usual download settings.', next: available ? 'Your download starts after you choose a version.' : 'You can review them and confirm a download outside your profile.', action: 'Choose a version' }
: { title: items.length ? 'No suitable downloads' : 'Nothing available yet', message: items.length ? 'The versions found cannot be downloaded with your current settings.' : 'No downloads were found in this search.', next: result?.nextOffset != null ? 'You can check the next group of missing episodes.' : 'You can try again later.', action: result?.nextOffset != null ? 'View search results' : undefined } : { title: items.length ? 'No suitable downloads' : 'Nothing available yet', message: items.length ? 'The versions found cannot be downloaded with your current settings.' : 'No downloads were found in this search.', next: result?.nextOffset != null ? 'You can check the next group of missing episodes.' : 'You can try again later.', action: result?.nextOffset != null ? 'View search results' : undefined }
: response.ok && result?.status === 'pending'
? { title: 'Waiting for download confirmation', message: 'The search was sent. The download queue may still be updating.', next: 'Close this window and recheck the request shortly. You do not need to start another search yet.' }
: response.ok && result?.status === 'downloading'
? { title: 'Download queued', message: 'The download service has confirmed a download for this title.', next: 'Close this window to follow its progress.' }
: response.ok && /\/actions\/grab$/.test(url) : response.ok && /\/actions\/grab$/.test(url)
? { title: 'Waiting to start', message: 'Your download has been sent.', next: 'Close this box to follow its progress. It may take a moment to start.' } ? { title: 'Waiting to start', message: 'Your download has been sent.', next: 'Close this box to follow its progress. It may take a moment to start.' }
: undefined : undefined
+26 -22
View File
@@ -330,20 +330,12 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
<div className="diagnostics-control-copy"> <div className="diagnostics-control-copy">
<h2>{embedded ? 'Connectivity diagnostics' : 'Control center'}</h2> <h2>{embedded ? 'Connectivity diagnostics' : 'Control center'}</h2>
<p className="lede"> <p className="lede">
Use live checks for Magent and service connectivity. Use run all when you want outbound notification Check Magent and your connected services. Automatic refresh runs health checks only.
channels to send a real ping through the configured provider. Test messages are managed in Notifications below.
</p> </p>
</div> </div>
<div className="diagnostics-control-actions"> <div className="diagnostics-control-actions">
<label className="diagnostics-email-recipient">
<span>Test email recipient</span>
<input
type="email"
placeholder="Leave blank to use configured sender"
value={emailRecipient}
onChange={(event) => setEmailRecipient(event.target.value)}
/>
</label>
<button <button
type="button" type="button"
className={autoRefresh ? 'is-active' : ''} className={autoRefresh ? 'is-active' : ''}
@@ -360,15 +352,7 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
> >
Run live checks Run live checks
</button> </button>
<button
type="button"
onClick={() => {
void runDiagnostics(undefined, 'all')
}}
disabled={runningKeys.length > 0 || checks.length === 0}
>
Run all tests
</button>
<span className={`small-pill ${autoRefresh ? 'is-positive' : ''}`}> <span className={`small-pill ${autoRefresh ? 'is-positive' : ''}`}>
{autoRefresh ? 'Auto refresh on' : 'Auto refresh off'} {autoRefresh ? 'Auto refresh on' : 'Auto refresh off'}
</span> </span>
@@ -420,6 +404,25 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
<span className="small-pill">{categoryChecks.length} checks</span> <span className="small-pill">{categoryChecks.length} checks</span>
</div> </div>
{category === 'Notifications' && (
<div className="diagnostics-notification-controls">
<label className="diagnostics-email-recipient">
<span>Test email recipient</span>
<input
type="email"
placeholder="Leave blank to use configured sender"
value={emailRecipient}
onChange={(event) => setEmailRecipient(event.target.value)}
/>
</label>
<p>Choose where the test email goes. Other channels use their configured destinations.</p>
<button type="button" disabled={runningKeys.length > 0 || categoryChecks.length === 0}
onClick={() => void runDiagnostics(categoryChecks.map(check => check.key), 'all')}>
Test all notification channels
</button>
</div>
)}
<div className="diagnostics-grid"> <div className="diagnostics-grid">
{categoryChecks.map((check) => { {categoryChecks.map((check) => {
const isRunning = runningKeys.includes(check.key) const isRunning = runningKeys.includes(check.key)
@@ -476,7 +479,8 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
return null return null
} }
return ( return (
<div className="diagnostic-detail-panel"> <details className="diagnostic-detail-panel">
<summary>Database storage, tables and timings</summary>
{renderDatabaseMetricGroup('Storage', [ {renderDatabaseMetricGroup('Storage', [
['Database file', formatBytes(detail.database_size_bytes)], ['Database file', formatBytes(detail.database_size_bytes)],
['WAL file', formatBytes(detail.wal_size_bytes)], ['WAL file', formatBytes(detail.wal_size_bytes)],
@@ -501,7 +505,7 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
`${value.toFixed(1)} ms`, `${value.toFixed(1)} ms`,
]), ]),
)} )}
</div> </details>
) )
})() })()
: null} : null}
+12 -2
View File
@@ -14,7 +14,7 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114';
if (path.startsWith('/api/operations/')) return route.fulfill({ json: { status: 'complete', events: [] } }); if (path.startsWith('/api/operations/')) return route.fulfill({ json: { status: 'complete', events: [] } });
if (path === '/api/requests/search') return route.fulfill({ json: { results: [media] } }); if (path === '/api/requests/search') return route.fulfill({ json: { results: [media] } });
if (path === '/api/requests/request-options') return route.fulfill({ json: { media, destination: { collector: 'Radarr', serverName: 'Movies', defaultProfileId: 6, profiles: [] } } }); if (path === '/api/requests/request-options') return route.fulfill({ json: { media, destination: { collector: 'Radarr', serverName: 'Movies', defaultProfileId: 6, profiles: [] } } });
if (path === '/api/requests/create') { writes.push(route.request().postDataJSON()); return route.fulfill({ json: { requestId: 12 } }); } if (path === '/api/requests/create') { writes.push(route.request().postDataJSON()); await new Promise(resolve => setTimeout(resolve, 1200)); return route.fulfill({ json: { requestId: 12 } }); }
return route.fulfill({ json: {} }); return route.fulfill({ json: {} });
}); });
const page = await context.newPage(); const page = await context.newPage();
@@ -39,7 +39,17 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114';
await consent.check(); await consent.check();
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
await page.getByRole('button', { name: 'Request movie', exact: true }).click(); await page.getByRole('button', { name: 'Request movie', exact: true }).click();
await page.getByText('Request #12 has been accepted by Seerr.', { exact: true }).waitFor(); const dialog = page.getByRole('dialog');
await dialog.getByRole('heading', { name: 'Sending your request' }).waitFor();
assert(await dialog.getByRole('progressbar').isVisible());
await dialog.getByRole('heading', { name: 'Request received', exact: true }).waitFor();
assert(await dialog.getByRole('button', { name: 'Follow your request' }).isVisible());
assert(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth));
await page.keyboard.press('Escape');
assert(!await dialog.isVisible());
await page.getByRole('button', { name: 'View request progress' }).click();
await dialog.getByRole('button', { name: 'Follow your request' }).click();
await page.waitForURL('**/requests/12');
} }
assert.equal(writes.length, 2); assert.equal(writes.length, 2);
assert(writes.every(w => w.acceptOriginalLanguage === true && w.tmdbId === 1417 && !w.profileId)); assert(writes.every(w => w.acceptOriginalLanguage === true && w.tmdbId === 1417 && !w.profileId));