Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd51332f3c | ||
|
|
6a84e68a03 | ||
|
|
c194db167a | ||
|
|
e232335ca9 | ||
|
|
8df02fdfd7 |
@@ -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)
|
||||
|
||||
@@ -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])],
|
||||
|
||||
@@ -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", []):
|
||||
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}"
|
||||
recent.append(row)
|
||||
return {**data, "recent": recent}
|
||||
rows.append(row)
|
||||
result[field] = rows
|
||||
return result
|
||||
|
||||
|
||||
def verify_artwork_token(user, runtime, media_id, token):
|
||||
|
||||
@@ -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>'
|
||||
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>'
|
||||
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"> </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]
|
||||
if top:
|
||||
content += '<h2 style="font-size:18px;color:#e5e1e4;margin:24px 0 8px">Your most watched</h2>'
|
||||
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>'
|
||||
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>'
|
||||
@@ -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:
|
||||
|
||||
@@ -100,10 +100,11 @@ async def movie_search_outcome(client, movie_id, command, attempts=12, delay=2):
|
||||
movie = await client.get_movie(movie_id)
|
||||
if (movie or {}).get('hasFile'):
|
||||
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:
|
||||
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):
|
||||
@@ -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}
|
||||
if statuses & {'failed', 'aborted', 'cancelled'}:
|
||||
return {'status': 'attention', 'message': 'A Sonarr search failed. Check the service or try Search and choose a download.'}
|
||||
if statuses == {'completed'}:
|
||||
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.'}
|
||||
# Even completed commands can precede Sonarr's download queue refresh.
|
||||
if attempt + 1 < attempts:
|
||||
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.'}
|
||||
|
||||
@@ -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"], [])
|
||||
@@ -85,8 +85,8 @@ class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
|
||||
await apply_original_to_movie(client, 613)
|
||||
|
||||
async def test_search_reports_real_outcomes(self):
|
||||
for command_status, queue, expected in [('completed', [], 'attention'), ('failed', [], 'attention'),
|
||||
('started', [], 'searching'), ('completed', [{'movieId': 6940}], 'downloading')]:
|
||||
for command_status, queue, expected in [('completed', [], 'pending'), ('failed', [], 'attention'),
|
||||
('started', [], 'pending'), ('completed', [{'movieId': 6940}], 'downloading')]:
|
||||
client = SimpleNamespace(get=AsyncMock(return_value={'status': command_status}),
|
||||
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)
|
||||
@@ -114,6 +114,23 @@ class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_tv_search_distinguishes_no_download_and_queue(self):
|
||||
from backend.app.services.request_language import series_search_outcome
|
||||
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}]}
|
||||
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')
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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>
|
||||
</section>
|
||||
</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">
|
||||
<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 ?? []} />
|
||||
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
|
||||
</div>
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
import { lockBodyScroll } from '../lib/scrollLock'
|
||||
import './request-progress.css'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
@@ -96,12 +98,6 @@ const apiError = async (response: Response, fallback: string) => {
|
||||
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() {
|
||||
const router = useRouter()
|
||||
const searchSectionRef = useRef<HTMLElement | null>(null)
|
||||
@@ -118,6 +114,16 @@ export default function NewRequestClient() {
|
||||
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
|
||||
const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState<boolean | null>(null)
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
@@ -269,12 +275,13 @@ export default function NewRequestClient() {
|
||||
}
|
||||
|
||||
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 (selected.type === 'tv' && selectedSeasons.length === 0) {
|
||||
setError('Select at least one season.')
|
||||
return
|
||||
}
|
||||
setProgressOpen(true)
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
@@ -309,7 +316,7 @@ export default function NewRequestClient() {
|
||||
setResults((current) => current.map((item) => item.tmdbId === selected.tmdbId && item.type === selected.type
|
||||
? { ...item, requestId, statusLabel: payload?.statusLabel ?? item.statusLabel }
|
||||
: 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) {
|
||||
setError(caught instanceof Error ? caught.message : 'The request could not be submitted.')
|
||||
} finally {
|
||||
@@ -336,6 +343,22 @@ export default function NewRequestClient() {
|
||||
|
||||
return (
|
||||
<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">→</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." />
|
||||
|
||||
<ol className="request-master-stepper" aria-label="New request progress">
|
||||
@@ -513,20 +536,10 @@ export default function NewRequestClient() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{operation && (
|
||||
<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>
|
||||
)}
|
||||
{operation && <button type="button" className="ghost-button" onClick={() => setProgressOpen(true)}>View request progress</button>}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -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; } }
|
||||
@@ -4982,8 +4982,34 @@ textarea:focus {
|
||||
|
||||
/* Fade the entire artwork layer, including its tint, into the page surface. */
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
@@ -615,6 +615,10 @@ export default function RequestTimelinePage() {
|
||||
: 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: 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)
|
||||
? { 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
|
||||
|
||||
@@ -330,20 +330,12 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
|
||||
<div className="diagnostics-control-copy">
|
||||
<h2>{embedded ? 'Connectivity diagnostics' : 'Control center'}</h2>
|
||||
<p className="lede">
|
||||
Use live checks for Magent and service connectivity. Use run all when you want outbound notification
|
||||
channels to send a real ping through the configured provider.
|
||||
Check Magent and your connected services. Automatic refresh runs health checks only.
|
||||
Test messages are managed in Notifications below.
|
||||
</p>
|
||||
</div>
|
||||
<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
|
||||
type="button"
|
||||
className={autoRefresh ? 'is-active' : ''}
|
||||
@@ -360,15 +352,7 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
|
||||
>
|
||||
Run live checks
|
||||
</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' : ''}`}>
|
||||
{autoRefresh ? 'Auto refresh on' : 'Auto refresh off'}
|
||||
</span>
|
||||
@@ -420,6 +404,25 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
|
||||
<span className="small-pill">{categoryChecks.length} checks</span>
|
||||
</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">
|
||||
{categoryChecks.map((check) => {
|
||||
const isRunning = runningKeys.includes(check.key)
|
||||
@@ -476,7 +479,8 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="diagnostic-detail-panel">
|
||||
<details className="diagnostic-detail-panel">
|
||||
<summary>Database storage, tables and timings</summary>
|
||||
{renderDatabaseMetricGroup('Storage', [
|
||||
['Database file', formatBytes(detail.database_size_bytes)],
|
||||
['WAL file', formatBytes(detail.wal_size_bytes)],
|
||||
@@ -501,7 +505,7 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
|
||||
`${value.toFixed(1)} ms`,
|
||||
]),
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
})()
|
||||
: null}
|
||||
|
||||
@@ -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 === '/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/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: {} });
|
||||
});
|
||||
const page = await context.newPage();
|
||||
@@ -39,7 +39,17 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114';
|
||||
await consent.check();
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
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(writes.every(w => w.acceptOriginalLanguage === true && w.tmdbId === 1417 && !w.profileId));
|
||||
|
||||
Reference in New Issue
Block a user